"""Visual channel editor view for PyQt6 GUI."""

import logging
from typing import Optional, Dict, Tuple, List
from pathlib import Path
import numpy as np
from collections import OrderedDict
from functools import lru_cache

from PyQt6.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QGroupBox,
    QPushButton, QLabel, QSpinBox, QComboBox, QCheckBox, QScrollArea,
    QMessageBox, QFileDialog, QDialog, QLineEdit, QGridLayout,
    QSlider, QSizePolicy
)
from PyQt6.QtCore import Qt, pyqtSignal, QSize
from PyQt6.QtGui import QPixmap

from eubi_bridge.ngff.multiscales import Pyramid
from eubi_bridge.utils.path_utils import is_ome_zarr
from eubi_bridge.conversion.updater import run_updates
from eubi_bridge.gui.state import ChannelEditorState, AppState
from eubi_bridge.gui.workers import run_async
from eubi_bridge.gui.widgets import (
    LabeledSlider, ColorPicker, RangeSlider, EfficientFileList,
    CachedImageDisplay, LoadingIndicator
)
from eubi_bridge.gui.rendering import numpy_to_qpixmap, hex_to_rgb

logger = logging.getLogger(__name__)


class ChannelControlDialog(QDialog):
    """Dialog for controlling individual channel properties."""
    
    # Signals to notify parent of changes
    changed = pyqtSignal()  # Emitted whenever any channel property changes
    
    def __init__(self, state: 'ChannelEditorState', num_channels: int, parent=None):
        super().__init__(parent)
        self.state = state
        self.num_channels = num_channels
        self.setWindowTitle("Channel Controls")
        self.setGeometry(100, 100, 500, 700)
        
        self._setup_ui()
    
    def _setup_ui(self):
        """Build dialog UI."""
        main_layout = QVBoxLayout()
        
        # Scroll area for channels
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        
        channels_widget = QWidget()
        channels_layout = QVBoxLayout(channels_widget)
        channels_layout.setSpacing(10)
        
        # Add channel controller for each channel
        self.channel_widgets = []
        default_colors = ["#FF0000", "#00FF00", "#0000FF", "#FF00FF", "#00FFFF", "#FFFF00", "#FFFFFF", "#808080"]
        
        for c in range(self.num_channels):
            channel_group = self._create_channel_controller(c, default_colors)
            channels_layout.addWidget(channel_group)
            self.channel_widgets.append(channel_group)
        
        channels_layout.addStretch()
        scroll.setWidget(channels_widget)
        main_layout.addWidget(scroll)
        
        # Locking control
        lock_layout = QHBoxLayout()
        self.lock_checkbox = QCheckBox("Lock All Changes")
        self.lock_checkbox.setChecked(self.state.changes_locked)
        self.lock_checkbox.toggled.connect(self._on_lock_toggled)
        lock_layout.addWidget(self.lock_checkbox)
        lock_layout.addStretch()
        main_layout.addLayout(lock_layout)
        
        # Close button
        close_btn = QPushButton("Close")
        close_btn.clicked.connect(self.accept)
        main_layout.addWidget(close_btn)
        
        self.setLayout(main_layout)
    
    def _create_channel_controller(self, channel_idx: int, default_colors: list) -> QGroupBox:
        """Create a channel control widget."""
        group = QGroupBox(f"Channel {channel_idx}")
        layout = QGridLayout()
        layout.setSpacing(5)
        
        # Channel label
        layout.addWidget(QLabel("Label:"), 0, 0)
        label_edit = QLineEdit()
        label_edit.setText(self.state.editing_labels.get(channel_idx, f"Channel {channel_idx}"))
        label_edit.textChanged.connect(
            lambda text, ch=channel_idx: self._on_label_changed(ch, text)
        )
        layout.addWidget(label_edit, 0, 1)
        
        # Color picker
        layout.addWidget(QLabel("Color:"), 1, 0)
        default_color = self.state.editing_colors.get(channel_idx, default_colors[channel_idx % len(default_colors)])
        color_picker = ColorPicker(f"", default_color)
        color_picker.colorChanged.connect(
            lambda color, ch=channel_idx: self._on_color_changed(ch, color)
        )
        layout.addWidget(color_picker, 1, 1)
        
        # Intensity range slider (min-max pixel value clipping)
        layout.addWidget(QLabel("Intensity Range:"), 2, 0)
        range_slider = RangeSlider(
            label="",
            min_val=0,
            max_val=65535,  # Supports up to uint16
            initial_min=self.state.editing_intensity_limits.get(channel_idx, (0, 65535))[0],
            initial_max=self.state.editing_intensity_limits.get(channel_idx, (0, 65535))[1]
        )
        range_slider.rangeChanged.connect(
            lambda min_v, max_v, ch=channel_idx: self._on_intensity_range_changed(ch, min_v, max_v)
        )
        layout.addWidget(range_slider, 2, 1)
        
        group.setLayout(layout)
        return group
    
    def _on_label_changed(self, channel: int, label: str):
        """Handle label change."""
        self.state.editing_labels[channel] = label
        logger.debug(f"Channel {channel} label changed to: {label}")
        self.changed.emit()
    
    def _on_color_changed(self, channel: int, color: str):
        """Handle color change."""
        self.state.editing_colors[channel] = color
        logger.debug(f"Channel {channel} color changed to: {color}")
        self.changed.emit()
    
    def _on_intensity_range_changed(self, channel: int, min_val: int, max_val: int):
        """Handle intensity range change (pixel value clipping)."""
        self.state.editing_intensity_limits[channel] = (min_val, max_val)
        logger.debug(f"Channel {channel} intensity range: {min_val} to {max_val}")
        self.changed.emit()
    
    def _on_lock_toggled(self, checked: bool):
        """Handle lock toggle."""
        self.state.changes_locked = checked
        logger.debug(f"Changes locked: {checked}")


class PlaneCache:
    """LRU cache for extracted planes with memory management."""
    
    def __init__(self, max_items: int = 30):
        self.max_items = max_items
        self.cache: OrderedDict[str, np.ndarray] = OrderedDict()
    
    def _make_key(self, t: int, z: int, c: int, orientation: str, resolution_key: str = '0',
                  rotation_roll: float = 0.0, rotation_tilt: float = 0.0, pan_offset_x: int = 0, 
                  pan_offset_y: int = 0, depth_offset: int = 0, fov_size: int = 256) -> str:
        """Create cache key including ALL transformation and spatial parameters.
        
        Must include:
        - Spatial indices: t, z, c, resolution_key (layer/zoom level)
        - Orientation: which plane
        - Rotation: camera angles (rounded to avoid float precision issues)
        - Pan: view offset in camera frame
        - Depth: movement along camera normal
        - FOV size: display dimensions (CRITICAL: affects output plane shape!)
        
        CRITICAL: resolution_key MUST be in the key because planes at different
        zoom/resolution levels have DIFFERENT DIMENSIONS and MUST NOT be confused!
        
        CRITICAL: fov_size MUST be in the key because planes with different FOV
        have DIFFERENT DIMENSIONS and MUST NOT be confused!
        
        Precision: .4f for rotations means:
        - 0.0001 rad difference → different cache keys
        - 0.00001 rad = 0.0006° = sub-voxel level differences
        - This prevents stale cache hits while avoiding excessive fragmentation
        """
        # Round angles to 4 decimal places (.4f)
        # This prevents cache key collisions while maintaining reasonable granularity
        return f"{orientation}_{t}_{z}_{c}_{resolution_key}_fov{fov_size}_{rotation_roll:.4f}_{rotation_tilt:.4f}_{pan_offset_x}_{pan_offset_y}_{depth_offset}"
    
    def get(self, t: int, z: int, c: int, orientation: str, resolution_key: str = '0',
            rotation_roll: float = 0.0, rotation_tilt: float = 0.0, pan_offset_x: int = 0, 
            pan_offset_y: int = 0, depth_offset: int = 0, fov_size: int = 256) -> Optional[np.ndarray]:
        key = self._make_key(t, z, c, orientation, resolution_key, rotation_roll, rotation_tilt, pan_offset_x, pan_offset_y, depth_offset, fov_size)
        if key in self.cache:
            # Move to end (most recently used)
            self.cache.move_to_end(key)
            return self.cache[key]
        return None
    
    def put(self, t: int, z: int, c: int, orientation: str, data: np.ndarray, resolution_key: str = '0',
            rotation_roll: float = 0.0, rotation_tilt: float = 0.0, pan_offset_x: int = 0, 
            pan_offset_y: int = 0, depth_offset: int = 0, fov_size: int = 256):
        key = self._make_key(t, z, c, orientation, resolution_key, rotation_roll, rotation_tilt, pan_offset_x, pan_offset_y, depth_offset, fov_size)
        
        if key in self.cache:
            self.cache.move_to_end(key)
        else:
            self.cache[key] = data
            # Remove oldest if cache is full
            if len(self.cache) > self.max_items:
                self.cache.popitem(last=False)
    
    def clear(self):
        self.cache.clear()


class VisualChannelEditor(QWidget):
    """Interactive multi-channel image viewer and editor."""
    
    def __init__(self, bridge, parent=None):
        super().__init__(parent)
        
        self.bridge = bridge
        self.state = ChannelEditorState()
        self.app_state = AppState()
        
        # Pyramid and caching
        self.pyramid: Optional[Pyramid] = None
        self.plane_cache = PlaneCache(max_items=30)
        
        # Image metadata
        self.image_shape: Optional[Tuple] = None
        self.num_channels: int = 0
        self.dtype = None
        
        self._setup_ui()
    
    def _setup_ui(self):
        """Build UI layout."""
        main_layout = QHBoxLayout(self)
        
        # Left panel: File browser and controls
        left_layout = QVBoxLayout()
        file_browser_group = self._create_file_browser_group()
        navigation_content = self._create_navigation_content()
        channel_content = self._create_channel_controls_content()
        navigation_group = self._create_expander_group(
            "Navigate Image & Channel Settings",
            [navigation_content, channel_content],
            default_checked=False
        )
        navigation_group.toggled.connect(lambda checked: self._on_expander_toggled("navigation", checked))
        action_buttons_group = self._create_action_buttons_group()

        self.file_browser_group = file_browser_group
        self.navigation_group = navigation_group
        self._expander_syncing = False

        left_layout.addWidget(file_browser_group)
        left_layout.setStretchFactor(file_browser_group, 3)
        left_layout.addWidget(navigation_group)
        left_layout.setStretchFactor(navigation_group, 2)
        left_layout.addStretch()
        left_layout.addWidget(action_buttons_group)

        self._set_expander_states(file_browser=True, navigation=False)

        left_panel = QWidget()
        left_panel.setLayout(left_layout)
        left_panel.setMaximumWidth(300)
        left_panel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
        
        main_layout.addWidget(left_panel)
        
        # Right panel: Image display and metadata
        right_layout = QVBoxLayout()
        right_layout.addWidget(self._create_image_display_group())
        right_layout.addWidget(self._create_metadata_group())
        
        right_panel = QWidget()
        right_panel.setLayout(right_layout)
        
        main_layout.addWidget(right_panel, 1)
        
        self.setLayout(main_layout)
    
    def _create_file_browser_group(self) -> QGroupBox:
        """Create the expandable file browser group."""
        group = QGroupBox("File Browser")
        group.setCheckable(True)
        group.setChecked(True)

        group_layout = QVBoxLayout(group)
        group_layout.setContentsMargins(6, 6, 6, 6)
        group_layout.setSpacing(6)

        content_widget = QWidget()
        content_layout = QVBoxLayout(content_widget)
        content_layout.setContentsMargins(0, 0, 0, 0)
        content_layout.setSpacing(6)

        self.loading_indicator = LoadingIndicator()
        self.loading_indicator.hide()
        content_layout.addWidget(self.loading_indicator)

        self.file_list = EfficientFileList()
        self.file_list.fileSelected.connect(self._on_file_selected)
        self.file_list.search_input.textChanged.connect(self._on_file_browser_activity)
        content_layout.addWidget(self.file_list)

        browse_btn = QPushButton("Browse...")
        browse_btn.clicked.connect(self._on_browse_clicked)
        content_layout.addWidget(browse_btn)

        pagination_layout = QHBoxLayout()
        pagination_layout.addWidget(QLabel("Page:"))
        self.page_spinbox = QSpinBox()
        self.page_spinbox.setMinimum(1)
        self.page_spinbox.setMaximumWidth(50)
        self.page_spinbox.valueChanged.connect(self._on_file_browser_activity)
        pagination_layout.addWidget(self.page_spinbox)

        total_pages_label = QLabel("/ 1")
        total_pages_label.setMaximumWidth(40)
        pagination_layout.addWidget(total_pages_label)
        pagination_layout.addStretch()
        content_layout.addLayout(pagination_layout)

        self.file_list.set_pagination_spinbox(self.page_spinbox, total_pages_label)

        group_layout.addWidget(content_widget)
        content_widget.setVisible(True)
        group.toggled.connect(content_widget.setVisible)
        group.toggled.connect(lambda checked: self._on_expander_toggled("file_browser", checked))

        return group
    
    def _create_navigation_content(self) -> QWidget:
        """Create the navigation controls widget without a border."""
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setSpacing(6)
        layout.setContentsMargins(0, 0, 0, 0)

        self.t_slider = LabeledSlider("Time (T):", 0, 0, 0)
        self.t_slider.valueChanged.connect(lambda v: self._on_index_changed('t', v))
        layout.addWidget(self.t_slider)

        self.z_slider = LabeledSlider("Depth (Z):", 0, 0, 0)
        self.z_slider.valueChanged.connect(lambda v: self._on_index_changed('z', v))
        layout.addWidget(self.z_slider)

        fov_layout = QHBoxLayout()
        fov_layout.addWidget(QLabel("FOV Size:"))
        self.fov_spinbox = QSpinBox()
        self.fov_spinbox.setMinimum(64)
        self.fov_spinbox.setMaximum(1024)
        self.fov_spinbox.setValue(256)
        self.fov_spinbox.setSingleStep(64)
        self.fov_spinbox.valueChanged.connect(self._on_fov_changed)
        fov_layout.addWidget(self.fov_spinbox)
        fov_layout.addWidget(QLabel("px"))
        fov_layout.addStretch()
        layout.addLayout(fov_layout)

        orientation_layout = QHBoxLayout()
        orientation_layout.addWidget(QLabel("Orientation:"))
        self.orientation_combo = QComboBox()
        self.orientation_combo.addItems(['XY', 'XZ', 'YZ'])
        self.orientation_combo.currentTextChanged.connect(self._on_orientation_changed)
        orientation_layout.addWidget(self.orientation_combo)
        orientation_layout.addStretch()
        layout.addLayout(orientation_layout)

        zoom_layout = QHBoxLayout()
        zoom_layout.addWidget(QLabel("Layer:"))
        self.zoom_spinbox = QSpinBox()
        self.zoom_spinbox.setMinimum(0)
        self.zoom_spinbox.setMaximum(100)  # Will be adjusted based on pyramid layers
        self.zoom_spinbox.setValue(0)
        self.zoom_spinbox.setSingleStep(1)
        self.zoom_spinbox.valueChanged.connect(self._on_zoom_changed)
        zoom_layout.addWidget(self.zoom_spinbox)
        zoom_layout.addStretch()
        layout.addLayout(zoom_layout)

        return widget

    def _create_channel_controls_content(self) -> QWidget:
        """Create the channel control widgets without a border."""
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setSpacing(6)
        layout.setContentsMargins(0, 0, 0, 0)

        layout.addWidget(QLabel("Active Channels:"))
        self.channel_checkboxes_layout = QVBoxLayout()
        self.channel_checkboxes = {}
        layout.addLayout(self.channel_checkboxes_layout)

        mode_layout = QHBoxLayout()
        mode_layout.addWidget(QLabel("Range Mode:"))
        self.range_mode_combo = QComboBox()
        self.range_mode_combo.addItems(["Data Values", "Data Type Range"])
        self.range_mode_combo.currentTextChanged.connect(self._on_range_mode_changed)
        mode_layout.addWidget(self.range_mode_combo)
        mode_layout.addStretch()
        layout.addLayout(mode_layout)

        self.intensity_slider = RangeSlider("Intensity Range", 0, 255, 0, 255)
        self.intensity_slider.rangeChanged.connect(self._on_intensity_changed)
        layout.addWidget(self.intensity_slider)

        self.channel_controls_btn = QPushButton("Open Channel Controls...")
        self.channel_controls_btn.clicked.connect(self._on_open_channel_controls)
        layout.addWidget(self.channel_controls_btn)

        self.histogram_checkbox = QCheckBox("Show Histogram")
        self.histogram_checkbox.setChecked(True)
        self.histogram_checkbox.toggled.connect(self._on_histogram_toggled)
        layout.addWidget(self.histogram_checkbox)

        return widget

    def _create_expander_group(self, title: str, widgets: List[QWidget], default_checked: bool = True) -> QGroupBox:
        group = QGroupBox(title)
        group.setCheckable(True)
        group.setChecked(default_checked)

        layout = QVBoxLayout(group)
        layout.setContentsMargins(6, 6, 6, 6)
        layout.setSpacing(6)

        content_widget = QWidget()
        content_layout = QVBoxLayout(content_widget)
        content_layout.setContentsMargins(0, 0, 0, 0)
        content_layout.setSpacing(6)

        for widget in widgets:
            content_layout.addWidget(widget)

        layout.addWidget(content_widget)
        content_widget.setVisible(default_checked)
        group.toggled.connect(content_widget.setVisible)

        return group

    def _set_expander_states(self, file_browser: Optional[bool] = None, navigation: Optional[bool] = None):
        if not hasattr(self, '_expander_syncing'):
            self._expander_syncing = False

        if self._expander_syncing:
            return

        self._expander_syncing = True

        if file_browser is not None and self.file_browser_group.isChecked() != file_browser:
            self.file_browser_group.setChecked(file_browser)
        if navigation is not None and self.navigation_group.isChecked() != navigation:
            self.navigation_group.setChecked(navigation)

        self._expander_syncing = False

    def _on_expander_toggled(self, source: str, checked: bool):
        if checked and not self._expander_syncing:
            if source == "file_browser":
                self._set_expander_states(file_browser=True, navigation=False)
            elif source == "navigation":
                self._set_expander_states(file_browser=False, navigation=True)

    def _on_file_browser_activity(self, *_) -> None:
        self._set_expander_states(file_browser=True, navigation=False)

    def _on_navigation_activity(self, *_) -> None:
        self._set_expander_states(file_browser=False, navigation=True)
    
    def _create_image_display_group(self) -> QGroupBox:
        """Create image display area with rotation sliders on edges."""
        group = QGroupBox("Image Preview")
        main_layout = QVBoxLayout()
        
        # Create a horizontal layout for the image + left rotation slider
        image_with_left_slider = QHBoxLayout()
        
        # Left rotation slider (vertical orientation) - ROLL (spin/in-plane rotation)
        self.rotation_roll_slider = QSlider()
        self.rotation_roll_slider.setOrientation(Qt.Orientation.Vertical)
        self.rotation_roll_slider.setMinimum(0)
        self.rotation_roll_slider.setMaximum(180)
        self.rotation_roll_slider.setValue(0)
        self.rotation_roll_slider.setFixedWidth(30)
        self.rotation_roll_slider.setTickPosition(QSlider.TickPosition.TicksLeft)
        self.rotation_roll_slider.sliderMoved.connect(lambda v: self._on_rotation_changed('roll', v))
        self.rotation_roll_slider.valueChanged.connect(lambda v: self._on_rotation_changed('roll', v))
        image_with_left_slider.addWidget(self.rotation_roll_slider)
        
        # Image display
        self.image_display = CachedImageDisplay()
        self.image_display.zIndexChangeRequested.connect(self._on_z_wheel_scroll)
        self.image_display.panRequested.connect(self._on_image_panned)
        image_with_left_slider.addWidget(self.image_display)
        
        main_layout.addLayout(image_with_left_slider)
        
        # Bottom rotation slider (horizontal orientation) - TILT (depth/Z exploration)
        self.rotation_tilt_slider = QSlider()
        self.rotation_tilt_slider.setOrientation(Qt.Orientation.Horizontal)
        self.rotation_tilt_slider.setMinimum(0)
        self.rotation_tilt_slider.setMaximum(180)
        self.rotation_tilt_slider.setValue(0)
        self.rotation_tilt_slider.setFixedHeight(30)
        self.rotation_tilt_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
        self.rotation_tilt_slider.sliderMoved.connect(lambda v: self._on_rotation_changed('tilt', v))
        self.rotation_tilt_slider.valueChanged.connect(lambda v: self._on_rotation_changed('tilt', v))
        main_layout.addWidget(self.rotation_tilt_slider)
        
        group.setLayout(main_layout)
        return group
    
    def _create_metadata_group(self) -> QGroupBox:
        """Create metadata display."""
        group = QGroupBox("Image Metadata")
        layout = QVBoxLayout()
        
        self.metadata_label = QLabel("No image loaded")
        self.metadata_label.setWordWrap(True)
        layout.addWidget(self.metadata_label)
        
        group.setLayout(layout)
        return group
    
    def _create_action_buttons_group(self) -> QGroupBox:
        """Create action buttons."""
        group = QGroupBox("Actions")
        layout = QVBoxLayout()
        
        lock_btn = QPushButton("Lock Changes")
        lock_btn.clicked.connect(self._on_lock_changes)
        layout.addWidget(lock_btn)
        
        update_btn = QPushButton("Update Metadata")
        update_btn.clicked.connect(self._on_update_metadata)
        layout.addWidget(update_btn)
        
        group.setLayout(layout)
        return group
    
    def _on_browse_clicked(self):
        """Handle browse button click."""
        self._on_file_browser_activity()
        start_dir = self.state.browse_path
        path = QFileDialog.getExistingDirectory(
            self,
            "Select Folder",
            start_dir
        )
        
        if path:
            self.state.browse_path = path
            self._scan_directory(path)
    
    def _scan_directory(self, path: str):
        """Scan directory for OME-Zarr files in background."""
        self.loading_indicator.set_message("Scanning directory...")
        self.loading_indicator.start()
        
        logger.info(f"Scanning directory: {path}")
        
        def scan():
            try:
                zarr_files = []
                scan_count = 0
                path_obj = Path(path)
                
                if not path_obj.exists():
                    return [], f"Path does not exist: {path}"
                
                logger.debug(f"Starting directory scan...")
                for item in path_obj.iterdir():
                    scan_count += 1
                    if scan_count % 100 == 0:
                        logger.debug(f"Scanned {scan_count} items...")
                    
                    try:
                        if item.is_dir() and is_ome_zarr(str(item)):
                            zarr_files.append(str(item))
                    except (OSError, PermissionError) as e:
                        logger.debug(f"Skipping {item}: {e}")
                        continue
                    
                    if len(zarr_files) > 1000:  # Safety limit
                        logger.warning(f"Found >1000 OME-Zarr files, stopping scan")
                        break
                
                logger.info(f"Scan complete. Found {len(zarr_files)} OME-Zarr files")
                return sorted(zarr_files), None
            except Exception as e:
                logger.error(f"Error scanning directory: {e}", exc_info=True)
                return [], str(e)
        
        def on_result(result):
            files, error = result
            if error:
                logger.error(f"Directory scan failed: {error}")
                QMessageBox.warning(self, "Scan Error", f"Failed to scan: {error}")
            else:
                if not files:
                    logger.info("No OME-Zarr files found in directory")
                self.file_list.set_items(files)
            self.loading_indicator.stop()
        
        run_async(scan, on_result=on_result)
    
    def _on_file_selected(self, path: str):
        """Handle file selection."""
        self._load_pyramid(path)
    
    def _load_pyramid(self, path: str):
        """Load pyramid from OME-Zarr file."""
        self.loading_indicator.set_message("Loading pyramid...")
        self.loading_indicator.start()
        
        logger.info(f"Loading pyramid from: {path}")
        
        def load():
            try:
                logger.debug(f"Creating Pyramid object for {path}")
                pyr = Pyramid(path)
                logger.debug(f"Pyramid loaded. Shape: {pyr.shape}, Axes: {pyr.axes}")
                return pyr, None
            except Exception as e:
                logger.error(f"Error loading pyramid from {path}: {e}", exc_info=True)
                return None, str(e)
        
        def on_result(result):
            pyr, error = result
            if error:
                logger.error(f"Failed to load pyramid: {error}")
                QMessageBox.critical(self, "Error", f"Failed to load: {error}")
                self.loading_indicator.stop()
                return
            
            try:
                # Clear cache and reset ALL state when loading new image
                self.plane_cache.clear()
                self.state.t_index = 0
                self.state.z_index = 0
                self.state.c_index = 0
                self.state.pan_offset_x = 0  # CRITICAL: Reset pan
                self.state.pan_offset_y = 0  # CRITICAL: Reset pan
                self.state.rotation_roll = 0.0  # CRITICAL: Reset rotation
                self.state.rotation_tilt = 0.0  # CRITICAL: Reset rotation
                
                self.pyramid = pyr
                self.state.current_zarr_path = path
                self._update_ui_from_pyramid()
                self.loading_indicator.stop()
                logger.info("Pyramid loaded and UI updated successfully")
            except Exception as e:
                logger.error(f"Error updating UI from pyramid: {e}", exc_info=True)
                QMessageBox.critical(self, "Error", f"Failed to update UI: {e}")
                self.loading_indicator.stop()
        
        run_async(load, on_result=on_result)

    
    def _update_ui_from_pyramid(self):
        """Update UI sliders and limits from loaded pyramid."""
        if not self.pyramid:
            return
        
        # Reset rotation sliders to zero position
        self.rotation_roll_slider.blockSignals(True)
        self.rotation_tilt_slider.blockSignals(True)
        self.rotation_roll_slider.setValue(0)
        self.rotation_tilt_slider.setValue(0)
        self.rotation_roll_slider.blockSignals(False)
        self.rotation_tilt_slider.blockSignals(False)
        logger.info("Rotation sliders reset to 0")
        
        # Get shape and metadata
        axes = self.pyramid.axes
        shape = self.pyramid.shape
        
        # Axes is a string like 'tczyx', convert to list
        axis_names = list(axes) if axes else []
        
        axis_map = {name.upper(): i for i, name in enumerate(axis_names)}
        
        # Set slider limits
        if 'T' in axis_map:
            t_idx = axis_map['T']
            self.t_slider.slider.setMaximum(max(0, shape[t_idx] - 1))
        else:
            self.t_slider.slider.setMaximum(0)
        
        if 'Z' in axis_map:
            z_idx = axis_map['Z']
            self.z_slider.slider.setMaximum(max(0, shape[z_idx] - 1))
        else:
            self.z_slider.slider.setMaximum(0)
        
        # Update metadata display
        if 'C' in axis_map:
            num_channels = shape[axis_map['C']]
        else:
            num_channels = 1
        
        self._update_metadata_display(num_channels)
        
        # Set up zoom/layer spinbox based on available pyramid layers
        if self.pyramid and self.pyramid.layers:
            num_layers = len(self.pyramid.layers)
            self.zoom_spinbox.setMaximum(num_layers - 1)
            
            # Find the best starting layer: highest resolution that completely fits 256 pixels
            best_layer = self._find_best_starting_layer()
            self.zoom_spinbox.blockSignals(True)
            self.zoom_spinbox.setValue(int(best_layer))
            self.zoom_spinbox.blockSignals(False)
            self.state.zoom_level = int(best_layer)
            logger.info(f"Pyramid has {num_layers} layers, starting at layer {best_layer}")
        
        # Update intensity slider range based on current mode
        self._update_intensity_slider_range()
        
        # Refresh display
        self._refresh_image()
    
    def _update_metadata_display(self, num_channels: int):
        """Update metadata label and initialize channel defaults."""
        self.num_channels = num_channels
        
        # Initialize default colors, labels, and intensity limits for all channels
        default_colors = ["#FF0000", "#00FF00", "#0000FF", "#FF00FF", "#00FFFF", "#FFFF00", "#FFFFFF", "#808080"]
        for c in range(num_channels):
            if c not in self.state.editing_colors:
                self.state.editing_colors[c] = default_colors[c % len(default_colors)]
            if c not in self.state.editing_labels:
                self.state.editing_labels[c] = f"Channel {c}"
            # Initialize intensity limits to full range (will be refined based on actual data)
            # Use 0-255 as default, will adjust based on dtype if needed
            if c not in self.state.editing_intensity_limits:
                if self.pyramid and hasattr(self.pyramid, 'dtype'):
                    # Set range based on dtype
                    dtype = self.pyramid.dtype
                    if np.issubdtype(dtype, np.uint8):
                        self.state.editing_intensity_limits[c] = (0, 255)
                    elif np.issubdtype(dtype, np.uint16):
                        self.state.editing_intensity_limits[c] = (0, 65535)
                    elif np.issubdtype(dtype, np.uint32):
                        self.state.editing_intensity_limits[c] = (0, 4294967295)
                    else:
                        # For floats, use 0-1 or 0-255 based on actual range
                        self.state.editing_intensity_limits[c] = (0, 1)
                else:
                    self.state.editing_intensity_limits[c] = (0, 65535)  # Default fallback
        
        # Update channel checkboxes
        self._update_channel_checkboxes(num_channels)
        
        text = f"Channels: {num_channels}\n"
        text += f"Path: {self.state.current_zarr_path}\n"
        
        if self.pyramid:
            text += f"Shape: {self.pyramid.shape}\n"
            text += f"Dtype: {self.pyramid.dtype}\n"
        
        self.metadata_label.setText(text)
    
    def _update_channel_checkboxes(self, num_channels: int):
        """Update channel selection checkboxes."""
        # Clear old checkboxes
        while self.channel_checkboxes_layout.count():
            item = self.channel_checkboxes_layout.takeAt(0)
            if item.widget():
                item.widget().deleteLater()
        
        self.channel_checkboxes.clear()
        
        # Initialize selected_channels if empty
        if not self.state.selected_channels:
            # Default: select first channel
            self.state.selected_channels = [0]
        
        # Create new checkboxes
        default_colors = ["#FF0000", "#00FF00", "#0000FF", "#FF00FF", "#00FFFF", "#FFFF00", "#FFFFFF", "#808080"]
        for c in range(num_channels):
            checkbox = QCheckBox(f"Channel {c} (#{default_colors[c % len(default_colors)]})")
            # Block signals while setting initial state to avoid triggering refresh
            checkbox.blockSignals(True)
            checkbox.setChecked(c in self.state.selected_channels)
            checkbox.blockSignals(False)
            # Connect signal after setting initial state
            checkbox.stateChanged.connect(lambda state, ch=c: self._on_channel_toggled(ch, state))
            self.channel_checkboxes[c] = checkbox
            self.channel_checkboxes_layout.addWidget(checkbox)
        
        self.channel_checkboxes_layout.addStretch()
    
    def _update_intensity_slider_range(self):
        """Update the intensity slider range based on the current mode and current channel (in background)."""
        if not self.pyramid:
            return
        
        c = self.state.c_index
        mode = self.state.intensity_range_mode
        
        def compute_range():
            try:
                if mode == "Data Type":
                    return self._get_intensity_range_from_metadata(c)
                else:  # "Data Values"
                    return self._get_intensity_range_from_data(c)
            except Exception as e:
                logger.error(f"Error computing intensity range: {e}", exc_info=True)
                return None, None
        
        def on_range_result(result):
            min_val, max_val = result
            if min_val is not None and max_val is not None:
                self.intensity_slider.min_input.blockSignals(True)
                self.intensity_slider.max_input.blockSignals(True)
                self.intensity_slider.set_range(min_val, max_val)
                self.intensity_slider.min_input.blockSignals(False)
                self.intensity_slider.max_input.blockSignals(False)
                
                # Update state
                self.state.editing_intensity_limits[c] = (min_val, max_val)
        
        run_async(compute_range, on_result=on_range_result)
    
    def _get_intensity_range_from_metadata(self, channel: int) -> Tuple[Optional[float], Optional[float]]:
        """Get intensity range from OME metadata (window.start, window.end)."""
        try:
            if not self.pyramid or not hasattr(self.pyramid, 'meta') or not self.pyramid.meta:
                return None, None
            
            metadata = self.pyramid.meta.metadata if hasattr(self.pyramid.meta, 'metadata') else None
            if not metadata or 'omero' not in metadata:
                return None, None
            
            omero = metadata['omero']
            if 'channels' not in omero or channel >= len(omero['channels']):
                return None, None
            
            channel_meta = omero['channels'][channel]
            if 'window' not in channel_meta:
                return None, None
            
            window = channel_meta['window']
            min_val = window.get('start')
            max_val = window.get('end')
            
            logger.debug(f"Channel {channel}: metadata range [{min_val}, {max_val}]")
            return min_val, max_val
        except Exception as e:
            logger.error(f"Error getting metadata range: {e}", exc_info=True)
            return None, None
    
    def _get_intensity_range_from_data(self, channel: int) -> Tuple[Optional[float], Optional[float]]:
        """Compute intensity range from actual array data (min/max across array)."""
        try:
            if not self.pyramid:
                return None, None
            
            # Get the base (full resolution) array
            base_array = self.pyramid.base_array
            
            if base_array is None:
                logger.warning("Base array is None")
                return None, None
            
            # For multi-channel data, extract the channel slice
            # Assuming array shape is (T, C, Z, Y, X) or similar with C axis
            axes = self.pyramid.axes if hasattr(self.pyramid, 'axes') else 'tczyx'
            axis_map = {name.upper(): i for i, name in enumerate(axes)}
            
            if 'C' in axis_map:
                c_idx = axis_map['C']
                # Create a slice for just this channel
                slices = [slice(None)] * len(base_array.shape)
                slices[c_idx] = channel
                channel_data = base_array[tuple(slices)]
            else:
                channel_data = base_array
            
            # Use dask if available, compute min/max lazily
            if hasattr(channel_data, 'min') and hasattr(channel_data, 'max'):
                # For dask arrays, compute() is called within the worker thread
                min_val = channel_data.min().compute() if hasattr(channel_data.min(), 'compute') else float(channel_data.min())
                max_val = channel_data.max().compute() if hasattr(channel_data.max(), 'compute') else float(channel_data.max())
            else:
                min_val = float(np.min(channel_data))
                max_val = float(np.max(channel_data))
            
            logger.debug(f"Channel {channel}: data range [{min_val}, {max_val}]")
            return min_val, max_val
        except Exception as e:
            logger.error(f"Error computing data range: {e}", exc_info=True)
            return None, None

    def _on_open_channel_controls(self):
        """Open channel control dialog."""
        if self.num_channels == 0:
            QMessageBox.information(self, "Info", "No channels available")
            return
        
        dialog = ChannelControlDialog(self.state, self.num_channels, self)
        # Connect dialog changes to refresh image
        dialog.changed.connect(self._refresh_image)
        dialog.exec()
    
    def _on_index_changed(self, axis: str, value: int):
        """Handle slider index changes."""
        if axis == 't':
            self.state.t_index = value
        elif axis == 'z':
            self.state.z_index = value
        
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_z_wheel_scroll(self, direction: int):
        """Handle mouse wheel scroll on image display to change Z slice.
        
        Args:
            direction: +1 for scroll up (next z), -1 for scroll down (prev z)
        """
        if not hasattr(self, 'z_slider'):
            return
        
        current_z = self.z_slider.value()
        max_z = self.z_slider.slider.maximum()
        
        new_z = current_z + direction
        new_z = max(0, min(new_z, max_z))
        
        if new_z != current_z:
            self.state.z_index = new_z
            self.z_slider.setValue(new_z)
            self._refresh_image()
            self._on_navigation_activity()
    
    def _clamp_pan_offset(self):
        """Clamp pan offset to valid range based on current layer size and display size.
        
        This prevents pan offset from accumulating beyond the point where the extraction
        window reaches the layer boundary. Without clamping, pan input at edges is consumed
        but produces no visual effect, making the UI feel unresponsive.
        """
        if not self.pyramid:
            return
        
        try:
            # Get current resolution layer info
            best_resolution_key = str(self.state.zoom_level)
            if best_resolution_key not in self.pyramid.layers:
                best_resolution_key = '0'
            
            data_source = self.pyramid.layers[best_resolution_key]
            if data_source is None:
                return
            
            # Get spatial dimensions (always last 2: Y, X)
            spatial_y_idx = len(data_source.shape) - 2
            spatial_x_idx = len(data_source.shape) - 1
            layer_height = data_source.shape[spatial_y_idx]
            layer_width = data_source.shape[spatial_x_idx]
            
            # FOV size
            fov_size = self.state.fov_size
            display_height = min(layer_height, fov_size)
            display_width = min(layer_width, fov_size)
            
            # Scale factor for this resolution
            layer_scale = 1.0 / (2 ** int(best_resolution_key))
            
            # Maximum pan offset (at full resolution)
            # Pan offset is in full-resolution coords, so scale the layer dimensions back up
            unscaled_layer_height = layer_height / layer_scale
            unscaled_layer_width = layer_width / layer_scale
            unscaled_display_height = display_height / layer_scale
            unscaled_display_width = display_width / layer_scale
            
            # Calculate valid range for pan offset
            # Pan offset positive = pan right/down = shift extraction window left/up
            # At center: pan_offset = 0
            # At left edge: pan_offset_x = (layer_width - display_width) / 2 (positive = can't pan further left)
            # At right edge: pan_offset_x = -(layer_width - display_width) / 2 (negative = can't pan further right)
            max_pan_x = int((unscaled_layer_width - unscaled_display_width) / 2)
            max_pan_y = int((unscaled_layer_height - unscaled_display_height) / 2)
            
            # Clamp to valid range
            self.state.pan_offset_x = max(-max_pan_x, min(max_pan_x, self.state.pan_offset_x))
            self.state.pan_offset_y = max(-max_pan_y, min(max_pan_y, self.state.pan_offset_y))
            
            logger.debug(f"Pan offset clamped to X[{-max_pan_x}:{max_pan_x}], Y[{-max_pan_y}:{max_pan_y}]: "
                        f"current=({self.state.pan_offset_x}, {self.state.pan_offset_y})")
        except Exception as e:
            logger.error(f"Error clamping pan offset: {e}", exc_info=True)
    
    def _on_image_panned(self, delta_x: int, delta_y: int):
        """Handle image pan via mouse drag.
        
        Args:
            delta_x: Screen pixel delta in X
            delta_y: Screen pixel delta in Y
        """
        # Accumulate pan offset in data space
        self.state.pan_offset_x += delta_x
        self.state.pan_offset_y += delta_y
        
        # Clamp pan offset to valid range to prevent going past layer boundaries
        self._clamp_pan_offset()
        
        logger.debug(f"Pan offset updated: X={self.state.pan_offset_x}, Y={self.state.pan_offset_y}")
        
        # NO cache clear: Cache key now includes pan_offset_x and pan_offset_y,
        # so different pans automatically use different cache entries.
        # Clearing here prevents reuse of previously computed planes for this pan offset.
        
        # Refresh image to show panned region
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_channel_toggled(self, channel: int, state: int):
        """Handle channel checkbox toggle."""
        from PyQt6.QtCore import Qt
        # state is passed as an int (0 = Unchecked, 2 = Checked, 1 = PartiallyChecked)
        is_checked = state == 2  # Qt.CheckState.Checked = 2
        
        if is_checked and channel not in self.state.selected_channels:
            self.state.selected_channels.append(channel)
        elif not is_checked and channel in self.state.selected_channels:
            self.state.selected_channels.remove(channel)
        
        logger.debug(f"Active channels: {self.state.selected_channels}")
        # Update slider range when channel is toggled
        self.state.c_index = channel
        self._update_intensity_slider_range()
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_fov_changed(self, value: int):
        """Handle FOV size change."""
        self.state.fov_size = value
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_orientation_changed(self, orientation: str):
        """Handle orientation change."""
        self.state.plane_orientation = orientation
        self.plane_cache.clear()
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_rotation_changed(self, axis: str, value: int):
        """Handle rotation slider change (camera-centric).
        
        Args:
            axis: 'roll' (spin/in-plane) or 'tilt' (depth exploration)
            value: New rotation angle (0-180 degrees)
        """
        # Store as degrees (functions expect degrees!)
        if axis == 'roll':
            self.state.rotation_roll = float(value)
            logger.critical(f">>> ROTATION CHANGED [ROLL]: {value}°")
        elif axis == 'tilt':
            self.state.rotation_tilt = float(value)
            logger.critical(f">>> ROTATION CHANGED [TILT]: {value}°")
        
        logger.critical(f"    Current state: roll={self.state.rotation_roll}°, tilt={self.state.rotation_tilt}°")
        logger.critical(f"    Will extract with: rotation_roll={self.state.rotation_roll}°, rotation_tilt={self.state.rotation_tilt}°")
        
        # NO cache clear: Cache key now includes rotation_roll and rotation_tilt,
        # so different rotations automatically use different cache entries.
        
        # Refresh display with new rotation
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_zoom_changed(self, value: int):
        """Handle layer selection change."""
        print(f"\n>>> _on_zoom_changed CALLED with value={value}")  # CRITICAL DEBUG
        old_layer = self.state.zoom_level
        self.state.zoom_level = value
        
        # Adjust t/z indices to correspond to the same region in the new layer
        if self.pyramid and old_layer != value:
            print(f">>> Calling _adjust_indices_for_layer_change({old_layer}, {value})")  # CRITICAL DEBUG
            self._adjust_indices_for_layer_change(old_layer, value)
        
        # Clear plane cache when layer changes
        self.plane_cache.clear()
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_range_mode_changed(self, mode: str):
        """Handle intensity range mode change."""
        self.state.intensity_range_mode = mode
        # Update slider to reflect the selected mode
        if self.pyramid:
            self._update_intensity_slider_range()
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_intensity_changed(self, min_val: float, max_val: float):
        """Handle intensity range change."""
        c = self.state.c_index
        self.state.editing_intensity_limits[c] = (min_val, max_val)
        logger.debug(f"Channel {c} intensity range: {min_val} to {max_val}")
        self._refresh_image()
        self._on_navigation_activity()
    
    def _on_histogram_toggled(self, checked: bool):
        """Handle histogram toggle."""
        self.state.show_histogram = checked
        self._on_navigation_activity()
    
    def _on_channel_color_changed(self, channel: int, color: str):
        """Handle channel color change."""
        self.state.editing_colors[channel] = color
        self._refresh_image()
    
    def _adjust_indices_for_layer_change(self, old_layer: int, new_layer: int):
        """Adjust t/z indices when switching layers to maintain the same region.
        
        Uses scale factors from the pyramid to calculate corresponding indices.
        For example, if z_scale is 2 (layer is 2x coarser), z_index is halved.
        
        Args:
            old_layer: Previous layer index
            new_layer: New layer index
        """
        print(f"\n*** _adjust_indices_for_layer_change({old_layer}, {new_layer}) ENTERED ***")  # CRITICAL DEBUG
        logger.info(f"=== LAYER CHANGE: {old_layer} -> {new_layer} ===")
        
        if not self.pyramid:
            print(f"!!! NO PYRAMID !!!")  # CRITICAL DEBUG
            logger.warning("No pyramid available for layer change adjustment")
            return
        
        print(f"Pyramid exists: {self.pyramid}")  # CRITICAL DEBUG
        print(f"Has scale_factor_dict: {hasattr(self.pyramid, 'scale_factor_dict')}")  # CRITICAL DEBUG
        
        try:
            # Try to access scale_factor_dict
            if not hasattr(self.pyramid, 'scale_factor_dict'):
                logger.warning("Pyramid does not have scale_factor_dict attribute")
                return
            
            scale_factor_dict = self.pyramid.scale_factor_dict
            logger.info(f"Scale factor dict: {scale_factor_dict}")
            
            old_key = str(old_layer)
            new_key = str(new_layer)
            
            logger.debug(f"Looking for keys: '{old_key}' and '{new_key}'")
            logger.debug(f"Available keys: {list(scale_factor_dict.keys())}")
            
            if old_key not in scale_factor_dict or new_key not in scale_factor_dict:
                logger.warning(f"Scale factors not found for layers {old_key} or {new_key}. Available: {list(scale_factor_dict.keys())}")
                return
            
            old_scales = scale_factor_dict[old_key]
            new_scales = scale_factor_dict[new_key]
            
            logger.info(f"Old scales ({old_key}): {old_scales}")
            logger.info(f"New scales ({new_key}): {new_scales}")
            
            # Get axis order to map axis names to indices
            axes = self.pyramid.axes
            axis_map = {name.upper(): i for i, name in enumerate(list(axes))}
            
            logger.debug(f"Axes: {axes}, Axis map: {axis_map}")
            logger.info(f"Current Z index: {self.state.z_index}, Current T index: {self.state.t_index}")
            
            # Adjust Z index based on scale factor (note: scale_factor_dict uses lowercase 'z')
            if 'z' in old_scales and 'z' in new_scales:
                logger.debug(f"Adjusting Z: old_scale={old_scales['z']}, new_scale={new_scales['z']}")
                # Scale factor ratio: how much more coarse the new layer is
                if old_scales['z'] != 0:
                    z_scale_ratio = new_scales['z'] / old_scales['z']
                    # IMPORTANT: if ratio > 1, new layer is coarser (fewer slices), so divide not multiply
                    new_z = int(self.state.z_index / z_scale_ratio)
                    
                    logger.info(f"Z scale ratio: {z_scale_ratio}, Old Z: {self.state.z_index}, New Z (before clamp): {new_z}")
                    
                    # Clamp to valid range for new layer
                    new_layer_shape = self.pyramid.layers[new_key].shape
                    if 'Z' in axis_map:
                        max_z = new_layer_shape[axis_map['Z']] - 1
                        new_z = min(new_z, max_z)
                        logger.info(f"Max Z for new layer: {max_z}, Final Z: {new_z}")
                        
                        self.state.z_index = new_z
                        self.z_slider.blockSignals(True)
                        self.z_slider.setValue(new_z)
                        self.z_slider.blockSignals(False)
                        logger.info(f"✓ Adjusted Z index to: {self.state.z_index}")
            else:
                logger.debug(f"z not in scales: z in old? {'z' in old_scales}, z in new? {'z' in new_scales}")
            
            # Adjust T index based on scale factor (note: scale_factor_dict uses lowercase 't')
            if 't' in old_scales and 't' in new_scales:
                logger.debug(f"Adjusting T: old_scale={old_scales['t']}, new_scale={new_scales['t']}")
                if old_scales['t'] != 0:
                    t_scale_ratio = new_scales['t'] / old_scales['t']
                    new_t = int(self.state.t_index * t_scale_ratio)
                    
                    logger.info(f"T scale ratio: {t_scale_ratio}, Old T: {self.state.t_index}, New T (before clamp): {new_t}")
                    
                    # Clamp to valid range for new layer
                    new_layer_shape = self.pyramid.layers[new_key].shape
                    if 'T' in axis_map:
                        max_t = new_layer_shape[axis_map['T']] - 1
                        new_t = min(new_t, max_t)
                        logger.info(f"Max T for new layer: {max_t}, Final T: {new_t}")
                        
                        self.state.t_index = new_t
                        self.t_slider.blockSignals(True)
                        self.t_slider.setValue(new_t)
                        self.t_slider.blockSignals(False)
                        logger.info(f"✓ Adjusted T index to: {self.state.t_index}")
            else:
                logger.debug(f"t not in scales: t in old? {'t' in old_scales}, t in new? {'t' in new_scales}")
            
            logger.info(f"=== LAYER CHANGE COMPLETE ===")
        
        except Exception as e:
            logger.error(f"Error adjusting indices for layer change: {e}", exc_info=True)
    
    def _on_lock_changes(self):
        """Lock/unlock changes."""
        self.state.changes_locked = not self.state.changes_locked
        QMessageBox.information(
            self,
            "Changes Locked",
            "Your changes are now locked. Click again to unlock."
        )
    
    def _on_update_metadata(self):
        """Update metadata in OME-Zarr file."""
        if not self.pyramid:
            QMessageBox.warning(self, "Warning", "No image loaded")
            return
        
        # Prepare updates
        updates = {}
        
        # Add color updates if any
        if self.state.editing_colors:
            updates['colors'] = self.state.editing_colors
        
        if not updates:
            QMessageBox.information(self, "Info", "No changes to save")
            return
        
        try:
            # This would call the actual update function
            # run_updates(self.state.current_zarr_path, updates)
            QMessageBox.information(self, "Success", "Metadata updated")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to update: {e}")
    
    def _refresh_image(self):
        """Refresh displayed image from pyramid, compositing selected channels."""
        if not self.pyramid:
            self.image_display.set_text("No image loaded")
            return
        
        if not self.state.selected_channels:
            self.image_display.set_text("No channels selected")
            return
        
        self.loading_indicator.set_message("Compositing channels...")
        self.loading_indicator.start()
        
        def extract():
            try:
                # CRITICAL: Capture ALL state values at the START of extraction
                # This prevents race conditions where state changes during extraction
                t = self.state.t_index
                z = self.state.z_index
                orientation = self.state.plane_orientation
                rotation_roll = self.state.rotation_roll  # CAPTURE NOW
                rotation_tilt = self.state.rotation_tilt  # CAPTURE NOW
                pan_offset_x = self.state.pan_offset_x    # CAPTURE NOW
                pan_offset_y = self.state.pan_offset_y    # CAPTURE NOW
                selected_channels = list(self.state.selected_channels)  # CAPTURE AS LIST
                
                logger.info(f"=== EXTRACT START ===")
                logger.info(f"State snapshot: t={t}, z={z}, orient={orientation}")
                logger.info(f"  Rotation: roll={rotation_roll:.4f}, tilt={rotation_tilt:.4f}")
                logger.info(f"  Pan: x={pan_offset_x}, y={pan_offset_y}")
                logger.info(f"  Channels: {selected_channels}")
                
                # Use the selected layer directly (zoom_level is the layer index)
                layer_index = self.state.zoom_level
                best_resolution_key = str(layer_index)
                logger.info(f"  Layer/zoom: {layer_index}")
                
                # Update slider ranges to match the selected resolution layer
                self._update_slider_ranges_for_resolution(best_resolution_key)
                
                # Extract planes for all active channels
                planes = {}
                for c in selected_channels:  # Use captured list, not live state
                    logger.info(f"  [Channel {c}] Looking up cache...")
                    
                    plane = self.plane_cache.get(
                        t, z, c, orientation,
                        best_resolution_key,
                        rotation_roll,    # Use captured values
                        rotation_tilt,
                        pan_offset_x,
                        pan_offset_y,
                        0,  # depth_offset
                        self.state.fov_size  # CRITICAL: FOV affects output plane dimensions!
                    )
                    
                    if plane is None:
                        logger.info(f"  [Channel {c}] Cache MISS - extracting...")
                        
                        # Use oblique extraction if rotation is active
                        if rotation_roll != 0.0 or rotation_tilt != 0.0:  # Use captured values
                            logger.info(f"    >>> OBLIQUE: roll={rotation_roll:.4f}, tilt={rotation_tilt:.4f}")
                            plane = self._extract_oblique_plane(t, z, c, best_resolution_key)
                        else:
                            logger.info(f"    >>> ORTHOGONAL (no rotation)")
                            plane = self._extract_plane(t, z, c, orientation, best_resolution_key)
                        
                        if plane is not None:
                            logger.info(f"    Extracted shape: {plane.shape}, range=[{np.min(plane):.1f}, {np.max(plane):.1f}]")
                            
                            self.plane_cache.put(
                                t, z, c, orientation, plane,
                                best_resolution_key,
                                rotation_roll,    # Use captured values
                                rotation_tilt,
                                pan_offset_x,
                                pan_offset_y,
                                0,  # depth_offset
                                self.state.fov_size  # CRITICAL: FOV affects output plane dimensions!
                            )
                            logger.info(f"    Stored in cache")
                        else:
                            logger.error(f"    FAILED to extract plane for channel {c}!")
                            continue
                    else:
                        logger.info(f"  [Channel {c}] Cache HIT - shape={plane.shape}")
                    
                    planes[c] = plane
                
                logger.info(f"=== EXTRACT END: {len(planes)}/{len(selected_channels)} channels ===")
                return planes if planes else None
            except Exception as e:
                logger.error(f"Error extracting planes: {e}", exc_info=True)
                return None
        
        def on_result(planes):
            try:
                if planes is None or not planes:
                    logger.error("=== COMPOSITE FAILED: No planes returned ===")
                    self.image_display.set_text("Failed to extract planes")
                else:
                    logger.info(f"=== COMPOSITE START: {len(planes)} channel(s) ===")
                    for c, plane in sorted(planes.items()):
                        logger.info(f"  Channel {c}: shape={plane.shape}, dtype={plane.dtype}, range=[{np.min(plane):.1f}, {np.max(plane):.1f}]")
                    
                    # Composite channels into RGB
                    composite_rgb = self._composite_channels(planes)
                    
                    if composite_rgb is None:
                        logger.error("  Compositing FAILED")
                        self.image_display.set_text("Failed to composite channels")
                    else:
                        logger.info(f"  Composite result: shape={composite_rgb.shape}, dtype={composite_rgb.dtype}, range=[{np.min(composite_rgb)}, {np.max(composite_rgb)}]")
                        logger.info(f"=== COMPOSITE END ===")
                        
                        # Convert to pixmap
                        pixmap = numpy_to_qpixmap(composite_rgb, colormap=None, normalize=False)
                        if pixmap.isNull():
                            logger.error("Failed to create pixmap from composite")
                            self.image_display.set_text("Failed to create pixmap")
                        else:
                            logger.info(f"Pixmap created: {pixmap.width()}x{pixmap.height()}")
                            self.image_display.set_pixmap(pixmap)
                            logger.info(f"Image displayed successfully")
                            logger.info(f"Image displayed successfully. Composite shape: {composite_rgb.shape}")
            except Exception as e:
                logger.error(f"Error in image display callback: {e}", exc_info=True)
                self.image_display.set_text(f"Error: {str(e)[:50]}")
            finally:
                self.loading_indicator.stop()
        
        run_async(extract, on_result=on_result)
    
    def _composite_channels(self, planes: Dict[int, np.ndarray]) -> Optional[np.ndarray]:
        """
        Composite multiple channel planes into a single RGB image.
        
        Args:
            planes: Dict mapping channel index to 2D numpy array
        
        Returns:
            RGB image as uint8 numpy array
        """
        if not planes:
            logger.warning("No planes to composite")
            return None
        
        try:
            # Get reference shape from first plane
            ref_shape = next(iter(planes.values())).shape
            logger.debug(f"Compositing {len(planes)} channels, shape: {ref_shape}")
            
            # Initialize RGB composite
            composite = np.zeros((ref_shape[0], ref_shape[1], 3), dtype=np.float32)
            
            # Add each channel with its color
            for c, plane in sorted(planes.items()):
                if plane is None:
                    logger.warning(f"Channel {c} plane is None, skipping")
                    continue
                
                try:
                    # Get channel color
                    color_hex = self.state.editing_colors.get(c, "#FFFFFF")
                    color_rgb = hex_to_rgb(color_hex)
                    logger.debug(f"Channel {c}: color={color_hex}, rgb={color_rgb}")
                    
                    # Convert to float for processing
                    plane_float = plane.astype(np.float32)
                    
                    # Get intensity limits for this channel
                    if c in self.state.editing_intensity_limits:
                        min_val, max_val = self.state.editing_intensity_limits[c]
                    else:
                        min_val = np.min(plane_float)
                        max_val = np.max(plane_float)
                    
                    logger.debug(f"Channel {c}: intensity range {min_val} to {max_val}, data range {np.min(plane_float)} to {np.max(plane_float)}")
                    
                    # Clip to intensity range
                    plane_clipped = np.clip(plane_float, min_val, max_val)
                    
                    # Normalize to 0-1
                    if max_val > min_val:
                        plane_norm = (plane_clipped - min_val) / (max_val - min_val)
                    else:
                        # If min == max, use constant 0.5 (mid-gray)
                        plane_norm = np.ones_like(plane_clipped) * 0.5
                    
                    # Apply color tint by scaling and adding to composite
                    composite[:, :, 0] += plane_norm * (color_rgb[0] / 255.0)
                    composite[:, :, 1] += plane_norm * (color_rgb[1] / 255.0)
                    composite[:, :, 2] += plane_norm * (color_rgb[2] / 255.0)
                    
                except Exception as e:
                    logger.error(f"Error compositing channel {c}: {e}", exc_info=True)
                    continue
            
            # Normalize composite (clamp to 0-1, then scale to 0-255)
            composite = np.clip(composite, 0, 1)
            composite_uint8 = (composite * 255).astype(np.uint8)
            logger.debug(f"Composite created: shape={composite_uint8.shape}, dtype={composite_uint8.dtype}, range=[{np.min(composite_uint8)}, {np.max(composite_uint8)}]")
            
            return composite_uint8
        except Exception as e:
            logger.error(f"Error in _composite_channels: {e}", exc_info=True)
            return None
    
    def _update_slider_ranges_for_resolution(self, resolution_key: str):
        """
        Update T and Z slider ranges based on the selected resolution layer's shape.
        This ensures sliders match the actual data being viewed.
        """
        if not self.pyramid or resolution_key not in self.pyramid.layers:
            return
        
        try:
            layer = self.pyramid.layers[resolution_key]
            axes = self.pyramid.axes
            axis_names = list(axes) if axes else []
            axis_map = {name.upper(): i for i, name in enumerate(axis_names)}
            
            # Update T slider
            if 'T' in axis_map:
                t_idx = axis_map['T']
                t_max = layer.shape[t_idx] - 1
                self.t_slider.slider.setMaximum(max(0, t_max))
                # Clamp current t_index if it exceeds new max
                if self.state.t_index > t_max:
                    self.state.t_index = t_max
                    self.t_slider.slider.setValue(t_max)
                logger.debug(f"Updated T slider max to {t_max} for resolution '{resolution_key}'")
            
            # Update Z slider
            if 'Z' in axis_map:
                z_idx = axis_map['Z']
                z_max = layer.shape[z_idx] - 1
                self.z_slider.slider.setMaximum(max(0, z_max))
                # Clamp current z_index if it exceeds new max
                if self.state.z_index > z_max:
                    self.state.z_index = z_max
                    self.z_slider.slider.setValue(z_max)
                logger.debug(f"Updated Z slider max to {z_max} for resolution '{resolution_key}'")
        except Exception as e:
            logger.error(f"Error updating slider ranges for resolution '{resolution_key}': {e}", exc_info=True)
    
    def _find_best_starting_layer(self) -> int:
        """Find the highest resolution layer that completely fits within 256 pixels.
        
        Returns:
            Layer index (0 = highest resolution)
        """
        if not self.pyramid or not self.pyramid.layers:
            return 0
        
        resolution_keys = sorted(self.pyramid.layers.keys(), key=lambda x: int(x) if x.isdigit() else 0)
        display_size = 256  # Target display size
        
        # Find the highest resolution layer where max spatial dim <= display_size
        for key in resolution_keys:
            layer = self.pyramid.layers[key]
            if hasattr(layer, 'shape'):
                spatial_dims = layer.shape[-2:]  # Last 2 dims are Y, X
                max_spatial = max(spatial_dims) if spatial_dims else float('inf')
                if max_spatial <= display_size:
                    logger.info(f"Best starting layer: '{key}' with max spatial dimension {max_spatial}px <= {display_size}px")
                    return int(key) if key.isdigit() else 0
        
        # Fallback: use last (coarsest) layer
        logger.warning("No layer fits within 256px, using coarsest layer")
        return len(resolution_keys) - 1
    
    def _select_best_resolution(self, target_size: float) -> str:
        """
        Select the best resolution layer based on target display size.
        Returns the resolution layer key that best fits the target size.
        
        Args:
            target_size: Target pixel size for the displayed region (can be modified by zoom)
        
        Returns:
            Resolution layer key as string
        """
        if not self.pyramid or not self.pyramid.layers:
            return '0'
        
        resolution_keys = sorted(self.pyramid.layers.keys(), key=lambda x: int(x) if x.isdigit() else 0)
        
        # Debug: log available layers
        layer_info = []
        for key in resolution_keys:
            layer = self.pyramid.layers[key]
            if hasattr(layer, 'shape'):
                layer_info.append(f"Layer {key}: {layer.shape}")
        logger.debug(f"Available resolution layers: {', '.join(layer_info)}")
        
        # Find layers that fit within target size
        fitting_layers = []
        for key in resolution_keys:
            layer = self.pyramid.layers[key]
            # Get spatial dimensions (usually last 2 for y, x)
            if hasattr(layer, 'shape'):
                spatial_dims = layer.shape[-2:]  # Last 2 dims are typically Y, X
                max_spatial = max(spatial_dims) if spatial_dims else float('inf')
                fitting_layers.append((key, max_spatial))
        
        if not fitting_layers:
            # Fallback to first (highest resolution)
            return resolution_keys[0]
        
        # Sort by spatial size (largest first)
        fitting_layers.sort(key=lambda x: x[1], reverse=True)
        
        # Debug: log fitting layers
        fitting_info = [f"Layer {k}: {s}px" for k, s in fitting_layers]
        logger.debug(f"Fitting layers for target {target_size}px: {', '.join(fitting_info)}")
        
        # Find best fit: highest resolution that doesn't exceed target
        best_key = fitting_layers[0][0]  # Default to largest available
        for key, size in fitting_layers:
            if size <= target_size:
                best_key = key
                break
        
        logger.debug(f"Selected resolution layer '{best_key}' (size ~{next((s for k, s in fitting_layers if k == best_key), 'unknown')}px) for target size {target_size}px")
        return best_key

    def _extract_plane(
        self,
        t: int,
        z: int,
        c: int,
        orientation: str,
        resolution_key: str = '0'
    ) -> Optional[np.ndarray]:
        """Extract a 2D plane from the pyramid at the specified resolution layer.
        
        Extracts a window from the layer's center, bounded by the FOV size but displaying at true size.
        The FOV size acts as a maximum display dimension - if the layer is smaller, the image is 
        displayed at its true size without resizing.
        
        Args:
            t: Time index
            z: Z/depth index
            c: Channel index
            orientation: Plane orientation ('XY', 'XZ', 'YZ')
            resolution_key: Resolution layer key to extract from ('0' = highest res, higher nums = coarser)
        
        Returns:
            2D numpy array with data from the selected layer at its true size
        """
        if not self.pyramid:
            return None
        
        try:
            axes = self.pyramid.axes
            
            # Axes is a string like 'tczyx', convert to list
            axis_names = list(axes) if axes else []
            
            axis_map = {name.upper(): i for i, name in enumerate(axis_names)}
            
            # Use the provided resolution key
            best_resolution_key = resolution_key
            
            # Get the selected resolution layer from pyr.layers
            if best_resolution_key not in self.pyramid.layers:
                logger.error(f"Resolution layer '{best_resolution_key}' not found in pyramid")
                best_resolution_key = '0'
            
            data_source = self.pyramid.layers[best_resolution_key]
            
            if data_source is None:
                logger.error(f"No data available in layer '{best_resolution_key}'")
                return None
            
            logger.debug(f"Extracting plane from resolution '{best_resolution_key}', source shape: {data_source.shape}")
            
            # Get the spatial dimensions (last 2 dims: Y, X)
            spatial_y_idx = len(data_source.shape) - 2
            spatial_x_idx = len(data_source.shape) - 1
            layer_height = data_source.shape[spatial_y_idx]
            layer_width = data_source.shape[spatial_x_idx]
            
            # FOV size is treated as a MAXIMUM display dimension, not a target to resize to
            fov_size = self.state.fov_size
            
            # Calculate actual display size (true size, not resized to FOV)
            display_height = min(layer_height, fov_size)
            display_width = min(layer_width, fov_size)
            
            # Calculate center position
            center_y = layer_height // 2
            center_x = layer_width // 2
            
            # Scale pan offsets for multi-resolution layers (consistent with oblique extraction)
            # Pan offsets are in full-resolution coordinate space, so they need to be scaled down
            # when viewing coarser resolution layers
            layer_scale = 1.0 / (2 ** int(best_resolution_key))
            pan_offset_x_scaled = int(self.state.pan_offset_x * layer_scale)
            pan_offset_y_scaled = int(self.state.pan_offset_y * layer_scale)
            
            # Apply scaled pan offset (pan_offset_x positive = pan right = shift extraction window left)
            # These must be integers for array indexing
            pan_center_y = center_y + pan_offset_y_scaled
            pan_center_x = center_x + pan_offset_x_scaled
            
            # Calculate extraction boundaries from pan center, limited by display size
            fov_y_start = max(0, pan_center_y - display_height // 2)
            fov_y_end = min(layer_height, fov_y_start + display_height)
            fov_x_start = max(0, pan_center_x - display_width // 2)
            fov_x_end = min(layer_width, fov_x_start + display_width)
            
            # Re-adjust if we hit the edge to maintain display size
            # But preserve pan direction - if we hit top/left edge, shift end; if bottom/right edge, shift start
            if fov_y_end - fov_y_start < display_height:
                if fov_y_end == layer_height:
                    # Hit bottom - shift start up to maintain size
                    fov_y_start = max(0, layer_height - display_height)
                    fov_y_end = layer_height
                else:
                    # Hit top - extend end down
                    fov_y_end = min(layer_height, fov_y_start + display_height)
            
            if fov_x_end - fov_x_start < display_width:
                if fov_x_end == layer_width:
                    # Hit right - shift start left to maintain size
                    fov_x_start = max(0, layer_width - display_width)
                    fov_x_end = layer_width
                else:
                    # Hit left - extend end right
                    fov_x_end = min(layer_width, fov_x_start + display_width)
            
            logger.debug(f"Extraction window: Y[{fov_y_start}:{fov_y_end}] X[{fov_x_start}:{fov_x_end}] "
                        f"(display size: {display_height}x{display_width}, layer size: {layer_height}x{layer_width}, pan: ({self.state.pan_offset_x}, {self.state.pan_offset_y}))")
            
            # Build indexing tuple for full array access
            idx = [slice(None)] * len(data_source.shape)
            
            # Set fixed dimensions if they exist
            if 'T' in axis_map:
                idx[axis_map['T']] = t
            if 'Z' in axis_map:
                idx[axis_map['Z']] = z
            if 'C' in axis_map:
                idx[axis_map['C']] = c
            
            # Set spatial extraction slices (last 2 dims)
            idx[spatial_y_idx] = slice(fov_y_start, fov_y_end)
            idx[spatial_x_idx] = slice(fov_x_start, fov_x_end)
            
            # Extract data
            data = data_source[tuple(idx)]
            
            # Convert dask array to numpy if needed
            if hasattr(data, 'compute'):
                data = data.compute()
            
            data = np.asarray(data, dtype=np.float32)
            
            # Ensure 2D - squeeze out singleton dimensions
            data = np.squeeze(data)
            
            if data.ndim == 1:
                # If still 1D, reshape to 2D
                data = data.reshape(-1, 1)
            elif data.ndim > 2:
                logger.warning(f"Expected 2D after slicing, got {data.ndim}D with shape {data.shape}")
                # Take first 2D slice
                data = data.flatten().reshape(-1, data.shape[-1])
            
            logger.debug(f"Extracted plane: shape={data.shape}, dtype={data.dtype}, range=[{np.min(data):.1f}, {np.max(data):.1f}]")
            return data
        
        except Exception as e:
            logger.error(f"Error in _extract_plane: {e}", exc_info=True)
            return None
    
    def _extract_oblique_plane(
        self,
        t: int,
        z: int,
        c: int,
        resolution_key: str = '0'
    ) -> Optional[np.ndarray]:
        """Extract an oblique plane from the pyramid using 3D rotation.
        
        Uses trilinear interpolation to sample voxels from a rotated viewing plane.
        Leverages zarr's fancy indexing to load only necessary chunks.
        Handles arbitrary dimension combinations (T, C, Z may or may not exist).
        
        Args:
            t: Time index
            z: Z/depth index (center plane)
            c: Channel index
            resolution_key: Resolution layer key
            
        Returns:
            2D numpy array with rotated/oblique plane data
        """
        if not self.pyramid:
            return None
        
        try:
            from eubi_bridge.gui.rotation import pixel_to_world_coordinates, trilinear_interpolate
            
            # Get axes mapping
            axes = self.pyramid.axes
            axis_names = list(axes) if axes else []
            axis_map = {name.upper(): i for i, name in enumerate(axis_names)}
            logger.debug(f"Pyramid axes: {axes}, axis_map: {axis_map}")
            
            # Get data source
            best_resolution_key = resolution_key
            if best_resolution_key not in self.pyramid.layers:
                logger.error(f"Resolution layer '{best_resolution_key}' not found")
                best_resolution_key = '0'
            
            data_source = self.pyramid.layers[best_resolution_key]
            logger.debug(f"Data source shape: {data_source.shape}, axes: {axes}")
            
            # Get spatial dimensions (always last 2: Y, X)
            spatial_y_idx = len(data_source.shape) - 2
            spatial_x_idx = len(data_source.shape) - 1
            layer_height = data_source.shape[spatial_y_idx]
            layer_width = data_source.shape[spatial_x_idx]
            
            # FOV dimensions
            fov_size = self.state.fov_size
            display_height = min(layer_height, fov_size)
            display_width = min(layer_width, fov_size)
            
            # Create pixel coordinate grid
            pixel_i, pixel_j = np.mgrid[0:display_height, 0:display_width]
            
            # Get Z dimension size (may not exist, default to 1)
            z_dim_size = 1
            if 'Z' in axis_map:
                z_idx = axis_map['Z']
                z_dim_size = data_source.shape[z_idx]
            
            # Extract voxel scaling from pyramid metadata for anisotropic volumes
            # Note: In NGFF/OME-Zarr format, voxel scales are already stored per-layer,
            # so layer 0 might have (1.0, 1.0, 1.0) and layer 1 might have (2.0, 2.0, 2.0).
            # We should use them as-is from the layer metadata.
            voxel_scale_xyz = (1.0, 1.0, 1.0)  # Default isotropic (z, y, x)
            try:
                if self.pyramid.meta and hasattr(self.pyramid.meta, 'get_scaledict'):
                    scale_dict = self.pyramid.meta.get_scaledict(best_resolution_key)
                    # Get scales for Z, Y, X (all default to 1.0 if not present)
                    voxel_scale_xyz = (
                        scale_dict.get('z', 1.0),
                        scale_dict.get('y', 1.0),
                        scale_dict.get('x', 1.0)
                    )
                    logger.debug(f"Voxel scaling from layer {best_resolution_key}: z={voxel_scale_xyz[0]}, y={voxel_scale_xyz[1]}, x={voxel_scale_xyz[2]}")
            except Exception as e:
                logger.debug(f"Could not extract voxel scaling from metadata: {e}")
            
            # Scale pan and depth offsets for current resolution layer
            # If we're at a lower resolution layer, the offsets need to be scaled down
            # For example: resolution_key='0' is full res (scale=1.0), resolution_key='1' is half res (scale=0.5)
            layer_scale = 1.0 / (2 ** int(best_resolution_key))  # Downsample factor
            pan_offset_x_scaled = self.state.pan_offset_x * layer_scale
            pan_offset_y_scaled = self.state.pan_offset_y * layer_scale
            
            # NOTE: z_index is already in the correct coordinate system for the current layer
            # DO NOT scale it - it's already layer-relative, not base-layer-relative
            z_for_rotation = z
            
            # Depth offset: how far to move along the camera normal from the center plane
            # The center plane is at the middle of the layer (layer_height/2, layer_width/2, z_dim_size/2)
            # This allows depth movement along the camera's viewing direction when rotation is applied
            z_center = z_dim_size // 2  # Middle of the layer (default rotation center)
            depth_offset_value = z - z_center  # Offset from center
            
            # Use voxel scales as-is from the layer metadata (they're already correct for this layer)
            # Do NOT multiply by layer_scale_factor - the metadata already accounts for it
            voxel_scale_xyz_scaled = voxel_scale_xyz
            
            # Pass voxel scaling to rotation function (camera-centric rotations)
            logger.info(f">>> OBLIQUE EXTRACTION: resolution_key={best_resolution_key}, layer_scale={layer_scale}")
            logger.info(f"    rotation_roll={self.state.rotation_roll}, rotation_tilt={self.state.rotation_tilt}")
            logger.info(f"    z={z} (layer-relative), z_center={z_center}, depth_offset={depth_offset_value}")
            logger.info(f"    layer_height={layer_height}, layer_width={layer_width}")
            logger.info(f"    voxel_scale_xyz: {voxel_scale_xyz_scaled}")
            
            world_y, world_x, world_z = pixel_to_world_coordinates(
                pixel_i, pixel_j,
                layer_height, layer_width,
                self.state.rotation_roll, self.state.rotation_tilt,
                z_center,  # Rotation center is at the middle of the layer
                z_dim_size,  # Z dimension size for proper scaling
                pan_offset_x_scaled,  # Scaled pan offsets
                pan_offset_y_scaled,
                depth_offset_value,  # Depth movement along camera normal
                voxel_scale_xyz_scaled  # Anisotropic voxel scaling (adjusted for layer resolution)
            )
            
            logger.info(f"    world coords range: z=[{np.min(world_z):.1f}, {np.max(world_z):.1f}], "
                        f"y=[{np.min(world_y):.1f}, {np.max(world_y):.1f}], "
                        f"x=[{np.min(world_x):.1f}, {np.max(world_x):.1f}]")
            
            # Get floor and ceil indices for trilinear interpolation
            # NOTE: We keep world coordinates unclamped for interpolation fractions
            y_floor = np.floor(world_y).astype(int)
            y_ceil = np.ceil(world_y).astype(int)
            x_floor = np.floor(world_x).astype(int)
            x_ceil = np.ceil(world_x).astype(int)
            z_floor = np.floor(world_z).astype(int)
            z_ceil = np.ceil(world_z).astype(int)
            
            # Compute interpolation fractions BEFORE clamping for correct gradients
            frac_y = world_y - y_floor
            frac_x = world_x - x_floor
            frac_z = world_z - z_floor
            
            # NOW clamp indices for array access (but this doesn't affect interpolation fractions)
            y_floor_clamped = np.clip(y_floor, 0, layer_height - 1)
            y_ceil_clamped = np.clip(y_ceil, 0, layer_height - 1)
            x_floor_clamped = np.clip(x_floor, 0, layer_width - 1)
            x_ceil_clamped = np.clip(x_ceil, 0, layer_width - 1)
            z_floor_clamped = np.clip(z_floor, 0, z_dim_size - 1)
            z_ceil_clamped = np.clip(z_ceil, 0, z_dim_size - 1)
            
            # Check how many pixels are out of bounds BEFORE clamping
            oob_y = np.sum((y_floor < 0) | (y_ceil >= layer_height))
            oob_x = np.sum((x_floor < 0) | (x_ceil >= layer_width))
            oob_z = np.sum((z_floor < 0) | (z_ceil >= z_dim_size))
            total_pixels = display_height * display_width
            pct_oob_y = 100.0 * oob_y / total_pixels if total_pixels > 0 else 0
            pct_oob_x = 100.0 * oob_x / total_pixels if total_pixels > 0 else 0
            pct_oob_z = 100.0 * oob_z / total_pixels if total_pixels > 0 else 0
            
            logger.info(f"Bounds check: Y={pct_oob_y:.1f}% oob, X={pct_oob_x:.1f}% oob, Z={pct_oob_z:.1f}% oob")
            logger.debug(f"Indices - z: [{np.min(z_floor)}, {np.max(z_ceil)}] (valid: [0, {z_dim_size-1}]), "
                        f"y: [{np.min(y_floor)}, {np.max(y_ceil)}] (valid: [0, {layer_height-1}]), "
                        f"x: [{np.min(x_floor)}, {np.max(x_ceil)}] (valid: [0, {layer_width-1}])")
            
            # Build indexing arrays dynamically based on available dimensions
            def make_index_arrays(t_val, c_val, z_arr, y_arr, x_arr):
                """Build 8 sets of index tuples for the 8 corner voxels."""
                corners = []
                for z_corner in [z_arr[0] if isinstance(z_arr, np.ndarray) else z_arr,
                                 z_arr[1] if isinstance(z_arr, tuple) else z_arr]:
                    for y_corner in [y_arr[0] if isinstance(y_arr, np.ndarray) else y_arr,
                                     y_arr[1] if isinstance(y_arr, tuple) else y_arr]:
                        for x_corner in [x_arr[0] if isinstance(x_arr, np.ndarray) else x_arr,
                                         x_arr[1] if isinstance(x_arr, tuple) else x_arr]:
                            corners.append((z_corner, y_corner, x_corner))
                return corners
            
            # Fetch 8 corner voxels using vectorized zarr indexing (single call)
            # Stack all 8 corner indices into arrays of shape (8, height, width)
            # Use the CLAMPED indices for actual array access
            z_indices = np.stack([z_floor_clamped, z_ceil_clamped, z_floor_clamped, z_ceil_clamped, z_floor_clamped, z_ceil_clamped, z_floor_clamped, z_ceil_clamped], axis=0)
            y_indices = np.stack([y_floor_clamped, y_floor_clamped, y_floor_clamped, y_floor_clamped, y_ceil_clamped, y_ceil_clamped, y_ceil_clamped, y_ceil_clamped], axis=0)
            x_indices = np.stack([x_floor_clamped, x_floor_clamped, x_ceil_clamped, x_ceil_clamped, x_floor_clamped, x_floor_clamped, x_ceil_clamped, x_ceil_clamped], axis=0)
            
            logger.debug(f"Fetching 8 corners with single vectorized call")
            
            # Build index tuple for vectorized fetch
            idx = [slice(None)] * len(data_source.shape)
            
            # Set fixed dimensions
            if 'T' in axis_map:
                idx[axis_map['T']] = t
            if 'C' in axis_map:
                idx[axis_map['C']] = c
            
            # Set spatial dimensions to index arrays
            if 'Z' in axis_map:
                idx[axis_map['Z']] = z_indices
            if 'Y' in axis_map:
                idx[axis_map['Y']] = y_indices
            elif len(data_source.shape) >= 4:  # Y is second-to-last
                idx[-2] = y_indices
            if 'X' in axis_map:
                idx[axis_map['X']] = x_indices
            elif len(data_source.shape) >= 3:  # X is last
                idx[-1] = x_indices
            
            # Single vectorized zarr fetch for all 8 corners
            voxel_stack = data_source[tuple(idx)]
            
            # Convert dask to numpy if needed
            if hasattr(voxel_stack, 'compute'):
                voxel_stack = voxel_stack.compute()
            
            voxel_stack = np.asarray(voxel_stack, dtype=np.float32)
            
            # Unpack the 8 corner voxels from the stack
            v000 = voxel_stack[0]
            v001 = voxel_stack[1]
            v010 = voxel_stack[2]
            v011 = voxel_stack[3]
            v100 = voxel_stack[4]
            v101 = voxel_stack[5]
            v110 = voxel_stack[6]
            v111 = voxel_stack[7]
            
            # Mask out-of-bounds voxels to zero BEFORE interpolation
            # This prevents edge artifacts from edge-clamped pixel values
            oob_mask_y_floor = (y_floor < 0) | (y_floor >= layer_height)
            oob_mask_y_ceil = (y_ceil < 0) | (y_ceil >= layer_height)
            oob_mask_x_floor = (x_floor < 0) | (x_floor >= layer_width)
            oob_mask_x_ceil = (x_ceil < 0) | (x_ceil >= layer_width)
            oob_mask_z_floor = (z_floor < 0) | (z_floor >= z_dim_size)
            oob_mask_z_ceil = (z_ceil < 0) | (z_ceil >= z_dim_size)
            
            # Apply masks to corresponding voxels (000, 001, 010, 011, 100, 101, 110, 111)
            v000[oob_mask_y_floor | oob_mask_x_floor | oob_mask_z_floor] = 0
            v001[oob_mask_y_floor | oob_mask_x_ceil | oob_mask_z_floor] = 0
            v010[oob_mask_y_ceil | oob_mask_x_floor | oob_mask_z_floor] = 0
            v011[oob_mask_y_ceil | oob_mask_x_ceil | oob_mask_z_floor] = 0
            v100[oob_mask_y_floor | oob_mask_x_floor | oob_mask_z_ceil] = 0
            v101[oob_mask_y_floor | oob_mask_x_ceil | oob_mask_z_ceil] = 0
            v110[oob_mask_y_ceil | oob_mask_x_floor | oob_mask_z_ceil] = 0
            v111[oob_mask_y_ceil | oob_mask_x_ceil | oob_mask_z_ceil] = 0
            
            logger.debug(f"Voxel stack shape: {voxel_stack.shape}, range=[{np.min(voxel_stack):.1f}, {np.max(voxel_stack):.1f}]")
            
            logger.debug(f"Corner voxel ranges: "
                        f"v000=[{np.min(v000):.1f},{np.max(v000):.1f}] "
                        f"v111=[{np.min(v111):.1f},{np.max(v111):.1f}]")
            
            # Compute fractional parts for interpolation
            frac_y = world_y - y_floor
            frac_x = world_x - x_floor
            frac_z = world_z - z_floor
            
            logger.debug(f"Fractional parts: "
                        f"frac_z=[{np.min(frac_z):.3f},{np.max(frac_z):.3f}] "
                        f"frac_y=[{np.min(frac_y):.3f},{np.max(frac_y):.3f}] "
                        f"frac_x=[{np.min(frac_x):.3f},{np.max(frac_x):.3f}]")
            
            # Perform trilinear interpolation
            result = trilinear_interpolate(
                v000, v001,
                v010, v011,
                v100, v101,
                v110, v111,
                frac_y, frac_x, frac_z
            )
            
            logger.debug(f"Interpolated result: shape={result.shape}, range=[{np.min(result):.1f}, {np.max(result):.1f}]")
            
            # Ensure 2D result
            result = np.squeeze(result)
            if result.ndim == 1:
                result = result.reshape(-1, 1)
            elif result.ndim > 2:
                logger.warning(f"Expected 2D after interpolation, got {result.ndim}D")
                result = result[:, :] if result.size > 0 else np.zeros((display_height, display_width), dtype=np.float32)
            
            logger.debug(f"Extracted oblique plane: shape={result.shape}, range=[{np.min(result):.1f}, {np.max(result):.1f}]")
            return result
        
        except Exception as e:
            logger.error(f"Error in _extract_oblique_plane: {e}", exc_info=True)
            return None
