"""gui_generator — Main application window for the Beaver PyQGIS cartographic tool.

This module defines the top-level Qt/PyQGIS GUI: the `GUI_class` QWidget that
hosts all controls, the QGIS layout-preview widget, the geoprocessing panel,
and the overlay-drawing/editing machinery. The app uses Qt's default (light)
style — no theme is applied — so inside QGIS it inherits the host's theme.

Exported symbols used by other modules
---------------------------------------
GUI_class          Top-level application widget
_find_qgis_prefix  Helper that locates the QGIS prefix for any installed version
"""

from qgis.core import (QgsVectorLayer,
                       QgsProject,
                       QgsLayoutItemMap,
                       QgsStyle,
                       QgsApplication,
                    QgsLayoutItemPicture,
                    QgsLayoutItemLabel,
                    QgsLayoutItemPolyline,
                    QgsLayoutItemPolygon,
                    QgsTextBufferSettings,
                    QgsLayoutSize,
                    QgsLayoutPoint,
                    QgsUnitTypes,
                    QgsLayoutItem,
                    QgsRectangle,
                    QgsLayoutExporter,
                    QgsMapRendererParallelJob,
                    QgsMapSettings,
                    QgsVectorLayerSimpleLabeling,
                    QgsSymbol,
                    QgsMarkerSymbol,
                    QgsLineSymbol,
                    QgsFillSymbol,
                    QgsWkbTypes,
                    QgsSymbolLayerUtils,
                    QgsReadWriteContext,
                    QgsSingleSymbolRenderer,
                    Qgis)

from PyQt5.QtWidgets import (QApplication,
                            QWidgetAction,
                            QWidget,
                            QGraphicsPathItem,
                            QLabel,
                            QLineEdit,
                            QPlainTextEdit,
                            QPushButton,
                            QVBoxLayout, 
                            QHBoxLayout, 
                            QMessageBox, 
                            QScrollArea, 
                            QRadioButton,
                            QButtonGroup, 
                            QGroupBox, 
                            QSizePolicy, 
                            QGridLayout, 
                            QComboBox,
                            QCheckBox, 
                            QListWidget, 
                            QAbstractItemView, 
                            QListWidgetItem, 
                            QLayout, 
                            QSplashScreen,
                            QFileDialog,
                            QSplitter,
                            QSpacerItem,
                            QStackedLayout,
                            QInputDialog,
                            QToolButton, 
                            QMenu, 
                            QDialog, 
                            QDialogButtonBox, 
                            QFormLayout, 
                            QFontComboBox, 
                            QDoubleSpinBox,
                            QSpinBox,
                            QColorDialog,
                            QProgressBar,
                            QFrame,
                            QTextBrowser,
                            QProgressDialog,
                            )

from PyQt5.QtGui import (
                        QPixmap,
                        QRegExpValidator,
                        QIcon,
                        QColor,
                        QFont,
                        QImage,
                        QPainter,
                        QPainterPath,
                        QPen,
                        QBrush,
                        QPolygonF,
                    )

from PyQt5.QtCore import (
                        Qt,
                        QSize,
                        QRegExp,
                        QVariant,
                        QTimer,
                        QFile,
                        QIODevice,
                        QBuffer,
                        QByteArray,
                        QFileInfo,
                        QRect,
                        QPointF,
                        QEvent,
                        QObject,
                        pyqtSignal,
                        QThread,
                        QSettings,
                    )

from qgis.gui import (QgsLayoutView,
                        QgsLayoutViewToolSelect,
                        # QgsSymbolSelectorDialog and QgsSymbolSelectorWidget
                        # are intentionally NOT imported: both segfault inside
                        # their setupUi() on this PyQGIS / Windows build, even
                        # after initQgis() and QgsGui.editorWidgetRegistry()
                        # .initEditors() have been called. _SymbolEditorDialog
                        # below is a stock-Qt replacement that exposes the same
                        # core controls (colour / outline / width / size).
                        QgsGui,)

from PyQt5.QtSvg import QSvgRenderer
from PyQt5.QtXml import QDomDocument

# ── QGIS 3.34 ↔ 3.44 compatibility shims ─────────────────────────────────────
# Many constants were moved to the `Qgis` namespace in 3.36–3.38.  We try the
# new location first; if it doesn't exist we fall back to the 3.34 location.
# All call sites below use these private aliases instead of the literal names.

try:
    _LAYOUT_MM  = Qgis.LayoutUnit.Millimeters        # 3.38+
    _RENDER_PT  = Qgis.RenderUnit.Points              # 3.38+
except AttributeError:
    _LAYOUT_MM  = QgsUnitTypes.LayoutMillimeters      # 3.34
    _RENDER_PT  = QgsUnitTypes.RenderPoints           # 3.34

try:
    _GEOM_POINT   = Qgis.GeometryType.Point           # 3.36+
    _GEOM_LINE    = Qgis.GeometryType.Line            # 3.36+
    _GEOM_POLYGON = Qgis.GeometryType.Polygon         # 3.36+
except AttributeError:
    _GEOM_POINT   = QgsWkbTypes.PointGeometry         # 3.34
    _GEOM_LINE    = QgsWkbTypes.LineGeometry          # 3.34
    _GEOM_POLYGON = QgsWkbTypes.PolygonGeometry       # 3.34

try:
    _SYM_MARKER = QgsSymbol.SymbolType.Marker         # 3.36+
    _SYM_LINE   = QgsSymbol.SymbolType.Line           # 3.36+
    _SYM_FILL   = QgsSymbol.SymbolType.Fill           # 3.36+
except AttributeError:
    _SYM_MARKER = QgsSymbol.Marker                    # 3.34
    _SYM_LINE   = QgsSymbol.Line                      # 3.34
    _SYM_FILL   = QgsSymbol.Fill                      # 3.34

try:
    _FLAG_DRAW_LABELS = Qgis.MapSettingsFlag.DrawLabeling   # 3.38+
except AttributeError:
    _FLAG_DRAW_LABELS = QgsMapSettings.DrawLabeling          # 3.34

try:
    _EXPORT_SUCCESS = QgsLayoutExporter.ExportResult.Success  # 3.38+
except AttributeError:
    _EXPORT_SUCCESS = QgsLayoutExporter.Success               # 3.34

try:
    _PIC_SVG    = QgsLayoutItemPicture.Format.SVG             # 3.36+
    _PIC_RASTER = QgsLayoutItemPicture.Format.Raster          # 3.36+
except AttributeError:
    _PIC_SVG    = QgsLayoutItemPicture.FormatSVG              # 3.34
    _PIC_RASTER = QgsLayoutItemPicture.FormatRaster           # 3.34


def _set_pic_mode(pic, fmt):
    """Apply a picture format mode only if setMode() still exists (deprecated in 3.38+)."""
    if hasattr(pic, "setMode"):
        try:
            pic.setMode(fmt)
        except Exception:
            pass


def _all_labels(results):
    """Fetch all label positions; handles API changes across QGIS versions."""
    if results is None:
        return []
    for method in ("allLabels", "labelPositions"):
        fn = getattr(results, method, None)
        if callable(fn):
            try:
                return fn() or []
            except Exception:
                pass
    return []

# ── End compatibility shims ───────────────────────────────────────────────────

PRESET_SCHEMA_VERSION = 1

import os, sys, tempfile
import logging
import traceback
print(f"USING PYTHON VERSION {sys.version}.")
from datetime import datetime, timezone, timedelta
import shutil
import base64
import json
import math

logger = logging.getLogger(__name__)

from .map_exporter import (export_focus_country_layout,)

from .config import (FLAGS_DIR, ICONS_DIR, OUTPUT_DIR,
                       QGIS_PROJECT_PATH)

from .methods import (get_symbologies_by_geometry,
                        parse_dms,
                        reset_border,
                        mark_missing,
                        connect_disable_export_on_change,
                        create_multiselect_dropdown,
                        harvest_layer_renderer_symbols,
                        set_multiselect_selection,
                        signals_blocked,
                        sync_map_item_to_gui_visibility,
                        discover_basemap_names,
                        build_graduated_renderer_from_ramp,
                        wait_for_render)


# NOTE: no QgsApplication is created at module level. Inside QGIS Desktop the
from .core_project import core_project, reload_core_project
# application already exists (creating another would crash the host); for
# standalone use, standalone.py performs the full bootstrap BEFORE importing
# this module.

# ── Module-level constants ────────────────────────────────────────────────────
# All paths are derived from config which is __file__-relative, ensuring the
# application works on any machine under any username.

OVERLAY_REPO = FLAGS_DIR   # flag and logo PNG images for the "Insert flag" menu

script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
    sys.path.append(script_dir)

def get_name_values(layer_name):
    """
    Return a sorted unique list of names for a given focus layer by scanning
    every layer with that name.

    Priority order for the index field:
        1) "Place_name"  (new standard)
        2) "Name"        (legacy)
        3) "ISO_CC_A3"   (country codes as a last resort)

    Fast path: use provider.uniqueValues(field_index) when available.
    Fallback: iterate features if uniqueValues is not supported or fails.

    Never returns None. Returns [] if nothing is found.
    """

    project = core_project()
    # Collect all instances that share the exact layer name
    layers = [lyr for lyr in project.mapLayers().values() if lyr.name() == layer_name]
    if not layers:
        print(f"[ERR] get_name_values: no layers found with name '{layer_name}'")
        return []

    # Pick the best available field for each instance
    def pick_field(fields):
        names = [f.name() for f in fields]
        for candidate in ("Place_name", "Name", "ISO_CC_A3"):
            if candidate in names:
                return candidate
        return None

    values = set()

    for layer in layers:
        # Decide index field per instance
        idx_field = pick_field(layer.fields())
        if not idx_field:
            logger.warning(f"get_name_values: layer '{layer.name()}' lacks Place_name/Name/ISO_CC_A3")
            continue

        # Resolve field index
        field_index = layer.fields().indexOf(idx_field)
        if field_index < 0:
            logger.warning(f"get_name_values: field '{idx_field}' not found on layer '{layer.name()}'")
            continue

        # Attempt fast path first
        try:
            unique_vals = layer.dataProvider().uniqueValues(field_index)
            if unique_vals is None:
                unique_vals = []  # defensive
            count_before = len(values)
            for val in unique_vals:
                if val is None:
                    continue
                s = str(val).strip()
                if s:
                    values.add(s)
            print(
                f"'{layer.name()}' LAYER FOUND. SCANNING '{idx_field}' FIELD; "
                f"{len(unique_vals)} VALUES FOUND; {len(values) - count_before} VALUES ADDED."
            )
            # If provider returned something, we can skip the fallback
            if unique_vals:
                continue
        except Exception as e:
            logger.warning(f"get_name_values: uniqueValues failed on '{layer.name()}' field '{idx_field}': {e}")

        # Fallback: iterate features
        count_before = len(values)
        for f in layer.getFeatures():
            try:
                v = f[idx_field]
            except Exception:
                v = None
            if v is None:
                continue
            s = str(v).strip()
            if s:
                values.add(s)
        print(
            f"[DEBUG] get_name_values (fallback): '{layer.name()}' → field='{idx_field}' "
            f"added={len(values) - count_before}"
        )

    # Finalize: sort case-insensitively, return a list
    vals = sorted(values, key=lambda s: s.lower())
    if not vals:
        logger.warning(f"get_name_values: zero values read for '{layer_name}'")
    return vals

def get_province_names_sorted_by_country():
    """Return Focus_province Place_name values sorted by CountryAff, then alphabetically.

    Scans all layer instances named 'Focus_province' and builds (CountryAff, Place_name)
    pairs so the multiselect dropdown groups provinces by their affiliated country.
    Falls back to plain alphabetical sort if the CountryAff field is absent.
    """
    project = core_project()
    layers = [lyr for lyr in project.mapLayers().values()
              if lyr.name() == "Focus_province"]
    if not layers:
        logger.warning("get_province_names_sorted_by_country: no Focus_province layers found.")
        return []

    pairs = []   # (country_key, name_key, display_name)
    seen = set()

    for layer in layers:
        field_names = [f.name() for f in layer.fields()]
        has_country = "CountryAff" in field_names
        has_name    = "Place_name" in field_names

        if not has_name:
            logger.warning(
                f"get_province_names_sorted_by_country: layer '{layer.name()}' "
                "has no Place_name field — skipping."
            )
            continue

        for feature in layer.getFeatures():
            try:
                name = str(feature["Place_name"]).strip()
            except Exception:
                continue
            if not name or name in seen:
                continue
            seen.add(name)

            country = ""
            if has_country:
                try:
                    v = feature["CountryAff"]
                    country = str(v).strip() if v is not None else ""
                except Exception:
                    country = ""

            pairs.append((country.lower(), name.lower(), name))

    pairs.sort(key=lambda x: (x[0], x[1]))
    return [p[2] for p in pairs]


def create_multiselect_widget(name_list):
    widget = QListWidget()
    widget.setSelectionMode(QAbstractItemView.MultiSelection)

    # Add placeholder item
    placeholder_item = QListWidgetItem("Select items")
    placeholder_item.setFlags(Qt.NoItemFlags)  # Make it non-selectable
    widget.addItem(placeholder_item)

    for name in name_list:
        item = QListWidgetItem(name)
        item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
        item.setCheckState(Qt.Unchecked)
        widget.addItem(item)

    widget.setFixedHeight(50)
    return widget

def get_selected_names(widget):
    if hasattr(widget, "selected_items"):
        return widget.selected_items()
    return []

class LazyStyleComboBox(QComboBox):
    def __init__(self, geom_type_func, style_loader, parent=None):
        super().__init__(parent)
        self.geom_type_func = geom_type_func  # function that returns "Point" or "Line"
        self.style_loader = style_loader
        self.loaded = False
        self.map_id = ""
        self.setMaxVisibleItems(15)
        self.view().setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
     
    def showPopup(self):
        geom_type = self.geom_type_func()
        if (not self.loaded) or (getattr(self, "_last_geom", None) != geom_type):
            self.clear()
            self.style_loader(self, geom_type)
            self.loaded = True
            self._last_geom = geom_type
        super().showPopup()


# ---- Custom symbol-editor dialog --------------------------------------------
# A hand-rolled QDialog exposing the core controls QGIS' native symbol editor
# would have shown. We do NOT use QgsSymbolSelectorWidget / Dialog because both
# segfault inside their setupUi() on this Windows PyQGIS 3.34 build — verified
# empirically with `prefix exists on disk = True`, `initQgis() returned`, and
# `QgsGui.editorWidgetRegistry().initEditors() ok` still followed by a silent
# C++ exit when the widget is constructed. Building our own dialog with stock
# Qt widgets sidesteps the QGIS editor-widget chain entirely.
#
# Controls per geometry type:
#   - Polygon: fill colour, outline colour, outline width
#   - Line:    line colour, line width
#   - Point:   marker colour, outline colour, outline width, marker size

class _SymbolEditorDialog(QDialog):
    """Minimal native-Qt dialog that edits a :class:`QgsSymbol` in place.

    The dialog reads the current colour / outline / width / size off the
    symbol's first symbol layer, lets the user adjust each via
    :class:`QColorDialog` and :class:`QDoubleSpinBox` controls, and on
    Accept writes the changes back through the symbol-level API
    (``setColor`` / ``setSize`` / ``setWidth``) plus
    per-symbol-layer setters (``setStrokeColor`` / ``setStrokeWidth``).
    The caller is responsible for any cancel-safety snapshot.
    """

    def __init__(self, symbol, style=None, vector_layer=None, parent=None):
        # ``style`` and ``vector_layer`` are accepted for signature parity
        # with the previous QgsSymbolSelectorWidget-based dialog; they're
        # unused in this minimal editor.
        del style, vector_layer
        super().__init__(parent)
        self.setWindowTitle("Customize symbol")
        self.resize(380, 240)

        # Hold a strong reference so the C++ side can't dangle.
        self._symbol = symbol

        # ---- Snapshot starting values from the symbol --------------------
        # Symbol.type() returns 0 (Marker) / 1 (Line) / 2 (Fill).
        gt = symbol.type()
        self._gt = gt

        # Primary colour comes off the symbol object directly.
        try:
            self._primary_color = QColor(symbol.color())
        except Exception:
            self._primary_color = QColor(0, 0, 0)

        # Outline colour + width + marker size are layer-level; read off
        # the first symbol layer when available.
        self._stroke_color = QColor(0, 0, 0)
        initial_outline_w = 0.26
        initial_marker_sz = 2.0
        initial_line_w = 0.30
        try:
            if symbol.symbolLayerCount() > 0:
                first = symbol.symbolLayer(0)
                if hasattr(first, "strokeColor"):
                    sc = first.strokeColor()
                    if sc is not None and sc.isValid():
                        self._stroke_color = QColor(sc)
                if hasattr(first, "strokeWidth"):
                    try:
                        initial_outline_w = float(first.strokeWidth())
                    except Exception:
                        pass
                if hasattr(first, "size"):
                    try:
                        initial_marker_sz = float(first.size())
                    except Exception:
                        pass
                if hasattr(first, "width"):
                    try:
                        initial_line_w = float(first.width())
                    except Exception:
                        pass
        except Exception as e:
            logger.warning(f"_SymbolEditorDialog: layer read failed: {e}")

        # ---- Build the form ---------------------------------------------
        form = QFormLayout()

        # Primary-colour picker (label depends on geometry).
        primary_label = {
            _SYM_MARKER: "Marker colour",
            _SYM_LINE:   "Line colour",
            _SYM_FILL:   "Fill colour",
        }.get(gt, "Colour")
        self._color_btn = self._make_color_button(self._primary_color)
        self._color_btn.clicked.connect(lambda: self._pick_color("_primary_color", self._color_btn))
        form.addRow(primary_label + ":", self._color_btn)

        # Outline-colour picker — Marker and Fill only. Lines have no
        # separate outline (the line itself IS the stroke).
        if gt in (_SYM_MARKER, _SYM_FILL):
            self._stroke_btn = self._make_color_button(self._stroke_color)
            self._stroke_btn.clicked.connect(lambda: self._pick_color("_stroke_color", self._stroke_btn))
            form.addRow("Outline colour:", self._stroke_btn)
        else:
            self._stroke_btn = None

        # Width spinbox — mm. Label switches to "Line width" for line symbols.
        self._width_spin = QDoubleSpinBox()
        self._width_spin.setRange(0.0, 100.0)
        self._width_spin.setDecimals(2)
        self._width_spin.setSingleStep(0.1)
        self._width_spin.setSuffix(" mm")
        if gt == _SYM_LINE:
            # For lines, prefer the symbol-level width (covers all layers).
            try:
                self._width_spin.setValue(float(symbol.width()))
            except Exception:
                self._width_spin.setValue(initial_line_w)
            form.addRow("Line width:", self._width_spin)
        else:
            self._width_spin.setValue(initial_outline_w if initial_outline_w > 0 else 0.26)
            form.addRow("Outline width:", self._width_spin)

        # Marker size — points only.
        if gt == _SYM_MARKER:
            self._size_spin = QDoubleSpinBox()
            self._size_spin.setRange(0.1, 100.0)
            self._size_spin.setDecimals(2)
            self._size_spin.setSingleStep(0.5)
            self._size_spin.setSuffix(" mm")
            try:
                self._size_spin.setValue(float(symbol.size()))
            except Exception:
                self._size_spin.setValue(initial_marker_sz)
            form.addRow("Marker size:", self._size_spin)
        else:
            self._size_spin = None

        # OK / Cancel.
        btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self)
        btns.accepted.connect(self._on_accept)
        btns.rejected.connect(self.reject)

        layout = QVBoxLayout(self)
        layout.addLayout(form)
        layout.addStretch(1)
        layout.addWidget(btns)

    # ---- Colour-button helpers -----------------------------------------

    def _make_color_button(self, color: QColor) -> QPushButton:
        """Build a colour-swatch push button. The text shows the hex name."""
        btn = QPushButton()
        btn.setMinimumWidth(120)
        btn.setMinimumHeight(24)
        self._paint_color_button(btn, color)
        return btn

    def _paint_color_button(self, btn: QPushButton, color: QColor) -> None:
        """Update ``btn``'s background colour swatch + label to ``color``."""
        # Choose a contrasting text colour for legibility.
        luma = (color.red() * 299 + color.green() * 587 + color.blue() * 114) / 1000
        text_color = "#000000" if luma > 128 else "#ffffff"
        btn.setStyleSheet(
            f"QPushButton {{ background-color: {color.name()}; color: {text_color}; "
            f"border: 1px solid #555; padding: 2px 6px; }}"
        )
        btn.setText(color.name().upper())

    def _pick_color(self, attr_name: str, btn: QPushButton) -> None:
        """Open :class:`QColorDialog` for the colour stored under ``attr_name``."""
        current = getattr(self, attr_name)
        new = QColorDialog.getColor(
            current, self, "Choose colour", QColorDialog.ShowAlphaChannel
        )
        if new.isValid():
            setattr(self, attr_name, new)
            self._paint_color_button(btn, new)

    # ---- Commit ---------------------------------------------------------

    def _on_accept(self) -> None:
        """Write the dialog's values back to the in-memory symbol, then ``accept()``."""
        symbol = self._symbol
        gt = self._gt

        # Diagnostic: prove the user's chosen values are reaching the
        # symbol object. Surfaces in the console; remove once Problem 2
        # is confirmed fixed.
        _size_txt = f"{self._size_spin.value():.2f}" if self._size_spin is not None else "n/a"
        _stroke_txt = self._stroke_color.name() if self._stroke_color else "n/a"
        print(
            f"[SYMBOL] _on_accept: gt={gt} primary={self._primary_color.name()} "
            f"stroke={_stroke_txt} width={self._width_spin.value():.2f} size={_size_txt}",
            flush=True,
        )

        # Primary colour: works on every symbol type.
        try:
            symbol.setColor(self._primary_color)
        except Exception as e:
            logger.warning(f"symbol.setColor failed: {e}")

        # Outline colour: applies to every symbol layer that has one.
        if self._stroke_btn is not None:
            for i in range(symbol.symbolLayerCount()):
                lyr = symbol.symbolLayer(i)
                if hasattr(lyr, "setStrokeColor"):
                    try:
                        lyr.setStrokeColor(self._stroke_color)
                    except Exception:
                        pass

        # Width: symbol-level for lines, per-layer stroke for marker/fill.
        w = float(self._width_spin.value())
        if gt == _SYM_LINE:
            try:
                symbol.setWidth(w)
            except Exception as e:
                logger.warning(f"symbol.setWidth failed: {e}")
        else:
            for i in range(symbol.symbolLayerCount()):
                lyr = symbol.symbolLayer(i)
                if hasattr(lyr, "setStrokeWidth"):
                    try:
                        lyr.setStrokeWidth(w)
                    except Exception:
                        pass

        # Marker size — points only.
        if self._size_spin is not None:
            try:
                symbol.setSize(float(self._size_spin.value()))
            except Exception as e:
                logger.warning(f"symbol.setSize failed: {e}")

        # Confirm the post-write state for the diagnostic trail.
        try:
            print(
                f"[SYMBOL] post-write: symbol.color={QColor(symbol.color()).name()} "
                f"layerCount={symbol.symbolLayerCount()}",
                flush=True,
            )
        except Exception:
            pass

        self.accept()


# ---- Customize-symbol button (replaces the old per-row Size dropdowns) ------

class SymbolCustomizationButton(QToolButton):
    """A button that opens the QGIS symbol-selector dialog for the row's symbol.

    Replaces the size-only dropdowns that used to live next to each Style
    dropdown. Holds a mutable ``QgsSymbol`` that is:

    - initialised from a geometry-typed default (filled marker / 1pt line /
      hollow polygon),
    - reset to a clone of the row's Style-dropdown selection via
      :meth:`reset_from_style_name`,
    - replaced wholesale by the user via :meth:`_open_editor` when the
      QGIS native :class:`QgsSymbolSelectorDialog` is accepted,
    - serialised to / restored from XML via :meth:`serialize` /
      :meth:`load_from_xml` so presets round-trip cleanly.

    Geometry is fixed at construction time when known (``Point`` / ``Line``
    / ``Polygon``); rows whose geometry depends on the user's file pick
    (CSV / shapefile uploads) may call :meth:`set_geometry_type` later.
    """

    # Sentinel text shown when the user hasn't yet customised the symbol.
    _LABEL_DEFAULT = "Customize"
    # Sentinel shown after the user accepts a dialog edit.
    _LABEL_CUSTOMIZED = "Customize ✓"

    def __init__(self, geometry_type=_GEOM_POLYGON, parent=None):
        super().__init__(parent)
        # Fixed-width keeps the row grid tidy; matches the old combo box width.
        self.setFixedWidth(110)
        self.setToolTip("Open the QGIS symbol editor (colour, outline, marker shape, etc.).")
        self.geometry_type = geometry_type
        # Start every row with a sensible default so the editor opens with
        # something the user can immediately tweak.
        self.current_symbol = self._default_symbol(geometry_type)
        self._customized = False
        self.setText(self._LABEL_DEFAULT)
        # The actual click handler opens the QGIS dialog.
        self.clicked.connect(self._open_editor)

    # ---- Symbol bootstrapping helpers --------------------------------------

    @staticmethod
    def _default_symbol(geom_type):
        """Return a fresh default symbol matching ``geom_type`` (or ``None``)."""
        if geom_type == _GEOM_POINT:
            return QgsMarkerSymbol.createSimple({})
        if geom_type == _GEOM_LINE:
            return QgsLineSymbol.createSimple({})
        if geom_type == _GEOM_POLYGON:
            return QgsFillSymbol.createSimple({})
        return None

    @staticmethod
    def wkb_from_geom_string(geom_str: str):
        """Map the GUI's string codes ("Point" / "Line" / "Polygon" / "Raster" / …)
        to a :class:`QgsWkbTypes` value used by the QGIS symbol API. Unknown
        or non-vector inputs (e.g. "Raster") fall back to Polygon — the
        button is typically disabled on those rows anyway.
        """
        return {
            "Point":   _GEOM_POINT,
            "Line":    _GEOM_LINE,
            "Polygon": _GEOM_POLYGON,
        }.get(geom_str, _GEOM_POLYGON)

    def set_geometry_type(self, geom_type):
        """Re-bind the row's geometry; resets the symbol to the new default."""
        if geom_type == self.geometry_type and self.current_symbol is not None:
            return
        self.geometry_type = geom_type
        self.current_symbol = self._default_symbol(geom_type)
        self._customized = False
        self._update_text()

    def reset_from_symbol(self, base_symbol):
        """Replace the current symbol with a clone of ``base_symbol``.

        Called whenever the row's Style dropdown selection changes so the
        editor always opens with the chosen named style as its starting
        point. ``base_symbol=None`` falls back to the geometry-typed default.
        """
        if base_symbol is not None:
            try:
                self.current_symbol = base_symbol.clone()
            except Exception:
                self.current_symbol = self._default_symbol(self.geometry_type)
        else:
            self.current_symbol = self._default_symbol(self.geometry_type)
        self._customized = False
        self._update_text()

    # ---- Dialog plumbing ---------------------------------------------------

    def _open_editor(self, *_args):
        """Open :class:`QgsSymbolSelectorDialog` on the row's current symbol.

        Heavy instrumentation: every step prints a flushed marker to stdout
        so a hard C++ segfault can be narrowed down to the last line that
        printed. Once the crash cause is identified the prints will be
        trimmed back; for now diagnostics outweigh tidiness.
        """
        def _p(msg: str) -> None:
            """Print + flush so a subsequent C++ segfault doesn't eat the line."""
            print(f"[CUSTOMIZE] {msg}", flush=True)

        _p("entered _open_editor")
        _p(f"geometry_type={self.geometry_type}, _customized={self._customized}")

        # ---- Ensure we always have a symbol to edit -----------------------
        if self.current_symbol is None:
            _p("current_symbol is None; building a default")
            self.current_symbol = self._default_symbol(self.geometry_type)
        if self.current_symbol is None:
            _p("default symbol is None (raster-style geometry?); bailing out")
            return
        _p(f"current_symbol type = {type(self.current_symbol).__name__}")

        # ---- Resolve the default style DB ---------------------------------
        try:
            default_style = QgsStyle.defaultStyle()
        except Exception as e:
            _p(f"QgsStyle.defaultStyle() raised: {e}")
            traceback.print_exc()
            return
        if default_style is None:
            _p("QgsStyle.defaultStyle() returned None; QGIS style DB not "
               "initialised — cannot open the editor")
            return
        _p(f"default_style ok ({type(default_style).__name__})")

        # ---- Pick the parent: top-level window if reachable ---------------
        try:
            parent_window = self.window()
        except Exception:
            parent_window = None
        _p(f"parent_window = {type(parent_window).__name__ if parent_window else 'None'}")

        # ---- Hold a strong reference to the edited symbol on `self` so
        #      Python's GC can't reap it mid-exec_() ----------------------
        try:
            # Clone via XML round-trip — eliminates any C++ raw-pointer
            # aliasing between the editor and the row's stored symbol.
            snapshot_xml = self.serialize()
        except Exception as e:
            _p(f"serialize() failed: {e}")
            snapshot_xml = ""
        _p(f"snapshot_xml len={len(snapshot_xml)}")

        try:
            editable = self.current_symbol.clone()
        except Exception as e:
            _p(f"current_symbol.clone() raised: {e}")
            traceback.print_exc()
            return
        # Stash on the instance so the Python wrapper survives the C++ dialog.
        self._editor_symbol_ref = editable
        _p(f"editable clone made ({type(editable).__name__})")

        # ---- Construct the dialog ----------------------------------------
        # We use the widget-in-our-own-QDialog pattern instead of the
        # bundled QgsSymbolSelectorDialog because the latter's
        # constructor segfaults inside setupUi() on this PyQGIS / Windows
        # build (verified empirically: prints stop at the ctor line).
        try:
            _p("constructing _SymbolEditorDialog (QgsSymbolSelectorWidget wrapper)…")
            dlg = _SymbolEditorDialog(
                editable,
                style=default_style,
                vector_layer=None,
                parent=parent_window if parent_window is not None else self,
            )
            # Stash the dialog on `self` so Python's GC can't reap it
            # while exec_() is running.
            self._editor_dialog_ref = dlg
            _p("dialog constructed")
        except Exception as e:
            _p(f"_SymbolEditorDialog ctor raised: {e}")
            logger.exception("_SymbolEditorDialog ctor failed")
            traceback.print_exc()
            return

        # ---- Run modally --------------------------------------------------
        try:
            _p("calling dlg.exec_()")
            result = dlg.exec_()
            _p(f"dlg.exec_() returned {result}")
        except Exception as e:
            _p(f"dlg.exec_() raised: {e}")
            logger.exception("QgsSymbolSelectorDialog exec failed")
            traceback.print_exc()
            return
        finally:
            # Drop the strong references we held; the dialog has completed.
            self._editor_dialog_ref = None

        # ---- Apply / revert -----------------------------------------------
        if result == QDialog.Accepted:
            self.current_symbol = editable
            self._customized = True
            self._update_text()
            _p("symbol committed (Accepted)")
        else:
            # Cancelled: keep the prior symbol; nothing else to do because
            # we edited a clone, not self.current_symbol.
            self._editor_symbol_ref = None
            _p("dialog cancelled; symbol left unchanged")

    def _update_text(self):
        """Reflect customised-or-not state in the button label."""
        self.setText(self._LABEL_CUSTOMIZED if self._customized else self._LABEL_DEFAULT)

    # ---- Preset serialisation ---------------------------------------------

    def serialize(self) -> str:
        """Return the current symbol as a QGIS-style XML string (or empty).

        Only emits XML when the user has actually customised this row's
        symbol — either by accepting the Customize dialog, or by restoring
        a preset that included a ``symbol_xml`` entry. Untouched rows
        return ``""`` so the exporter falls through to its other paths
        (named-style application from the Style dropdown, or the .qgz's
        built-in symbology when no style is picked either).

        Without this gate, every row stamps its default symbol over the
        project's stored symbology — which is what users would see as
        "preloaded layers always render with the customize-button default".
        """
        if self.current_symbol is None or not self._customized:
            return ""
        try:
            doc = QDomDocument()
            elem = QgsSymbolLayerUtils.saveSymbol(
                "symbol", self.current_symbol, doc, QgsReadWriteContext()
            )
            doc.appendChild(elem)
            xml = doc.toString()
            # Diagnostic: surface in the console which rows produce non-empty
            # XML so we can confirm the exporter is receiving the user's
            # actual edits. Remove once both reported bugs are confirmed fixed.
            print(f"[SYMBOL] serialize() emitted {len(xml)} chars (customized=True)", flush=True)
            return xml
        except Exception as e:
            logger.warning(f"SymbolCustomizationButton.serialize failed: {e}")
            return ""

    def load_from_xml(self, xml_string: str) -> bool:
        """Restore a previously-serialised symbol; return ``True`` on success."""
        if not xml_string:
            return False
        try:
            doc = QDomDocument()
            if not doc.setContent(xml_string):
                return False
            elem = doc.documentElement()
            sym = QgsSymbolLayerUtils.loadSymbol(elem, QgsReadWriteContext())
            if sym is None:
                return False
            self.current_symbol = sym
            # Restored symbols always reflect a user choice; mark customized.
            self._customized = True
            self._update_text()
            return True
        except Exception as e:
            logger.warning(f"SymbolCustomizationButton.load_from_xml failed: {e}")
            return False

    # ---- Convenience: wire Style-dropdown to symbol reset ------------------

    def link_style_dropdown(self, style_dropdown, styles_dict_getter):
        """Connect ``style_dropdown`` so its changes reset this button's symbol.

        ``styles_dict_getter`` is a zero-arg callable returning the GUI's
        styles dict (``{'Point': [...], 'Line': [...], 'Polygon': [...]}``).
        Passed as a callable so the lookup happens at signal time, AFTER
        the GUI has harvested project styles (which is lazy on some rows).
        """
        geom_key_lookup = {
            _GEOM_POINT:   "Point",
            _GEOM_LINE:    "Line",
            _GEOM_POLYGON: "Polygon",
        }

        def _on_changed(_idx=None):
            name = (style_dropdown.currentText() or "").strip()
            # Treat sentinel choices as "no named style" — reset to default.
            if not name or name in ("Select style", "No symbol", "Size"):
                self.reset_from_symbol(None)
                return
            styles_dict = styles_dict_getter() or {}
            # Search the button's own geometry bucket first, then the others.
            # This handles rows whose geometry type doesn't yet match the
            # selected style (e.g. upload rows default to Polygon but the
            # dropdown shows all geometry types).
            geom_key_primary = geom_key_lookup.get(self.geometry_type)
            search_order = (
                [geom_key_primary] + [k for k in ("Point", "Line", "Polygon") if k != geom_key_primary]
                if geom_key_primary
                else ["Point", "Line", "Polygon"]
            )
            for geom_key in search_order:
                for n, sym in styles_dict.get(geom_key, []) or []:
                    if n.strip().lower() == name.lower():
                        self.reset_from_symbol(sym)
                        return
            # Style name not found in any bucket — fall back to default.
            self.reset_from_symbol(None)

        style_dropdown.currentIndexChanged.connect(_on_changed)


# ── Interactive drawing tool for line / polygon overlays ──────────────────────

class _DrawingTool(QObject):
    """Event filter that collects mouse-click vertices on a QgsLayoutView.

    Install on the view before drawing; the tool emits ``finished`` or
    ``cancelled`` and then the caller should call ``remove()`` to clean up.

    ``finished`` carries: (list[QPointF], QgsSymbol, str)
        where the str is "line" or "polygon".
    ``cancelled`` carries no data.
    """

    finished  = pyqtSignal(list, object, str)
    cancelled = pyqtSignal()

    # Rubber-band pen/brush constants (class-level so they are shared)
    _RUBBER_PEN    = None  # set in __init__ to avoid Qt init-order issues
    _TENTATIVE_PEN = None

    def __init__(self, view, symbol, mode, parent=None):
        super().__init__(parent)
        self._view      = view
        self._symbol    = symbol
        self._mode      = mode          # "line" | "polygon"
        self._points    = []            # QPointF list in scene coords
        self._committed = None          # QGraphicsPathItem — clicked vertices path
        self._tentative = None          # QGraphicsPathItem — cursor-to-last segment

        # Build pens here so Qt is fully up before we touch Qt objects
        self._rubber_pen    = QPen(QColor(0, 140, 255), 1.5, Qt.DashLine)
        self._rubber_brush  = QBrush(QColor(0, 140, 255, 35))
        self._tentative_pen = QPen(QColor(0, 140, 255, 120), 1.0, Qt.DotLine)

        view.viewport().installEventFilter(self)
        view.viewport().setCursor(Qt.CrossCursor)

    def remove(self):
        """Remove event filter and clean up rubber-band graphics items."""
        try:
            self._view.viewport().removeEventFilter(self)
            self._view.viewport().unsetCursor()
        except Exception:
            pass
        scene = None
        try:
            scene = self._view.scene()
        except Exception:
            pass
        for attr in ("_committed", "_tentative"):
            item = getattr(self, attr, None)
            if item and scene:
                try:
                    scene.removeItem(item)
                except Exception:
                    pass
            setattr(self, attr, None)

    # ------------------------------------------------------------------
    def eventFilter(self, obj, event):
        t = event.type()

        if t == QEvent.MouseButtonPress:
            btn = event.button()
            if btn == Qt.LeftButton:
                sp = self._view.mapToScene(event.pos())
                self._points.append(QPointF(sp))
                self._rebuild_committed()
                return True
            if btn == Qt.RightButton:
                self.cancelled.emit()
                return True

        elif t == QEvent.MouseButtonDblClick:
            if event.button() == Qt.LeftButton:
                if len(self._points) >= 2:
                    self.finished.emit(list(self._points), self._symbol, self._mode)
                return True

        elif t == QEvent.MouseMove:
            self._rebuild_tentative(self._view.mapToScene(event.pos()))

        elif t == QEvent.KeyPress:
            key = event.key()
            if key == Qt.Key_Escape:
                self.cancelled.emit()
                return True
            if key in (Qt.Key_Return, Qt.Key_Enter) and len(self._points) >= 2:
                self.finished.emit(list(self._points), self._symbol, self._mode)
                return True

        return False

    # ------------------------------------------------------------------
    def _path_from_points(self, pts, close=False):
        path = QPainterPath()
        if not pts:
            return path
        path.moveTo(pts[0])
        for p in pts[1:]:
            path.lineTo(p)
        if close and len(pts) >= 3:
            path.closeSubpath()
        return path

    def _rebuild_committed(self):
        scene = self._view.scene()
        if self._committed:
            try:
                scene.removeItem(self._committed)
            except Exception:
                pass
            self._committed = None
        if len(self._points) < 2:
            return
        close = (self._mode == "polygon")
        item = QGraphicsPathItem(self._path_from_points(self._points, close))
        item.setPen(self._rubber_pen)
        if close:
            item.setBrush(self._rubber_brush)
        item.setZValue(9999)
        scene.addItem(item)
        self._committed = item

    def _rebuild_tentative(self, cursor_scene_pt):
        scene = self._view.scene()
        if self._tentative:
            try:
                scene.removeItem(self._tentative)
            except Exception:
                pass
            self._tentative = None
        if not self._points:
            return
        item = QGraphicsPathItem(
            self._path_from_points([self._points[-1], cursor_scene_pt]))
        item.setPen(self._tentative_pen)
        item.setZValue(9999)
        scene.addItem(item)
        self._tentative = item


# ── Clickable focus-row frame ─────────────────────────────────────────────────

class _ClickableFrame(QFrame):
    """A QFrame that emits `clicked` on any mouse press anywhere in its area.

    Used for the three focus-row containers so the user can click a row to
    make it the active map-centre input.
    """
    clicked = pyqtSignal()

    def mousePressEvent(self, event):
        self.clicked.emit()
        super().mousePressEvent(event)


class FieldQueryWidget(QWidget):
    """Guided three-control filter builder: Query field / Operation / Value(s).

    Replaces the free-text SQL filter box. Assembles a QGIS/OGR expression
    (suitable for ``QgsVectorLayer.setSubsetString``) from a chosen field,
    operator, and value(s). The Value(s) button stays disabled until both a
    field and an operator are selected, and the editor it opens depends on the
    operator (single-pick / multi-select distinct values, numeric, or text).

    Data is sourced lazily through provider callables so the same widget serves
    preloaded layers (read from the live project) and file-based layers
    (read from a path) alike.
    """

    # (label, sql token, value-kind)
    #   single  -> one distinct value      multi -> several distinct values
    #   numeric -> typed number            text  -> typed text (LIKE)
    #   none    -> no value required
    _OPERATORS = [
        ("equals",            "=",            "single"),
        ("not equals",        "<>",           "single"),
        ("is one of",         "IN",           "multi"),
        ("is not one of",     "NOT IN",       "multi"),
        ("greater than",      ">",            "numeric"),
        ("less than",         "<",            "numeric"),
        ("greater or equal",  ">=",           "numeric"),
        ("less or equal",     "<=",           "numeric"),
        ("contains",          "LIKE_C",       "text"),
        ("does not contain",  "NOT LIKE_C",   "text"),
        ("begins with",       "LIKE_B",       "text"),
        ("ends with",         "LIKE_E",       "text"),
        ("is null",           "IS NULL",      "none"),
        ("is not null",       "IS NOT NULL",  "none"),
    ]
    _OP_FIELD_PLACEHOLDER = "Query field"
    _OP_PLACEHOLDER = "Operation"

    def __init__(self, parent=None):
        super().__init__(parent)
        self._fields_fn = lambda: []
        self._values_fn = lambda field: []
        self._fieldtype_fn = lambda field: False  # True => numeric field
        self._values = []   # selected value(s) as strings

        lay = QHBoxLayout(self)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(4)

        outer = self

        class _FieldCombo(QComboBox):
            def showPopup(_self):
                outer._repopulate_fields()
                super().showPopup()

        self.field_combo = _FieldCombo()
        self.field_combo.setFixedWidth(130)
        self.field_combo.addItem(self._OP_FIELD_PLACEHOLDER)
        self.field_combo.setToolTip("Field to filter on.")

        self.op_combo = QComboBox()
        self.op_combo.setFixedWidth(130)
        self.op_combo.addItem(self._OP_PLACEHOLDER)
        self.op_combo.addItems([o[0] for o in self._OPERATORS])
        self.op_combo.setToolTip("How to compare the field.")

        self.value_btn = QPushButton("Value(s)")
        self.value_btn.setFixedWidth(120)
        self.value_btn.setEnabled(False)
        self.value_btn.setToolTip("Choose a field and an operation first.")

        lay.addWidget(self.field_combo)
        lay.addWidget(self.op_combo)
        lay.addWidget(self.value_btn)

        self.field_combo.currentIndexChanged.connect(self._on_field_or_op_changed)
        self.op_combo.currentIndexChanged.connect(self._on_field_or_op_changed)
        self.value_btn.clicked.connect(self._on_value_clicked)

    # -- provider wiring -------------------------------------------------------
    def set_providers(self, fields_fn=None, values_fn=None, fieldtype_fn=None):
        if fields_fn is not None:
            self._fields_fn = fields_fn
        if values_fn is not None:
            self._values_fn = values_fn
        if fieldtype_fn is not None:
            self._fieldtype_fn = fieldtype_fn

    def _repopulate_fields(self):
        """Refresh the field dropdown from the provider, preserving selection."""
        try:
            names = list(self._fields_fn() or [])
        except Exception as e:
            logger.warning(f"FieldQueryWidget: field provider failed: {e}")
            names = []
        current = self.field_combo.currentText()
        self.field_combo.blockSignals(True)
        self.field_combo.clear()
        self.field_combo.addItem(self._OP_FIELD_PLACEHOLDER)
        self.field_combo.addItems(names)
        if current in names:
            self.field_combo.setCurrentText(current)
        self.field_combo.blockSignals(False)

    # -- internal helpers ------------------------------------------------------
    def _current_field(self):
        t = self.field_combo.currentText().strip()
        return "" if t == self._OP_FIELD_PLACEHOLDER else t

    def _current_op(self):
        """Return (label, sql, kind) for the selected operator, or None."""
        label = self.op_combo.currentText().strip()
        for o in self._OPERATORS:
            if o[0] == label:
                return o
        return None

    def _on_field_or_op_changed(self, *_):
        # Changing the field or operator invalidates any chosen value.
        self._values = []
        self._refresh_value_button()

    def _refresh_value_button(self):
        field = self._current_field()
        op = self._current_op()
        needs_value = bool(op) and op[2] != "none"
        self.value_btn.setEnabled(bool(field) and needs_value)
        if not needs_value:
            self.value_btn.setText("Value(s)")
            self.value_btn.setToolTip(
                "No value needed for this operation." if op else
                "Choose a field and an operation first."
            )
        elif self._values:
            summary = ", ".join(self._values)
            if len(summary) > 16:
                summary = summary[:15] + "…"
            self.value_btn.setText(summary)
            self.value_btn.setToolTip(", ".join(self._values))
        else:
            self.value_btn.setText("Value(s)")
            self.value_btn.setToolTip("Choose the value(s) to keep.")

    def _on_value_clicked(self):
        op = self._current_op()
        field = self._current_field()
        if not op or not field:
            return
        kind = op[2]
        if kind in ("single", "multi"):
            try:
                vals = [str(v) for v in (self._values_fn(field) or [])]
            except Exception as e:
                logger.warning(f"FieldQueryWidget: value provider failed: {e}")
                vals = []
            vals = sorted(set(vals))
            if kind == "single":
                chosen = self._pick_single(vals)
                if chosen is not None:
                    self._values = [chosen]
            else:
                chosen = self._pick_multi(vals, set(self._values))
                if chosen is not None:
                    self._values = chosen
        elif kind == "numeric":
            start = float(self._values[0]) if self._values else 0.0
            num, ok = QInputDialog.getDouble(self, "Enter value", f'"{field}" value:',
                                             start, -1e12, 1e12, 6)
            if ok:
                self._values = [self._fmt_number(num)]
        elif kind == "text":
            start = self._values[0] if self._values else ""
            txt, ok = QInputDialog.getText(self, "Enter text", f'"{field}" text:',
                                           text=start)
            if ok and txt.strip():
                self._values = [txt.strip()]
        self._refresh_value_button()

    def _pick_single(self, values):
        if not values:
            QMessageBox.information(self, "No values",
                                   "No distinct values are available for this field.")
            return None
        cur = self._values[0] if self._values else values[0]
        idx = values.index(cur) if cur in values else 0
        item, ok = QInputDialog.getItem(self, "Select value", "Value:", values, idx, False)
        return item if ok else None

    def _pick_multi(self, values, preselected):
        if not values:
            QMessageBox.information(self, "No values",
                                   "No distinct values are available for this field.")
            return None
        dlg = QDialog(self)
        dlg.setWindowTitle("Select value(s)")
        v = QVBoxLayout(dlg)
        lst = QListWidget()
        lst.setSelectionMode(QAbstractItemView.MultiSelection)
        for val in values:
            it = QListWidgetItem(val)
            lst.addItem(it)
            if val in preselected:
                it.setSelected(True)
        v.addWidget(lst)
        bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg)
        bb.accepted.connect(dlg.accept)
        bb.rejected.connect(dlg.reject)
        v.addWidget(bb)
        if dlg.exec_() != QDialog.Accepted:
            return None
        return [i.text() for i in lst.selectedItems()]

    @staticmethod
    def _fmt_number(num):
        return str(int(num)) if float(num).is_integer() else repr(num)

    @staticmethod
    def _quote(v):
        return "'" + str(v).replace("'", "''") + "'"

    # -- public API ------------------------------------------------------------
    def expression(self) -> str:
        """Assemble the QGIS/OGR WHERE clause, or '' when incomplete."""
        field = self._current_field()
        op = self._current_op()
        if not field or not op:
            return ""
        _, sql, kind = op
        fid = f'"{field}"'

        if kind == "none":
            return f"{fid} {sql}"

        if not self._values:
            return ""

        numeric_field = False
        try:
            numeric_field = bool(self._fieldtype_fn(field))
        except Exception:
            numeric_field = False

        def lit(v):
            if numeric_field:
                s = str(v).strip()
                # emit bare number when it parses, else fall back to quoted
                try:
                    float(s)
                    return s
                except ValueError:
                    return self._quote(v)
            return self._quote(v)

        if kind == "single":
            return f"{fid} {sql} {lit(self._values[0])}"
        if kind == "multi":
            inner = ", ".join(lit(v) for v in self._values)
            return f"{fid} {sql} ({inner})"
        if kind == "numeric":
            return f"{fid} {sql} {self._values[0]}"
        if kind == "text":
            v = str(self._values[0]).replace("'", "''")
            if sql == "LIKE_C":
                return f"{fid} LIKE '%{v}%'"
            if sql == "NOT LIKE_C":
                return f"{fid} NOT LIKE '%{v}%'"
            if sql == "LIKE_B":
                return f"{fid} LIKE '{v}%'"
            if sql == "LIKE_E":
                return f"{fid} LIKE '%{v}'"
        return ""

    def get_state(self) -> dict:
        return {
            "field": self._current_field(),
            "op": self.op_combo.currentText().strip(),
            "values": list(self._values),
        }

    def set_state(self, state):
        if not isinstance(state, dict):
            return
        field = str(state.get("field", "") or "")
        op = str(state.get("op", "") or "")
        self._values = [str(v) for v in (state.get("values") or [])]
        # Ensure the field appears even before the popup is opened.
        if field and self.field_combo.findText(field) < 0:
            self.field_combo.addItem(field)
        self.field_combo.blockSignals(True)
        self.op_combo.blockSignals(True)
        self.field_combo.setCurrentText(field if field else self._OP_FIELD_PLACEHOLDER)
        self.op_combo.setCurrentText(op if op else self._OP_PLACEHOLDER)
        self.field_combo.blockSignals(False)
        self.op_combo.blockSignals(False)
        self._refresh_value_button()

    def reset(self):
        self._values = []
        self.field_combo.blockSignals(True)
        self.op_combo.blockSignals(True)
        self.field_combo.setCurrentIndex(0)
        self.op_combo.setCurrentIndex(0)
        self.field_combo.blockSignals(False)
        self.op_combo.blockSignals(False)
        self._refresh_value_button()


class GuidedTourDialog(QDialog):
    """BEAVER's single, comprehensive guide popup.

    Left side: a scrollable index with bold headings and clickable entries.
    Right side: the selected entry's title/description, plus Back/Next to walk
    all entries in order. Each entry may anchor one or more widgets: the guide
    opens the panel that hosts them (centre / geoprocessing),
    scrolls them into view, and applies a temporary neon-cyan highlight
    (restored on entry change / close). Non-modal so the app stays usable while
    the guide is open. "Don't show this guide again" persists per-user via
    QSettings; the "❔ Guide" toolbar button re-opens it any time.
    """

    _HIGHLIGHT_SS = "border: 3px solid #00e5ff;"  # neon cyan — red means 'missing field'

    def __init__(self, gui, entries):
        super().__init__(gui)
        self._gui = gui
        self._entries = entries      # list of dicts: heading/title/body/anchor/panel/key
        self._index = 0
        self._highlighted = []       # list of (widget, previous_stylesheet)

        self.setWindowTitle("Guide - Beaver application")
        self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint | Qt.Tool)
        self.resize(720, 470)

        outer = QVBoxLayout(self)
        outer.setSpacing(8)

        split = QHBoxLayout()
        split.setSpacing(10)

        # ---- Left: index / legend --------------------------------------------
        self._index_list = QListWidget()
        self._index_list.setFixedWidth(230)
        self._index_list.setStyleSheet("QListWidget::item { padding: 3px 6px; }")
        # Build items: headings are non-selectable separators; entries map to
        # their index in self._entries via _row_to_entry.
        self._row_to_entry = {}   # list row -> entry index
        self._entry_to_row = {}   # entry index -> list row
        last_heading = None
        for ei, e in enumerate(self._entries):
            if e["heading"] != last_heading:
                last_heading = e["heading"]
                h_item = QListWidgetItem(e["heading"])
                h_item.setFlags(Qt.NoItemFlags)
                f = h_item.font(); f.setBold(True); h_item.setFont(f)
                self._index_list.addItem(h_item)
            item = QListWidgetItem("   " + e["title"])
            self._index_list.addItem(item)
            row = self._index_list.count() - 1
            self._row_to_entry[row] = ei
            self._entry_to_row[ei] = row
        self._index_list.currentRowChanged.connect(self._on_index_clicked)
        split.addWidget(self._index_list)

        # ---- Right: content pane ----------------------------------------------
        right = QVBoxLayout()
        right.setSpacing(8)

        self._title_lbl = QLabel("")
        self._title_lbl.setStyleSheet("font-weight: bold; font-size: 12pt;")
        self._title_lbl.setWordWrap(True)
        right.addWidget(self._title_lbl)

        self._body_lbl = QLabel("")
        self._body_lbl.setWordWrap(True)
        self._body_lbl.setAlignment(Qt.AlignTop | Qt.AlignLeft)
        self._body_lbl.setTextFormat(Qt.RichText)  # bodies use per-button HTML markup
        body_scroll = QScrollArea()
        body_scroll.setWidgetResizable(True)
        body_scroll.setFrameShape(QFrame.NoFrame)
        body_scroll.setWidget(self._body_lbl)
        right.addWidget(body_scroll, 1)

        self._counter_lbl = QLabel("")
        self._counter_lbl.setStyleSheet("font-style: italic;")
        right.addWidget(self._counter_lbl)

        split.addLayout(right, 1)
        outer.addLayout(split, 1)

        # ---- Bottom row ---------------------------------------------------------
        bottom = QHBoxLayout()
        self._dont_show_chk = QCheckBox("Don't show this guide again")
        self._dont_show_chk.setChecked(
            QSettings("BEAVER", "Beaver").value("tour/disabled", False, type=bool)
        )
        bottom.addWidget(self._dont_show_chk)
        bottom.addStretch(1)
        self._back_btn = QPushButton("🡸 Back")
        self._back_btn.clicked.connect(self._on_back)
        self._next_btn = QPushButton("Next 🡺")
        self._next_btn.clicked.connect(self._on_next)
        bottom.addWidget(self._back_btn)
        bottom.addWidget(self._next_btn)
        outer.addLayout(bottom)

        self._show_entry(0)

    # -- navigation --------------------------------------------------------------
    def _on_index_clicked(self, row):
        ei = self._row_to_entry.get(row)
        if ei is not None and ei != self._index:
            self._show_entry(ei)

    def _on_back(self):
        if self._index > 0:
            self._show_entry(self._index - 1)

    def _on_next(self):
        if self._index >= len(self._entries) - 1:
            self.close()  # Finish
        else:
            self._show_entry(self._index + 1)

    def jump_to_key(self, key):
        """Jump to the entry whose 'key' matches (used by the section guide buttons)."""
        for ei, e in enumerate(self._entries):
            if e.get("key") == key:
                self._show_entry(ei)
                return

    def _show_entry(self, i):
        self._restore_highlight()
        self._index = i
        e = self._entries[i]

        self._title_lbl.setText(e["title"])
        self._body_lbl.setText(e["body"])
        self._counter_lbl.setText(f"Entry {i + 1} of {len(self._entries)}")
        self._back_btn.setEnabled(i > 0)
        self._next_btn.setText("Finish" if i == len(self._entries) - 1 else "Next 🡺")

        # Sync the index list without re-triggering navigation.
        row = self._entry_to_row.get(i)
        if row is not None:
            self._index_list.blockSignals(True)
            self._index_list.setCurrentRow(row)
            self._index_list.blockSignals(False)

        # Open the panel hosting this entry's controls, if needed.
        panel = e.get("panel")
        try:
            if panel == "right":
                btn = getattr(self._gui, "_toggle_right_btn", None)
                if btn is not None and not btn.isChecked():
                    btn.setChecked(True)
                    self._gui._toggle_right_panel(True)
        except Exception:
            pass

        # Resolve anchors (single widget or list), scroll to the first, highlight all.
        anchors = []
        anchor_getter = e.get("anchor")
        if anchor_getter is not None:
            try:
                result = anchor_getter()
                if isinstance(result, (list, tuple)):
                    anchors = [w for w in result if w is not None]
                elif result is not None:
                    anchors = [result]
            except Exception:
                anchors = []
        if anchors:
            try:
                scroll_attr = "_right_scroll" if panel == "right" else "_left_scroll"
                scroll = getattr(self._gui, scroll_attr, None)
                if scroll is not None:
                    scroll.ensureWidgetVisible(anchors[0], 50, 50)
            except Exception:
                pass
            for w in anchors:
                try:
                    # Plain QWidget containers (e.g. the query builder) only paint
                    # stylesheet borders when styled-background is enabled.
                    w.setAttribute(Qt.WA_StyledBackground, True)
                    prev_ss = w.styleSheet()
                    w.setStyleSheet(prev_ss + self._HIGHLIGHT_SS)
                    self._highlighted.append((w, prev_ss))
                except Exception:
                    pass

    def _restore_highlight(self):
        for w, prev_ss in self._highlighted:
            try:
                w.setStyleSheet(prev_ss)
            except Exception:
                pass
        self._highlighted = []

    # -- teardown ------------------------------------------------------------------
    def closeEvent(self, event):
        self._restore_highlight()
        try:
            QSettings("BEAVER", "Beaver").setValue(
                "tour/disabled", self._dont_show_chk.isChecked()
            )
        except Exception:
            pass
        super().closeEvent(event)


class GUI_class(QWidget):

    def _reset_pan(self):
        self._pan_dx_m = 0.0
        self._pan_dy_m = 0.0

    def _restore_build_button(self):
        """Swap back to the BUILD button and clear the re-entry guard."""
        self._build_btn_stack.layout().setCurrentIndex(0)
        self.generate_button.setEnabled(True)
        self._building = False

    def _apply_checkbox_visibility_to_layer_tree(self):
        """
        Make GUI layer checkboxes authoritative by writing their state
        into the QGIS layer tree. This is the ONLY place where
        checkbox state becomes 'real' visibility.
        """
        project = core_project()
        root = project.layerTreeRoot()

        for layer_name, checkbox in self.layer_checkboxes.items():
            want_visible = checkbox.isChecked()

            for layer in project.mapLayers().values():
                if layer.name() == layer_name:
                    node = root.findLayer(layer.id())
                    if node:
                        node.setItemVisibilityChecked(want_visible)

    def _nudge_map(self, dx_frac=0.0, dy_frac=0.0):
        """
        Pan the main map by a fraction of the current frame extent:
        dx_frac: +right / -left   (e.g., +0.05 = +5% of width)
        dy_frac: +up    / -down   (e.g., +0.05 = +5% of height)

        Also updates the GUI pan inputs (km) so subsequent BUILD/EXPORT match the preview.
        """
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            QMessageBox.information(self, "No layout", "Click BUILD first to create the map preview.")
            return

        map_item = layout.itemById("Map_frame")
        if not map_item:
            QMessageBox.warning(self, "Map frame not found", "The layout doesn't contain 'Map_frame'.")
            return

        # Current extent
        ext = map_item.extent()
        w = ext.width()
        h = ext.height()

        dx_m = float(dx_frac) * w
        dy_m = float(dy_frac) * h

        # ✅ Accumulate persistent pan offsets
        self._pan_dx_m += dx_m
        self._pan_dy_m += dy_m

        # Apply to preview extent
        new_ext = QgsRectangle(
            ext.xMinimum() + dx_m,
            ext.yMinimum() + dy_m,
            ext.xMaximum() + dx_m,
            ext.yMaximum() + dy_m
        )

        map_item.setExtent(new_ext)
        map_item.refresh()
        layout.refresh()

        # Keep the preview scale label roughly in sync (scale itself is unchanged here)
        try:
            frame_scale_lbl = self.preview_label
            raw_scale = int(round(map_item.scale()))
            frame_scale_lbl.setText(f"Current scale = 1:{raw_scale:,}")
        except Exception:
            pass

    def _zoom_map(self, factor: float):
        """
        Zoom the main map frame by a multiplicative factor.
        factor < 1.0 → zoom in
        factor > 1.0 → zoom out
        """

        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            QMessageBox.information(self, "No layout", "Click BUILD first to create the map preview.")
            return

        map_item = layout.itemById("Map_frame")
        if not map_item:
            QMessageBox.warning(self, "Map frame not found", "The layout doesn't contain 'Map_frame'.")
            return

        ext = map_item.extent()
        if not ext or not ext.isFinite():
            QMessageBox.warning(self, "Extent unavailable", "Map extent is invalid; try BUILD again.")
            return

        cx = ext.center().x()
        cy = ext.center().y()

        self._scale_factor = 1.0

        new_w = ext.width() * factor
        new_h = ext.height() * factor
        self._scale_factor *= factor
        
        new_extent = QgsRectangle(
            cx - new_w / 2.0,
            cy - new_h / 2.0,
            cx + new_w / 2.0,
            cy + new_h / 2.0,
        )

        map_item.setExtent(new_extent)

        try:
            self._user_scale = map_item.scale()
        except Exception:
            self._user_scale = None

        map_item.refresh()
        layout.refresh()

        # Update preview scale label (read-only)
        try:
            raw_scale = int(round(map_item.scale()))
            self.preview_label.setText(f"Current scale = 1:{raw_scale:,}")
        except Exception:
            pass

        # Export must be rebuilt after any visual change
        self.export_final_button.setEnabled(False)




    def _pan_layout_view_to_page(self, fit_width=True):
        """
        Center (and optionally fit) the layout view on the current page (i.e., the layout canvas),
        not on the union of items.
        """
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            return
        try:
            pc = layout.pageCollection()
            # Try to get the current page; fall back to the first
            page = pc.page(pc.currentPage()) if hasattr(pc, "currentPage") else (pc.pages()[0] if pc.pageCount() else None)
            if not page:
                # Fallback: just fit to layout width
                try:
                    self.layout_view.zoomWidth()
                except Exception:
                    pass
                return

            rect = page.sceneBoundingRect()
            if fit_width:
                # Fit to page width with aspect ratio preserved
                self.layout_view.fitInView(rect, Qt.KeepAspectRatio)
            # Center on the page either way
            self.layout_view.centerOn(rect.center())

        except Exception:
            # Safe fallback
            try:
                self.layout_view.zoomWidth()
            except Exception:
                pass

    def _icon_for_symbol(self, symbol, *, key=None, w=32, h=32, dpr=None) -> QIcon:
        """
        Render a QgsSymbol into a QIcon and cache it.
        key: a hashable cache key (e.g., (geom_type, style_name))
        w,h: logical pixel size of the preview (pre-DPR)
        dpr: devicePixelRatio override; if None, try reading from the layout view
        """
        if key and key in self._symbol_icon_cache:
            return self._symbol_icon_cache[key]

        try:
            # Determine DPR (helps HiDPI). If layout_view exists, use its DPR.
            if dpr is None:
                try:
                    dpr = float(getattr(self.layout_view, "devicePixelRatioF", lambda: 1.0)())
                except Exception:
                    dpr = 1.0

            # Backing image in device pixels
            w_px = max(16, int(round(w * dpr)))
            h_px = max(16, int(round(h * dpr)))

            # Render symbol. Using asImage keeps parity with your style dropdown swatches.
            img = symbol.asImage(QSize(w_px, h_px))
            if img is None or (hasattr(img, "isNull") and img.isNull()):
                # Fallback: an empty icon rather than raising
                return QIcon()

            # Convert to pixmap and normalize DPR for crispness
            pm = QPixmap.fromImage(img)
            try:
                pm.setDevicePixelRatio(dpr)
            except Exception:
                pass

            icon = QIcon(pm)
            if key:
                self._symbol_icon_cache[key] = icon
            return icon
        except Exception:
            return QIcon()

    def _sync_layout_view(self):
        lm = core_project().layoutManager()
        layout_name = "Map_layout"
        layout = lm.layoutByName(layout_name)
        if not layout:
            QMessageBox.warning(self, "Layout not found", f"Layout '{layout_name}' not found in the project.")
            return

        # MUST: attach the scene to the view so it can render
        self.layout_view.setCurrentLayout(layout)

        # keep the select/move/resize tool active
        try:
            if hasattr(self, "_tool_select") and self._tool_select is not None:
                self._tool_select.setLayout(layout)
                self.layout_view.setTool(self._tool_select)
        except Exception as e:
            logger.warning(f"Could not activate layout select tool: {e}")

        # Pan/fit to the page (canvas), not layout items
        self._pan_layout_view_to_page(fit_width=True)
        self.layout_view.setEnabled(True)
        try:
            self.layout_view.setFocus()
        except Exception:
            pass

    def _unlock_builtin_moveables(self):
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            return
        for item_id in ("Legend", "Loc_diag"):
            it = layout.itemById(item_id)
            if it:
                try:
                    it.setCustomProperty("beaver_overlay", True)  # treated like user overlays
                    it.setLocked(False)
                    it.setZValue(max(1001.0, getattr(it, "zValue", lambda: 1001.0)()))
                except Exception:
                    pass
        # Re-apply lock policy so only overlays + these two stay moveable
        self._lock_all_but_overlays()

    def _lock_all_but_overlays(self):
        """Lock every layout item except those we created (beaver_overlay=True)."""
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            return
        for it in layout.items():
            if not isinstance(it, QgsLayoutItem):
                continue
            is_overlay = bool(it.customProperty("beaver_overlay", False))
            try:
                it.setLocked(not is_overlay)  # lock non-overlays; unlock overlays
            except Exception:
                pass
        layout.refresh()

    def _mark_as_overlay(self, item):
        """Tag an item as GUI overlay and ensure it's unlocked/moveable."""
        try:
            self._overlay_n += 1
        except AttributeError:
            self._overlay_n = 1
        try:
            item.setId(f"OV_{self._overlay_n}")
        except Exception:
            pass
        item.setCustomProperty("beaver_overlay", True)
        try:
            item.setLocked(False)  # make sure user can move/resize it
            item.setZValue(1500)  # above most items (legend in your project sits ~1000)
        except Exception:
            pass

    # ── Interactive drawing mode (line / polygon) ─────────────────────────────

    def _start_drawing_mode(self, symbol, mode):
        """Activate vertex-drawing mode for a Line or Polygon symbol overlay.

        The user clicks vertices on the layout canvas; double-click (or Enter)
        finishes the shape.  Right-click or Escape cancels.
        """
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            QMessageBox.information(self, "No layout",
                "Build the map first before inserting overlay shapes.")
            return
        # Cancel any in-progress drawing session before starting a new one
        self._cancel_drawing()
        shape_name = "line" if mode == "line" else "polygon"
        QMessageBox.information(
            self, f"Draw {shape_name}",
            f"Click on the map to add vertices.\n"
            f"Double-click (or press Enter) to finish.\n"
            f"Right-click or press Escape to cancel."
        )
        self._drawing_tool = _DrawingTool(self.layout_view, symbol, mode, parent=self)
        self._drawing_tool.finished.connect(self._finish_drawing)
        self._drawing_tool.cancelled.connect(self._cancel_drawing)

    def _cancel_drawing(self):
        """Cancel any active drawing session and restore the select tool."""
        tool = getattr(self, "_drawing_tool", None)
        if tool:
            try:
                tool.finished.disconnect()
                tool.cancelled.disconnect()
            except Exception:
                pass
            tool.remove()
            self._drawing_tool = None
        try:
            self.layout_view.setTool(self._tool_select)
        except Exception:
            pass

    def _finish_drawing(self, points, symbol, mode):
        """Called by _DrawingTool when the user finalises a shape.

        Creates a QgsLayoutItemPolyline or QgsLayoutItemPolygon, applies the
        selected symbol, and registers it as a moveable overlay.
        """
        tool = getattr(self, "_drawing_tool", None)
        if tool:
            try:
                tool.finished.disconnect()
                tool.cancelled.disconnect()
            except Exception:
                pass
            tool.remove()
            self._drawing_tool = None

        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout or len(points) < 2:
            return

        poly = QPolygonF(points)

        if mode == "line":
            item = QgsLayoutItemPolyline(layout)
            item.setNodes(poly)
            sym = symbol.clone() if hasattr(symbol, "clone") else symbol
            if isinstance(sym, QgsLineSymbol):
                item.setSymbol(sym)
        else:  # polygon
            item = QgsLayoutItemPolygon(layout)
            item.setNodes(poly)
            sym = symbol.clone() if hasattr(symbol, "clone") else symbol
            if isinstance(sym, QgsFillSymbol):
                item.setSymbol(sym)

        layout.addLayoutItem(item)
        self._mark_as_overlay(item)
        layout.refresh()
        self._lock_all_but_overlays()
        try:
            self.layout_view.setTool(self._tool_select)
        except Exception:
            pass

    def populate_dropdown_with_icons(self, dropdown, geom_type):
        dropdown.clear()
        dropdown.addItem("Select style")
        dropdown.addItem("No symbol")

        valid_types = ["Point", "Line", "Polygon"]
        if geom_type == "all":
            types_to_load = valid_types
        elif geom_type == "point_line":
            types_to_load = ["Point", "Line"]
        elif geom_type in valid_types:
            types_to_load = [geom_type]
        else:
            print(f"Invalid geometry type for symbology: {geom_type}")
            return

        for gtype in types_to_load:
            style_list = self.styles.get(gtype, [])
            for name, symbol in style_list:
                try:
                    # Render a small swatch image for the symbol
                    img = symbol.asImage(QSize(32, 32))
                    # Some symbols can return a null image; guard it.
                    if img is None or (hasattr(img, "isNull") and img.isNull()):
                        raise RuntimeError("Null QImage returned from symbol.asImage")

                    pixmap = QPixmap.fromImage(img)
                    icon = QIcon(pixmap) if not pixmap.isNull() else QIcon()
                except Exception as e:
                    # Fallback: add without icon so the dropdown still works
                    logger.warning(f"Icon render failed for style '{name}': {e}")
                    icon = QIcon()
                dropdown.addItem(icon, name)

    @staticmethod
    def _make_ramp_icon(ramp_name: str, width: int = 80, height: int = 12) -> "QIcon":
        """Return a QIcon showing the gradient preview for the named colour ramp.

        The icon is a small horizontal strip painted with the ramp colours so
        the user can visually identify each ramp in the dropdown without having
        to remember its name.  Falls back to an empty QIcon if the ramp cannot
        be resolved.
        """
        ramp = None
        try:
            ramp = QgsStyle.defaultStyle().colorRamp(ramp_name)
        except Exception:
            pass
        if not ramp:
            try:
                ramp = (core_project()
                        .styleSettings().projectStyle().colorRamp(ramp_name))
            except Exception:
                pass
        if not ramp:
            return QIcon()

        img = QImage(width, height, QImage.Format_ARGB32)
        for x in range(width):
            t = x / max(width - 1, 1)
            c = ramp.color(t)
            for y in range(height):
                img.setPixel(x, y, c.rgba())
        return QIcon(QPixmap.fromImage(img))

    def _toggle_label_controls(self, enable_checkbox, field_dropdown, size_dropdown):
        is_on = enable_checkbox.isChecked()
        field_dropdown.setEnabled(is_on)
        size_dropdown.setEnabled(is_on)

    def populate_field_dropdown(self, filepath, dropdown, style_dropdown=None):
        layer = QgsVectorLayer(filepath, "temp", "ogr")
        if layer.isValid():
            current_selection = dropdown.currentText().strip()
            dropdown.clear()
            dropdown.addItem(dropdown.placeholderText())  # Add null option
            field_names = [field.name() for field in layer.fields()]
            dropdown.addItems(field_names)
            if current_selection in field_names:
                dropdown.setCurrentText(current_selection)

    # === Field-query builder providers ==========================================
    def _query_providers_for_layer(self, layer_getter):
        """Build (fields_fn, values_fn, fieldtype_fn) callables for a layer getter."""
        def fields_fn():
            lyr = layer_getter()
            return [f.name() for f in lyr.fields()] if lyr and lyr.isValid() else []

        def values_fn(field):
            lyr = layer_getter()
            if not lyr or not lyr.isValid():
                return []
            idx = lyr.fields().indexOf(field)
            if idx < 0:
                return []
            out = []
            try:
                for v in lyr.dataProvider().uniqueValues(idx):
                    if v is None:
                        continue
                    s = str(v).strip()
                    if s:
                        out.append(s)
            except Exception as e:
                logger.warning(f"query values lookup failed for '{field}': {e}")
            return out

        def fieldtype_fn(field):
            lyr = layer_getter()
            if not lyr or not lyr.isValid():
                return False
            idx = lyr.fields().indexOf(field)
            return lyr.fields().at(idx).isNumeric() if idx >= 0 else False

        return fields_fn, values_fn, fieldtype_fn

    def _preloaded_layer_getter(self, layer_name):
        """Return a callable yielding the live project vector layer of that name."""
        def getter():
            for lyr in core_project().mapLayers().values():
                if lyr.name() == layer_name and isinstance(lyr, QgsVectorLayer):
                    return lyr
            return None
        return getter

    def _file_layer_getter(self, path_getter):
        """Return a callable yielding a (cached) QgsVectorLayer for a row's file path."""
        def getter():
            path = (path_getter() or "").strip()
            if not path:
                return None
            cache = self._query_file_layer_cache
            lyr = cache.get(path)
            if lyr is None or not lyr.isValid():
                lyr = QgsVectorLayer(path, "q_src", "ogr")
                cache[path] = lyr
            return lyr
        return getter

    def _make_preloaded_query_widget(self, layer_name):
        w = FieldQueryWidget()
        w.set_providers(*self._query_providers_for_layer(
            self._preloaded_layer_getter(layer_name)))
        return w

    def _make_file_query_widget(self, path_getter):
        w = FieldQueryWidget()
        w.set_providers(*self._query_providers_for_layer(
            self._file_layer_getter(path_getter)))
        return w

    # === Guided tour ============================================================
    def _tour_entries(self):
        """All guide entries: dicts with heading/title/body/anchor/panel/key.

        Ordered top-to-bottom through the workflow; the index sidebar groups
        them under their headings. Geoprocessing content is distilled from the
        former per-section HTML guides.
        """
        def E(heading, title, body, anchor=None, panel=None, key=None):
            return {"heading": heading, "title": title, "body": body,
                    "anchor": anchor, "panel": panel, "key": key}

        H1 = "Getting started"
        H2 = "Map setup"
        H3 = "Area of interest"
        H4 = "Map layers"
        H5 = "Build & preview"
        H6 = "Metadata & export"
        H7 = "Geoprocessing panel"
        H8 = "Presets & help"
        geo = lambda k: (lambda k=k: getattr(self, "_geo_sections", {}).get(k))

        def _vals(attr):
            """All widgets of a per-layer dict, as a list (rename-proof anchors)."""
            return [w for w in getattr(self, attr, {}).values() if w is not None]

        return [
            # ---- Getting started -------------------------------------------------
            E(H1, "Welcome",
              "<p>This app simplifies common geoprocessing workflows in a few guided steps:</p>"
              "<p><b>1.</b> Set the map size &nbsp;<b>2.</b> Set an area "
              "of interest &nbsp;<b>3.</b> Tick the layers you want &nbsp;<b>4.</b> "
              "Press BUILD &nbsp;<b>5.</b> Fine-tune the preview &nbsp;<b>6.</b> Fill "
              "in the metadata and EXPORT.</p>"
              "<p>Use <b>Next</b>/<b>Back</b> below to walk through every control in "
              "order, or click any item in the index on the left to jump straight to "
              "it — the matching buttons light up bright blue in the app.</p>"
              "<p>Experienced already? Tick <b>Don't show this guide again</b> and it "
              "will stay out of your way (the ❔ Guide button brings it back).</p>"),
            # ---- Map setup ------------------------------------------------------
            E(H2, "Global map",
              "<p>Every product is a <i>supplemental graphic</i>: a clean, "
              "borderless map for embedding in briefings and documents.</p>"
              "<p><b>Global map</b> — tick for a whole-world WGS84 overview. The "
              "area-of-interest inputs are ignored while this is on; untick it to "
              "return to a focused map.</p>",
              lambda: getattr(self, "world_map_checkbox", None)),
            E(H2, "Width and height",
              "<p>The exported product's physical size, in inches.</p>"
              "<p><b>Map width (inches)</b> / <b>Map height (inches)</b> — both are "
              "required before BUILD (minimum 6 × 6). Larger sizes mean sharper "
              "print output but longer build times.</p>",
              lambda: getattr(self, "width_input", None)),
            # ---- Area of interest -------------------------------------------------
            E(H3, "Focus rows (coords / country / province)",
              "<p>At least one of these three rows is required. They do double "
              "duty: they define your focus <i>and</i> the clicked row (green "
              "border) becomes the <b>active map centre</b> — where the map pans "
              "and zooms to on BUILD.</p>"
              "<p><b>Coordinates of interest</b> — type a longitude and latitude "
              "(DD or DMS per the format radios) to centre on an exact spot; "
              "highest priority when filled.</p>"
              "<p><b>Province(s) of interest</b> — pick one or more provinces/"
              "states from the dropdown (grouped by country); the map frames "
              "them.</p>"
              "<p><b>Country(s) of interest</b> — pick one or more countries; the "
              "map frames them.</p>"
              "<p>These selections are <i>not drawn</i> on the product — they "
              "exist solely to project, frame and pan the map. Use a tile basemap "
              "below for the visual backdrop.</p>",
              lambda: [f for f in getattr(self, "_focus_center_frames", {}).values() if f]),
            # ---- Map layers -------------------------------------------------------
            E(H4, "Tile basemap toggles",
              "<p>The basemap rows are simple on/off toggles — no styling "
              "options. Their appearance is fixed by the tile provider, and they "
              "are streamed from the internet at BUILD time, so pick at most one "
              "as your backdrop. Four are shipped by default:</p>"
              "<p><b>OpenStreetMap</b> — the standard general-purpose street "
              "map.</p>"
              "<p><b>OpenTopoMap</b> — topographic style with contours and "
              "hillshading.</p>"
              "<p><b>CartoDB Positron</b> — a muted light-grey canvas that lets "
              "your own data stand out.</p>"
              "<p><b>CartoDB Dark Matter</b> — the equivalent dark canvas.</p>"
              "<p><b>Adding your own basemaps</b> — this list is built from the "
              "bundled project, so you can add more (including subscription "
              "services). In QGIS, open <i>beaver_master_project.qgz</i> (in the "
              "plugin's folder), add any XYZ / WMS / WMTS tile layer the usual way "
              "(Browser panel → right-click the service → Add Layer), save the "
              "project, then relaunch Beaver — the new layer appears here as its "
              "own toggle. <i>Tip: because a plugin update can overwrite the "
              "bundled project, keep a copy of your edited version.</i></p>"
              "<p>The focus country/province selectors above are <i>not</i> drawn "
              "on the map — they exist solely to frame, project and pan it.</p>",
              lambda: _vals("layer_checkboxes")),
            # ---- Build & preview ---------------------------------------------------
            E(H5, "BUILD",
              "<p><b>B U I L D</b> — renders everything you've configured into the "
              "map preview. Nothing appears on the map until you press it, and any "
              "change to settings (layers, styles, filters, area of interest, "
              "geoprocessing rows) requires another BUILD to take effect.</p>"
              "<p>While building, the button becomes a progress bar. Heavy maps "
              "(tile basemaps, many layers) take longer — tile basemaps are "
              "fetched from the internet at build time.</p>",
              lambda: getattr(self, "_build_btn_stack", None)),
            E(H5, "Pan & zoom",
              "<p>Adjust the built map's view without touching your settings:</p>"
              "<p><b>🡸 🡹 🡻 🡺</b> — nudge the map west / north / south / east. "
              "Each press shifts the centre a fixed fraction of the view.</p>"
              "<p><b>IN</b> / <b>OUT</b> — zoom by changing the map scale.</p>"
              "<p><b>Current scale</b> — the readout beside the preview shows the "
              "resulting 1:n scale; the same scale is printed on the product.</p>"
              "<p>Pan and zoom persist through subsequent BUILDs until you change "
              "the area of interest.</p>",
              lambda: getattr(self, "layout_view", None)),
            E(H5, "Insert text, flags & symbols",
              "<p>Overlay annotations directly onto the built map — then drag them "
              "into position on the preview:</p>"
              "<p><b>Insert text</b> — a free text label; set the wording, size, "
              "colour and optional halo in the dialog that opens.</p>"
              "<p><b>Insert flag</b> — a country flag or organisational logo from "
              "the bundled image library.</p>"
              "<p><b>Insert symbol</b> — a point symbol/icon for marking "
              "facilities, events or units.</p>"
              "<p>Overlays survive re-BUILDs and are included in the EXPORT.</p>",
              lambda: [getattr(self, "btn_add_text", None),
                       getattr(self, "btn_add_graphic", None),
                       getattr(self, "btn_add_symbol", None)]),
            E(H5, "Make labels moveable",
              "<p><b>Make labels moveable</b> — converts the map's automatic "
              "labels into draggable items so you can resolve overlaps and awkward "
              "placements by hand before exporting. Click it after a BUILD, then "
              "drag individual labels on the preview. Re-building resets automatic "
              "placement, so do this as a final touch.</p>",
              lambda: getattr(self, "btn_convert_labels", None)),
            E(H5, "Refresh export preview",
              "<p><b>Refresh export preview</b> — re-renders the exact "
              "print-resolution PNG that EXPORT will produce and shows it at 100% "
              "scale in a scrollable box below. Use it to check fine details "
              "(label collisions, line weights, legibility) that the small "
              "preview can hide, before committing to an export.</p>",
              lambda: getattr(self, "btn_refresh_png_preview", None)),
            # ---- Metadata & export --------------------------------------------------
            E(H6, "Map metadata",
              "<p>Mandatory before exporting — these fields populate the product's "
              "metadata block and its unique product ID (PID):</p>"
              "<p><b>Recipient</b> — who the product is for.</p>"
              "<p><b>Map content</b> — a one-line description of what the map "
              "shows.</p>"
              "<p><b>Producer</b> — who made it.</p>"
              "<p><b>Producer code</b> — a short (2-letter) code folded into the "
              "product ID, e.g. 20260629XX01.</p>",
              lambda: getattr(self, "metadata_group", None)),
            E(H6, "Additional data sources",
              "<p><b>Additional data sources</b> — required whenever the map "
              "includes your own data (custom points, CSVs, shapefiles, KMLs). "
              "List the originators here; the text is appended to the map's "
              "data-sources note so the product credits every source. Preloaded "
              "layers are credited automatically.</p>",
              lambda: getattr(self, "custom_sources_input", None)),
            E(H6, "EXPORT",
              "<p><b>E X P O R T</b> — greyed out until a successful BUILD, and "
              "re-greyed whenever you change a setting (rebuild to re-enable). It "
              "asks for a format and destination, then saves:</p>"
              "<p>• the finished product as <b>PNG</b>, <b>JPG</b> or <b>PDF</b> "
              "at print resolution;</p>"
              "<p>• a companion <b>QGIS project (.qgz)</b> so the map can be "
              "opened and refined in QGIS Desktop;</p>"
              "<p>• an archived copy for record-keeping, and an analytics log "
              "entry.</p>",
              lambda: getattr(self, "export_final_button", None)),
            # ---- Geoprocessing panel -------------------------------------------------
            E(H7, "About the geoprocessing panel",
              "<p>Opened with the toggle at the top right, this panel adds "
              "<i>your own</i> data to the map. Five sections, top to bottom: "
              "custom points &amp; range rings, CSV upload, shapefile/KML upload, "
              "hexagon bins, and measure distances.</p>"
              "<p><b>Consult user guide</b> — each section's button opens this "
              "guide at that section.</p>"
              "<p><b>Right-click → Remove</b> — clears a row completely (file, "
              "dropdowns, styles).</p>"
              "<p>As everywhere: changes only appear after another <b>BUILD</b>.</p>",
              panel="right",
              anchor=lambda: getattr(self, "_right_scroll", None)),
            E(H7, "Custom points & range rings",
              "<p>Place up to 25 hand-typed markers, each with an optional range "
              "ring:</p>"
              "<p><b>DD / DMS radios</b> — coordinate format for <i>all</i> rows "
              "in this section (decimal degrees, or packed degrees-minutes-seconds "
              "like 253043).</p>"
              "<p><b>Longitude / Latitude / Label</b> — the point's position and "
              "its on-map text (blank label = unlabelled marker).</p>"
              "<p><b>Select style + Customize</b> (first pair) — the point "
              "marker's symbol and fine-tuning.</p>"
              "<p><b>Radius (km)</b> — optional; draws a range ring of that ground "
              "distance around the point.</p>"
              "<p><b>Select style + Customize</b> (second pair) — the ring's "
              "outline/fill styling.</p>"
              "<p><i>Tip: a second row at the same coordinates with 'No symbol' as "
              "the point style draws a second, concentric ring.</i></p>",
              panel="right", key="points", anchor=geo("points")),
            E(H7, "CSV upload",
              "<p>Puts spreadsheet data on the map (up to 10 files):</p>"
              "<p><b>Browse for CSV</b> — pick the file; its header row fills the "
              "column dropdowns.</p>"
              "<p><b>DD / DMS</b> — the file's coordinate format (set per "
              "file).</p>"
              "<p><b>Longitude / Latitude column</b> — which columns hold the "
              "coordinates.</p>"
              "<p><b>Select style + Customize</b> — the symbol; the editor "
              "switches between point and line options with the mode radios.</p>"
              "<p><b>Label field / Label size</b> — optional per-point text from "
              "a column, 6–20 pt.</p>"
              "<p><b>Display as points / Draw line</b> — one marker per row, or "
              "all rows connected in file order (routes and tracks — sort the "
              "file first).</p>",
              panel="right", key="csv", anchor=geo("csv")),
            E(H7, "Shapefile / KML upload",
              "<p>Adds existing GIS layers (up to 10): shapefiles (keep the .dbf/"
              ".shx/.prj sidecars beside the .shp) or KML files. Geometry — point, "
              "line or polygon — is detected on load.</p>"
              "<p><b>Browse for Shp/KML</b> — pick the file; its attribute table "
              "fills the field dropdowns.</p>"
              "<p><b>Select style + Customize</b> — symbol and fine-tuning, "
              "matched to the detected geometry.</p>"
              "<p><b>Query builder</b> — the same field / operation / value(s) "
              "filter as the preloaded layers, run against this file's fields.</p>"
              "<p><b>Label + Label field + Label size</b> — annotate features "
              "from an attribute.</p>"
              "<p><b>Categorize + Category field + Categorized symbols</b> — "
              "colour features by an attribute with a ramp; disables Customize "
              "while on.</p>",
              panel="right", key="shp", anchor=geo("shp")),
            E(H7, "Hexagon bin aggregation",
              "<p>Turns a dense point layer into a readable density surface — "
              "hexagons coloured by how many points fall inside (stored in the "
              "NUMPOINTS field):</p>"
              "<p><b>Browse for points</b> — the input layer (.shp / .kml / .kmz "
              "/ .gpkg).</p>"
              "<p><b>Diameter</b> — hexagon size, flat-to-flat, 5–500 km. Smaller "
              "shows finer structure; larger suits sparse data.</p>"
              "<p><b>Colour ramp</b> — the graduated scheme; sequential ramps "
              "(Blues, Viridis) read best.</p>"
              "<p><b>Classes</b> — how many colour bins (2–12).</p>"
              "<p><b>Classification mode</b> — <i>Equal Count</i> (same number of "
              "hexes per class), <i>Equal Interval</i> (equal value ranges), or "
              "<i>Natural Breaks (Jenks)</i> (boundaries at natural gaps).</p>"
              "<p><b>Customize</b> — outline only; the ramp owns the fill.</p>"
              "<p><b>Query / Label controls</b> — pre-filter which points are "
              "counted; label hexes (e.g. with NUMPOINTS).</p>",
              panel="right", key="hex", anchor=geo("hex")),
            E(H7, "Measure distances",
              "<p>Draws measured lines from a Source to up to two Targets per "
              "group — four groups, so up to eight lines:</p>"
              "<p><b>Longitude / Latitude</b> — type a row's position directly, "
              "OR</p>"
              "<p><b>Browse</b> — load it from a point file (.shp / .kml / .csv); "
              "for CSVs, pick the <b>Lon/Lat columns</b>. Typing disables the "
              "file controls and vice-versa — right-click → Remove to switch "
              "methods.</p>"
              "<p><b>Style + Customize</b> (per group) — the line's appearance; "
              "dashed lines are the cartographic convention for measurements.</p>"
              "<p><b>Label distances + Label size</b> (per group) — print each "
              "line's measured ground distance on the map.</p>",
              panel="right", key="dist", anchor=geo("dist")),
            # ---- Presets & help --------------------------------------------------------
            E(H8, "Save / Load preset buttons",
              "<p>Presets as portable files — save a setup once, reload it any "
              "time:</p>"
              "<p><b>Save new preset</b> — writes every current setting (layers, "
              "styles, filters, metadata, geoprocessing rows) to a JSON file of "
              "your choosing.</p>"
              "<p><b>Load saved preset</b> — restores a previously saved file. "
              "Press BUILD afterwards to render it.</p>",
              lambda: [getattr(self, "btn_save_preset", None),
                       getattr(self, "btn_load_preset", None)]),
            E(H8, "Re-opening this guide",
              "<p><b>❔ Guide</b> — click it in the toolbar any time to re-open "
              "this guide, jump around via the index, or untick \"Don't show this "
              "guide again\" to re-enable it at startup.</p>",
              lambda: getattr(self, "_guide_btn", None)),
        ]

    def show_guided_tour(self, force=False, jump_to=None):
        """Show the guide unless the user disabled it (or force=True).

        ``jump_to`` selects the entry with that key (used by the per-section
        "Consult user guide" buttons).
        """
        if not force:
            try:
                if QSettings("BEAVER", "Beaver").value("tour/disabled", False, type=bool):
                    return
            except Exception:
                pass
        existing = getattr(self, "_tour_dialog", None)
        if existing is None or not existing.isVisible():
            self._tour_dialog = GuidedTourDialog(self, self._tour_entries())
            self._tour_dialog.show()
        else:
            existing.raise_()
            existing.activateWindow()
        if jump_to:
            self._tour_dialog.jump_to_key(jump_to)

    def _toggle_right_panel(self, checked: bool):
        """
        Expand/collapse the right panel without swapping widgets.
        We keep the centered wrapper on the left permanently and just
        remove/add its side spacers to switch between 'centered' and 'full-width'.
        """

        if checked:
            # ===== OPEN (show right) =====
            self._toggle_right_btn.setText("Close geoprocessing panel 🡺")

            # Make right panel visible
            self._right_scroll.setVisible(True)

            # Remove the centering spacers so left content can stretch evenly
            if self._center_layout is not None:
                if self._center_left_spacer is not None:
                    self._center_layout.removeItem(self._center_left_spacer)
                if self._center_right_spacer is not None:
                    self._center_layout.removeItem(self._center_right_spacer)

            # Lift width cap on the inner frame (so it can share half)
            if hasattr(self, "_left_center_frame_inner") and self._left_center_frame_inner:
                self._left_center_frame_inner.setMaximumWidth(16777215)
                self._left_center_frame_inner.setMinimumWidth(0) 


            # Ensure ~50/50 split: set equal stretches on the row
            # (we added both widgets with stretch=1 already; nothing else required)
            # But if you ever need to reassert:
            self._row_hbox.setStretchFactor(self._left_center_frame, 1)
            self._row_hbox.setStretchFactor(self._right_scroll, 1)

        else:
            # ===== CLOSE (hide right) =====
            self._toggle_right_btn.setText("Open geoprocessing panel 🡸")

            # Hide right panel; left fills the window
            self._right_scroll.setVisible(False)

            # Re‑add centering spacers to keep left content nicely centered
            if self._center_layout is not None:
                if self._center_layout.indexOf(self._center_left_spacer) == -1 and self._center_left_spacer:
                    self._center_layout.insertItem(0, self._center_left_spacer)
                if self._center_layout.indexOf(self._center_right_spacer) == -1 and self._center_right_spacer:
                    self._center_layout.addItem(self._center_right_spacer)

            # Restore pleasant width cap for centered mode
            if hasattr(self, "_left_center_frame_inner") and self._left_center_frame_inner:
                self._left_center_frame_inner.setFixedWidth(1500)
            # Give stretch to the left only (right is hidden, but this keeps intent clear)
            self._row_hbox.setStretchFactor(self._left_center_frame, 1)
            self._row_hbox.setStretchFactor(self._right_scroll, 0)

    def _wrap_left_in_center(self, left_scroll: QScrollArea) -> QWidget:
        """
        Return a QWidget that centers the left scroll area horizontally.
        We keep this wrapper permanently in the splitter's left slot.
        When the right panel is open, we temporarily remove the side spacers so
        the left content stretches edge-to-edge; when closed, we add them back.
        """
        wrapper = QWidget()
        self._center_layout = QHBoxLayout(wrapper)
        self._center_layout.setContentsMargins(0, 0, 0, 0)
        self._center_layout.setSpacing(0)

        # Keep references to the spacers so we can remove/add them later
        self._center_left_spacer  = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum)
        self._center_right_spacer = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum)

        self._center_layout.addItem(self._center_left_spacer)

        # Inner frame holds the actual scroll (easy width capping here if desired)
        self._left_center_frame_inner = QWidget()
        v = QVBoxLayout(self._left_center_frame_inner)
        v.setContentsMargins(0, 0, 0, 0)
        v.setSpacing(0)

        # Initially (collapsed by default), the left content should appear centered
        # by virtue of the side spacers:
        v.addWidget(left_scroll)

        # Gentle width guidance for centered mode (adjust to taste, or comment out)
        # Gentle width guidance for centered mode (matches the parent app).
        self._left_center_frame_inner.setMinimumWidth(1500)
        self._left_center_frame_inner.setMaximumWidth(1700)

        self._center_layout.addWidget(self._left_center_frame_inner)
        self._center_layout.addItem(self._center_right_spacer)
        return wrapper


    
    
    
    def validate_csv_inputs(self):
        # Ensure tracking list exists and matches input count
        if not hasattr(self, "last_validated_csv_paths") or len(self.last_validated_csv_paths) != len(self.csv_inputs):
            self.last_validated_csv_paths = [""] * len(self.csv_inputs)
        for i, input_box in enumerate(self.csv_inputs):
            filename = input_box.text().strip()
            if not filename:
                continue  # Skip empty input boxes

            if not filename.lower().endswith(".csv"):
                filename += ".csv"

            filepath = input_box.text().strip()

            if os.path.exists(filepath):
                if filepath != self.last_validated_csv_paths[i]:
                    self.last_validated_csv_paths[i] = filepath
                    self.populate_field_dropdown(filepath, self.csv_name_field_dropdowns[i])
                    self.populate_field_dropdown(filepath, self.csv_x_field_dropdowns[i])
                    self.populate_field_dropdown(filepath, self.csv_y_field_dropdowns[i])
            else:
                QMessageBox.warning(self, "Validation error", f"CSV not found:\n{filepath}")

    def validate_shp_inputs(self):
        if not hasattr(self, "last_validated_upload_paths") or len(self.last_validated_upload_paths) != len(self.upload_inputs):
            self.last_validated_upload_paths = [""] * len(self.upload_inputs)
        for i, input_box in enumerate(self.upload_inputs):
            filepath = input_box.text().strip()
            if not filepath:
                continue  # Skip empty input boxes
            if os.path.exists(filepath):
                if filepath != self.last_validated_upload_paths[i]:
                    self.last_validated_upload_paths[i] = filepath
                    self.populate_field_dropdown(filepath, self.upload_name_field_dropdowns[i], self.upload_symbology_dropdowns[i])
                    self.populate_field_dropdown(filepath, self.upload_category_field[i])
            else:
                QMessageBox.warning(self, "Validation error", f"Shapefile/KML not found:\n{filepath}")


    def _browse_csv(self, idx):
        path, _ = QFileDialog.getOpenFileName(
            self, "Select CSV file", self._last_browse_dir,
            "CSV files (*.csv);;All files (*.*)"
        )
        if not path:
            return
        self._last_browse_dir = os.path.dirname(path)
        self.csv_inputs[idx].setText(path)
        self.populate_field_dropdown(path, self.csv_name_field_dropdowns[idx])
        self.populate_field_dropdown(path, self.csv_x_field_dropdowns[idx])
        self.populate_field_dropdown(path, self.csv_y_field_dropdowns[idx])

    def _browse_upload(self, idx):
        path, _ = QFileDialog.getOpenFileName(
            self, "Select Shapefile or KML", self._last_browse_dir,
            "Geospatial files (*.shp *.kml);;Shapefiles (*.shp);;KML files (*.kml);;All files (*.*)"
        )
        if not path:
            return
        self._last_browse_dir = os.path.dirname(path)
        self.upload_inputs[idx].setText(path)
        self.populate_field_dropdown(path, self.upload_name_field_dropdowns[idx],
                                     self.upload_symbology_dropdowns[idx])
        self.populate_field_dropdown(path, self.upload_category_field[idx])
        # New source file → clear any stale field/value selection in the filter.
        self._query_file_layer_cache.pop(path, None)
        if idx < len(self.upload_definition_queries):
            self.upload_definition_queries[idx].reset()

    # === Helper: core setup & validators =========================================

    def _setup_core(self):
        """Load project, set window properties, create top-level collections."""
        core_project()  # lazily loads the bundled project (private, not the host's)

        # Window
        self.setWindowTitle("Beaver processor")
        self.setWindowIcon(QIcon(os.path.join(ICONS_DIR, "window_logo.png")))
        self.setMinimumSize(400, 400)
        self.resize(1600, 800)

        # Working state
        self.last_arguments_for_exporter = {}

        # 1) Load augmented style sets (project + global + optional XMLs)
        extra_xmls = []
        styles_db = get_symbologies_by_geometry(core_project(), extra_style_xml_paths=extra_xmls)

        # 2) Harvest renderer symbols from existing layers in the project
        styles_from_layers = harvest_layer_renderer_symbols(core_project())

        # 3) Merge while preserving order: DB styles first, then layer renderers
        self.styles = {
            'Point': styles_db.get('Point', []) + styles_from_layers.get('Point', []),
            'Line': styles_db.get('Line', []) + styles_from_layers.get('Line', []),
            'Polygon': styles_db.get('Polygon', []) + styles_from_layers.get('Polygon', [])
        }
        # --- END PATCH -------------------------------------------------------------


        # Basemaps are discovered from the bundled project rather than hard-coded,
        # so a user who adds an XYZ/WMS/WMTS layer to beaver_master_project.qgz
        # gets a toggle for it automatically (the four defaults are simply the
        # ones shipped in that project).
        self.basemap_names = discover_basemap_names(core_project())

        # Friendly labels for the shipped defaults; any user-added basemap falls
        # back to its own layer name.
        _default_basemap_labels = {
            "OpenStreetMap": "OpenStreetMap basemap",
            "OpenTopoMap": "OpenTopoMap basemap",
            "CartoDb_Positron": "CartoDB Positron (light) basemap",
            "CartoDb_Dark_Matter": "CartoDB Dark Matter basemap",
        }

        self.preloaded_layers = [
            ("Focus_province", "Polygon"),
            ("Focus_country", "Polygon"),
        ] + [(name, "Raster") for name in self.basemap_names]

        self.fixed_geometry_types = {
            "preloaded": {
                "Focus_province": "Polygon",
                "Focus_country": "Polygon",
                **{name: "Raster" for name in self.basemap_names},
            },
            "custom_points": "Point",
            "range_rings": "Polygon",
            "csv": ("Point", "Line"),
            "uploaded": ("Point", "Line", "Polygon"),
        }

        self.layer_aliases = {
            "Focus_province": "Province(s) of interest",
            "Focus_country": "Country(s) of interest",
            **{name: _default_basemap_labels.get(name, name)
               for name in self.basemap_names},
        }

        # Instance-level basemap group for the layer grid (overrides the class
        # default), so the toggles reflect whatever basemaps the project holds.
        self.preloaded_layer_groups = [
            ("Choose Area Of Interest (AT LEAST ONE REQUIRED)", [
                "__focus_coords__",
                "Focus_country",
                "Focus_province",
            ]),
            ("Tile basemaps (internet required)", list(self.basemap_names)),
        ]

    def _create_validators(self):
        """Create and return all regex validators used by metadata inputs."""
        allowed_chars_5 = QRegExp(r"^[a-zA-Z0-9]{2,5}$")
        allowed_chars_5_30 = QRegExp(r"^[a-zA-Z, ]{8,30}$")
        allowed_chars_5_10 = QRegExp(r"^[a-zA-Z0-9]{5,10}$")
        allowed_chars_1_15 = QRegExp(r"^[A-Z_]{1,15}$")
        allowed_chars_class = QRegExp(r"^[a-zA-Z_/, ]{8,35}$")
        allowed_chars_add_sources = QRegExp(r"^[a-zA-Z0-9_/()., ]{0,75}$")
        prod2 = QRegExp(r"^(?:[A-Z]{2})?$")
        
        return {
            "validator_5": QRegExpValidator(allowed_chars_5),
            "validator_5_30": QRegExpValidator(allowed_chars_5_30),
            "validator_5_10": QRegExpValidator(allowed_chars_5_10),
            "validator_1_15": QRegExpValidator(allowed_chars_1_15),
            "validator_class": QRegExpValidator(allowed_chars_class),
            "validator_add_sources": QRegExpValidator(allowed_chars_add_sources),
            "validator_prod2": QRegExpValidator(prod2),
        }

    # === Helper: collections used by other methods ===============================
    def _init_collections(self):
        """Initialize all lists/dicts that other methods expect to exist."""
        self.layer_checkboxes = {}
        self.layer_style_dropdowns = {}
        self.preloaded_label_toggles = {}

        # Map-centre focus selection state
        # "coords" | "Focus_country" | "Focus_province" | None
        self._active_center = None
        # Container frames for the three focus rows (populated in _build_preloaded_layer_grid)
        self._focus_center_frames = {"coords": None, "Focus_country": None, "Focus_province": None}

        self.point_size_dropdowns = []
        self.ring_outline_dropdowns = []
        self.csv_size_dropdowns = []
        self.upload_size_dropdowns = []
        self.csv_label_size_dropdowns = []
        self.upload_label_size_dropdowns = []
        
        self.preloaded_category_enable = {}
        self.preloaded_category_field  = {}
        self.preloaded_category_scheme = {}
        self._symbol_icon_cache = {}
        self._last_browse_dir = ""
        # CSV collections
        self.csv_inputs = []
        self.csv_radio_groups = []
        self.csv_symbology_dropdowns = []
        self.csv_format_groups = []
        self.csv_name_field_dropdowns = []
        self.csv_x_field_dropdowns = []
        self.csv_y_field_dropdowns = []

        # Upload collections
        self.upload_inputs = []
        self.upload_symbology_dropdowns = []
        self.upload_name_field_dropdowns = []
        self.upload_definition_queries = []
        self.last_validated_upload_paths = [""] * len(self.upload_inputs)

        # Custom points
        self.lon_inputs = []
        self.lat_inputs = []
        self.name_inputs = []
        self.point_symbology_dropdowns = []
        self.radius_inputs = []
        self.ring_symbology_dropdowns = []

        self.preloaded_definition_queries = {}
        self.preloaded_label_fields = {}
        self.preloaded_label_sizes = {}
        self.preloaded_size_dropdowns = {}

        # Cache of QgsVectorLayer objects keyed by file path, used by the
        # FieldQueryWidget value pickers for uploaded / hex source files.
        self._query_file_layer_cache = {}

        # Uploaded: categorize controls per row
        self.upload_category_enable = []   # list[QCheckBox]
        self.upload_category_field = []    # list[QComboBox]
        self.upload_category_scheme = []   # list[QComboBox]

        # Uploaded section additions: a label enable checkbox per row
        self.upload_label_toggles = []  # list of QCheckBox aligned to upload row indices

        # Hex-bin aggregation collections (10 rows)
        self.hex_inputs = []                 # list[QLineEdit]  — source point file path
        self.hex_diameter_inputs = []        # list[QSpinBox]   — bin diameter in km
        self.hex_ramp_dropdowns = []         # list[QComboBox]  — QGIS colour ramp name
        self.hex_classes_inputs = []         # list[QSpinBox]   — number of classes
        self.hex_mode_dropdowns = []         # list[QComboBox]  — classification mode
        self.hex_customize_buttons = []      # list[SymbolCustomizationButton]
        self.hex_query_inputs = []           # list[QLineEdit]  — pre-filter on input points
        self.hex_label_toggles = []          # list[QCheckBox]
        self.hex_label_field_dropdowns = []  # list[QComboBox]
        self.hex_label_size_dropdowns = []   # list[QComboBox]

        # Measure-distances section (12 rows, 4 groups of 3)
        self.dist_lon_inputs        = []   # list[QLineEdit]
        self.dist_lat_inputs        = []   # list[QLineEdit]
        self.dist_file_inputs       = []   # list[QLineEdit]  read-only path readout
        self.dist_browse_btns       = []   # list[QPushButton]
        self.dist_x_field_dropdowns = []   # list[QComboBox]  CSV lon column
        self.dist_y_field_dropdowns = []   # list[QComboBox]  CSV lat column
        # Per-group (4 groups)
        self.dist_style_dropdowns   = []   # list[LazyStyleComboBox]
        self.dist_customize_btns    = []   # list[SymbolCustomizationButton]
        self.dist_label_toggles     = []   # list[QCheckBox]
        self.dist_label_size_dds    = []   # list[QComboBox]

    def default_preset_filters(self):
        return "Preset files (*.json);;All files (*.*)"

    def save_preset_dialog(self):
        path, _ = QFileDialog.getSaveFileName(
            self,
            "Save Beaver preset",
            "",
            self.default_preset_filters()
        )
        if not path:
            return
        try:
            state = self.get_preset_state()
            with open(path, "w", encoding="utf-8") as f:
                json.dump(state, f, ensure_ascii=False, indent=2)
            QMessageBox.information(self, "Preset saved.", f"Preset saved to:\n{path}")
        except Exception as e:
            QMessageBox.warning(self, "Save failed", str(e))

    def load_preset_dialog(self):
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Load Beaver preset",
            "",
            self.default_preset_filters()
        )
        if not path:
            return
        try:
            with open(path, "r", encoding="utf-8") as f:
                state = json.load(f)
            self.apply_preset_state(state)
            QMessageBox.information(self, "Preset loaded.", f"Preset loaded from:\n{path}\n\nClick BUILD to preview.")
        except Exception as e:
            QMessageBox.warning(self, "Load failed", str(e))

    def _dropdown_text(self, dd, default=""):
        try:
            txt = dd.currentText().strip()
            return txt if txt else default
        except Exception:
            return default

    def _dropdown_float(self, dd):
        try:
            s = dd.currentText().strip()
            return None if (not s or s.lower() == "size" or s.lower() == "label size") else float(s)
        except Exception:
            return None

    def _widget_symbol_xml(self, widget) -> str:
        """Return the serialised symbol XML for a :class:`SymbolCustomizationButton`.

        Returns an empty string when the widget isn't a customize button or
        when no symbol is set. Used by ``get_preset_state`` to round-trip
        each row's customised symbol through the JSON preset file.
        """
        try:
            if isinstance(widget, SymbolCustomizationButton):
                return widget.serialize()
        except Exception:
            pass
        return ""

    def _int_or_none(self, s):
        try:
            return int(str(s).strip())
        except Exception:
            return None

    def _bool(self, widget):
        try:
            return bool(widget.isChecked())
        except Exception:
            return False

    def _safe_text(self, widget):
        try:
            return widget.text().strip()
        except Exception:
            return ""

    def _query_get_state(self, widget):
        """Structured state {field, op, values} for a FieldQueryWidget, else {}."""
        return widget.get_state() if isinstance(widget, FieldQueryWidget) else {}

    def _query_set_state(self, widget, obj):
        if isinstance(widget, FieldQueryWidget):
            widget.set_state(obj if isinstance(obj, dict) else {})

    def get_preset_state(self):
        """
        Serialize the GUI state into a versioned JSON-serializable dict.
        Uses only your stable collections (dicts/lists), not widget positions.
        """
        state = {
            "schema": PRESET_SCHEMA_VERSION,
            "saved_at": datetime.now(timezone.utc).isoformat(),
            "layout": {
                "world_map": self.world_map_checkbox.isChecked(),
                "width_inches": self._safe_text(self.width_input),
                "height_inches": self._safe_text(self.height_input),
            },
            "coordinates": {
                "format": "DMS" if self.dms_radio.isChecked() else "DD",
                "lon": self._safe_text(self.lon_input),
                "lat": self._safe_text(self.lat_input),
                "user_scale": int(round(self._user_scale)) if getattr(self, "_user_scale", None) else None,
                "active_center": self._active_center,
            },
            "focus": {
                "country": get_selected_names(getattr(self, "focus_country_multiselect", None)),
                "province": get_selected_names(getattr(self, "focus_province_multiselect", None)),
            },
            "preloaded": {
                "layers": {},
                "categorize_fields": {},
                "categorize_schemes": {},
                "categorize_enable": {},
            },
            "custom_points": [],
            "csv": [],
            "uploads": [],
            "metadata": {
                "recipient": self._safe_text(self.recipient_input),
                "producer": self._safe_text(self.producer_input),
                "content": self._safe_text(self.map_content_input),
                "additional_sources": self._safe_text(self.custom_sources_input),
            },
            "export": {
                "format": ("PDF" if self.pdf_radio.isChecked()
                        else "JPG" if self.jpg_radio.isChecked()
                        else "PNG")
            }
        }

        # --- Preloaded layers (visibility, style, size, filter, labels) -------------
        for lname, cb in self.layer_checkboxes.items():
            entry = {
                "visible": self._bool(cb),
                "style": self._dropdown_text(self.layer_style_dropdowns.get(lname), ""),
                "size_mm": self._dropdown_float(self.preloaded_size_dropdowns.get(lname)),
                "symbol_xml": self._widget_symbol_xml(self.preloaded_size_dropdowns.get(lname)),
                "filter_query": self._query_get_state(self.preloaded_definition_queries.get(lname)),
                "label": {
                    "enabled": self._bool(self.preloaded_label_toggles.get(lname, QCheckBox())),
                    "field": self._dropdown_text(self.preloaded_label_fields.get(lname, QComboBox()), ""),
                    "size_pt": self._int_or_none(self._dropdown_text(self.preloaded_label_sizes.get(lname, QComboBox()), "")),
                }
            }
            state["preloaded"]["layers"][lname] = entry

        # Categorize options for preloaded
        for lname, chk in self.preloaded_category_enable.items():
            state["preloaded"]["categorize_enable"][lname] = self._bool(chk)
        for lname, dd in self.preloaded_category_field.items():
            state["preloaded"]["categorize_fields"][lname] = self._dropdown_text(dd, "")
        for lname, dd in self.preloaded_category_scheme.items():
            state["preloaded"]["categorize_schemes"][lname] = self._dropdown_text(dd, "")

        # --- Custom points / rings ---------------------------------------------------
        for i in range(len(self.lon_inputs)):
            lon = self._safe_text(self.lon_inputs[i]); lat = self._safe_text(self.lat_inputs[i])
            if not lon and not lat:
                continue
            state["custom_points"].append({
                "lon": lon, "lat": lat,
                "label": self._safe_text(self.name_inputs[i]),
                "point_style": self._dropdown_text(self.point_symbology_dropdowns[i], ""),
                "point_size_mm": self._dropdown_float(self.point_size_dropdowns[i]),
                "point_symbol_xml": self._widget_symbol_xml(self.point_size_dropdowns[i]),
                "ring_radius_km": self._safe_text(self.radius_inputs[i]),
                "ring_style": self._dropdown_text(self.ring_symbology_dropdowns[i], ""),
                "ring_outline_mm": self._dropdown_float(self.ring_outline_dropdowns[i]),
                "ring_symbol_xml": self._widget_symbol_xml(self.ring_outline_dropdowns[i]),
            })

        # --- CSV rows ----------------------------------------------------------------
        for i in range(len(self.csv_inputs)):
            row = {
                "path": self._safe_text(self.csv_inputs[i]),
                "format": "DMS" if self.csv_format_groups[i][2].isChecked() else "DD",
                "mode": "line" if self.csv_radio_groups[i][2].isChecked() else "point",
                "x_field": self._dropdown_text(self.csv_x_field_dropdowns[i], ""),
                "y_field": self._dropdown_text(self.csv_y_field_dropdowns[i], ""),
                "label_field": self._dropdown_text(self.csv_name_field_dropdowns[i], ""),
                "label_size_pt": self._int_or_none(self._dropdown_text(self.csv_label_size_dropdowns[i], "")),
                "style": self._dropdown_text(self.csv_symbology_dropdowns[i], ""),
                "size_mm": self._dropdown_float(self.csv_size_dropdowns[i]),
                "symbol_xml": self._widget_symbol_xml(self.csv_size_dropdowns[i]),
            }
            if any(row.values()):
                state["csv"].append(row)

        # --- Upload rows -------------------------------------------------------------
        for i in range(len(self.upload_inputs)):
            row = {
                "path": self._safe_text(self.upload_inputs[i]),
                "style": self._dropdown_text(self.upload_symbology_dropdowns[i], ""),
                "size_mm": self._dropdown_float(self.upload_size_dropdowns[i]),
                "symbol_xml": self._widget_symbol_xml(self.upload_size_dropdowns[i]),
                "filter_query": self._query_get_state(self.upload_definition_queries[i]),
                "label": {
                    "enabled": self._bool(self.upload_label_toggles[i]),
                    "field": self._dropdown_text(self.upload_name_field_dropdowns[i], ""),
                    "size_pt": self._int_or_none(self._dropdown_text(self.upload_label_size_dropdowns[i], "")),
                },
                "categorize": {
                    "enabled": self._bool(self.upload_category_enable[i]),
                    "field": self._dropdown_text(self.upload_category_field[i], ""),
                    "ramp": self._dropdown_text(self.upload_category_scheme[i], ""),
                }
            }
            if any([row["path"], row["style"], row["filter_query"], row["label"]["enabled"], row["categorize"]["enabled"]]):
                state["uploads"].append(row)

        # --- Hex bin rows -------------------------------------------------------
        state["hex_bins"] = []
        for i in range(len(self.hex_inputs)):
            state["hex_bins"].append({
                "path": self._safe_text(self.hex_inputs[i]),
                "diameter_km": self.hex_diameter_inputs[i].value(),
                "ramp": self._dropdown_text(self.hex_ramp_dropdowns[i], ""),
                "classes": self.hex_classes_inputs[i].value(),
                "mode": self._dropdown_text(self.hex_mode_dropdowns[i], ""),
                "symbol_xml": self._widget_symbol_xml(self.hex_customize_buttons[i]),
                "query": self._query_get_state(self.hex_query_inputs[i]),
                "label_on": self.hex_label_toggles[i].isChecked(),
                "label_field": self._dropdown_text(self.hex_label_field_dropdowns[i], ""),
                "label_size": self._dropdown_text(self.hex_label_size_dropdowns[i], ""),
            })

        # --- Measure-distances rows / groups ------------------------------------
        state["distance_rows"] = []
        for i in range(len(self.dist_lon_inputs)):
            state["distance_rows"].append({
                "lon":     self._safe_text(self.dist_lon_inputs[i]),
                "lat":     self._safe_text(self.dist_lat_inputs[i]),
                "path":    self._safe_text(self.dist_file_inputs[i]),
                "x_field": self._dropdown_text(self.dist_x_field_dropdowns[i], ""),
                "y_field": self._dropdown_text(self.dist_y_field_dropdowns[i], ""),
            })
        state["distance_groups"] = []
        for g in range(len(self.dist_style_dropdowns)):
            state["distance_groups"].append({
                "style":      self._dropdown_text(self.dist_style_dropdowns[g], ""),
                "symbol_xml": self._widget_symbol_xml(self.dist_customize_btns[g]),
                "label_on":   self.dist_label_toggles[g].isChecked(),
                "label_size": self._dropdown_text(self.dist_label_size_dds[g], ""),
            })

        return state


    def _ensure_style_options(self, dd, geom_hint):
        """
        LazyStyleComboBox items are only loaded on first popup; populate explicitly
        so setCurrentText(...) can find matches by visible text.
        """
        try:
            if hasattr(dd, "loaded") and not dd.loaded:
                self.populate_dropdown_with_icons(dd, geom_hint)
                dd.loaded = True
        except Exception as e:
            logger.warning(f"ensure styles failed: {e}")

    def apply_preset_state(self, state: dict):
        """
        Apply a preset to the GUI. Unknown keys are ignored. Arrays are truncated
        to available rows; extras are ignored. Robust against GUI growth.
        """
        if not isinstance(state, dict):
            raise ValueError("Preset is not a JSON object.")

        # --- Layout / top controls ---------------------------------------------------
        layout = state.get("layout", {})
        self.world_map_checkbox.setChecked(bool(layout.get("world_map", False)))
        self.width_input.setText(str(layout.get("width_inches") or ""))
        self.height_input.setText(str(layout.get("height_inches") or ""))
        if "legend_position" in layout:
            self.legend_position_dropdown.setCurrentText(layout["legend_position"])
        if "locator_position" in layout:
            self.loc_diag_placement_dropdown.setCurrentText(layout["locator_position"])

        # --- Coordinates / scale / pan ----------------------------------------------
        coords = state.get("coordinates", {})
        fmt = (coords.get("format") or "DD").upper()
        self.dd_radio.setChecked(fmt == "DD")
        self.dms_radio.setChecked(fmt == "DMS")
        self.lon_input.setText(str(coords.get("lon") or ""))
        self.lat_input.setText(str(coords.get("lat") or ""))

        # --- Restore user scale (do not apply here; just store it) ---
        coords = state.get("coordinates", {})
        try:
            self._user_scale = (
                float(coords.get("user_scale"))
                if coords.get("user_scale")
                else None
            )
        except Exception:
            self._user_scale = None

        # --- Restore active map-centre selection ---
        saved_center = coords.get("active_center", None)
        if saved_center in ("coords", "Focus_country", "Focus_province", None):
            self._active_center = saved_center
        else:
            self._active_center = None
        # Border refresh happens after focus multiselects are restored (below).

        focus = state.get("focus", {})
        try:
            set_multiselect_selection(self.focus_country_multiselect, focus.get("country", []))
        except Exception:
            pass
        try:
            set_multiselect_selection(self.focus_province_multiselect, focus.get("province", []))
        except Exception:
            pass

        # Refresh the green border now that focus selections are restored.
        self._refresh_focus_borders()

        # --- Preloaded layers --------------------------------------------------------
        P = state.get("preloaded", {})
        layers = P.get("layers", {})
        cat_on  = P.get("categorize_enable", {})
        cat_fld = P.get("categorize_fields", {})
        cat_rmp = P.get("categorize_schemes", {})

        # We’ll block signals on many widgets at once
        all_widgets = []

        for lname, entry in layers.items():
            cb = self.layer_checkboxes.get(lname)
            dd = self.layer_style_dropdowns.get(lname)
            sz = self.preloaded_size_dropdowns.get(lname)
            q  = self.preloaded_definition_queries.get(lname)
            lt = self.preloaded_label_toggles.get(lname)
            lf = self.preloaded_label_fields.get(lname)
            ls = self.preloaded_label_sizes.get(lname)

            for w in (cb, dd, sz, q, lt, lf, ls):
                if w: all_widgets.append(w)

        with signals_blocked(*all_widgets):
            for lname, entry in layers.items():
                cb = self.layer_checkboxes.get(lname)
                if cb is not None:
                    cb.setChecked(bool(entry.get("visible", False)))

                # Style
                dd = self.layer_style_dropdowns.get(lname)
                if dd is not None:
                    geom_hint = self.fixed_geometry_types["preloaded"].get(lname, "Polygon")
                    self._ensure_style_options(dd, geom_hint)
                    name = str(entry.get("style") or "").strip()
                    if name:
                        dd.setCurrentText(name)

                # Customised symbol (preferred) or legacy size_mm fallback.
                # The Style dropdown change above has already reset the
                # button's symbol to a clone of the named style, so loading
                # XML on top of that overrides cleanly. Old presets only
                # have size_mm; we ignore it here because the new editor
                # carries its own size baked into the symbol XML.
                sz = self.preloaded_size_dropdowns.get(lname)
                if sz is not None and isinstance(sz, SymbolCustomizationButton):
                    xml = entry.get("symbol_xml") or ""
                    if xml:
                        sz.load_from_xml(xml)

                # Filter
                q  = self.preloaded_definition_queries.get(lname)
                if q is not None:
                    self._query_set_state(q, entry.get("filter_query") or {})

                # Labels
                lb = entry.get("label", {})
                lt = self.preloaded_label_toggles.get(lname)
                lf = self.preloaded_label_fields.get(lname)
                ls = self.preloaded_label_sizes.get(lname)
                if lt is not None:
                    lt.setChecked(bool(lb.get("enabled", False)))
                if lf is not None:
                    lf.setCurrentText(lb.get("field") or lf.placeholderText())
                if ls is not None:
                    v = lb.get("size_pt")
                    ls.setCurrentText("" if v in (None, "", "Label size") else str(v))

            # Preloaded categorize
            for lname, chk in self.preloaded_category_enable.items():
                chk.setChecked(bool(cat_on.get(lname, False)))
            for lname, dd in self.preloaded_category_field.items():
                dd.setCurrentText(cat_fld.get(lname) or dd.placeholderText())
            for lname, dd in self.preloaded_category_scheme.items():
                dd.setCurrentText(cat_rmp.get(lname) or (dd.itemText(0) if dd.count() else ""))

        # --- Custom points -----------------------------------------------------------
        for i, rec in enumerate(state.get("custom_points", [])):
            if i >= len(self.lon_inputs): break
            self.lon_inputs[i].setText(str(rec.get("lon") or ""))
            self.lat_inputs[i].setText(str(rec.get("lat") or ""))
            self.name_inputs[i].setText(str(rec.get("label") or ""))
            self.radius_inputs[i].setText(str(rec.get("ring_radius_km") or ""))

            # ensure style options exist before set
            pdd = self.point_symbology_dropdowns[i]
            self._ensure_style_options(pdd, "Point")
            pdd.setCurrentText(str(rec.get("point_style") or ""))
            rdd = self.ring_symbology_dropdowns[i]
            self._ensure_style_options(rdd, "Polygon")
            rdd.setCurrentText(str(rec.get("ring_style") or ""))

            # Customised symbols (preferred) — fall through silently for old
            # presets that don't yet have these keys.
            psz = self.point_size_dropdowns[i]
            if isinstance(psz, SymbolCustomizationButton):
                pxml = rec.get("point_symbol_xml") or ""
                if pxml:
                    psz.load_from_xml(pxml)
            rsz = self.ring_outline_dropdowns[i]
            if isinstance(rsz, SymbolCustomizationButton):
                rxml = rec.get("ring_symbol_xml") or ""
                if rxml:
                    rsz.load_from_xml(rxml)

        # --- CSV (first set paths, then validate, then set field dropdowns) ----------
        # Set basic values
        for i, rec in enumerate(state.get("csv", [])):
            if i >= len(self.csv_inputs): break
            self.csv_inputs[i].setText(str(rec.get("path") or ""))
            is_dms = str(rec.get("format", "DD")).upper() == "DMS"
            self.csv_format_groups[i][2].setChecked(is_dms)
            self.csv_format_groups[i][1].setChecked(not is_dms)
            # mode
            is_line = str(rec.get("mode", "point")).lower() == "line"
            self.csv_radio_groups[i][2].setChecked(is_line)
            self.csv_radio_groups[i][1].setChecked(not is_line)
            # style/size after dropdowns are loaded
            self._ensure_style_options(self.csv_symbology_dropdowns[i], "point_line")
            self.csv_symbology_dropdowns[i].setCurrentText(str(rec.get("style") or ""))
            # Customised symbol (preferred) — falls through cleanly for old presets.
            csz = self.csv_size_dropdowns[i]
            if isinstance(csz, SymbolCustomizationButton):
                cxml = rec.get("symbol_xml") or ""
                if cxml:
                    csz.load_from_xml(cxml)
            self.csv_label_size_dropdowns[i].setCurrentText("" if rec.get("label_size_pt") in (None, "") else str(rec.get("label_size_pt")))

        # trigger validators to populate field dropdowns
        try:
            self.validate_csv_inputs()
        except Exception:
            pass

        # now set field dropdowns (they exist post-validate)
        for i, rec in enumerate(state.get("csv", [])):
            if i >= len(self.csv_inputs): break
            self.csv_x_field_dropdowns[i].setCurrentText(str(rec.get("x_field") or self.csv_x_field_dropdowns[i].placeholderText()))
            self.csv_y_field_dropdowns[i].setCurrentText(str(rec.get("y_field") or self.csv_y_field_dropdowns[i].placeholderText()))
            self.csv_name_field_dropdowns[i].setCurrentText(str(rec.get("label_field") or self.csv_name_field_dropdowns[i].placeholderText()))

        # --- Uploads (same idea) -----------------------------------------------------
        for i, rec in enumerate(state.get("uploads", [])):
            if i >= len(self.upload_inputs): break
            self.upload_inputs[i].setText(str(rec.get("path") or ""))

        try:
            self.validate_shp_inputs()
        except Exception:
            pass

        for i, rec in enumerate(state.get("uploads", [])):
            if i >= len(self.upload_inputs): break
            self._ensure_style_options(self.upload_symbology_dropdowns[i], "all")
            self.upload_symbology_dropdowns[i].setCurrentText(str(rec.get("style") or ""))
            # Customised symbol (preferred). Old presets carrying just
            # size_mm fall through — the Style dropdown above has already
            # primed the customize button with a clone of the chosen style.
            usz = self.upload_size_dropdowns[i]
            if isinstance(usz, SymbolCustomizationButton):
                uxml = rec.get("symbol_xml") or ""
                if uxml:
                    usz.load_from_xml(uxml)
            self._query_set_state(self.upload_definition_queries[i], rec.get("filter_query") or {})

            lbl = rec.get("label", {}) or {}
            self.upload_label_toggles[i].setChecked(bool(lbl.get("enabled", False)))
            self.upload_name_field_dropdowns[i].setCurrentText(str(lbl.get("field") or self.upload_name_field_dropdowns[i].placeholderText()))
            self.upload_label_size_dropdowns[i].setCurrentText("" if lbl.get("size_pt") in (None, "") else str(lbl.get("size_pt")))

            cat = rec.get("categorize", {}) or {}
            self.upload_category_enable[i].setChecked(bool(cat.get("enabled", False)))
            self.upload_category_field[i].setCurrentText(str(cat.get("field") or self.upload_category_field[i].placeholderText()))
            self.upload_category_scheme[i].setCurrentText(str(cat.get("ramp") or self.upload_category_scheme[i].itemText(0)))

        specs = state.get("overlays", [])
        # After you build/sync the layout:
        self._restore_movable_specs(specs)
        self._unlock_builtin_moveables()

        # --- Hex bin rows -------------------------------------------------------
        for i, rec in enumerate(state.get("hex_bins", [])):
            if i >= len(self.hex_inputs):
                break
            self.hex_inputs[i].setText(str(rec.get("path") or ""))
            diam = rec.get("diameter_km")
            if diam is not None:
                try:
                    self.hex_diameter_inputs[i].setValue(int(diam))
                except Exception:
                    pass
            self.hex_ramp_dropdowns[i].setCurrentText(str(rec.get("ramp") or ""))
            classes = rec.get("classes")
            if classes is not None:
                try:
                    self.hex_classes_inputs[i].setValue(int(classes))
                except Exception:
                    pass
            self.hex_mode_dropdowns[i].setCurrentText(str(rec.get("mode") or ""))
            xml = rec.get("symbol_xml") or ""
            if xml and isinstance(self.hex_customize_buttons[i], SymbolCustomizationButton):
                try:
                    self.hex_customize_buttons[i].load_from_xml(xml)
                except Exception:
                    pass
            self._query_set_state(self.hex_query_inputs[i], rec.get("query") or {})
            self.hex_label_toggles[i].setChecked(bool(rec.get("label_on", False)))
            lf = rec.get("label_field") or ""
            if lf:
                self.hex_label_field_dropdowns[i].setCurrentText(lf)
            ls = rec.get("label_size") or ""
            if ls:
                self.hex_label_size_dropdowns[i].setCurrentText(str(ls))
            # populate label field dropdown if file is on disk
            path = rec.get("path") or ""
            if path and os.path.exists(path):
                try:
                    self._browse_hex(i, path)
                except Exception:
                    pass

        # --- Measure-distances rows / groups ----------------------------------------
        for i, rec in enumerate(state.get("distance_rows", [])):
            if i >= len(self.dist_lon_inputs):
                break
            with signals_blocked(self.dist_lon_inputs[i], self.dist_lat_inputs[i]):
                self.dist_lon_inputs[i].setText(str(rec.get("lon") or ""))
                self.dist_lat_inputs[i].setText(str(rec.get("lat") or ""))
            path = str(rec.get("path") or "")
            self.dist_file_inputs[i].setText(path)
            if path and os.path.exists(path):
                ext = os.path.splitext(path)[1].lower()
                if ext == ".csv":
                    try:
                        self.populate_field_dropdown(path, self.dist_x_field_dropdowns[i])
                        self.populate_field_dropdown(path, self.dist_y_field_dropdowns[i])
                    except Exception:
                        pass
                    self.dist_x_field_dropdowns[i].setEnabled(True)
                    self.dist_y_field_dropdowns[i].setEnabled(True)
            x = str(rec.get("x_field") or "")
            y = str(rec.get("y_field") or "")
            if x:
                self.dist_x_field_dropdowns[i].setCurrentText(x)
            if y:
                self.dist_y_field_dropdowns[i].setCurrentText(y)
            self._update_dist_row_exclusion(i)

        for g, rec in enumerate(state.get("distance_groups", [])):
            if g >= len(self.dist_style_dropdowns):
                break
            self._ensure_style_options(self.dist_style_dropdowns[g], "Line")
            self.dist_style_dropdowns[g].setCurrentText(str(rec.get("style") or ""))
            xml = str(rec.get("symbol_xml") or "")
            if xml and isinstance(self.dist_customize_btns[g], SymbolCustomizationButton):
                try:
                    self.dist_customize_btns[g].load_from_xml(xml)
                except Exception:
                    pass
            self.dist_label_toggles[g].setChecked(bool(rec.get("label_on", False)))
            ls = str(rec.get("label_size") or "")
            if ls:
                self.dist_label_size_dds[g].setCurrentText(ls)
                self.dist_label_size_dds[g].setEnabled(True)

        # --- Metadata ----------------------------------------------------------------
        meta = state.get("metadata", {})
        self.recipient_input.setText(meta.get("recipient", ""))
        self.producer_input.setText(meta.get("producer", ""))
        self.map_content_input.setText(meta.get("content", ""))
        self.custom_sources_input.setText(meta.get("additional_sources", ""))

        # --- Export format -----------------------------------------------------------
        fmt = (state.get("export", {}) or {}).get("format", "PNG").upper()
        self.png_radio.setChecked(fmt == "PNG")
        self.jpg_radio.setChecked(fmt == "JPG")
        self.pdf_radio.setChecked(fmt == "PDF")

        # Mirror your behavior: any change -> disable EXPORT until BUILD
        self.export_final_button.setEnabled(False)
   
    def _apply_label_format(
        self,
        label_item,
        *,
        font_family="Arial",
        font_size_pt=12.0,
        text_color=QColor(0, 0, 0),
        halo_enabled=False,
        halo_size=0.8,
        halo_color=QColor(255, 255, 255),
    ):
        # ------------------------------------------------------------------
        # 1) Create explicit font
        # ------------------------------------------------------------------
        font = QFont(font_family)
        font.setPointSizeF(float(font_size_pt))
        label_item.setFont(font)  # GUI preview ONLY

        # ------------------------------------------------------------------
        # 2) Build text format EXPLICITLY FOR EXPORT
        # ------------------------------------------------------------------
        fmt = label_item.textFormat()

        fmt.setFont(font)

        # ✅ THIS IS THE CRITICAL LINE FOR EXPORT
        fmt.setSize(float(font_size_pt))
        fmt.setSizeUnit(_RENDER_PT)

        fmt.setColor(text_color)

        buffer = QgsTextBufferSettings()
        buffer.setEnabled(halo_enabled)
        if halo_enabled:
            buffer.setSize(float(halo_size))
            buffer.setColor(halo_color)

        fmt.setBuffer(buffer)

        # ------------------------------------------------------------------
        # 3) Apply format
        # ------------------------------------------------------------------
        label_item.setTextFormat(fmt)
        label_item.adjustSizeToText()
        label_item.refresh()

    def _overlay_icon_for_path(self, file_path: str, thumb_w: int = 48, thumb_h: int = 32) -> QIcon:
        """
        Return a QIcon thumbnail for PNG/JPG/SVG at file_path.
        Uses a simple in-memory cache keyed by absolute path.
        """
        try:
            abspath = os.path.abspath(file_path)
        except Exception:
            abspath = file_path

        # Cache hit
        icon = self._overlay_icon_cache.get(abspath)
        if icon is not None:
            return icon

        ext = os.path.splitext(abspath)[1].lower()
        # Transparent ARGB buffer (keeps SVG backgrounds clean)
        img = QImage(thumb_w, thumb_h, QImage.Format_ARGB32)
        img.fill(0)

        if ext == ".svg":
            try:
                renderer = QSvgRenderer(abspath)
                if renderer.isValid():
                    # Preserve aspect by fitting into the thumbnail rect
                    painter = QPainter(img)
                    try:
                        renderer.render(painter, QRect(0, 0, thumb_w, thumb_h))
                    finally:
                        painter.end()
                else:
                    # Fallback: empty icon
                    return QIcon()
            except Exception:
                return QIcon()
        else:
            # Raster: load and scale with smooth transform
            pm = QPixmap(abspath)
            if pm.isNull():
                return QIcon()
            pm = pm.scaled(thumb_w, thumb_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
            img = pm.toImage()

        icon = QIcon(QPixmap.fromImage(img))
        self._overlay_icon_cache[abspath] = icon
        return icon
    
    def _refresh_png_print_preview(self):
        """
        Export the current layout to a temporary PNG using the
        same exporter logic as EXPORT, then load it at 100% scale
        into the scrollable preview.
        """
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            QMessageBox.information(
                self,
                "No layout available",
                "Click BUILD first to create the layout before refreshing the PNG preview."
            )
            return

        preview_png = os.path.join(
            tempfile.gettempdir(),
            "_beaver_print_preview.png"
        )

        try:
            # ✅ Use EXACT same exporter logic as final PNG/JPG export
            self._export_layout_screenshot(preview_png)
        except Exception as e:
            QMessageBox.warning(self, "PNG preview failed", str(e))
            return

        pixmap = QPixmap(preview_png)
        if pixmap.isNull():
            QMessageBox.warning(self, "PNG preview failed", "Rendered image is empty.")
            return

        # ✅ Enforce true 1:1 pixel display
        pixmap.setDevicePixelRatio(1.0)

        self.png_preview_label.setPixmap(pixmap)
        self.png_preview_label.resize(pixmap.size())


    def _populate_graphics_menu(self):
        self.menu_graphics.clear()

        root = getattr(self, "overlay_repo", "") or ""
        if not root or not os.path.isdir(root):
            act = self.menu_graphics.addAction("(Set OVERLAY_REPO path)")
            act.setEnabled(False)
            return

        exts = {".png", ".jpg", ".jpeg", ".svg"}
        files = [
            f for f in os.listdir(root)
            if os.path.splitext(f)[1].lower() in exts
        ]
        files.sort(key=str.lower)

        if not files:
            act = self.menu_graphics.addAction("(No PNG/JPG/SVG found)")
            act.setEnabled(False)
            return

        # --- ✅ Scrollable list widget ---
        list_widget = QListWidget()
        list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        list_widget.setMinimumWidth(340)
        list_widget.setMaximumHeight(600)
        list_widget.setSelectionMode(QAbstractItemView.SingleSelection)

        for name in files:
            full = os.path.join(root, name)

            icon = self._overlay_icon_for_path(full, thumb_w=48, thumb_h=32)
            item = QListWidgetItem(icon, name)
            list_widget.addItem(item)

            # Bind handler safely
            def handler(_, p=full):
                self._insert_graphic_from_path(p)
                self.menu_graphics.close()

            item._handler = handler

        list_widget.itemClicked.connect(
            lambda item: getattr(item, "_handler", lambda *_: None)(None)
        )

        # --- ✅ Embed list into menu ---
        action = QWidgetAction(self.menu_graphics)
        action.setDefaultWidget(list_widget)
        self.menu_graphics.addAction(action)


    def _insert_graphic_from_path(self, file_path):
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            QMessageBox.information(self, "No layout", "Build first to create the layout preview.")
            return

        # Resolve the main map frame to compute the page-center of the frame
        map_item = layout.itemById("Map_frame")
        if not map_item:
            QMessageBox.warning(self, "Map frame not found", "The layout doesn't contain 'Map_frame'.")
            return

        # Embed as base64 so exports don't depend on disk path
        qf = QFile(file_path)
        if not qf.open(QIODevice.ReadOnly):
            QMessageBox.warning(self, "Cannot read file", file_path)
            return
        encoded = bytes(qf.readAll().toBase64()).decode("utf-8")
        qf.close()
        src = f"base64:{encoded}"

        # Create picture item after we know we can proceed
        pic = QgsLayoutItemPicture.create(layout) or QgsLayoutItemPicture(layout)
        ext = os.path.splitext(file_path)[1].lower()
        try:
            if ext == ".svg":
                _set_pic_mode(pic, _PIC_SVG)
            else:
                _set_pic_mode(pic, _PIC_RASTER)
        except Exception:
            pass
        pic.setPicturePath(src)
        layout.addLayoutItem(pic)

        # Default size 60×30 mm; center on MAP FRAME (not page)
        w, h = 60.0, 30.0
        mf_pos = map_item.positionWithUnits()
        mf_size = map_item.sizeWithUnits()
        cx = mf_pos.x() + mf_size.width() / 2.0
        cy = mf_pos.y() + mf_size.height() / 2.0
        tl_x = cx - w / 2.0
        tl_y = cy - h / 2.0
        pic.attemptMove(QgsLayoutPoint(tl_x, tl_y, _LAYOUT_MM))
        pic.attemptResize(QgsLayoutSize(w, h, _LAYOUT_MM))

        # Mark as overlay and unlock it
        self._mark_as_overlay(pic)

        layout.refresh()
        self._lock_all_but_overlays()          # now safe, after the item exists

        # Re-activate selection tool so user can immediately move/resize the new item
        try:
            if hasattr(self, "_tool_select") and self._tool_select is not None:
                self.layout_view.setTool(self._tool_select)
        except Exception:
            pass
        self._pan_layout_view_to_page(fit_width=True)

    def _collect_overlay_specs(self):
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            return []
        specs = []
        for item in layout.items():
            if not isinstance(item, QgsLayoutItem):
                continue
            if not bool(item.customProperty("beaver_overlay", False)):
                continue
            pos = item.positionWithUnits(); size = item.sizeWithUnits()
            if isinstance(item, QgsLayoutItemPicture):
                try:
                    path = item.evaluatedPath()
                    if not path:
                        path = item.picturePath()
                except Exception:
                    path = item.picturePath() if hasattr(item, "picturePath") else ""
                specs.append({
                    "type": "picture",
                    "path": path,
                    "x": float(pos.x()), "y": float(pos.y()),
                    "w": float(size.width()), "h": float(size.height()),
                })
            elif isinstance(item, QgsLayoutItemLabel):
                fmt = item.textFormat()
                f = fmt.font() if isinstance(fmt.font(), QFont) else QFont()
                buf = fmt.buffer() if hasattr(fmt, "buffer") else None
                try:
                    rot = float(item.itemRotation())
                except Exception:
                    rot = 0.0
                specs.append({
                    "type": "label",
                    "text": item.text(),
                    "font_family": f.family(),
                    "font_size_pt": float(f.pointSizeF() if f.pointSizeF() > 0 else 12.0),
                    "halo_on": bool(buf.enabled()) if buf else False,
                    "halo_size": float(buf.size()) if (buf and buf.enabled()) else 0.0,
                    "x": float(pos.x()), "y": float(pos.y()),
                    "w": float(size.width()), "h": float(size.height()),
                    "text_color": fmt.color().name(),
                    "halo_color": buf.color().name() if buf.enabled() else None,
                    "rotation": rot,
                })
            elif isinstance(item, (QgsLayoutItemPolyline, QgsLayoutItemPolygon)):
                # Use QGIS's own writeXml — handles nodes + symbol in one go
                try:
                    doc = QDomDocument()
                    root = doc.createElement("ov_root")
                    doc.appendChild(root)
                    ok = item.writeXml(root, doc, QgsReadWriteContext())
                    if ok:
                        specs.append({
                            "type": ("polyline" if isinstance(item, QgsLayoutItemPolyline)
                                     else "polygon_shape"),
                            "xml": doc.toString(),
                        })
                    else:
                        logger.warning("_collect_overlay_specs: writeXml returned False for shape overlay")
                except Exception as e:
                    logger.warning(f"_collect_overlay_specs: shape writeXml failed: {e}")
        return specs


    def _convert_dynamic_labels_to_graphics(self):
        """
        Convert dynamic vector-layer labels into individual QgsLayoutItemLabel overlays.

        - Renders the layout map at 300 DPI to obtain QgsLabelPosition results.
        - For each label, creates a layout label that inherits the source layer's
          font/size/colour/halo from QgsPalLayerSettings.format().
        - Disables labelling on every vector layer afterwards AND unchecks the
          corresponding GUI label toggles so the next BUILD does not regenerate
          dynamic labels alongside the new static overlays.
        """
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            QMessageBox.information(self, "No layout", "Build the map first.")
            return

        map_item = layout.itemById("Map_frame")
        if not map_item:
            QMessageBox.warning(self, "Missing map", "Map_frame not found.")
            return

        try:
            ms = QgsMapSettings()
            ms.setLayers(map_item.layers())
            ms.setExtent(map_item.extent())
            ms.setDestinationCrs(map_item.crs())

            size_mm = map_item.sizeWithUnits()
            dpi = 300
            px_w = int((size_mm.width() / 25.4) * dpi)
            px_h = int((size_mm.height() / 25.4) * dpi)
            ms.setOutputSize(QSize(px_w, px_h))
            ms.setOutputDpi(dpi)
            ms.setFlag(_FLAG_DRAW_LABELS, True)

            job = QgsMapRendererParallelJob(ms)
            job.start()
            job.waitForFinished()

            labeling_results = job.takeLabelingResults()
            if labeling_results is None:
                QMessageBox.warning(self, "No labels", "No labeling results found.")
                return

            all_labels = _all_labels(labeling_results)
            if not all_labels:
                QMessageBox.information(self, "No labels", "No label features extracted.")
                return

            # Map -> layout coordinate conversion (layout y-axis is page-down)
            map_extent = map_item.extent()
            map_rect = map_item.rectWithFrame()
            xmin = map_extent.xMinimum(); xmax = map_extent.xMaximum()
            ymin = map_extent.yMinimum(); ymax = map_extent.yMaximum()
            width_mm = map_rect.width()
            height_mm = map_rect.height()
            origin = map_item.positionWithUnits()

            def map_to_layout(mx, my):
                x_rel = (mx - xmin) / (xmax - xmin) if xmax != xmin else 0.0
                y_rel = (ymax - my) / (ymax - ymin) if ymax != ymin else 0.0
                return (origin.x() + x_rel * width_mm,
                        origin.y() + y_rel * height_mm)

            project = core_project()
            format_cache = {}

            def _get_source_format(layer_id):
                if layer_id in format_cache:
                    return format_cache[layer_id]
                layer = project.mapLayer(layer_id) if layer_id else None
                props = None
                if isinstance(layer, QgsVectorLayer):
                    labeling = layer.labeling()
                    if isinstance(labeling, QgsVectorLayerSimpleLabeling):
                        try:
                            src_fmt = labeling.settings().format()
                            src_buf = src_fmt.buffer()
                            sz = float(src_fmt.size()) if src_fmt.size() > 0 else 9.0
                            props = {
                                "font_family": src_fmt.font().family() or "Arial",
                                "font_size_pt": sz,
                                "text_color": QColor(src_fmt.color()),
                                "halo_on": bool(src_buf.enabled()),
                                "halo_size": float(src_buf.size()) if src_buf.enabled() else 0.8,
                                "halo_color": QColor(src_buf.color()) if src_buf.enabled() else QColor(255, 255, 255),
                            }
                        except Exception as e:
                            logger.warning(f"could not read label format from '{layer.name()}': {e}")
                format_cache[layer_id] = props
                return props

            def _attr_or_call(obj, name, default):
                v = getattr(obj, name, default)
                if v is default:
                    return default
                if callable(v):
                    try:
                        return v()
                    except Exception:
                        return default
                return v

            def _label_centre_map(lab):
                # cornerPoints handles rotated labels correctly; fall back to AABB.
                try:
                    pts = _attr_or_call(lab, "cornerPoints", None)
                    if pts:
                        n = float(len(pts))
                        return (sum(p.x() for p in pts) / n,
                                sum(p.y() for p in pts) / n)
                except Exception:
                    pass
                try:
                    rect = _attr_or_call(lab, "rect", None)
                    if rect is not None:
                        c = rect.center()
                        return (c.x(), c.y())
                except Exception:
                    pass
                return None

            created_count = 0
            converted_layer_ids = set()

            for lab in all_labels:
                try:
                    centre = _label_centre_map(lab)
                    if centre is None:
                        continue
                    cx, cy = centre

                    text = _attr_or_call(lab, "labelText", "")
                    if not text:
                        continue
                    angle_rad = float(_attr_or_call(lab, "rotation", 0.0))
                    layer_id = _attr_or_call(lab, "layerID", "")

                    if layer_id:
                        converted_layer_ids.add(layer_id)

                    src_props = _get_source_format(layer_id)
                    if src_props:
                        kwargs = src_props
                    else:
                        kwargs = {
                            "font_family": "Arial",
                            "font_size_pt": 9.0,
                            "text_color": QColor(0, 0, 0),
                            "halo_on": False,
                            "halo_size": 0.8,
                            "halo_color": QColor(255, 255, 255),
                        }

                    lbl = QgsLayoutItemLabel(layout)
                    lbl.setText(text)
                    layout.addLayoutItem(lbl)

                    # Format BEFORE sizing so adjustSizeToText measures the real font.
                    self._apply_label_format(
                        lbl,
                        font_family=kwargs["font_family"],
                        font_size_pt=kwargs["font_size_pt"],
                        text_color=kwargs["text_color"],
                        halo_enabled=kwargs["halo_on"],
                        halo_size=kwargs["halo_size"],
                        halo_color=kwargs["halo_color"],
                    )
                    lbl.adjustSizeToText()

                    lx, ly = map_to_layout(cx, cy)
                    sz = lbl.sizeWithUnits()
                    lbl.attemptMove(
                        QgsLayoutPoint(
                            lx - sz.width() / 2.0,
                            ly - sz.height() / 2.0,
                            _LAYOUT_MM
                        )
                    )

                    # QgsLabelPosition.rotation is radians; layout y-axis is flipped
                    # relative to map y-axis, so negate when converting to degrees.
                    try:
                        lbl.setItemRotation(-math.degrees(angle_rad))
                    except Exception:
                        pass

                    self._mark_as_overlay(lbl)
                    created_count += 1

                except Exception as e:
                    logger.warning(f"label conversion failed: {e}")

            layout.refresh()
            self._lock_all_but_overlays()

            # Turn off labelling on every vector layer so the layout doesn't render
            # dynamic labels underneath the static overlays.
            for layer in project.mapLayers().values():
                try:
                    if hasattr(layer, "labelsEnabled") and layer.labelsEnabled():
                        layer.setLabelsEnabled(False)
                        layer.triggerRepaint()
                except Exception:
                    pass

            # Mirror in the GUI: uncheck label toggles for converted source layers
            # so the next BUILD does not regenerate the labels.
            self._sync_gui_label_toggles_off(converted_layer_ids)

            QMessageBox.information(
                self,
                "Done",
                f"{created_count} labels converted to graphics."
            )

        except Exception as e:
            QMessageBox.warning(self, "Conversion failed", str(e))

    def _sync_gui_label_toggles_off(self, converted_layer_ids):
        """
        Uncheck preloaded / uploaded GUI label toggles whose source layers had their
        labels just converted to graphics. Signals are blocked so the EXPORT button
        stays enabled — the layout is already correct.
        """
        if not converted_layer_ids:
            return

        project = core_project()
        names = set()
        for lid in converted_layer_ids:
            lyr = project.mapLayer(lid)
            if not lyr:
                continue
            n = lyr.name()
            if n.endswith("_projected"):
                n = n[:-len("_projected")]
            names.add(n)

        # Preloaded layer toggles: keyed by base_name
        preloaded_targets = []
        for base, cb in (getattr(self, "preloaded_label_toggles", {}) or {}).items():
            if cb is not None and base in names and cb.isChecked():
                preloaded_targets.append(cb)

        # Uploaded layer toggles: map each upload row's file basename to its index
        uploaded_targets = []
        upload_inputs = getattr(self, "upload_inputs", []) or []
        upload_toggles = getattr(self, "upload_label_toggles", []) or []
        for i, input_box in enumerate(upload_inputs):
            if i >= len(upload_toggles):
                break
            try:
                path = input_box.text().strip()
            except Exception:
                path = ""
            if not path:
                continue
            stem = os.path.splitext(os.path.basename(path))[0]
            if stem in names and upload_toggles[i] is not None and upload_toggles[i].isChecked():
                uploaded_targets.append(upload_toggles[i])

        targets = preloaded_targets + uploaded_targets
        if not targets:
            return

        with signals_blocked(*targets):
            for cb in targets:
                try:
                    cb.setChecked(False)
                except Exception:
                    pass



    def _collect_movable_specs(self):
        """
        Return a list of specs for user-overlays + builtin movable items:
        each spec: {"type": "picture"|"label"|"builtin", "id": <ID or None>,
                    "path" (pictures only), "text"/style (labels only),
                    "x","y","w","h"}
        """
        specs = []
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            return specs

        # 1) Existing user overlays (reuse your existing collector)
        specs.extend(self._collect_overlay_specs())

        # 2) Builtin moveables: Legend, Locator
        for item_id in ("Legend", "Loc_diag"):
            it = layout.itemById(item_id)
            if not it: 
                continue
            pos = it.positionWithUnits(); size = it.sizeWithUnits()
            specs.append({
                "type": "builtin",
                "id": item_id,
                "x": float(pos.x()), "y": float(pos.y()),
                "w": float(size.width()), "h": float(size.height()),
            })
        return specs
    
    def _restore_movable_specs(self, specs):
        """
        Recreate user overlays; move/resize builtin 'Legend' and 'Loc_diag'.
        """
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout or not specs:
            return

        for item in list(layout.items()):
            if isinstance(item, QgsLayoutItem) and bool(item.customProperty("beaver_overlay", False)):
                layout.removeLayoutItem(item)

        for s in specs:
            t = s.get("type", "")
            if t == "picture":
                pic = QgsLayoutItemPicture.create(layout) or QgsLayoutItemPicture(layout)
                _set_pic_mode(pic, _PIC_SVG if s.get("path", "").lower().endswith(".svg") else _PIC_RASTER)
                pic.setPicturePath(s.get("path",""))
                layout.addLayoutItem(pic)
                pic.attemptMove(QgsLayoutPoint(s["x"], s["y"], _LAYOUT_MM))
                pic.attemptResize(QgsLayoutSize(s["w"], s["h"], _LAYOUT_MM))
                # tag as overlay so it's unlocked
                self._mark_as_overlay(pic)

            # elif t == "label":
            #     lbl = QgsLayoutItemLabel(layout)
            #     lbl.setText(s.get("text",""))
            #     layout.addLayoutItem(lbl)
            #     lbl.attemptMove(QgsLayoutPoint(s["x"], s["y"], _LAYOUT_MM))
            #     lbl.attemptResize(QgsLayoutSize(s["w"], s["h"], _LAYOUT_MM))
            #     # You already store/restore format (font/halo) in _collect_overlay_specs(); reapply here if needed.
            #     self._mark_as_overlay(lbl)

            elif t == "label":
                lbl = QgsLayoutItemLabel(layout)
                lbl.setText(s.get("text",""))
                layout.addLayoutItem(lbl)

                self._apply_label_format(
                    lbl,
                    font_family=s.get("font_family", "Arial"),
                    font_size_pt=s.get("font_size_pt", 12.0),
                    text_color=QColor(s.get("text_color", "#000000")),
                    halo_enabled=bool(s.get("halo_on", False)),
                    halo_size=s.get("halo_size", 0.8),
                    halo_color=QColor(s.get("halo_color") or "#ffffff")
                )

                lbl.attemptMove(QgsLayoutPoint(s["x"], s["y"], _LAYOUT_MM))
                lbl.attemptResize(QgsLayoutSize(s["w"], s["h"], _LAYOUT_MM))
                try:
                    lbl.setItemRotation(float(s.get("rotation", 0.0)))
                except Exception:
                    pass

                self._mark_as_overlay(lbl)

            elif t in ("polyline", "polygon_shape"):
                xml_str = s.get("xml", "")
                if not xml_str:
                    continue
                try:
                    doc = QDomDocument()
                    doc.setContent(xml_str)
                    # writeXml wrote one child element inside ov_root
                    item_elem = doc.documentElement().firstChildElement()
                    if t == "polyline":
                        item = (QgsLayoutItemPolyline.create(layout)
                                or QgsLayoutItemPolyline(layout))
                    else:
                        item = (QgsLayoutItemPolygon.create(layout)
                                or QgsLayoutItemPolygon(layout))
                    layout.addLayoutItem(item)
                    item.readXml(item_elem, doc, QgsReadWriteContext())
                    self._mark_as_overlay(item)
                except Exception as e:
                    logger.warning(f"_restore_movable_specs: shape readXml failed: {e}")

            elif t == "builtin":
                target = layout.itemById(s.get("id") or "")
                if target:
                    target.attemptMove(QgsLayoutPoint(s["x"], s["y"], _LAYOUT_MM))
                    target.attemptResize(QgsLayoutSize(s["w"], s["h"], _LAYOUT_MM))
                    # keep them moveable
                    target.setCustomProperty("beaver_overlay", True)
                    target.setLocked(False)

        layout.refresh()
        self._lock_all_but_overlays()

    def _edit_label_properties(self, label_item):
        dlg = QDialog(self)
        dlg.setWindowTitle("Text style")
        form = QFormLayout(dlg)

        text_color_btn = QPushButton("Text colour…", dlg)
        halo_color_btn = QPushButton("Halo colour…", dlg)
        chosen_text_color = label_item.textFormat().color()
        chosen_halo_color = QColor(255, 255, 255)
        font_box = QFontComboBox(dlg); font_box.setCurrentFont(label_item.textFormat().font())
        size_box = QDoubleSpinBox(dlg); size_box.setDecimals(1); size_box.setRange(4.0, 96.0); size_box.setSingleStep(0.5)
        size_box.setValue(label_item.textFormat().font().pointSizeF() or 12.0)

        halo_chk = QCheckBox("Halo (buffer)", dlg)
        halo_size = QDoubleSpinBox(dlg); halo_size.setDecimals(1); halo_size.setRange(0.1, 10.0); halo_size.setSingleStep(0.2); halo_size.setValue(0.8)
        halo_size.setEnabled(False)
        halo_chk.toggled.connect(lambda on: halo_size.setEnabled(on))

        form.addRow("Font:", font_box)
        form.addRow("Point size:", size_box)
        form.addRow(halo_chk, halo_size)

        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg)
        form.addRow(buttons)
        buttons.accepted.connect(dlg.accept); buttons.rejected.connect(dlg.reject)

        # chosen_text_color = label_item.textFormat().color()
        # chosen_halo_color = QColor(255, 255, 255)

        def pick_text_color():
            nonlocal chosen_text_color
            col = QColorDialog.getColor(chosen_text_color, dlg, "Text colour")
            if col.isValid():
                chosen_text_color = col

        def pick_halo_color():
            nonlocal chosen_halo_color
            col = QColorDialog.getColor(chosen_halo_color, dlg, "Halo colour")
            if col.isValid():
                chosen_halo_color = col

        text_color_btn.clicked.connect(pick_text_color)
        halo_color_btn.clicked.connect(pick_halo_color)

        halo_chk.toggled.connect(halo_color_btn.setEnabled)
        halo_chk.toggled.connect(halo_size.setEnabled)

        form.addRow("Text colour:", text_color_btn)
        form.addRow("Halo colour:", halo_color_btn)

        if dlg.exec_() != QDialog.Accepted:
            return

        self._apply_label_format(
            label_item,
            font_family=font_box.currentFont().family(),
            font_size_pt=size_box.value(),
            text_color=chosen_text_color,
            halo_enabled=halo_chk.isChecked(),
            halo_size=halo_size.value(),
            halo_color=chosen_halo_color,
        )


    def _insert_text_dialog(self):
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            QMessageBox.information(
                self,
                "No layout",
                "Build first to create the layout preview."
            )
            return

        # ------------------------------------------------------------------
        # Dialog setup
        # ------------------------------------------------------------------
        dlg = QDialog(self)
        dlg.setWindowTitle("Insert text")
        form = QFormLayout(dlg)

        # --- Text input ----------------------------------------------------
        text_input = QLineEdit()
        text_input.setPlaceholderText("Enter text…")
        text_input.setMinimumWidth(350)
        form.addRow("Text:", text_input)

        # --- Font controls -------------------------------------------------
        font_box = QFontComboBox()
        size_box = QDoubleSpinBox()
        size_box.setRange(4.0, 96.0)
        size_box.setDecimals(1)
        size_box.setSingleStep(0.5)
        size_box.setValue(12.0)

        form.addRow("Font:", font_box)
        form.addRow("Point size:", size_box)

        # --- Colours -------------------------------------------------------
        chosen_text_color = QColor(0, 0, 0)
        chosen_halo_color = QColor(255, 255, 255)

        text_color_btn = QPushButton("Choose…")
        halo_color_btn = QPushButton("Choose…")
        halo_color_btn.setEnabled(False)

        def pick_text_color():
            nonlocal chosen_text_color
            c = QColorDialog.getColor(chosen_text_color, dlg, "Text colour")
            if c.isValid():
                chosen_text_color = c

        def pick_halo_color():
            nonlocal chosen_halo_color
            c = QColorDialog.getColor(chosen_halo_color, dlg, "Halo colour")
            if c.isValid():
                chosen_halo_color = c

        text_color_btn.clicked.connect(pick_text_color)
        halo_color_btn.clicked.connect(pick_halo_color)

        form.addRow("Text colour:", text_color_btn)

        # --- Halo controls -------------------------------------------------
        halo_chk = QCheckBox("Halo (buffer)")
        halo_size = QDoubleSpinBox()
        halo_size.setRange(0.1, 10.0)
        halo_size.setSingleStep(0.2)
        halo_size.setDecimals(1)
        halo_size.setValue(0.8)
        halo_size.setEnabled(False)

        halo_chk.toggled.connect(halo_size.setEnabled)
        halo_chk.toggled.connect(halo_color_btn.setEnabled)

        form.addRow(halo_chk, halo_size)
        form.addRow("Halo colour:", halo_color_btn)

        # --- Dialog buttons ------------------------------------------------
        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(dlg.accept)
        buttons.rejected.connect(dlg.reject)
        form.addRow(buttons)

        # ------------------------------------------------------------------
        # Run dialog
        # ------------------------------------------------------------------
        if dlg.exec_() != QDialog.Accepted:
            return

        text = text_input.text().strip()
        if not text:
            return

        # ------------------------------------------------------------------
        # Create label
        # ------------------------------------------------------------------
        lbl = QgsLayoutItemLabel(layout)
        lbl.setText(text)
        layout.addLayoutItem(lbl)

        # Default placement: center over Map_frame
        map_item = layout.itemById("Map_frame")
        if map_item:
            w, h = 80.0, 10.0
            pos = map_item.positionWithUnits()
            sz = map_item.sizeWithUnits()
            x = pos.x() + sz.width() / 2.0 - w / 2.0
            y = pos.y() + sz.height() / 2.0 - h / 2.0
            lbl.attemptMove(QgsLayoutPoint(x, y, _LAYOUT_MM))
            lbl.attemptResize(QgsLayoutSize(w, h, _LAYOUT_MM))

        # ------------------------------------------------------------------
        # ✅ Apply text styling exactly once
        # ------------------------------------------------------------------
        self._apply_label_format(
            lbl,
            font_family=font_box.currentFont().family(),
            font_size_pt=size_box.value(),
            text_color=chosen_text_color,
            halo_enabled=halo_chk.isChecked(),
            halo_size=halo_size.value(),
            halo_color=chosen_halo_color,
        )

        # ------------------------------------------------------------------
        # Overlay bookkeeping
        # ------------------------------------------------------------------
        self._mark_as_overlay(lbl)
        layout.refresh()
        self._lock_all_but_overlays()

        try:
            if hasattr(self, "_tool_select") and self._tool_select is not None:
                self.layout_view.setTool(self._tool_select)
        except Exception:
            pass


    # --- in GUI_class (near other overlay helpers) ---
    def _insert_symbol_picture(self, symbol, width_mm=20.0, height_mm=20.0, dpi=300):
        """
        Render a QgsSymbol to a raster image and insert it as a layout picture overlay.
        Uses a base64 data-uri so there are no temp files on disk.
        """
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            QMessageBox.information(self, "No layout", "Build first to create the layout preview.")
            return

        # Render symbol to a QImage at requested print size + DPI
        # Convert mm→pixels: px = (mm / 25.4) * dpi
        w_px = int(round((width_mm / 50) * dpi))
        h_px = int(round((height_mm / 50) * dpi))
        if w_px <= 0 or h_px <= 0:
            w_px, h_px = 256, 256  # sane default

        img = QImage(w_px, h_px, QImage.Format_ARGB32)
        img.fill(0)  # transparent

        # Render centered
        painter = QPainter(img)
        try:
            # QGIS renders symbol sizes in millimeters by default
            # We'll ask the symbol to draw itself into a painter target rect.
            # Use symbol.asImage for simplicity/consistency with your swatch logic.
            swatch = symbol.asImage(img.size())
            if swatch is None or (hasattr(swatch, "isNull") and swatch.isNull()):
                raise RuntimeError("Failed to render symbol to image.")
            painter.drawImage(0, 0, swatch)
        finally:
            painter.end()
        ba = QByteArray()
        buf = QBuffer(ba)
        buf.open(QBuffer.WriteOnly)
        img.save(buf, "PNG")
        buf.close()

        b64 = str(ba.toBase64(), "utf-8")
        src = f"base64:{b64}"

        # Create picture item (same as _insert_graphic_from_path)
        pic = QgsLayoutItemPicture.create(layout) or QgsLayoutItemPicture(layout)
        _set_pic_mode(pic, _PIC_RASTER)
        pic.setPicturePath(src)
        layout.addLayoutItem(pic)

        # Place it centered over Map_frame (like your graphic inserter)
        map_item = layout.itemById("Map_frame")
        if not map_item:
            QMessageBox.warning(self, "Map frame not found", "The layout doesn't contain 'Map_frame'.")
            return

        mf_pos = map_item.positionWithUnits()
        mf_size = map_item.sizeWithUnits()
        cx = mf_pos.x() + mf_size.width() / 2.0
        cy = mf_pos.y() + mf_size.height() / 2.0
        tl_x = cx - (width_mm / 2.0)
        tl_y = cy - (height_mm / 2.0)

        pic.attemptMove(QgsLayoutPoint(tl_x, tl_y, _LAYOUT_MM))
        pic.attemptResize(QgsLayoutSize(width_mm, height_mm, _LAYOUT_MM))

        # Mark/unlock overlay as you already do
        self._mark_as_overlay(pic)
        layout.refresh()
        self._lock_all_but_overlays()
        try:
            if hasattr(self, "_tool_select") and self._tool_select is not None:
                self.layout_view.setTool(self._tool_select)
        except Exception:
            pass
        self._pan_layout_view_to_page(fit_width=True)

    def _build_png_print_preview(self):
        """
        Scrollable, 100% scale PNG preview rendered using
        the same layout export logic as EXPORT (includes basemaps).
        """
        container = QWidget()
        v = QVBoxLayout(container)
        v.setContentsMargins(0, 0, 0, 0)
        v.setSpacing(6)

        # --- Refresh button ---
        self.btn_refresh_png_preview = QPushButton("Refresh export preview")
        self.btn_refresh_png_preview.setFixedWidth(200)
        self.btn_refresh_png_preview.setToolTip(
            "Re-render a print-accurate PNG preview using the current layout state."
        )
        self.btn_refresh_png_preview.clicked.connect(self._refresh_png_print_preview)

        self.png_preview_label = None
        self.png_preview_scroll = None

        # --- Scrollable PNG viewer ---
        self.png_preview_label = QLabel("PNG print preview will appear here.")
        self.png_preview_label.setAlignment(Qt.AlignTop | Qt.AlignLeft)
        self.png_preview_label.setScaledContents(False)

        self.png_preview_scroll = QScrollArea()
        self.png_preview_scroll.setWidget(self.png_preview_label)
        self.png_preview_scroll.setWidgetResizable(False)
        self.png_preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.png_preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.png_preview_scroll.setMinimumHeight(1000)

        v.addWidget(self.btn_refresh_png_preview)
        v.addWidget(self.png_preview_scroll)

        return container

    def _build_layout_preview_section(self):
        # --- Container row ---
        box = QGroupBox()
        v = QVBoxLayout(box)

        # === Layout view (always on; disabled until first BUILD) ===
        self.layout_view = QgsLayoutView()
        self._tool_select = QgsLayoutViewToolSelect(self.layout_view)
        self.layout_view.setMinimumSize(900, 600)
        self.layout_view.setEnabled(False)
        # v.addWidget(self.layout_view)

        self.layout_preview_container = QWidget()
        stack = QStackedLayout(self.layout_preview_container)
        stack.setStackingMode(QStackedLayout.StackAll)
        stack.addWidget(self.layout_view)

        self.layout_watermark = QLabel("UNFINISHED")
        self.layout_watermark.setAlignment(Qt.AlignCenter)
        self.layout_watermark.setAttribute(Qt.WA_TransparentForMouseEvents, True)
        self.layout_watermark.setStyleSheet("""
            QLabel {
                color: rgba(200, 0, 0, 150);
                font-family: Arial;
                font-weight: 700;
                font-size: 72pt;
                letter-spacing: 3px;
                background: transparent;
            }
        """)
        stack.addWidget(self.layout_watermark)
        v.addWidget(self.layout_preview_container)

        # === Overlay dropdowns ===
        row = QHBoxLayout()

        self.btn_add_graphic = QToolButton()
        self.btn_add_graphic.setText("Insert flag ")

        # Show the menu immediately on click (no long-press)
        self.btn_add_graphic.setPopupMode(QToolButton.InstantPopup)

        # Attach a QMenu to the tool button
        self.menu_graphics = QMenu(self.btn_add_graphic)
        self.btn_add_graphic.setMenu(self.menu_graphics)

        self.menu_graphics.aboutToShow.connect(self._populate_graphics_menu)

        self.btn_add_text = QToolButton()
        self.btn_add_text.setText("Insert text")
        self.btn_add_text.clicked.connect(self._insert_text_dialog)

        # --- in _build_layout_preview_section(), beside btn_add_graphic ---
        self.btn_add_symbol = QToolButton()
        self.btn_add_symbol.setText("Insert symbol ")
        self.btn_add_symbol.setPopupMode(QToolButton.InstantPopup)

        # === Nudge buttons (5% pans) ===
        btn_left  = QToolButton(); btn_left.setText("🡸") 
        btn_right = QToolButton(); btn_right.setText("🡺")
        btn_up    = QToolButton(); btn_up.setText("🡹")
        btn_down  = QToolButton(); btn_down.setText("🡻")

        for b in (btn_left, btn_right, btn_up, btn_down):
            b.setFixedSize(26, 22)
            b.setToolTip("Pan the map in this direction.")

        # Connect: +X right, -X left; +Y up, -Y down
        btn_left.clicked.connect(lambda: self._nudge_map(dx_frac=-0.05, dy_frac=0.0))
        btn_right.clicked.connect(lambda: self._nudge_map(dx_frac=+0.05, dy_frac=0.0))
        btn_up.clicked.connect(lambda: self._nudge_map(dx_frac=0.0, dy_frac=+0.05))
        btn_down.clicked.connect(lambda: self._nudge_map(dx_frac=0.0, dy_frac=-0.05))

        self.menu_symbols = QMenu(self.btn_add_symbol)
        self.btn_add_symbol.setMenu(self.menu_symbols)
        self.menu_symbols.setMaximumHeight(500)

        btn_zoom_in = QToolButton()
        btn_zoom_in.setText("IN")
        btn_zoom_in.setToolTip("Zoom in 5%. NOTE: if you zoom in past 1:5,000,000 and your map disappears, click BUILD.")

        btn_zoom_out = QToolButton()
        btn_zoom_out.setText("OUT")
        btn_zoom_out.setToolTip("Zoom out 5%")

        btn_zoom_in.setFixedSize(30, 22)
        btn_zoom_out.setFixedSize(30, 22)

        btn_zoom_in.clicked.connect(lambda: self._zoom_map(0.95))
        btn_zoom_out.clicked.connect(lambda: self._zoom_map(1.05))

        self.scale_image = QLabel(); self.scale_image.setScaledContents(True)

        # --- Convert dynamic labels to graphics ---
        self.btn_convert_labels = QToolButton()
        self.btn_convert_labels.setText("Make labels moveable")
        self.btn_convert_labels.setToolTip(
            "Convert all dynamic map labels into static graphics so they can be moved/exported independently."
        )
        self.btn_convert_labels.clicked.connect(self._convert_dynamic_labels_to_graphics)

        row.addSpacing(8)
        row.addWidget(self.btn_convert_labels)
        row.addWidget(self.btn_add_symbol)
        row.addWidget(self.btn_add_graphic)
        row.addWidget(self.btn_add_text)

        row.addWidget(btn_left)
        row.addWidget(btn_right)
        row.addWidget(btn_up)
        row.addWidget(btn_down)
        row.addWidget(self.preview_label)
        row.addWidget(btn_zoom_out)
        row.addWidget(btn_zoom_in)
        row.addStretch(1)
        v.addLayout(row)

        def _populate_symbol_menu():
            self.menu_symbols.clear()

            PREVIEW_W, PREVIEW_H = 32, 32

            for gtype in ("Point", "Line", "Polygon"):
                sub = self.menu_symbols.addMenu(gtype)

                # --- Create scrollable list ---
                list_widget = QListWidget()
                list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
                list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
                list_widget.setMinimumWidth(320)
                list_widget.setMaximumHeight(600)

                items = self.styles.get(gtype, []) or []
                if not items:
                    item = QListWidgetItem("(No styles found)")
                    item.setFlags(Qt.NoItemFlags)
                    list_widget.addItem(item)
                else:
                    for name, sym in items:
                        sym_clone = sym.clone()
                        cache_key = (gtype, name.strip().lower())

                        icon = self._icon_for_symbol(
                            sym_clone,
                            key=cache_key,
                            w=PREVIEW_W,
                            h=PREVIEW_H
                        )

                        item = QListWidgetItem(icon, name)
                        list_widget.addItem(item)

                        def handler(_, s=sym_clone, g=gtype):
                            self.menu_symbols.close()
                            if g == "Point":
                                self._insert_symbol_picture(
                                    s, width_mm=20.0, height_mm=20.0
                                )
                            elif g == "Line":
                                self._start_drawing_mode(s, "line")
                            else:  # Polygon
                                self._start_drawing_mode(s, "polygon")

                        item._handler = handler

                list_widget.itemClicked.connect(
                    lambda item: getattr(item, "_handler", lambda *_: None)(None)
                )

                # --- Embed list into submenu ---
                action = QWidgetAction(sub)
                action.setDefaultWidget(list_widget)
                sub.addAction(action)
                    
        self.menu_symbols.aboutToShow.connect(_populate_symbol_menu)

        # Overlay ID counter (stable-ish item names)
        self._overlay_n = 0
        # Repository path (Andrew will set OVERLAY_REPO later)
        self.overlay_repo = OVERLAY_REPO
        return box

    # === Helper: top-left layout controls ========================================
    def _build_layout_selector(self):
        # Beaver processor: every product is a supplemental graphic, so the only
        # control here is the global-map toggle.
        self.world_map_checkbox = QCheckBox("Global map")
        self.world_map_checkbox.setChecked(False)
        self.world_map_checkbox.setToolTip(
            "When checked, use the WGS 1984 world projection to display large portions of the world.")

        row = QWidget()
        row_layout = QHBoxLayout(row)
        row_layout.setContentsMargins(0, 0, 0, 0)
        row_layout.addWidget(self.world_map_checkbox)
        row_layout.addStretch(1)
        return row

    def _min_dimensions_inches(self):
        """Supplemental graphic is the only layout: minimum 6 x 6 inches."""
        return (6.0, 6.0)

    def _build_dimension_inputs(self, validators):
        self.width_label = QLabel("Map width (inches):")
        self.width_input = QLineEdit()
        self.width_input.setPlaceholderText("6")
        self.width_input.setToolTip("Map width in inches.")

        self.height_label = QLabel("Map height (inches):")
        self.height_input = QLineEdit()
        self.height_input.setPlaceholderText("6")
        self.height_input.setToolTip("Map height in inches.")

        row = QWidget()
        row_layout = QHBoxLayout(row)
        row_layout.addWidget(self.width_label)
        row_layout.addWidget(self.width_input)
        row_layout.addWidget(self.height_label)
        row_layout.addWidget(self.height_input)
        return row



    def _build_preview_controls(self):
        self.generate_button = QPushButton("B U I L D")
        self.generate_button.clicked.connect(self.generate_map_preview)

        # Progress bar shown in place of the button while BUILD runs.
        # Uses indeterminate mode (0, 0) — the QGIS/Qt export pipeline must
        # run on the main thread, so a ticking timer cannot animate it.
        # processEvents() is called once before the blocking export to ensure
        # the bar paints, and the bar is restored before the success dialog.
        self._build_progress = QProgressBar()
        self._build_progress.setRange(0, 0)   # indeterminate / busy indicator
        self._build_progress.setFormat("B U I L D  —  rendering…")
        self._build_progress.setAlignment(Qt.AlignCenter)
        self._build_progress.setTextVisible(True)
        self._build_progress.setFixedHeight(self.generate_button.sizeHint().height())

        # Stack: index 0 = button (idle), index 1 = bar (busy)
        self._build_btn_stack = QWidget()
        _stk = QStackedLayout(self._build_btn_stack)
        _stk.addWidget(self.generate_button)
        _stk.addWidget(self._build_progress)
        _stk.setCurrentIndex(0)

        self._building = False  # re-entry guard

        try:
            img_label = self.preview_image
        except AttributeError:
            self.preview_image = QLabel("Edit the map.")
            self.preview_image.setScaledContents(False)
            img_label = self.preview_image
        v = QVBoxLayout()
        v.addWidget(self._build_btn_stack)
        self.preview_container = QWidget()
        self.preview_stack = QStackedLayout(self.preview_container)
        self.preview_stack.setStackingMode(QStackedLayout.StackAll)
        self.preview_stack.addWidget(img_label)
        return v

    def _build_png_preview_box(self):
        """
        Scrollable PNG preview of the exported layout at 100% scale.
        """
        self.png_preview_label = QLabel("Preview will appear after BUILD.")
        self.png_preview_label.setAlignment(Qt.AlignTop | Qt.AlignLeft)
        self.png_preview_label.setScaledContents(False)

        self.png_preview_scroll = QScrollArea()
        self.png_preview_scroll.setWidget(self.png_preview_label)
        self.png_preview_scroll.setWidgetResizable(False)
        self.png_preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.png_preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.png_preview_scroll.setMinimumHeight(350)
        self.png_preview_scroll.setFrameShape(QScrollArea.StyledPanel)

        return self.png_preview_scroll

    preview_label = QLabel("Current scale = 1:-------")
    preview_image = QLabel(); preview_image.setScaledContents(False)
    preview_label.setTextInteractionFlags(
        Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
    preview_label.setFocusPolicy(Qt.StrongFocus)  # allows keyboard copy (Ctrl/Cmd+C)
    preview_label.setCursor(Qt.IBeamCursor)       # optional: show text cursor

    def confirm_and_export(self):
        """
        New order:
        1) Preflight validation (metadata + additional data sources, combined).
        2) If OK, show the Beaver confirmation dialog.
        3) On Proceed, call export_map_files().
        BUILD/Preview remains unaffected because preflight uses is_preview=False only here.
        """
        # 1) Preflight validation (combined metadata + additional data sources)
        try:
            # This will raise an Exception with a clear message if something is missing.
            # It already includes our EXPORT-only "Additional data sources" rule.
            # _ = self.compile_arguments_for_exporter(is_preview=False)  # no file ops; just validation & packing
        
            if hasattr(self, "layout_watermark"):
                self.layout_watermark.hide()
        except Exception as e:
            # One consolidated warning covering both metadata and additional data sources
            QMessageBox.warning(
                self,
                "Action required:",
                str(e)
            )
            return  # stop here; user can fix inputs and click EXPORT again

        # 2) Beaver confirmation appears ONLY after preflight passes
        dlg = QMessageBox(self)
        dlg.setIcon(QMessageBox.Question)
        dlg.setWindowTitle("Beaver is about to export a map. Please read the following before proceeding:")
        dlg.setText(
            "It is the responsibility of the user to verify the data and graphical representations "
            "displayed on the map preview before distribution.\n\n"
            "All preloaded layers drawn in the map are sourced from third-parties. The data they contain "
            "span the whole Earth, and are not intended to support any particular land claims or to represent authoritative boundaries.\n\n"
            "Maps have the potential to mislead. For this reason, it is wise to consult colleagues to "
            "visually verify the accuracy of map features, and when possible, consult other maps to corroborate them.\n\n"
            "By clicking 'Proceed,' the user acknowledges this information."
        )
        proceed_btn = dlg.addButton("Proceed", QMessageBox.AcceptRole)
        cancel_btn = dlg.addButton("Cancel", QMessageBox.RejectRole)
        dlg.setDefaultButton(proceed_btn)
        dlg.setEscapeButton(cancel_btn)
        dlg.exec_()

        if dlg.clickedButton() is proceed_btn:
            # Ask the user where to save outputs
            export_dir = self.choose_export_folder()
            if not export_dir:
                # User canceled; stop gracefully
                return

            self.export_final_button.setEnabled(False)
            try:
                # Pass the chosen folder through to the exporter
                self.export_map_files(export_dir=export_dir)
            finally:
                self.export_final_button.setEnabled(True)

    def _toggle_categorize_controls(self, enable_checkbox, field_dropdown, scheme_dropdown,
                                    customize_button=None):
        """Enable/disable Categorize support widgets based on the checkbox state.

        Field + scheme dropdowns mirror the checkbox state (enabled when
        ticked). The Customize-symbol button does the inverse: when
        categorize is on, the per-category colour ramp is the source of
        truth, so the single-symbol customize editor is greyed out.
        """
        is_on = bool(enable_checkbox.isChecked())
        # Field + scheme are only meaningful when Categorize is ticked.
        field_dropdown.setEnabled(is_on)
        scheme_dropdown.setEnabled(is_on)
        # Customize is disabled while categorize drives rendering.
        if customize_button is not None:
            customize_button.setEnabled(not is_on)

    
    def _build_preloaded_layer_grid(self):
        """
        Creates a grid layout for all preloaded layers:
        - Adds checkbox, style dropdown, and label toggle for each layer.
        """
        layer_rows = []

        for header, names in self.preloaded_layer_groups:
            layer_rows.append(("__header__", header))
            for lname in names:
                layer_rows.append(("__layer__", lname))
                
        grid = QGridLayout()
        # for i, (layer_name, geom_type) in enumerate(self.preloaded_layers):

        row = 0

        for kind, value in layer_rows:
            if kind == "__header__":
                header = QLabel(value)
                header.setStyleSheet(
                    "QLabel { font-weight: bold; padding-top: 8px; padding-bottom: 4px; }"
                )
                grid.addWidget(header, row, 0, 1, 10)  # span all columns
                row += 1
                continue

        # ------------ FOCUS COORDINATES ROW ------------
            if value == "__focus_coords__":

                coord_row = _ClickableFrame()
                coord_row.setObjectName("focus_center_frame")
                coord_row.setFrameShape(QFrame.NoFrame)
                hl = QHBoxLayout(coord_row)
                hl.setContentsMargins(2, 2, 2, 2)
                hl.setSpacing(6)

                lbl = QLabel("      Coordinates of interest: ")
                lbl.setMinimumWidth(120)

                hl.addWidget(lbl)
                hl.addWidget(self.lon_input)
                hl.addWidget(self.lat_input)
                hl.addStretch(1)
                grid.addWidget(coord_row, row, 0, 1, 10)

                # Register as a focus-centre frame and connect click handler
                self._focus_center_frames["coords"] = coord_row
                coord_row.clicked.connect(lambda: self._on_focus_row_clicked("coords"))

                row += 1
                continue

            layer_name = value
            geom_type = self.fixed_geometry_types["preloaded"].get(layer_name, "Polygon")

            alias = self.layer_aliases.get(layer_name, layer_name)

            # Focus_country and Focus_province rows are wrapped in a _ClickableFrame
            # so the user can click anywhere in the row to make it the active map centre.
            is_focus_row = layer_name in ("Focus_country", "Focus_province")
            _row_frame = None
            _row_hl = None
            if is_focus_row:
                _row_frame = _ClickableFrame()
                _row_frame.setObjectName("focus_center_frame")
                _row_frame.setFrameShape(QFrame.NoFrame)
                _row_hl = QHBoxLayout(_row_frame)
                _row_hl.setContentsMargins(2, 2, 2, 2)
                _row_hl.setSpacing(6)

            def _place(widget, col):
                """Add widget to frame's HBox (focus rows) or directly to grid (other rows)."""
                if is_focus_row:
                    _row_hl.addWidget(widget)
                else:
                    grid.addWidget(widget, row, col)

            # Beaver processor: preloaded rows carry NO styling options. The NE
            # focus layers exist solely to project/pan the map, so they get a
            # plain label + multiselect (no visibility toggle, never drawn);
            # basemap rows get a simple on/off checkbox only. All the styling /
            # query / label / categorize widgets are still created and
            # registered in their dicts (hidden, defaults) so the exporter and
            # preset harvest logic downstream keeps working unmodified.
            cb = QCheckBox(alias)
            cb.setChecked(False)
            cb.setToolTip("Toggle visibility of this layer on the exported map.")
            self.layer_checkboxes[layer_name] = cb
            if is_focus_row:
                cb.setVisible(False)  # focus layers are AOI selectors, not drawn
                _lbl = QLabel(alias)
                _lbl.setMinimumWidth(120)
                _place(_lbl, 0)
            else:
                _place(cb, 0)

            # Hidden styling widgets (registered, never placed).
            dd = LazyStyleComboBox(
                lambda name=layer_name: self.fixed_geometry_types["preloaded"].get(name, "Polygon"),
                self.populate_dropdown_with_icons
            )
            dd.addItem("Select style")
            dd.addItem("No symbol")
            dd.setCurrentIndex(0)
            dd.setVisible(False)
            self.layer_style_dropdowns[layer_name] = dd

            _geom_str = self.fixed_geometry_types["preloaded"].get(layer_name, "Polygon")
            _wkb = SymbolCustomizationButton.wkb_from_geom_string(_geom_str)
            size_dropdown = SymbolCustomizationButton(geometry_type=_wkb)
            size_dropdown.setVisible(False)
            self.preloaded_size_dropdowns[layer_name] = size_dropdown

            # Focus selection (the only interactive control besides the toggle).
            q_input = None
            if layer_name in ("Focus_province", "Focus_country"):
                if layer_name == "Focus_province":
                    name_list = get_province_names_sorted_by_country()
                else:
                    name_list = get_name_values(layer_name)
                ms = create_multiselect_dropdown(name_list)
                ms.set_popup_dimensions(rows=20, width=400, row_height_px=22)
                ms.setFixedWidth(260)
                if layer_name == "Focus_country":
                    self.focus_country_multiselect = ms
                else:
                    self.focus_province_multiselect = ms
                _place(ms, 1)
            else:
                q_input = self._make_preloaded_query_widget(layer_name)
                q_input.setVisible(False)
                self.preloaded_definition_queries[layer_name] = q_input

            # Hidden label / categorize widgets (registered, never placed).
            lb_enable = QCheckBox("Label"); lb_enable.setChecked(False)
            lb_enable.setVisible(False)
            self.preloaded_label_toggles[layer_name] = lb_enable

            lb_field = QComboBox()
            lb_field.setPlaceholderText("Label field")
            lb_field.addItem(lb_field.placeholderText())
            lb_field.setCurrentIndex(0)
            lb_field.setVisible(False)
            self.preloaded_label_fields[layer_name] = lb_field

            lb_size = QComboBox()
            lb_size.addItem("Label size")
            lb_size.addItems([str(s) for s in range(6, 21)])
            lb_size.setVisible(False)
            self.preloaded_label_sizes[layer_name] = lb_size

            cat_enable = QCheckBox("Categorize"); cat_enable.setChecked(False)
            cat_enable.setVisible(False)
            cat_field = QComboBox()
            cat_field.addItem("Category field"); cat_field.setCurrentIndex(0)
            cat_field.setVisible(False)
            cat_scheme = QComboBox()
            cat_scheme.addItem("Categorized symbols"); cat_scheme.setCurrentIndex(0)
            cat_scheme.setVisible(False)
            self.preloaded_category_enable[layer_name] = cat_enable
            self.preloaded_category_field[layer_name] = cat_field
            self.preloaded_category_scheme[layer_name] = cat_scheme

            # Focus rows: add the wrapping frame to the grid and register it
            if is_focus_row:
                # Trailing stretch packs the label + selector together on the
                # left instead of spreading them across the row.
                _row_hl.addStretch(1)
                grid.addWidget(_row_frame, row, 0, 1, 10)
                self._focus_center_frames[layer_name] = _row_frame
                _row_frame.clicked.connect(
                    lambda checked=False, key=layer_name: self._on_focus_row_clicked(key)
                )

            row += 1

        # Compact layout: tight row spacing, and send all surplus vertical
        # space to a trailing stretch row so rows don't drift apart when the
        # section is taller than its content.
        grid.setVerticalSpacing(4)
        grid.setRowStretch(row, 1)

        return grid
    
    
    # === Helper: metadata group ===================================================
    def _build_metadata_group(self, validators):
        group = QGroupBox("Provide map metadata:")
        group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)

        layout = QVBoxLayout()

        
        # Content of map
        self.userinput2_label = QLabel("Describe the map:")
        self.map_content_input = QLineEdit()
        self.map_content_input.setValidator(validators["validator_5_30"])
        self.map_content_input.setPlaceholderText("LetterCharactersOnly")
        self.map_content_input.setToolTip("Describe the content of the map in CamelCase format.")

        self.recipient_label = QLabel("Recipient/destination(s):")
        self.recipient_input = QLineEdit()
        self.recipient_input.setValidator(validators["validator_5_30"])
        self.recipient_input.setPlaceholderText("Recipient_1, Recipient_2, etc")
        self.recipient_input.setToolTip("Enter the intended recipient(s) of the map.")

        self.producer_label = QLabel("Map producer:")
        self.producer_input = QLineEdit()
        self.producer_input.setValidator(validators["validator_5_30"])
        self.producer_input.setPlaceholderText("Firstname Lastname")

        row = QHBoxLayout()
        row.addWidget(self.userinput2_label)
        row.addWidget(self.map_content_input)
        row.addWidget(self.recipient_label)
        row.addWidget(self.recipient_input)
        row.addWidget(self.producer_label)
        row.addWidget(self.producer_input)
        layout.addLayout(row)

        # Additional data sources
        self.custom_sources_label = QLabel("List all external data sources used:")
        self.custom_sources_input = QLineEdit()
        self.custom_sources_input.setPlaceholderText("Natural Earth (2026). Project Owl (2026).")
        self.custom_sources_input.setToolTip("Any user-inputted data uploaded to the geoprocessing " \
                                            "panel (custom points, CSVs, shapefiles, KMLs) must be " \
                                            "sourced.")
        self.custom_sources_input.setValidator(validators["validator_add_sources"])
        self.custom_sources_input.setMaxLength(75)

        self.format_label = QLabel("Export as:")
        self.png_radio = QRadioButton("PNG"); self.jpg_radio = QRadioButton("JPG"); self.pdf_radio = QRadioButton("PDF (as image)")
        self.png_radio.setChecked(True)
        self.format_and_export_group = QButtonGroup()
        self.format_and_export_group.addButton(self.png_radio)
        self.format_and_export_group.addButton(self.jpg_radio)
        self.format_and_export_group.addButton(self.pdf_radio)

        self.export_final_button = QPushButton("E X P O R T")
        self.export_final_button.setToolTip("The 'EXPORT' button remains grayed out until all changes are generated in the map preview. Please click 'BUILD' before exporting.")
        self.export_final_button.setEnabled(False)
        self.export_final_button.clicked.connect(self.confirm_and_export)

        row2 = QHBoxLayout()
        row2.addWidget(self.custom_sources_label)
        row2.addWidget(self.custom_sources_input)
        row2.addWidget(self.format_label)
        row2.addWidget(self.png_radio)
        row2.addWidget(self.jpg_radio)
        row2.addWidget(self.pdf_radio)
        row2.addWidget(self.export_final_button)
        layout.addLayout(row2)

        group.setLayout(layout)
        group.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
        return group

    # === Helper: right-side custom points ========================================
    # === Helper: per-section user guide popups ===================================
    # Maps the legacy per-section guide filenames to main-guide entry keys.
    _GUIDE_FILE_KEYS = {
        "guide_custom_points.html": "points",
        "guide_csv_upload.html": "csv",
        "guide_shp_kml_upload.html": "shp",
        "guide_hex_bins.html": "hex",
        "guide_measure_distances.html": "dist",
    }

    def _make_guide_button_row(self, guide_filename: str, title: str) -> QHBoxLayout:
        """Build a left-aligned 'Consult user guide' button row for a section.

        Opens the main guide jumped to this section's entry (the former
        per-section HTML popups are retired; the main guide is the only guide).
        """
        key = self._GUIDE_FILE_KEYS.get(guide_filename)
        row = QHBoxLayout()
        btn = QPushButton("Consult user guide")
        btn.setFixedWidth(150)
        btn.setToolTip("Open the guide at this section.")
        btn.clicked.connect(lambda: self.show_guided_tour(force=True, jump_to=key))
        row.addWidget(btn)
        row.addStretch(1)
        return row

    def _build_points_section(self):
        container_layout = QVBoxLayout()
        # Coordinate format radios (DD / DMS) used by compile_arguments_for_exporter()
        self.dd_radio = QRadioButton("Decimal Degrees (DD)"); self.dd_radio.setChecked(True)
        self.dms_radio = QRadioButton("Degrees Minutes Seconds (DMS)")
        self.dd_radio.setToolTip("Use decimal degrees format for coordinate inputs. Examples: 25 ; 25.5")
        self.dms_radio.setToolTip("Use degrees-minutes-seconds format. Examples: 25 ; 2530 ; 253043")
        self.coord_format_group = QButtonGroup()
        self.coord_format_group.addButton(self.dd_radio)
        self.coord_format_group.addButton(self.dms_radio)

        grid = QGridLayout()
        fmt_label = QLabel("Coordinate format:"); fmt_label.setStyleSheet("font-weight: bold;")
        grid.addWidget(fmt_label, 0, 0); grid.addWidget(self.dd_radio, 0, 1); grid.addWidget(self.dms_radio, 0, 2)

        size_values = ["Size"] + [f"{i/2:.1f}" for i in range(0, 41)]  # 0.0 to 20.0
        for i in range(25):
            lon_input = QLineEdit(); lon_input.setMinimumWidth(80); lon_input.setPlaceholderText("Longitude")
            lat_input = QLineEdit(); lat_input.setMinimumWidth(80); lat_input.setPlaceholderText("Latitude")
            name_input = QLineEdit(); name_input.setMinimumWidth(150); name_input.setPlaceholderText("Label")

            point_style_dropdown = LazyStyleComboBox(
                lambda: self.fixed_geometry_types["custom_points"],
                self.populate_dropdown_with_icons
            )
            point_style_dropdown.setFixedWidth(150); point_style_dropdown.setPlaceholderText("Select style")

            point_style_dropdown.addItem("Select style")
            point_style_dropdown.addItem("No symbol")
            point_style_dropdown.setCurrentIndex(0)

            point_size_dropdown = SymbolCustomizationButton(geometry_type=_GEOM_POINT)
            point_size_dropdown.link_style_dropdown(point_style_dropdown, lambda: self.styles)
            self.point_size_dropdowns.append(point_size_dropdown)

            radius_input = QLineEdit(); radius_input.setPlaceholderText("Radius (km)")
            radius_input.setFixedWidth(75)

            ring_style_dropdown = LazyStyleComboBox(
                lambda: self.fixed_geometry_types["range_rings"],
                self.populate_dropdown_with_icons
            )
            ring_style_dropdown.setFixedWidth(150); ring_style_dropdown.setPlaceholderText("Select style")

            ring_style_dropdown.addItem("Select style")
            ring_style_dropdown.addItem("No symbol")
            ring_style_dropdown.setCurrentIndex(0)
            
            ring_outline_dropdown = SymbolCustomizationButton(geometry_type=_GEOM_POLYGON)
            ring_outline_dropdown.link_style_dropdown(ring_style_dropdown, lambda: self.styles)
            self.ring_outline_dropdowns.append(ring_outline_dropdown)

            row = i + 1
            grid.addWidget(lon_input, row, 0)
            grid.addWidget(lat_input, row, 1)
            grid.addWidget(point_style_dropdown, row, 3)
            grid.addWidget(point_size_dropdown, row, 4)
            grid.addWidget(name_input, row, 2)
            grid.addWidget(radius_input, row, 5)
            grid.addWidget(ring_style_dropdown, row, 6)
            grid.addWidget(ring_outline_dropdown, row, 7)

            self.lon_inputs.append(lon_input)
            self.lat_inputs.append(lat_input)
            self.name_inputs.append(name_input)
            self.radius_inputs.append(radius_input)
            self.point_symbology_dropdowns.append(point_style_dropdown)
            self.ring_symbology_dropdowns.append(ring_style_dropdown)


        points_container = QWidget(); points_container.setLayout(grid)
        points_group = QGroupBox("Create, style and add custom points to the map.")
        vg = QVBoxLayout(points_group); vg.addWidget(points_container)
        vg.insertLayout(0, self._make_guide_button_row(
            "guide_custom_points.html", "User Guide — Custom Points and Range Rings"))

        points_scroll = QScrollArea()
        points_scroll.setWidget(points_group)
        points_scroll.setWidgetResizable(True)
        points_scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        points_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        points_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)

        container_layout.addWidget(points_scroll)
        return points_scroll

    # === Helper: right-side CSV upload ===========================================
    def _build_csv_section(self):
        csv_layout = QVBoxLayout()

        for i in range(10):
            dd_radio = QRadioButton("DD"); dms_radio = QRadioButton("DMS"); dd_radio.setChecked(True)
            csv_format_group = QButtonGroup(); csv_format_group.addButton(dd_radio); csv_format_group.addButton(dms_radio)
            self.csv_format_groups.append((csv_format_group, dd_radio, dms_radio))

            browse_csv_btn = QPushButton("Browse for CSV")
            browse_csv_btn.setFixedWidth(130)
            browse_csv_btn.clicked.connect(lambda checked, idx=i: self._browse_csv(idx))

            csv_input = QLineEdit()
            csv_input.setReadOnly(True)
            csv_input.setFixedWidth(200)
            csv_input.setPlaceholderText(f"spreadsheet_{i+1}.csv")
            csv_input.setContextMenuPolicy(Qt.CustomContextMenu)
            csv_input.customContextMenuRequested.connect(
                lambda pos, w=csv_input, idx=i: self._show_remove_menu(
                    pos, w, lambda: self._clear_csv_row(idx)))
            self.csv_inputs.append(csv_input)

            name_field_dropdown = QComboBox(); name_field_dropdown.setFixedWidth(150); name_field_dropdown.setPlaceholderText("Label field")
            name_field_dropdown.addItem(name_field_dropdown.placeholderText())
            name_field_dropdown.setCurrentIndex(0)

            self.csv_name_field_dropdowns.append(name_field_dropdown)

            point_radio = QRadioButton("Display as points"); line_radio = QRadioButton("Draw line"); point_radio.setChecked(True)
            radio_group = QButtonGroup(); radio_group.addButton(point_radio); radio_group.addButton(line_radio)
            self.csv_radio_groups.append((radio_group, point_radio, line_radio))

            x_field_dropdown = QComboBox(); x_field_dropdown.setFixedWidth(110); x_field_dropdown.setPlaceholderText("Longitude column")
            
            x_field_dropdown.addItem(x_field_dropdown.placeholderText())
            x_field_dropdown.setCurrentIndex(0)

            x_field_dropdown.setToolTip("Geographic coordinates are required to render points on maps. CSVs uploaded need"
                                        " to hold columns with longitude and latitude values, in either DD or DMS.")
            y_field_dropdown = QComboBox(); y_field_dropdown.setFixedWidth(110); y_field_dropdown.setPlaceholderText("Latitude column")


            y_field_dropdown.addItem(y_field_dropdown.placeholderText())
            y_field_dropdown.setCurrentIndex(0)


            y_field_dropdown.setToolTip("Geographic coordinates are required to render points on maps. CSVs uploaded need"
                                        " to hold columns with longitude and latitude values, in either DD or DMS.")            
            self.csv_x_field_dropdowns.append(x_field_dropdown); self.csv_y_field_dropdowns.append(y_field_dropdown)

            sym_dropdown = LazyStyleComboBox(lambda: "point_line", self.populate_dropdown_with_icons)
            sym_dropdown.setFixedWidth(180)
            sym_dropdown.setPlaceholderText("Select style")
            sym_dropdown.addItem("Select style")
            sym_dropdown.addItem("No symbol")
            sym_dropdown.setCurrentIndex(0)


            self.csv_symbology_dropdowns.append(sym_dropdown)

            # Customize symbol button — replaces the old per-CSV-row "Size" combo.
            # Initial geometry is Point; the radio buttons below toggle the row
            # to Line and back, so the customize button mirrors that choice.
            size_dropdown = SymbolCustomizationButton(geometry_type=_GEOM_POINT)
            size_dropdown.link_style_dropdown(sym_dropdown, lambda: self.styles)
            # When the user toggles "Draw line", switch the customize button
            # to Line geometry so the editor offers line-symbol options. We
            # only listen on point/line, not the DD/DMS coordinate radios.
            point_radio.toggled.connect(
                lambda checked, b=size_dropdown:
                    b.set_geometry_type(_GEOM_POINT) if checked else None
            )
            line_radio.toggled.connect(
                lambda checked, b=size_dropdown:
                    b.set_geometry_type(_GEOM_LINE) if checked else None
            )
            self.csv_size_dropdowns.append(size_dropdown)

            label_size_dropdown = QComboBox(); label_size_dropdown.setFixedWidth(90)
            label_size_dropdown.addItem("Label size"); label_size_dropdown.addItems([str(s) for s in range(6, 21)])
            self.csv_label_size_dropdowns.append(label_size_dropdown)

            row = QHBoxLayout()
            row.addWidget(csv_input)
            row.addWidget(browse_csv_btn)
            row.addWidget(dd_radio); row.addWidget(dms_radio)
            row.addWidget(x_field_dropdown); row.addWidget(y_field_dropdown)
            row.addWidget(sym_dropdown); row.addWidget(size_dropdown)
            row.addWidget(name_field_dropdown); row.addWidget(label_size_dropdown)
            row.addWidget(point_radio); row.addWidget(line_radio)


            csv_layout.addLayout(row)

        csv_container = QWidget(); csv_container.setLayout(csv_layout)
        csv_group = QGroupBox("Upload CSVs containing LAT & LONG coordinates.")
        vg = QVBoxLayout(csv_group); vg.addWidget(csv_container)
        vg.insertLayout(0, self._make_guide_button_row(
            "guide_csv_upload.html", "User Guide — CSV Upload"))

        csv_scroll = QScrollArea()
        csv_scroll.setWidget(csv_group)
        csv_scroll.setWidgetResizable(True)
        csv_scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        csv_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        csv_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        return csv_scroll

    # === Helper: right-side shapefile/KML upload =================================
    def _build_upload_section(self):
        shp_layout = QVBoxLayout()

        for i in range(10):
            browse_shp_btn = QPushButton("Browse for Shp/KML")
            browse_shp_btn.setFixedWidth(130)
            browse_shp_btn.clicked.connect(lambda checked, idx=i: self._browse_upload(idx))

            upload_input = QLineEdit()
            upload_input.setReadOnly(True)
            upload_input.setFixedWidth(200)
            upload_input.setPlaceholderText(f"layer_{i+1}.shp")
            upload_input.setContextMenuPolicy(Qt.CustomContextMenu)
            upload_input.customContextMenuRequested.connect(
                lambda pos, w=upload_input, idx=i: self._show_remove_menu(
                    pos, w, lambda: self._clear_upload_row(idx)))
            self.upload_inputs.append(upload_input)

            # 1) Select style
            sym_dropdown = LazyStyleComboBox(lambda: "all", self.populate_dropdown_with_icons)
            sym_dropdown.setFixedWidth(150)
            sym_dropdown.setPlaceholderText("Select style")
            sym_dropdown.addItem("Select style")
            sym_dropdown.addItem("No symbol")
            sym_dropdown.setCurrentIndex(0)

            self.upload_symbology_dropdowns.append(sym_dropdown)

            # 2) Customize symbol — replaces the old "Size" combo. Geometry
            # isn't known until the user actually loads a file (could be
            # Point / Line / Polygon), so the button starts as Polygon and
            # the loader call site updates it after introspecting the layer.
            # Falling back to Polygon keeps the editor functional (outline
            # width / colour map cleanly to any geometry).
            size_dropdown = SymbolCustomizationButton(geometry_type=_GEOM_POLYGON)
            size_dropdown.link_style_dropdown(sym_dropdown, lambda: self.styles)
            self.upload_size_dropdowns.append(size_dropdown)

            # 3) Filter query
            query_input = self._make_file_query_widget(
                lambda i=i: self.upload_inputs[i].text())
            self.upload_definition_queries.append(query_input)

            # 4) Label (checkbox)
            label_enable = QCheckBox("Label")
            label_enable.setChecked(False)
            self.upload_label_toggles.append(label_enable)

            # 5) Label field
            name_field_dropdown = QComboBox()
            name_field_dropdown.setFixedWidth(150)
            name_field_dropdown.setPlaceholderText("Label field")
            name_field_dropdown.addItem("Label field")
            name_field_dropdown.addItem(name_field_dropdown.placeholderText())
            name_field_dropdown.setCurrentIndex(0)
            self.upload_name_field_dropdowns.append(name_field_dropdown)

            # 6) Label size
            label_size_dropdown = QComboBox()
            label_size_dropdown.setFixedWidth(90)
            label_size_dropdown.addItem("Label size")
            label_size_dropdown.addItems([str(s) for s in range(6, 21)])
            self.upload_label_size_dropdowns.append(label_size_dropdown)

            name_field_dropdown.setEnabled(False); label_size_dropdown.setEnabled(False)
            label_enable.stateChanged.connect(
                lambda _s, e=label_enable, f=name_field_dropdown, z=label_size_dropdown:
                    self._toggle_label_controls(e, f, z)
            )

            # 7) Categorize checkbox
            cat_enable = QCheckBox("Categorize")
            cat_enable.setChecked(False)
            self.upload_category_enable.append(cat_enable)

            # 8) Categorize field dropdown
            cat_field = QComboBox()
            cat_field.setFixedWidth(140)
            cat_field.setPlaceholderText("Category field")
            cat_field.addItem("Category field")
            cat_field.addItem(cat_field.placeholderText())
            cat_field.setCurrentIndex(0)

            self.upload_category_field.append(cat_field)

            # 9) Categorized symbols (ramp) dropdown
            cat_scheme = QComboBox()
            cat_scheme.setFixedWidth(180)
            cat_scheme.setPlaceholderText("Categorized symbols")

            cat_scheme.addItem(cat_scheme.placeholderText())
            cat_scheme.setCurrentIndex(0)

            self.upload_category_scheme.append(cat_scheme)

            # Populate ramp names (project + default) with gradient icon previews
            try:
                ramp_names = []
                try:
                    proj_style = core_project().styleSettings().projectStyle()
                    ramp_names.extend(proj_style.colorRampNames() or [])
                except Exception:
                    pass
                ramp_names_default = QgsStyle.defaultStyle().colorRampNames() or []
                seen = set()
                for rn in ramp_names + ramp_names_default:
                    if rn not in seen:
                        seen.add(rn)
                        cat_scheme.addItem(self._make_ramp_icon(rn), rn)
                if cat_scheme.count() == 0:
                    cat_scheme.addItem("viridis")
            except Exception as e:
                logger.warning(f"Failed to populate uploaded categorized schemes: {e}")
                cat_scheme.clear(); cat_scheme.addItem("viridis")

            # Disable field/scheme until Categorize is checked. Also grey
            # out the Customize button so the user can't simultaneously
            # categorize and customise a single symbol — the colour ramp
            # is authoritative when categorize is on.
            cat_field.setEnabled(False)
            cat_scheme.setEnabled(False)
            cat_enable.stateChanged.connect(
                lambda _s, e=cat_enable, f=cat_field, s=cat_scheme, c=size_dropdown:
                    self._toggle_categorize_controls(e, f, s, customize_button=c)
            )
            # Build row left→right
            row = QHBoxLayout()
            row.addWidget(upload_input)
            row.addWidget(browse_shp_btn)
            row.addWidget(sym_dropdown)
            row.addWidget(size_dropdown)
            row.addWidget(query_input)
            row.addWidget(label_enable)
            row.addWidget(name_field_dropdown)
            row.addWidget(label_size_dropdown)
            row.addWidget(cat_enable)
            row.addWidget(cat_field)
            row.addWidget(cat_scheme)

            shp_layout.addLayout(row)

        shp_container = QWidget(); shp_container.setLayout(shp_layout)
        shp_group = QGroupBox("Upload shapefiles or KML (Keyhole Markup Language) files.")
        vg = QVBoxLayout(shp_group); vg.addWidget(shp_container)
        vg.insertLayout(0, self._make_guide_button_row(
            "guide_shp_kml_upload.html", "User Guide — Shapefile and KML Upload"))

        shp_scroll = QScrollArea()
        shp_scroll.setWidget(shp_group)
        shp_scroll.setWidgetResizable(True)
        shp_scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        shp_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        shp_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        return shp_scroll


    # === Helper: hex-bin aggregation upload ======================================
    def _browse_hex(self, idx: int, path: str = None):
        """Open a file picker for point SHP / KML / GPKG and populate row idx."""
        if path is None:
            path, _ = QFileDialog.getOpenFileName(
                self, "Select point layer",
                "",
                "Point layers (*.shp *.kml *.kmz *.gpkg);;All files (*.*)"
            )
        if not path:
            return
        self.hex_inputs[idx].setText(path)
        self.populate_field_dropdown(path, self.hex_label_field_dropdowns[idx])
        # New source file → clear any stale field/value selection in the filter.
        self._query_file_layer_cache.pop(path, None)
        if idx < len(self.hex_query_inputs):
            self.hex_query_inputs[idx].reset()

    # ── Row-clear helpers (right-click "Remove") ──────────────────────────────

    def _show_remove_menu(self, pos, widget, clear_fn):
        """Show a tiny context menu with a single Remove action."""
        menu = QMenu(widget)
        act = menu.addAction("Remove")
        act.triggered.connect(clear_fn)
        menu.exec_(widget.mapToGlobal(pos))

    def _clear_csv_row(self, idx: int):
        """Reset all widgets in CSV row idx to their blank defaults."""
        self.csv_inputs[idx].clear()
        # Field dropdowns back to placeholder
        for dd, ph in (
            (self.csv_x_field_dropdowns[idx],   "Longitude column"),
            (self.csv_y_field_dropdowns[idx],   "Latitude column"),
            (self.csv_name_field_dropdowns[idx], "Label field"),
        ):
            dd.clear()
            dd.addItem(ph)
            dd.setCurrentIndex(0)
        # Style + customize
        self.csv_symbology_dropdowns[idx].setCurrentIndex(0)
        btn = self.csv_size_dropdowns[idx]
        if isinstance(btn, SymbolCustomizationButton):
            try:
                btn.reset()
            except Exception:
                pass
        # Label size back to first item
        self.csv_label_size_dropdowns[idx].setCurrentIndex(0)
        # Radios back to DD + point
        _fmt_grp, _dd_radio, _dms_radio = self.csv_format_groups[idx]
        _dd_radio.setChecked(True)
        _rg, _pt_radio, _ln_radio = self.csv_radio_groups[idx]
        _pt_radio.setChecked(True)

    def _clear_upload_row(self, idx: int):
        """Reset all widgets in upload (Shp/KML) row idx to blank defaults."""
        self.upload_inputs[idx].clear()
        self.upload_symbology_dropdowns[idx].setCurrentIndex(0)
        btn = self.upload_size_dropdowns[idx]
        if isinstance(btn, SymbolCustomizationButton):
            try:
                btn.reset()
            except Exception:
                pass
        self.upload_definition_queries[idx].reset()
        self.upload_label_toggles[idx].setChecked(False)
        self.upload_name_field_dropdowns[idx].clear()
        self.upload_name_field_dropdowns[idx].addItem("Label field")
        self.upload_name_field_dropdowns[idx].setCurrentIndex(0)
        self.upload_label_size_dropdowns[idx].setCurrentIndex(0)
        self.upload_category_enable[idx].setChecked(False)
        self.upload_category_field[idx].clear()
        self.upload_category_field[idx].addItem("Category field")
        self.upload_category_field[idx].setCurrentIndex(0)
        self.upload_category_scheme[idx].setCurrentIndex(0)

    def _clear_hex_row(self, idx: int):
        """Reset all widgets in hex-bin row idx to blank defaults."""
        self.hex_inputs[idx].clear()
        self.hex_diameter_inputs[idx].setValue(50)
        self.hex_classes_inputs[idx].setValue(8)
        self.hex_mode_dropdowns[idx].setCurrentIndex(0)
        self.hex_query_inputs[idx].reset()
        self.hex_label_toggles[idx].setChecked(False)
        self.hex_label_field_dropdowns[idx].clear()
        self.hex_label_field_dropdowns[idx].addItem("Label field")
        self.hex_label_field_dropdowns[idx].setCurrentIndex(0)
        self.hex_label_field_dropdowns[idx].setEnabled(False)
        self.hex_label_size_dropdowns[idx].setCurrentIndex(0)
        self.hex_label_size_dropdowns[idx].setEnabled(False)
        btn = self.hex_customize_buttons[idx]
        if isinstance(btn, SymbolCustomizationButton):
            try:
                btn.reset()
            except Exception:
                pass

    # ── Measure-distances helpers ─────────────────────────────────────────────

    def _update_dist_row_exclusion(self, i: int):
        """Enforce mutual exclusion between manual lon/lat and file upload for row i."""
        lon_has_text  = bool(self.dist_lon_inputs[i].text().strip())
        lat_has_text  = bool(self.dist_lat_inputs[i].text().strip())
        file_has_text = bool(self.dist_file_inputs[i].text().strip())

        if lon_has_text or lat_has_text:
            # Manual mode: disable file widgets
            self.dist_browse_btns[i].setEnabled(False)
            self.dist_file_inputs[i].setEnabled(False)
            self.dist_x_field_dropdowns[i].setEnabled(False)
            self.dist_y_field_dropdowns[i].setEnabled(False)
        elif file_has_text:
            # File mode: disable lon/lat inputs
            self.dist_lon_inputs[i].setEnabled(False)
            self.dist_lat_inputs[i].setEnabled(False)
        else:
            # Both empty: re-enable everything
            self.dist_browse_btns[i].setEnabled(True)
            self.dist_file_inputs[i].setEnabled(True)
            self.dist_lon_inputs[i].setEnabled(True)
            self.dist_lat_inputs[i].setEnabled(True)
            self.dist_x_field_dropdowns[i].setEnabled(False)
            self.dist_y_field_dropdowns[i].setEnabled(False)

    def _browse_dist(self, i: int):
        """Open a file dialog and populate dist row i with the chosen file."""
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Select point file",
            "",
            "Point files (*.shp *.kml *.csv);;All files (*.*)",
        )
        if not path:
            return
        self.dist_file_inputs[i].setText(path)
        self.dist_lon_inputs[i].setEnabled(False)
        self.dist_lat_inputs[i].setEnabled(False)

        ext = os.path.splitext(path)[1].lower()
        if ext == ".csv":
            try:
                self.populate_field_dropdown(path, self.dist_x_field_dropdowns[i])
                self.populate_field_dropdown(path, self.dist_y_field_dropdowns[i])
            except Exception:
                pass
            self.dist_x_field_dropdowns[i].setEnabled(True)
            self.dist_y_field_dropdowns[i].setEnabled(True)
        else:
            self.dist_x_field_dropdowns[i].clear()
            self.dist_x_field_dropdowns[i].setEnabled(False)
            self.dist_y_field_dropdowns[i].clear()
            self.dist_y_field_dropdowns[i].setEnabled(False)

    def _clear_dist_row(self, i: int):
        """Reset all widgets in dist row i to blank defaults."""
        self.dist_file_inputs[i].setText("")
        self.dist_lon_inputs[i].setText("")
        self.dist_lat_inputs[i].setText("")
        self.dist_x_field_dropdowns[i].clear()
        self.dist_y_field_dropdowns[i].clear()
        self.dist_lon_inputs[i].setEnabled(True)
        self.dist_lat_inputs[i].setEnabled(True)
        self.dist_browse_btns[i].setEnabled(True)
        self.dist_file_inputs[i].setEnabled(True)
        self.dist_x_field_dropdowns[i].setEnabled(False)
        self.dist_y_field_dropdowns[i].setEnabled(False)

    def _collect_dist_specs(self):
        """Serialise the Measure-distances section into a list of group dicts."""
        specs = []
        for g in range(4):
            rows_data = []
            for r in range(3):
                idx = g * 3 + r
                rows_data.append({
                    "lon":     self.dist_lon_inputs[idx].text().strip(),
                    "lat":     self.dist_lat_inputs[idx].text().strip(),
                    "path":    self.dist_file_inputs[idx].text().strip(),
                    "x_field": self.dist_x_field_dropdowns[idx].currentText().strip(),
                    "y_field": self.dist_y_field_dropdowns[idx].currentText().strip(),
                })
            lbl_txt = self.dist_label_size_dds[g].currentText().strip()
            try:
                xml = self.dist_customize_btns[g].serialize() or ""
            except Exception:
                xml = ""
            specs.append({
                "group":      g,
                "rows":       rows_data,
                "style":      self.dist_style_dropdowns[g].currentText().strip(),
                "symbol_xml": xml,
                "label_on":   self.dist_label_toggles[g].isChecked(),
                "label_size": int(lbl_txt) if lbl_txt.isdigit() else None,
            })
        return specs

    def _build_distance_section(self):
        """Build the Measure distances section (4 groups of 3 rows)."""
        outer_layout = QVBoxLayout()

        for g in range(4):
            grp_box = QGroupBox(f"Group {g + 1}  (rows {g*3+1}–{g*3+3})")
            grp_vbox = QVBoxLayout(grp_box)

            # ── 3 data rows ───────────────────────────────────────────────────
            for r in range(3):
                i = g * 3 + r  # global row index 0-11

                role_label = QLabel("Source" if r == 0 else f"Target {r}")
                role_label.setFixedWidth(55)

                lon_input = QLineEdit()
                lon_input.setFixedWidth(90)
                lon_input.setPlaceholderText("Longitude")
                lon_input.textChanged.connect(lambda _t, idx=i: self._update_dist_row_exclusion(idx))
                self.dist_lon_inputs.append(lon_input)

                lat_input = QLineEdit()
                lat_input.setFixedWidth(90)
                lat_input.setPlaceholderText("Latitude")
                lat_input.textChanged.connect(lambda _t, idx=i: self._update_dist_row_exclusion(idx))
                self.dist_lat_inputs.append(lat_input)

                browse_btn = QPushButton("Browse")
                browse_btn.setFixedWidth(70)
                browse_btn.clicked.connect(lambda _c, idx=i: self._browse_dist(idx))
                self.dist_browse_btns.append(browse_btn)

                file_input = QLineEdit()
                file_input.setReadOnly(True)
                file_input.setFixedWidth(200)
                file_input.setPlaceholderText("or upload .shp / .kml / .csv")
                file_input.setContextMenuPolicy(Qt.CustomContextMenu)
                file_input.customContextMenuRequested.connect(
                    lambda pos, w=file_input, idx=i: self._show_remove_menu(
                        pos, w, lambda: self._clear_dist_row(idx)))
                self.dist_file_inputs.append(file_input)

                x_dd = QComboBox()
                x_dd.setFixedWidth(110)
                x_dd.setPlaceholderText("Lon column")
                x_dd.setEnabled(False)
                self.dist_x_field_dropdowns.append(x_dd)

                y_dd = QComboBox()
                y_dd.setFixedWidth(110)
                y_dd.setPlaceholderText("Lat column")
                y_dd.setEnabled(False)
                self.dist_y_field_dropdowns.append(y_dd)

                row_hbox = QHBoxLayout()
                row_hbox.addWidget(role_label)
                row_hbox.addWidget(lon_input)
                row_hbox.addWidget(lat_input)
                row_hbox.addWidget(browse_btn)
                row_hbox.addWidget(file_input)
                row_hbox.addWidget(x_dd)
                row_hbox.addWidget(y_dd)
                row_hbox.addStretch()
                grp_vbox.addLayout(row_hbox)

            # ── Group-level controls (style / customize / label) ──────────────
            style_dd = LazyStyleComboBox(
                lambda: "Line",
                self.populate_dropdown_with_icons,
            )
            style_dd.setFixedWidth(170)
            style_dd.setPlaceholderText("Select style")
            style_dd.addItem("Select style")
            style_dd.setCurrentIndex(0)
            self.dist_style_dropdowns.append(style_dd)

            customize_btn = SymbolCustomizationButton(geometry_type=_GEOM_LINE)
            customize_btn.link_style_dropdown(style_dd, lambda: self.styles)
            self.dist_customize_btns.append(customize_btn)

            label_chk = QCheckBox("Label distances")
            label_chk.setChecked(False)
            self.dist_label_toggles.append(label_chk)

            label_size_dd = QComboBox()
            label_size_dd.setFixedWidth(90)
            label_size_dd.addItem("Label size")
            label_size_dd.addItems([str(s) for s in range(6, 21)])
            label_size_dd.setEnabled(False)
            self.dist_label_size_dds.append(label_size_dd)

            label_chk.stateChanged.connect(
                lambda _s, chk=label_chk, sz=label_size_dd:
                    sz.setEnabled(chk.isChecked())
            )

            ctrl_hbox = QHBoxLayout()
            ctrl_hbox.addWidget(QLabel("Style:"))
            ctrl_hbox.addWidget(style_dd)
            ctrl_hbox.addWidget(customize_btn)
            ctrl_hbox.addWidget(label_chk)
            ctrl_hbox.addWidget(label_size_dd)
            ctrl_hbox.addStretch()
            grp_vbox.addLayout(ctrl_hbox)

            outer_layout.addWidget(grp_box)

        outer_container = QWidget()
        outer_container.setLayout(outer_layout)
        outer_group = QGroupBox("Measure distances between points.")
        vg = QVBoxLayout(outer_group)
        vg.addWidget(outer_container)
        vg.insertLayout(0, self._make_guide_button_row(
            "guide_measure_distances.html", "User Guide — Measure Distances"))

        dist_scroll = QScrollArea()
        dist_scroll.setWidget(outer_group)
        dist_scroll.setWidgetResizable(True)
        dist_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        dist_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        dist_scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        return dist_scroll

    def _build_hex_section(self):
        """Build the hex-bin aggregation section (10 rows)."""
        hex_layout = QVBoxLayout()

        # Shared colour-ramp names (same population as categorize dropdowns)
        ramp_names = []
        try:
            proj_ramps = (core_project()
                          .styleSettings().projectStyle().colorRampNames() or [])
            ramp_names.extend(proj_ramps)
        except Exception:
            pass
        try:
            default_ramps = QgsStyle.defaultStyle().colorRampNames() or []
            seen = set(ramp_names)
            for rn in default_ramps:
                if rn not in seen:
                    ramp_names.append(rn)
                    seen.add(rn)
        except Exception:
            pass
        if not ramp_names:
            ramp_names = ["Blues", "Reds", "Viridis", "Spectral"]

        for i in range(10):
            # ── File path + browse ──────────────────────────────────────────
            hex_input = QLineEdit()
            hex_input.setReadOnly(True)
            hex_input.setFixedWidth(200)
            hex_input.setPlaceholderText(f"points_{i+1}.shp")
            hex_input.setContextMenuPolicy(Qt.CustomContextMenu)
            hex_input.customContextMenuRequested.connect(
                lambda pos, w=hex_input, idx=i: self._show_remove_menu(
                    pos, w, lambda: self._clear_hex_row(idx)))
            self.hex_inputs.append(hex_input)

            browse_btn = QPushButton("Browse for points")
            browse_btn.setFixedWidth(130)
            browse_btn.clicked.connect(lambda _checked, idx=i: self._browse_hex(idx))

            # ── Diameter ────────────────────────────────────────────────────
            diameter = QSpinBox()
            diameter.setRange(5, 500)
            diameter.setValue(50)
            diameter.setSuffix(" km")
            diameter.setFixedWidth(80)
            diameter.setToolTip("Approximate diameter of each hexagon bin (flat-to-flat, in km).")
            self.hex_diameter_inputs.append(diameter)

            # ── Colour ramp ─────────────────────────────────────────────────
            ramp_dd = QComboBox()
            ramp_dd.setFixedWidth(160)
            ramp_dd.setPlaceholderText("Colour ramp")
            for rn in ramp_names:
                ramp_dd.addItem(self._make_ramp_icon(rn), rn)
            # Default to a perceptually sequential ramp if available
            for preferred in ("Blues", "YlOrRd", "Viridis", "Reds"):
                if preferred in ramp_names:
                    ramp_dd.setCurrentText(preferred)
                    break
            self.hex_ramp_dropdowns.append(ramp_dd)

            # ── Classes ─────────────────────────────────────────────────────
            classes_sb = QSpinBox()
            classes_sb.setRange(2, 12)
            classes_sb.setValue(8)
            classes_sb.setPrefix("Classes: ")
            classes_sb.setFixedWidth(100)
            self.hex_classes_inputs.append(classes_sb)

            # ── Classification mode ──────────────────────────────────────────
            mode_dd = QComboBox()
            mode_dd.setFixedWidth(190)
            mode_dd.addItems([
                "Equal Count (Quantile)",
                "Equal Interval",
                "Natural Breaks (Jenks)",
            ])
            mode_dd.setCurrentIndex(0)
            mode_dd.setToolTip(
                "Equal Count: same number of hexagons per class.\n"
                "Equal Interval: equal data-value range per class.\n"
                "Natural Breaks: minimise within-class variance (Jenks)."
            )
            self.hex_mode_dropdowns.append(mode_dd)

            # ── Customize base symbol ────────────────────────────────────────
            customize_btn = SymbolCustomizationButton(geometry_type=_GEOM_POLYGON)
            # No style dropdown linked — the graduated ramp drives colour; the
            # customize button only affects outline width/colour.
            self.hex_customize_buttons.append(customize_btn)

            # ── Filter query (pre-filter input points before counting) ───────
            query_input = self._make_file_query_widget(
                lambda i=i: self.hex_inputs[i].text())
            self.hex_query_inputs.append(query_input)

            # ── Label ────────────────────────────────────────────────────────
            label_chk = QCheckBox("Label")
            label_chk.setChecked(False)
            self.hex_label_toggles.append(label_chk)

            label_field_dd = QComboBox()
            label_field_dd.setFixedWidth(130)
            label_field_dd.setPlaceholderText("Label field")
            label_field_dd.addItem("Label field")
            label_field_dd.setCurrentIndex(0)
            label_field_dd.setEnabled(False)
            self.hex_label_field_dropdowns.append(label_field_dd)

            label_size_dd = QComboBox()
            label_size_dd.setFixedWidth(90)
            label_size_dd.addItem("Label size")
            label_size_dd.addItems([str(s) for s in range(6, 21)])
            label_size_dd.setEnabled(False)
            self.hex_label_size_dropdowns.append(label_size_dd)

            label_chk.stateChanged.connect(
                lambda _s, e=label_chk, f=label_field_dd, z=label_size_dd:
                    self._toggle_label_controls(e, f, z)
            )

            # ── Assemble row ─────────────────────────────────────────────────
            row = QHBoxLayout()
            row.addWidget(hex_input)
            row.addWidget(browse_btn)
            row.addWidget(diameter)
            row.addWidget(ramp_dd)
            row.addWidget(classes_sb)
            row.addWidget(mode_dd)
            row.addWidget(customize_btn)
            row.addWidget(query_input)
            row.addWidget(label_chk)
            row.addWidget(label_field_dd)
            row.addWidget(label_size_dd)

            hex_layout.addLayout(row)

        hex_container = QWidget()
        hex_container.setLayout(hex_layout)
        hex_group = QGroupBox(
            "Aggregate point data into graduated hexagon bins.  "
            "(Graduated symbology on point count — NUMPOINTS field.)"
        )
        vg = QVBoxLayout(hex_group)
        vg.addWidget(hex_container)
        vg.insertLayout(0, self._make_guide_button_row(
            "guide_hex_bins.html", "User Guide — Hexagon Bin Aggregation"))

        hex_scroll = QScrollArea()
        hex_scroll.setWidget(hex_group)
        hex_scroll.setWidgetResizable(True)
        hex_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        hex_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        hex_scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        return hex_scroll

    # ── Map-centre focus selection ────────────────────────────────────────────

    # Priority order used when falling back after the active row becomes invalid.
    _FOCUS_FALLBACK_ORDER = ("coords", "Focus_province", "Focus_country")

    def _is_focus_valid(self, key: str) -> bool:
        """Return True if the named focus row currently has valid (non-empty) input."""
        if key == "coords":
            return bool(self.lon_input.text().strip() and self.lat_input.text().strip())
        if key == "Focus_country":
            return bool(get_selected_names(getattr(self, "focus_country_multiselect", None)))
        if key == "Focus_province":
            return bool(get_selected_names(getattr(self, "focus_province_multiselect", None)))
        return False

    def _update_active_center(self, changed_row: str = None):
        """Called whenever any focus input changes.

        Rules (in order):
        1. If ``changed_row`` now has valid data → activate it immediately.
        2. If the currently active row just became invalid → fall back to the
           first valid row in ``_FOCUS_FALLBACK_ORDER``; None if nothing is valid.
        3. Refresh the green-border visual.
        """
        if changed_row and self._is_focus_valid(changed_row):
            self._active_center = changed_row
        elif self._active_center and not self._is_focus_valid(self._active_center):
            self._active_center = None
            for fallback in self._FOCUS_FALLBACK_ORDER:
                if self._is_focus_valid(fallback):
                    self._active_center = fallback
                    break
        self._refresh_focus_borders()

    def _on_focus_row_clicked(self, row_key: str):
        """User clicked somewhere in a focus row — switch to it if it has valid data."""
        if self._is_focus_valid(row_key):
            self._active_center = row_key
            self._refresh_focus_borders()

    def _refresh_focus_borders(self):
        """Apply the green active border to the selected frame; clear all others."""
        # Use objectName selector so only the outer container frame gets the border.
        # Without this, Qt applies the style to all child QFrame/QLabel descendants too.
        ACTIVE   = "QFrame#focus_center_frame { border: 2px solid rgb(0, 200, 0); border-radius: 3px; padding: 1px; }"
        INACTIVE = "QFrame#focus_center_frame { border: none; }"
        for key, frame in self._focus_center_frames.items():
            if frame is None:
                continue
            frame.setStyleSheet(ACTIVE if key == self._active_center else INACTIVE)

    def _connect_focus_signals(self):
        """Wire focus-input changes to _update_active_center.

        Called once from __init__ after both the preloaded layer grid and the
        multiselect widgets have been fully constructed.
        """
        self.lon_input.textChanged.connect(lambda: self._update_active_center("coords"))
        self.lat_input.textChanged.connect(lambda: self._update_active_center("coords"))
        try:
            self.focus_country_multiselect.model().itemChanged.connect(
                lambda _item: self._update_active_center("Focus_country")
            )
        except Exception as e:
            logger.warning(f"Could not connect focus_country_multiselect signal: {e}")
        try:
            self.focus_province_multiselect.model().itemChanged.connect(
                lambda _item: self._update_active_center("Focus_province")
            )
        except Exception as e:
            logger.warning(f"Could not connect focus_province_multiselect signal: {e}")

    # ── End map-centre focus selection ────────────────────────────────────────

    def _current_focus_signature(self):
        lon = self.lon_input.text().strip()
        lat = self.lat_input.text().strip()


        def get_checked_texts(widget):
            if not widget:
                return ()
            return tuple(get_selected_names(widget))


        return (
            get_checked_texts(self.focus_country_multiselect),
            get_checked_texts(self.focus_province_multiselect),
            lon,
            lat,
        )

    # === Helper: connect signals to disable export on changes =====================
    def _connect_disable_export_signals(self):
        """Mirror previous behavior: any change disables the EXPORT button until rebuild."""
        targets = [
            self.width_input, self.height_input, self.lon_input, self.lat_input,
            self.custom_sources_input,
            self.world_map_checkbox,
        ]
        for w in targets:
            connect_disable_export_on_change(w, self.export_final_button)
        for dropdown in self.layer_style_dropdowns.values():
            connect_disable_export_on_change(dropdown, self.export_final_button)
        for checkbox in self.layer_checkboxes.values():
            connect_disable_export_on_change(checkbox, self.export_final_button)
        for toggle in self.preloaded_label_toggles.values():
            connect_disable_export_on_change(toggle, self.export_final_button)
        for chk in self.upload_category_enable:
            connect_disable_export_on_change(chk, self.export_final_button)
        for dd in self.upload_category_field + self.upload_category_scheme:
            connect_disable_export_on_change(dd, self.export_final_button)

        # Size dropdown collections
        for dropdown in (
            self.point_size_dropdowns + self.ring_outline_dropdowns +
            self.csv_size_dropdowns + self.upload_size_dropdowns +
            self.csv_label_size_dropdowns + self.upload_label_size_dropdowns
        ):
            connect_disable_export_on_change(dropdown, self.export_final_button)

        # CSV radio groups (point/line)
        for group, point_radio, line_radio in self.csv_radio_groups:
            connect_disable_export_on_change(point_radio, self.export_final_button)
            connect_disable_export_on_change(line_radio, self.export_final_button)

        # Multiselects
        connect_disable_export_on_change(self.focus_country_multiselect, self.export_final_button)
        connect_disable_export_on_change(self.focus_province_multiselect, self.export_final_button)

        self.lon_input.textChanged.connect(self._reset_pan)
        self.lat_input.textChanged.connect(self._reset_pan)

        connect_disable_export_on_change(self.focus_country_multiselect, self.export_final_button)
        connect_disable_export_on_change(self.focus_province_multiselect, self.export_final_button)

        # DD/DMS format radios
        connect_disable_export_on_change(self.dd_radio, self.export_final_button)

    def _build_focus_multiselects(self):
        """Top-of-section focus queries (outside the loop)."""
        container = QWidget()
        v = QVBoxLayout(container)

        def add_focus_row(layer_name, label_text):
            name_list = get_name_values(layer_name)
            widget = create_multiselect_dropdown(name_list)
            widget.set_popup_dimensions(rows=20, width=420, row_height_px=22) 
            row = QWidget()
            row.setFixedWidth(700)
            hl = QHBoxLayout(row); hl.setContentsMargins(0, 0, 0, 0)
            lbl = QLabel(label_text); lbl.setMinimumWidth(120)
            hl.addWidget(lbl); hl.addWidget(widget)

            # store references for compile_arguments_for_exporter()
            if layer_name == "Focus_country":
                self.focus_country_multiselect = widget
            elif layer_name == "Focus_province":
                self.focus_province_multiselect = widget
            else:
                self.secondary_multiselect = widget
            v.addWidget(row)

        add_focus_row("Focus_province", "Province(s) of interest:")
        add_focus_row("Focus_country", "Country(s) of interest:")
        add_focus_row("Secondary_focus_country", "Secondary country(s) of interest:")

        return container

    def choose_export_folder(self) -> str:
        """
        Open a folder picker so the user can choose where to save exported files.
        Returns the selected folder path or '' if the user cancels.
        """
        path = QFileDialog.getExistingDirectory(
            self,
            "Choose export folder",
            self.last_export_dir,
            QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks
        )
        if path:
            self.last_export_dir = path
        return path

    def __init__(self):
        super().__init__()

        self._pan_dx_m = 0.0
        self._pan_dy_m = 0.0
        self._last_focus_signature = None

        # 1) Core setup & collections
        self._setup_core()
        validators = self._create_validators()

        self.coord_label = QLabel("Coordinates of interest:")
        self.lon_input = QLineEdit(); self.lon_input.setPlaceholderText("Longitude")
        self.lat_input = QLineEdit(); self.lat_input.setPlaceholderText("Latitude")
        self.lon_input.setToolTip("Enter longitude in DD or DMS format. Used to center the map.")
        self.lat_input.setToolTip("Enter latitude in DD or DMS format. Used to center the map.")
        self.lon_input.setMinimumWidth(120)
        self.lat_input.setMinimumWidth(120)

        self._init_collections()

        self.last_export_dir = os.path.expanduser("~")  # fallback; will update after first pick

        # --- LEFT panel (map configuration) ---
        left_form_layout = QVBoxLayout()

        # Layout selector
        left_form_layout.addWidget(self._build_layout_selector())

        # Dimensions + classification (long-form)
        left_form_layout.addWidget(self._build_dimension_inputs(validators))

        # Create a plain container to hold your preloaded layer grid + any top content you add
        self.custom_vector_container = QWidget()
        cv_layout = QVBoxLayout(self.custom_vector_container)
        cv_layout.setContentsMargins(0, 0, 0, 0)

        # Build and insert the preloaded layer grid (reuses your existing builder)
        cv_layout.addLayout(self._build_preloaded_layer_grid())

        # Now wrap this container in a dedicated scroll area so its width doesn't force the whole left panel wider
        self.custom_vector_scroll = QScrollArea()
        self.custom_vector_scroll.setWidget(self.custom_vector_container)
        self.custom_vector_scroll.setWidgetResizable(True)
        self.custom_vector_scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
        
        # Compact section: the simplified grid (AOI + 4 basemap toggles) needs
        # far less height than the parent app's full layer table.
        self.custom_vector_scroll.setFixedHeight(380)
        # Make sure there’s no artificial minimum width on the left container
        self.custom_vector_scroll.setMinimumWidth(0)
        self.custom_vector_scroll.setMaximumWidth(16777215)

        left_form_layout.addWidget(self.custom_vector_scroll)        

        left_form_layout.addLayout(self._build_preview_controls())
        left_form_layout.addWidget(self._build_layout_preview_section())

        # ✅ NEW: print-accurate PNG preview (under live preview)
        left_form_layout.addWidget(self._build_png_print_preview())

        # Metadata group
        metadata_group = self._build_metadata_group(validators)
        self.metadata_group = metadata_group  # kept for the guided tour anchor
        left_form_layout.addWidget(metadata_group)

        # 3) RIGHT panel (data inputs)
        right_group_layout = QVBoxLayout()
        right_group_layout.setAlignment(Qt.AlignTop)

        # ── Geoprocessing panel sections ──────────────────────────────────────
        # Each section's inner QScrollArea handles horizontal overflow only
        # (vertical bar suppressed).  The outer right_scroll provides a single
        # global vertical scroll bar so the user can navigate between sections
        # as more are added over time.

        def _configure_section_scroll(scroll):
            """Strip height cap; let section size naturally for global scroll."""
            scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
            scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
            scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)

        points_scroll = self._build_points_section()
        _configure_section_scroll(points_scroll)
        right_group_layout.addWidget(points_scroll)

        csv_scroll = self._build_csv_section()
        _configure_section_scroll(csv_scroll)
        right_group_layout.addWidget(csv_scroll)

        shp_scroll = self._build_upload_section()
        _configure_section_scroll(shp_scroll)
        right_group_layout.addWidget(shp_scroll)

        hex_scroll = self._build_hex_section()
        _configure_section_scroll(hex_scroll)
        right_group_layout.addWidget(hex_scroll)

        dist_scroll = self._build_distance_section()
        _configure_section_scroll(dist_scroll)
        right_group_layout.addWidget(dist_scroll)

        # Section references for the guided tour (anchors + jump targets).
        self._geo_sections = {
            "points": points_scroll,
            "csv": csv_scroll,
            "shp": shp_scroll,
            "hex": hex_scroll,
            "dist": dist_scroll,
        }

        # 4) Scroll wrappers for both panels
        left_container = QWidget(); left_container.setLayout(left_form_layout)
        left_scroll = QScrollArea(); left_scroll.setMaximumSize(10000, 10000)
        left_scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
        left_scroll.setWidget(left_container); left_scroll.setWidgetResizable(True)

        right_container = QWidget(); right_container.setLayout(right_group_layout)
        right_scroll = QScrollArea(); right_container.setMaximumSize(10000, 10000)
        right_scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
        right_scroll.setWidget(right_container); right_scroll.setWidgetResizable(True)

        # Wrap left area in a centering frame, same as before
        self._left_center_frame = self._wrap_left_in_center(left_scroll)

        # Keep references (used by the toggle)
        self._left_scroll = left_scroll
        self._right_scroll = right_scroll


        self._left_center_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self._right_scroll.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)


        # Build a two‑column row: [centre/map] [right geoprocessing]
        self._row_hbox = QHBoxLayout()
        self._row_hbox.setContentsMargins(0, 0, 0, 0)
        self._row_hbox.setSpacing(6)

        self._row_hbox.addWidget(self._left_center_frame, 1)
        self._row_hbox.addWidget(self._right_scroll, 1)
        self._right_scroll.setVisible(False)

        # Put into a top-level layout (so we can add the top toolbar row)
        self._root_vbox = QVBoxLayout()
        self._root_vbox.setSpacing(4)
        self._root_vbox.setContentsMargins(8, 8, 8, 8)

        # Toolbar row at top — AI toggle left, geoprocessing toggle right
        toolbar_row_0 = QHBoxLayout()
        toolbar_row_0.setContentsMargins(0, 0, 0, 0)
        toolbar_row_0.setSpacing(0)
        toolbar_row_1 = QHBoxLayout()
        toolbar_row_1.setContentsMargins(0, 0, 0, 0)
        toolbar_row_1.setSpacing(0)
        toolbar_row_2 = QHBoxLayout()
        toolbar_row_2.setContentsMargins(0, 0, 0, 0)
        toolbar_row_2.setSpacing(0)

        self.btn_save_preset = QPushButton("Save new preset")
        self.btn_save_preset.setFixedWidth(180)
        self.btn_load_preset = QPushButton("Load saved preset")
        self.btn_load_preset.setFixedWidth(180)
        self.btn_save_preset.setToolTip("Save all current settings to a preset JSON.")
        self.btn_load_preset.setToolTip("Load a previously saved preset JSON.")

        self.btn_save_preset.clicked.connect(self.save_preset_dialog)
        self.btn_load_preset.clicked.connect(self.load_preset_dialog)

        self._toggle_right_btn = QPushButton("Open geoprocessing panel 🡸")
        self._toggle_right_btn.setFixedWidth(180)
        self._toggle_right_btn.setCheckable(True)
        self._toggle_right_btn.setChecked(False)  # "closed" state
        self._toggle_right_btn.setToolTip(
            "Use this panel to place points, range rings or add geo-located CSVs, shapefiles or KMLs to your map."
        )
        self._toggle_right_btn.clicked.connect(self._toggle_right_panel)

        # Re-open the guided tour on demand (also lets users re-enable it).
        self._guide_btn = QPushButton("? Guide")
        self._guide_btn.setMaximumWidth(80)
        self._guide_btn.setToolTip("Open the step-by-step guide to the map builder.")
        self._guide_btn.clicked.connect(lambda: self.show_guided_tour(force=True))

        toolbar_row_0.addWidget(self._guide_btn)  # far left
        toolbar_row_0.addStretch(1)
        toolbar_row_0.addWidget(self._toggle_right_btn)  # far right
        toolbar_row_1.addStretch(1)
        toolbar_row_1.addWidget(self.btn_save_preset)
        toolbar_row_2.addStretch(1)
        toolbar_row_2.addWidget(self.btn_load_preset)
        self._root_vbox.addLayout(toolbar_row_1)
        self._root_vbox.addLayout(toolbar_row_2)
        self._root_vbox.addLayout(toolbar_row_0)

        # Now add our three‑column row
        self._root_vbox.addLayout(self._row_hbox, 1)

        self.setLayout(self._root_vbox)

        # Show the guided tour shortly after the window paints (skipped when the
        # user has ticked "Don't show this guide again").
        QTimer.singleShot(600, self.show_guided_tour)


        # 6) Initial symbology dropdown preloads for focus styles
        self.focus_country_style_dropdown = LazyStyleComboBox(
            lambda: "Polygon", self.populate_dropdown_with_icons
        )
        self.secondary_country_style_dropdown = LazyStyleComboBox(
            lambda: "Polygon", self.populate_dropdown_with_icons
        )

        # 7) Set initial dimension tooltips / placeholders
        self.disable_fields()

        # 8) Connect “disable export on change” signals (preserve original behavior)
        self._connect_disable_export_signals()

        # 9) Connect focus-row signals for active map-centre selection
        self._connect_focus_signals()

        self._overlay_icon_cache = {}  # {absolute_path: QIcon}

# other functions -----------------------------------------------------------------------------------

    def disable_fields(self):
        # Every product is a supplemental graphic (6x6 minimum).
        min_w, min_h = self._min_dimensions_inches()
        self.width_input.setToolTip(f"Map width in inches (min {min_w:.0f} inches).")
        self.height_input.setToolTip(f"Map height in inches (min {min_h:.0f} inches).")
        self.width_input.setPlaceholderText("6")
        self.height_input.setPlaceholderText("6")

    def compile_arguments_for_exporter(self, is_preview=False, export_folder=None):
        """
        Collects all user inputs, validates them, and returns a dictionary of render arguments.
        If is_preview=True, uses temporary folder and skips strict metadata validation.
        """

        # --- Basic validation ---
        missing_fields = []
        if not self.width_input.text().strip():
            missing_fields.append("Map width (inches):")
            mark_missing(self.width_input)
        else:
            reset_border(self.width_input)

        if not self.height_input.text().strip():
            missing_fields.append("Map height (inches):")
            mark_missing(self.height_input)
        else:
            reset_border(self.height_input)

        if not is_preview:
            # Additional required fields for export
            required = [
                ("Recipient:", self.recipient_input),
                ("Map producer", self.producer_input),
                ("Content of map:", self.map_content_input),
            ]
            for label, w in required:
                # Use currentText() for QComboBox, text() for line edits
                val = w.currentText().strip() if isinstance(w, QComboBox) else w.text().strip()
                if not val or (isinstance(w, QComboBox) and val.lower().startswith("select")):
                    missing_fields.append(label)
                    mark_missing(w)
                else:
                    reset_border(w)

            if missing_fields:
                raise Exception("Missing required fields:\n" + "\n".join(missing_fields))

        try:
            image_width_inches = float(self.width_input.text().strip())
            image_height_inches = float(self.height_input.text().strip())
            image_width_mm = image_width_inches * 25.4
            image_height_mm = image_height_inches * 25.4
        except ValueError:
            raise Exception("Invalid map dimensions")

        min_w_inches, min_h_inches = self._min_dimensions_inches()
        dim_errors = []

        if image_width_inches < min_w_inches:
            dim_errors.append(f"Width must be at least {min_w_inches:.0f} inches.")
            mark_missing(self.width_input)
        else:
            reset_border(self.width_input)

        if image_height_inches < min_h_inches:
            dim_errors.append(f"Height must be at least {min_h_inches:.0f} inches.")
            mark_missing(self.height_input)
        else:
            reset_border(self.height_input)

        if dim_errors:
            # In preview mode we still block, to avoid odd layout states
            raise Exception("Map size too small for selected layout:\n" + "\n".join(dim_errors))

        # --- Coordinates and pan ---
        use_dms = self.dms_radio.isChecked()
        center_coordinates = None

        # Pan is now interpreted as KILOMETRES (not degrees)
        lon_shift_km = 0.0
        lat_shift_km = 0.0

        # The active-centre row tells us which focus input drives the map centre.
        # Coordinates are only parsed when the user chose "coords" as the active centre.
        active_center = getattr(self, "_active_center", None)

        if active_center == "coords" and self.lon_input.text().strip() and self.lat_input.text().strip():
            try:
                if use_dms:
                    lon = parse_dms(self.lon_input.text().strip(), is_latitude=False)
                    lat = parse_dms(self.lat_input.text().strip(), is_latitude=True)
                else:
                    lon = float(self.lon_input.text().strip())
                    lat = float(self.lat_input.text().strip())
                center_coordinates = (lon, lat)
            except ValueError:
                raise Exception("Invalid coordinates")

        # --- Scale ---
        scale_denominator = None
        # scale_text = self.scale_input.text().strip()
        # if scale_text:
        #     try:
        #         scale_denominator = float(scale_text)
        #         if scale_denominator < 1000 or scale_denominator > 100000000:
        #             raise ValueError("Scale out of range")
        #     except ValueError:
        #         raise Exception("Invalid scale value")
        if center_coordinates:
            scale_denominator = 10000000  # Default if coords provided


        # --- NEW: default to 1:10,000,000 when no Focus_* selected (applies to all layouts) ---
        focus_country_sel = get_selected_names(self.focus_country_multiselect)
        focus_province_sel = get_selected_names(self.focus_province_multiselect)

        if (not focus_country_sel) and (not focus_province_sel):
            # No user-entered scale and no focus selection → default to 1:10,000,000
            scale_denominator = 10_000_000.0


        # --- Collect styles and toggles ---
        included_preloaded_layers = {}
        for layer_name, checkbox in self.layer_checkboxes.items():
            if checkbox.isChecked():
                style = self.layer_style_dropdowns[layer_name].currentText().strip()
                included_preloaded_layers[layer_name] = style if style else None

        preloaded_label_toggles = {layer_name: toggle.isChecked() for layer_name, toggle in self.preloaded_label_toggles.items()}

        preloaded_category_fields = {}
        preloaded_category_schemes = {}
        preloaded_category_enable = {}

        for layer_name, checkbox in self.layer_checkboxes.items():
            # Only consider when layer is included (checked)
            if not checkbox.isChecked():
                continue
            enable = self.preloaded_category_enable.get(layer_name, None)
            if enable and enable.isChecked():
                field = self.preloaded_category_field[layer_name].currentText().strip()
                scheme = self.preloaded_category_scheme[layer_name].currentText().strip()
                if field and field != "Category field" and scheme:
                    preloaded_category_fields[layer_name] = field
                    preloaded_category_schemes[layer_name] = scheme
                    preloaded_category_enable[layer_name] = True


        # --- Custom points and rings ---
        custom_points = []
        custom_point_styles = {}
        ring_styles = {}
        for i, (lon_input, lat_input, name_input, radius_input) in enumerate(zip(self.lon_inputs, self.lat_inputs, self.name_inputs, self.radius_inputs)):
            lon_text = lon_input.text().strip()
            lat_text = lat_input.text().strip()
            if lon_text and lat_text:
                try:
                    lon = parse_dms(lon_text) if use_dms else float(lon_text)
                    lat = parse_dms(lat_text) if use_dms else float(lat_text)
                    radius_km = float(radius_input.text().strip()) if radius_input.text().strip() else 0.0
                    custom_points.append((lon, lat, name_input.text().strip(), radius_km))
                    custom_point_styles[i] = self.point_symbology_dropdowns[i].currentText().strip()
                    ring_styles[i] = self.ring_symbology_dropdowns[i].currentText().strip()
                except ValueError:
                    continue

        # --- CSV data ---
        csv_data = []
        for i, (csv_input, radio_group, style_dropdown) in enumerate(zip(self.csv_inputs, self.csv_radio_groups, self.csv_symbology_dropdowns)):
            filename = csv_input.text().strip()
            if filename:
                mode = "point" if radio_group[1].isChecked() else "line"
                style = style_dropdown.currentText().strip()
                name_field = self.csv_name_field_dropdowns[i].currentText().strip()
                x_field = self.csv_x_field_dropdowns[i].currentText().strip()
                y_field = self.csv_y_field_dropdowns[i].currentText().strip()
                label_size_text = self.csv_label_size_dropdowns[i].currentText().strip()
                label_size = int(label_size_text) if label_size_text.isdigit() else None
                csv_data.append({
                    "filename": filename,
                    "mode": mode,
                    "style": style,
                    "index": i,
                    "name_field": name_field if name_field != "Label field" else None,
                    "label_enabled": bool(name_field and style and style != "Select style"),
                    "x_field": x_field,
                    "y_field": y_field,
                    "label_size": label_size
                })

        # --- Legend styles ---
        legend_styles = []
        legend_labels = []

        # --- Uploaded layers: paths, styles, labels, sizes, queries ---
        uploaded_layer_paths = []
        uploaded_layer_styles = {}
        uploaded_layer_labels = {}
        uploaded_layer_sizes = {}
        uploaded_layer_label_sizes = {}
        uploaded_layer_queries = {}
        uploaded_category_enable = {}
        uploaded_category_fields = {}
        uploaded_category_schemes = {}

        for i in range(len(self.upload_inputs)):
            # Only consider rows where a file path exists; categorize UI still may be used
            enable = (self.upload_category_enable[i].isChecked()
                    if i < len(self.upload_category_enable) else False)
            field = (self.upload_category_field[i].currentText().strip()
                    if i < len(self.upload_category_field) else "")
            scheme = (self.upload_category_scheme[i].currentText().strip()
                    if i < len(self.upload_category_scheme) else "")

            if enable and field and field != "Category field" and scheme:
                uploaded_category_enable[i] = True
                uploaded_category_fields[i] = field
                uploaded_category_schemes[i] = scheme



        for layer_name, checkbox in self.layer_checkboxes.items():
            if checkbox.isChecked():
                style = self.layer_style_dropdowns[layer_name].currentText().strip()
                if style and style != "Select style":
                    legend_styles.append(style)
                    legend_labels.append(self.layer_aliases.get(layer_name, layer_name))

        for i, (style_dropdown, label_input) in enumerate(zip(self.point_symbology_dropdowns, self.name_inputs)):
            style = style_dropdown.currentText().strip()
            label = label_input.text().strip()
            if style and style != "Select style" and label:
                legend_styles.append(style)
                legend_labels.append(label)

        # Paths and label fields
        for i, input_box in enumerate(self.upload_inputs):
            full_path = input_box.text().strip()
            if full_path and os.path.exists(full_path):
                uploaded_layer_paths.append((i, full_path))
                # Label field selection (optional)
                name_field = self.upload_name_field_dropdowns[i].currentText().strip()
                if name_field and name_field != "Label field":
                    uploaded_layer_labels[i] = name_field

        # Styles
        for i, dropdown in enumerate(self.upload_symbology_dropdowns):
            style_name = dropdown.currentText().strip()
            if style_name and style_name != "Select style":
                uploaded_layer_styles[i] = style_name

        # Customised symbol XML (one per row whose button was actually used).
        # The legacy uploaded_layer_sizes dict stays empty in this build — the
        # exporter checks for an XML entry first and falls back to size only
        # when it's missing (which keeps old preset loads working).
        uploaded_layer_symbols = {}
        for i, button in enumerate(self.upload_size_dropdowns):
            xml = ""
            try:
                xml = button.serialize() if isinstance(button, SymbolCustomizationButton) else ""
            except Exception:
                xml = ""
            if xml:
                uploaded_layer_symbols[i] = xml

        # Label font sizes
        for i, dropdown in enumerate(self.upload_label_size_dropdowns):
            size_text = dropdown.currentText().strip()
            uploaded_layer_label_sizes[i] = int(size_text) if size_text.isdigit() else None

        # Definition queries
        for i, query_input in enumerate(self.upload_definition_queries):
            query = query_input.expression().strip()
            if query:
                uploaded_layer_queries[i] = query


        # --- Customised symbols for custom points / rings / CSV rows --------
        # New buttons replace the old size combos; serialise each into XML so
        # the exporter can deserialise + apply via QgsSingleSymbolRenderer.
        # Legacy *_sizes dicts stay empty in fresh builds; the exporter only
        # consults them as a fallback when an XML entry is absent.
        custom_point_sizes = {}
        custom_point_symbols = {}
        for i, button in enumerate(self.point_size_dropdowns):
            try:
                xml = button.serialize() if isinstance(button, SymbolCustomizationButton) else ""
            except Exception:
                xml = ""
            if xml:
                custom_point_symbols[i] = xml

        range_ring_sizes = {}
        range_ring_symbols = {}
        for i, button in enumerate(self.ring_outline_dropdowns):
            try:
                xml = button.serialize() if isinstance(button, SymbolCustomizationButton) else ""
            except Exception:
                xml = ""
            if xml:
                range_ring_symbols[i] = xml

        csv_layer_sizes = {}
        csv_layer_symbols = {}
        for i, button in enumerate(self.csv_size_dropdowns):
            try:
                xml = button.serialize() if isinstance(button, SymbolCustomizationButton) else ""
            except Exception:
                xml = ""
            if xml:
                csv_layer_symbols[i] = xml
                
        # use_focus_center: True when coordinates are the active centre source
        use_focus_center = (active_center == "coords") and bool(center_coordinates)

        # --- Hex bin aggregation specs ----------------------------------------
        hex_bin_specs = []
        for i in range(len(self.hex_inputs)):
            path = self.hex_inputs[i].text().strip()
            if not path or not os.path.exists(path):
                continue
            xml = ""
            try:
                xml = self.hex_customize_buttons[i].serialize() or ""
            except Exception:
                xml = ""
            label_size_text = self.hex_label_size_dropdowns[i].currentText().strip()
            hex_bin_specs.append({
                "index": i,
                "path": path,
                "diameter_km": self.hex_diameter_inputs[i].value(),
                "ramp": self.hex_ramp_dropdowns[i].currentText().strip(),
                "classes": self.hex_classes_inputs[i].value(),
                "mode": self.hex_mode_dropdowns[i].currentText().strip(),
                "symbol_xml": xml,
                "query": self.hex_query_inputs[i].expression().strip(),
                "label_on": self.hex_label_toggles[i].isChecked(),
                "label_field": self.hex_label_field_dropdowns[i].currentText().strip(),
                "label_size": int(label_size_text) if label_size_text.isdigit() else None,
            })

        preloaded_label_toggles = {layer_name: toggle.isChecked()
                                for layer_name, toggle in self.preloaded_label_toggles.items()}
        preloaded_definition_queries = {}
        preloaded_label_fields = {}
        preloaded_label_sizes = {}
        preloaded_layer_sizes = {}  # legacy; kept for back-compat with old presets
        preloaded_layer_symbols = {}  # new: XML by layer name from each Customize button

        for layer_name, checkbox in self.layer_checkboxes.items():
            if not checkbox.isChecked():
                continue

            # Style
            style = self.layer_style_dropdowns[layer_name].currentText().strip()
            included_preloaded_layers[layer_name] = style if style else None

            # Customised symbol XML — the Customize button replaces the old
            # "Size" dropdown. We harvest its serialised symbol here; the
            # legacy preloaded_layer_sizes dict is left empty for fresh
            # builds and only used by the exporter as a fallback when no
            # XML is present.
            customize_btn = self.preloaded_size_dropdowns.get(layer_name)
            if isinstance(customize_btn, SymbolCustomizationButton):
                try:
                    xml = customize_btn.serialize()
                except Exception:
                    xml = ""
                if xml:
                    preloaded_layer_symbols[layer_name] = xml

            # Filter query (not present for Focus_* rows anymore)
            q_widget = self.preloaded_definition_queries.get(layer_name)
            if q_widget is not None and hasattr(q_widget, "expression"):
                q_text = q_widget.expression().strip()
                if q_text:
                    preloaded_definition_queries[layer_name] = q_text

            # Label field
            lb_field = self.preloaded_label_fields[layer_name].currentText().strip()
            if lb_field and lb_field != "Label field":
                preloaded_label_fields[layer_name] = lb_field

            # Label size
            lb_size_text = self.preloaded_label_sizes[layer_name].currentText().strip()
            if lb_size_text.isdigit():
                preloaded_label_sizes[layer_name] = int(lb_size_text)

        # --- Uploaded label enable map ---
        uploaded_layer_label_enabled = {i: chk.isChecked()
                                        for i, chk in enumerate(self.upload_label_toggles)}


        uses_user_supplied_points  = bool(custom_points)                 # user typed lon/lat rows that passed parsing
        uses_user_supplied_csvs    = any(bool(entry.get("filename")) for entry in (csv_data or []))
        uses_user_supplied_uploads = bool(uploaded_layer_paths)          # we only append paths that exist on disk

        requires_additional_sources = (
            uses_user_supplied_points or uses_user_supplied_csvs or uses_user_supplied_uploads
        )

        if (not is_preview) and requires_additional_sources:
            addl_sources = self.custom_sources_input.text().strip()
            if not addl_sources:
                # Visual cue + clear exception that the caller (EXPORT handler) will surface
                mark_missing(self.custom_sources_input)
                raise Exception(
                    "Please list your sources for all custom points, spreadsheets, shapefiles or KMLs "
                    "in the 'Additional data sources:' box. This allows the map to credit the data's originators."
                )
            else:
                reset_border(self.custom_sources_input)

        arguments_for_exporter = {
            "focus_country_names": get_selected_names(self.focus_country_multiselect),
            "focus_province_names": get_selected_names(self.focus_province_multiselect),
            # Secondary_focus_country is now a standard query layer (no multiselect);
            # it filters itself via preloaded_definition_queries like other layers.
            "secondary_country_names": [],
            "center_coordinates": center_coordinates,
            "center_on": active_center,   # which focus row the user chose as the map centre
            "image_width_mm": image_width_mm,
            "image_height_mm": image_height_mm,
            "scale_denominator": scale_denominator,
            "included_preloaded_layers": included_preloaded_layers,
            "preloaded_label_toggles": preloaded_label_toggles,
            "custom_points": custom_points,
            "custom_point_styles": custom_point_styles,
            "ring_styles": ring_styles,
            "csv_data": csv_data,
            "legend_styles": legend_styles,
            "legend_labels": legend_labels,
            "styles_dict": self.styles,
            "layout_name": "Map_layout",
            "recipient": self.recipient_input.text().strip(),
            "map_content": self.map_content_input.text().strip(),
            "map_producer": self.producer_input.text().strip(),
            "custom_sources_text": self.custom_sources_input.text().strip(),
            "date_str": datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%SZ"),
            "uploaded_layer_paths": uploaded_layer_paths,
            "uploaded_layer_styles": uploaded_layer_styles,
            "uploaded_layer_labels": uploaded_layer_labels,
            "uploaded_layer_sizes": uploaded_layer_sizes,
            "uploaded_layer_label_sizes": uploaded_layer_label_sizes,
            "uploaded_layer_queries": uploaded_layer_queries,
            "csv_format_groups": self.csv_format_groups,
            "custom_point_sizes": custom_point_sizes,
            "range_ring_sizes": range_ring_sizes,
            "csv_layer_sizes": csv_layer_sizes,
            "world_map": self.world_map_checkbox.isChecked(),
            "center_coordinates_from_focus": use_focus_center,
            "pan_dx_m": getattr(self, "_pan_dx_m", 0.0),
            "pan_dy_m": getattr(self, "_pan_dy_m", 0.0),
            "preloaded_category_fields": preloaded_category_fields,
            "preloaded_category_schemes": preloaded_category_schemes,
            "preloaded_category_enable": preloaded_category_enable,
            "preloaded_definition_queries": preloaded_definition_queries,
            "preloaded_label_fields": preloaded_label_fields,
            "preloaded_label_sizes": preloaded_label_sizes,
            "preloaded_layer_sizes": preloaded_layer_sizes,
            # Customised symbol XML, one per row whose Customize button has
            # been used. Exporter applies these via QgsSingleSymbolRenderer
            # and falls back to the *_sizes dicts when an entry is missing.
            "preloaded_layer_symbols": preloaded_layer_symbols,
            "uploaded_layer_symbols": uploaded_layer_symbols,
            "custom_point_symbols": custom_point_symbols,
            "range_ring_symbols": range_ring_symbols,
            "csv_layer_symbols": csv_layer_symbols,
            "uploaded_layer_label_enabled": uploaded_layer_label_enabled,
            "uploaded_category_enable": uploaded_category_enable,
            "uploaded_category_fields": uploaded_category_fields,
            "uploaded_category_schemes": uploaded_category_schemes,
            "hex_bin_specs": hex_bin_specs,
            "dist_specs": self._collect_dist_specs(),
            }

        if is_preview:
            arguments_for_exporter["export_folder"] = tempfile.gettempdir()
        if not is_preview and export_folder:
            arguments_for_exporter["export_folder"] = export_folder

        return arguments_for_exporter


    def _prune_built_maps(self, max_keep: int = 20):
        """Keep only the newest `max_keep` BUILT_AT_*Z.png files in built_maps.

        Filenames embed a UTC date-time group, so lexicographic order is
        chronological — the lowest-DTG (oldest) files are deleted first.
        """
        try:
            names = sorted(
                n for n in os.listdir(OUTPUT_DIR)
                if n.startswith("BUILT_AT_") and n.endswith("Z.png")
            )
            for old in names[:-max_keep] if max_keep > 0 else names:
                try:
                    os.remove(os.path.join(OUTPUT_DIR, old))
                except OSError:
                    pass
        except OSError:
            pass

    def generate_map_preview(self):
        # Re-entry guard — ignore clicks while a build is already running
        if getattr(self, "_building", False):
            return

        # Beaver processor: map IDs are the build date plus a per-session
        # sequence number. The number is kept in memory only — nothing is
        # written outside the user's chosen export folder — so it resets each
        # launch (which is fine: export folders also embed recipient/content
        # and are de-duplicated on write).
        date_token = datetime.now().strftime("%Y%m%d")
        self._pid_seq = getattr(self, "_pid_seq", 0) + 1
        self.map_id = f"{date_token}_{self._pid_seq:02d}"

        missing_fields = []

        if not self.width_input.text().strip():
            missing_fields.append("Map width (inches):")
            mark_missing(self.width_input)
        else:
            reset_border(self.width_input)

        if not self.height_input.text().strip():
            missing_fields.append("Map height (inches):")
            mark_missing(self.height_input)
        else:
            reset_border(self.height_input)

        if missing_fields:
            QMessageBox.warning(
                self,
                "Action required:",
                "The following fields are required:\n\n" + "\n".join(missing_fields)
            )
            return

        # --- Enter building state; show the progress bar ---
        self._building = True
        self._build_btn_stack.layout().setCurrentIndex(1)
        QApplication.processEvents()  # paint the bar before the main thread blocks

        self._run_build()

    def _run_build(self):
        try:
            arguments_for_exporter = self.compile_arguments_for_exporter(is_preview=True)
            arguments_for_exporter["map_id"] = self.map_id

            persist_specs = self._collect_movable_specs()
            arguments_for_exporter["overlays"] = persist_specs

            os.makedirs(OUTPUT_DIR, exist_ok=True)
            # Cache each build under a unique UTC date-time name (rolling last 20)
            # rather than overwriting a single file.
            _dtg = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
            output_path = os.path.join(OUTPUT_DIR, f"BUILT_AT_{_dtg}Z.png")

            current_focus = self._current_focus_signature()
            if self._last_focus_signature is None:
                self._last_focus_signature = current_focus
            elif current_focus != self._last_focus_signature:
                self._user_scale = None
                self._last_focus_signature = current_focus

            map_path, _, Map_frame_scale, _, scale_denominator, map_scale = export_focus_country_layout(
                output_pdf_path=output_path, **arguments_for_exporter
            )

            # Keep only the newest 20 cached builds in built_maps.
            self._prune_built_maps(max_keep=20)

            self._apply_checkbox_visibility_to_layer_tree()

            layout = core_project().layoutManager().layoutByName(
                arguments_for_exporter["layout_name"]
            )
            map_item = layout.itemById("Map_frame")

            try:
                if getattr(self, "_user_scale", None) and map_item:
                    map_item.setScale(self._user_scale)
                    map_item.refresh()
                    layout.refresh()
            except Exception:
                pass

            sync_map_item_to_gui_visibility(map_item, basemap_names=self.basemap_names)

            final_scale = None
            try:
                final_scale = int(round(map_item.scale()))
                map_item.refresh()
                layout.refresh()
            except Exception as e:
                logger.warning(f"Scale readout failed: {e}")

            if final_scale:
                self.preview_label.setText(f"Current scale = 1:{final_scale:,}")
            else:
                self.preview_label.setText("Current scale = 1:-------")

            pixmap = QPixmap(map_path)
            pixmap.setDevicePixelRatio(1.0)
            self.png_preview_label.setPixmap(pixmap)
            self.png_preview_label.resize(pixmap.size())

            try:
                if hasattr(self, "preview_container"):
                    self.preview_container.setFixedSize(pixmap.size())
                if hasattr(self, "preview_overlay"):
                    self.preview_overlay.setFixedSize(pixmap.size())
                    self.preview_overlay.show()
                    self.preview_overlay.raise_()
            except Exception:
                pass

            self.export_final_button.setEnabled(True)

            try:
                self.preview_overlay.setFixedSize(self.preview_image.size())
                if self.preview_watermark_chk.isChecked():
                    self.preview_overlay.show()
                else:
                    self.preview_overlay.hide()
            except Exception:
                pass

            # Restore button BEFORE showing the dialog
            self._restore_build_button()

            QMessageBox.information(
                self,
                "Preview generated.",
                f"Map frame scale = 1:{final_scale:,}" if final_scale else "Map preview generated."
            )

            try:
                self._sync_layout_view()
                self._lock_all_but_overlays()
                self._restore_movable_specs(persist_specs)
                self._unlock_builtin_moveables()
                if hasattr(self, "layout_preview_container"):
                    self.layout_preview_container.setFixedSize(self.layout_view.size())
                if hasattr(self, "layout_watermark"):
                    self.layout_watermark.setFixedSize(self.layout_view.size())
                    self.layout_watermark.show()
                self.layout_view.setEnabled(True)
            except Exception:
                pass

        except Exception as e:
            self._restore_build_button()
            QMessageBox.critical(self, "Preview did not generate.", str(e))

        try:
            self._refresh_png_print_preview()
        except Exception:
            pass


    def _restore_view_overlays(self, specs):
        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            return
        # Remove existing overlay items
        for item in list(layout.items()):
            if isinstance(item, QgsLayoutItem) and bool(item.customProperty("beaver_overlay", False)):
                layout.removeLayoutItem(item)

        for ov in specs or []:
            t = (ov or {}).get("type", "").lower()
            x = ov.get("x", 0.0); y = ov.get("y", 0.0)
            w = ov.get("w", 30.0); h = ov.get("h", 10.0)
            if t == "picture":
                pic = QgsLayoutItemPicture.create(layout) or QgsLayoutItemPicture(layout)
                src = str(ov.get("path", ""))
                ext = ".svg" if src.lower().startswith("base64:") and ";svg" in src.lower() else os.path.splitext(src)[1].lower()
                try:
                    if ext == ".svg":
                        _set_pic_mode(pic, _PIC_SVG)
                    else:
                        _set_pic_mode(pic, _PIC_RASTER)
                except Exception:
                    pass
                if src:
                    pic.setPicturePath(src)
                layout.addLayoutItem(pic)
                pic.attemptMove(QgsLayoutPoint(x, y, _LAYOUT_MM))
                pic.attemptResize(QgsLayoutSize(w, h, _LAYOUT_MM))
                pic.setCustomProperty("beaver_overlay", True)
            elif t == "label":
                lbl = QgsLayoutItemLabel(layout)
                lbl.setText(str(ov.get("text", "")))
                layout.addLayoutItem(lbl)
                lbl.attemptMove(QgsLayoutPoint(x, y, _LAYOUT_MM))
                lbl.attemptResize(QgsLayoutSize(w, h, _LAYOUT_MM))
                fmt = lbl.textFormat()
                f = fmt.font() if isinstance(fmt.font(), QFont) else QFont()
                f.setFamily(ov.get("font_family", "Arial"))
                f.setPointSizeF(float(ov.get("font_size_pt", 12.0)))
                fmt.setFont(f)
                if bool(ov.get("halo_on", False)):
                    halo = QgsTextBufferSettings(); halo.setEnabled(True)
                    halo.setColor(QColor(255, 255, 255)); halo.setSize(float(ov.get("halo_size", 0.8)))
                    fmt.setBuffer(halo)
                else:
                    halo = QgsTextBufferSettings(); halo.setEnabled(False); fmt.setBuffer(halo)
                lbl.setTextFormat(fmt)
                lbl.setCustomProperty("beaver_overlay", True)
            elif t in ("polyline", "polygon_shape"):
                xml_str = ov.get("xml", "")
                if not xml_str:
                    continue
                try:
                    doc = QDomDocument()
                    doc.setContent(xml_str)
                    item_elem = doc.documentElement().firstChildElement()
                    if t == "polyline":
                        item = (QgsLayoutItemPolyline.create(layout)
                                or QgsLayoutItemPolyline(layout))
                    else:
                        item = (QgsLayoutItemPolygon.create(layout)
                                or QgsLayoutItemPolygon(layout))
                    layout.addLayoutItem(item)
                    item.readXml(item_elem, doc, QgsReadWriteContext())
                    item.setCustomProperty("beaver_overlay", True)
                except Exception as e:
                    logger.warning(f"_restore_view_overlays: shape readXml failed: {e}")
        layout.refresh()
        self.layout_view.zoomWidth()
    
    def _export_layout_pdf_raster(self, output_path: str):
        """
        Export the current layout to a PDF with the entire page rasterized
        at 300 PPI (PDF as image).
        """

        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            raise Exception("No active layout. Click BUILD before exporting.")

        exporter = QgsLayoutExporter(layout)

        settings = QgsLayoutExporter.PdfExportSettings()
        settings.dpi = 300.0

        # ✅ Critical: force raster output
        settings.rasterizeWholeImage = True

        # Safety defaults
        settings.forceVectorOutput = False

        # Let async layer/tile rendering finish so nothing is missing.
        layout.refresh()
        wait_for_render()

        result = exporter.exportToPdf(output_path, settings)

        if result != _EXPORT_SUCCESS:
            raise Exception("PDF export failed.")

    def _export_layout_screenshot(self, output_path: str):
        """
        Export the currently active QGIS layout page as a raster image at 300 PPI.

        QGIS 3.34 behavior:
        - Image format is inferred from output_path extension (.png or .jpg)
        - No ImageFormat enum exists
        """

        layout = getattr(self.layout_view, "currentLayout", lambda: None)()
        if not layout:
            raise Exception("No active layout. Click BUILD before exporting.")

        page_collection = layout.pageCollection()
        if page_collection.pageCount() == 0:
            raise Exception("Layout has no pages.")

        page = page_collection.page(0)
        page_size = page.pageSize()  # QgsLayoutSize, millimeters

        width_mm = page_size.width()
        height_mm = page_size.height()

        DPI = 300.0
        px_width = int(round((width_mm / 25.4) * DPI))
        px_height = int(round((height_mm / 25.4) * DPI))

        exporter = QgsLayoutExporter(layout)

        settings = QgsLayoutExporter.ImageExportSettings()
        settings.dpi = DPI
        settings.imageSize = QSize(px_width, px_height)

        # Force opaque background (no transparency)
        settings.backgroundColor = QColor(255, 255, 255)

        # JPG-only setting; harmless for PNG
        settings.quality = 100

        # Let async layer/tile rendering finish so nothing is missing.
        layout.refresh()
        wait_for_render()

        result = exporter.exportToImage(output_path, settings)

        if result != _EXPORT_SUCCESS:
            raise Exception("Layout export failed.")


    def export_map_files(self, export_dir=None):
        try:
            # --- Build render args ---
            arguments_for_exporter = self.compile_arguments_for_exporter(is_preview=False, export_folder=export_dir)

            # --- Prepare export root folder ---
            base_root = export_dir if export_dir else os.path.join(os.path.expanduser("~"), "Documents")
            os.makedirs(base_root, exist_ok=True)

            if not self.map_id:
                raise Exception("No Map ID is available. Click BUILD first to generate the preview (and Map ID), then try EXPORT again.")

            map_id = self.map_id  # <-- reuse the exact same ID compiled earlier
            arguments_for_exporter["map_id"] = map_id
            recipient    = arguments_for_exporter["recipient"]
            map_content  = arguments_for_exporter["map_content"]
            base_name = f"{map_id}_{recipient}_{map_content}"

            # --- Create final export folder (avoid overwrite) ---
            export_folder = os.path.join(base_root, base_name)

            if os.path.exists(export_folder):
                n = 2
                while True:
                    candidate = f"{export_folder} ({n})"
                    if not os.path.exists(candidate):
                        export_folder = candidate
                        break
                    n += 1

            os.makedirs(export_folder, exist_ok=True)

            # Refresh base_name/filename so the on-disk name matches the (n) suffix:
            base_name = os.path.basename(export_folder)
            ext = "png" if self.png_radio.isChecked() else "jpg" if self.jpg_radio.isChecked() else "pdf"
            filename = f"{base_name}.{ext}"
            output_path = os.path.join(export_folder, filename)




            # Pass into arguments so exporter writes to the right, non-overwriting place
            arguments_for_exporter["base_name"] = base_name
            arguments_for_exporter["export_folder"] = export_folder
            arguments_for_exporter["map_id"]        = map_id

            # --- Determine output format ---
            ext = "png" if self.png_radio.isChecked() else "jpg" if self.jpg_radio.isChecked() else "pdf"
            filename = f"{base_name}.{ext}"
            output_path = os.path.join(export_folder, filename)

            if self.pdf_radio.isChecked():
                self._export_layout_pdf_raster(output_path)
            elif self.jpg_radio.isChecked():
                self._export_layout_screenshot(output_path)
            else:
                # default PNG
                self._export_layout_screenshot(output_path)

            self._apply_checkbox_visibility_to_layer_tree()

            # --- Save QGIS project ---
            project = core_project()
            project_save_path = os.path.join(export_folder, f"{base_name}.qgz")
            if not project.write(project_save_path):
                QMessageBox.warning(self, "Project save failed", f"Could not save project to {project_save_path}")
            else:
                print(f"Project saved to: {project_save_path}")

            msg = QMessageBox(self)
            msg.setWindowTitle("Export complete.")
            msg.setText("MAP EXPORTED TO: \n"
                        f"{export_folder} \n"
                        f"Filename: {filename}")
            msg.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
            msg.setCursor(Qt.IBeamCursor)  # optional but nice UX
            msg.exec_()

        except Exception as e:
            QMessageBox.critical(self, "Map did not export.", str(e))




def _find_qgis_prefix() -> str:
    """Resolve the QGIS install prefix for the standalone (dev) launcher.

    Priority (cross-platform):
    1. ``QGIS_PREFIX_PATH`` environment variable — the ``python-qgis`` launchers
       set this on Windows, macOS and Linux.
    2. ``QgsApplication.prefixPath()`` — populated once QGIS libs are imported.
    3. Windows only: scan ``Program Files`` for ``QGIS*/apps/qgis[-ltr]``.
    4. Empty string — the caller surfaces a clear error rather than failing
       silently.

    Inside QGIS Desktop (plugin mode) this is never called; the host already
    owns the prefix.
    """
    env = os.environ.get("QGIS_PREFIX_PATH", "")
    if env and os.path.isdir(env):
        return env

    try:
        pref = QgsApplication.prefixPath()
        if pref and os.path.isdir(pref):
            return pref
    except Exception:
        pass

    if sys.platform.startswith("win"):
        import glob as _glob
        pf = os.environ.get("PROGRAMFILES", r"C:\Program Files")
        for pattern in (
            os.path.join(pf, "QGIS*", "apps", "qgis-ltr"),
            os.path.join(pf, "QGIS*", "apps", "qgis"),
        ):
            hits = sorted(_glob.glob(pattern))
            if hits:
                return hits[-1]  # highest-versioned install
    return ""


if __name__ == "__main__":
    # This module is part of the ``beaver_processor.plugin`` package and uses
    # relative imports, so it can't be run directly. Standalone execution goes
    # through standalone.py (which bootstraps the package).
    print("Run standalone.py to launch Beaver processor outside QGIS.")
    raise SystemExit(1)
