
"""
ncuhep.muography.data_processing_gui.gui

PyQt-based GUI for the YMS 4444 detector analysis, built on top of
ncuhep.muography.data_processing_gui.data_processing.
"""

from __future__ import annotations

import json
import logging
import os
import sys
from datetime import date, datetime
from pathlib import Path
from typing import List, Optional, Tuple

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
from PyQt5 import QtCore, QtGui, QtWidgets

from ncuhep.muography.classes import AnalysisConfig, MuTxtFormat, PlaneDetector
from ncuhep.muography.utils.tikhonov import tikhonov_smooth_neumann
from ncuhep.units import Counts, GeometricFactor, Time

from .data_processing import (
    FileFilterResult,
    HitmapResult,
    build_layer_geometry,
    compute_flux_from_tracks,
    compute_hitmaps_parallel,
    compute_layer_pixel_mapping,
    filter_files,
    parse_run_filename,
)

# Use a non-interactive backend for safety; Qt5Agg is still needed for FigureCanvas
matplotlib.use("Qt5Agg")

logger = logging.getLogger("4444_gui")


# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------


def _json_numpy_default(obj):
    """Helper for json.dumps to handle numpy scalars and arrays."""
    import numpy as np

    if isinstance(obj, (np.integer,)):
        return int(obj)
    if isinstance(obj, (np.floating,)):
        return float(obj)
    if isinstance(obj, (np.ndarray,)):
        return obj.tolist()
    raise TypeError(f"Object of type {type(obj)} is not JSON serializable")


class MplCanvas(FigureCanvas):
    def __init__(self, parent=None, width=6, height=5, dpi=100):
        fig = Figure(figsize=(width, height), dpi=dpi)
        self.axes = fig.add_subplot(111)
        super().__init__(fig)
        self.setParent(parent)


# ---------------------------------------------------------------------------
# Analysis worker (runs in a separate thread)
# ---------------------------------------------------------------------------


class AnalysisWorker(QtCore.QThread):
    """
    QThread wrapper around compute_hitmaps_parallel + flux computation.
    """

    progress = QtCore.pyqtSignal(int, int, int)  # percent, done, total
    finished = QtCore.pyqtSignal(
        list, list, list, object, object, object, float, str, str
    )  # hits, events, tracks, det, flux, unc, fov, unit_str, summary

    def __init__(
        self,
        data_dir: str,
        config_dir: str,
        filenames: List[str],
        recon_path: str,
        fov_deg: float = 13.0,
        max_workers: int = 0,
        parent=None,
    ):
        super().__init__(parent)
        self.data_dir = data_dir
        self.config_dir = config_dir
        self.filenames = filenames
        self.recon_path = recon_path
        self.max_workers = max_workers
        self.fov_deg = fov_deg

    def _progress_cb(self, done: int, total: int):
        percent = int(done * 100 / total) if total else 0
        self.progress.emit(percent, done, total)

    def run(self):
        try:
            result: HitmapResult = compute_hitmaps_parallel(
                data_folder=self.data_dir,
                config_folder=self.config_dir,
                filenames=self.filenames,
                max_workers=self.max_workers,
                progress_callback=self._progress_cb,
            )

            hits_layers = result.hits_per_layer
            events_layers = result.events_per_layer
            tracks_layers = result.tracks_per_layer
            det = result.detector

            flux_raw = None
            unc_raw = None
            unit_str = ""
            fov = self.fov_deg
            summary = result.summary

            if (
                result.tx_all is not None
                and result.ty_all is not None
                and result.tx_all.size > 0
                and result.total_live_time_s > 0.0
            ):
                try:
                    flux_raw, unc_raw, fov, unit_str, summary_flux = compute_flux_from_tracks(
                        result.tx_all,
                        result.ty_all,
                        result.total_live_time_s,
                        self.recon_path,
                        fov_deg=self.fov_deg,
                    )
                    summary = result.summary + " | " + summary_flux
                except Exception as exc:  # noqa: BLE001
                    logger.exception("Flux computation failed: %s", exc)
                    summary = result.summary + " | Flux: ERROR"

            else:
                summary = result.summary + " | Flux: no tracks or zero live time for selected runs."

            self.finished.emit(
                hits_layers, events_layers, tracks_layers, det, flux_raw, unc_raw, fov, unit_str, summary
            )

        except Exception as exc:  # noqa: BLE001
            logger.exception("AnalysisWorker.run exception: %s", exc)
            self.finished.emit([], [], [], None, None, None, 0.0, "", f"ERROR: {exc}")


# ---------------------------------------------------------------------------
# Main GUI window
# ---------------------------------------------------------------------------


class HitMapWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("4444 YMS Hit/Track/Flux Viewer")
        self.resize(1400, 900)

        self._current_det: Optional[PlaneDetector] = None
        self._hit_layers: Optional[List[np.ndarray]] = None
        self._event_layers: Optional[List[np.ndarray]] = None
        self._track_layers: Optional[List[np.ndarray]] = None

        self._flux_raw: Optional[np.ndarray] = None
        self._unc_raw: Optional[np.ndarray] = None
        self._fov_deg: float = 13.0
        self._flux_unit: str = "counts/m²/s/sr"
        self._flux_base: Optional[np.ndarray] = None
        self._unc_base: Optional[np.ndarray] = None

        self._worker: Optional[AnalysisWorker] = None

        self._setup_ui()

    # ------------------------ UI construction ------------------------ #

    def _setup_ui(self):
        central = QtWidgets.QWidget(self)
        self.setCentralWidget(central)

        main_layout = QtWidgets.QVBoxLayout(central)

        tabs = QtWidgets.QTabWidget()
        main_layout.addWidget(tabs)

        # Tab 1: Hit / Track maps
        self._tab_hits = QtWidgets.QWidget()
        tabs.addTab(self._tab_hits, "Hits / Tracks")

        # Tab 2: Flux
        self._tab_flux = QtWidgets.QWidget()
        tabs.addTab(self._tab_flux, "Flux")

        # Tab 3: Config tools
        self._tab_config = QtWidgets.QWidget()
        tabs.addTab(self._tab_config, "Config")

        self._build_hits_tab()
        self._build_flux_tab()
        self._build_config_tab()

    # ------------------------ Hits tab ------------------------ #

    def _build_hits_tab(self):
        layout = QtWidgets.QVBoxLayout(self._tab_hits)

        # Top control row
        ctrl_layout = QtWidgets.QHBoxLayout()
        layout.addLayout(ctrl_layout)

        # Config dir
        self.config_dir_edit = QtWidgets.QLineEdit()
        self.config_dir_edit.setPlaceholderText("config4444-2")
        btn_cfg = QtWidgets.QPushButton("Load Config")
        btn_cfg.clicked.connect(self.on_load_config_clicked)

        ctrl_layout.addWidget(QtWidgets.QLabel("Config dir:"))
        ctrl_layout.addWidget(self.config_dir_edit)
        ctrl_layout.addWidget(btn_cfg)

        # Data dir
        self.data_dir_edit = QtWidgets.QLineEdit()
        self.data_dir_edit.setPlaceholderText("D:/4444/YMS")
        btn_data = QtWidgets.QPushButton("Browse Data")
        btn_data.clicked.connect(self.on_browse_data_clicked)

        ctrl_layout.addSpacing(20)
        ctrl_layout.addWidget(QtWidgets.QLabel("Data dir:"))
        ctrl_layout.addWidget(self.data_dir_edit)
        ctrl_layout.addWidget(btn_data)

        ctrl_layout.addStretch()

        # Filter controls
        filter_row = QtWidgets.QHBoxLayout()
        layout.addLayout(filter_row)

        self.filter_mode_combo = QtWidgets.QComboBox()
        self.filter_mode_combo.addItems(["By Date", "By Run"])
        self.filter_mode_combo.currentIndexChanged.connect(self.on_filter_mode_changed)

        # Date widgets
        self.date_from = QtWidgets.QDateEdit()
        self.date_to = QtWidgets.QDateEdit()
        self.date_from.setCalendarPopup(True)
        self.date_to.setCalendarPopup(True)
        today = QtCore.QDate.currentDate()
        self.date_from.setDate(today)
        self.date_to.setDate(today)

        # Run widgets
        self.run_min_spin = QtWidgets.QSpinBox()
        self.run_max_spin = QtWidgets.QSpinBox()
        self.run_min_spin.setRange(0, 999999)
        self.run_max_spin.setRange(0, 999999)
        self.run_min_spin.setValue(0)
        self.run_max_spin.setValue(999999)

        self._update_filter_widgets()

        filter_row.addWidget(QtWidgets.QLabel("Filter mode:"))
        filter_row.addWidget(self.filter_mode_combo)
        filter_row.addSpacing(20)
        filter_row.addWidget(QtWidgets.QLabel("From:"))
        filter_row.addWidget(self.date_from)
        filter_row.addWidget(QtWidgets.QLabel("To:"))
        filter_row.addWidget(self.date_to)
        filter_row.addWidget(QtWidgets.QLabel("Run min:"))
        filter_row.addWidget(self.run_min_spin)
        filter_row.addWidget(QtWidgets.QLabel("Run max:"))
        filter_row.addWidget(self.run_max_spin)

        filter_row.addStretch()

        # Recon npz path + run button
        recon_row = QtWidgets.QHBoxLayout()
        layout.addLayout(recon_row)

        self.recon_path_edit = QtWidgets.QLineEdit()
        self.recon_path_edit.setPlaceholderText("4444.npz")
        btn_recon = QtWidgets.QPushButton("Browse Recon NPZ")
        btn_recon.clicked.connect(self.on_browse_recon_clicked)

        btn_run = QtWidgets.QPushButton("Run analysis")
        btn_run.clicked.connect(self.on_run_analysis_clicked)

        self.progress_bar = QtWidgets.QProgressBar()
        self.progress_bar.setMinimum(0)
        self.progress_bar.setMaximum(100)

        recon_row.addWidget(QtWidgets.QLabel("Recon NPZ:"))
        recon_row.addWidget(self.recon_path_edit)
        recon_row.addWidget(btn_recon)
        recon_row.addSpacing(20)
        recon_row.addWidget(btn_run)
        recon_row.addWidget(self.progress_bar)

        # Summary label
        self.summary_label = QtWidgets.QLabel("Ready.")
        layout.addWidget(self.summary_label)

        # Hit / track canvases
        plot_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
        layout.addWidget(plot_splitter, 1)

        left_widget = QtWidgets.QWidget()
        left_layout = QtWidgets.QVBoxLayout(left_widget)

        self.hit_canvas = MplCanvas(self, width=5, height=5, dpi=100)
        self.hit_toolbar = NavigationToolbar(self.hit_canvas, self)
        left_layout.addWidget(QtWidgets.QLabel("Hits (per layer)"))
        left_layout.addWidget(self.hit_canvas)
        left_layout.addWidget(self.hit_toolbar)

        right_widget = QtWidgets.QWidget()
        right_layout = QtWidgets.QVBoxLayout(right_widget)

        self.track_canvas = MplCanvas(self, width=5, height=5, dpi=100)
        self.track_toolbar = NavigationToolbar(self.track_canvas, self)
        right_layout.addWidget(QtWidgets.QLabel("Tracks (per layer)"))
        right_layout.addWidget(self.track_canvas)
        right_layout.addWidget(self.track_toolbar)

        plot_splitter.addWidget(left_widget)
        plot_splitter.addWidget(right_widget)
        plot_splitter.setStretchFactor(0, 1)
        plot_splitter.setStretchFactor(1, 1)

        # Bottom layer selection
        bottom_row = QtWidgets.QHBoxLayout()
        layout.addLayout(bottom_row)

        self.layer_combo = QtWidgets.QComboBox()
        self.layer_combo.currentIndexChanged.connect(self.on_layer_changed)
        bottom_row.addWidget(QtWidgets.QLabel("Layer:"))
        bottom_row.addWidget(self.layer_combo)

        bottom_row.addStretch()

    # ------------------------ Flux tab ------------------------ #

    def _build_flux_tab(self):
        layout = QtWidgets.QVBoxLayout(self._tab_flux)

        # Top controls
        ctrl = QtWidgets.QHBoxLayout()
        layout.addLayout(ctrl)

        self.lambda_spin = QtWidgets.QDoubleSpinBox()
        self.lambda_spin.setDecimals(4)
        self.lambda_spin.setRange(0.0, 10.0)  # allow lambda = 0
        self.lambda_spin.setSingleStep(0.01)
        self.lambda_spin.setValue(0.1)

        self.update_flux_button = QtWidgets.QPushButton("Update Smoothing")
        self.update_flux_button.clicked.connect(self.on_update_flux_clicked)

        ctrl.addWidget(QtWidgets.QLabel(r"Tikhonov $\lambda$:"))
        ctrl.addWidget(self.lambda_spin)
        ctrl.addWidget(self.update_flux_button)
        ctrl.addStretch()

        # Canvas
        self.flux_canvas = MplCanvas(self, width=6, height=5, dpi=100)
        self.flux_toolbar = NavigationToolbar(self.flux_canvas, self)
        layout.addWidget(self.flux_canvas)
        layout.addWidget(self.flux_toolbar)

    # ------------------------ Config tab ------------------------ #

    def _build_config_tab(self):
        layout = QtWidgets.QVBoxLayout(self._tab_config)

        # Top row: export / import
        top_row = QtWidgets.QHBoxLayout()
        layout.addLayout(top_row)

        self.config_dir_edit2 = QtWidgets.QLineEdit()
        self.config_dir_edit2.setPlaceholderText("config4444-2")
        btn_cfg_browse = QtWidgets.QPushButton("Browse Config Folder")
        btn_cfg_browse.clicked.connect(self.on_browse_config_dir_clicked)

        btn_generate = QtWidgets.QPushButton("Generate Default Config")
        btn_generate.clicked.connect(self.on_generate_config_clicked)

        top_row.addWidget(QtWidgets.QLabel("Config dir:"))
        top_row.addWidget(self.config_dir_edit2)
        top_row.addWidget(btn_cfg_browse)
        top_row.addWidget(btn_generate)
        top_row.addStretch()

        # Mapping preview
        mapping_group = QtWidgets.QGroupBox("Layer / Pixel Mapping Preview")
        layout.addWidget(mapping_group, 1)

        mg_layout = QtWidgets.QVBoxLayout(mapping_group)

        self.mapping_canvas = MplCanvas(self, width=5, height=5, dpi=100)
        self.mapping_toolbar = NavigationToolbar(self.mapping_canvas, self)
        mg_layout.addWidget(self.mapping_canvas)
        mg_layout.addWidget(self.mapping_toolbar)

        layout.addStretch()

    # ------------------------ Slots / callbacks ------------------------ #

    def on_load_config_clicked(self):
        cfg_dir = self.config_dir_edit.text().strip() or "config4444-2"
        det_path = Path(cfg_dir) / "detector_config.json"
        ana_path = Path(cfg_dir) / "analysis_config.json"
        mutxt_path = Path(cfg_dir) / "mutxt_config.json"

        try:
            det = PlaneDetector._import(det_path)
            ana = AnalysisConfig._import(ana_path)
            mutxt = MuTxtFormat._import(mutxt_path)
        except Exception as exc:  # noqa: BLE001
            QtWidgets.QMessageBox.critical(
                self,
                "Config error",
                f"Failed to load config from {cfg_dir}:\n{exc}",
            )
            logger.exception("Failed to load config from %s: %s", cfg_dir, exc)
            return

        self._current_det = det
        self.summary_label.setText(f"Config loaded from {cfg_dir}")
        logger.info("Config loaded from %s", cfg_dir)

        # Update mapping preview
        self._update_mapping_preview(det)

    def on_browse_data_clicked(self):
        d = QtWidgets.QFileDialog.getExistingDirectory(
            self, "Select data folder", self.data_dir_edit.text() or "."
        )
        if d:
            self.data_dir_edit.setText(d)

    def on_browse_recon_clicked(self):
        path, _ = QtWidgets.QFileDialog.getOpenFileName(
            self,
            "Select recon NPZ",
            self.recon_path_edit.text() or ".",
            "NPZ files (*.npz);;All files (*)",
        )
        if path:
            self.recon_path_edit.setText(path)

    def on_browse_config_dir_clicked(self):
        d = QtWidgets.QFileDialog.getExistingDirectory(
            self, "Select config folder", self.config_dir_edit2.text() or "."
        )
        if d:
            self.config_dir_edit2.setText(d)
            self.config_dir_edit.setText(d)

    def on_filter_mode_changed(self, idx: int):
        self._update_filter_widgets()

    def _update_filter_widgets(self):
        mode = self.filter_mode_combo.currentIndex()
        is_date = mode == 0
        self.date_from.setEnabled(is_date)
        self.date_to.setEnabled(is_date)
        self.run_min_spin.setEnabled(not is_date)
        self.run_max_spin.setEnabled(not is_date)

    def on_run_analysis_clicked(self):
        data_dir = self.data_dir_edit.text().strip()
        if not data_dir:
            QtWidgets.QMessageBox.warning(self, "No data", "Please select a data folder.")
            return

        cfg_dir = self.config_dir_edit.text().strip()
        if not cfg_dir:
            QtWidgets.QMessageBox.warning(self, "No config", "Please select a config folder.")
            return

        recon_path = self.recon_path_edit.text().strip()
        if not recon_path:
            QtWidgets.QMessageBox.warning(self, "No recon", "Please select a recon NPZ.")
            return

        # List files
        all_files = [
            f for f in os.listdir(data_dir) if f.endswith("_Mu.txt")
        ]
        logger.info("Run analysis clicked: found %d *_Mu.txt files", len(all_files))

        # Filter
        if self.filter_mode_combo.currentIndex() == 0:
            # By date
            d_from = self.date_from.date().toPyDate()
            d_to = self.date_to.date().toPyDate()
            ff: FileFilterResult = filter_files(
                all_files,
                mode="date",
                date_from=d_from,
                date_to=d_to,
            )
        else:
            # By run
            run_min = self.run_min_spin.value()
            run_max = self.run_max_spin.value()
            ff = filter_files(
                all_files,
                mode="run",
                run_min=run_min,
                run_max=run_max,
            )

        if not ff.selected:
            QtWidgets.QMessageBox.information(
                self,
                "No files",
                f"Filter result: 0 files selected.\n({ff.description})",
            )
            logger.info("Filter result: 0 files selected")
            return

        logger.info(
            "run_analysis: mode=%s, filter_desc=%s, selected_files=%d",
            "date" if self.filter_mode_combo.currentIndex() == 0 else "run",
            ff.description,
            len(ff.selected),
        )

        # Start worker thread
        self.progress_bar.setValue(0)
        self.summary_label.setText("Running analysis ...")

        self._worker = AnalysisWorker(
            data_dir=data_dir,
            config_dir=cfg_dir,
            filenames=ff.selected,
            recon_path=recon_path,
            fov_deg=self._fov_deg,
            max_workers=os.cpu_count() or 1,
            parent=self,
        )
        self._worker.progress.connect(self.on_analysis_progress)
        self._worker.finished.connect(self.on_analysis_finished)
        logger.info("Starting AnalysisWorker thread")
        self._worker.start()

    @QtCore.pyqtSlot(int, int, int)
    def on_analysis_progress(self, percent: int, done: int, total: int):
        self.progress_bar.setValue(percent)
        self.summary_label.setText(f"Processing files {done}/{total} ...")

    @QtCore.pyqtSlot(list, list, list, object, object, object, float, str, str)
    def on_analysis_finished(
        self,
        hits_layers,
        events_layers,
        tracks_layers,
        det,
        flux_raw,
        unc_raw,
        fov_deg,
        unit_str,
        summary,
    ):
        logger.info("on_analysis_finished called")
        self._worker = None

        if det is None:
            self.summary_label.setText(summary)
            QtWidgets.QMessageBox.warning(
                self, "Analysis error", f"Analysis failed:\n{summary}"
            )
            return

        self._current_det = det
        self._hit_layers = hits_layers
        self._event_layers = events_layers
        self._track_layers = tracks_layers
        self._fov_deg = fov_deg
        self._flux_unit = unit_str or "counts/m²/s/sr"
        self._flux_raw = flux_raw
        self._unc_raw = unc_raw
        self._flux_base = flux_raw
        self._unc_base = unc_raw

        self.summary_label.setText(summary)

        # Update layer combo
        self.layer_combo.blockSignals(True)
        self.layer_combo.clear()
        for i in range(len(hits_layers)):
            self.layer_combo.addItem(f"Layer {i}")
        self.layer_combo.blockSignals(False)
        if hits_layers:
            self.layer_combo.setCurrentIndex(0)
            self._update_hit_track_plots(0)

        # Update flux plot
        self._update_flux_plot()

    def on_layer_changed(self, idx: int):
        if self._hit_layers is None:
            return
        self._update_hit_track_plots(idx)

    def _update_hit_track_plots(self, layer_idx: int):
        if (
            self._hit_layers is None
            or self._track_layers is None
            or layer_idx < 0
            or layer_idx >= len(self._hit_layers)
        ):
            return

        hits = self._hit_layers[layer_idx]
        tracks = self._track_layers[layer_idx]

        # Hits
        ax = self.hit_canvas.axes
        ax.clear()
        im = ax.imshow(hits, cmap="viridis", origin="lower")
        ax.set_title(f"Hits - Layer {layer_idx}")
        self.hit_canvas.figure.colorbar(im, ax=ax)
        self.hit_canvas.draw()

        # Tracks
        ax2 = self.track_canvas.axes
        ax2.clear()
        im2 = ax2.imshow(tracks, cmap="viridis", origin="lower")
        ax2.set_title(f"Tracks - Layer {layer_idx}")
        self.track_canvas.figure.colorbar(im2, ax=ax2)
        self.track_canvas.draw()

    # ------------------------ Flux handling ------------------------ #

    def on_update_flux_clicked(self):
        if self._flux_base is None or self._unc_base is None:
            QtWidgets.QMessageBox.information(
                self, "No flux", "Run analysis first to compute flux."
            )
            return

        lam = float(self.lambda_spin.value())
        logger.info("Updating flux smoothing with lambda=%g", lam)

        hx = 0.001
        hy = 0.001

        flux_sm = self._flux_base.copy()
        unc_sm = self._unc_base.copy()

        if lam > 0.0:
            flux_sm = tikhonov_smooth_neumann(flux_sm, lam=lam, hx=hx, hy=hy)
            unc_sm = tikhonov_smooth_neumann(unc_sm, lam=lam, hx=hx, hy=hy)

        self._flux_raw = flux_sm
        self._unc_raw = unc_sm
        self._update_flux_plot()

    def _update_flux_plot(self):
        ax = self.flux_canvas.axes
        ax.clear()

        if self._flux_raw is None:
            ax.text(
                0.5,
                0.5,
                "No flux yet",
                transform=ax.transAxes,
                ha="center",
                va="center",
            )
            self.flux_canvas.draw()
            return

        fov = self._fov_deg
        im = ax.imshow(
            self._flux_raw.T,
            extent=[-fov, fov, -fov, fov],
            origin="lower",
        )
        ax.set_xlabel("Theta X (deg)")
        ax.set_ylabel("Theta Y (deg)")
        ax.set_title(f"Flux ({self._flux_unit})")

        self.flux_canvas.figure.colorbar(im, ax=ax)
        self.flux_canvas.draw()

    # ------------------------ Config generation / mapping preview ------------------------ #

    def on_generate_config_clicked(self):
        """
        Generate default config4444-2 style JSON files into selected folder.
        """
        cfg_dir = self.config_dir_edit2.text().strip() or "config4444-2"
        cfg_path = Path(cfg_dir)
        cfg_path.mkdir(parents=True, exist_ok=True)

        # Very basic default detector / mutxt / analysis, tailored to current hardware.
        det = PlaneDetector.default_4444()
        ana = AnalysisConfig.default_4444()
        mutxt = MuTxtFormat.default_4444()

        # Ensure numpy types are JSON-serialisable
        json_opts = dict(indent=2, sort_keys=True, default=_json_numpy_default)

        with open(cfg_path / "detector_config.json", "w", encoding="utf-8") as f:
            json.dump(det._export(), f, **json_opts)
        with open(cfg_path / "analysis_config.json", "w", encoding="utf-8") as f:
            json.dump(ana._export(), f, **json_opts)
        with open(cfg_path / "mutxt_config.json", "w", encoding="utf-8") as f:
            json.dump(mutxt._export(), f, **json_opts)

        self.summary_label.setText(f"Default config generated in {cfg_dir}")
        logger.info("Default config generated in %s", cfg_dir)

        self._current_det = det
        self._update_mapping_preview(det)

    def _update_mapping_preview(self, det: PlaneDetector):
        try:
            mapping = compute_layer_pixel_mapping(det)
        except Exception as exc:  # noqa: BLE001
            logger.exception("Failed to compute mapping preview: %s", exc)
            return

        ax = self.mapping_canvas.axes
        ax.clear()

        # Stack layers vertically for a simple preview
        stacked = np.vstack(mapping)
        im = ax.imshow(stacked, cmap="coolwarm", origin="lower")
        ax.set_title("Global UNIQUEID mapping")
        self.mapping_canvas.figure.colorbar(im, ax=ax)
        self.mapping_canvas.draw()


# ---------------------------------------------------------------------------
# Dark theme & logging setup
# ---------------------------------------------------------------------------


def apply_dark_theme(app: QtWidgets.QApplication):
    palette = QtGui.QPalette()
    palette.setColor(QtGui.QPalette.Window, QtGui.QColor(53, 53, 53))
    palette.setColor(QtGui.QPalette.WindowText, QtCore.Qt.white)
    palette.setColor(QtGui.QPalette.Base, QtGui.QColor(25, 25, 25))
    palette.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor(53, 53, 53))
    palette.setColor(QtGui.QPalette.ToolTipBase, QtCore.Qt.white)
    palette.setColor(QtGui.QPalette.ToolTipText, QtCore.Qt.white)
    palette.setColor(QtGui.QPalette.Text, QtCore.Qt.white)
    palette.setColor(QtGui.QPalette.Button, QtGui.QColor(53, 53, 53))
    palette.setColor(QtGui.QPalette.ButtonText, QtCore.Qt.white)
    palette.setColor(QtGui.QPalette.BrightText, QtCore.Qt.red)
    palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor(142, 45, 197).lighter())
    palette.setColor(QtGui.QPalette.HighlightedText, QtCore.Qt.black)
    app.setPalette(palette)


def init_logging():
    logger = logging.getLogger("4444_gui")
    logger.setLevel(logging.INFO)

    if logger.handlers:
        return logger

    formatter = logging.Formatter(
        "%(asctime)s [%(levelname)s] pid=%(process)d tid=%(threadName)s %(name)s: %(message)s"
    )

    # console
    ch = logging.StreamHandler(stream=sys.stdout)
    ch.setLevel(logging.INFO)
    ch.setFormatter(formatter)
    logger.addHandler(ch)

    # file
    fh = logging.FileHandler("4444_gui_debug.log", mode="a", encoding="utf-8")
    fh.setLevel(logging.INFO)
    fh.setFormatter(formatter)
    logger.addHandler(fh)

    return logger


def main():
    init_logging()
    logger.info("Application starting")

    app = QtWidgets.QApplication(sys.argv)
    apply_dark_theme(app)

    w = HitMapWindow()
    w.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()
