"""Shared helpers for the BEAVER export pipeline.

This module groups utility routines used by both ``gui_generator`` (the PyQt GUI
front-end) and map_exporter (the QGIS export back-end). Functions
fall into a handful of buckets:

- Coordinate parsing (``parse_dd``, ``parse_dms``)
- Symbology helpers (``apply_selected_style``, ``apply_size_to_symbol``,
  ``build_categorized_renderer_from_ramp``)
- Layout helpers (``configure_layout_legend``, ``position_item``,
  ``apply_layout_metadata_v3``)
- GUI helpers (``MultiSelectComboBox``, ``signals_blocked``,
  ``set_multiselect_selection``)

Anything moved here from a caller should be free of caller-specific state so
the function can be reused from a future second layout or a unit test.
"""

# ---- Standard library --------------------------------------------------------
import re
import sys
import os
import csv
import math
import logging
from datetime import datetime, timezone
from collections import defaultdict
from contextlib import contextmanager
from typing import Optional, Union, Any, Dict, List, Tuple

# Module-level logger; basicConfig is wired up in gui_generator's __main__ block
# so existing console output keeps surfacing.
logger = logging.getLogger(__name__)

from qgis.core import (QgsApplication,
                       QgsProject,
                       QgsSymbol,
                       QgsRenderContext,
                       QgsSingleSymbolRenderer,
                       QgsCoordinateReferenceSystem,
                       QgsStyle,
                       QgsCoordinateTransform,
                       QgsVectorLayer,
                       QgsPointXY,
                       QgsDistanceArea,
                       QgsPrintLayout,
                       QgsLayoutItemLabel,
                       QgsLayoutItemGroup,
                       QgsLayoutSize,
                       QgsLayoutPoint,
                       QgsUnitTypes,
                       QgsVectorLayerSimpleLabeling,
                       QgsProperty,
                       QgsPropertyCollection,
                       QgsLayoutItemMap,
                       QgsFeature,
                       QgsGeometry,
                       QgsField,
                       QgsPalLayerSettings,
                       QgsLayoutItemPicture,
                       QgsTextFormat,
                       QgsTextBufferSettings,
                       QgsCategorizedSymbolRenderer,
                       QgsRendererCategory,
                       QgsGraduatedSymbolRenderer,
                       QgsRendererRange,
                       QgsWkbTypes,
                       QgsMarkerSymbol,
                       QgsLineSymbol,
                       QgsFillSymbol,
                       QgsLayoutItemLegend,
                       QgsLegendStyle,
                       QgsLayoutItem,
                       QgsLayerTreeLayer,
                       QgsRasterLayer,
                       QgsRectangle,
                       Qgis)

from PyQt5.QtWidgets import (QLineEdit, 
                             QCheckBox, 
                             QRadioButton, 
                             QListWidget, 
                             QComboBox, 
                             QStyledItemDelegate)

from PyQt5.QtGui import (QStandardItemModel,
                         QStandardItem,
                         QColor,
                         QFont,
                         QFontMetricsF,
                         QImage)

from PyQt5.QtCore import (QVariant,
                          Qt,
                          QSize,
                          QRectF,
                          QEventLoop,
                          QTimer)


def wait_for_render(ms: int = 800):
    """Pump the Qt event loop briefly so pending repaints / async tile fetches
    finish before a layout image export.

    Without this, a layout can be exported while some layers (notably XYZ
    basemaps fetched over the network, or layers just given a new subset string)
    are still rendering, leaving them missing from the output. Uses a bounded
    nested event loop rather than a hard sleep so the UI keeps processing.
    """
    try:
        loop = QEventLoop()
        QTimer.singleShot(max(0, int(ms)), loop.quit)
        loop.exec_()
    except Exception:
        pass

# ── QGIS 3.34 ↔ 3.44 compatibility shims ─────────────────────────────────────
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 = _SYM_MARKER                    # 3.34
    _SYM_LINE   = _SYM_LINE                      # 3.34
    _SYM_FILL   = _SYM_FILL                      # 3.34
# ── End compatibility shims ───────────────────────────────────────────────────

# The QGIS prefix path is provided by the host: inside QGIS Desktop it is
# already set, and for standalone use standalone.py sets it before importing
# this module. No platform-specific discovery is done here, keeping the import
# path OS-agnostic (Windows/macOS/Linux).

# Ensure the project directory is on sys.path so sibling modules import cleanly
# regardless of the current working directory at launch.
script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
    sys.path.append(script_dir)

from .config import ANALYTICS_CSV_PATH
from .core_project import core_project, reload_core_project


# ---- Cross-platform font helper ---------------------------------------------

def sans_font(size=8, bold=False) -> QFont:
    """Return an Arial-family ``QFont`` with a sans-serif fallback.

    Arial ships on Windows and macOS but not on a stock Linux install, where
    Qt would otherwise pick an arbitrary substitute. Setting the SansSerif
    style hint makes Qt choose a clean, metric-compatible sans-serif (e.g.
    DejaVu Sans / Liberation Sans) when Arial is absent, so map/label text
    renders consistently across all three platforms.
    """
    f = QFont("Arial", size)
    f.setStyleHint(QFont.SansSerif, QFont.PreferMatch)
    if bold:
        f.setBold(True)
    return f


# ---- Basemap discovery -------------------------------------------------------

def discover_basemap_names(project=None):
    """Return the names of every web/tile-served raster basemap in the project.

    A basemap is any :class:`QgsRasterLayer` served by a tile/web provider —
    XYZ, WMS or WMTS, which all use the QGIS ``wms`` provider. Results are in
    layer-tree order, so the bundled defaults come first and any basemap the
    user adds to ``beaver_master_project.qgz`` follows automatically.

    This is how users get more basemaps than the four shipped defaults: open
    ``beaver_master_project.qgz`` in QGIS, add an XYZ/WMS/WMTS layer (e.g. a
    subscription tile service), save, and Beaver offers it as a toggle.
    """
    from qgis.core import QgsRasterLayer
    project = project or core_project()
    names = []
    for node in project.layerTreeRoot().findLayers():
        lyr = node.layer()
        if not isinstance(lyr, QgsRasterLayer):
            continue
        prov = lyr.dataProvider()
        if prov is not None and prov.name() == "wms":
            names.append(lyr.name())
    return names


# ---- GUI helpers: multi-select combo & signal silencing ----------------------

def set_multiselect_selection(widget, selected_values):
    """Programmatically tick items on a :class:`MultiSelectComboBox`.

    Matching is case-insensitive on item text. Row 0 (the synthetic
    "Deselect all" entry) is always left unchecked.
    """
    try:
        # Normalize desired-set to lowercase strings for case-insensitive match.
        want = {str(v).strip().lower() for v in (selected_values or [])}
        model = widget.model()
        # Walk every row in the combo's item-model.
        for i in range(model.rowCount()):
            item = model.item(i)
            if not item or not item.flags() & Qt.ItemIsUserCheckable:
                continue
            if i == 0:
                # Row 0 is the 'Deselect all' pseudo-item; keep it unchecked.
                item.setCheckState(Qt.Unchecked)
                continue
            # Tick the item iff its label is in the wanted set.
            label = item.text().strip().lower()
            item.setCheckState(Qt.Checked if label in want else Qt.Unchecked)
        # Refresh the combo's displayed comma-separated summary.
        if hasattr(widget, "update_display"):
            widget.update_display()
    except Exception as e:
        logger.warning(f"set_multiselect_selection failed: {e}")


@contextmanager
def signals_blocked(*widgets):
    """Context manager that mutes Qt signals on the supplied widgets.

    Used when restoring a preset or programmatically updating a checkbox so
    downstream handlers (e.g., the one that disables EXPORT on any change)
    don't fire.
    """
    # Remember each widget's previous blocked state so we can restore it.
    prev = []
    try:
        for w in widgets:
            prev.append((w, w.blockSignals(True)))
        yield
    finally:
        # Best-effort restore; never let a teardown error bubble out.
        for w, old in prev:
            try:
                w.blockSignals(old)
            except Exception:
                pass


# ---- Layout label text styling -----------------------------------------------

def apply_export_label_format(
    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),
):
    """Apply font / colour / halo to a :class:`QgsLayoutItemLabel` for export.

    Sets *both* the legacy ``QFont`` (which drives the layout preview) *and*
    the modern :class:`QgsTextFormat` (which drives the exported PDF/PNG).
    Both must be set or the on-screen and exported sizes will disagree.
    """
    # Build the QFont that the layout preview reads.
    font = QFont(font_family)
    font.setPointSizeF(float(font_size_pt))
    label_item.setFont(font)

    # Build the QgsTextFormat that the exporter reads.
    fmt = label_item.textFormat()
    fmt.setFont(font)
    # Size must be set explicitly with a unit; QFont's point size is otherwise
    # ignored by the export renderer.
    fmt.setSize(float(font_size_pt))
    fmt.setSizeUnit(_RENDER_PT)
    fmt.setColor(text_color)

    # Halo / buffer: only set size+colour when enabled, to avoid drawing a
    # zero-width buffer that QGIS still renders as faint anti-aliasing.
    buffer = QgsTextBufferSettings()
    buffer.setEnabled(halo_enabled)
    if halo_enabled:
        buffer.setSize(float(halo_size))
        buffer.setColor(halo_color)
    fmt.setBuffer(buffer)

    label_item.setTextFormat(fmt)


# ---- Safe transforms ---------------------------------------------------------
def apply_categorized_ramp_to_named_layers(
    project: QgsProject,
    logical_base_name: str,
    field_name: str,
    ramp_name: str,
    limit: Optional[int] = 50
):
    """Apply a categorised renderer to the named layer.

    Layers without ``field_name`` are skipped.
    """
    for layer in [lyr for lyr in project.mapLayers().values() if lyr.name() == logical_base_name]:
        # Skip raster basemaps; only vector layers carry a renderer to categorise.
        if isinstance(layer, QgsRasterLayer):
            continue
        # Layers without the field carry no matching renderer; skip silently.
        if field_name not in [f.name() for f in layer.fields()]:
            continue
        renderer = build_categorized_renderer_from_ramp(layer, field_name, ramp_name, limit=limit)
        if renderer:
            layer.setRenderer(renderer)
            layer.triggerRepaint()


# ---- Layer / group helpers ---------------------------------------------------

def hide_legend_if_empty(layout):
    """Remove every legend item from the layout.

    Called when the GUI produced zero legend entries; leaving an empty
    legend box in place would draw an ugly outline.
    """
    legends = [item for item in layout.items() if isinstance(item, QgsLayoutItemLegend)]
    for li in legends:
        layout.removeLayoutItem(li)
    if legends:
        logger.debug("Legend removed because it has no entries.")


# ---- Coordinate parsing (decimal degrees & DMS) -----------------------------

def parse_dd(value, is_latitude=True):
    """Parse a decimal-degrees string into a float, or return ``None``.

    Accepts forms like ``"45.6"``, ``"45,6"``, ``"45.6°"``. Latitudes outside
    [-90, 90] are rejected; longitudes are wrapped into [-180, 180].
    """
    if value is None:
        return None
    try:
        # Strip the degree sign and accept comma as decimal separator.
        value_str = str(value).strip().replace("°", "").replace(",", ".")
        decimal_degrees = float(value_str)
    except ValueError:
        return None

    if is_latitude:
        # Latitudes have no wrap-around; reject out-of-range values.
        if not (-90 <= decimal_degrees <= 90):
            return None
        return decimal_degrees
    else:
        # Longitudes wrap; normalise to [-180, 180].
        return ((decimal_degrees + 180) % 360) - 180


def parse_dms(value, is_latitude=True):
    """Parse a degrees-minutes-seconds string into decimal degrees.

    Supports cardinal-direction suffixes (``"45 30 N"``), signed numeric
    forms (``"-45 30 0"``), and packed integers (``"453000"`` → 45° 30' 00").
    Returns ``None`` on parse failure or out-of-range latitude.
    """
    if value is None:
        return None

    # Normalise: uppercase + drop typographic ° ' " characters.
    value_str = str(value).strip().upper().replace("°", "").replace("'", "").replace('"', '')

    # Cardinal-direction suffix (N/S/E/W) overrides any explicit sign.
    direction = None
    if value_str[-1:] in "NSEW":
        direction = value_str[-1]
        value_str = value_str[:-1].strip()

    # Capture a leading minus sign before stripping it for tokenisation.
    is_negative = value_str.startswith("-")
    value_str = value_str.lstrip("+-")

    # Three forms accepted: "D M S", "D M", and a packed integer.
    parts = value_str.split()
    try:
        if len(parts) == 3:
            deg, min, sec = map(float, parts)
        elif len(parts) == 2:
            deg, min = map(float, parts)
            sec = 0
        elif len(parts) == 1:
            # Packed integer (e.g., 453000 -> 45°30'00") — decide width by magnitude.
            dms_int = int(parts[0])
            if dms_int >= 100000:
                deg = dms_int // 10000
                min = (dms_int % 10000) // 100
                sec = dms_int % 100
            elif dms_int >= 1000:
                deg = dms_int // 100
                min = dms_int % 100
                sec = 0
            else:
                deg = dms_int
                min = sec = 0
        else:
            return None
    except ValueError:
        return None

    # Combine components and apply sign (direction overrides explicit sign).
    decimal_degrees = deg + min / 60 + sec / 3600
    if direction is not None:
        if direction in "SW":
            decimal_degrees *= -1
    elif is_negative:
        decimal_degrees *= -1

    if is_latitude:
        if not (-90 <= decimal_degrees <= 90):
            return None
        return decimal_degrees
    else:
        # Longitudes wrap; normalise to [-180, 180].
        return ((decimal_degrees + 180) % 360) - 180


# ---- Symbology harvesters ----------------------------------------------------

def get_symbologies_by_geometry(project=None, extra_style_xml_paths=None):
    """Aggregate symbols by geometry type from every available style source.

    Returns a dict keyed by ``'Point' | 'Line' | 'Polygon'`` whose values are
    ``[(symbol_name, QgsSymbol), ...]``. Sources merged, in order:

    1. The current project's style DB (highest priority).
    2. The global QGIS default style DB.
    3. Any additional style XML files passed via ``extra_style_xml_paths``.

    Names that already exist from an earlier source are skipped, so the
    project-level entries always win.
    """
    project = project or core_project()
    styles = {'Point': [], 'Line': [], 'Polygon': []}

    def _append_symbol(name: str, symbol: QgsSymbol):
        """Bucket a single symbol into Point / Line / Polygon."""
        if not symbol:
            return
        # Clone so we own a copy independent of the source style DB.
        clone = symbol.clone()
        st = clone.type()
        if st == _SYM_MARKER:
            styles['Point'].append((name, clone))
        elif st == _SYM_LINE:
            styles['Line'].append((name, clone))
        elif st == _SYM_FILL:
            styles['Polygon'].append((name, clone))

    try:
        # 1) PROJECT style DB — these win over later sources.
        proj_style = project.styleSettings().projectStyle()
        for name in proj_style.symbolNames() or []:
            sym = proj_style.symbol(name)
            if sym:
                _append_symbol(name, sym)

        # 2) GLOBAL QGIS default style DB — skip names already harvested.
        qgs_style = QgsStyle.defaultStyle()
        for name in qgs_style.symbolNames() or []:
            sym = qgs_style.symbol(name)
            if sym and not any(name == n for n, _ in styles['Point'] + styles['Line'] + styles['Polygon']):
                _append_symbol(name, sym)

        # 3) Optional user-supplied .xml style collections.
        for xml_path in (extra_style_xml_paths or []):
            try:
                tmp_style = QgsStyle()
                if tmp_style.importXml(xml_path):
                    for name in tmp_style.symbolNames() or []:
                        sym = tmp_style.symbol(name)
                        # Same de-dup rule: first source wins.
                        if sym and not any(name == n for n, _ in styles['Point'] + styles['Line'] + styles['Polygon']):
                            _append_symbol(name, sym)
                else:
                    logger.warning(f"Failed to import symbols from {xml_path}")
            except Exception as e:
                logger.warning(f"XML style load failed for {xml_path}: {e}")

    except Exception as e:
        logger.warning(f"get_symbologies_by_geometry (augmented) failed: {e}")

    return styles


def harvest_layer_renderer_symbols(project=None):
    """Pull current renderer symbols off every vector layer in the project.

    Lets the GUI's "Insert symbol" menu reuse the exact symbology that's
    already painting layers on the map, rather than just generic style-DB
    entries. Names follow ``"<layer> – Renderer"`` or ``"<layer> – Renderer N"``
    when a renderer carries multiple symbols.
    """
    project = project or core_project()
    out = {'Point': [], 'Line': [], 'Polygon': []}

    def _emit(name, symbol):
        """Clone and bucket a symbol; clones outlive their source layer."""
        if not symbol:
            return
        st = symbol.type()
        # Cloning is essential: layer destruction frees the original symbol.
        clone = symbol.clone()
        if st == _SYM_MARKER:
            out['Point'].append((name, clone))
        elif st == _SYM_LINE:
            out['Line'].append((name, clone))
        elif st == _SYM_FILL:
            out['Polygon'].append((name, clone))

    # Walk every vector layer; raster layers have no renderer symbols to harvest.
    for lyr in project.mapLayers().values():
        if not isinstance(lyr, QgsVectorLayer):
            continue
        try:
            renderer = lyr.renderer()
            if not renderer:
                continue
            # Categorised / rule-based renderers expose a list; single-symbol
            # renderers expose ``.symbol()``. Tolerate either shape.
            symbols = renderer.symbols(QgsRenderContext()) if hasattr(renderer, "symbols") else [renderer.symbol()] if hasattr(renderer, "symbol") else []
            base = lyr.name().strip()
            if not symbols:
                continue
            # Singular vs plural naming for readability in the menu.
            if len(symbols) == 1:
                _emit(f"{base} – Renderer", symbols[0])
            else:
                for i, sym in enumerate(symbols, 1):
                    _emit(f"{base} – Renderer {i}", sym)
        except Exception as e:
            logger.warning(f"harvest_layer_renderer_symbols failed on '{lyr.name()}': {e}")

    return out


# ---- Unique-value collection (for categorised renderers) --------------------

def collect_unique_values(layer: QgsVectorLayer, field_name: str, limit: Optional[int] = None):
    """Return the distinct values of ``field_name`` on ``layer``.

    Uses the provider's fast ``uniqueValues`` when supported, with an
    iteration-based fallback for providers that don't. Result is sorted
    case-insensitively and trimmed to ``limit`` entries when supplied.
    """
    values = []
    if not isinstance(layer, QgsVectorLayer):
        return values
    # Bail out cleanly when the field doesn't exist on this layer.
    idx = layer.fields().indexOf(field_name)
    if idx < 0:
        return values
    try:
        # Fast path: provider-native unique extraction.
        vals = layer.dataProvider().uniqueValues(idx)
        values = [v for v in vals if v is not None]
    except Exception:
        # Slow fallback: walk every feature once, deduping by hand.
        seen = set()
        for f in layer.getFeatures():
            v = f[field_name]
            if v is None or v in seen:
                continue
            seen.add(v)
        values = list(seen)
    # Trim to limit (callers use this for UI dropdowns).
    if limit and len(values) > limit:
        values = values[:limit]
    try:
        values.sort(key=lambda s: str(s).lower())
    except Exception:
        pass
    return values


# ---- Categorised-renderer construction --------------------------------------
# PT_TO_MM converts typographic points to millimetres for symbol stroke widths
# and marker outlines that need to read at a fixed printed size.
PT_TO_MM = 0.3528

def build_categorized_renderer_from_ramp(
    layer: QgsVectorLayer,
    field_name: str,
    ramp_name: str,
    limit: Optional[int] = None
) -> Optional[QgsCategorizedSymbolRenderer]:
    """Build a :class:`QgsCategorizedSymbolRenderer` driven by a colour ramp.

    For each unique value of ``field_name`` on ``layer``, allocate a colour
    sampled from the named QGIS colour ramp and produce a renderer category.
    Geometry-type-specific defaults (marker outline, line width, minimum
    marker size) are enforced so categories read well on a printed map.

    Returns ``None`` when inputs are invalid, the ramp can't be resolved, or
    the layer has zero unique values for the field.
    """
    if not isinstance(layer, QgsVectorLayer) or not field_name or not ramp_name:
        return None

    # Discover the distinct attribute values that will drive the categories.
    uniques = collect_unique_values(layer, field_name, limit=limit)
    if not uniques:
        return None

    # Resolve the ramp: project styles win over the global default style DB.
    color_ramp = None
    try:
        color_ramp = core_project().styleSettings().projectStyle().colorRamp(ramp_name)
    except Exception:
        color_ramp = None
    if color_ramp is None:
        try:
            color_ramp = QgsStyle.defaultStyle().colorRamp(ramp_name)
        except Exception:
            color_ramp = None
    if color_ramp is None:
        logger.warning(f"Color ramp '{ramp_name}' not found.")
        return None

    gt = layer.geometryType()
    categories = []
    n = len(uniques)

    # Symbol-rendering defaults expressed in millimetres so they reproduce at
    # the intended physical size at any print scale.
    marker_outline_mm = 0.25 * PT_TO_MM   # 0.0882 mm (~ 0.25 pt)
    line_width_mm     = 1.00 * PT_TO_MM   # 0.3528 mm (~ 1.00 pt)
    min_marker_mm     = 1.50              # smallest legible marker

    # Build one category per unique value, sampling the ramp linearly.
    for i, uval in enumerate(uniques):
        t = 0.0 if n <= 1 else float(i) / float(n - 1)
        try:
            color = color_ramp.color(t)
        except Exception:
            # Ramp sampling can fail for exotic ramps; grey is a safe fallback.
            color = QColor(128, 128, 128)

        # Try to clone the layer's current symbol so categories inherit any
        # baseline styling. Fall back to a fresh simple-symbol on failure.
        sym = None
        try:
            r = layer.renderer()
            if r and hasattr(r, "symbol") and callable(getattr(r, "symbol")):
                base = r.symbol()
                sym = base.clone() if base else None
        except Exception:
            sym = None

        # Last-resort: build a default symbol matching the geometry type.
        if sym is None:
            if gt == _GEOM_POINT:
                sym = QgsMarkerSymbol.createSimple({})
            elif gt == _GEOM_LINE:
                sym = QgsLineSymbol.createSimple({})
            elif gt == _GEOM_POLYGON:
                sym = QgsFillSymbol.createSimple({})
            else:
                continue

        try:
            # Force full opacity to keep categories visible.
            if hasattr(sym, "setOpacity"):
                sym.setOpacity(1.0)

            if gt == _GEOM_LINE:
                # Lines: colour the stroke and enforce a sane print width.
                for sl in range(sym.symbolLayerCount()):
                    lyr = sym.symbolLayer(sl)
                    if hasattr(lyr, "setColor"):
                        lyr.setColor(color)
                    if hasattr(lyr, "setWidth"):
                        lyr.setWidth(line_width_mm)
                if hasattr(sym, "setWidth"):
                    sym.setWidth(line_width_mm)

            elif gt == _GEOM_POINT:
                # Points: fill + minimum size + thin black outline.
                sym.setColor(color)
                # Bump tiny sizes up to the minimum legible mark.
                if hasattr(sym, "size") and hasattr(sym, "setSize"):
                    try:
                        if float(sym.size()) <= 0.0:
                            sym.setSize(min_marker_mm)
                    except Exception:
                        sym.setSize(min_marker_mm)
                else:
                    # Some symbol classes only expose size on each layer.
                    for sl in range(sym.symbolLayerCount()):
                        lyr = sym.symbolLayer(sl)
                        if hasattr(lyr, "setSize"):
                            lyr.setSize(min_marker_mm)

                # Apply outline (only honoured by SimpleMarker-style layers).
                for sl in range(sym.symbolLayerCount()):
                    lyr = sym.symbolLayer(sl)
                    if hasattr(lyr, "setStrokeColor"):
                        lyr.setStrokeColor(QColor(0, 0, 0))
                    if hasattr(lyr, "setStrokeWidth"):
                        lyr.setStrokeWidth(marker_outline_mm)

            else:
                # Polygons: just colour the fill; existing stroke is preserved.
                sym.setColor(color)

        except Exception as e:
            logger.warning(f"Failed to configure categorized symbol: {e}")

        categories.append(QgsRendererCategory(uval, sym, str(uval)))
        # Optional caller-imposed cap on category count.
        if limit and len(categories) >= limit:
            break

    if not categories:
        return None

    return QgsCategorizedSymbolRenderer(field_name, categories)


def build_graduated_renderer_from_ramp(
    layer: "QgsVectorLayer",
    field_name: str,
    ramp_name: str,
    n_classes: int = 8,
    mode_str: str = "Equal Count (Quantile)",
) -> "Optional[QgsGraduatedSymbolRenderer]":
    """Build a :class:`QgsGraduatedSymbolRenderer` on *field_name* using a
    named QGIS colour ramp.

    Intended for the hexagon-bin output layer where ``field_name`` is
    ``"NUMPOINTS"`` and graduated symbology is the only valid choice
    (single quantitative field).

    Parameters
    ----------
    layer:     The target QgsVectorLayer (polygon geometry expected).
    field_name:Attribute field to classify on.
    ramp_name: Name of a QGIS colour ramp (project or global style DB).
    n_classes: Number of graduated classes (2–12 recommended).
    mode_str:  Classification method label — one of:
               ``"Equal Count (Quantile)"``, ``"Equal Interval"``,
               ``"Natural Breaks (Jenks)"``  (substring match used).

    Returns ``None`` when inputs are invalid or ramp resolution fails.
    """
    if not isinstance(layer, QgsVectorLayer) or not field_name or not ramp_name:
        return None

    # ── 1. Resolve colour ramp (project style wins, global default fallback) ──
    color_ramp = None
    try:
        color_ramp = (core_project()
                      .styleSettings().projectStyle().colorRamp(ramp_name))
    except Exception:
        color_ramp = None
    if not color_ramp:
        try:
            color_ramp = QgsStyle.defaultStyle().colorRamp(ramp_name)
        except Exception:
            color_ramp = None
    if not color_ramp:
        logger.warning(f"build_graduated_renderer_from_ramp: "
                       f"ramp '{ramp_name}' not found.")
        return None

    # ── 2. Read field values directly from the layer ───────────────────────────
    # Using createRenderer() on a scratch/temporary layer (e.g. processing
    # output) is unreliable because QGIS may not have pre-computed statistics
    # for it.  Building ranges manually from raw feature values is robust.
    field_idx = layer.fields().indexOf(field_name)
    if field_idx < 0:
        logger.warning(f"build_graduated_renderer_from_ramp: "
                       f"field '{field_name}' not found in '{layer.name()}'.")
        return None

    values = []
    for feat in layer.getFeatures():
        v = feat.attribute(field_idx)
        if v is not None:
            try:
                values.append(float(v))
            except (TypeError, ValueError):
                pass

    if not values:
        logger.warning(f"build_graduated_renderer_from_ramp: "
                       f"no valid values in '{field_name}'.")
        return None

    values.sort()
    n_vals = len(values)
    # Clamp classes to the number of distinct values
    n_classes = max(1, min(n_classes, n_vals))

    # ── 3. Compute class breaks ────────────────────────────────────────────────
    mode_lc = mode_str.lower()
    if "interval" in mode_lc:
        # Equal interval
        lo_v, hi_v = values[0], values[-1]
        step = (hi_v - lo_v) / n_classes if hi_v > lo_v else 1.0
        raw_breaks = [lo_v + i * step for i in range(n_classes + 1)]
    else:
        # Quantile (default, also covers Jenks fallback)
        # Try Jenks via QGIS classification API first
        if "jenks" in mode_lc or "natural" in mode_lc:
            try:
                from qgis.core import QgsClassificationJenks
                _method = QgsClassificationJenks()
                _ranges = _method.classes(layer, field_name, n_classes)
                raw_breaks = (
                    [_ranges[0].lowerBound()]
                    + [r.upperBound() for r in _ranges]
                )
            except Exception:
                # Fall back to quantile if Jenks API unavailable
                raw_breaks = [
                    values[int(i * (n_vals - 1) / n_classes)]
                    for i in range(n_classes + 1)
                ]
        else:
            raw_breaks = [
                values[int(i * (n_vals - 1) / n_classes)]
                for i in range(n_classes + 1)
            ]

    # Deduplicate breaks while preserving order (collapse duplicate boundaries)
    breaks: list = []
    for b in raw_breaks:
        if not breaks or b > breaks[-1]:
            breaks.append(b)

    actual_n = len(breaks) - 1
    if actual_n < 1:
        logger.warning("build_graduated_renderer_from_ramp: "
                       "all break values are identical — cannot classify.")
        return None

    # ── 4. Build QgsRendererRange list with interpolated ramp colours ──────────
    ranges = []
    for i in range(actual_n):
        lo_b = breaks[i]
        hi_b = breaks[i + 1]
        t = i / max(actual_n - 1, 1)          # 0.0 → 1.0 across the ramp
        color = color_ramp.color(t)
        sym = QgsFillSymbol.createSimple({
            "color":         color.name(),
            "outline_color": "100,100,100,180",
            "outline_width": "0.1",
        })
        label = f"{lo_b:.0f} – {hi_b:.0f}"
        ranges.append(QgsRendererRange(lo_b, hi_b, sym, label))

    renderer = QgsGraduatedSymbolRenderer(field_name, ranges)
    return renderer


# ---- Misc layout & filesystem helpers ---------------------------------------
def _release_qgis_layers(filepath):
    """Remove any QGIS project layers whose data source is ``filepath``.

    Called before deletion so Windows releases its file handle and
    os.remove() does not raise WinError 32.
    """
    import gc
    norm = os.path.normcase(os.path.abspath(filepath))
    project = core_project()
    to_remove = [
        lid
        for lid, layer in project.mapLayers().items()
        if os.path.normcase(os.path.abspath(layer.source().split("|")[0])) == norm
    ]
    for lid in to_remove:
        project.removeMapLayer(lid)
    if to_remove:
        gc.collect()


def safe_overwrite(filepath):
    """Delete ``filepath`` if it exists, to avoid GDAL overwrite errors.

    Several GDAL writers refuse to overwrite an existing file in-place;
    pre-deletion is the simplest path that works across drivers.
    On Windows, a first attempt may fail if QGIS still holds the file open;
    the function retries once after flushing Qt's deferred-deletion queue
    and logs a warning rather than raising if both attempts fail.
    """
    if not os.path.exists(filepath):
        return
    _release_qgis_layers(filepath)
    try:
        os.remove(filepath)
        print(f"Old file removed: {filepath}")
        return
    except OSError as e:
        if getattr(e, "winerror", None) != 32:
            raise Exception(f"Failed to remove existing file: {filepath}\n{e}")
    # WinError 32 – file still in use.  Flush Qt's deferred-deletion queue so
    # any canvas references to the removed layer are destroyed, then retry.
    try:
        QgsApplication.processEvents()
    except Exception:
        pass
    try:
        os.remove(filepath)
        print(f"Old file removed (on retry): {filepath}")
    except OSError:
        # Still locked.  For the line-mode CSV path a second safe_overwrite
        # call is made just before writeAsVectorFormat, which will succeed
        # once the event loop has fully processed the layer removal.
        logger.warning(f"Could not remove {filepath} – will retry before write.")
def apply_selected_style(layer, selected_name, styles_dict, geom_type):
    """Apply a named style (or the "No symbol" / "Select style" sentinels) to ``layer``.

    ``styles_dict`` is the geometry-keyed dict produced by
    :func:`get_symbologies_by_geometry` / :func:`harvest_layer_renderer_symbols`.
    """
    selected_name = selected_name.strip().lower()

    # Sentinel "Select style": keep whatever renderer the layer already has.
    if selected_name == "select style":
        layer.setRenderer(layer.renderer())
        print(f"Reset to default renderer for layer: {layer.name()}")
        return

    # Sentinel "No symbol": replace the renderer with a fully transparent one.
    if selected_name == "no symbol":
        empty_symbol = (layer.geometryType())

        # Pick the right symbol class for the layer's geometry.
        if geom_type == _GEOM_POINT:
            empty_symbol = QgsMarkerSymbol.createSimple({})
        elif geom_type == _GEOM_LINE:
            empty_symbol = QgsLineSymbol.createSimple({})
        elif geom_type == _GEOM_POLYGON:
            empty_symbol = QgsFillSymbol.createSimple({})
        else:
            empty_symbol = None

        empty_symbol.setOpacity(0.0)
        layer.setRenderer(QgsSingleSymbolRenderer(empty_symbol))
        print(f"Applied 'No symbol' to layer: {layer.name()}")
        return

    # Look up the chosen named style in the appropriate geometry bucket.
    geom_key = {0: 'Point', 1: 'Line', 2: 'Polygon'}.get(geom_type)
    style_list = styles_dict.get(geom_key, [])
    for name, symbol in style_list:
        if name.strip().lower() == selected_name:
            # Clone so the renderer owns its own symbol independently.
            layer.setRenderer(QgsSingleSymbolRenderer(symbol.clone()))
            print(f"STYLE '{name}' APPLIED TO LAYER: {layer.name()}")
            return

# ---- Analytics logging -------------------------------------------------------
def reset_border(widget):
    """Clear any inline stylesheet (typically a red 'missing field' border)."""
    widget.setStyleSheet("")


def mark_missing(widget):
    """Draw a red border around ``widget`` to flag a missing required value."""
    widget.setStyleSheet("border: 2px solid red;")


# ---- Dummy legend layer ------------------------------------------------------

def create_dummy_legend_layer(legend_styles, legend_labels, styles_dict):
    """Build an invisible point layer used only to populate the layout legend.

    QGIS legend items are driven by map-layer legend nodes. To draw a custom
    legend with one entry per (style, label) pair, we synthesise a memory
    layer whose categorised renderer produces one category per pair. The
    layer is later set to zero opacity on the map so only the legend shows.
    """
    layer = QgsVectorLayer("Point?crs=EPSG:4326", "", "memory")
    pr = layer.dataProvider()
    pr.addAttributes([QgsField("LABEL", QVariant.String)])
    layer.updateFields()

    categories = []

    # First pass: bucket each (style, label) pair by the geometry type its
    # symbol belongs to, so the legend reads Point -> Line -> Polygon.
    grouped = {'Point': [], 'Line': [], 'Polygon': []}
    for style_name, label_text in zip(legend_styles, legend_labels):
        matched = False
        for geom_type in ['Point', 'Line', 'Polygon']:
            for name, sym in styles_dict.get(geom_type, []):
                if name.strip().lower() == style_name.strip().lower():
                    grouped[geom_type].append((style_name, label_text, sym))
                    matched = True
                    break
            if matched:
                break

    # Concatenate buckets in the desired legend order.
    ordered = grouped['Point'] + grouped['Line'] + grouped['Polygon']

    # Second pass: emit one feature + one renderer category per ordered entry.
    for i, (style_name, label_text, symbol) in enumerate(ordered):
        x = i * 0.01
        y = 0.0
        feat = QgsFeature()
        feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y)))
        feat.setAttributes([label_text])
        pr.addFeature(feat)

        category = QgsRendererCategory(label_text, symbol.clone(), label_text)
        categories.append(category)

    # Apply renderer only when we actually have categories to render.
    if categories:
        renderer = QgsCategorizedSymbolRenderer("LABEL", categories)
        layer.setRenderer(renderer)

    layer.updateExtents()

    # Re-assign the renderer (kept for compatibility with existing callers
    # that may rely on a renderer being present even when categories is empty).
    renderer = QgsCategorizedSymbolRenderer("LABEL", categories)
    layer.setRenderer(renderer)

    # Configure label settings so each legend entry shows its label text.
    label_settings = QgsPalLayerSettings()
    label_settings.fieldName = "LABEL"
    label_settings.placement = QgsPalLayerSettings.AroundPoint
    label_settings.enabled = True
    label_settings.displayAll = True
    label_settings.obstacle = False
    label_settings.priority = 10

    # Default labelling format (Arial 8pt black) — the legend caller restyles
    # the actual on-page legend text separately.
    text_format = QgsTextFormat()
    text_format.setFont(sans_font(8))
    text_format.setSize(8)
    text_format.setColor(QColor(0, 0, 0))
    label_settings.setFormat(text_format)

    layer.setLabeling(QgsVectorLayerSimpleLabeling(label_settings))
    layer.setLabelsEnabled(True)

    core_project().addMapLayer(layer)
    return layer

# ---- Map-grid latitude labelling --------------------------------------------

def _label_internal_lat_rings_bottom(layout, map_item, project, custom_crs, y_interval_deg=1.0,
                                     margin_mm=1.5, font=sans_font(8),
                                     fmt=lambda lat: f"{abs(lat):.1f}°{'N' if lat>=0 else 'S'}",
                                     samples=36, min_separation_mm=8.0):
    """Place one outside-frame bottom label per latitude parallel fully inside the map.

    Orthographic and similarly curved projections render full circles of
    latitude that may sit entirely inside the rectangular map frame. The
    builtin QGIS grid annotation only labels parallels that touch the frame
    edge, so this helper supplements those for interior rings.

    Parameters
    ----------
    y_interval_deg : float
        Latitude step in degrees. Should match the grid's Y interval.
    fmt : callable
        Function ``lat -> str`` used to render the label text.
    samples : int
        Number of longitude samples per parallel for intersection testing.
    min_separation_mm : float
        Minimum horizontal spacing between consecutive bottom labels.
    """
    try:
        # Transform the visible map extent to WGS84 so we can iterate latitudes.
        wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
        xform_back = QgsCoordinateTransform(custom_crs, wgs84, project)
        ext_wgs = xform_back.transformBoundingBox(map_item.extent())
        lat_min = max(-90.0, ext_wgs.yMinimum())
        lat_max = min( 90.0, ext_wgs.yMaximum())

        # Round-down helper: lock the first label to a grid-interval boundary.
        def _round_to_interval(v, step):
            return step * int(v / step)

        step = float(y_interval_deg)
        lat = _round_to_interval(lat_min, step)

        # Cache map-frame geometry in layout units for label placement maths.
        map_pos  = map_item.positionWithUnits()
        map_size = map_item.sizeWithUnits()
        map_left_x   = map_pos.x()
        map_right_x  = map_pos.x() + map_size.width()
        map_bottom_y = map_pos.y() + map_size.height()

        placed_x = []  # x positions already used; prevents label overlap

        # Walk every grid-aligned latitude inside the WGS84 extent.
        while lat <= lat_max:
            # Skip the poles; sampling them is degenerate.
            if abs(lat) >= 90.0:
                lat += step; continue

            # Sample longitudes around the parallel to detect frame intersection.
            intersects_frame = False
            lowest_y = None
            lowest_x = None

            xform_fwd = QgsCoordinateTransform(wgs84, custom_crs, project)
            for i in range(samples):
                lon = -180.0 + (360.0 * i / samples)
                pt_wgs = QgsPointXY(lon, lat)
                try:
                    pt_map = xform_fwd.transform(pt_wgs)
                except Exception:
                    # Some lon/lat pairs are undefined in this CRS (e.g., the
                    # far side of an orthographic). Skip and continue.
                    continue
                # If any sample lies outside the frame, the parallel crosses
                # the frame edge and is already labelled by the builtin grid.
                if not map_item.extent().contains(pt_map):
                    intersects_frame = True
                    break
                # Otherwise track the lowest point — that's where we'll label.
                if lowest_y is None or pt_map.y() < lowest_y:
                    lowest_y = pt_map.y(); lowest_x = pt_map.x()

            # Parallel is entirely interior: emit a label below the frame at
            # the bottom-most projected point of the ring.
            if not intersects_frame and lowest_x is not None:
                # Convert map-CRS X to layout-mm X by proportion along the
                # frame width. Equivalent to QGIS's linear world->screen map.
                ext = map_item.extent()
                ratio = (lowest_x - ext.xMinimum()) / max(1e-9, (ext.xMaximum() - ext.xMinimum()))
                label_x_mm = map_left_x + ratio * (map_right_x - map_left_x)

                # Skip when too close to a previously placed label to avoid
                # overlap; the next interval's label will be checked next.
                if placed_x and min(abs(label_x_mm - x) for x in placed_x) < min_separation_mm:
                    lat += step; continue

                # Build the label item and its text format (Arial + halo).
                lbl = QgsLayoutItemLabel(layout)
                lbl.setText(fmt(lat))
                tf = QgsTextFormat()
                tf.setFont(font)
                tf.setSize(font.pointSize() if font.pointSize() > 0 else 8)
                # Light halo so the text reads cleanly over grid lines.
                halo = QgsTextBufferSettings(); halo.setEnabled(True); halo.setColor(QColor(200,200,200)); halo.setSize(0.15)
                tf.setBuffer(halo)
                lbl.setTextFormat(tf)
                lbl.setHAlign(Qt.AlignCenter)  # centre under the ring's low point

                # Place the label just below the map frame.
                layout.addLayoutItem(lbl)
                lbl.attemptResize(QgsLayoutSize(18.0, 4.5, _LAYOUT_MM))
                lbl.attemptMove(QgsLayoutPoint(label_x_mm, map_bottom_y + margin_mm, _LAYOUT_MM))
                lbl.refresh()

                placed_x.append(label_x_mm)

            lat += step

    except Exception as e:
        logger.warning(f"internal latitude labeling failed: {e}")


# ---- GUI signal wiring -------------------------------------------------------

def connect_disable_export_on_change(widget, export_button):
    """Wire ``widget`` so any user edit disables ``export_button``.

    Used during GUI build to make sure the user always has to re-BUILD before
    the next EXPORT — otherwise an edit made after a preview would silently
    diverge from what gets written out.
    """
    # Each widget type emits a different signal for "value changed".
    if isinstance(widget, QLineEdit):
        widget.textChanged.connect(lambda: export_button.setEnabled(False))
    elif isinstance(widget, QComboBox):
        widget.currentIndexChanged.connect(lambda: export_button.setEnabled(False))
    elif isinstance(widget, QCheckBox):
        widget.stateChanged.connect(lambda: export_button.setEnabled(False))
    elif isinstance(widget, QRadioButton):
        widget.toggled.connect(lambda: export_button.setEnabled(False))
    elif isinstance(widget, QListWidget):
        widget.itemChanged.connect(lambda: export_button.setEnabled(False))
    else:
        # Fallback for QToolButton / QPushButton-style widgets (the new
        # SymbolCustomizationButton lives here). A click means the user
        # might have edited symbology, so the next EXPORT needs a fresh
        # BUILD. We use the generic ``clicked`` signal since this branch
        # also covers any future button-like widget added to the same
        # bulk-wiring list.
        if hasattr(widget, "clicked"):
            try:
                widget.clicked.connect(lambda: export_button.setEnabled(False))
            except Exception:
                pass

# ---- Multi-select combo box --------------------------------------------------

class MultiSelectComboBox(QComboBox):
    """A :class:`QComboBox` whose popup lets the user tick multiple items.

    Row 0 of the model is a special "Deselect all" pseudo-row that, when
    ticked, unchecks every other row and immediately unticks itself.
    The line-edit shows the comma-separated list of currently ticked items.
    """

    def __init__(self, items, parent=None):
        super().__init__(parent)
        # Use a standard item-model so we get checkable rows and easy access
        # to each row's check state.
        self.setModel(QStandardItemModel(self))
        self.setItemDelegate(QStyledItemDelegate(self))
        # Editable + read-only line-edit gives us a display label we can set.
        self.setEditable(True)
        self.lineEdit().setReadOnly(True)
        self.lineEdit().setPlaceholderText("Select items")
        self.lineEdit().setText("")

        # Row 0 is the synthetic "Deselect all" action; rendered greyed-out.
        clear_item = QStandardItem("Deselect all")
        clear_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
        clear_item.setData(Qt.Unchecked, Qt.CheckStateRole)
        clear_item.setForeground(QColor(150, 150, 150))
        self.model().appendRow(clear_item)

        # Real choices live at rows 1..N.
        for item_text in items:
            it = QStandardItem(item_text)
            it.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
            it.setData(Qt.Unchecked, Qt.CheckStateRole)
            self.model().appendRow(it)

        # Update the line-edit summary whenever any check state changes.
        self.model().dataChanged.connect(self.update_display)

    def update_display(self):
        """Refresh the line-edit text from the current check states.

        Also services the "Deselect all" action: if row 0 is checked, this
        method unchecks every real row and then unchecks row 0 itself.
        """
        try:
            # Special-case row 0 ("Deselect all") as an immediate command.
            if self.model().rowCount() > 0:
                clear_row = self.model().item(0)
                if clear_row and clear_row.checkState() == Qt.Checked:
                    # Wipe all real items.
                    for i in range(1, self.model().rowCount()):
                        it = self.model().item(i)
                        if it and it.checkState() != Qt.Unchecked:
                            it.setCheckState(Qt.Unchecked)
                    # Reset the pseudo-action so it doesn't stay checked.
                    clear_row.setCheckState(Qt.Unchecked)

            # Gather currently-checked labels (skip the pseudo-row at index 0).
            selected = []
            for i in range(1, self.model().rowCount()):
                it = self.model().item(i)
                if it and it.checkState() == Qt.Checked:
                    selected.append(it.text())
            # Render as a comma-separated list, or the placeholder when empty.
            self.lineEdit().setText(", ".join(selected) if selected else "Select items")
        except Exception as e:
            logger.warning(f"MultiSelectComboBox.update_display failed: {e}")

    def selected_items(self):
        """Return the list of labels currently ticked (excludes row 0)."""
        return [
            self.model().item(i).text()
            for i in range(1, self.model().rowCount())
            if self.model().item(i).checkState() == Qt.Checked
        ]

    def set_popup_dimensions(self, *, rows: int = None, width: int = None, row_height_px: int = None):
        """Tune the popup list's visible rows / minimum width / row height.

        Parameters
        ----------
        rows : int, optional
            Maximum visible items before scrolling (``setMaxVisibleItems``).
        width : int, optional
            Minimum popup width in pixels.
        row_height_px : int, optional
            Fixed row height (applied via a tiny stylesheet).
        """
        # Visible-row count is on the combo itself.
        if rows is not None:
            try:
                self.setMaxVisibleItems(int(rows))
            except Exception:
                pass
        # Width and row-height are on the popup view.
        try:
            v = self.view()
            if width is not None:
                v.setMinimumWidth(int(width))
            if row_height_px is not None:
                v.setStyleSheet(f"QListView::item {{ height: {int(row_height_px)}px; }}")
        except Exception:
            pass


def create_multiselect_dropdown(name_list):
    """Factory: build a :class:`MultiSelectComboBox` with the placeholder text set."""
    widget = MultiSelectComboBox(name_list)
    widget.lineEdit().setPlaceholderText("Select items")
    widget.lineEdit().setText("Select items")
    return widget


# ---- Adaptive map CRS selection ---------------------------------------------

def get_custom_projection(lon,
                          lat,
                          scale_denominator=None,
                          force_lcc=False,
                          map_height_mm=None,
                          margin_frac=0.10):
    """Return a PROJ.4 string for a CRS chosen to suit ``(lon, lat)`` and scale.

    Decision tree:

    - **UTM** when ``scale_denominator < 1 000 000`` and ``force_lcc`` is False
      — high-detail maps benefit from UTM's small-area equal-shape behaviour.
    - **Orthographic** when ``|lat| > 60`` — preserves shape near the poles
      where conic projections distort heavily.
    - **Lambert Conformal Conic** otherwise. If both ``scale_denominator``
      and ``map_height_mm`` are supplied, the two standard parallels are
      placed *inside* the visible vertical span (offset by ``margin_frac``)
      so the true-scale lines fall within the frame, minimising visible
      distortion. Otherwise, a latitude-only heuristic is used.

    Both standard parallels must share a hemisphere (PROJ requirement) and
    are clamped to ±80° to stay numerically stable.
    """
    # ---- 1) Harden + normalise inputs -------------------------------------
    try:
        lon_f = float(lon)
        lat_f = float(lat)
    except Exception:
        # Defensive: callers occasionally pass UI strings.
        print(f"[ERR] get_custom_projection: non-numeric lon/lat -> lon={lon!r}, lat={lat!r}")
        lon_f, lat_f = 0.0, 0.0

    # Longitudes wrap; normalise into [-180, 180].
    lon_f = ((lon_f + 180.0) % 360.0) - 180.0

    # Optional scale coercion (string → float) with a log on failure.
    scale_f = None
    if scale_denominator is not None:
        try:
            scale_f = float(scale_denominator)
        except Exception:
            logger.warning(f"get_custom_projection: non-numeric scale -> {scale_denominator!r}")
            scale_f = None

    # ---- 2) UTM for zoomed-in maps ----------------------------------------
    if (scale_f is not None) and (scale_f < 1_000_000) and not force_lcc:
        # UTM zone number runs 1..60 in 6° longitude strips from 180°W.
        zone = int((lon_f + 180) / 6) + 1
        hemisphere = "north" if lat_f >= 0 else "south"
        return (
            f"+proj=utm +zone={zone} +{hemisphere} "
            f"+datum=WGS84 +units=m +no_defs"
        )

    # ---- 3) Orthographic near the poles -----------------------------------
    if abs(lat_f) > 60:
        lat0 = max(-80.0, min(80.0, lat_f))
        return (
            f"+proj=ortho +lat_0={lat0:.6f} +lon_0={lon_f:.6f} "
            f"+x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs"
        )

    # ---- 4) Lambert Conformal Conic ---------------------------------------
    # Try the frame-aware placement first; fall back to the latitude-only
    # heuristic if anything in the maths goes sideways.
    lat1 = lat2 = None
    if (scale_f is not None) and (map_height_mm is not None):
        try:
            # Approximate the vertical map span in degrees at the centre latitude.
            m_per_deg_lat, _ = meters_per_degree_lat_lon(lat_f)
            map_height_m = (float(map_height_mm) / 1000.0) * scale_f
            lat_span_deg = map_height_m / max(1e-6, m_per_deg_lat)

            # Frame top / bottom latitudes; pull parallels in by ``margin_frac``.
            half = 0.5 * lat_span_deg
            lat_top = lat_f + half
            lat_bot = lat_f - half

            # Minimum margin of 0.5° guards against ultra-tall narrow frames.
            margin = max(0.5, float(margin_frac) * lat_span_deg)
            lat1 = lat_bot + margin
            lat2 = lat_top - margin

            # Clamp into the LCC-safe band.
            lat1 = max(-80.0, min(80.0, lat1))
            lat2 = max(-80.0, min(80.0, lat2))

            # Force both parallels into the same hemisphere (LCC requirement).
            H = 1.0 if lat_f >= 0.0 else -1.0
            if lat1 * lat2 <= 0.0:
                # The frame straddles the equator or clamping pushed one over.
                # Re-place both parallels into the centre latitude's hemisphere.
                delta = max(2.0, half * 0.66)
                lat1 = max(-80.0, min(80.0, lat_f - delta * H))
                lat2 = max(-80.0, min(80.0, lat_f + delta * H))

            # Enforce a minimum 2° separation; coincident parallels are degenerate.
            if abs(lat2 - lat1) < 2.0:
                expand = (2.0 - abs(lat2 - lat1)) * 0.5
                lat1 = max(-80.0, min(80.0, lat1 - expand))
                lat2 = max(-80.0, min(80.0, lat2 + expand))
        except Exception as e:
            logger.warning(f"LCC parallel placement (frame-aware) failed: {e}")
            lat1 = lat2 = None

    # Fallback: latitude-only heuristic — covers the case where the caller
    # didn't supply scale or map_height_mm.
    if (lat1 is None) or (lat2 is None):
        lat0 = max(-75.0, min(75.0, lat_f))
        H = 1.0 if lat0 > 0.0 else (-1.0 if lat0 < 0.0 else 1.0)
        abs_lat0 = abs(lat0)
        # Three regimes: equatorial, mid-latitude, sub-polar.
        if abs_lat0 < 10.0:
            lat1 = 10.0 * H
            lat2 = 30.0 * H
        elif abs_lat0 < 30.0:
            lat1 = (abs_lat0 - 5.0) * H
            lat2 = (abs_lat0 + 15.0) * H
        else:
            lat1 = (abs_lat0 - 10.0) * H
            lat2 = (abs_lat0 + 10.0) * H

        # Clamp + same-hemisphere safety net.
        lat1 = max(-80.0, min(80.0, lat1))
        lat2 = max(-80.0, min(80.0, lat2))
        if (lat1 == 0.0) or (lat2 == 0.0) or (lat1 * lat2 < 0.0):
            lat1 = 10.0 * H
            lat2 = 30.0 * H

    # Centre-latitude of the projection (clamped well inside the LCC band).
    lat0 = max(-75.0, min(75.0, lat_f))

    # Assemble the final PROJ.4 string.
    proj_string = (
        f"+proj=lcc +lat_1={lat1:.6f} +lat_2={lat2:.6f} "
        f"+lat_0={lat0:.6f} +lon_0={lon_f:.6f} +x_0=0 +y_0=0 "
        f"+datum=WGS84 +units=m +no_defs"
    )
    print("[DBG] Projection decision: LCC",
          f"lat0={lat0:.3f}, lat1={lat1:.3f}, lat2={lat2:.3f}, lon0={lon_f:.3f}")
    return proj_string



# ---- Geometry-aware symbol sizing -------------------------------------------

def apply_size_to_symbol(symbol, geom_type, size_mm):
    """Set the size dimension on ``symbol`` according to its geometry type.

    Mapping:

    - Point    → marker size
    - Line     → stroke width
    - Polygon  → outline / border width (set on each contained symbol layer
                 since :class:`QgsFillSymbol` does not expose stroke setters
                 at the symbol level).

    All setters are guarded by ``hasattr`` because QGIS exposes multiple
    symbol-layer types and not every layer has every setter.
    """
    try:
        if geom_type == _GEOM_POINT:
            # Symbol-level marker size when supported.
            if hasattr(symbol, "setSize"):
                symbol.setSize(float(size_mm))
            else:
                # Older / custom symbol classes only expose size per layer.
                for i in range(symbol.symbolLayerCount()):
                    lyr = symbol.symbolLayer(i)
                    if hasattr(lyr, "setSize"):
                        lyr.setSize(float(size_mm))

        elif geom_type == _GEOM_LINE:
            # Symbol-level stroke width on line symbols.
            if hasattr(symbol, "setWidth"):
                symbol.setWidth(float(size_mm))
            else:
                # Fall back to walking the layers.
                for i in range(symbol.symbolLayerCount()):
                    lyr = symbol.symbolLayer(i)
                    if hasattr(lyr, "setWidth"):
                        lyr.setWidth(float(size_mm))

        elif geom_type == _GEOM_POLYGON:
            # Polygon outline width is layer-level only. Try the canonical
            # setter names in order; first match wins.
            for i in range(symbol.symbolLayerCount()):
                lyr = symbol.symbolLayer(i)
                if hasattr(lyr, "setStrokeWidth"):
                    lyr.setStrokeWidth(float(size_mm))
                    continue
                # Older style/layer types use setBorderWidth.
                if hasattr(lyr, "setBorderWidth"):
                    lyr.setBorderWidth(float(size_mm))
                    continue
                # Simple line outlines expose setWidth.
                if hasattr(lyr, "setWidth"):
                    lyr.setWidth(float(size_mm))
                    continue
                # Fill-only layers with no outline silently skip.
    except Exception as e:
        logger.warning(f"apply_size_to_symbol failed: {e}")

# ---- Layout-item placement helpers ------------------------------------------

def compute_item_anchor(
    layout_name: str,
    map_pos, map_size,
    item_width: float, item_height: float,
    position: str,
    margin: float,
    *,
    item_id: str = None,
    item_nudges: Optional[dict] = None,
    corner_offsets: Optional[dict] = None
):
    """Compute the (x, y) anchor for an item placed relative to the map frame.

    Returns the top-left corner of a box sized ``(item_width, item_height)``
    placed at ``position`` (``"Top left"`` / ``"Top right"`` / ``"Bottom left"``
    / ``"Bottom right"``) with ``margin`` mm of padding from the map frame.

    Two optional per-item adjustments:

    - ``item_nudges`` — override the default vertical nudge for a specific
      item id. Format: ``{"Legend": {"Top": -4, "Bottom": 12}, ...}``.
    - ``corner_offsets`` — apply a small ``(dx, dy)`` tweak for a specific
      ``item_id`` at a specific corner, useful to visually snap a circular
      locator inside its layout-box.

    Defaults: the vertical nudge is 0 unless overridden per item.
    """

    # Map-frame corner coordinates in layout units.
    top_left_x = map_pos.x()
    top_left_y = map_pos.y()
    top_right_x = map_pos.x() + map_size.width()
    top_right_y = map_pos.y()
    bottom_left_x = map_pos.x()
    bottom_left_y = map_pos.y() + map_size.height()
    bottom_right_x = map_pos.x() + map_size.width()
    bottom_right_y = map_pos.y() + map_size.height()

    default_top_nudge    = 0.0
    default_bottom_nudge = 0.0

    # Per-item override of those nudges, if supplied.
    top_nudge    = default_top_nudge
    bottom_nudge = default_bottom_nudge
    if item_id and isinstance(item_nudges, dict):
        n = item_nudges.get(item_id)
        if isinstance(n, dict):
            try:
                top_nudge    = float(n.get("Top",    top_nudge))
                bottom_nudge = float(n.get("Bottom", bottom_nudge))
            except (TypeError, ValueError):
                # Bad override value: keep the layout default.
                pass

    # Compute the base anchor coordinates for the requested corner.
    if position == "Top right":
        anchor_x = top_right_x  - item_width - margin
        anchor_y = top_right_y  + margin     + top_nudge
    elif position == "Bottom right":
        anchor_x = bottom_right_x - item_width - margin
        anchor_y = bottom_right_y - item_height - margin + bottom_nudge
    elif position == "Top left":
        anchor_x = top_left_x + margin
        anchor_y = top_left_y + margin + top_nudge
    elif position == "Bottom left":
        anchor_x = bottom_left_x + margin
        anchor_y = bottom_left_y - item_height - margin + bottom_nudge
    else:
        # Unknown corner string: fall back to Bottom right.
        anchor_x = bottom_right_x - item_width - margin
        anchor_y = bottom_right_y - item_height - margin + bottom_nudge

    # Tiny per-corner (dx, dy) tweaks to visually snap an item if needed.
    if item_id and isinstance(corner_offsets, dict):
        per_item = corner_offsets.get(item_id, {})
        if isinstance(per_item, dict):
            pair = per_item.get(position)
            if isinstance(pair, (tuple, list)) and len(pair) == 2:
                try:
                    dx, dy = float(pair[0]), float(pair[1])
                    anchor_x += dx
                    anchor_y += dy
                except (TypeError, ValueError):
                    pass

    return anchor_x, anchor_y


def compute_page_anchor(
    layout,
    item_width: float, item_height: float,
    position: str,
    margin: float
):
    """Compute an item anchor relative to the **page** edges (not the map frame).

    Used for elements like the legend that should sit at a fixed page-corner
    distance regardless of map frame size.
    """
    # Page dimensions.
    page = layout.pageCollection().pages()[0]
    page_size = page.pageSize()
    pw = page_size.width()
    ph = page_size.height()

    # Pick the anchor based on the requested page corner.
    if position == "Top right":
        anchor_x = pw - margin - item_width
        anchor_y = margin
    elif position == "Top left":
        anchor_x = margin
        anchor_y = margin
    elif position == "Bottom left":
        anchor_x = margin
        anchor_y = ph - margin - item_height
    else:
        # Bottom right (and unknown corners default here).
        anchor_x = pw - margin - item_width
        anchor_y = ph - margin - item_height

    return anchor_x, anchor_y


def position_item(
    layout,
    item_id,
    map_item,
    position="Bottom right",
    margin=8.0,
    item_margins=None,
    anchor_reference="map",
    set_reference_point=False,
    item_nudges=None,
    corner_offsets=None
):
    """Move a layout item to a named corner relative to the map or the page.

    Parameters
    ----------
    anchor_reference : {"map", "page"}
        Whether ``position`` is measured against the map-frame corners (the
        usual locator-and-legend case) or the page corners (so the item
        doesn't move with the map frame).
    set_reference_point : bool
        Only honoured when ``anchor_reference='page'``. Adjusts the item's
        reference point so the anchor refers to the chosen corner rather
        than the default upper-left.
    item_nudges, corner_offsets : dict, optional
        See :func:`compute_item_anchor`.
    """
    item = layout.itemById(item_id)
    if not item:
        # Soft warning: callers are expected to be tolerant of missing items.
        print(f"[WARNING] '{item_id}' item not found in layout.")
        return

    # Resolve the effective margin: per-item override > default.
    eff_margin = margin
    if isinstance(item_margins, dict):
        try:
            eff_margin = float(item_margins.get(item_id, margin))
        except (TypeError, ValueError):
            eff_margin = margin

    # Item's current size in layout units.
    item_size = item.sizeWithUnits()
    item_width, item_height = item_size.width(), item_size.height()

    if anchor_reference == "page":
        # Page-anchored placement (e.g. the legend at a fixed page corner).
        anchor_x, anchor_y = compute_page_anchor(
            layout, item_width, item_height, position, eff_margin
        )
        # Optionally align the item's reference point with the chosen corner.
        if set_reference_point:
            if position == "Top right":
                item.setReferencePoint(QgsLayoutItem.UpperRight)
            elif position == "Bottom right":
                item.setReferencePoint(QgsLayoutItem.LowerRight)
            elif position == "Top left":
                item.setReferencePoint(QgsLayoutItem.UpperLeft)
            elif position == "Bottom left":
                item.setReferencePoint(QgsLayoutItem.LowerLeft)
            else:
                item.setReferencePoint(QgsLayoutItem.LowerRight)
    else:
        # Map-anchored placement; pass per-item nudges & offsets through.
        map_pos  = map_item.positionWithUnits()
        map_size = map_item.sizeWithUnits()
        anchor_x, anchor_y = compute_item_anchor(
            layout_name=layout.name(),
            map_pos=map_pos,
            map_size=map_size,
            item_width=item_width,
            item_height=item_height,
            position=position,
            margin=eff_margin,
            item_id=item_id,
            item_nudges=item_nudges,
            corner_offsets=corner_offsets
        )

    # Apply the computed anchor and refresh the item to repaint.
    item.attemptMove(QgsLayoutPoint(anchor_x, anchor_y, _LAYOUT_MM))
    item.refresh()

# ---- Legend configuration & placement ---------------------------------------

def configure_layout_legend(layout, map_item, dummy_legend_layer, legend_position="Bottom right"):
    """Build / style / position the layout legend backed by a dummy layer.

    The legend's content is driven by the synthetic ``dummy_legend_layer``
    (see :func:`create_dummy_legend_layer`). Placement is page-anchored, not
    map-anchored, so the legend doesn't shift when the map frame is resized,
    with a tight 1 mm gutter from the page edges.

    Returns the (existing or newly created) :class:`QgsLayoutItemLegend`.
    """
    # Reuse an existing legend item if the layout already has one.
    legend_item = next((item for item in layout.items() if isinstance(item, QgsLayoutItemLegend)), None)
    if not legend_item:
        legend_item = QgsLayoutItemLegend(layout)
        legend_item.setId("Legend")  # stable ID for later itemById lookups
        layout.addLayoutItem(legend_item)

    # Link the legend to the main map so layer visibility & extent drive it.
    legend_item.setLinkedMap(map_item)
    legend_item.setAutoUpdateModel(False)
    legend_item.setTitle("")

    # Compact Arial styling for symbol labels, groups, and subgroups.
    legend_item.setStyleFont(QgsLegendStyle.SymbolLabel, sans_font(8))
    legend_item.setStyleFont(QgsLegendStyle.Group,       sans_font(8))
    legend_item.setStyleFont(QgsLegendStyle.Subgroup,    sans_font(8))

    # Shrink symbol patches so the legend reads at small sizes.
    legend_item.setSymbolWidth(5.0)
    legend_item.setSymbolHeight(3.0)

    # Tight inter-row / inter-section spacing.
    legend_item.setStyleMargin(QgsLegendStyle.Title,       -0.3)
    legend_item.setStyleMargin(QgsLegendStyle.Group,        4.0)
    legend_item.setStyleMargin(QgsLegendStyle.Symbol,       1.0)
    legend_item.setStyleMargin(QgsLegendStyle.SymbolLabel,  0.5)
    legend_item.setTitleAlignment(Qt.AlignLeft)

    # Replace whatever was in the legend model with the dummy layer only.
    root_group = legend_item.model().rootGroup()
    root_group.removeAllChildren()
    root_group.addChildNode(QgsLayerTreeLayer(dummy_legend_layer))

    # Hide the dummy layer from the actual map render; the legend is the only
    # place it should appear.
    root = core_project().layerTreeRoot()
    node = root.findLayer(dummy_legend_layer.id())
    if node:
        node.setItemVisibilityChecked(False)
    dummy_legend_layer.setLabelsEnabled(False)

    # Auto-shrink to fit contents, then scale down to 40% W / 80% H — these
    # ratios were chosen empirically to match the original print layouts.
    legend_item.adjustBoxSize()
    current_size = legend_item.sizeWithUnits()
    legend_item.attemptResize(QgsLayoutSize(current_size.width() * 0.4,
                                            current_size.height() * 0.8,
                                            _LAYOUT_MM))

    legend_item.refresh()
    layout.refresh()

    # Tight 1 mm gutter on all sides.
    side_margin   = 1.0
    top_margin    = 1.0
    bottom_margin = 1.0
    up_nudge      = 0.0
    down_nudge    = 0.0

    # The reference point determines which corner of the box gets placed at
    # the anchor; mirror it to the chosen corner.
    if legend_position == "Top right":
        legend_item.setReferencePoint(QgsLayoutItem.UpperRight)
    elif legend_position == "Bottom right":
        legend_item.setReferencePoint(QgsLayoutItem.LowerRight)
    elif legend_position == "Top left":
        legend_item.setReferencePoint(QgsLayoutItem.UpperLeft)
    elif legend_position == "Bottom left":
        legend_item.setReferencePoint(QgsLayoutItem.LowerLeft)
    else:
        legend_item.setReferencePoint(QgsLayoutItem.LowerRight)

    # Compute the (x, y) anchor on the page.
    page = layout.pageCollection().pages()[0]
    page_size = page.pageSize()
    pw = page_size.width()
    ph = page_size.height()

    # Snapshot the resized legend size (used by some downstream callers).
    box_size = legend_item.sizeWithUnits()
    box_w = box_size.width()
    box_h = box_size.height()

    # Pick the page-anchor coordinates by corner.
    if legend_position == "Top right":
        legend_x = pw - side_margin
        legend_y = top_margin + up_nudge
    elif legend_position == "Bottom right":
        legend_x = pw - side_margin
        legend_y = ph - bottom_margin + down_nudge
    elif legend_position == "Top left":
        legend_x = side_margin
        legend_y = top_margin + up_nudge
    elif legend_position == "Bottom left":
        legend_x = side_margin
        legend_y = ph - bottom_margin + down_nudge
    else:
        # Fallback: Bottom right.
        legend_x = pw - side_margin
        legend_y = ph - bottom_margin + down_nudge

    # Move into place and bring to the front (above any other overlays).
    legend_item.attemptMove(QgsLayoutPoint(legend_x, legend_y, _LAYOUT_MM))
    legend_item.setZValue(1000)
    legend_item.adjustBoxSize()
    legend_item.refresh()
    layout.refresh()

    return legend_item

# ---- Geodesic helpers --------------------------------------------------------

def meters_per_degree_lat_lon(center_lat_deg: float):
    """Return ``(m_per_deg_lat, m_per_deg_lon)`` at ``center_lat_deg`` on WGS84.

    Uses the standard truncated series approximations for the meridional and
    parallel arc lengths. Accuracy is more than sufficient for grid-spacing
    calculations (sub-metre at all latitudes).
    """
    phi = math.radians(center_lat_deg)
    # Meridional arc length per degree of latitude.
    m_per_deg_lat = (
        111132.954
        - 559.822 * math.cos(2 * phi)
        + 1.175 * math.cos(4 * phi)
        - 0.0023 * math.cos(6 * phi)
    )
    # Parallel arc length per degree of longitude.
    m_per_deg_lon = (
        111412.84 * math.cos(phi)
        - 93.5 * math.cos(3 * phi)
        + 0.118 * math.cos(5 * phi)
    )
    # Clamp to a tiny positive value near the poles to avoid div-by-zero.
    if m_per_deg_lon < 1e-6:
        m_per_deg_lon = 1e-6
    return m_per_deg_lat, m_per_deg_lon


def compute_main_map_extent_area_m2(map_item, project=None):
    """Return the geodesic area (m²) of the map frame's current extent.

    The map extent is transformed corner-by-corner into WGS84 and the area
    is measured on the ellipsoid via :class:`QgsDistanceArea`. This is the
    "true" map-area regardless of the source projection — useful for
    triggering scale-driven UI like the locator zoom-out logic.
    """
    project = project or core_project()
    extent = map_item.extent()
    if extent is None or not extent.isFinite():
        return None

    # Build a closed-ring polygon (5 vertices) from the extent corners.
    corners_map = [
        QgsPointXY(extent.xMinimum(), extent.yMinimum()),
        QgsPointXY(extent.xMaximum(), extent.yMinimum()),
        QgsPointXY(extent.xMaximum(), extent.yMaximum()),
        QgsPointXY(extent.xMinimum(), extent.yMaximum()),
        QgsPointXY(extent.xMinimum(), extent.yMinimum()),
    ]

    # Project the corners into WGS84 so the geodesic measurer can run.
    wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
    try:
        xform = QgsCoordinateTransform(map_item.crs(), wgs84, project)
        corners_ll = [xform.transform(pt) for pt in corners_map]
    except Exception as e:
        logger.warning(f"Extent->WGS84 transform failed: {e}")
        return None

    # Build the polygon geometry in WGS84.
    poly_ll = QgsGeometry.fromPolygonXY([corners_ll])

    # Measure on the WGS84 ellipsoid.
    da = QgsDistanceArea()
    da.setSourceCrs(wgs84, project.transformContext())
    da.setEllipsoid('WGS84')
    return da.measureArea(poly_ll)
def format_distance_label(metres: float) -> str:
    """Return a human-readable rounded distance string for map labelling.

    Rounding tiers (English comma notation):
      < 1 000 m  → nearest 5 m   (e.g. "850 m"); if rounded ≥ 1 000 → "1 km"
      1 – 100 km → nearest 1 km  (e.g. "43 km")
      100 – 1 000 km → nearest 5 km  (e.g. "430 km")
      1 000 – 10 000 km → nearest 50 km  (e.g. "1,250 km")
      > 10 000 km → nearest 500 km (e.g. "12,500 km")
    """
    if metres < 1000:
        rounded = round(metres / 5) * 5
        if rounded >= 1000:
            return "1 km"
        return f"{int(rounded):,} m"
    km = metres / 1000.0
    if km <= 100:
        rounded_km = round(km)
        return f"{rounded_km:,} km"
    elif km <= 1000:
        rounded_km = round(km / 5) * 5
        return f"{int(rounded_km):,} km"
    elif km <= 10000:
        rounded_km = round(km / 50) * 50
        return f"{int(rounded_km):,} km"
    else:
        rounded_km = round(km / 500) * 500
        return f"{int(rounded_km):,} km"


# ---- Map-frame layer-set synchronisation ------------------------------------

def sync_map_item_to_gui_visibility(map_item, *, basemap_names=None):
    """Hard-sync the layout map's layer-set to the current layer-tree visibility.

    The GUI checkboxes are the source of truth: this rebuilds the map item's
    explicit layer list from the currently-visible tree nodes, in a
    deterministic order (vectors above basemaps), and locks the layer set
    so the layout can't drift back to a stale snapshot.

    ``basemap_names`` is the set of layer names treated as basemaps; any
    :class:`QgsRasterLayer` is also classified as a basemap regardless.
    """
    if not map_item:
        return

    project = core_project()
    root = project.layerTreeRoot()
    basemap_names = set(basemap_names or [])

    # Two buckets: basemaps (rendered first/lowest) and vectors (rendered above).
    basemap_layers = []
    vector_layers = []

    for node in root.findLayers():
        # Honour the layer-tree visibility checkbox.
        if not node.isVisible():
            continue

        layer = node.layer()
        if layer is None:
            continue

        # Bucket: explicit basemap name OR any raster layer goes underneath.
        if layer.name() in basemap_names or isinstance(layer, QgsRasterLayer):
            basemap_layers.append(layer)
        else:
            vector_layers.append(layer)

    # QgsLayoutItemMap renders the first layer in the list ON TOP, so to
    # paint basemaps below vectors we put vectors first.
    ordered_layers = vector_layers + basemap_layers

    # Set the explicit layer set and lock it so subsequent layer-tree edits
    # don't silently leak into a different map render.
    map_item.setLayers(ordered_layers)
    map_item.setKeepLayerSet(True)
    map_item.refresh()


def _layers_by_name(project: QgsProject, name: str):
    """Return every layer object whose name equals ``name``."""
    return [lyr for lyr in project.mapLayers().values() if lyr.name() == name]


def _q(s: str) -> str:
    """Single-quote a SQL literal, doubling embedded single quotes."""
    return "'" + str(s).replace("'", "''") + "'"


def _build_in_clause(names, field="Place_name"):
    """Build a ``"field" IN (...)`` clause; empty list yields an always-false filter."""
    if names:
        quoted = ', '.join(_q(n) for n in names)
        return f'"{field}" IN ({quoted})'
    else:
        # Empty input means "match nothing"; ``IN ("")`` is the simplest way.
        return f'"{field}" IN ("")'


def _build_not_in_clause(names, field="Place_name"):
    """Build a ``"field" NOT IN (...)`` clause; empty list yields no filter."""
    if names:
        quoted = ', '.join(_q(n) for n in names)
        return f'"{field}" NOT IN ({quoted})'
    else:
        # No exclusions requested → return an empty clause (i.e. clear filter).
        return ""

def _set_layer_subset(project: QgsProject,
                      layer_name: str,
                      names,
                      include: bool = True,
                      field_name: str = "Place_name"):
    """Apply an ``IN`` / ``NOT IN`` SQL subset to the named layer.

    ``names=None`` is a deliberate no-op so callers can choose not to touch
    a layer's existing filter; pass an empty list instead to mean
    "match nothing".
    """
    if names is None:
        # No-op: preserves any previously-set filter on this layer.
        return

    # Choose IN vs NOT IN by the include flag.
    clause = _build_in_clause(names, field_name) if include else _build_not_in_clause(names, field_name)
    for layer in _layers_by_name(project, layer_name):
        # Skip instances that don't carry the field; they contribute nothing.
        if field_name not in [f.name() for f in layer.fields()]:
            continue
        layer.setSubsetString(clause)


def apply_layer_filters(
    project,
    focus_country_names,
    secondary_country_names,
    included_preloaded_layers,
    styles_dict,
    focus_country_style=None,
    secondary_country_style=None,
    focus_province_names=None
):
    """Centralised filter + style application for the four focus-related layers.

    Applies ``IN``/``NOT IN`` filtering to ``Focus_country``,
    ``Secondary_focus_country``, ``Focus_province`` and ``Countries_and_lakes``,
    plus any named symbology selected for the preloaded layers in
    ``included_preloaded_layers``.
    """

    # Apply user-chosen styles to the focus and secondary layers, if any.
    if focus_country_style:
        for lyr in _layers_by_name(project, "Focus_country"):
            if isinstance(lyr, QgsRasterLayer):
                continue
            apply_selected_style(lyr, focus_country_style, styles_dict, _GEOM_POLYGON)
    if secondary_country_style:
        for lyr in _layers_by_name(project, "Secondary_focus_country"):
            if isinstance(lyr, QgsRasterLayer):
                continue
            apply_selected_style(lyr, secondary_country_style, styles_dict, _GEOM_POLYGON)

    # Focus + Secondary country subsets — same IN(...) pattern.
    _set_layer_subset(project, "Focus_country", focus_country_names, include=True,  field_name="Place_name")
    # Compute the secondary-only set so a country isn't both focus and secondary.
    secondary_only = list(set(secondary_country_names or []) - set(focus_country_names or []))
    _set_layer_subset(project, "Secondary_focus_country", secondary_only, include=True, field_name="Place_name")

    # Province subset uses the same IN(...) helper.
    _set_layer_subset(project, "Focus_province", focus_province_names, include=True, field_name="Place_name")

    # Background countries: exclude every focus + secondary name (NOT IN(...)).
    exclusion_names = set(focus_country_names or []) | set(secondary_country_names or [])
    _set_layer_subset(project, "Countries_and_lakes", list(exclusion_names), include=False, field_name="Place_name")

    # Apply each named style to the user-included preloaded layers.
    for preloaded_name, style_name in included_preloaded_layers.items():
        if not style_name:
            # User left the dropdown at "Select style"; keep current renderer.
            continue
        for layer in _layers_by_name(project, preloaded_name):
            if isinstance(layer, QgsRasterLayer):
                continue
            try:
                geom_type = layer.geometryType()
                apply_selected_style(layer, style_name, styles_dict, geom_type)
                layer.triggerRepaint()
            except Exception as e:
                logger.warning(f"Failed to apply style '{style_name}' to '{layer.name()}': {e}")


# ---- Per-day map-id assignment ----------------------------------------------

def next_map_unique_id(scan_dir: str, date_token: str) -> str:
    """Return the next zero-padded two-digit map-id for ``date_token``.

    Scans ``scan_dir`` for filenames of the form ``YYYYMMDD_NN`` and returns
    the highest ``NN`` + 1. Returns ``"01"`` when ``scan_dir`` doesn't exist or
    contains no matches.
    """
    try:
        highest = 0
        if not os.path.isdir(scan_dir):
            return "01"

        pat = re.compile(rf"{re.escape(date_token)}_(\d{{2}})", re.IGNORECASE)
        for fn in os.listdir(scan_dir):
            m = pat.search(fn)
            if m:
                try:
                    highest = max(highest, int(m.group(1)))
                except Exception:
                    pass

        return str(highest + 1).zfill(2)
    except Exception:
        return "01"



def _wrapped_line_metrics(text, box_width_mm, point_size=8, family="Arial", dpi=300.0):
    """Return (n_lines, line_height_mm) for ``text`` word-wrapped to ``box_width_mm``.

    Width and font metrics are taken from a single off-screen paint device at
    ``dpi`` so the result is DPI-independent (the DPI cancels between the box
    width and the measured text). Used to detect when a metadata label wraps.
    """
    mm_to_px = dpi / 25.4
    img = QImage(8, 8, QImage.Format_ARGB32)
    # Match the device DPI so the point-size font maps to ``dpi`` pixels.
    dots_per_m = int(round(dpi / 0.0254))
    img.setDotsPerMeterX(dots_per_m)
    img.setDotsPerMeterY(dots_per_m)

    f = QFont(family)
    f.setPointSizeF(point_size)
    fm = QFontMetricsF(f, img)
    line_spacing = fm.lineSpacing() or 1.0
    line_height_mm = line_spacing / mm_to_px

    if not text or not str(text).strip():
        return 1, line_height_mm

    box_px = max(1.0, box_width_mm * mm_to_px)
    rect = fm.boundingRect(QRectF(0, 0, box_px, 1e6), Qt.TextWordWrap, str(text))
    n_lines = max(1, int(round(rect.height() / line_spacing)))
    return n_lines, line_height_mm


def apply_layout_metadata_v3(
    layout,
    layout_name: str,
    *,
    map_id: str,
    projection_name: str,
    origin_lon_dd: float,
    origin_lat_dd: float,
    source_text: str,
    # production_note: str = "Map generated by Beaver Interpreter.",
    round_center_to_int: bool = True,
    produced_label_prefix: str = "Produced:",    
    map_item: QgsLayoutItemMap = None,
    note_offset_mm: float = 10.0,
    scale_denominator: Optional[Union[float, int]] = None

):
    """Compose & place the four-line metadata block on Map_layout.

    The block carries four facts (map id, spatial reference + centre,
    production note, data sources), anchored bottom-left. Items are created
    once with stable ``Meta_*`` ids; subsequent calls update text in place.
    """

    # ---- Page geometry --------------------------------------------------------
    page = layout.pageCollection().pages()[0]
    page_size = page.pageSize()
    pw = page_size.width()
    ph = page_size.height()

    # ---- Shared house style (Arial 7 pt, mid-grey, white halo) ---------------
    plain_font = sans_font(7)
    plain_fmt = QgsTextFormat()
    plain_fmt.setFont(plain_font)
    plain_fmt.setSize(7)
    plain_fmt.setColor(QColor(130, 130, 130))

    # Halo gives legibility over any underlying map content.
    plain_fmt.buffer().setEnabled(True)
    plain_fmt.buffer().setSize(0.3)  # points
    plain_fmt.buffer().setColor(QColor(255, 255, 255))

    # Helper that renders the centre coordinates with the degree symbol.
    def deg_text(v):
        return f"{int(round(v))}°" if round_center_to_int else f"{v:.1f}°"
    centre_lon_txt = deg_text(origin_lon_dd)
    centre_lat_txt = deg_text(origin_lat_dd)

    if layout_name == "Map_layout":
        # ---- Map_layout: bottom-left four-line block --------------------------
        # Margins scaled to the 7 pt house style (7/8 of the original 8 pt).
        block_x = 0.8
        line_h  = 1.3
        spacing = 1.6
        # Compute y0 so the four-line stack sits just above the bottom edge.
        y0 = ph - (line_h + spacing*3 + 2.6)

        # The four metadata lines, in display order.
        lines = [
            ("Meta_MapID",    f"PID: {map_id}"),
            ("Meta_SR_line",  f"Projection: {projection_name}, Origin: {centre_lon_txt}, {centre_lat_txt}."),
            ("Meta_Sources",  f"Generated by the Beaver application. Data sources: {source_text}"),
        ]

        # When the bottom Sources note wraps past one line it would otherwise run
        # off the bottom of the page. Detect the wrapped-line count and raise the
        # whole stack (all three labels + the scale bar) by one line height per
        # extra line so the wrapped text stays on the page.
        LABEL_WIDTH_MM = 130.0
        usable_w = LABEL_WIDTH_MM - 2.0  # small allowance for the label's margins
        n_src, line_h_mm = _wrapped_line_metrics(lines[2][1], usable_w, point_size=7)
        shift = max(0, n_src - 1) * line_h_mm

        # Emit/update each metadata label using a stable id so repeat calls
        # update text in place rather than creating duplicates.
        for idx, (item_id, text) in enumerate(lines):
            y = y0 + idx * (line_h + spacing) - shift
            label = layout.itemById(item_id)
            if label is None:
                label = QgsLayoutItemLabel(layout)
                label.setId(item_id)
                layout.addLayoutItem(label)
            label.setText(text)
            label.setTextFormat(plain_fmt)
            label.attemptResize(QgsLayoutSize(LABEL_WIDTH_MM, line_h, _LAYOUT_MM))
            label.attemptMove(QgsLayoutPoint(block_x, y, _LAYOUT_MM))
            label.refresh()

        # Position the scale-bar label just above the metadata block (raised by
        # the same shift so it stays clear of the lifted text stack).
        scale_item = layout.itemById("Scale")
        if scale_item:
            try:
                scale_item.setTextFormat(plain_fmt)
            except Exception:
                pass
            scale_size = scale_item.sizeWithUnits()
            sx = block_x - 1
            sy = y0 - (scale_size.height() - 8.5) - shift
            scale_item.attemptMove(QgsLayoutPoint(sx, sy, _LAYOUT_MM))
            scale_item.refresh()
        else:
            print("[WARNING] 'Scale' item not found in Map_layout.")

        layout.refresh()
        return
