from __future__ import annotations

import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np
from PySide6 import QtCore, QtGui, QtWidgets

from ..core import features as feature_selector
from ..core import indices as index_engine
from ..core.config import AppConfig, load_config, save_config
from ..core.io import (
    RasterData,
    load_envi,
    load_geotiff,
    load_netcdf,
    load_npy,
    load_sentinel2_bands,
    load_sentinel2_band_tiffs,
    load_tabular,
    scan_sentinel2_folder,
    scan_sentinel2_tiffs,
)
from ..core.preprocess import prepare_gray, prepare_rgb
from ..core.projects import create_run_folder, save_run_config, save_segmentation_outputs
from ..core.segmentation import gmm_segment, gmm_segment_table
from ..core.sensors import SENSORS, SENSOR_BANDS
from ..core.npy_export import export_all_bands, export_individual_bands, export_indices, export_selected_bands
from .utils import colorize_index, colorize_labels, colorize_probability, numpy_to_qimage
from .widgets import PandasTableModel


@dataclass
class Layer:
    id: str
    name: str
    kind: str  # raster, mask, probability, index, table
    data: Any
    meta: dict[str, Any]
    visible: bool = True


@dataclass
class ROI:
    id: str
    rect: QtCore.QRectF
    label: str
    color: QtGui.QColor
    item: QtWidgets.QGraphicsRectItem


@dataclass
class Dataset:
    id: str
    name: str
    kind: str  # raster or table
    raster: RasterData | None
    table: Any | None
    layers: list[Layer]
    rois: list[ROI]
    segmentation_result: Any
    sensor: str
    display_mode: str = "rgb"
    band_selection: dict[str, int] | None = None


class RasterCanvas(QtWidgets.QGraphicsView):
    roi_created = QtCore.Signal(QtCore.QRectF)

    def __init__(self) -> None:
        super().__init__()
        self.setRenderHint(QtGui.QPainter.Antialiasing)
        self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag)
        self._scene = QtWidgets.QGraphicsScene(self)
        self.setScene(self._scene)
        self._pixmap_item = QtWidgets.QGraphicsPixmapItem()
        self._scene.addItem(self._pixmap_item)
        self._roi_mode = False
        self._roi_start: QtCore.QPointF | None = None
        self._roi_preview: QtWidgets.QGraphicsRectItem | None = None
        self._image_size = QtCore.QSizeF(1, 1)

    def set_roi_mode(self, enabled: bool) -> None:
        self._roi_mode = enabled
        self.setDragMode(QtWidgets.QGraphicsView.NoDrag if enabled else QtWidgets.QGraphicsView.ScrollHandDrag)

    def set_image(self, image: np.ndarray) -> None:
        qimage = numpy_to_qimage(image)
        pixmap = QtGui.QPixmap.fromImage(qimage)
        self._pixmap_item.setPixmap(pixmap)
        self._image_size = QtCore.QSizeF(qimage.width(), qimage.height())
        self._scene.setSceneRect(QtCore.QRectF(0, 0, qimage.width(), qimage.height()))
        self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)

    def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
        super().resizeEvent(event)
        self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
        if self._roi_mode and event.button() == QtCore.Qt.LeftButton:
            self._roi_start = self.mapToScene(event.position().toPoint())
            if self._roi_preview:
                self._scene.removeItem(self._roi_preview)
                self._roi_preview = None
            return
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        if self._roi_mode and self._roi_start:
            current = self.mapToScene(event.position().toPoint())
            rect = QtCore.QRectF(self._roi_start, current).normalized()
            if self._roi_preview is None:
                self._roi_preview = self._scene.addRect(rect, QtGui.QPen(QtGui.QColor("#38bdf8"), 2))
            else:
                self._roi_preview.setRect(rect)
            return
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
        if self._roi_mode and self._roi_start:
            end = self.mapToScene(event.position().toPoint())
            rect = QtCore.QRectF(self._roi_start, end).normalized()
            self._roi_start = None
            if self._roi_preview:
                self._scene.removeItem(self._roi_preview)
                self._roi_preview = None
            rect = rect.intersected(QtCore.QRectF(0, 0, self._image_size.width(), self._image_size.height()))
            if rect.width() > 5 and rect.height() > 5:
                self.roi_created.emit(rect)
            return
        super().mouseReleaseEvent(event)


class BandSelectionDialog(QtWidgets.QDialog):
    def __init__(self, bands: dict[str, Path], parent: QtWidgets.QWidget | None = None) -> None:
        super().__init__(parent)
        self.setWindowTitle("Select Sentinel-2 Bands")
        self.setModal(True)
        self.resize(420, 400)

        self._list = QtWidgets.QListWidget()
        self._list.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
        for band in sorted(bands.keys()):
            item = QtWidgets.QListWidgetItem(band)
            item.setCheckState(QtCore.Qt.Unchecked)
            self._list.addItem(item)

        info = QtWidgets.QLabel("Select bands to load (at least one).")
        btn_select_all = QtWidgets.QPushButton("Select All")
        btn_ok = QtWidgets.QPushButton("Load")
        btn_cancel = QtWidgets.QPushButton("Cancel")
        btn_select_all.clicked.connect(self._select_all)
        btn_ok.clicked.connect(self.accept)
        btn_cancel.clicked.connect(self.reject)

        button_row = QtWidgets.QHBoxLayout()
        button_row.addWidget(btn_select_all)
        button_row.addStretch(1)
        button_row.addWidget(btn_cancel)
        button_row.addWidget(btn_ok)

        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(info)
        layout.addWidget(self._list)
        layout.addLayout(button_row)

    def selected_bands(self) -> list[str]:
        selected = []
        for i in range(self._list.count()):
            item = self._list.item(i)
            if item.checkState() == QtCore.Qt.Checked:
                selected.append(item.text())
        return selected

    def _select_all(self) -> None:
        for i in range(self._list.count()):
            self._list.item(i).setCheckState(QtCore.Qt.Checked)


class MultiSelectDialog(QtWidgets.QDialog):
    def __init__(self, title: str, items: list[str], parent: QtWidgets.QWidget | None = None) -> None:
        super().__init__(parent)
        self.setWindowTitle(title)
        self.setModal(True)
        self.resize(420, 400)

        self._list = QtWidgets.QListWidget()
        for item_text in items:
            item = QtWidgets.QListWidgetItem(item_text)
            item.setCheckState(QtCore.Qt.Unchecked)
            self._list.addItem(item)

        btn_select_all = QtWidgets.QPushButton("Select All")
        btn_ok = QtWidgets.QPushButton("Use Selection")
        btn_cancel = QtWidgets.QPushButton("Cancel")
        btn_select_all.clicked.connect(self._select_all)
        btn_ok.clicked.connect(self.accept)
        btn_cancel.clicked.connect(self.reject)

        button_row = QtWidgets.QHBoxLayout()
        button_row.addWidget(btn_select_all)
        button_row.addStretch(1)
        button_row.addWidget(btn_cancel)
        button_row.addWidget(btn_ok)

        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(QtWidgets.QLabel("Select bands to include:"))
        layout.addWidget(self._list)
        layout.addLayout(button_row)

    def selected_items(self) -> list[str]:
        selected = []
        for i in range(self._list.count()):
            item = self._list.item(i)
            if item.checkState() == QtCore.Qt.Checked:
                selected.append(item.text())
        return selected

    def _select_all(self) -> None:
        for i in range(self._list.count()):
            self._list.item(i).setCheckState(QtCore.Qt.Checked)


class MainWindow(QtWidgets.QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle("SpectraForge")
        self.resize(1400, 900)

        self.datasets: dict[str, Dataset] = {}
        self.active_dataset_id: str | None = None
        self.layers: list[Layer] = []
        self.current_raster: RasterData | None = None
        self.current_table = None
        self.rois: list[ROI] = []
        self.segmentation_result = None
        self.display_mode = "rgb"
        self.current_sensor = "Sentinel-2"
        self.custom_indices: list[index_engine.IndexDefinition] = []
        self.config: AppConfig = load_config()
        self.index_band_selectors: dict[str, QtWidgets.QComboBox] = {}

        self._init_ui()
        self._apply_style()
        self._load_saved_custom_indices()

    def _apply_style(self) -> None:
        style_path = Path(__file__).resolve().parent.parent / "assets" / "style.qss"
        if style_path.exists():
            self.setStyleSheet(style_path.read_text(encoding="utf-8"))

    def _init_ui(self) -> None:
        self._init_menu()

        self.canvas = RasterCanvas()
        self.canvas.roi_created.connect(self._on_roi_created)

        self.table_view = QtWidgets.QTableView()
        self.table_view.setAlternatingRowColors(True)

        self.central_stack = QtWidgets.QStackedWidget()
        self.central_stack.addWidget(self.canvas)
        self.central_stack.addWidget(self.table_view)
        self.setCentralWidget(self.central_stack)

        self.layer_list = QtWidgets.QListWidget()
        self.layer_list.itemChanged.connect(self._on_layer_toggled)
        self.layer_list.itemSelectionChanged.connect(self._on_layer_selected)

        layer_dock = QtWidgets.QDockWidget("Layers")
        layer_dock.setWidget(self.layer_list)
        self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, layer_dock)

        self.inspector = QtWidgets.QTabWidget()
        self._build_data_tab()
        self._build_segmentation_tab()
        self._build_tabular_tab()
        self._build_roi_tab()
        self._build_connectors_tab()

        inspector_dock = QtWidgets.QDockWidget("Inspector")
        inspector_dock.setWidget(self.inspector)
        self.addDockWidget(QtCore.Qt.RightDockWidgetArea, inspector_dock)

        self.statusBar().showMessage("Ready")
        self._refresh_indices()

    def _init_menu(self) -> None:
        file_menu = self.menuBar().addMenu("File")

        open_auto = QtGui.QAction("Open Folder (Auto)", self)
        open_auto.triggered.connect(self.open_folder_auto)
        file_menu.addAction(open_auto)

        open_raster = QtGui.QAction("Open GeoTIFF", self)
        open_raster.triggered.connect(self.open_geotiff)
        file_menu.addAction(open_raster)

        open_netcdf = QtGui.QAction("Open NetCDF", self)
        open_netcdf.triggered.connect(self.open_netcdf)
        file_menu.addAction(open_netcdf)

        open_envi = QtGui.QAction("Open ENVI", self)
        open_envi.triggered.connect(self.open_envi)
        file_menu.addAction(open_envi)

        open_npy = QtGui.QAction("Open NPY", self)
        open_npy.triggered.connect(self.open_npy)
        file_menu.addAction(open_npy)

        open_s2 = QtGui.QAction("Open Sentinel-2 Folder", self)
        open_s2.triggered.connect(self.open_sentinel2)
        file_menu.addAction(open_s2)

        open_tabular = QtGui.QAction("Open Tabular (CSV/XLSX)", self)
        open_tabular.triggered.connect(self.open_tabular)
        file_menu.addAction(open_tabular)

        file_menu.addSeparator()

        open_runs = QtGui.QAction("Open Runs Folder", self)
        open_runs.triggered.connect(self.open_runs_folder)
        file_menu.addAction(open_runs)

        file_menu.addSeparator()

        exit_action = QtGui.QAction("Exit", self)
        exit_action.triggered.connect(self.close)
        file_menu.addAction(exit_action)

        toolbar = self.addToolBar("Quick")
        toolbar.addAction(open_auto)
        toolbar.addAction(open_raster)
        toolbar.addAction(open_netcdf)
        toolbar.addAction(open_envi)
        toolbar.addAction(open_npy)
        toolbar.addAction(open_s2)
        toolbar.addAction(open_tabular)

    def _build_data_tab(self) -> None:
        widget = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout(widget)

        self.dataset_label = QtWidgets.QLabel("No dataset loaded")
        layout.addWidget(self.dataset_label)

        dataset_group = QtWidgets.QGroupBox("Datasets")
        dataset_layout = QtWidgets.QHBoxLayout(dataset_group)
        self.dataset_combo = QtWidgets.QComboBox()
        self.dataset_combo.currentIndexChanged.connect(self._on_dataset_selected)
        self.btn_close_dataset = QtWidgets.QPushButton("Close Dataset")
        self.btn_close_dataset.clicked.connect(self._close_active_dataset)
        dataset_layout.addWidget(QtWidgets.QLabel("Active"))
        dataset_layout.addWidget(self.dataset_combo)
        dataset_layout.addWidget(self.btn_close_dataset)
        dataset_layout.addStretch(1)
        layout.addWidget(dataset_group)

        quick_group = QtWidgets.QGroupBox("Quick Load")
        quick_layout = QtWidgets.QHBoxLayout(quick_group)
        self.btn_open_auto = QtWidgets.QPushButton("Open Folder (Auto)")
        self.btn_open_auto.clicked.connect(self.open_folder_auto)
        self.btn_open_file = QtWidgets.QPushButton("Open File…")
        self.btn_open_file.clicked.connect(self.open_any_file)
        quick_layout.addWidget(self.btn_open_auto)
        quick_layout.addWidget(self.btn_open_file)
        quick_layout.addStretch(1)
        layout.addWidget(quick_group)

        sensor_group = QtWidgets.QGroupBox("Sensor Type")
        sensor_layout = QtWidgets.QHBoxLayout(sensor_group)
        self.sensor_combo = QtWidgets.QComboBox()
        self.sensor_combo.addItems(SENSORS)
        self.sensor_combo.blockSignals(True)
        self.sensor_combo.setCurrentText(self.current_sensor)
        self.sensor_combo.blockSignals(False)
        self.sensor_combo.currentTextChanged.connect(self._on_sensor_changed)
        sensor_layout.addWidget(QtWidgets.QLabel("Sensor"))
        sensor_layout.addWidget(self.sensor_combo)
        sensor_layout.addStretch(1)
        layout.addWidget(sensor_group)

        band_group = QtWidgets.QGroupBox("Band Display")
        band_layout = QtWidgets.QFormLayout(band_group)

        self.band_r = QtWidgets.QComboBox()
        self.band_g = QtWidgets.QComboBox()
        self.band_b = QtWidgets.QComboBox()
        self.band_gray = QtWidgets.QComboBox()

        band_layout.addRow("Red", self.band_r)
        band_layout.addRow("Green", self.band_g)
        band_layout.addRow("Blue", self.band_b)
        band_layout.addRow("Gray", self.band_gray)

        self.btn_apply_rgb = QtWidgets.QPushButton("Apply RGB")
        self.btn_apply_gray = QtWidgets.QPushButton("Apply Gray")
        self.btn_apply_rgb.clicked.connect(lambda: self._set_display_mode("rgb"))
        self.btn_apply_gray.clicked.connect(lambda: self._set_display_mode("gray"))

        band_button_row = QtWidgets.QHBoxLayout()
        band_button_row.addWidget(self.btn_apply_rgb)
        band_button_row.addWidget(self.btn_apply_gray)
        band_layout.addRow(band_button_row)

        layout.addWidget(band_group)

        catalog_group = QtWidgets.QGroupBox("Band Catalog")
        catalog_layout = QtWidgets.QVBoxLayout(catalog_group)
        self.band_catalog = QtWidgets.QListWidget()
        catalog_layout.addWidget(self.band_catalog)
        layout.addWidget(catalog_group)
        self._update_band_catalog()

        feature_group = QtWidgets.QGroupBox("Feature Selection")
        feature_layout = QtWidgets.QVBoxLayout(feature_group)
        self.btn_auto_features = QtWidgets.QPushButton("Auto-select Features")
        self.btn_auto_features.clicked.connect(self._auto_select_features)
        self.feature_output = QtWidgets.QLabel("Selected: -")
        feature_layout.addWidget(self.btn_auto_features)
        feature_layout.addWidget(self.feature_output)

        layout.addWidget(feature_group)

        index_group = QtWidgets.QGroupBox("Index Builder")
        index_layout = QtWidgets.QVBoxLayout(index_group)
        index_row = QtWidgets.QHBoxLayout()
        self.index_combo = QtWidgets.QComboBox()
        self.index_combo.currentTextChanged.connect(self._on_index_changed)
        self.index_desc = QtWidgets.QLabel("Select an index to see details.")
        self.index_desc.setWordWrap(True)
        index_row.addWidget(QtWidgets.QLabel("Index"))
        index_row.addWidget(self.index_combo)
        index_layout.addLayout(index_row)
        index_layout.addWidget(self.index_desc)

        self.index_mapping_widget = QtWidgets.QWidget()
        self.index_mapping_layout = QtWidgets.QFormLayout(self.index_mapping_widget)
        index_layout.addWidget(self.index_mapping_widget)

        index_button_row = QtWidgets.QHBoxLayout()
        self.btn_add_index = QtWidgets.QPushButton("Add Index Layer")
        self.btn_add_index.clicked.connect(self._add_index_layer)
        self.btn_load_index = QtWidgets.QPushButton("Load Custom Indices")
        self.btn_load_index.clicked.connect(self._load_custom_indices)
        index_button_row.addWidget(self.btn_add_index)
        index_button_row.addWidget(self.btn_load_index)
        index_layout.addLayout(index_button_row)

        layout.addWidget(index_group)

        export_group = QtWidgets.QGroupBox("NPY Export")
        export_layout = QtWidgets.QVBoxLayout(export_group)
        self.check_all_bands = QtWidgets.QCheckBox("All bands in one .npy")
        self.check_all_bands.setChecked(True)
        self.check_selected_bands = QtWidgets.QCheckBox("Selected bands in one .npy")
        self.check_individual_bands = QtWidgets.QCheckBox("Individual band .npy files")
        self.check_export_indices = QtWidgets.QCheckBox("Include index layers (.npy)")

        export_layout.addWidget(self.check_all_bands)
        export_layout.addWidget(self.check_selected_bands)
        export_layout.addWidget(self.check_individual_bands)
        export_layout.addWidget(self.check_export_indices)

        self.btn_export_npy = QtWidgets.QPushButton("Export NPY Files")
        self.btn_export_npy.clicked.connect(self._export_npy)
        export_layout.addWidget(self.btn_export_npy)

        layout.addWidget(export_group)
        layout.addStretch(1)

        self.inspector.addTab(widget, "Data")

    def _build_segmentation_tab(self) -> None:
        widget = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout(widget)

        config_group = QtWidgets.QGroupBox("GMM Segmentation")
        config_layout = QtWidgets.QFormLayout(config_group)

        self.spin_components = QtWidgets.QSpinBox()
        self.spin_components.setRange(2, 20)
        self.spin_components.setValue(8)

        self.spin_sample = QtWidgets.QSpinBox()
        self.spin_sample.setRange(1000, 500000)
        self.spin_sample.setValue(80000)
        self.spin_sample.setSingleStep(5000)

        self.combo_cov = QtWidgets.QComboBox()
        self.combo_cov.addItems(["full", "diag", "tied", "spherical"])

        self.combo_engine = QtWidgets.QComboBox()
        self.combo_engine.addItems(["Safe (numpy)", "Fast (sklearn)"])

        self.check_prob_maps = QtWidgets.QCheckBox("Generate probability maps")
        self.check_prob_maps.setChecked(True)

        self.slider_temp = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self.slider_temp.setRange(5, 30)
        self.slider_temp.setValue(10)
        self.label_temp = QtWidgets.QLabel("Temperature: 1.0")
        self.slider_temp.valueChanged.connect(self._update_temperature_label)

        config_layout.addRow("Components", self.spin_components)
        config_layout.addRow("Sample size", self.spin_sample)
        config_layout.addRow("Covariance", self.combo_cov)
        config_layout.addRow("Engine", self.combo_engine)
        config_layout.addRow(self.check_prob_maps)
        config_layout.addRow(self.label_temp)
        config_layout.addRow(self.slider_temp)

        layout.addWidget(config_group)

        self.btn_run_gmm = QtWidgets.QPushButton("Run GMM Segmentation")
        self.btn_run_gmm.clicked.connect(self.run_segmentation)
        layout.addWidget(self.btn_run_gmm)

        self.cluster_table = QtWidgets.QTableWidget(0, 2)
        self.cluster_table.setHorizontalHeaderLabels(["Cluster", "Label"])
        self.cluster_table.horizontalHeader().setStretchLastSection(True)

        layout.addWidget(QtWidgets.QLabel("Cluster Labels (editable)") )
        layout.addWidget(self.cluster_table)

        layout.addStretch(1)

        self.inspector.addTab(widget, "Segmentation")

    def _build_tabular_tab(self) -> None:
        widget = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout(widget)

        self.tabular_info = QtWidgets.QLabel("No tabular dataset loaded")
        layout.addWidget(self.tabular_info)

        summary_group = QtWidgets.QGroupBox("Summary")
        summary_layout = QtWidgets.QVBoxLayout(summary_group)
        self.btn_tabular_refresh = QtWidgets.QPushButton("Refresh Summary")
        self.btn_tabular_refresh.clicked.connect(self._refresh_tabular_summary)
        self.tabular_summary = QtWidgets.QPlainTextEdit()
        self.tabular_summary.setReadOnly(True)
        summary_layout.addWidget(self.btn_tabular_refresh)
        summary_layout.addWidget(self.tabular_summary)
        layout.addWidget(summary_group)

        join_group = QtWidgets.QGroupBox("EO Join (sample raster bands into table)")
        join_layout = QtWidgets.QFormLayout(join_group)
        self.eo_raster_combo = QtWidgets.QComboBox()
        self.lat_combo = QtWidgets.QComboBox()
        self.lon_combo = QtWidgets.QComboBox()
        self.btn_sample_raster = QtWidgets.QPushButton("Sample Raster Bands")
        self.btn_sample_raster.clicked.connect(self._sample_raster_to_table)
        join_layout.addRow("Raster dataset", self.eo_raster_combo)
        join_layout.addRow("Latitude column", self.lat_combo)
        join_layout.addRow("Longitude column", self.lon_combo)
        join_layout.addRow(self.btn_sample_raster)
        layout.addWidget(join_group)

        layout.addStretch(1)
        self.inspector.addTab(widget, "Tabular")

    def _build_roi_tab(self) -> None:
        widget = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout(widget)

        self.btn_roi_mode = QtWidgets.QPushButton("Draw ROI")
        self.btn_roi_mode.setCheckable(True)
        self.btn_roi_mode.toggled.connect(self._toggle_roi_mode)
        layout.addWidget(self.btn_roi_mode)

        self.roi_list = QtWidgets.QListWidget()
        layout.addWidget(self.roi_list)

        btn_label = QtWidgets.QPushButton("Set ROI Label")
        btn_label.clicked.connect(self._set_roi_label)
        btn_remove = QtWidgets.QPushButton("Remove ROI")
        btn_remove.clicked.connect(self._remove_roi)
        btn_apply = QtWidgets.QPushButton("Apply ROI Labels to Clusters")
        btn_apply.clicked.connect(self._apply_roi_labels)

        layout.addWidget(btn_label)
        layout.addWidget(btn_remove)
        layout.addWidget(btn_apply)
        layout.addStretch(1)

        self.inspector.addTab(widget, "ROI")

    def _build_connectors_tab(self) -> None:
        widget = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout(widget)

        cop_group = QtWidgets.QGroupBox("Copernicus (Sentinel-1/2/3)")
        cop_layout = QtWidgets.QFormLayout(cop_group)
        self.cop_key = QtWidgets.QLineEdit()
        self.cop_secret = QtWidgets.QLineEdit()
        self.cop_secret.setEchoMode(QtWidgets.QLineEdit.Password)
        self.cop_key.setText(self.config.copernicus_key)
        self.cop_secret.setText(self.config.copernicus_secret)
        cop_layout.addRow("API Key", self.cop_key)
        cop_layout.addRow("API Secret", self.cop_secret)
        layout.addWidget(cop_group)

        planet_group = QtWidgets.QGroupBox("PlanetScope")
        planet_layout = QtWidgets.QFormLayout(planet_group)
        self.planet_key = QtWidgets.QLineEdit()
        self.planet_key.setEchoMode(QtWidgets.QLineEdit.Password)
        self.planet_key.setText(self.config.planetscope_key)
        planet_layout.addRow("API Key", self.planet_key)
        layout.addWidget(planet_group)

        download_group = QtWidgets.QGroupBox("Download Queue")
        download_layout = QtWidgets.QVBoxLayout(download_group)
        self.download_path = QtWidgets.QLineEdit(self.config.download_dir)
        btn_browse = QtWidgets.QPushButton("Choose Folder")
        btn_browse.clicked.connect(self._choose_download_dir)
        path_row = QtWidgets.QHBoxLayout()
        path_row.addWidget(self.download_path)
        path_row.addWidget(btn_browse)
        download_layout.addLayout(path_row)

        self.download_list = QtWidgets.QListWidget()
        download_layout.addWidget(self.download_list)
        queue_row = QtWidgets.QHBoxLayout()
        self.btn_add_queue = QtWidgets.QPushButton("Add Placeholder Download")
        self.btn_add_queue.clicked.connect(self._enqueue_placeholder)
        self.btn_start_download = QtWidgets.QPushButton("Start Download")
        self.btn_start_download.clicked.connect(self._start_downloads)
        queue_row.addWidget(self.btn_add_queue)
        queue_row.addWidget(self.btn_start_download)
        download_layout.addLayout(queue_row)
        layout.addWidget(download_group)

        browse_group = QtWidgets.QGroupBox("Dataset Browser (Offline Stub)")
        browse_layout = QtWidgets.QVBoxLayout(browse_group)
        search_row = QtWidgets.QHBoxLayout()
        self.search_query = QtWidgets.QLineEdit()
        btn_search = QtWidgets.QPushButton("Search")
        btn_search.clicked.connect(self._search_datasets)
        search_row.addWidget(self.search_query)
        search_row.addWidget(btn_search)
        browse_layout.addLayout(search_row)
        self.dataset_list = QtWidgets.QListWidget()
        browse_layout.addWidget(self.dataset_list)
        btn_add_dataset = QtWidgets.QPushButton("Add Selected to Queue")
        btn_add_dataset.clicked.connect(self._add_selected_dataset)
        browse_layout.addWidget(btn_add_dataset)
        layout.addWidget(browse_group)

        self.btn_save_keys = QtWidgets.QPushButton("Save API Settings")
        self.btn_save_keys.clicked.connect(self._save_api_settings)
        layout.addWidget(self.btn_save_keys)

        info = QtWidgets.QLabel(
            "Network downloads are disabled in this build. "
            "Save API keys now to enable future online connectors."
        )
        info.setWordWrap(True)
        layout.addWidget(info)

        layout.addStretch(1)
        self.inspector.addTab(widget, "Connectors")

    def _update_temperature_label(self, value: int) -> None:
        temp = value / 10.0
        self.label_temp.setText(f"Temperature: {temp:.1f}")

    def _on_sensor_changed(self, sensor: str) -> None:
        self.current_sensor = sensor
        self._update_band_catalog()
        self._refresh_indices()

    def _update_band_catalog(self) -> None:
        self.band_catalog.clear()
        bands = SENSOR_BANDS.get(self.current_sensor, [])
        if bands:
            for band in bands:
                self.band_catalog.addItem(band)
        else:
            self.band_catalog.addItem("No preset bands for this sensor.")

    def _update_band_catalog_from_raster(self, raster: RasterData) -> None:
        self.band_catalog.clear()
        for band in raster.band_names:
            self.band_catalog.addItem(band)

    def _refresh_indices(self) -> None:
        self.index_combo.blockSignals(True)
        self.index_combo.clear()
        custom = [
            idx
            for idx in self.custom_indices
            if self.current_sensor in idx.sensors or "Custom" in idx.sensors
        ]
        indices = index_engine.get_indices(self.current_sensor) + custom
        for idx in indices:
            self.index_combo.addItem(idx.name)
        self.index_combo.blockSignals(False)
        if self.index_combo.count() == 0:
            self.index_desc.setText("No indices available for this sensor.")
        else:
            self.index_combo.setCurrentIndex(0)
            self._on_index_changed(self.index_combo.currentText())

    def _on_index_changed(self, index_name: str) -> None:
        custom = [
            idx
            for idx in self.custom_indices
            if self.current_sensor in idx.sensors or "Custom" in idx.sensors
        ]
        indices = index_engine.get_indices(self.current_sensor) + custom
        selected = next((idx for idx in indices if idx.name == index_name), None)
        if not selected:
            self.index_desc.setText("Select an index to see details.")
            return
        self.index_desc.setText(f"{selected.name}: {selected.description} | Formula: {selected.formula}")
        self._build_index_mapping(selected)

    def _load_saved_custom_indices(self) -> None:
        if not self.config.custom_indices:
            return
        loaded: list[index_engine.IndexDefinition] = []
        for path in self.config.custom_indices:
            try:
                loaded.extend(index_engine.load_custom_indices(Path(path)))
            except Exception:
                continue
        if loaded:
            self.custom_indices.extend(loaded)
            self._refresh_indices()

    def _build_index_mapping(self, index_def: index_engine.IndexDefinition) -> None:
        while self.index_mapping_layout.rowCount():
            self.index_mapping_layout.removeRow(0)
        self.index_band_selectors.clear()

        band_names = []
        raster_layer = self._get_base_raster_layer()
        if raster_layer:
            band_names = raster_layer.data.band_names

        for alias in index_def.required:
            combo = QtWidgets.QComboBox()
            if band_names:
                combo.addItems(band_names)
                self._preselect_alias(combo, alias, band_names)
            else:
                combo.addItem("No raster loaded")
                combo.setEnabled(False)
            self.index_mapping_layout.addRow(alias, combo)
            self.index_band_selectors[alias] = combo

    def _preselect_alias(self, combo: QtWidgets.QComboBox, alias: str, band_names: list[str]) -> None:
        resolved = index_engine.resolve_alias(self.current_sensor, alias)
        for idx, name in enumerate(band_names):
            if resolved.lower() in name.lower():
                combo.setCurrentIndex(idx)
                return

    def _add_index_layer(self) -> None:
        raster_layer = self._get_base_raster_layer()
        if not raster_layer:
            self.statusBar().showMessage("Load a raster before adding an index layer.")
            return
        index_name = self.index_combo.currentText()
        custom = [
            idx
            for idx in self.custom_indices
            if self.current_sensor in idx.sensors or "Custom" in idx.sensors
        ]
        indices = index_engine.get_indices(self.current_sensor) + custom
        index_def = next((idx for idx in indices if idx.name == index_name), None)
        if not index_def:
            self.statusBar().showMessage("Index not found.")
            return
        band_map: dict[str, np.ndarray] = {}
        for alias, combo in self.index_band_selectors.items():
            band_name = combo.currentText()
            if band_name not in raster_layer.data.band_names:
                self.statusBar().showMessage(f"Band {band_name} not found.")
                return
            band_idx = raster_layer.data.band_names.index(band_name)
            band_map[alias] = raster_layer.data.array[band_idx]
        try:
            index_img = index_engine.compute_index(index_def.formula, band_map)
        except Exception as exc:
            self.statusBar().showMessage(f"Index calculation failed: {exc}")
            return
        self._add_layer(index_def.name, "index", index_img.astype("float32"))
        self._render_canvas()
        self.statusBar().showMessage(f"Index layer added: {index_def.name}")

    def _load_custom_indices(self) -> None:
        path, _ = QtWidgets.QFileDialog.getOpenFileName(
            self, "Load Index Definitions", "", "JSON/YAML (*.json *.yaml *.yml)"
        )
        if not path:
            return
        try:
            custom = index_engine.load_custom_indices(Path(path))
        except Exception as exc:
            self.statusBar().showMessage(f"Failed to load indices: {exc}")
            return
        self.custom_indices.extend(custom)
        self.config.custom_indices.append(path)
        save_config(self.config)
        self._refresh_indices()
        self.statusBar().showMessage(f"Loaded {len(custom)} custom indices.")

    def _save_api_settings(self) -> None:
        self.config.copernicus_key = self.cop_key.text().strip()
        self.config.copernicus_secret = self.cop_secret.text().strip()
        self.config.planetscope_key = self.planet_key.text().strip()
        self.config.download_dir = self.download_path.text().strip()
        save_config(self.config)
        self.statusBar().showMessage("API settings saved locally.")

    def _choose_download_dir(self) -> None:
        folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Choose Download Folder")
        if folder:
            self.download_path.setText(folder)

    def _enqueue_placeholder(self) -> None:
        item = QtWidgets.QListWidgetItem("Placeholder download (configure connectors to enable)")
        self.download_list.addItem(item)

    def _start_downloads(self) -> None:
        self.statusBar().showMessage("Downloads are disabled in this offline build.")

    def _search_datasets(self) -> None:
        query = self.search_query.text().strip()
        self.dataset_list.clear()
        if not query:
            self.dataset_list.addItem("Enter a search term to preview datasets.")
            return
        self.dataset_list.addItem(f"No online results (offline). Query: {query}")

    def _add_selected_dataset(self) -> None:
        selected = self.dataset_list.selectedItems()
        if not selected:
            return
        for item in selected:
            self.download_list.addItem(f"Queued: {item.text()}")

    def _export_npy(self) -> None:
        raster_layer = self._get_base_raster_layer()
        if not raster_layer:
            self.statusBar().showMessage("Load a raster before exporting to NPY.")
            return
        raster = raster_layer.data

        if not (self.check_all_bands.isChecked() or self.check_selected_bands.isChecked() or self.check_individual_bands.isChecked()):
            self.check_all_bands.setChecked(True)

        selected_indices: list[int] = []
        if self.check_selected_bands.isChecked() or self.check_individual_bands.isChecked():
            dialog = MultiSelectDialog("Select Bands", raster.band_names, self)
            if dialog.exec() != QtWidgets.QDialog.Accepted:
                return
            selected_names = dialog.selected_items()
            if not selected_names:
                self.statusBar().showMessage("No bands selected.")
                return
            selected_indices = [raster.band_names.index(name) for name in selected_names]
        else:
            selected_indices = list(range(len(raster.band_names)))

        outdir = QtWidgets.QFileDialog.getExistingDirectory(self, "Choose NPY Export Folder")
        if not outdir:
            return

        default_base = Path(raster.source).stem if raster.source else "dataset"
        base_name, ok = QtWidgets.QInputDialog.getText(self, "Base Name", "Base filename:", text=default_base)
        if not ok or not base_name:
            return

        paths = []
        if self.check_all_bands.isChecked():
            paths.append(export_all_bands(raster, Path(outdir), base_name))
        if self.check_selected_bands.isChecked():
            paths.append(export_selected_bands(raster, selected_indices, Path(outdir), base_name))
        if self.check_individual_bands.isChecked():
            paths.extend(export_individual_bands(raster, selected_indices, Path(outdir), base_name))

        if self.check_export_indices.isChecked():
            index_layers = [
                (layer.name, layer.data)
                for layer in self.layers
                if layer.kind == "index"
            ]
            paths.extend(export_indices(index_layers, Path(outdir), base_name))

        self.statusBar().showMessage(f"Exported {len(paths)} NPY file(s).")

    def _toggle_roi_mode(self, enabled: bool) -> None:
        self.canvas.set_roi_mode(enabled)
        self.statusBar().showMessage("ROI mode enabled" if enabled else "ROI mode disabled")

    def _auto_select_features(self) -> None:
        raster_layer = self._get_base_raster_layer()
        if not raster_layer:
            self.statusBar().showMessage("Load a raster before selecting features.")
            return
        bands = raster_layer.data.array
        indices = feature_selector.select_features(bands)
        self.feature_output.setText("Selected: " + ", ".join(str(i + 1) for i in indices))
        if len(indices) >= 3:
            self.band_r.setCurrentIndex(indices[0])
            self.band_g.setCurrentIndex(indices[1])
            self.band_b.setCurrentIndex(indices[2])
        elif indices:
            self.band_gray.setCurrentIndex(indices[0])
        self._render_canvas()

    def _set_display_mode(self, mode: str) -> None:
        self.display_mode = mode
        self._save_active_display_state()
        self._render_canvas()

    def _save_active_display_state(self) -> None:
        dataset = self._get_active_dataset()
        if not dataset or not dataset.raster:
            return
        dataset.display_mode = self.display_mode
        dataset.band_selection = {
            "r": self.band_r.currentIndex(),
            "g": self.band_g.currentIndex(),
            "b": self.band_b.currentIndex(),
            "gray": self.band_gray.currentIndex(),
        }

    def _get_base_raster_layer(self) -> Layer | None:
        for layer in self.layers:
            if layer.kind == "raster" and layer.visible:
                return layer
        return None

    def _render_canvas(self) -> None:
        raster_layer = self._get_base_raster_layer()
        if not raster_layer:
            return
        bands = raster_layer.data.array
        if self.display_mode == "rgb" and bands.shape[0] >= 3:
            rgb = prepare_rgb(
                bands,
                (self.band_r.currentIndex(), self.band_g.currentIndex(), self.band_b.currentIndex()),
            )
            base = rgb
        else:
            base = prepare_gray(bands, self.band_gray.currentIndex())
        overlays = []
        for layer in self.layers:
            if not layer.visible or layer.kind not in {"mask", "prob", "uncertainty", "index"}:
                continue
            if layer.kind == "mask":
                overlay_img = colorize_labels(layer.data)
                overlays.append((overlay_img, 0.45))
            elif layer.kind == "prob":
                overlay_img = colorize_probability(layer.data)
                overlays.append((overlay_img, 0.45))
            elif layer.kind == "uncertainty":
                overlay_img = colorize_probability(layer.data)
                overlays.append((overlay_img, 0.35))
            elif layer.kind == "index":
                overlay_img = colorize_index(layer.data)
                overlays.append((overlay_img, 0.45))

        if overlays:
            if base.ndim == 2:
                base = np.stack([base, base, base], axis=-1)
            composed = self._compose_layers(base, overlays)
        else:
            composed = base
        self.canvas.set_image(composed)
        self.central_stack.setCurrentWidget(self.canvas)
        self._save_active_display_state()

    def _compose_layers(self, base: np.ndarray, overlays: list[tuple[np.ndarray, float]]) -> np.ndarray:
        composed = base.astype("float32")
        for overlay, alpha in overlays:
            if overlay.shape != composed.shape:
                continue
            composed = composed * (1 - alpha) + overlay.astype("float32") * alpha
        return np.clip(composed, 0, 255).astype("uint8")

    def _refresh_layer_list(self) -> None:
        self.layer_list.blockSignals(True)
        self.layer_list.clear()
        for layer in self.layers:
            item = QtWidgets.QListWidgetItem(layer.name)
            item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsSelectable)
            item.setCheckState(QtCore.Qt.Checked if layer.visible else QtCore.Qt.Unchecked)
            item.setData(QtCore.Qt.UserRole, layer.id)
            self.layer_list.addItem(item)
        self.layer_list.blockSignals(False)

    def _on_layer_toggled(self, item: QtWidgets.QListWidgetItem) -> None:
        layer_id = item.data(QtCore.Qt.UserRole)
        for layer in self.layers:
            if layer.id == layer_id:
                layer.visible = item.checkState() == QtCore.Qt.Checked
        self._render_canvas()

    def _on_layer_selected(self) -> None:
        selected = self.layer_list.selectedItems()
        if not selected:
            return
        layer_id = selected[0].data(QtCore.Qt.UserRole)
        layer = next((layer for layer in self.layers if layer.id == layer_id), None)
        if not layer:
            return
        if layer.kind == "table":
            self.central_stack.setCurrentWidget(self.table_view)

    def _refresh_tabular_controls(self) -> None:
        dataset = self._get_active_dataset()
        if not dataset or dataset.kind != "table" or dataset.table is None:
            self.tabular_info.setText("No tabular dataset loaded")
            self.tabular_summary.setPlainText("")
            self.lat_combo.clear()
            self.lon_combo.clear()
            self.eo_raster_combo.clear()
            return

        df = dataset.table
        self.tabular_info.setText(f"Active table: {dataset.name} ({df.shape[0]} rows, {df.shape[1]} cols)")
        cols = list(df.columns)
        self.lat_combo.clear()
        self.lon_combo.clear()
        self.lat_combo.addItems(cols)
        self.lon_combo.addItems(cols)
        # heuristics to preselect lat/lon
        for idx, col in enumerate(cols):
            if col.lower() in {"lat", "latitude"}:
                self.lat_combo.setCurrentIndex(idx)
            if col.lower() in {"lon", "longitude", "lng"}:
                self.lon_combo.setCurrentIndex(idx)

        self.eo_raster_combo.clear()
        for ds in self.datasets.values():
            if ds.kind == "raster" and ds.raster is not None:
                self.eo_raster_combo.addItem(ds.name, ds.id)

    def _refresh_tabular_summary(self) -> None:
        dataset = self._get_active_dataset()
        if not dataset or dataset.kind != "table" or dataset.table is None:
            self.tabular_summary.setPlainText("Load a tabular dataset to see summary.")
            return

        df = dataset.table
        lines = []
        lines.append(f"Rows: {df.shape[0]}  Columns: {df.shape[1]}")

        missing = df.isna().sum().sort_values(ascending=False)
        if missing.any():
            lines.append("\nMissing values (top 10):")
            for col, count in missing.head(10).items():
                lines.append(f"- {col}: {int(count)}")
        else:
            lines.append("\nMissing values: none")

        lines.append("\nDtypes:")
        dtype_counts = df.dtypes.value_counts()
        for dtype, count in dtype_counts.items():
            lines.append(f"- {dtype}: {int(count)} columns")

        numeric = df.select_dtypes(include="number")
        if not numeric.empty:
            lines.append("\nNumeric summary:")
            lines.append(numeric.describe().to_string())

        categorical = df.select_dtypes(exclude="number")
        if not categorical.empty:
            lines.append("\nCategorical snapshots:")
            cardinalities = {col: categorical[col].nunique(dropna=True) for col in categorical.columns}
            for col in sorted(cardinalities, key=cardinalities.get)[:5]:
                lines.append(f"\n{col} (top 5):")
                vc = categorical[col].value_counts(dropna=True).head(5)
                for k, v in vc.items():
                    lines.append(f"  - {k}: {int(v)}")

        self.tabular_summary.setPlainText("\n".join(lines))

    def _sample_raster_to_table(self) -> None:
        dataset = self._get_active_dataset()
        if not dataset or dataset.kind != "table" or dataset.table is None:
            self.statusBar().showMessage("Load a tabular dataset first.")
            return
        raster_id = self.eo_raster_combo.currentData()
        if not raster_id or raster_id not in self.datasets:
            self.statusBar().showMessage("Select a raster dataset to sample.")
            return
        raster_ds = self.datasets[raster_id]
        if raster_ds.raster is None or raster_ds.raster.transform is None:
            self.statusBar().showMessage("Selected raster has no georeferencing.")
            return

        lat_col = self.lat_combo.currentText()
        lon_col = self.lon_combo.currentText()
        if lat_col not in dataset.table.columns or lon_col not in dataset.table.columns:
            self.statusBar().showMessage("Latitude/longitude columns not found.")
            return

        band_names = raster_ds.raster.band_names
        band_dialog = MultiSelectDialog("Select Raster Bands", band_names, self)
        if band_dialog.exec() != QtWidgets.QDialog.Accepted:
            return
        selected = band_dialog.selected_items()
        if not selected:
            self.statusBar().showMessage("No bands selected.")
            return

        try:
            import rasterio
            from rasterio.transform import rowcol
        except Exception:
            self.statusBar().showMessage("Raster sampling requires rasterio.")
            return

        df = dataset.table.copy()
        xs = df[lon_col].to_numpy()
        ys = df[lat_col].to_numpy()

        rows, cols = rowcol(raster_ds.raster.transform, xs, ys)
        rows = np.asarray(rows)
        cols = np.asarray(cols)
        h, w = raster_ds.raster.array.shape[1], raster_ds.raster.array.shape[2]
        valid = (rows >= 0) & (rows < h) & (cols >= 0) & (cols < w)

        for band_name in selected:
            band_idx = band_names.index(band_name)
            values = np.full(len(df), np.nan, dtype="float32")
            values[valid] = raster_ds.raster.array[band_idx, rows[valid], cols[valid]]
            df[f"{raster_ds.name}_{band_name}"] = values

        dataset.table = df
        table_layer = next((layer for layer in dataset.layers if layer.kind == "table"), None)
        if table_layer:
            table_layer.data = df
        model_view = self.table_view.model()
        if isinstance(model_view, PandasTableModel):
            model_view.update(df)
        self.statusBar().showMessage(f"Added {len(selected)} raster band column(s) to table.")
        self._refresh_tabular_summary()

    def _on_roi_created(self, rect: QtCore.QRectF) -> None:
        color = QtGui.QColor("#facc15")
        pen = QtGui.QPen(color, 2)
        item = self.canvas.scene().addRect(rect, pen)
        roi = ROI(id=str(uuid.uuid4()), rect=rect, label="Unlabeled", color=color, item=item)
        self.rois.append(roi)
        list_item = QtWidgets.QListWidgetItem(f"{roi.label} ({int(rect.width())}x{int(rect.height())})")
        list_item.setData(QtCore.Qt.UserRole, roi.id)
        self.roi_list.addItem(list_item)

    def _set_roi_label(self) -> None:
        selected = self.roi_list.selectedItems()
        if not selected:
            return
        item = selected[0]
        roi_id = item.data(QtCore.Qt.UserRole)
        roi = next((r for r in self.rois if r.id == roi_id), None)
        if not roi:
            return
        text, ok = QtWidgets.QInputDialog.getText(self, "ROI Label", "Label name:")
        if ok and text:
            roi.label = text.strip()
            item.setText(f"{roi.label} ({int(roi.rect.width())}x{int(roi.rect.height())})")

    def _remove_roi(self) -> None:
        selected = self.roi_list.selectedItems()
        if not selected:
            return
        item = selected[0]
        roi_id = item.data(QtCore.Qt.UserRole)
        roi = next((r for r in self.rois if r.id == roi_id), None)
        if roi:
            self.canvas.scene().removeItem(roi.item)
            self.rois.remove(roi)
        row = self.roi_list.row(item)
        self.roi_list.takeItem(row)

    def _apply_roi_labels(self) -> None:
        if self.segmentation_result is None:
            self.statusBar().showMessage("Run segmentation first.")
            return
        labels = self.segmentation_result.labels
        mapping = {}
        for roi in self.rois:
            x0 = int(roi.rect.x())
            y0 = int(roi.rect.y())
            x1 = int(roi.rect.x() + roi.rect.width())
            y1 = int(roi.rect.y() + roi.rect.height())
            patch = labels[y0:y1, x0:x1]
            patch = patch[np.isfinite(patch)]
            if patch.size == 0:
                continue
            values, counts = np.unique(patch.astype(int), return_counts=True)
            if values.size == 0:
                continue
            cluster = int(values[np.argmax(counts)])
            mapping[cluster] = roi.label
        if not mapping:
            self.statusBar().showMessage("No ROI labels applied.")
            return
        self._populate_cluster_table(self.segmentation_result.model.n_components, mapping)
        self.statusBar().showMessage("Applied ROI labels to clusters.")

    def _populate_cluster_table(self, n_components: int, mapping: dict[int, str] | None = None) -> None:
        self.cluster_table.setRowCount(n_components)
        for idx in range(n_components):
            id_item = QtWidgets.QTableWidgetItem(str(idx))
            id_item.setFlags(id_item.flags() & ~QtCore.Qt.ItemIsEditable)
            label_item = QtWidgets.QTableWidgetItem(mapping.get(idx, f"Class {idx}") if mapping else f"Class {idx}")
            self.cluster_table.setItem(idx, 0, id_item)
            self.cluster_table.setItem(idx, 1, label_item)

    def run_segmentation(self) -> None:
        raster_layer = self._get_base_raster_layer()
        if not raster_layer:
            table_layer = next((layer for layer in self.layers if layer.kind == "table"), None)
            if table_layer is None:
                self.statusBar().showMessage("Load a raster or tabular dataset first.")
                return
            self._run_tabular_segmentation(table_layer)
            return

        n_components = self.spin_components.value()
        sample_size = self.spin_sample.value()
        covariance = self.combo_cov.currentText()
        engine = "safe" if self.combo_engine.currentText().startswith("Safe") else "sklearn"
        temperature = self.slider_temp.value() / 10.0
        return_prob = self.check_prob_maps.isChecked()

        self.statusBar().showMessage("Running GMM segmentation...")
        QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
        try:
            result = gmm_segment(
                raster_layer.data.array,
                n_components=n_components,
                sample_size=sample_size,
                covariance_type=covariance,
                temperature=temperature,
                return_probabilities=return_prob,
                engine=engine,
            )
        except Exception as exc:
            QtWidgets.QApplication.restoreOverrideCursor()
            self.statusBar().showMessage(f"Segmentation failed: {exc}")
            return
        QtWidgets.QApplication.restoreOverrideCursor()

        self.segmentation_result = result
        dataset = self._get_active_dataset()
        if dataset:
            dataset.segmentation_result = result
        self._populate_cluster_table(n_components)

        self._add_layer("Segmentation (labels)", "mask", result.labels)
        self._add_layer("Uncertainty", "uncertainty", result.uncertainty)
        if result.probabilities is not None:
            confidence = np.nanmax(result.probabilities, axis=0)
            self._add_layer("Confidence", "prob", confidence)
            for idx in range(result.probabilities.shape[0]):
                self._add_layer(f"Probability P{idx}", "prob", result.probabilities[idx])

        run_dir = create_run_folder(Path("runs"))
        config = {
            "components": n_components,
            "sample_size": sample_size,
            "covariance": covariance,
            "temperature": temperature,
            "source": raster_layer.data.source,
        }
        save_run_config(run_dir, config)
        save_segmentation_outputs(
            run_dir,
            raster_layer.data,
            result.labels,
            result.uncertainty,
            result.probabilities,
        )
        self.statusBar().showMessage(f"Segmentation complete. Run saved to {run_dir}")
        self._render_canvas()

    def _run_tabular_segmentation(self, table_layer: Layer) -> None:
        n_components = self.spin_components.value()
        engine = "safe" if self.combo_engine.currentText().startswith("Safe") else "sklearn"
        self.statusBar().showMessage("Running GMM on tabular data...")
        QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
        try:
            labels, probabilities, model, columns = gmm_segment_table(table_layer.data, n_components, engine=engine)
        except Exception as exc:
            QtWidgets.QApplication.restoreOverrideCursor()
            self.statusBar().showMessage(f"Tabular segmentation failed: {exc}")
            return
        QtWidgets.QApplication.restoreOverrideCursor()

        df = table_layer.data.copy()
        df["cluster"] = labels
        df["confidence"] = probabilities.max(axis=1)
        table_layer.data = df
        model_view = self.table_view.model()
        if isinstance(model_view, PandasTableModel):
            model_view.update(df)
        self._populate_cluster_table(model.n_components)
        self.statusBar().showMessage(f"Tabular segmentation complete on columns: {', '.join(columns)}")
        dataset = self._get_active_dataset()
        if dataset and dataset.kind == "table":
            dataset.table = df
            dataset.segmentation_result = model

    def _add_layer(self, name: str, kind: str, data: Any) -> None:
        layer = Layer(id=str(uuid.uuid4()), name=name, kind=kind, data=data, meta={}, visible=True)
        self.layers.append(layer)
        self._refresh_layer_list()

    def _get_active_dataset(self) -> Dataset | None:
        if self.active_dataset_id and self.active_dataset_id in self.datasets:
            return self.datasets[self.active_dataset_id]
        return None

    def _set_active_dataset(self, dataset_id: str) -> None:
        if dataset_id not in self.datasets:
            return
        self._save_active_display_state()

        # remove previous ROI items from scene
        for roi in self.rois:
            if roi.item.scene() is not None:
                self.canvas.scene().removeItem(roi.item)

        dataset = self.datasets[dataset_id]
        self.active_dataset_id = dataset_id
        self.layers = dataset.layers
        self.current_raster = dataset.raster
        self.current_table = dataset.table
        self.rois = dataset.rois
        self.segmentation_result = dataset.segmentation_result
        self.current_sensor = dataset.sensor

        if self.dataset_combo.currentData() != dataset_id:
            idx = self.dataset_combo.findData(dataset_id)
            if idx >= 0:
                self.dataset_combo.setCurrentIndex(idx)

        if dataset.kind == "raster" and dataset.raster is not None:
            self.sensor_combo.setCurrentText(self.current_sensor)
            self._populate_band_controls(dataset.raster.band_names, dataset.band_selection)
            self.display_mode = dataset.display_mode
            self._refresh_indices()
            self._update_band_catalog_from_raster(dataset.raster)
            self._render_canvas()
            self.central_stack.setCurrentWidget(self.canvas)
            self.dataset_label.setText(
                f"Active: {dataset.name} ({dataset.raster.array.shape[1]}x{dataset.raster.array.shape[2]}, "
                f"{dataset.raster.array.shape[0]} bands) | Sensor: {self.current_sensor}"
            )
        elif dataset.kind == "table" and dataset.table is not None:
            self.sensor_combo.setCurrentText("Custom")
            self.dataset_label.setText(f"Active table: {dataset.name} ({dataset.table.shape[0]} rows)")
            model = PandasTableModel(dataset.table)
            self.table_view.setModel(model)
            self.central_stack.setCurrentWidget(self.table_view)

        self.roi_list.clear()
        for roi in self.rois:
            if roi.item.scene() is None:
                self.canvas.scene().addItem(roi.item)
            list_item = QtWidgets.QListWidgetItem(f"{roi.label} ({int(roi.rect.width())}x{int(roi.rect.height())})")
            list_item.setData(QtCore.Qt.UserRole, roi.id)
            self.roi_list.addItem(list_item)

        self._refresh_layer_list()
        self._refresh_tabular_controls()
        if dataset.kind == "table":
            self._refresh_tabular_summary()

    def _on_dataset_selected(self, index: int) -> None:
        dataset_id = self.dataset_combo.itemData(index)
        if dataset_id:
            self._set_active_dataset(dataset_id)

    def _close_active_dataset(self) -> None:
        dataset = self._get_active_dataset()
        if not dataset:
            return
        # remove ROI items
        for roi in dataset.rois:
            if roi.item.scene() is not None:
                self.canvas.scene().removeItem(roi.item)

        if dataset.id in self.datasets:
            del self.datasets[dataset.id]
        idx = self.dataset_combo.findData(dataset.id)
        if idx >= 0:
            self.dataset_combo.removeItem(idx)

        if self.dataset_combo.count() > 0:
            next_id = self.dataset_combo.itemData(0)
            if next_id:
                self._set_active_dataset(next_id)
                return

        self.active_dataset_id = None
        self.layers = []
        self.current_raster = None
        self.current_table = None
        self.rois = []
        self.segmentation_result = None
        self.layer_list.clear()
        for combo in (self.band_r, self.band_g, self.band_b, self.band_gray):
            combo.clear()
        self.band_catalog.clear()
        self.dataset_label.setText("No dataset loaded")
        self.tabular_summary.setPlainText("")
        self.tabular_info.setText("No tabular dataset loaded")

    def _clear_layers(self) -> None:
        self.layers.clear()
        self.layer_list.clear()
        self.segmentation_result = None
        self.current_raster = None
        for roi in self.rois:
            self.canvas.scene().removeItem(roi.item)
        self.rois.clear()
        self.roi_list.clear()

    def open_geotiff(self) -> None:
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open GeoTIFF", "", "GeoTIFF (*.tif *.tiff)")
        if not path:
            return
        raster = load_geotiff(Path(path))
        self._load_raster(raster, Path(path).name)

    def open_netcdf(self) -> None:
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open NetCDF", "", "NetCDF (*.nc)")
        if not path:
            return
        raster = load_netcdf(Path(path))
        self._load_raster(raster, Path(path).name)

    def open_envi(self) -> None:
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open ENVI", "", "ENVI HDR (*.hdr)")
        if not path:
            return
        raster = load_envi(Path(path))
        self._load_raster(raster, Path(path).name)

    def open_npy(self) -> None:
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open NPY", "", "NumPy (*.npy)")
        if not path:
            return
        try:
            raster = load_npy(Path(path))
        except Exception as exc:
            self.statusBar().showMessage(f"Failed to load NPY: {exc}")
            return
        self._load_raster(raster, Path(path).name)

    def open_sentinel2(self) -> None:
        folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Open Sentinel-2 Folder")
        if not folder:
            return
        bands = scan_sentinel2_folder(Path(folder))
        if bands:
            band_paths = [bands[name] for name in sorted(bands.keys())]
            raster = load_sentinel2_bands(band_paths)
            self._load_raster(raster, Path(folder).name)
            self.statusBar().showMessage(f"Loaded {len(band_paths)} Sentinel-2 bands automatically.")
            return

        tiff_bands = scan_sentinel2_tiffs(Path(folder))
        if tiff_bands:
            raster = load_sentinel2_band_tiffs(tiff_bands)
            self._load_raster(raster, Path(folder).name)
            self.statusBar().showMessage(f"Loaded {len(tiff_bands)} Sentinel-2 TIFF bands automatically.")
            return

        jp2_files = list(Path(folder).rglob("*.jp2"))
        if not jp2_files:
            self.statusBar().showMessage("No JP2 bands found in folder.")
            return

        mapping = {path.stem: path for path in jp2_files}
        dialog = BandSelectionDialog(mapping, self)
        if dialog.exec() != QtWidgets.QDialog.Accepted:
            return
        selected = dialog.selected_bands()
        if not selected:
            self.statusBar().showMessage("No bands selected.")
            return
        band_paths = [mapping[name] for name in selected]
        raster = load_sentinel2_bands(band_paths)
        self._load_raster(raster, Path(folder).name)
        self.statusBar().showMessage(f"Loaded {len(band_paths)} bands (manual selection).")

    def open_tabular(self) -> None:
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open Tabular", "", "CSV/XLSX (*.csv *.xlsx *.xls)")
        if not path:
            return
        df = load_tabular(Path(path))
        self._load_table(df, Path(path).name)

    def open_any_file(self) -> None:
        path, _ = QtWidgets.QFileDialog.getOpenFileName(
            self,
            "Open Dataset",
            "",
            "All Supported (*.tif *.tiff *.nc *.hdr *.npy *.csv *.xlsx *.xls);;"
            "GeoTIFF (*.tif *.tiff);;NetCDF (*.nc);;ENVI HDR (*.hdr);;NPY (*.npy);;Tabular (*.csv *.xlsx *.xls)",
        )
        if not path:
            return
        suffix = Path(path).suffix.lower()
        if suffix in {".tif", ".tiff"}:
            raster = load_geotiff(Path(path))
            self._load_raster(raster, Path(path).name)
        elif suffix == ".nc":
            raster = load_netcdf(Path(path))
            self._load_raster(raster, Path(path).name)
        elif suffix == ".hdr":
            raster = load_envi(Path(path))
            self._load_raster(raster, Path(path).name)
        elif suffix == ".npy":
            raster = load_npy(Path(path))
            self._load_raster(raster, Path(path).name)
        elif suffix in {".csv", ".xlsx", ".xls"}:
            df = load_tabular(Path(path))
            self._load_table(df, Path(path).name)
        else:
            self.statusBar().showMessage("Unsupported file type.")

    def open_folder_auto(self) -> None:
        folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Open Folder (Auto)")
        if not folder:
            return
        folder_path = Path(folder)

        jp2_files = list(folder_path.rglob("*.jp2"))
        if jp2_files:
            bands = scan_sentinel2_folder(folder_path)
            if bands:
                band_paths = [bands[name] for name in sorted(bands.keys())]
                raster = load_sentinel2_bands(band_paths)
                self._load_raster(raster, folder_path.name)
                self.statusBar().showMessage(f"Loaded {len(band_paths)} Sentinel-2 bands automatically.")
                return
            # fallback: ask user to select JP2 files manually
            mapping = {path.stem: path for path in jp2_files}
            dialog = BandSelectionDialog(mapping, self)
            if dialog.exec() != QtWidgets.QDialog.Accepted:
                return
            selected = dialog.selected_bands()
            if not selected:
                self.statusBar().showMessage("No JP2 bands selected.")
                return
            band_paths = [mapping[name] for name in selected]
            raster = load_sentinel2_bands(band_paths)
            self._load_raster(raster, folder_path.name)
            self.statusBar().showMessage(f"Loaded {len(band_paths)} bands (manual selection).")
            return

        tiff_bands = scan_sentinel2_tiffs(folder_path)
        if tiff_bands:
            raster = load_sentinel2_band_tiffs(tiff_bands)
            self._load_raster(raster, folder_path.name)
            self.statusBar().showMessage(f"Loaded {len(tiff_bands)} Sentinel-2 TIFF bands automatically.")
            return

        npy_files = list(folder_path.rglob("*.npy"))
        if len(npy_files) == 1:
            raster = load_npy(npy_files[0])
            self._load_raster(raster, npy_files[0].name)
            return

        raster_files = list(folder_path.rglob("*.tif")) + list(folder_path.rglob("*.tiff"))
        if len(raster_files) == 1:
            raster = load_geotiff(raster_files[0])
            self._load_raster(raster, raster_files[0].name)
            return
        if len(raster_files) > 1:
            self.statusBar().showMessage("Multiple GeoTIFFs found. Please choose a file.")
            self.open_any_file()
            return

        self.statusBar().showMessage("No supported data found. Please choose a file manually.")
        self.open_any_file()

    def _load_raster(self, raster: RasterData, name: str) -> None:
        sensor = raster.sensor if raster.sensor in SENSORS else self.current_sensor
        dataset = Dataset(
            id=str(uuid.uuid4()),
            name=name,
            kind="raster",
            raster=raster,
            table=None,
            layers=[],
            rois=[],
            segmentation_result=None,
            sensor=sensor,
            display_mode="rgb",
            band_selection=None,
        )
        dataset.layers.append(Layer(id=str(uuid.uuid4()), name=name, kind="raster", data=raster, meta={}, visible=True))
        self.datasets[dataset.id] = dataset
        self.dataset_combo.addItem(f"{name} (raster)", dataset.id)
        self._set_active_dataset(dataset.id)
        reason = raster.profile.get("sensor_guess_reason") if isinstance(raster.profile, dict) else None
        if raster.sensor and reason:
            self.statusBar().showMessage(
                f"Auto-detected sensor {raster.sensor} ({reason}). Change it in Sensor Type if needed."
            )

    def _load_table(self, df, name: str) -> None:
        dataset = Dataset(
            id=str(uuid.uuid4()),
            name=name,
            kind="table",
            raster=None,
            table=df,
            layers=[],
            rois=[],
            segmentation_result=None,
            sensor="Custom",
            display_mode="rgb",
            band_selection=None,
        )
        dataset.layers.append(Layer(id=str(uuid.uuid4()), name=name, kind="table", data=df, meta={}, visible=True))
        self.datasets[dataset.id] = dataset
        self.dataset_combo.addItem(f"{name} (table)", dataset.id)
        self._set_active_dataset(dataset.id)

    def _populate_band_controls(self, band_names: list[str], selection: dict[str, int] | None = None) -> None:
        for combo in (self.band_r, self.band_g, self.band_b, self.band_gray):
            combo.blockSignals(True)
            combo.clear()
            combo.addItems(band_names)
            combo.blockSignals(False)
        if selection:
            self.band_r.setCurrentIndex(min(selection.get("r", 0), len(band_names) - 1))
            self.band_g.setCurrentIndex(min(selection.get("g", 1), len(band_names) - 1))
            self.band_b.setCurrentIndex(min(selection.get("b", 2), len(band_names) - 1))
            self.band_gray.setCurrentIndex(min(selection.get("gray", 0), len(band_names) - 1))
        else:
            if len(band_names) >= 3:
                self.band_r.setCurrentIndex(0)
                self.band_g.setCurrentIndex(1)
                self.band_b.setCurrentIndex(2)
                self.display_mode = "rgb"
            else:
                self.display_mode = "gray"
            self.band_gray.setCurrentIndex(0)

    def open_runs_folder(self) -> None:
        runs_path = Path("runs").resolve()
        runs_path.mkdir(exist_ok=True)
        QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(str(runs_path)))
