# SLAM_GUI.py
from __future__ import annotations

import json
import os
import sys
import signal
import time
from pathlib import Path

import numpy as np

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
    QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox,
    QFrame, QSplitter, QCheckBox, QGraphicsDropShadowEffect, QScrollArea
)
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal

import pyqtgraph.opengl as gl

from SLAM_engine import SlamEngineClient, load_slam_config


def apply_card_shadow(widget: QFrame):
    shadow = QGraphicsDropShadowEffect(widget)
    shadow.setBlurRadius(22)
    shadow.setOffset(0, 6)
    shadow.setColor(Qt.GlobalColor.black)
    widget.setGraphicsEffect(shadow)


def pill_button(bg: str, fg: str = "white") -> str:
    return f"""
    QPushButton {{
        background-color: {bg};
        color: {fg};
        font-weight: 800;
        border: 1px solid rgba(255,255,255,0.08);
        border-radius: 10px;
        padding: 10px 12px;
    }}
    QPushButton:hover {{ border: 1px solid rgba(255,255,255,0.18); }}
    QPushButton:disabled {{
        background-color: rgba(255,255,255,0.08);
        color: rgba(255,255,255,0.35);
        border: 1px solid rgba(255,255,255,0.05);
    }}
    """


def card_style() -> str:
    return """
    QFrame#Card {
        background-color: rgba(255,255,255,0.04);
        border: 1px solid rgba(255,255,255,0.06);
        border-radius: 14px;
    }
    QLabel#CardTitle { color: rgba(255,255,255,0.75); font-weight: 800; }
    QLineEdit {
        background-color: rgba(0,0,0,0.30);
        border: 1px solid rgba(255,255,255,0.10);
        border-radius: 10px;
        padding: 10px 10px;
        color: white;
        font-weight: 600;
    }
    QLineEdit:disabled {
        color: rgba(255,255,255,0.40);
        background-color: rgba(255,255,255,0.05);
        border: 1px solid rgba(255,255,255,0.05);
    }
    QCheckBox { color: white; font-weight: 700; spacing: 10px; }
    QCheckBox:disabled { color: rgba(255,255,255,0.40); }
    """


class SLAMGLViewWidget(gl.GLViewWidget):
    mapClicked = pyqtSignal(float, float, float)
    mapPickDrag = pyqtSignal(float, float, float, bool)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.pick_mode = False
        self.pick_plane_z = 0.0
        self._pick_dragging = False
        self.pick_points = None
        self.pick_screen_max_px = 140.0

    @staticmethod
    def _qmat_to_np_candidates(m) -> list[np.ndarray]:
        try:
            data = np.array(m.copyDataTo(), dtype=np.float64)
            if data.size != 16:
                return [np.eye(4, dtype=np.float64)]
            mats = [
                data.reshape((4, 4), order="F"),
                data.reshape((4, 4), order="C"),
            ]
            out: list[np.ndarray] = []
            for mm in mats:
                if np.isfinite(mm).all():
                    out.append(np.asarray(mm, dtype=np.float64))
            return out if out else [np.eye(4, dtype=np.float64)]
        except Exception:
            return [np.eye(4, dtype=np.float64)]

    def _screen_to_world_on_plane(self, sx: float, sy: float, plane_z: float = 0.0):
        w = max(2, int(self.width()))
        h = max(2, int(self.height()))
        x_ndc = (2.0 * float(sx) / float(w)) - 1.0
        y_ndc = 1.0 - (2.0 * float(sy) / float(h))

        try:
            cam = self.cameraPosition()
            ctr = self.opts["center"]
            cam_p = np.asarray([float(cam.x()), float(cam.y()), float(cam.z())], dtype=np.float64)
            ctr_p = np.asarray([float(ctr.x()), float(ctr.y()), float(ctr.z())], dtype=np.float64)
            fwd = ctr_p - cam_p
            nf = float(np.linalg.norm(fwd))
            if nf < 1e-9:
                raise ValueError("degenerate camera forward")
            fwd = fwd / nf
            up_world = np.asarray([0.0, 0.0, 1.0], dtype=np.float64)
            right = np.cross(fwd, up_world)
            nr = float(np.linalg.norm(right))
            if nr < 1e-6:
                up_world = np.asarray([0.0, 1.0, 0.0], dtype=np.float64)
                right = np.cross(fwd, up_world)
                nr = float(np.linalg.norm(right))
            if nr < 1e-9:
                raise ValueError("degenerate camera right")
            right = right / nr
            up = np.cross(right, fwd)
            nu = float(np.linalg.norm(up))
            if nu < 1e-9:
                raise ValueError("degenerate camera up")
            up = up / nu
            fov_rad = float(np.deg2rad(float(self.opts.get("fov", 60.0))))
            sx_scale = float(np.tan(0.5 * fov_rad))
            sy_scale = sx_scale * (float(h) / float(w))
            ray = fwd + (x_ndc * sx_scale * right) + (y_ndc * sy_scale * up)
            nray = float(np.linalg.norm(ray))
            if nray < 1e-9:
                raise ValueError("degenerate pick ray")
            ray = ray / nray
            if abs(float(ray[2])) < 1e-9:
                return None
            t = (float(plane_z) - float(cam_p[2])) / float(ray[2])
            if t < 0.0:
                return None
            pt = cam_p + (t * ray)
            if np.isfinite(pt).all():
                return np.asarray(pt, dtype=np.float64)
        except Exception:
            pass

        # Fallback: matrix unproject solver
        viewport = self.getViewport()
        region = viewport
        proj_cands = self._qmat_to_np_candidates(self.projectionMatrix(region, viewport))
        view_cands = self._qmat_to_np_candidates(self.viewMatrix())
        near = np.array([x_ndc, y_ndc, -1.0, 1.0], dtype=np.float64)
        far = np.array([x_ndc, y_ndc, 1.0, 1.0], dtype=np.float64)
        best_pt = None
        best_err = 1e18
        for proj in proj_cands:
            for view in view_cands:
                for m in (proj @ view, view @ proj):
                    try:
                        inv = np.linalg.inv(m)
                    except Exception:
                        continue
                    p0 = inv @ near
                    p1 = inv @ far
                    if abs(float(p0[3])) < 1e-9 or abs(float(p1[3])) < 1e-9:
                        continue
                    p0 = p0[:3] / p0[3]
                    p1 = p1[:3] / p1[3]
                    ray = p1 - p0
                    if abs(float(ray[2])) < 1e-9:
                        continue
                    t = (float(plane_z) - float(p0[2])) / float(ray[2])
                    if t < 0.0:
                        continue
                    pt = p0 + (t * ray)
                    if not np.isfinite(pt).all():
                        continue
                    clip = m @ np.array([float(pt[0]), float(pt[1]), float(pt[2]), 1.0], dtype=np.float64)
                    if abs(float(clip[3])) < 1e-9:
                        continue
                    ndc = clip[:3] / clip[3]
                    sx2 = ((float(ndc[0]) + 1.0) * 0.5) * float(w)
                    sy2 = ((1.0 - float(ndc[1])) * 0.5) * float(h)
                    err = (sx2 - float(sx)) ** 2 + (sy2 - float(sy)) ** 2
                    if err < best_err:
                        best_err = float(err)
                        best_pt = np.asarray(pt, dtype=np.float64)
        return best_pt

    def _screen_pick_from_points(self, sx: float, sy: float):
        pts = self.pick_points
        if pts is None:
            return None
        arr = np.asarray(pts, dtype=np.float64)
        if arr.ndim != 2 or arr.shape[1] != 3 or len(arr) == 0:
            return None
        w = max(2, int(self.width()))
        h = max(2, int(self.height()))
        try:
            cam = self.cameraPosition()
            ctr = self.opts["center"]
            cam_p = np.asarray([float(cam.x()), float(cam.y()), float(cam.z())], dtype=np.float64)
            ctr_p = np.asarray([float(ctr.x()), float(ctr.y()), float(ctr.z())], dtype=np.float64)
            fwd = ctr_p - cam_p
            nf = float(np.linalg.norm(fwd))
            if nf < 1e-9:
                return None
            fwd = fwd / nf
            up_world = np.asarray([0.0, 0.0, 1.0], dtype=np.float64)
            right = np.cross(fwd, up_world)
            nr = float(np.linalg.norm(right))
            if nr < 1e-6:
                up_world = np.asarray([0.0, 1.0, 0.0], dtype=np.float64)
                right = np.cross(fwd, up_world)
                nr = float(np.linalg.norm(right))
            if nr < 1e-9:
                return None
            right = right / nr
            up = np.cross(right, fwd)
            nu = float(np.linalg.norm(up))
            if nu < 1e-9:
                return None
            up = up / nu
            fov_rad = float(np.deg2rad(float(self.opts.get("fov", 60.0))))
            sx_scale = float(np.tan(0.5 * fov_rad))
            sy_scale = sx_scale * (float(h) / float(w))
            rel = arr - cam_p.reshape((1, 3))
            zc = rel @ fwd
            keep = zc > 1e-6
            if not np.any(keep):
                return None
            rel = rel[keep]
            zc = zc[keep]
            pts_k = arr[keep]
            xc = rel @ right
            yc = rel @ up
            x_ndc = xc / (zc * sx_scale)
            y_ndc = yc / (zc * sy_scale)
            sxv = ((x_ndc + 1.0) * 0.5) * float(w)
            syv = ((1.0 - y_ndc) * 0.5) * float(h)
            d2 = (sxv - float(sx)) ** 2 + (syv - float(sy)) ** 2
            idx = int(np.argmin(d2))
            if float(d2[idx]) > float(self.pick_screen_max_px) * float(self.pick_screen_max_px):
                return None
            p = pts_k[idx]
            if np.isfinite(p).all():
                return p.astype(np.float64)
            return None
        except Exception:
            return None

    def mousePressEvent(self, ev):
        lpos = ev.position() if hasattr(ev, "position") else ev.localPos()
        self.mousePos = lpos
        if self.pick_mode and ev.button() == Qt.MouseButton.LeftButton:
            pt = self._screen_pick_from_points(float(lpos.x()), float(lpos.y()))
            if pt is None:
                pt = self._screen_to_world_on_plane(float(lpos.x()), float(lpos.y()), float(self.pick_plane_z))
            if pt is not None:
                self._pick_dragging = False
                self.mapPickDrag.emit(float(pt[0]), float(pt[1]), float(pt[2]), True)
                self.mapClicked.emit(float(pt[0]), float(pt[1]), float(pt[2]))
                ev.accept()
                return
        super().mousePressEvent(ev)

    def mouseMoveEvent(self, ev):
        lpos = ev.position() if hasattr(ev, "position") else ev.localPos()
        if not hasattr(self, "mousePos"):
            self.mousePos = lpos
        diff = lpos - self.mousePos
        self.mousePos = lpos

        buttons = ev.buttons()
        mods = ev.modifiers()

        if self.pick_mode and self._pick_dragging and (buttons & Qt.MouseButton.LeftButton):
            pt = self._screen_to_world_on_plane(float(lpos.x()), float(lpos.y()), float(self.pick_plane_z))
            if pt is not None:
                self.mapPickDrag.emit(float(pt[0]), float(pt[1]), float(pt[2]), False)
            ev.accept()
            return

        # Keep default left-drag behavior.
        if buttons & Qt.MouseButton.LeftButton:
            if mods & Qt.KeyboardModifier.ControlModifier:
                self.pan(diff.x(), diff.y(), 0, relative="view")
            else:
                self.orbit(-diff.x(), diff.y())
            return

        # Add right-drag panning so touchpads/mice without middle-click can move map.
        if buttons & Qt.MouseButton.RightButton:
            if mods & Qt.KeyboardModifier.ControlModifier:
                self.pan(diff.x(), 0, diff.y(), relative="view-upright")
            else:
                self.pan(diff.x(), diff.y(), 0, relative="view-upright")
            return

        # Keep default middle-drag panning behavior.
        if buttons & Qt.MouseButton.MiddleButton:
            if mods & Qt.KeyboardModifier.ControlModifier:
                self.pan(diff.x(), 0, diff.y(), relative="view-upright")
            else:
                self.pan(diff.x(), diff.y(), 0, relative="view-upright")

    def mouseReleaseEvent(self, ev):
        lpos = ev.position() if hasattr(ev, "position") else ev.localPos()
        if self.pick_mode and ev.button() == Qt.MouseButton.LeftButton and self._pick_dragging:
            self._pick_dragging = False
            pt = self._screen_to_world_on_plane(float(lpos.x()), float(lpos.y()), float(self.pick_plane_z))
            if pt is not None:
                self.mapPickDrag.emit(float(pt[0]), float(pt[1]), float(pt[2]), True)
                self.mapClicked.emit(float(pt[0]), float(pt[1]), float(pt[2]))
            ev.accept()
            return
        super().mouseReleaseEvent(ev)


class RefLoaderThread(QThread):
    loaded = pyqtSignal(object, object, str)  # pts, colors, filename
    failed = pyqtSignal(str)

    def __init__(self, filename: str, display_voxel: float, color_mode: str = "GRAY"):
        super().__init__()
        self.filename = filename
        self.display_voxel = float(display_voxel)
        self.color_mode = str(color_mode).upper().strip()

    @staticmethod
    def _height_colors_fast(pts: np.ndarray, alpha: float = 0.40) -> np.ndarray:
        if pts is None or len(pts) == 0:
            return np.zeros((0, 4), dtype=np.float32)
        z = pts[:, 2]
        zmin = float(np.min(z))
        zmax = float(np.max(z))
        span = max(1e-6, zmax - zmin)
        norm = np.clip((z - zmin) / span, 0.0, 1.0).astype(np.float32)
        colors = np.zeros((len(pts), 4), dtype=np.float32)
        colors[:, 0] = norm
        colors[:, 1] = 1.0 - np.abs(norm - 0.5) * 2.0
        colors[:, 2] = 1.0 - norm
        colors[:, 3] = alpha
        return colors

    def run(self):
        try:
            import open3d as o3d
            pcd = o3d.io.read_point_cloud(self.filename)
            if self.display_voxel > 0:
                pcd = pcd.voxel_down_sample(self.display_voxel)
            pts = np.asarray(pcd.points).astype(np.float32)
            if len(pts) < 2:
                raise RuntimeError("Ref map is empty.")

            mode = self.color_mode if self.color_mode in ("GRAY", "HEIGHT", "ORIGINAL") else "GRAY"
            colors = np.zeros((len(pts), 4), dtype=np.float32)
            colors[:, 3] = 0.40
            if mode == "HEIGHT":
                colors = self._height_colors_fast(pts, alpha=0.40)
            elif mode == "ORIGINAL":
                src = np.asarray(pcd.colors)
                if src is not None and len(src) == len(pts) and len(src) > 0:
                    src_rgb = np.asarray(src, dtype=np.float32)
                    colors[:, :3] = np.clip(src_rgb[:, :3], 0.0, 1.0)
                else:
                    colors[:, :3] = 0.7
            else:
                colors[:, :3] = 0.7
            self.loaded.emit(pts, colors, self.filename)
        except Exception as e:
            self.failed.emit(str(e))


class UltimateDashboard(QMainWindow):
    def __init__(self):
        super().__init__()

        self.cfg = load_slam_config()
        title = self.cfg["app"]["title"]
        accent = self.cfg["gui"]["colors"]["accent"]
        maps_dir_cfg = str(self.cfg["app"]["maps_dir"])
        maps_dir_path = Path(maps_dir_cfg).expanduser()
        if not maps_dir_path.is_absolute():
            cfg_path = os.environ.get("SLAM_CONFIG", "").strip()
            base_dir = Path(cfg_path).expanduser().resolve().parent if cfg_path else Path(__file__).resolve().parent
            maps_dir_path = (base_dir / maps_dir_path).resolve()
        maps_dir = str(maps_dir_path)

        self.setWindowTitle(title)
        self.resize(1600, 900)

        Path(maps_dir).mkdir(parents=True, exist_ok=True)

        self._self_check_popup_shown = False
        self.client = SlamEngineClient()
        self.client.start_process()
        self.show_self_check_popup(self.client.get_self_check(), source="engine")

        self.save_armed = False
        self.ref_map_path = None
        self._ref_thread = None
        self.ref_color_mode = "GRAY"
        self.density_mode = "MEDIUM"
        self.min_stable_points = int(self.cfg["map"]["min_points_to_save"])
        self.loop_closure_enabled = bool(self.cfg.get("loop_closure", {}).get("enabled", False))
        self.loc_machine_enabled = bool(self.cfg.get("state_machine", {}).get("enabled", True))
        self.submap_mode_enabled = bool(self.cfg.get("submap_mapping", {}).get("enabled", True))
        self.autosave_enabled = bool(self.cfg.get("autosave", {}).get("enabled", False))
        self.autosave_interval_sec = float(self.cfg.get("autosave", {}).get("interval_sec", 90.0))
        self.nav_cfg = dict(self.cfg.get("navigation_export", {}))
        self.map_quality_cfg = dict(self.cfg.get("map_quality", {}))
        self._last_error_popup_text = ""
        self._last_error_popup_t = 0.0
        self._error_popup_cooldown_sec = 2.0
        self._worker_dead_notified = False
        self._last_saved_popup_text = ""
        self._last_saved_popup_t = 0.0
        self._saved_popup_cooldown_sec = 1.0
        self.current_stable_points = 0
        self.worker_mode = "IDLE"
        self.is_connected = False
        self.recording_enabled = False
        self.recording_frames = 0
        self.recording_dropped = 0
        self.workflow_profile = "BALANCED"
        self.workflow_selected = False
        self.last_replay_report = None
        self.replay_baseline_path = str(Path(maps_dir) / "SLAM_replay_baseline.json")
        self.approx_pick_armed = False
        self.approx_guess_z = float(self.cfg.get("localization", {}).get("approx_guess_default_z_m", 0.0))
        self.last_loc_confidence = 0.0
        self.last_loc_match_ratio = 0.0
        self.require_start_pick = True
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._pick_help_shown_for_ref = None
        self.ref_pick_points = None
        self.ref_pick_snap_radius_m = float(
            self.cfg.get("localization", {}).get("pick_snap_radius_m", 1.2)
        )
        self.ref_pick_max_points = int(
            self.cfg.get("localization", {}).get("pick_snap_max_points", 60000)
        )

        root = QSplitter(Qt.Orientation.Horizontal)
        self.setCentralWidget(root)

        # Sidebar
        sidebar = QFrame()
        sidebar.setMinimumWidth(320)
        sidebar.setMaximumWidth(760)
        sidebar.setStyleSheet("background-color: rgba(0,0,0,0.35);")
        side_layout = QVBoxLayout(sidebar)
        side_layout.setContentsMargins(18, 18, 18, 18)
        side_layout.setSpacing(14)

        side_scroll = QScrollArea()
        side_scroll.setWidgetResizable(True)
        side_scroll.setFrameShape(QFrame.Shape.NoFrame)
        side_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        side_scroll.setWidget(sidebar)
        side_scroll.setMinimumWidth(330)
        side_scroll.setMaximumWidth(780)

        lbl = QLabel("SLAM COMMANDER")
        lbl.setStyleSheet(f"color:{accent}; font-size:20px; font-weight:900;")
        side_layout.addWidget(lbl)

        # Network card
        net_card = QFrame(); net_card.setObjectName("Card"); net_card.setStyleSheet(card_style()); apply_card_shadow(net_card)
        net_layout = QVBoxLayout(net_card); net_layout.setContentsMargins(14,14,14,14); net_layout.setSpacing(10)
        net_title = QLabel("NETWORK SETTINGS"); net_title.setObjectName("CardTitle"); net_layout.addWidget(net_title)

        self.txt_iface = QLineEdit(self.cfg["network"]["default_interface"])
        self.txt_ip = QLineEdit(self.cfg["network"]["default_host_ip"])
        net_layout.addWidget(self.txt_iface); net_layout.addWidget(self.txt_ip)

        row1 = QHBoxLayout()
        self.btn_connect = QPushButton("🔌  CONNECT"); self.btn_connect.setStyleSheet(pill_button("#1677ff")); self.btn_connect.clicked.connect(self.on_connect)
        self.btn_connect.setEnabled(False)
        self.btn_connect.setToolTip("Choose a workflow first.")
        self.btn_start = QPushButton("▶  START"); self.btn_start.setStyleSheet(pill_button("#21a453")); self.btn_start.clicked.connect(self.on_start)
        self.btn_localize_only = QPushButton("🎯  LOCALIZE-ONLY")
        self.btn_localize_only.setStyleSheet(pill_button("#7a4cff"))
        self.btn_localize_only.clicked.connect(self.on_start_localize_only)
        row1.addWidget(self.btn_connect); row1.addWidget(self.btn_start); row1.addWidget(self.btn_localize_only); net_layout.addLayout(row1)

        row2 = QHBoxLayout()
        self.btn_pause = QPushButton("⏸  PAUSE"); self.btn_pause.setStyleSheet(pill_button("#c78a12", fg="black")); self.btn_pause.clicked.connect(self.on_pause)
        self.btn_stop = QPushButton("⛔  STOP"); self.btn_stop.setStyleSheet(pill_button("#d0302f")); self.btn_stop.clicked.connect(self.on_stop)
        row2.addWidget(self.btn_pause); row2.addWidget(self.btn_stop); net_layout.addLayout(row2)

        self.btn_reset = QPushButton("🧹  RESET (NO SAVE)"); self.btn_reset.setStyleSheet(pill_button("#3a3a3a")); self.btn_reset.clicked.connect(self.on_reset)
        net_layout.addWidget(self.btn_reset)

        side_layout.addWidget(net_card)

        # Workflow card
        wf_card = QFrame(); wf_card.setObjectName("Card"); wf_card.setStyleSheet(card_style()); apply_card_shadow(wf_card)
        wf_layout = QVBoxLayout(wf_card); wf_layout.setContentsMargins(14,14,14,14); wf_layout.setSpacing(10)
        wf_title = QLabel("WORKFLOW (BEFORE RUN)"); wf_title.setObjectName("CardTitle"); wf_layout.addWidget(wf_title)

        self.btn_wf_map_new = QPushButton("🧭 MAP NEW PLACE")
        self.btn_wf_map_new.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_wf_map_new.clicked.connect(self.on_workflow_map_new)
        wf_layout.addWidget(self.btn_wf_map_new)

        self.btn_wf_extend = QPushButton("📌 EXTEND SAVED MAP")
        self.btn_wf_extend.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_wf_extend.clicked.connect(self.on_workflow_extend_map)
        wf_layout.addWidget(self.btn_wf_extend)

        self.btn_wf_localize = QPushButton("📍 NAVIGATE IN MAPPED PLACE")
        self.btn_wf_localize.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_wf_localize.clicked.connect(self.on_workflow_localize_existing)
        wf_layout.addWidget(self.btn_wf_localize)

        self.btn_wf_live_nav = QPushButton("🤖 LIVE NAV WITH MAP")
        self.btn_wf_live_nav.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_wf_live_nav.clicked.connect(self.on_workflow_live_nav)
        wf_layout.addWidget(self.btn_wf_live_nav)

        self.btn_wf_live_nav_nomap = QPushButton("🛰 LIVE NAV (NO MAP)")
        self.btn_wf_live_nav_nomap.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_wf_live_nav_nomap.clicked.connect(self.on_workflow_live_nav_nomap)
        wf_layout.addWidget(self.btn_wf_live_nav_nomap)

        self.btn_wf_quick = QPushButton("🧪 QUICK DEMO")
        self.btn_wf_quick.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_wf_quick.clicked.connect(self.on_workflow_quick_demo)
        wf_layout.addWidget(self.btn_wf_quick)

        self.lbl_workflow = QLabel("Workflow: NOT_SELECTED")
        self.lbl_workflow.setStyleSheet("color:white; font-family:monospace; font-weight:800;")
        wf_layout.addWidget(self.lbl_workflow)

        side_layout.addWidget(wf_card)

        # Map card
        map_card = QFrame(); map_card.setObjectName("Card"); map_card.setStyleSheet(card_style()); apply_card_shadow(map_card)
        map_layout = QVBoxLayout(map_card); map_layout.setContentsMargins(14,14,14,14); map_layout.setSpacing(10)
        map_title = QLabel("MAP CONTROLS"); map_title.setObjectName("CardTitle"); map_layout.addWidget(map_title)

        self.txt_name = QLineEdit("Work")
        map_layout.addWidget(self.txt_name)

        self.chk_save = QCheckBox("✅  SAVE ARMED (WILL SAVE ON STOP)")
        self.chk_save.stateChanged.connect(self.on_toggle_save)
        map_layout.addWidget(self.chk_save)

        self.btn_save_now = QPushButton("💾  SAVE NOW")
        self.btn_save_now.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_save_now.clicked.connect(self.on_save_now)
        map_layout.addWidget(self.btn_save_now)

        auto_title = QLabel("AUTOSAVE")
        auto_title.setObjectName("CardTitle")
        map_layout.addWidget(auto_title)

        auto_row = QHBoxLayout()
        self.chk_autosave = QCheckBox("ON")
        self.chk_autosave.setChecked(self.autosave_enabled)
        self.txt_autosave_sec = QLineEdit(str(int(self.autosave_interval_sec)))
        self.txt_autosave_sec.setMaximumWidth(90)
        self.btn_apply_autosave = QPushButton("APPLY")
        self.btn_apply_autosave.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_apply_autosave.clicked.connect(self.on_apply_autosave)
        auto_row.addWidget(self.chk_autosave)
        auto_row.addWidget(self.txt_autosave_sec)
        auto_row.addWidget(self.btn_apply_autosave)
        map_layout.addLayout(auto_row)

        rec_title = QLabel("RECORD / REPLAY")
        rec_title.setObjectName("CardTitle")
        map_layout.addWidget(rec_title)

        rec_row1 = QHBoxLayout()
        self.btn_record_start = QPushButton("⏺ START REC")
        self.btn_record_start.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_record_start.clicked.connect(self.on_record_start)
        self.btn_record_stop = QPushButton("⏹ STOP REC")
        self.btn_record_stop.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_record_stop.clicked.connect(self.on_record_stop)
        rec_row1.addWidget(self.btn_record_start)
        rec_row1.addWidget(self.btn_record_stop)
        map_layout.addLayout(rec_row1)

        rec_row2 = QHBoxLayout()
        self.btn_replay_run = QPushButton("▶ RUN REPLAY")
        self.btn_replay_run.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_replay_run.clicked.connect(self.on_replay_run)
        self.btn_replay_set_base = QPushButton("📌 SET BASELINE")
        self.btn_replay_set_base.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_replay_set_base.clicked.connect(self.on_replay_set_baseline)
        rec_row2.addWidget(self.btn_replay_run)
        rec_row2.addWidget(self.btn_replay_set_base)
        map_layout.addLayout(rec_row2)

        min_pts_title = QLabel("MIN STABLE POINTS")
        min_pts_title.setObjectName("CardTitle")
        map_layout.addWidget(min_pts_title)

        min_pts_row = QHBoxLayout()
        self.txt_min_stable = QLineEdit(str(self.min_stable_points))
        self.txt_min_stable.returnPressed.connect(self.on_apply_min_stable_points)
        self.btn_apply_min_stable = QPushButton("APPLY")
        self.btn_apply_min_stable.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_apply_min_stable.clicked.connect(self.on_apply_min_stable_points)
        min_pts_row.addWidget(self.txt_min_stable)
        min_pts_row.addWidget(self.btn_apply_min_stable)
        map_layout.addLayout(min_pts_row)

        self.btn_export_nav = QPushButton("🧭  EXPORT NAV")
        self.btn_export_nav.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_export_nav.clicked.connect(self.on_export_nav)
        map_layout.addWidget(self.btn_export_nav)

        nav_title = QLabel("NAV TUNING")
        nav_title.setObjectName("CardTitle")
        map_layout.addWidget(nav_title)

        nav_row1 = QHBoxLayout()
        self.txt_nav_zmin = QLineEdit(str(self.nav_cfg.get("z_min_m", -0.4)))
        self.txt_nav_zmax = QLineEdit(str(self.nav_cfg.get("z_max_m", 1.2)))
        self.txt_nav_zmin.setPlaceholderText("z min")
        self.txt_nav_zmax.setPlaceholderText("z max")
        nav_row1.addWidget(self.txt_nav_zmin)
        nav_row1.addWidget(self.txt_nav_zmax)
        map_layout.addLayout(nav_row1)

        nav_row2 = QHBoxLayout()
        self.txt_nav_res = QLineEdit(str(self.nav_cfg.get("resolution_m", 0.05)))
        self.txt_nav_inf = QLineEdit(str(self.nav_cfg.get("inflation_radius_m", 0.2)))
        self.txt_nav_res.setPlaceholderText("res")
        self.txt_nav_inf.setPlaceholderText("inflate")
        self.btn_apply_nav = QPushButton("APPLY NAV")
        self.btn_apply_nav.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_apply_nav.clicked.connect(self.on_apply_nav_config)
        nav_row2.addWidget(self.txt_nav_res)
        nav_row2.addWidget(self.txt_nav_inf)
        nav_row2.addWidget(self.btn_apply_nav)
        map_layout.addLayout(nav_row2)

        goal_row = QHBoxLayout()
        self.txt_goal_x = QLineEdit("0.0")
        self.txt_goal_y = QLineEdit("0.0")
        self.txt_goal_x.setPlaceholderText("goal x")
        self.txt_goal_y.setPlaceholderText("goal y")
        self.btn_goal_apply = QPushButton("SET GOAL")
        self.btn_goal_apply.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_goal_apply.clicked.connect(self.on_set_nav_goal)
        self.btn_goal_clear = QPushButton("CLEAR")
        self.btn_goal_clear.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_goal_clear.clicked.connect(self.on_clear_nav_goal)
        goal_row.addWidget(self.txt_goal_x)
        goal_row.addWidget(self.txt_goal_y)
        goal_row.addWidget(self.btn_goal_apply)
        goal_row.addWidget(self.btn_goal_clear)
        map_layout.addLayout(goal_row)

        mission_row1 = QHBoxLayout()
        self.txt_mission = QLineEdit("1.0,0.0; 2.0,0.0")
        self.txt_mission.setPlaceholderText("x,y; x,y; ...")
        self.btn_mission_start = QPushButton("MISSION START")
        self.btn_mission_start.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_mission_start.clicked.connect(self.on_mission_start)
        mission_row1.addWidget(self.txt_mission)
        mission_row1.addWidget(self.btn_mission_start)
        map_layout.addLayout(mission_row1)

        mission_row2 = QHBoxLayout()
        self.btn_mission_pause = QPushButton("PAUSE M")
        self.btn_mission_pause.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_mission_pause.clicked.connect(lambda: self._send_mission_cmd("pause"))
        self.btn_mission_resume = QPushButton("RESUME M")
        self.btn_mission_resume.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_mission_resume.clicked.connect(lambda: self._send_mission_cmd("resume"))
        self.btn_mission_stop = QPushButton("STOP M")
        self.btn_mission_stop.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_mission_stop.clicked.connect(lambda: self._send_mission_cmd("stop"))
        mission_row2.addWidget(self.btn_mission_pause)
        mission_row2.addWidget(self.btn_mission_resume)
        mission_row2.addWidget(self.btn_mission_stop)
        map_layout.addLayout(mission_row2)

        den_title = QLabel("POINT DENSITY")
        den_title.setObjectName("CardTitle")
        map_layout.addWidget(den_title)

        den_row = QHBoxLayout()
        self.btn_den_low = QPushButton("LOW")
        self.btn_den_med = QPushButton("MEDIUM")
        self.btn_den_high = QPushButton("HIGH")
        self.btn_den_low.clicked.connect(lambda: self.set_density_mode("LOW"))
        self.btn_den_med.clicked.connect(lambda: self.set_density_mode("MEDIUM"))
        self.btn_den_high.clicked.connect(lambda: self.set_density_mode("HIGH"))
        den_row.addWidget(self.btn_den_low)
        den_row.addWidget(self.btn_den_med)
        den_row.addWidget(self.btn_den_high)
        map_layout.addLayout(den_row)
        self.refresh_density_buttons()

        self.chk_loop_closure = QCheckBox("🔁 LOOP CLOSURE SAFE")
        self.chk_loop_closure.setChecked(self.loop_closure_enabled)
        self.chk_loop_closure.stateChanged.connect(self.on_toggle_loop_closure)
        map_layout.addWidget(self.chk_loop_closure)

        self.chk_loc_machine = QCheckBox("🧠 LOC STATE MACHINE")
        self.chk_loc_machine.setChecked(self.loc_machine_enabled)
        self.chk_loc_machine.stateChanged.connect(self.on_toggle_loc_machine)
        map_layout.addWidget(self.chk_loc_machine)

        self.chk_submap_mode = QCheckBox("🗺️ SUBMAP (LOCAL+GLOBAL)")
        self.chk_submap_mode.setChecked(self.submap_mode_enabled)
        self.chk_submap_mode.stateChanged.connect(self.on_toggle_submap_mode)
        map_layout.addWidget(self.chk_submap_mode)

        quality_title = QLabel("MAP QUALITY")
        quality_title.setObjectName("CardTitle")
        map_layout.addWidget(quality_title)

        q_row1 = QHBoxLayout()
        self.txt_near_range = QLineEdit(str(self.map_quality_cfg.get("near_min_range_m", 0.15)))
        self.chk_outlier = QCheckBox("Outlier")
        self.chk_outlier.setChecked(bool(self.map_quality_cfg.get("outlier_filter_enabled", False)))
        q_row1.addWidget(self.txt_near_range)
        q_row1.addWidget(self.chk_outlier)
        map_layout.addLayout(q_row1)

        q_row2 = QHBoxLayout()
        self.chk_clip_z = QCheckBox("Clip Z")
        self.chk_clip_z.setChecked(bool(self.map_quality_cfg.get("world_z_clip_enabled", False)))
        self.txt_world_zmin = QLineEdit(str(self.map_quality_cfg.get("world_z_min_m", -2.0)))
        self.txt_world_zmax = QLineEdit(str(self.map_quality_cfg.get("world_z_max_m", 3.0)))
        q_row2.addWidget(self.chk_clip_z)
        q_row2.addWidget(self.txt_world_zmin)
        q_row2.addWidget(self.txt_world_zmax)
        map_layout.addLayout(q_row2)

        q_row3 = QHBoxLayout()
        self.txt_outlier_voxel = QLineEdit(str(self.map_quality_cfg.get("outlier_voxel_m", 0.12)))
        self.txt_outlier_min = QLineEdit(str(self.map_quality_cfg.get("outlier_min_points", 2)))
        self.btn_apply_quality = QPushButton("APPLY Q")
        self.btn_apply_quality.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_apply_quality.clicked.connect(self.on_apply_map_quality)
        q_row3.addWidget(self.txt_outlier_voxel)
        q_row3.addWidget(self.txt_outlier_min)
        q_row3.addWidget(self.btn_apply_quality)
        map_layout.addLayout(q_row3)

        side_layout.addWidget(map_card)

        # Localization card
        loc_card = QFrame(); loc_card.setObjectName("Card"); loc_card.setStyleSheet(card_style()); apply_card_shadow(loc_card)
        loc_layout = QVBoxLayout(loc_card); loc_layout.setContentsMargins(14,14,14,14); loc_layout.setSpacing(10)
        loc_title = QLabel("LOCALIZATION"); loc_title.setObjectName("CardTitle"); loc_layout.addWidget(loc_title)

        rowl = QHBoxLayout()
        self.btn_load_ref = QPushButton("🗺️  LOAD REF MAP"); self.btn_load_ref.setStyleSheet(pill_button("#2b2b2b")); self.btn_load_ref.clicked.connect(self.on_load_ref)
        self.btn_localize = QPushButton("📍  LOCALIZE NOW"); self.btn_localize.setStyleSheet(pill_button("#2b2b2b")); self.btn_localize.clicked.connect(self.on_localize)
        rowl.addWidget(self.btn_load_ref); rowl.addWidget(self.btn_localize)
        loc_layout.addLayout(rowl)

        self.btn_clear_ref = QPushButton("🧽  CLEAR REF"); self.btn_clear_ref.setStyleSheet(pill_button("#2b2b2b")); self.btn_clear_ref.clicked.connect(self.on_clear_ref)
        loc_layout.addWidget(self.btn_clear_ref)

        ref_color_title = QLabel("REF MAP COLOR")
        ref_color_title.setObjectName("CardTitle")
        loc_layout.addWidget(ref_color_title)

        ref_color_row = QHBoxLayout()
        self.btn_ref_gray = QPushButton("GRAY")
        self.btn_ref_height = QPushButton("HEIGHT")
        self.btn_ref_orig = QPushButton("ORIGINAL")
        self.btn_ref_gray.clicked.connect(lambda: self.set_ref_color_mode("GRAY"))
        self.btn_ref_height.clicked.connect(lambda: self.set_ref_color_mode("HEIGHT"))
        self.btn_ref_orig.clicked.connect(lambda: self.set_ref_color_mode("ORIGINAL"))
        ref_color_row.addWidget(self.btn_ref_gray)
        ref_color_row.addWidget(self.btn_ref_height)
        ref_color_row.addWidget(self.btn_ref_orig)
        loc_layout.addLayout(ref_color_row)
        self.refresh_ref_color_buttons()

        approx_title = QLabel("START POINTER")
        approx_title.setObjectName("CardTitle")
        loc_layout.addWidget(approx_title)

        approx_row1 = QHBoxLayout()
        self.txt_approx_z = QLineEdit(f"{self.approx_guess_z:.2f}")
        self.txt_approx_z.setPlaceholderText("z")
        self.txt_approx_z.setVisible(False)
        self.btn_pick_approx = QPushButton("🧭 CLICK START POINTER")
        self.btn_pick_approx.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_pick_approx.clicked.connect(self.on_set_approx_pick)
        approx_row1.addWidget(self.btn_pick_approx)
        loc_layout.addLayout(approx_row1)

        self.btn_clear_approx = QPushButton("✖ CLEAR APPROX")
        self.btn_clear_approx.setStyleSheet(pill_button("#2b2b2b"))
        self.btn_clear_approx.clicked.connect(self.on_clear_approx)
        loc_layout.addWidget(self.btn_clear_approx)

        self.chk_require_start_pick = QCheckBox("Require start-point pick before START")
        self.chk_require_start_pick.setChecked(True)
        self.chk_require_start_pick.stateChanged.connect(self.on_toggle_require_start_pick)
        loc_layout.addWidget(self.chk_require_start_pick)

        self.lbl_pick_status = QLabel("Start pick: pending")
        self.lbl_pick_status.setStyleSheet("color:#ffcc00; font-family:monospace; font-weight:800;")
        loc_layout.addWidget(self.lbl_pick_status)

        side_layout.addWidget(loc_card)

        perf_card = QFrame(); perf_card.setObjectName("Card"); perf_card.setStyleSheet(card_style()); apply_card_shadow(perf_card)
        perf_layout = QVBoxLayout(perf_card); perf_layout.setContentsMargins(14,14,14,14); perf_layout.setSpacing(6)
        perf_title = QLabel("PERFORMANCE"); perf_title.setObjectName("CardTitle"); perf_layout.addWidget(perf_title)
        self.lbl_perf_fps = QLabel("FPS in/out: 0.0 / 0.0")
        self.lbl_perf_fps.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
        self.lbl_perf_icp = QLabel("ICP: 0.0 ms  CPU: 0.0%")
        self.lbl_perf_icp.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
        self.lbl_perf_queue = QLabel("Queue lag: 0  Growth: 0.0 pts/s")
        self.lbl_perf_queue.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
        self.lbl_mode = QLabel("Mode: IDLE  Rec: OFF")
        self.lbl_mode.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
        self.lbl_nav = QLabel("Nav: goal none  cmd 0.00/0.00")
        self.lbl_nav.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
        self.lbl_loc_match = QLabel("Match: n/a")
        self.lbl_loc_match.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
        perf_layout.addWidget(self.lbl_perf_fps)
        perf_layout.addWidget(self.lbl_perf_icp)
        perf_layout.addWidget(self.lbl_perf_queue)
        perf_layout.addWidget(self.lbl_mode)
        perf_layout.addWidget(self.lbl_nav)
        perf_layout.addWidget(self.lbl_loc_match)
        side_layout.addWidget(perf_card)

        side_layout.addStretch(1)

        # Status labels
        self.lbl_status = QLabel("Status: IDLE")
        self.lbl_status.setStyleSheet(f"color:{accent}; font-family:monospace; font-weight:900;")

        self.lbl_points = QLabel("Stable points: 0")
        self.lbl_points.setStyleSheet("color:white; font-family:monospace; font-weight:800;")

        self.lbl_pose = QLabel("Pos: 0.00, 0.00, 0.00")
        self.lbl_pose.setStyleSheet("color:white; font-family:monospace; font-weight:800;")

        self.lbl_loc = QLabel("Localization: NO_REF")
        self.lbl_loc.setStyleSheet("color:white; font-family:monospace; font-weight:800;")

        self.lbl_loc_state = QLabel("Loc health: TRACKING")
        self.lbl_loc_state.setStyleSheet("color:white; font-family:monospace; font-weight:800;")

        self.lbl_density = QLabel(f"Density: {self.density_mode}")
        self.lbl_density.setStyleSheet("color:white; font-family:monospace; font-weight:800;")

        self.lbl_min_stable = QLabel(f"Min stable: {self.min_stable_points}")
        self.lbl_min_stable.setStyleSheet("color:white; font-family:monospace; font-weight:800;")

        side_layout.addWidget(self.lbl_status)
        side_layout.addWidget(self.lbl_points)
        side_layout.addWidget(self.lbl_pose)
        side_layout.addWidget(self.lbl_loc)
        side_layout.addWidget(self.lbl_loc_state)
        side_layout.addWidget(self.lbl_density)
        side_layout.addWidget(self.lbl_min_stable)

        root.addWidget(side_scroll)

        # 3D view
        self.view = SLAMGLViewWidget()
        self.view.opts["distance"] = float(self.cfg["gui"]["view"]["distance"])
        self.view.setBackgroundColor("#0a0c10")
        root.addWidget(self.view)
        root.setChildrenCollapsible(False)
        root.setHandleWidth(10)
        root.setStretchFactor(0, 0)
        root.setStretchFactor(1, 1)
        root.setSizes([380, 1220])

        grid = gl.GLGridItem()
        grid.setSize(x=float(self.cfg["gui"]["view"]["grid_size"]), y=float(self.cfg["gui"]["view"]["grid_size"]), z=0)
        self.view.addItem(grid)

        self.axis = gl.GLAxisItem()
        self.axis.setSize(2,2,2)
        self.view.addItem(self.axis)

        self.scatter = gl.GLScatterPlotItem(pos=np.zeros((1,3),dtype=np.float32), size=2)
        self.scatter.setGLOptions("opaque")
        self.view.addItem(self.scatter)

        # Reference map overlay (shown after user loads a map)
        self.ref_scatter = gl.GLScatterPlotItem(pos=np.zeros((1,3),dtype=np.float32), size=1.2)
        self.ref_scatter.setGLOptions("translucent")
        self.view.addItem(self.ref_scatter)
        self.ref_match_scatter = gl.GLScatterPlotItem(pos=np.zeros((1,3),dtype=np.float32), size=1.4)
        self.ref_match_scatter.setGLOptions("translucent")
        self.view.addItem(self.ref_match_scatter)
        self.start_ptr_scatter = gl.GLScatterPlotItem(pos=np.zeros((1,3),dtype=np.float32), size=1.8)
        self.start_ptr_scatter.setGLOptions("translucent")
        self.view.addItem(self.start_ptr_scatter)
        self.view.mapPickDrag.connect(self.on_view_map_pick_drag)

        self.timer = QTimer()
        self.timer.timeout.connect(self.update_loop)
        self.timer.start(50)

        self.max_draw = int(self.cfg["gui"]["render"]["max_draw_points"])
        self.max_ref_draw = max(50000, self.max_draw // 2)
        self.decimate_step = int(self.cfg["gui"]["render"]["decimate_step"])
        self.maps_dir = maps_dir
        self.ref_display_voxel = float(self.cfg["localization"]["ref_display_voxel"])

        # Workflow UI control groups: non-relevant controls are disabled/greyed.
        self._workflow_widget_groups = {
            "runtime": [
                self.txt_min_stable, self.btn_apply_min_stable,
                self.btn_den_low, self.btn_den_med, self.btn_den_high,
            ],
            "mapping": [
                self.txt_name, self.chk_save, self.btn_save_now,
                self.chk_autosave, self.txt_autosave_sec, self.btn_apply_autosave,
                self.chk_loop_closure,
                self.txt_near_range, self.chk_outlier, self.chk_clip_z,
                self.txt_world_zmin, self.txt_world_zmax,
                self.txt_outlier_voxel, self.txt_outlier_min, self.btn_apply_quality,
            ],
            "recording": [
                self.btn_record_start, self.btn_record_stop,
                self.btn_replay_run, self.btn_replay_set_base,
            ],
            "localization": [
                self.btn_localize_only, self.btn_load_ref, self.btn_localize, self.btn_clear_ref,
                self.chk_loc_machine,
                self.btn_ref_gray, self.btn_ref_height, self.btn_ref_orig,
                self.txt_approx_z, self.btn_pick_approx, self.btn_clear_approx,
                self.chk_require_start_pick, self.lbl_pick_status,
            ],
            "submap_nav": [
                self.chk_submap_mode,
            ],
            "nav_export": [
                self.btn_export_nav, self.txt_nav_zmin, self.txt_nav_zmax,
                self.txt_nav_res, self.txt_nav_inf, self.btn_apply_nav,
            ],
            "nav_live": [
                self.txt_goal_x, self.txt_goal_y, self.btn_goal_apply, self.btn_goal_clear,
                self.txt_mission, self.btn_mission_start,
                self.btn_mission_pause, self.btn_mission_resume, self.btn_mission_stop,
            ],
        }
        self._workflow_managed_widgets = []
        seen = set()
        for group_widgets in self._workflow_widget_groups.values():
            for widget in group_widgets:
                k = id(widget)
                if k in seen:
                    continue
                seen.add(k)
                self._workflow_managed_widgets.append(widget)
        self._apply_workflow_ui_rules(self.workflow_profile, update_label=True)
        self._refresh_pick_status()
        self._hide_start_pointer_visual()

    def set_status(self, text: str, color: str):
        self.lbl_status.setText(text)
        self.lbl_status.setStyleSheet(f"color:{color}; font-family:monospace; font-weight:900;")

    def show_error_popup(self, message: str):
        msg = str(message).strip() or "Unknown error"
        now = time.monotonic()
        if msg == self._last_error_popup_text and (now - self._last_error_popup_t) < self._error_popup_cooldown_sec:
            return
        self._last_error_popup_text = msg
        self._last_error_popup_t = now
        QMessageBox.critical(self, "SLAM Error", msg)

    def show_saved_popup(self, message: str):
        msg = str(message).strip()
        if not msg:
            return
        now = time.monotonic()
        if msg == self._last_saved_popup_text and (now - self._last_saved_popup_t) < self._saved_popup_cooldown_sec:
            return
        self._last_saved_popup_text = msg
        self._last_saved_popup_t = now
        QMessageBox.information(self, "Map Saved", msg)

    def show_self_check_popup(self, report: dict | None, source: str = "startup"):
        if self._self_check_popup_shown and source != "worker":
            return
        rep = report or {}
        errs = list(rep.get("errors", []) or [])
        warns = list(rep.get("warnings", []) or [])
        info = list(rep.get("info", []) or [])
        if not errs and not warns:
            if source == "engine":
                self._self_check_popup_shown = True
            return
        msg_lines = []
        if errs:
            msg_lines.append("Errors:")
            msg_lines.extend(f"- {x}" for x in errs)
        if warns:
            msg_lines.append("Warnings:")
            msg_lines.extend(f"- {x}" for x in warns)
        if info:
            msg_lines.append("Info:")
            msg_lines.extend(f"- {x}" for x in info[:3])
        text = "\n".join(msg_lines)
        if errs:
            QMessageBox.critical(self, "SLAM Startup Check", text)
        else:
            QMessageBox.warning(self, "SLAM Startup Check", text)
        if source == "engine":
            self._self_check_popup_shown = True

    @staticmethod
    def _read_float(line: QLineEdit, default: float) -> float:
        txt = line.text().strip()
        try:
            return float(txt)
        except Exception:
            line.setText(str(default))
            return float(default)

    def request_map_export_if_armed(self) -> bool:
        if not self.save_armed:
            return False
        base = self.txt_name.text().strip() or "map_robot"
        self.client.export_map(base)
        return True

    def _read_min_stable_input(self, show_error: bool = False):
        txt = self.txt_min_stable.text().strip()
        try:
            value = int(txt)
        except Exception:
            if show_error:
                self.show_error_popup("Minimum stable points must be an integer.")
            return None
        return int(np.clip(value, 50, 5_000_000))

    def _apply_min_stable_value(self, value: int, set_status_msg: bool = True):
        self.min_stable_points = int(value)
        self.txt_min_stable.setText(str(self.min_stable_points))
        self.lbl_min_stable.setText(f"Min stable: {self.min_stable_points}")
        try:
            self.client.set_min_stable_points(self.min_stable_points)
        except Exception:
            pass
        if set_status_msg:
            self.set_status(f"Min stable set: {self.min_stable_points}", "#ffcc00")

    def ensure_min_stable_synced(self, show_error: bool = False, set_status_msg: bool = False) -> bool:
        value = self._read_min_stable_input(show_error=show_error)
        if value is None:
            return False
        if int(value) != int(self.min_stable_points):
            self._apply_min_stable_value(int(value), set_status_msg=set_status_msg)
        return True

    def _set_widgets_enabled(self, widgets, enabled: bool):
        for w in widgets:
            if w is None:
                continue
            w.setEnabled(bool(enabled))

    def _apply_workflow_ui_rules(self, profile: str | None = None, update_label: bool = False):
        p = str(profile if profile is not None else self.workflow_profile).upper().strip()
        if p not in ("MAP_NEW", "EXTEND_MAP", "LOCALIZE_MAP", "LIVE_NAV_MAP", "LIVE_NAV_NO_MAP", "QUICK_DEMO", "BALANCED"):
            p = "BALANCED"
        self.workflow_profile = p
        if update_label:
            if self.workflow_selected:
                self.lbl_workflow.setText(f"Workflow: {self.workflow_profile}")
            else:
                self.lbl_workflow.setText("Workflow: NOT_SELECTED")

        # Start from fully enabled, then gray-out non-relevant controls per workflow.
        self._set_widgets_enabled(self._workflow_managed_widgets, True)
        # Submap LOCAL+GLOBAL is intentionally limited to map-based localization/nav workflows.
        self._set_widgets_enabled(
            self._workflow_widget_groups["submap_nav"],
            p in ("LOCALIZE_MAP", "LIVE_NAV_MAP"),
        )

        if p == "MAP_NEW":
            self._set_widgets_enabled(self._workflow_widget_groups["localization"], False)
            self._set_widgets_enabled(self._workflow_widget_groups["nav_live"], False)
        elif p == "EXTEND_MAP":
            # Extending an existing map needs localization (to anchor incoming
            # scans to the loaded frame) AND mapping (to add the new points).
            self._set_widgets_enabled(self._workflow_widget_groups["nav_live"], False)
        elif p == "LOCALIZE_MAP":
            self._set_widgets_enabled(self._workflow_widget_groups["mapping"], False)
            self._set_widgets_enabled(self._workflow_widget_groups["recording"], False)
            self._set_widgets_enabled(self._workflow_widget_groups["nav_export"], False)
            self._set_widgets_enabled(self._workflow_widget_groups["nav_live"], False)
        elif p == "LIVE_NAV_MAP":
            self._set_widgets_enabled(self._workflow_widget_groups["mapping"], False)
            self._set_widgets_enabled(self._workflow_widget_groups["recording"], False)
        elif p == "LIVE_NAV_NO_MAP":
            self._set_widgets_enabled(self._workflow_widget_groups["localization"], False)
        elif p == "QUICK_DEMO":
            # Quick demo keeps most options active, but submap remains map-workflow only.
            pass
        else:
            # BALANCED keeps broad controls active; submap remains map-workflow only.
            pass
        if p not in ("LOCALIZE_MAP", "LIVE_NAV_MAP") and self.approx_pick_armed:
            self._set_approx_pick_mode(False)
        self._refresh_pick_status()

    def apply_runtime_settings(self):
        try:
            self.client.set_density(self.density_mode)
        except Exception:
            pass
        if not self.ensure_min_stable_synced(show_error=False, set_status_msg=False):
            try:
                self.client.set_min_stable_points(self.min_stable_points)
            except Exception:
                pass
        try:
            self.client.set_loop_closure(bool(self.loop_closure_enabled))
        except Exception:
            pass
        try:
            self.client.set_loc_state_machine(bool(self.loc_machine_enabled))
        except Exception:
            pass
        try:
            self.client.set_submap_mode(
                bool(self.submap_mode_enabled),
                {"apply_profiles": ["LOCALIZE_MAP", "LIVE_NAV_MAP"]},
            )
        except Exception:
            pass
        try:
            self.client.set_stability_profile(str(self.workflow_profile))
        except Exception:
            pass
        self.on_apply_autosave(set_status_msg=False, quiet=True)
        self.on_apply_nav_config(set_status_msg=False, quiet=True)
        self.on_apply_map_quality(set_status_msg=False, quiet=True)

    def _apply_workflow_profile(self, profile: str, set_status_msg: bool = True):
        p = str(profile).upper().strip()
        self.workflow_selected = True
        self._apply_workflow_ui_rules(p, update_label=True)
        self.btn_connect.setEnabled(True)
        self.btn_connect.setToolTip("")
        try:
            self.client.set_stability_profile(self.workflow_profile)
        except Exception:
            pass
        if set_status_msg:
            self.set_status(f"Workflow selected: {self.workflow_profile}", "#ffcc00")

    def _pick_and_load_ref_map(self) -> bool:
        fname, _ = QFileDialog.getOpenFileName(self, "Load Reference Map", self.maps_dir, "Point Clouds (*.ply *.pcd)")
        if not fname:
            return False
        self.ref_map_path = fname
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._pick_help_shown_for_ref = None
        self._refresh_pick_status()
        self.client.load_ref_map(fname)
        self.lbl_loc.setText(f"Localization: ref={Path(fname).name}")
        self._start_ref_loader(fname, show_status=False)
        return True

    def _workflow_requires_ref_map(self) -> bool:
        return str(self.workflow_profile).upper().strip() in ("LOCALIZE_MAP", "LIVE_NAV_MAP")

    def on_workflow_map_new(self):
        self._apply_workflow_profile("MAP_NEW", set_status_msg=False)
        self._set_approx_pick_mode(False)
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._refresh_pick_status()
        self.set_density_mode("MEDIUM")
        # Keep loop closure off by default for stability; user can enable manually later.
        self.chk_loop_closure.setChecked(False)
        self.chk_submap_mode.setChecked(False)
        # Mapping-new should run in pure SLAM frame (no stale reference alignment).
        if self.ref_map_path:
            self.on_clear_ref()
        self.set_status("Workflow MAP_NEW ready: CONNECT -> START mapping (loop closure OFF by default).", "#ffcc00")

    def on_workflow_extend_map(self):
        """Extend an existing saved map: load .ply, seed stable map, continue mapping."""
        fname, _ = QFileDialog.getOpenFileName(
            self, "Extend Saved Map", self.maps_dir, "Point Clouds (*.ply *.pcd)"
        )
        if not fname:
            return
        self._apply_workflow_profile("EXTEND_MAP", set_status_msg=False)
        self._set_approx_pick_mode(False)
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._refresh_pick_status()
        self.set_density_mode("MEDIUM")
        # Loop closure on by default for extend — drift accumulated during the
        # original scan plus drift in the new segment both benefit from it.
        self.chk_loop_closure.setChecked(True)
        self.chk_submap_mode.setChecked(False)
        # Wire ref_map_path so the GUI shows the loaded map in the viewer.
        self.ref_map_path = fname
        self.lbl_loc.setText(f"Localization: ref={Path(fname).name}")
        self._start_ref_loader(fname, show_status=False)
        # Send the extend command (also sets ref internally and seeds stable/filter).
        try:
            self.client.load_for_extend(fname)
        except Exception as e:
            self.show_error_popup(f"Extend load failed: {e}")
            return
        self.set_status(
            f"Workflow EXTEND_MAP ready (loaded {Path(fname).name}): CONNECT -> START.",
            "#ffcc00",
        )
        QMessageBox.information(
            self,
            "Extend Saved Map",
            "1) Click CONNECT\n2) Click START\n\n"
            "New scans will be added to the loaded map. Save as usual on STOP — "
            "the merged map is written to a new file (auto-incremented name).",
        )

    def on_workflow_localize_existing(self):
        self._apply_workflow_profile("LOCALIZE_MAP", set_status_msg=False)
        self._set_approx_pick_mode(False)
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._refresh_pick_status()
        self.set_density_mode("MEDIUM")
        self.chk_loop_closure.setChecked(False)
        self.chk_loc_machine.setChecked(True)
        # Reference-anchored default: keep uploaded map static, use submap only if user enables it.
        self.chk_submap_mode.setChecked(False)
        self.set_status(
            "Workflow LOCALIZE_MAP ready: LOAD REF -> click start pointer -> CONNECT -> START.",
            "#ffcc00",
        )
        QMessageBox.information(
            self,
            "Navigate In Mapped Place",
            "1) Click LOAD REF MAP\n2) Click CLICK START POINTER, then click current location on map\n3) Click CONNECT\n4) Click START\n\nLocalization will start automatically from your picked start location.",
        )

    def on_workflow_live_nav(self):
        self._apply_workflow_profile("LIVE_NAV_MAP", set_status_msg=False)
        self._set_approx_pick_mode(False)
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._refresh_pick_status()
        self.set_density_mode("HIGH")
        self.chk_loop_closure.setChecked(False)
        self.chk_loc_machine.setChecked(True)
        # Reference-anchored default: keep uploaded map static, use submap only if user enables it.
        self.chk_submap_mode.setChecked(False)
        try:
            self.client.set_nav_runtime_cfg({"enabled": True, "dynamic_decay_sec": 1.6, "dynamic_min_hits": 2})
        except Exception:
            pass
        self.set_status(
            "Workflow LIVE_NAV_MAP ready: LOAD REF -> click start pointer -> CONNECT -> START.",
            "#ffcc00",
        )
        QMessageBox.information(
            self,
            "Live Nav With Map",
            "1) Click LOAD REF MAP\n2) Click CLICK START POINTER, then click current location on map\n3) Click CONNECT\n4) Click START\n\nLocalization will start automatically from your picked start location.",
        )

    def on_workflow_live_nav_nomap(self):
        self._apply_workflow_profile("LIVE_NAV_NO_MAP", set_status_msg=False)
        self._set_approx_pick_mode(False)
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._refresh_pick_status()
        self.set_density_mode("HIGH")
        self.chk_loop_closure.setChecked(False)
        self.chk_submap_mode.setChecked(False)
        if self.ref_map_path:
            # Force no-reference mode for live mapless navigation.
            self.on_clear_ref()
        try:
            self.client.set_nav_runtime_cfg({"enabled": True, "dynamic_decay_sec": 1.4, "dynamic_min_hits": 2})
        except Exception:
            pass
        self.set_status("Workflow LIVE_NAV_NO_MAP ready: CONNECT -> START -> set GOAL/MISSION.", "#ffcc00")

    def on_workflow_quick_demo(self):
        self._apply_workflow_profile("QUICK_DEMO", set_status_msg=False)
        self._set_approx_pick_mode(False)
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._refresh_pick_status()
        self.set_density_mode("HIGH")
        self.set_status("Workflow QUICK_DEMO ready (fast build, lower persistence).", "#ffcc00")

    # actions
    def on_connect(self):
        if not self.workflow_selected:
            self.show_error_popup("Select a workflow first, then CONNECT.")
            self.set_status("Status: select workflow first", "#ffcc00")
            return
        if self._workflow_requires_ref_map() and not self.ref_map_path:
            self.show_error_popup("Load a reference map first for this workflow.")
            self.set_status("Status: load ref map first", "#ffcc00")
            return
        # Update config-derived IP at runtime by environment is not needed; just re-create engine process
        ip = self.txt_ip.text().strip()
        if not ip:
            self.set_status("Status: IP required", "#ffcc00")
            return

        # write into process config by setting env and recreating client config file? keep behavior: just connect
        # engine reads config ip; for runtime typed IP, we pass command CONNECT and worker uses eng_cfg.host_ip.
        # easiest: set env override and restart client
        self.client.stop_process()
        # update env config override is not a numeric param; it’s user input.
        # We rebuild a new client, then mutate host_ip for this session:
        self.client = SlamEngineClient()
        self.client.eng_cfg.host_ip = ip
        self.client.start_process()
        self.show_self_check_popup(self.client.get_self_check(), source="engine")
        self._worker_dead_notified = False
        self.worker_mode = "IDLE"
        self.is_connected = False
        self.recording_enabled = False
        self.recording_frames = 0
        self.recording_dropped = 0
        self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'}")
        self.client.connect()
        self.apply_runtime_settings()
        if self._workflow_requires_ref_map() and self.ref_map_path:
            self.client.load_ref_map(self.ref_map_path)
            if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
                px, py, pz = self.start_pick_xyz
                try:
                    self.client.set_approx_pose(float(px), float(py), float(pz))
                except Exception:
                    pass
            else:
                self._prompt_pick_before_start_if_needed()
        self.set_status("Status: CONNECTING...", self.cfg["gui"]["colors"]["accent"])

    def on_start(self):
        if not self.is_connected:
            self.show_error_popup("Click CONNECT first.")
            return
        self._set_approx_pick_mode(False)
        self.apply_runtime_settings()
        if self._workflow_requires_ref_map():
            if not self.ref_map_path:
                self.show_error_popup("Load a reference map first, then CONNECT and START.")
                self.set_status("Status: load ref map first", "#ffcc00")
                return
            if self.require_start_pick and not self.start_pick_ready:
                self.show_error_popup("Set approximate start location on the map first.")
                self._prompt_pick_before_start_if_needed()
                self.set_status("Status: click map to set start location", "#ffcc00")
                return
            if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
                px, py, pz = self.start_pick_xyz
                try:
                    self.client.set_approx_pose(float(px), float(py), float(pz))
                except Exception:
                    pass
            self.client.start_localize_only()
            self.worker_mode = "LOCALIZE_ONLY"
            self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'}")
            self.set_status("Status: LOCALIZE_ONLY (AUTO LOCALIZING FROM LIVE SCAN)", "#ffcc00")
            return
        self.client.start_mapping()
        self.worker_mode = "MAPPING"
        self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'}")
        self.set_status("Status: MAPPING", self.cfg["gui"]["colors"]["accent"])

    def on_start_localize_only(self):
        if not self.ref_map_path:
            self.show_error_popup("Load a reference map first for LOCALIZE-ONLY mode.")
            return
        if not self.is_connected:
            self.show_error_popup("Click CONNECT first.")
            return
        if self.require_start_pick and not self.start_pick_ready:
            self.show_error_popup("Set approximate start location on the map first.")
            self._prompt_pick_before_start_if_needed()
            return
        self.apply_runtime_settings()
        if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
            px, py, pz = self.start_pick_xyz
            try:
                self.client.set_approx_pose(float(px), float(py), float(pz))
            except Exception:
                pass
        self.client.start_localize_only()
        self.worker_mode = "LOCALIZE_ONLY"
        self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'}")
        self.set_status("Status: LOCALIZE_ONLY", "#ffcc00")

    def on_pause(self):
        self.client.pause_mapping()
        self.worker_mode = "PAUSED"
        self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'}")
        self.set_status("Status: PAUSED", "#ffcc00")

    def on_stop(self):
        if not self.ensure_min_stable_synced(show_error=True, set_status_msg=False):
            return
        self.client.stop_mapping()
        self.worker_mode = "STOPPED"
        self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'}")
        if self.request_map_export_if_armed():
            self.set_status("Status: STOPPED (SAVING MAP...)", "#ffcc00")
        else:
            self.set_status("Status: STOPPED", "white")

    def on_record_start(self):
        base = self.txt_name.text().strip() or "slam_recording"
        self.client.record_start(base)
        self.recording_enabled = True
        self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: ON")
        self.set_status("Recording started.", "#ffcc00")

    def on_record_stop(self):
        base = self.txt_name.text().strip() or "slam_recording"
        self.client.record_stop(True, base)
        self.recording_enabled = False
        self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: OFF")
        self.set_status("Recording stopped.", "#ffcc00")

    def on_replay_run(self):
        fname, _ = QFileDialog.getOpenFileName(self, "Run Replay", self.maps_dir, "Replay Files (*.npz)")
        if not fname:
            return
        try:
            from SLAM_Replay import load_replay_npz, run_replay, compare_reports

            frames, poses = load_replay_npz(fname)
            report = run_replay(frames, poses=poses, export_nav=False)
            self.last_replay_report = report
            out = {"report": report}
            passed = None
            base_path = Path(self.replay_baseline_path)
            if base_path.exists():
                baseline = json.loads(base_path.read_text(encoding="utf-8"))
                base_rep = baseline.get("report", baseline) if isinstance(baseline, dict) else {}
                out["comparison"] = compare_reports(report, base_rep, tol_ratio=0.15)
                passed = bool(out["comparison"].get("passed", False))
            msg = json.dumps(out, indent=2)
            QMessageBox.information(self, "Replay Report", msg)
            if passed is None:
                self.set_status("Replay complete (no baseline).", self.cfg["gui"]["colors"]["accent"])
            elif passed:
                self.set_status("Replay regression: PASS", self.cfg["gui"]["colors"]["accent"])
            else:
                self.set_status("Replay regression: FAIL", "#ff4444")
        except Exception as e:
            self.show_error_popup(f"Replay failed: {e}")

    def on_replay_set_baseline(self):
        if not isinstance(self.last_replay_report, dict):
            self.show_error_popup("Run replay first, then set baseline.")
            return
        try:
            base = {"report": self.last_replay_report}
            Path(self.replay_baseline_path).write_text(json.dumps(base, indent=2), encoding="utf-8")
            self.set_status(f"Baseline saved: {Path(self.replay_baseline_path).name}", "#ffcc00")
        except Exception as e:
            self.show_error_popup(f"Failed to write baseline: {e}")

    def on_save_now(self):
        if not self.ensure_min_stable_synced(show_error=True, set_status_msg=False):
            return
        base = self.txt_name.text().strip() or "map_robot"
        self.client.export_map(base)
        self.set_status("Saving map now...", "#ffcc00")

    def on_reset(self):
        self.save_armed = False
        self.chk_save.blockSignals(True); self.chk_save.setChecked(False); self.chk_save.blockSignals(False)
        self.client.reset_mapping()
        self.worker_mode = "IDLE"
        self.recording_enabled = False
        self.recording_frames = 0
        self.recording_dropped = 0
        self.lbl_mode.setText("Mode: IDLE  Rec: OFF")
        self.scatter.setData(pos=np.zeros((1,3),dtype=np.float32))
        self.set_status("Status: RESET", "#ffcc00")
        self.lbl_points.setText("Stable points: 0")
        self.current_stable_points = 0

    def on_toggle_save(self, state: int):
        self.save_armed = bool(state)
        self.set_status("Save armed" if self.save_armed else "Save disarmed",
                        self.cfg["gui"]["colors"]["accent"] if self.save_armed else "white")

    def on_apply_autosave(self, _checked: bool = False, set_status_msg: bool = True, quiet: bool = False):
        sec = self._read_float(self.txt_autosave_sec, self.autosave_interval_sec)
        sec = max(5.0, sec)
        self.autosave_interval_sec = sec
        self.autosave_enabled = bool(self.chk_autosave.isChecked())
        self.txt_autosave_sec.setText(str(int(sec)))
        base = self.txt_name.text().strip() or "autosave_map"
        try:
            self.client.set_autosave(self.autosave_enabled, self.autosave_interval_sec, base)
        except Exception:
            if not quiet:
                self.show_error_popup("Failed to send autosave settings to worker.")
            return
        if set_status_msg:
            mode = "ON" if self.autosave_enabled else "OFF"
            self.set_status(f"Autosave {mode} ({int(self.autosave_interval_sec)}s)", "#ffcc00")

    def on_export_nav(self):
        if not self.ensure_min_stable_synced(show_error=True, set_status_msg=False):
            return
        self.on_apply_nav_config(set_status_msg=False, quiet=True)
        base = self.txt_name.text().strip() or "map_robot"
        self.client.export_nav(base)
        self.set_status("Exporting nav map...", "#ffcc00")

    def on_apply_min_stable_points(self):
        value = self._read_min_stable_input(show_error=True)
        if value is None:
            return
        self._apply_min_stable_value(int(value), set_status_msg=True)

    def on_toggle_loop_closure(self, state: int):
        self.loop_closure_enabled = bool(state)
        try:
            self.client.set_loop_closure(self.loop_closure_enabled)
        except Exception:
            pass
        self.set_status(
            "Loop closure SAFE ON" if self.loop_closure_enabled else "Loop closure OFF",
            "#ffcc00",
        )

    def on_toggle_loc_machine(self, state: int):
        self.loc_machine_enabled = bool(state)
        try:
            self.client.set_loc_state_machine(self.loc_machine_enabled)
        except Exception:
            pass
        self.set_status(
            "Localization state machine ON" if self.loc_machine_enabled else "Localization state machine OFF",
            "#ffcc00",
        )

    def on_toggle_submap_mode(self, state: int):
        self.submap_mode_enabled = bool(state)
        try:
            self.client.set_submap_mode(
                self.submap_mode_enabled,
                {"apply_profiles": ["LOCALIZE_MAP", "LIVE_NAV_MAP"]},
            )
        except Exception:
            pass
        self.set_status(
            "Submap LOCAL+GLOBAL ON" if self.submap_mode_enabled else "Submap LOCAL+GLOBAL OFF",
            "#ffcc00",
        )

    def on_apply_nav_config(self, _checked: bool = False, set_status_msg: bool = True, quiet: bool = False):
        zmin = self._read_float(self.txt_nav_zmin, float(self.nav_cfg.get("z_min_m", -0.4)))
        zmax = self._read_float(self.txt_nav_zmax, float(self.nav_cfg.get("z_max_m", 1.2)))
        res = max(0.01, self._read_float(self.txt_nav_res, float(self.nav_cfg.get("resolution_m", 0.05))))
        inf = max(0.0, self._read_float(self.txt_nav_inf, float(self.nav_cfg.get("inflation_radius_m", 0.2))))
        patch = {
            "z_min_m": zmin,
            "z_max_m": zmax,
            "resolution_m": res,
            "inflation_radius_m": inf,
            "min_points": int(self.min_stable_points),
        }
        self.nav_cfg.update(patch)
        try:
            self.client.set_nav_export_cfg(patch)
        except Exception:
            if not quiet:
                self.show_error_popup("Failed to send nav tuning to worker.")
            return
        if set_status_msg:
            self.set_status("Nav tuning applied.", "#ffcc00")

    def on_set_nav_goal(self):
        x = self._read_float(self.txt_goal_x, 0.0)
        y = self._read_float(self.txt_goal_y, 0.0)
        try:
            self.client.set_nav_goal(x, y)
            self.set_status(f"Nav goal set ({x:.2f}, {y:.2f})", "#ffcc00")
        except Exception:
            self.show_error_popup("Failed to send nav goal.")

    def on_clear_nav_goal(self):
        try:
            self.client.clear_nav_goal()
            self.set_status("Nav goal cleared.", "white")
        except Exception:
            self.show_error_popup("Failed to clear nav goal.")

    def on_mission_start(self):
        raw = self.txt_mission.text().strip()
        if not raw:
            self.show_error_popup("Mission waypoints are empty.")
            return
        waypoints = []
        try:
            chunks = [x.strip() for x in raw.split(";") if x.strip()]
            for ch in chunks:
                parts = [p.strip() for p in ch.split(",")]
                if len(parts) < 2:
                    continue
                waypoints.append({"x": float(parts[0]), "y": float(parts[1])})
        except Exception:
            self.show_error_popup("Mission format should be: x,y; x,y; ...")
            return
        if not waypoints:
            self.show_error_popup("No valid mission waypoints parsed.")
            return
        try:
            self.client.mission_start(waypoints)
            self.set_status(f"Mission started ({len(waypoints)} wp)", "#ffcc00")
        except Exception:
            self.show_error_popup("Failed to start mission.")

    def _send_mission_cmd(self, mode: str):
        m = str(mode).lower().strip()
        try:
            if m == "pause":
                self.client.mission_pause()
                self.set_status("Mission paused.", "#ffcc00")
            elif m == "resume":
                self.client.mission_resume()
                self.set_status("Mission resumed.", "#ffcc00")
            elif m == "stop":
                self.client.mission_stop()
                self.set_status("Mission stopped.", "white")
        except Exception:
            self.show_error_popup("Failed to send mission command.")

    def on_apply_map_quality(self, _checked: bool = False, set_status_msg: bool = True, quiet: bool = False):
        patch = {
            "enabled": True,
            "near_min_range_m": max(0.0, self._read_float(self.txt_near_range, 0.15)),
            "world_z_clip_enabled": bool(self.chk_clip_z.isChecked()),
            "world_z_min_m": self._read_float(self.txt_world_zmin, -2.0),
            "world_z_max_m": self._read_float(self.txt_world_zmax, 3.0),
            "outlier_filter_enabled": bool(self.chk_outlier.isChecked()),
            "outlier_voxel_m": max(0.02, self._read_float(self.txt_outlier_voxel, 0.12)),
            "outlier_min_points": max(1, int(self._read_float(self.txt_outlier_min, 2))),
        }
        self.map_quality_cfg.update(patch)
        try:
            self.client.set_map_quality_cfg(patch)
        except Exception:
            if not quiet:
                self.show_error_popup("Failed to send map-quality settings to worker.")
            return
        if set_status_msg:
            self.set_status("Map-quality filter applied.", "#ffcc00")

    def refresh_density_buttons(self):
        for mode, btn in (
            ("LOW", self.btn_den_low),
            ("MEDIUM", self.btn_den_med),
            ("HIGH", self.btn_den_high),
        ):
            if mode == self.density_mode:
                btn.setStyleSheet(pill_button("#0f8f4b"))
            else:
                btn.setStyleSheet(pill_button("#2b2b2b"))

    def set_density_mode(self, mode: str):
        self.density_mode = str(mode).upper().strip()
        if self.density_mode not in ("LOW", "MEDIUM", "HIGH"):
            self.density_mode = "MEDIUM"
        self.refresh_density_buttons()
        self.lbl_density.setText(f"Density: {self.density_mode}")
        try:
            self.client.set_density(self.density_mode)
        except Exception:
            pass
        self.set_status(f"Density set: {self.density_mode}", "#ffcc00")

    def refresh_ref_color_buttons(self):
        for mode, btn in (
            ("GRAY", self.btn_ref_gray),
            ("HEIGHT", self.btn_ref_height),
            ("ORIGINAL", self.btn_ref_orig),
        ):
            if mode == self.ref_color_mode:
                btn.setStyleSheet(pill_button("#0f8f4b"))
            else:
                btn.setStyleSheet(pill_button("#2b2b2b"))

    def _start_ref_loader(self, filename: str, show_status: bool = True):
        if self._ref_thread is not None and self._ref_thread.isRunning():
            self._ref_thread.quit()
            self._ref_thread.wait(200)
        self._ref_thread = RefLoaderThread(
            filename,
            display_voxel=self.ref_display_voxel,
            color_mode=self.ref_color_mode,
        )
        self._ref_thread.loaded.connect(self.on_ref_loaded)
        self._ref_thread.failed.connect(lambda e: QMessageBox.critical(self, "REF load failed", e))
        self._ref_thread.start()
        if show_status:
            self.set_status(f"Ref loading ({self.ref_color_mode})...", "#ffcc00")

    def set_ref_color_mode(self, mode: str):
        m = str(mode).upper().strip()
        if m not in ("GRAY", "HEIGHT", "ORIGINAL"):
            m = "GRAY"
        self.ref_color_mode = m
        self.refresh_ref_color_buttons()
        if self.ref_map_path:
            self._start_ref_loader(self.ref_map_path, show_status=False)
        self.set_status(f"Ref color: {self.ref_color_mode}", "#ffcc00")

    def on_load_ref(self):
        if not self._pick_and_load_ref_map():
            return
        self.set_status("Ref loaded.", "#ffcc00")

    def on_ref_loaded(self, pts, cols, filename: str):
        draw_pts = pts
        draw_cols = cols
        if len(draw_pts) > self.max_ref_draw:
            step = max(2, len(draw_pts) // self.max_ref_draw)
            draw_pts = draw_pts[::step]
            if draw_cols is not None and len(draw_cols) == len(pts):
                draw_cols = draw_cols[::step]

        if draw_cols is not None:
            self.ref_scatter.setData(pos=draw_pts, color=draw_cols, size=1.2)
        else:
            self.ref_scatter.setData(pos=draw_pts, size=1.2)
        try:
            pick_pts = np.asarray(pts, dtype=np.float32)
            if pick_pts.ndim == 2 and pick_pts.shape[1] == 3 and len(pick_pts) > 0:
                max_n = max(2000, int(self.ref_pick_max_points))
                if len(pick_pts) > max_n:
                    step = max(1, int(np.ceil(float(len(pick_pts)) / float(max_n))))
                    pick_pts = pick_pts[::step][:max_n]
                self.ref_pick_points = np.asarray(pick_pts, dtype=np.float32)
                self.view.pick_points = self.ref_pick_points
            else:
                self.ref_pick_points = None
                self.view.pick_points = None
        except Exception:
            self.ref_pick_points = None
            self.view.pick_points = None

        # Auto-localize only when stable map is ready; otherwise avoid error popups.
        if self.worker_mode == "LOCALIZE_ONLY":
            self.client.start_localize_only()
            self.set_status("Ref shown. Localize-only running...", "#ffcc00")
        elif self._workflow_requires_ref_map():
            if self.require_start_pick and not self.start_pick_ready:
                self._set_approx_pick_mode(True)
                if self._pick_help_shown_for_ref != str(filename):
                    self._pick_help_shown_for_ref = str(filename)
                    QMessageBox.information(
                        self,
                        "Set Start Location",
                        "Reference map loaded.\n\nClick CLICK START POINTER, then click your current robot location on the map, then press START.",
                    )
                self.set_status("Ref shown. Click start pointer, then click map to set start location.", "#ffcc00")
            else:
                self.set_status("Ref shown. Click CONNECT then START (auto localize).", "#ffcc00")
        elif int(self.current_stable_points) >= int(self.min_stable_points):
            self.client.localize_now()
            self.set_status("Ref shown. Localizing...", "#ffcc00")
        else:
            self.set_status(
                f"Ref shown. Need stable points {self.current_stable_points}/{self.min_stable_points} before localize.",
                "#ffcc00",
            )
        self.lbl_loc.setText(
            f"Localization: ref={Path(filename).name} ({len(pts)} pts, {self.ref_color_mode})"
        )
        self._refresh_pick_status()

    def on_localize(self):
        if not self.ref_map_path:
            self.show_error_popup("Load a reference map first.")
            return
        if not self._workflow_requires_ref_map() and self.worker_mode != "LOCALIZE_ONLY":
            if not self.ensure_min_stable_synced(show_error=True, set_status_msg=False):
                return
        self.client.localize_now()
        self.set_status("Localization forced...", "#ffcc00")

    def _refresh_pick_status(self):
        if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
            x, y, z = self.start_pick_xyz
            self.lbl_pick_status.setText(
                f"Start pick: set ({self._fmt_coord(x)}, {self._fmt_coord(y)}, {self._fmt_coord(z)})"
            )
            self.lbl_pick_status.setStyleSheet("color:#00ff88; font-family:monospace; font-weight:800;")
        else:
            txt = "Start pick: optional (not required)" if not self.require_start_pick else "Start pick: pending"
            self.lbl_pick_status.setText(txt)
            self.lbl_pick_status.setStyleSheet("color:#ffcc00; font-family:monospace; font-weight:800;")

    @staticmethod
    def _fmt_coord(v: float, ndigits: int = 3) -> str:
        vv = float(v)
        if abs(vv) < (10 ** (-(ndigits + 1))):
            vv = 0.0
        return f"{vv:.{int(ndigits)}f}"

    def _hide_start_pointer_visual(self):
        self.start_ptr_scatter.setData(
            pos=np.zeros((1, 3), dtype=np.float32),
            color=np.zeros((1, 4), dtype=np.float32),
            size=1.0,
        )

    def _set_start_pointer_visual(self, x: float, y: float, z: float):
        pos = np.asarray([[float(x), float(y), float(z)]], dtype=np.float32)
        col = np.asarray([[1.00, 0.82, 0.08, 0.96]], dtype=np.float32)
        self.start_ptr_scatter.setData(pos=pos, color=col, size=19.0)

    def _snap_pick_to_ref(self, x: float, y: float, z: float) -> tuple[float, float, float]:
        pts = self.ref_pick_points
        if pts is None:
            return float(x), float(y), float(z)
        arr = np.asarray(pts, dtype=np.float32)
        if arr.ndim != 2 or arr.shape[1] != 3 or len(arr) == 0:
            return float(x), float(y), float(z)
        q = np.asarray([float(x), float(y)], dtype=np.float32)
        rel = arr[:, :2] - q.reshape((1, 2))
        d2 = np.einsum("ij,ij->i", rel, rel)
        idx = int(np.argmin(d2))
        if float(d2[idx]) <= float(self.ref_pick_snap_radius_m) * float(self.ref_pick_snap_radius_m):
            p = arr[idx]
            return float(p[0]), float(p[1]), float(p[2])
        return float(x), float(y), float(z)

    def on_toggle_require_start_pick(self, state: int):
        self.require_start_pick = bool(state)
        if not self.require_start_pick:
            self._set_approx_pick_mode(False)
        elif self._workflow_requires_ref_map() and self.ref_map_path and (not self.start_pick_ready):
            self._set_approx_pick_mode(True)
        self._refresh_pick_status()
        self.set_status(
            "Start-point pick required." if self.require_start_pick else "Start-point pick optional.",
            "#ffcc00",
        )

    def _prompt_pick_before_start_if_needed(self):
        if (not self._workflow_requires_ref_map()) or (not self.require_start_pick):
            return
        if self.start_pick_ready:
            return
        self._set_approx_pick_mode(True)
        QMessageBox.information(
            self,
            "Set Start Location",
            "Before START, click CLICK START POINTER, then click the reference map at robot current location.",
        )

    def _set_approx_pick_mode(self, armed: bool):
        self.approx_pick_armed = bool(armed)
        self.view.pick_mode = bool(armed)
        if bool(armed):
            self.btn_pick_approx.setStyleSheet(pill_button("#0f8f4b"))
            self.btn_pick_approx.setText("🧭 CLICK START POINTER (ON)")
            self.set_status("Click on map to set start pointer. Click again to move it. Press button to stop.", "#ffcc00")
        else:
            self.btn_pick_approx.setStyleSheet(pill_button("#2b2b2b"))
            self.btn_pick_approx.setText("🧭 CLICK START POINTER")

    def on_set_approx_pick(self):
        if not self.ref_map_path:
            self.show_error_popup("Load a reference map first.")
            return
        if self.approx_pick_armed:
            self._set_approx_pick_mode(False)
            self.set_status("Start pointer pick disabled.", "#ffcc00")
            return
        self.view.pick_plane_z = float(self.approx_guess_z)
        self._set_approx_pick_mode(True)
        if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
            sx, sy, _ = self.start_pick_xyz
            self._set_start_pointer_visual(float(sx), float(sy), float(self.approx_guess_z))

    def on_clear_approx(self):
        self._set_approx_pick_mode(False)
        self.start_pick_ready = False
        self.start_pick_xyz = None
        try:
            self.client.clear_approx_pose()
        except Exception:
            pass
        self._refresh_pick_status()
        self._hide_start_pointer_visual()
        self.set_status("Approximate position cleared.", "#ffcc00")

    def on_view_map_pick_drag(self, x: float, y: float, z: float, final: bool):
        if not self.approx_pick_armed:
            return
        if not self.ref_map_path:
            return
        z_guess = float(z) if np.isfinite(float(z)) else float(self.approx_guess_z)
        sx, sy, sz = self._snap_pick_to_ref(float(x), float(y), float(z_guess))
        self.approx_guess_z = float(sz)
        self.txt_approx_z.setText(f"{self.approx_guess_z:.2f}")
        self._set_start_pointer_visual(float(sx), float(sy), float(sz))
        self.start_pick_ready = True
        self.start_pick_xyz = (float(sx), float(sy), float(sz))
        self._refresh_pick_status()
        if not bool(final):
            return
        try:
            self.client.set_approx_pose(float(sx), float(sy), float(sz))
            self.set_status(
                f"Start pointer set at ({self._fmt_coord(sx)}, {self._fmt_coord(sy)}, {self._fmt_coord(sz)}). Click map again to adjust.",
                "#ffcc00",
            )
        except Exception:
            self.show_error_popup("Failed to send approximate position to worker.")

    def on_clear_ref(self):
        if self._ref_thread is not None and self._ref_thread.isRunning():
            self._ref_thread.quit()
            self._ref_thread.wait(200)
        self.ref_map_path = None
        self.ref_pick_points = None
        self.view.pick_points = None
        self._set_approx_pick_mode(False)
        self.start_pick_ready = False
        self.start_pick_xyz = None
        self._pick_help_shown_for_ref = None
        self.client.clear_ref()
        try:
            self.client.clear_approx_pose()
        except Exception:
            pass
        self.ref_scatter.setData(
            pos=np.zeros((1, 3), dtype=np.float32),
            color=np.zeros((1, 4), dtype=np.float32),
            size=1.0,
        )
        self.ref_match_scatter.setData(
            pos=np.zeros((1, 3), dtype=np.float32),
            color=np.zeros((1, 4), dtype=np.float32),
            size=1.0,
        )
        self.lbl_loc.setText("Localization: NO_REF")
        self._refresh_pick_status()
        self.lbl_loc_match.setText("Match: n/a")
        self.set_status("Ref cleared.", "white")

    def update_loop(self):
        # handle status msgs
        try:
            while True:
                level, msg = self.client.status_q.get_nowait()
                if level == "ERROR":
                    self.set_status(f"ERR: {msg}", "#ff4444")
                    if "CONNECT" in str(msg).upper() or "LIVOX" in str(msg).upper():
                        self.is_connected = False
                    self.show_error_popup(str(msg))
                elif level == "WARN":
                    self.set_status(f"WARN: {msg}", "#ffcc00")
                elif level == "INFO" and isinstance(msg, str):
                    up = msg.upper()
                    if "CONNECTED TO LIVOX" in up:
                        self.is_connected = True
                        self.set_status("Status: CONNECTED", self.cfg["gui"]["colors"]["accent"])
                    elif "MAPPING STARTED" in up:
                        self.set_status("Status: MAPPING", self.cfg["gui"]["colors"]["accent"])
                    elif "LOCALIZE_ONLY STARTED" in up:
                        self.set_status("Status: LOCALIZE_ONLY", "#ffcc00")
                    elif "MAPPING PAUSED" in up:
                        self.set_status("Status: PAUSED", "#ffcc00")
                    elif "MAPPING STOPPED" in up:
                        self.set_status("Status: STOPPED", "white")
                    elif "SAVED:" in up:
                        saved_path = str(msg).split("SAVED:", 1)[1].strip() if "SAVED:" in str(msg) else ""
                        if saved_path:
                            self.set_status(f"Status: MAP SAVED ({Path(saved_path).name})", self.cfg["gui"]["colors"]["accent"])
                            self.show_saved_popup(f"Map saved:\n{saved_path}")
                        else:
                            self.set_status("Status: MAP SAVED", self.cfg["gui"]["colors"]["accent"])
                elif isinstance(msg, dict) and "NAV_EXPORTED" in msg:
                    nav = msg.get("NAV_EXPORTED", {})
                    pgm = str(nav.get("pgm", ""))
                    self.set_status("Status: NAV EXPORTED", self.cfg["gui"]["colors"]["accent"])
                    if pgm:
                        QMessageBox.information(self, "Navigation Export", f"Saved navigation files:\n{pgm}")
                elif isinstance(msg, dict) and "SESSION" in msg:
                    sess = msg.get("SESSION", {})
                    if bool(sess.get("prior_loaded", False)):
                        ref = str(sess.get("ref", "reference"))
                        self.set_status(f"Session prior loaded: {ref}", "#ffcc00")
                elif isinstance(msg, dict) and "SELF_CHECK" in msg:
                    rep = msg.get("SELF_CHECK", {})
                    src = str(msg.get("source", "worker"))
                    self.show_self_check_popup(rep if isinstance(rep, dict) else {}, source=src)
                elif isinstance(msg, dict) and "LOOP" in msg:
                    loop = msg.get("LOOP", {})
                    if bool(loop.get("optimized", False)):
                        self.set_status("Status: LOOP CLOSED", self.cfg["gui"]["colors"]["accent"])
                    rej = loop.get("rejected", {})
                    if isinstance(rej, dict) and rej:
                        tr = float(rej.get("translation_m", 0.0))
                        ang = float(rej.get("rotation_deg", 0.0))
                        self.set_status(f"Loop reject safe guard ({tr:.2f}m, {ang:.1f}deg)", "#ffcc00")
                elif isinstance(msg, dict) and "LOOP_MODE" in msg:
                    info = msg.get("LOOP_MODE", {})
                    enabled = bool(info.get("enabled", self.loop_closure_enabled))
                    self.loop_closure_enabled = enabled
                    self.chk_loop_closure.blockSignals(True)
                    self.chk_loop_closure.setChecked(enabled)
                    self.chk_loop_closure.blockSignals(False)
                elif isinstance(msg, dict) and "LOC_MACHINE" in msg:
                    info = msg.get("LOC_MACHINE", {})
                    enabled = bool(info.get("enabled", self.loc_machine_enabled))
                    self.loc_machine_enabled = enabled
                    self.chk_loc_machine.blockSignals(True)
                    self.chk_loc_machine.setChecked(enabled)
                    self.chk_loc_machine.blockSignals(False)
                elif isinstance(msg, dict) and "SUBMAP_MODE" in msg:
                    info = msg.get("SUBMAP_MODE", {})
                    enabled = bool(info.get("enabled", self.submap_mode_enabled))
                    self.submap_mode_enabled = enabled
                    self.chk_submap_mode.blockSignals(True)
                    self.chk_submap_mode.setChecked(enabled)
                    self.chk_submap_mode.blockSignals(False)
                elif isinstance(msg, dict) and "LOC_STATE" in msg:
                    loc_info = msg.get("LOC_STATE", {})
                    state = str(loc_info.get("state", "TRACKING")).upper().strip()
                    enabled = bool(loc_info.get("enabled", self.loc_machine_enabled))
                    self.loc_machine_enabled = enabled
                    self.chk_loc_machine.blockSignals(True)
                    self.chk_loc_machine.setChecked(enabled)
                    self.chk_loc_machine.blockSignals(False)
                    self.lbl_loc_state.setText(f"Loc health: {state}")
                    if state == "LOST":
                        self.set_status("Status: LOST (RECOVERING)", "#ff4444")
                    elif state == "RECOVERY":
                        self.set_status("Status: RECOVERY (AUTO RELOCALIZE)", "#ffcc00")
                    elif state == "DEGRADED":
                        self.set_status("Status: DEGRADED", "#ffcc00")
                elif isinstance(msg, dict) and "DENSITY" in msg:
                    den = msg.get("DENSITY", {})
                    mode = str(den.get("mode", self.density_mode)).upper().strip()
                    stride = int(den.get("stride", 1))
                    self.density_mode = mode if mode in ("LOW", "MEDIUM", "HIGH") else self.density_mode
                    self.refresh_density_buttons()
                    self.lbl_density.setText(f"Density: {self.density_mode} (stride {stride})")
                elif isinstance(msg, dict) and "MIN_STABLE_POINTS" in msg:
                    info = msg.get("MIN_STABLE_POINTS", {})
                    try:
                        value = int(info.get("value", self.min_stable_points))
                    except Exception:
                        value = self.min_stable_points
                    self.min_stable_points = value
                    self.txt_min_stable.setText(str(value))
                    self.lbl_min_stable.setText(f"Min stable: {value}")
                elif isinstance(msg, dict) and "AUTOSAVE" in msg:
                    info = msg.get("AUTOSAVE", {})
                    self.autosave_enabled = bool(info.get("enabled", self.autosave_enabled))
                    self.autosave_interval_sec = float(info.get("interval_sec", self.autosave_interval_sec))
                    self.chk_autosave.blockSignals(True)
                    self.chk_autosave.setChecked(self.autosave_enabled)
                    self.chk_autosave.blockSignals(False)
                    self.txt_autosave_sec.setText(str(int(self.autosave_interval_sec)))
                elif isinstance(msg, dict) and "AUTOSAVED" in msg:
                    auto = msg.get("AUTOSAVED", {})
                    path = str(auto.get("path", ""))
                    pts = int(auto.get("points", 0))
                    if path:
                        self.set_status(f"Autosaved: {Path(path).name} ({pts} pts)", self.cfg["gui"]["colors"]["accent"])
                elif isinstance(msg, dict) and "MODE" in msg:
                    info = msg.get("MODE", {})
                    self.worker_mode = str(info.get("mode", self.worker_mode)).upper().strip()
                    self.lbl_mode.setText(
                        f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'}"
                    )
                elif isinstance(msg, dict) and "RECORDING" in msg:
                    info = msg.get("RECORDING", {})
                    self.recording_enabled = bool(info.get("enabled", self.recording_enabled))
                    self.recording_frames = int(info.get("frames", self.recording_frames))
                    self.recording_dropped = int(info.get("dropped", self.recording_dropped))
                    self.lbl_mode.setText(
                        f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'}"
                    )
                elif isinstance(msg, dict) and "RECORD_SAVED" in msg:
                    rec = msg.get("RECORD_SAVED", {})
                    path = str(rec.get("path", ""))
                    frames = int(rec.get("frames", 0))
                    dropped = int(rec.get("dropped", 0))
                    if path:
                        self.set_status(f"Recording saved ({frames} frames)", self.cfg["gui"]["colors"]["accent"])
                        QMessageBox.information(
                            self,
                            "Recording Saved",
                            f"Saved replay file:\n{path}\n\nFrames: {frames}\nDropped: {dropped}",
                        )
                elif isinstance(msg, dict) and "NAV_CONFIG" in msg:
                    info = msg.get("NAV_CONFIG", {})
                    self.txt_nav_zmin.setText(str(info.get("z_min_m", self.txt_nav_zmin.text())))
                    self.txt_nav_zmax.setText(str(info.get("z_max_m", self.txt_nav_zmax.text())))
                    self.txt_nav_res.setText(str(info.get("resolution_m", self.txt_nav_res.text())))
                    self.txt_nav_inf.setText(str(info.get("inflation_radius_m", self.txt_nav_inf.text())))
                elif isinstance(msg, dict) and "NAV_GOAL" in msg:
                    info = msg.get("NAV_GOAL", None)
                    if isinstance(info, dict):
                        self.set_status(
                            f"Nav goal set: ({float(info.get('x', 0.0)):.2f}, {float(info.get('y', 0.0)):.2f})",
                            "#ffcc00",
                        )
                    else:
                        self.set_status("Nav goal cleared.", "white")
                elif isinstance(msg, dict) and "MISSION" in msg:
                    info = msg.get("MISSION", {})
                    if bool(info.get("completed", False)):
                        self.set_status("Mission completed.", self.cfg["gui"]["colors"]["accent"])
                elif isinstance(msg, dict) and "MAP_QUALITY" in msg:
                    info = msg.get("MAP_QUALITY", {})
                    self.txt_near_range.setText(str(info.get("near_min_range_m", self.txt_near_range.text())))
                    self.chk_outlier.setChecked(bool(info.get("outlier_filter_enabled", self.chk_outlier.isChecked())))
                    self.chk_clip_z.setChecked(bool(info.get("world_z_clip_enabled", self.chk_clip_z.isChecked())))
                    self.txt_world_zmin.setText(str(info.get("world_z_min_m", self.txt_world_zmin.text())))
                    self.txt_world_zmax.setText(str(info.get("world_z_max_m", self.txt_world_zmax.text())))
                    self.txt_outlier_voxel.setText(str(info.get("outlier_voxel_m", self.txt_outlier_voxel.text())))
                    self.txt_outlier_min.setText(str(info.get("outlier_min_points", self.txt_outlier_min.text())))
                elif isinstance(msg, dict) and "FILTER_TUNING" in msg:
                    info = msg.get("FILTER_TUNING", {})
                    prof = str(info.get("profile", self.workflow_profile)).upper().strip()
                    self._apply_workflow_ui_rules(prof, update_label=True)
                elif isinstance(msg, dict) and "WORKFLOW_PROFILE" in msg:
                    info = msg.get("WORKFLOW_PROFILE", {})
                    prof = str(info.get("name", self.workflow_profile)).upper().strip()
                    self._apply_workflow_ui_rules(prof, update_label=True)
                elif isinstance(msg, dict) and "LOCALIZE" in msg:
                    loc = msg["LOCALIZE"]
                    fit = float(loc.get("fitness", 0.0))
                    rmse = float(loc.get("rmse", 0.0))
                    conf = float(loc.get("confidence", 0.0))
                    self.last_loc_confidence = float(conf)
                    ok = bool(loc.get("accepted", False))
                    src = str(loc.get("source", "")).upper().strip()
                    state = str(loc.get("state", self.lbl_loc_state.text().split(":")[-1].strip())).upper().strip()
                    bi = float(loc.get("inlier_bidir", 0.0))
                    prefix = f"{src} " if src else ""
                    self.lbl_loc.setText(
                        f"Localization: {prefix}fit={fit:.3f} rmse={rmse:.3f} bi={bi:.2f} conf={conf:.2f} {'OK' if ok else 'NO'}"
                    )
                    self.lbl_loc_state.setText(f"Loc health: {state}")
                    if ok:
                        self.set_status(f"Status: LOCALIZED ({src or 'RUN'})", self.cfg["gui"]["colors"]["accent"])
                    else:
                        self.set_status(f"Status: LOCALIZE WEAK ({src or 'RUN'})", "#ffcc00")
                elif isinstance(msg, dict) and "APPROX_POSE" in msg:
                    info = msg.get("APPROX_POSE", {})
                    if bool(info.get("set", False)):
                        ax = float(info.get("x", 0.0))
                        ay = float(info.get("y", 0.0))
                        az = float(info.get("z", self.approx_guess_z))
                        self.approx_guess_z = az
                        self.txt_approx_z.setText(f"{az:.2f}")
                        self.start_pick_ready = True
                        self.start_pick_xyz = (ax, ay, az)
                        self._set_start_pointer_visual(ax, ay, az)
                        self._refresh_pick_status()
                        self.set_status(
                            f"Approx pose armed at ({self._fmt_coord(ax)}, {self._fmt_coord(ay)}, {self._fmt_coord(az)})",
                            "#ffcc00",
                        )
                    else:
                        self.start_pick_ready = False
                        self.start_pick_xyz = None
                        self._hide_start_pointer_visual()
                        self._refresh_pick_status()
                        self.set_status("Approx pose cleared.", "#ffcc00")
        except Exception:
            pass

        if self.client.proc is not None and not self.client.proc.is_alive():
            if not self._worker_dead_notified:
                self._worker_dead_notified = True
                code = self.client.proc.exitcode
                code_txt = "unknown" if code is None else str(code)
                self.worker_mode = "DEAD"
                self.is_connected = False
                self.lbl_mode.setText(f"Mode: {self.worker_mode}  Rec: OFF")
                self.set_status(f"ERR: Worker stopped (exit {code_txt})", "#ff4444")
                if int(code or 0) == -11:
                    # Native crash fallback: keep advanced loop closure off on next restart.
                    was_loop_on = bool(self.loop_closure_enabled)
                    self.loop_closure_enabled = False
                    self.chk_loop_closure.blockSignals(True)
                    self.chk_loop_closure.setChecked(False)
                    self.chk_loop_closure.blockSignals(False)
                    extra = "Advanced loop closure was disabled for safety.\n" if was_loop_on else ""
                    self.show_error_popup(
                        "SLAM worker process stopped (exit code -11).\n"
                        "Native ICP/localization path crashed.\n"
                        f"{extra}"
                        "Click CONNECT to restart."
                    )
                else:
                    self.show_error_popup(f"SLAM worker process stopped (exit code {code_txt}). Click CONNECT to restart.")
        else:
            self._worker_dead_notified = False

        latest = None
        try:
            while True:
                latest = self.client.data_q.get_nowait()
        except Exception:
            pass
        if not latest:
            return

        tag, payload = latest
        if tag != "FRAME":
            return

        pts = payload.get("stable_points", None)
        cols = payload.get("stable_colors", None)
        pose = payload.get("pose", None)
        perf = payload.get("perf", {}) if isinstance(payload, dict) else {}
        map_lock = payload.get("map_lock", {}) if isinstance(payload, dict) else {}
        continuity = payload.get("continuity", {}) if isinstance(payload, dict) else {}
        submap = payload.get("submap", {}) if isinstance(payload, dict) else {}
        mode = str(payload.get("mode", self.worker_mode)).upper().strip() if isinstance(payload, dict) else self.worker_mode
        nav = payload.get("nav", {}) if isinstance(payload, dict) else {}
        safety = payload.get("safety", {}) if isinstance(payload, dict) else {}
        rec = payload.get("recording", {}) if isinstance(payload, dict) else {}
        wf = str(payload.get("workflow_profile", self.workflow_profile)).upper().strip() if isinstance(payload, dict) else self.workflow_profile
        ref_match_points = payload.get("ref_match_points", None) if isinstance(payload, dict) else None
        ref_match_colors = payload.get("ref_match_colors", None) if isinstance(payload, dict) else None
        ref_match_stats = payload.get("ref_match", {}) if isinstance(payload, dict) else {}
        self.last_loc_confidence = float(payload.get("loc_confidence", self.last_loc_confidence)) if isinstance(payload, dict) else self.last_loc_confidence

        if pts is not None and len(pts) > 1:
            draw_pts = pts
            draw_cols = cols
            if len(draw_pts) > self.max_draw:
                step = max(2, len(draw_pts) // self.max_draw)
                if self.decimate_step > 1:
                    step = max(step, self.decimate_step)
                draw_pts = draw_pts[::step]
                if draw_cols is not None and len(draw_cols) == len(pts):
                    draw_cols = draw_cols[::step]

            if draw_cols is not None:
                self.scatter.setData(pos=draw_pts, color=draw_cols)
            else:
                self.scatter.setData(pos=draw_pts)

            self.lbl_points.setText(f"Stable points: {len(pts)}")
            self.current_stable_points = int(len(pts))

        if ref_match_points is not None:
            try:
                mpts = np.asarray(ref_match_points, dtype=np.float32)
                if mpts.ndim == 2 and mpts.shape[1] == 3 and len(mpts) > 1:
                    mcols = None
                    if ref_match_colors is not None:
                        mcols_arr = np.asarray(ref_match_colors, dtype=np.float32)
                        if mcols_arr.ndim == 2 and mcols_arr.shape[1] == 4 and len(mcols_arr) == len(mpts):
                            mcols = mcols_arr
                    if len(mpts) > self.max_ref_draw:
                        mstep = max(2, len(mpts) // self.max_ref_draw)
                        mpts = mpts[::mstep]
                        if mcols is not None:
                            mcols = mcols[::mstep]
                    if mcols is not None:
                        self.ref_match_scatter.setData(pos=mpts, color=mcols, size=1.5)
                    else:
                        self.ref_match_scatter.setData(pos=mpts, size=1.5)
                else:
                    self.ref_match_scatter.setData(
                        pos=np.zeros((1, 3), dtype=np.float32),
                        color=np.zeros((1, 4), dtype=np.float32),
                        size=1.0,
                    )
            except Exception:
                pass
        elif str(mode).upper().strip() != "LOCALIZE_ONLY":
            self.ref_match_scatter.setData(
                pos=np.zeros((1, 3), dtype=np.float32),
                color=np.zeros((1, 4), dtype=np.float32),
                size=1.0,
            )

        if pose is not None:
            try:
                x, y, z = float(pose[0, 3]), float(pose[1, 3]), float(pose[2, 3])
                self.lbl_pose.setText(f"Pos: {x:.2f}, {y:.2f}, {z:.2f}")
                self.axis.resetTransform()
                self.axis.translate(x, y, z)
            except Exception:
                pass

        if isinstance(perf, dict) and perf:
            try:
                in_fps = float(perf.get("input_fps", 0.0))
                out_fps = float(perf.get("publish_fps", 0.0))
                icp_ms = float(perf.get("icp_ms", 0.0))
                cpu_pct = float(perf.get("cpu_percent", 0.0))
                qlag = int(perf.get("queue_lag", 0))
                growth = float(perf.get("stable_growth_per_sec", 0.0))
                freeze_count = int(map_lock.get("freeze_count", 0)) if isinstance(map_lock, dict) else 0
                c_rej_total = int(continuity.get("reject_count", 0)) if isinstance(continuity, dict) else 0
                c_rej_streak = int(continuity.get("reject_streak", 0)) if isinstance(continuity, dict) else 0
                self.lbl_perf_fps.setText(f"FPS in/out: {in_fps:.1f} / {out_fps:.1f}")
                self.lbl_perf_icp.setText(f"ICP: {icp_ms:.1f} ms  CPU: {cpu_pct:.1f}%")
                hold_txt = f"  Hold: {freeze_count}" if freeze_count > 0 else ""
                self.lbl_perf_queue.setText(
                    f"Queue lag: {qlag}  Growth: {growth:.1f} pts/s{hold_txt}  Cont: {c_rej_streak}/{c_rej_total}"
                )
            except Exception:
                pass

        if isinstance(rec, dict):
            self.recording_enabled = bool(rec.get("enabled", self.recording_enabled))
            self.recording_frames = int(rec.get("frames", self.recording_frames))
            self.recording_dropped = int(rec.get("dropped", self.recording_dropped))

        if mode:
            self.worker_mode = mode
        if wf:
            self._apply_workflow_ui_rules(wf, update_label=True)
        mode_suffix = ""
        if isinstance(map_lock, dict) and bool(map_lock.get("freeze_active", False)):
            mode_suffix = "  [ROTATE HOLD]"
        submap_suffix = ""
        if isinstance(submap, dict):
            if bool(submap.get("enabled", False)) and bool(submap.get("active", False)):
                lg = int(submap.get("local_points", 0))
                gg = int(submap.get("global_points", 0))
                submap_suffix = f"  Submap L/G:{lg}/{gg}"
            elif bool(submap.get("enabled", False)):
                submap_suffix = "  Submap ON"
        self.lbl_mode.setText(
            f"Mode: {self.worker_mode}  Rec: {'ON' if self.recording_enabled else 'OFF'} ({self.recording_frames}){mode_suffix}{submap_suffix}"
        )

        if isinstance(nav, dict):
            goal = nav.get("goal", None)
            cmd = nav.get("cmd", {}) if isinstance(nav.get("cmd", {}), dict) else {}
            lin = float(cmd.get("linear_mps", 0.0))
            ang = float(cmd.get("angular_rps", 0.0))
            if goal and isinstance(goal, (list, tuple)) and len(goal) >= 2:
                gx = float(goal[0]); gy = float(goal[1])
                self.lbl_nav.setText(f"Nav: goal {gx:.2f},{gy:.2f}  cmd {lin:.2f}/{ang:.2f}")
            else:
                self.lbl_nav.setText(f"Nav: goal none  cmd {lin:.2f}/{ang:.2f}")

        if isinstance(ref_match_stats, dict) and ref_match_stats:
            matched_ratio = float(ref_match_stats.get("matched_ratio", self.last_loc_match_ratio))
            self.last_loc_match_ratio = matched_ratio
            matched = int(ref_match_stats.get("matched", 0))
            near = int(ref_match_stats.get("near", 0))
            unmatched = int(ref_match_stats.get("unmatched", 0))
            confirmed = int(ref_match_stats.get("confirmed", 0))
            self.lbl_loc_match.setText(
                f"Match: {matched_ratio * 100.0:.0f}%  C:{confirmed} M:{matched} N:{near} U:{unmatched}  Conf:{self.last_loc_confidence:.2f}"
            )
        elif not self._workflow_requires_ref_map():
            self.lbl_loc_match.setText("Match: n/a")

        if isinstance(safety, dict) and bool(safety.get("emergency", False)):
            reasons = safety.get("reasons", [])
            rs = ",".join(str(x) for x in reasons[:2]) if isinstance(reasons, list) else "safety"
            self.set_status(f"SAFETY STOP: {rs}", "#ff4444")

    def closeEvent(self, event):
        try:
            if self.recording_enabled:
                self.client.record_stop(True, self.txt_name.text().strip() or "slam_recording")
            # If save is armed, request one final export before shutting down worker.
            self.client.stop_mapping()
            if self.request_map_export_if_armed():
                end_t = time.monotonic() + 1.2
                while time.monotonic() < end_t:
                    QApplication.processEvents()
                    time.sleep(0.05)
            self.client.stop_process()
        except Exception:
            pass
        event.accept()


def main():
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    app = QApplication(sys.argv)
    app.setStyle("Fusion")
    w = UltimateDashboard()
    w.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
