from qgis.core import (
    QgsProject,
    QgsLayoutExporter,
    QgsLayoutItemMap,
    QgsLayoutItemScaleBar,
    QgsLayoutItemLabel,
    QgsLayoutSize,
    QgsLayoutPoint,
    QgsUnitTypes,
    QgsRectangle,
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsGeometry,
    QgsVectorLayer,
    QgsFeature,
    QgsField,
    QgsTextFormat,
    QgsTextBufferSettings,
    QgsPalLayerSettings,
    QgsVectorLayerSimpleLabeling,
    QgsProperty,
    QgsPropertyCollection,
    QgsVectorFileWriter,
    QgsWkbTypes,
    QgsLayoutRenderContext,
    QgsApplication,
    QgsRasterLayer,
    QgsPointXY,
    QgsSingleSymbolRenderer,
    QgsLayerTreeLayer,
    QgsCategorizedSymbolRenderer,
    QgsRenderContext,
    QgsLayoutItemMapGrid,
    QgsLayoutItem,
    QgsRuleBasedRenderer,
    QgsExpression,
    QgsExpressionContext,
    QgsExpressionContextUtils,
    QgsFillSymbol,
    QgsLayoutItemPicture,
    QgsSymbolLayerUtils,
    QgsReadWriteContext,
    Qgis)

from PyQt5.QtGui import QColor, QFont
from PyQt5.QtCore import QVariant, Qt, QRegExp
from PyQt5.QtXml import QDomDocument
from pyproj import Geod

import os, csv
import logging

# ── 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:
    _EXPORT_SUCCESS = QgsLayoutExporter.ExportResult.Success  # 3.38+
except AttributeError:
    _EXPORT_SUCCESS = QgsLayoutExporter.Success               # 3.34
# ── End compatibility shims ───────────────────────────────────────────────────

from collections import defaultdict
import traceback
import math
from datetime import datetime
import re

logger = logging.getLogger(__name__)

from .methods import (sync_map_item_to_gui_visibility,
                         sans_font,
                         discover_basemap_names,
                         hide_legend_if_empty,
                       create_dummy_legend_layer,
                       get_custom_projection,
                       configure_layout_legend,
                       apply_layer_filters,
                       parse_dd,
                       parse_dms,
                       safe_overwrite,
                        apply_selected_style,
                        apply_size_to_symbol,
                        apply_layout_metadata_v3,
                        _label_internal_lat_rings_bottom,
                        compute_main_map_extent_area_m2,
                        position_item,
                        apply_categorized_ramp_to_named_layers,
                        build_categorized_renderer_from_ramp,
                        build_graduated_renderer_from_ramp,
                        next_map_unique_id,
                        apply_export_label_format,
                        format_distance_label,
                        wait_for_render)

from .core_project import core_project, reload_core_project
from .config import (PRELOADED_LAYER_NAMES,
                      PRELOADED_LAYER_SOURCES,
                      QGIS_PROJECT_PATH)


def safe_int_scale(value, default=5_000_000):
    """
    Convert a scale-like value (string/float/int) to a sane positive integer.
    Falls back to 'default' (1:5,000,000 by your spec) if value is NaN/inf/<=0/invalid.
    """
    try:
        f = float(value)
        if math.isnan(f) or math.isinf(f) or f <= 0:
            return int(default)
        return int(round(f))
    except Exception:
        return int(default)

def is_finite_number(x) -> bool:
    try:
        f = float(x)
        return math.isfinite(f)
    except Exception:
        return False

def is_finite_lonlat(lon, lat) -> bool:
    return is_finite_number(lon) and is_finite_number(lat)

def safe_lonlat(lon, lat, fallback=(0.0, 0.0)):
    """
    Returns (lon, lat) if both finite, else returns fallback.
    """
    if is_finite_lonlat(lon, lat):
        return float(lon), float(lat)
    return fallback


# ---- Customised-symbol application ------------------------------------------

def _apply_customized_symbol(layer, xml_string: str) -> bool:
    """Replace ``layer``'s renderer with one driven by the supplied symbol XML.

    The XML is produced by ``SymbolCustomizationButton.serialize()`` in the
    GUI (via :class:`QgsSymbolLayerUtils`). Round-trips through the JSON
    preset format. Returns ``True`` when the symbol was deserialised and
    applied, ``False`` on any failure — so callers can fall back to the
    legacy size-only path.
    """
    if not xml_string or layer is None or isinstance(layer, QgsRasterLayer):
        return False
    try:
        # Re-hydrate the symbol from its XML representation.
        doc = QDomDocument()
        if not doc.setContent(xml_string):
            return False
        sym = QgsSymbolLayerUtils.loadSymbol(doc.documentElement(), QgsReadWriteContext())
        if sym is None:
            return False
        # Replace the renderer wholesale: the Customize button is only
        # available when Categorize is OFF, so single-symbol is the right
        # renderer here.
        layer.setRenderer(QgsSingleSymbolRenderer(sym))
        layer.triggerRepaint()
        return True
    except Exception as e:
        logger.warning(f"_apply_customized_symbol failed on '{getattr(layer, 'name', lambda: '?')()}': {e}")
        return False


def _geodesic_arc(pt1: QgsPointXY, pt2: QgsPointXY, n: int = 100):
    """Return n+1 QgsPointXY tracing a great-circle arc from pt1 to pt2 (WGS84)."""
    import math as _m
    lon1, lat1 = _m.radians(pt1.x()), _m.radians(pt1.y())
    lon2, lat2 = _m.radians(pt2.x()), _m.radians(pt2.y())
    dlat, dlon = lat2 - lat1, lon2 - lon1
    a = _m.sin(dlat / 2) ** 2 + _m.cos(lat1) * _m.cos(lat2) * _m.sin(dlon / 2) ** 2
    d = 2 * _m.asin(_m.sqrt(min(1.0, a)))
    if d < 1e-10:
        return [pt1, pt2]
    pts = []
    for i in range(n + 1):
        f = i / n
        A = _m.sin((1 - f) * d) / _m.sin(d)
        B = _m.sin(f * d) / _m.sin(d)
        x = A * _m.cos(lat1) * _m.cos(lon1) + B * _m.cos(lat2) * _m.cos(lon2)
        y = A * _m.cos(lat1) * _m.sin(lon1) + B * _m.cos(lat2) * _m.sin(lon2)
        z = A * _m.sin(lat1) + B * _m.sin(lat2)
        lat = _m.atan2(z, _m.sqrt(x * x + y * y))
        lon = _m.atan2(y, x)
        pts.append(QgsPointXY(_m.degrees(lon), _m.degrees(lat)))
    return pts


def _resolve_dist_row_points(row_data: dict, project, wgs84):
    """Resolve a dist-row dict to a list of QgsPointXY (WGS84).

    Supports manual lon/lat, CSV (with x_field/y_field), and SHP/KML (OGR).
    Returns an empty list on any failure.
    """
    lon_txt = (row_data.get("lon") or "").strip()
    lat_txt = (row_data.get("lat") or "").strip()
    path    = (row_data.get("path") or "").strip()
    x_field = (row_data.get("x_field") or "").strip()
    y_field = (row_data.get("y_field") or "").strip()

    if lon_txt and lat_txt:
        try:
            lon = parse_dd(lon_txt, is_latitude=False)
            lat = parse_dd(lat_txt, is_latitude=True)
            if lon is not None and lat is not None:
                return [QgsPointXY(lon, lat)]
        except Exception:
            pass
        return []

    if not path or not os.path.exists(path):
        return []

    ext = os.path.splitext(path)[1].lower()
    try:
        if ext == ".csv":
            if not x_field or not y_field:
                return []
            uri = f"file:///{path}?delimiter=,&xField={x_field}&yField={y_field}&crs=EPSG:4326"
            layer = QgsVectorLayer(uri, "_dist_tmp", "delimitedtext")
        else:
            layer = QgsVectorLayer(path, "_dist_tmp", "ogr")
        if not layer.isValid():
            return []
        xform = QgsCoordinateTransform(layer.crs(), wgs84, project)
        pts = []
        for feat in layer.getFeatures():
            g = feat.geometry()
            if not g or g.isEmpty():
                continue
            pt = g.asPoint()
            try:
                pts.append(xform.transform(pt))
            except Exception:
                pass
        return pts
    except Exception as e:
        logger.warning(f"_resolve_dist_row_points failed for '{path}': {e}")
        return []


def export_focus_country_layout(output_pdf_path,
                                focus_country_names=None,
                                secondary_country_names=None,
                                center_coordinates=None, 
                                layout_name='Map_layout',
                                pan_dx_m=None, 
                                pan_dy_m=None,
                                image_width_mm=152.4, 
                                image_height_mm=152.4,
                                export_folder=None, 
                                preloaded_category_fields=None,
                                preloaded_category_schemes=None,
                                preloaded_category_enable=None,
                                map_content="",
                                recipient="",
                                date_str="",
                                custom_points=None,
                                custom_point_sizes=None,
                                custom_sources_text="",
                                uploaded_layer_paths=None,
                                uploaded_layer_styles=None, 
                                custom_point_styles=None, 
                                ring_styles=None, 
                                styles_dict=None,
                                csv_data=None,
                                loc_diag_placement="Bottom right",
                                legend_position="Top left",
                                use_dms=False,
                                csv_format_groups=None,
                                legend_styles=None,
                                legend_labels=None,
                                included_preloaded_layers=None,
                                focus_country_style=None,
                                secondary_country_style=None,
                                scale_denominator=None,
                                Map_frame_scale=None,
                                uploaded_layer_labels=None,
                                base_name=None,
                                map_producer="",
                                map_id=None,
                                uploaded_layer_queries=None,
                                uploaded_layer_label_sizes=None,
                                uploaded_layer_sizes=None,
                                range_ring_sizes=None,
                                csv_layer_sizes=None,
                                focus_province_names=None,
                                focus_province_layer=None,
                                focus_country_layer=None,
                                world_map=False,
                                preloaded_label_toggles=None,
                                center_coordinates_from_focus=None,
                                preloaded_definition_queries=None,
                                preloaded_label_fields=None,
                                preloaded_label_sizes=None,
                                preloaded_layer_sizes=None,
                                uploaded_layer_label_enabled=None,
                                uploaded_category_enable=None,
                                uploaded_category_fields=None,
                                uploaded_category_schemes=None,
                                overlays=None,
                                # ---- Customised-symbol XML (new) -----------
                                # One entry per row whose Customize button was
                                # used. Take precedence over the matching
                                # *_sizes dict when present.
                                preloaded_layer_symbols=None,
                                uploaded_layer_symbols=None,
                                custom_point_symbols=None,
                                range_ring_symbols=None,
                                csv_layer_symbols=None,
                                center_on=None,  # "coords"|"Focus_country"|"Focus_province"|None
                                hex_bin_specs=None,
                                dist_specs=None,
                                ):
    

    # Safely initialize all optional dictionaries with empty dicts
    included_preloaded_layers = included_preloaded_layers or {}
    uploaded_layer_label_sizes = uploaded_layer_label_sizes or {}
    uploaded_layer_queries = uploaded_layer_queries or {}
    uploaded_layer_sizes = uploaded_layer_sizes or {}
    range_ring_sizes = range_ring_sizes or {}
    csv_layer_sizes = csv_layer_sizes or {}
    uploaded_layer_labels = uploaded_layer_labels or {}
    uploaded_layer_styles = uploaded_layer_styles or {}
    custom_point_styles = custom_point_styles or {}
    ring_styles = ring_styles or {}
    custom_point_sizes = custom_point_sizes or {}
    preloaded_label_toggles = preloaded_label_toggles or {}
    preloaded_category_fields = preloaded_category_fields or {}
    preloaded_category_schemes   = preloaded_category_schemes or {}
    preloaded_category_enable   = preloaded_category_enable or {}
    preloaded_definition_queries = preloaded_definition_queries or {}
    preloaded_label_fields = preloaded_label_fields or {}
    preloaded_label_sizes = preloaded_label_sizes or {}
    preloaded_layer_sizes = preloaded_layer_sizes or {}
    uploaded_layer_label_enabled = uploaded_layer_label_enabled or {}
    uploaded_category_enable = uploaded_category_enable or {}
    uploaded_category_fields = uploaded_category_fields or {}
    uploaded_category_schemes = uploaded_category_schemes or {}
    # Customised-symbol XML dicts (new): take precedence over *_sizes.
    preloaded_layer_symbols = preloaded_layer_symbols or {}
    uploaded_layer_symbols = uploaded_layer_symbols or {}
    custom_point_symbols = custom_point_symbols or {}
    range_ring_symbols = range_ring_symbols or {}
    csv_layer_symbols = csv_layer_symbols or {}

    overlays = overlays or []
    legend_layers = []

    halo = QgsTextBufferSettings()
    halo.setEnabled(True)
    halo.setColor(QColor(229, 225, 219))
    halo.setSize(0.25)

    # Fresh state for this build — operates on the PRIVATE project only (the
    # user's open QGIS project is never read, cleared, or written).
    project = reload_core_project()

    layout_manager = project.layoutManager()
    layout = layout_manager.layoutByName(layout_name)

    # Resize layout and map item
    map_item = next((item for item in layout.items() if isinstance(item, QgsLayoutItemMap)), None)
    if not map_item:
        raise Exception("No map item found in the layout.")

    # --- World map override (projection + full world extent; ignore lon/lat) -------
    if world_map:
        # Ignore Focus lon/lat; keep origin fixed (0,0) for metadata, or set to None for "N/A"
        origin_lon_dd = 0.0
        origin_lat_dd = 0.0

        # CRS = EPSG:4326 and full world extent
        wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
        map_item.setCrs(wgs84)
        core_project().setCrs(wgs84)

        world_extent = QgsRectangle(-180.0, -90.0, 180.0, 90.0)
        map_item.setExtent(world_extent)
        map_item.setKeepLayerSet(True)
        map_item.refresh()
        layout.refresh()

        # Remove scale text (global map has no fixed scale)
        for _id in ["Scale", "Scale_text"]:
            item = layout.itemById(_id)
            if item:
                layout.removeLayoutItem(item)

        # --- Prepare variables for downstream code (so no AOI buffer runs) ---
        buffered_extent  = world_extent            # used in extent fitting
        custom_crs       = wgs84                   # used for reprojection/saving
        lon, lat         = origin_lon_dd, origin_lat_dd
        projection_name  = "WGS 84 (EPSG:4326)"
        center_projected = buffered_extent.center()  # safe placeholder point in map CRS




    dummy_legend_layer = None
    if legend_styles and legend_labels and styles_dict:
        dummy_legend_layer = create_dummy_legend_layer(legend_styles, legend_labels, styles_dict)
        print(f"{dummy_legend_layer.featureCount()} FEATURES USED IN LEGEND.")
        max_size_mm = 11.0  # Maximum size in millimeters
        renderer = dummy_legend_layer.renderer()
        for symbol in renderer.symbols(QgsRenderContext()):
            for i in range(symbol.symbolLayerCount()):
                layer = symbol.symbolLayer(i)
                if hasattr(layer, "size") and hasattr(layer, "setSize"):
                    current_size = layer.size()
                    if current_size > max_size_mm:
                        layer.setSize(max_size_mm)
        dummy_legend_layer.setRenderer(renderer)
    else:
        logger.debug(f"No legend entries provided; legend will be hidden.")

    if isinstance(focus_country_names, str):
        focus_country_names = [focus_country_names]
    elif not isinstance(focus_country_names, list):
        raise ValueError("focus_country_names must be a list of strings.")

    focus_layer = next((lyr for lyr in project.mapLayers().values() if lyr.name() == 'Focus_country'), None)
    if not focus_layer:
        raise Exception("Layer 'Focus_country' not found in the project.")

    geom = None
    matched_features = []

    if not world_map:
        # Resolve which centering path to use.
        # center_on is set by the GUI when the user has explicitly chosen a focus row.
        # When None (e.g. old presets), fall back to the original priority order.
        if center_on == "coords" or (center_on is None and center_coordinates):
            # Treat exactly like the old "if center_coordinates:" path.
            pass  # the block below will fire because center_coordinates is set.
        elif center_on == "Focus_province" and not focus_province_names:
            raise Exception("Map centre is set to 'Province(s) of interest' but no provinces are selected.")
        elif center_on == "Focus_country" and not focus_country_names:
            raise Exception("Map centre is set to 'Country(s) of interest' but no countries are selected.")

        # When the user chose province or country as the centre, clear center_coordinates
        # so the old priority block routes correctly into the province/country path.
        if center_on in ("Focus_province", "Focus_country"):
            center_coordinates = None

        if center_coordinates:
            lon, lat = center_coordinates
            print(f"Using custom coordinates: {lon}, {lat}")
            # Ensure lon/lat are finite before any integer math (e.g., UTM zone)
            fallback_center = (0.0, 0.0)  # choose a better fallback if you prefer
            lon, lat = safe_lonlat(lon, lat, fallback=fallback_center)

            if not is_finite_lonlat(lon, lat):
                print("ERROR: Center lon/lat are invalid after fallback; aborting province update.")
                return  # or set a default CRS/extent and continue depending on your UX preference
            
            point_geom = QgsGeometry.fromPointXY(QgsPointXY(lon, lat))

            if not point_geom or point_geom.isEmpty():
                raise Exception("Generated point geometry is empty.")
            proj_string = get_custom_projection(lon, lat, scale_denominator, force_lcc=False)
            print(f"PROJECTION PROPERTIES: {proj_string}")

            # Robust CRS creation (instance method returns bool)
            custom_crs = QgsCoordinateReferenceSystem()
            ok = custom_crs.createFromProj(proj_string)
            if not ok or not custom_crs.isValid():
                raise Exception(f"Failed to create CRS from PROJ: {proj_string}")

            # Apply to the current map item (this may be replaced later by Map_frame)
            map_item.setCrs(custom_crs)
            project.setCrs(custom_crs)


            wgs84_crs = QgsCoordinateReferenceSystem("EPSG:4326")

            # Attempt to transform; if it fails, keep point in WGS84 and log a warning.
            try:
                transform = QgsCoordinateTransform(wgs84_crs, custom_crs, project)
                point_geom.transform(transform)
                geom = point_geom
                center_projected = point_geom.asPoint()
            except Exception as e:
                print(f"AOI CENTRE FAILED TO TRANSFORM: {e}. FALLING BACK TO WGS84 CENTRE.")
                geom = QgsGeometry.fromPointXY(QgsPointXY(lon, lat))  # still WGS84
                center_projected = QgsPointXY(lon, lat)               # set


            # # --- PAN (KM) for center_coordinates path: shift the center in map CRS by km → meters ---
            # try:
            #     dx_km = float(lon_shift_dd or 0.0)  # treat as KM
            #     dy_km = float(lat_shift_dd or 0.0)  # treat as KM
            # except Exception:
            #     dx_km, dy_km = 0.0, 0.0

            # if dx_km != 0.0 or dy_km != 0.0:
            #     # Work in the map CRS (meters) — center_projected is already in custom_crs
            #     dx_m = dx_km * 1000.0
            #     dy_m = dy_km * 1000.0
            #     cx, cy = center_projected.x(), center_projected.y()
            #     center_projected = QgsPointXY(cx + dx_m, cy + dy_m)
            #     # Leave lon/lat metadata as original focus unless you want origin to reflect pan

            if scale_denominator:
                # Use user-specified scale to size the rectangle
                width_m  = (image_width_mm  / 1000.0) * float(scale_denominator)
                height_m = (image_height_mm / 1000.0) * float(scale_denominator)
            else:
                default_scale = 10_000_000.0
                width_m  = (image_width_mm  / 1000.0) * default_scale
                height_m = (image_height_mm / 1000.0) * default_scale

            cx, cy = center_projected.x(), center_projected.y()
            combined_extent = QgsRectangle(
                cx - width_m / 2.0,
                cy - height_m / 2.0,
                cx + width_m / 2.0,
                cy + height_m / 2.0
            )
            
            # Suppose combined_extent is a QgsRectangle from your province selection
            if (combined_extent is None
                or not math.isfinite(combined_extent.xMinimum())
                or not math.isfinite(combined_extent.yMinimum())
                or not math.isfinite(combined_extent.xMaximum())
                or not math.isfinite(combined_extent.yMaximum())
                or combined_extent.width() <= 0
                or combined_extent.height() <= 0):
                logger.error(f"Focus province produced an empty/invalid extent. Aborting update.")
                return

            adjusted_center = QgsPointXY(lon, lat)
            buffered_extent = combined_extent

        else:
            focus_province_layer = next((lyr for lyr in project.mapLayers().values() if lyr.name() == 'Focus_province'), None)
            focus_country_layer = next((lyr for lyr in project.mapLayers().values() if lyr.name() == 'Focus_country'), None)

            geom = None
            matched_features = []

            def process_focus_union(layer_name, names, project):
                """
                Subset ALL layers named `layer_name` by Place_name, union their features,
                derive centroid and CRS.
                """
                if not names:
                    raise Exception(f"No {layer_name} names provided.")
                quoted = ', '.join(f"'{n}'" for n in names)
                targets = [lyr for lyr in project.mapLayers().values() if lyr.name() == layer_name]
                features_all = []
                for layer in targets:
                    # Only subset if the field exists
                    if "Place_name" in [f.name() for f in layer.fields()]:
                        layer.setSubsetString(f'"Place_name" IN ({quoted})')
                    feats = list(layer.getFeatures())
                    features_all.extend(feats)

                if not features_all:
                    raise Exception(f"No matching features found in {layer_name} (union across groups).")

                geom = QgsGeometry.unaryUnion([f.geometry() for f in features_all])
                if not geom or geom.isEmpty():
                    raise Exception("Geometry is empty. Cannot set extent.")

                centroid = geom.centroid().asPoint()
                lon, lat = centroid.x(), centroid.y()
                proj_string = get_custom_projection(lon, lat, None)
                custom_crs = QgsCoordinateReferenceSystem()
                ok = custom_crs.createFromProj(proj_string)
                if not ok or not custom_crs.isValid():
                    raise Exception(f"Failed to create CRS from PROJ: {proj_string}")

                map_item.setCrs(custom_crs)
                project.setCrs(custom_crs)

                # Try to transform into the custom CRS using the first instance's CRS
                try:
                    src_crs = targets[0].crs()
                    transform = QgsCoordinateTransform(src_crs, custom_crs, project)
                    geom.transform(transform)
                except Exception as e:
                    logger.warning(f"Focus geometry transform failed: {e}. Using untransformed geometry for extent.")

                return geom, lon, lat, custom_crs

            def compute_combined_extent(layer_name, names, project, field_name="Place_name"):
                """
                Subset ALL layers named `layer_name` by `field_name` IN (names),
                then compute a combined extent across the matching layers.
                Returns: (combined_extent: QgsRectangle, centroid_lon: float, centroid_lat: float, custom_crs: QgsCoordinateReferenceSystem)

                - No geometry union is performed.
                - The centroid is computed from the combined extent (center point), not polygon centroid.
                """
                if not names:
                    raise Exception(f"No {layer_name} names provided.")

                # Build quoted list once
                quoted = ', '.join(f"'{n}'" for n in names)

                # Collect targets by name
                targets = [lyr for lyr in project.mapLayers().values() if lyr.name() == layer_name]
                if not targets:
                    raise Exception(f"No layers found with name '{layer_name}'.")

                # Subset + accumulate extent
                combined_extent = None
                for layer in targets:
                    # Only subset if field exists on this instance
                    field_names = [f.name() for f in layer.fields()]
                    if field_name in field_names:
                        layer.setSubsetString(f'"{field_name}" IN ({quoted})')
                    else:
                        # If field missing, skip this instance (it contributes nothing)
                        logger.warning(f"compute_combined_extent: '{layer.name()}' lacks field '{field_name}'. Skipping.")
                        continue

                    # If no matching features, skip contribution
                    if layer.featureCount() == 0:
                        logger.warning(f"compute_combined_extent: '{layer.name()}' has 0 features after subset.")
                        continue

                    ext = layer.extent()  # QgsRectangle
                    if not ext or not ext.isFinite():
                        logger.warning(f"compute_combined_extent: '{layer.name()}' extent invalid.")
                        continue

                    if combined_extent is None:
                        # Make a copy so we can safely mutate
                        combined_extent = QgsRectangle(ext)  # copy constructor
                    else:
                        # Mutates in place; combines with incoming bbox
                        combined_extent.combineExtentWith(ext)

                if combined_extent is None or not combined_extent.isFinite():
                    raise Exception(f"Combined extent is invalid for '{layer_name}' with names={names}.")

                # Compute center in source CRS (it’s just a rectangle center)
                center = combined_extent.center()
                lon, lat = center.x(), center.y()

                # Decide projection centered on lon/lat (same decision path you had)
                proj_string = get_custom_projection(lon, lat, None)
                custom_crs = QgsCoordinateReferenceSystem()
                ok = custom_crs.createFromProj(proj_string)
                if not ok or not custom_crs.isValid():
                    raise Exception(f"Failed to create CRS from PROJ: {proj_string}")

                # Apply CRS to map/project
                map_item.setCrs(custom_crs)
                project.setCrs(custom_crs)

                src_crs = None
                for lyr in targets:
                    # pick the first instance that contributed to the extent (has field and featureCount>0)
                    fnames = [f.name() for f in lyr.fields()]
                    if field_name in fnames and lyr.featureCount() > 0:
                        src_crs = lyr.crs()
                        break
                if src_crs is None:
                    # fall back to project CRS if nothing matched
                    src_crs = project.crs()

                xform = QgsCoordinateTransform(src_crs, custom_crs, project)
                combined_extent_proj = xform.transformBoundingBox(combined_extent)

                combined_extent = combined_extent_proj

                return combined_extent, lon, lat, custom_crs

            # Determine which layer to zoom to, respecting the user’s center_on choice.
            # When center_on is None (old presets), keep the original priority: province > country.
            _use_province = (
                (center_on == "Focus_province" and focus_province_names) or
                (center_on is None and focus_province_names)
            )
            _use_country = (
                (center_on == "Focus_country" and focus_country_names) or
                (center_on is None and not focus_province_names and focus_country_names)
            )

            if _use_province:
                geom_union, lon, lat, custom_crs = process_focus_union("Focus_province", focus_province_names, project)
                map_item.setCrs(custom_crs)
                project.setCrs(custom_crs)

                # In custom CRS, derive the AOI extent from the union geometry’s bbox
                try:
                    # geom_union is already in custom_crs (process_focus_union transforms it)
                    combined_extent = geom_union.boundingBox()
                except Exception as e:
                    logger.warning(f"Union bbox failed: {e}. Falling back to compute_combined_extent.")
                    combined_extent, lon, lat, custom_crs = compute_combined_extent("Focus_province", focus_province_names, project, field_name="Place_name")
            elif _use_country:
                # Robust center for very large/multipart countries (Russia, USA, etc.)
                geom_union, lon, lat, custom_crs = process_focus_union("Focus_country", focus_country_names, project)

                # Apply CRS to map/project
                map_item.setCrs(custom_crs)
                project.setCrs(custom_crs)

                # In custom CRS, derive the AOI extent from the union geometry’s bbox
                try:
                    # geom_union is already in custom_crs (process_focus_union transforms it)
                    combined_extent = geom_union.boundingBox()
                except Exception as e:
                    logger.warning(f"Union bbox failed: {e}. Falling back to compute_combined_extent.")
                    combined_extent, lon, lat, custom_crs = compute_combined_extent("Focus_country", focus_country_names, project, field_name="Place_name")

            else:
                raise Exception("Please specify focus coordinates, and/or "
                                "focus country(s) and/or focus province(s).")

        ext = QgsRectangle(combined_extent)  # copy to avoid mutating original accidentally
        expand_x = ext.width() * 0.02  # 5% margin horizontally
        expand_y = ext.height() * 0.02  # 5% margin vertically

        # Guard against zero-size extents (degenerate case)
        if ext.width() <= 0 or ext.height() <= 0:
            # Fallback to a fixed margin (in projected meters), e.g., 10 km
            fixed = 1000.0
            ext.setXMinimum(ext.xMinimum() - fixed)
            ext.setXMaximum(ext.xMaximum() + fixed)
            ext.setYMinimum(ext.yMinimum() - fixed)
            ext.setYMaximum(ext.yMaximum() + fixed)
        else:
            ext.setXMinimum(ext.xMinimum() - expand_x)
            ext.setXMaximum(ext.xMaximum() + expand_x)
            ext.setYMinimum(ext.yMinimum() - expand_y)
            ext.setYMaximum(ext.yMaximum() + expand_y)

        buffered_extent = ext 


        # # --- PAN (KM) for focus-by-layer path: translate the buffered AOI extent by km → meters ---
        # try:
        #     dx_km = float(lon_shift_dd or 0.0)  # treat as KM
        #     dy_km = float(lat_shift_dd or 0.0)  # treat as KM
        # except Exception:
        #     dx_km, dy_km = 0.0, 0.0

        # # Avoid double-pan: only translate here when NOT using center_coordinates
        # if (dx_km != 0.0 or dy_km != 0.0) and not center_coordinates:
        #     dx_m = dx_km * 1000.0
        #     dy_m = dy_km * 1000.0
        #     buffered_extent.setXMinimum(buffered_extent.xMinimum() + dx_m)
        #     buffered_extent.setXMaximum(buffered_extent.xMaximum() + dx_m)
        #     buffered_extent.setYMinimum(buffered_extent.yMinimum() + dy_m)
        #     buffered_extent.setYMaximum(buffered_extent.yMaximum() + dy_m)

        # Optional: recompute metadata lon/lat from the shifted center
        center_projected = buffered_extent.center()
        try:
            wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
            xform_back = QgsCoordinateTransform(custom_crs, wgs84, project)
            center_ll = xform_back.transform(center_projected)  # QgsPointXY in degrees
            lon, lat = center_ll.x(), center_ll.y()
        except Exception as e:
            logger.warning(f"Metadata origin transform failed: {e}. Keeping original lon/lat.")

    root = project.layerTreeRoot()
    focus_group = root.findGroup('Focus_layers')


    apply_layer_filters(
        project=project,
        focus_country_names=focus_country_names,
        secondary_country_names=secondary_country_names,
        included_preloaded_layers=included_preloaded_layers,
        styles_dict=styles_dict,
        focus_country_style=focus_country_style,
        secondary_country_style=secondary_country_style,
        focus_province_names=focus_province_names   # <-- now harmonized
    )

    # --- Visibility & styling for preloaded layers ---
    def _layer_by_name(name: str):
        return next((lyr for lyr in project.mapLayers().values() if lyr.name() == name), None)


    map_item.setKeepLayerSet(False)

    if not world_map:
        try:
            zone = int((float(lon) + 180.0) / 6.0) + 1
        except Exception:
            # If lon somehow still invalid, force a sane default
            zone = 31  # default to a valid UTM zone (e.g., 31)
        if scale_denominator is not None and scale_denominator < 1000000:
            projection_name = f"UTM Zone {zone}"
        elif abs(lat) > 60:
            projection_name = "Orthographic"
        else:
            projection_name = "Lambert C.C."

    # aspect fit
    layout_aspect   = image_width_mm / image_height_mm
    extent_width    = buffered_extent.width()
    extent_height   = buffered_extent.height()
    extent_aspect   = extent_width / extent_height

    # World map ignores Focus center; always use buffered_extent.center()
    center = buffered_extent.center() if world_map else (center_projected if center_coordinates else buffered_extent.center())
    
    if extent_aspect > layout_aspect:
        # Too wide - adjust height
        new_height = extent_width / layout_aspect
        buffered_extent.setYMinimum(center.y() - new_height / 2)
        buffered_extent.setYMaximum(center.y() + new_height / 2)
    else:
        # Too tall - adjust width
        new_width = extent_height * layout_aspect
        buffered_extent.setXMinimum(center.x() - new_width / 2)
        buffered_extent.setXMaximum(center.x() + new_width / 2)

    map_item.setFrameEnabled(False)  # Optional: remove any border
    map_item.setBackgroundEnabled(False)  # Optional: remove background
    map_item.refresh()
    layout.refresh()
    layout.renderContext().setFlag(QgsLayoutRenderContext.FlagForceVectorOutput, True)

    map_item.setExtent(buffered_extent)

    # raw_map_item_scale = int(round(map_item.scale()))
    raw_map_item_scale = safe_int_scale(map_item.scale(), default=5_000_000)

    if not buffered_extent.isFinite():
        raise Exception("Buffered extent is invalid.")

    # Determine map scale
    item = layout.itemById("Map_frame_scale_denom")
    # Map_frame_scale = int(item.currentText())
    Map_frame_scale = safe_int_scale(item.currentText(), default=raw_map_item_scale)


    def _safely_apply_size_to_layer(layer, size_mm):
        """
        Resize symbols for common renderer types WITHOUT replacing the renderer:
        - Categorized: update each category's symbol
        - Rule-based: update each rule's symbol (recursively)
        - Single symbol: update the renderer's symbol
        - Renderers exposing symbols(context): update all returned symbols

        Uses apply_size_to_symbol(...) from methods.py to set:
        Point    -> marker size
        Line     -> stroke width
        Polygon  -> outline (stroke/border) width
        """
        if not layer or isinstance(layer, QgsRasterLayer):
            return
        r = layer.renderer()
        if not r:
            return

        gt = layer.geometryType()
        try:
            # Categorized renderer
            if isinstance(r, QgsCategorizedSymbolRenderer):
                for cat in r.categories():
                    sym = cat.symbol()
                    if sym:
                        apply_size_to_symbol(sym, gt, float(size_mm))
                layer.triggerRepaint()

            # Rule-based renderer
            elif isinstance(r, QgsRuleBasedRenderer):
                def _walk(rule):
                    sym = rule.symbol()
                    if sym:
                        apply_size_to_symbol(sym, gt, float(size_mm))
                    for child in rule.children():
                        _walk(child)
                _walk(r.rootRule())
                layer.triggerRepaint()

            # Renderers exposing symbols(context)
            elif hasattr(r, "symbols"):
                try:
                    syms = r.symbols(QgsRenderContext())
                except Exception:
                    syms = []
                for sym in syms or []:
                    apply_size_to_symbol(sym, gt, float(size_mm))
                layer.triggerRepaint()

            # Single-symbol style
            elif hasattr(r, "symbol"):
                try:
                    sym = r.symbol()
                except Exception:
                    sym = None
                if sym:
                    apply_size_to_symbol(sym, gt, float(size_mm))
                    layer.triggerRepaint()
        except Exception as e:
            logger.warning(f"size update failed on '{layer.name()}': {e}")


    def _apply_style_size_filter_label(
        layer,
        base_name,
        preloaded_layer_sizes,
        preloaded_definition_queries,
        preloaded_label_toggles,
        preloaded_label_fields,
        preloaded_label_sizes,
        *,
        preloaded_category_enable=None,
        preloaded_category_fields=None,
        preloaded_category_schemes=None,
        project=None,
        map_item=None
    ):
        """
        One consolidated path for preloaded layers:
        1) Categorize (if enabled + field exists)
        2) Size (renderer-aware)
        3) Filter (subset)
        4) Labels (toggle + optional field/size, preserve styling)

        Pass map_item explicitly to avoid scope issues. 'project' used for ramps.
        """
        if not layer or isinstance(layer, QgsRasterLayer):
            return

        # --- CATEGORIZED (optional) ---
        try:
            if (preloaded_category_enable and
                bool(preloaded_category_enable.get(base_name, False)) and
                preloaded_category_fields and preloaded_category_schemes and
                project is not None):

                field  = preloaded_category_fields.get(base_name, None)
                scheme = preloaded_category_schemes.get(base_name, None)

                if field and scheme and field in [f.name() for f in layer.fields()]:
                    renderer = build_categorized_renderer_from_ramp(layer, field, scheme, limit=None)
                    if renderer:
                        layer.setRenderer(renderer)
                        layer.triggerRepaint()
                        logger.info(f"Preloaded '{layer.name()}' categorized by '{field}' with ramp '{scheme}'.")
        except Exception as e:
            logger.warning(f"categorize failed on '{layer.name()}': {e}")

        # --- CUSTOMISED SYMBOL (preferred) or SIZE-ONLY FALLBACK ---
        # The Customize button writes full symbol XML into
        # preloaded_layer_symbols. When present, install it wholesale and
        # skip the legacy size scalar. Older callers (or rows where the
        # button wasn't touched) fall through to the size-only path.
        symbol_xml = preloaded_layer_symbols.get(base_name, "") if preloaded_layer_symbols else ""
        if symbol_xml and _apply_customized_symbol(layer, symbol_xml):
            pass  # symbol applied; size scalar intentionally ignored
        else:
            sz = preloaded_layer_sizes.get(base_name, None)
            if sz is not None:
                _safely_apply_size_to_layer(layer, float(sz))

        # --- FILTER QUERY ---
        qtext = (preloaded_definition_queries.get(base_name, "") or "").strip()
        if qtext:
            layer.setSubsetString(qtext)

        # --- LABELING (toggle + field + size_pt, preserve styling) ---
        label_enabled = bool(preloaded_label_toggles.get(base_name, False))
        field = preloaded_label_fields.get(base_name, None)
        size_pt = preloaded_label_sizes.get(base_name, None)  # None -> leave size unchanged

        if label_enabled:
            _update_label_field_and_size(
                layer,
                new_field=(field if field in [f.name() for f in layer.fields()] else None),
                new_size_pt=size_pt,
            )
        else:
            layer.setLabelsEnabled(False)
            layer.triggerRepaint()

        # Ensure the layout map reflects layer-tree changes
        if map_item:
            map_item.setKeepLayerSet(False)

    # --- Helper: update only label field & size, preserve existing formatting ---
    def _update_label_field_and_size(layer, new_field=None, new_size_pt=None):
        """
        Updates label field and/or text size on 'layer' while preserving all existing
        label styling (color, halo/buffer, opacity, placement, priority, DDP, etc.).
        If the layer has no labeling yet, we create minimal settings, but we DO NOT
        overwrite styling when labeling already exists.
        """
        if layer is None or isinstance(layer, QgsRasterLayer):
            return

        # Try to read current SimpleLabeling settings
        existing_settings = None
        current_labeling = layer.labeling()
        if current_labeling and isinstance(current_labeling, QgsVectorLayerSimpleLabeling):
            try:
                # Make a copy of Pal settings
                existing_settings = QgsPalLayerSettings(current_labeling.settings())
            except Exception:
                existing_settings = None

        # If nothing exists, make minimal settings (only for first-time labeling)
        if existing_settings is None:
            s = QgsPalLayerSettings()
            gt = layer.geometryType()
            if gt == _GEOM_POINT:
                s.placement = QgsPalLayerSettings.Placement.AroundPoint
            elif gt == _GEOM_LINE:
                s.placement = QgsPalLayerSettings.Placement.Line
            else:
                s.placement = QgsPalLayerSettings.Placement.Free
            s.enabled = True
            s.displayAll = True
            s.obstacle = False
            s.priority = 10
        else:
            s = existing_settings  # preserve everything already set

        # Update only the label field, if supplied and exists on the layer
        if isinstance(new_field, str) and new_field and new_field in [f.name() for f in layer.fields()]:
            s.fieldName = new_field

        # Update only the text size, if supplied
        if new_size_pt is not None:
            tf = s.format()
            if not isinstance(tf, QgsTextFormat):
                tf = QgsTextFormat()
            tf.setSize(float(new_size_pt))  # preserve color/halo/etc.
            s.setFormat(tf)

        # Apply back without touching other properties
        layer.setLabeling(QgsVectorLayerSimpleLabeling(s))
        layer.setLabelsEnabled(True)
        layer.triggerRepaint()

    actual_map_scale = safe_int_scale(map_item.scale(), default=5_000_000)

    if layout_name == "Map_layout":
        page = layout.pageCollection().pages()[0]
        page.setPageSize(QgsLayoutSize(image_width_mm, image_height_mm, _LAYOUT_MM))

        # Ensure the map frame box exactly matches the desired image dimensions
        map_item.setFrameEnabled(False)
        map_item.setBackgroundEnabled(False)
        map_item.attemptMove(QgsLayoutPoint(0, 0, _LAYOUT_MM))
        map_item.attemptResize(QgsLayoutSize(image_width_mm, image_height_mm, _LAYOUT_MM))

        # Start from the buffered AOI
        map_item.setExtent(buffered_extent)

        # ENFORCE manual scale ON THE MAP FRAME
        if scale_denominator:
            map_width_m  = image_width_mm  / 1000.0
            map_height_m = image_height_mm / 1000.0
            center = buffered_extent.center() if world_map else (center_projected if center_coordinates else buffered_extent.center())
            map_extent_width  = map_width_m  * scale_denominator
            map_extent_height = map_height_m * scale_denominator
            new_extent = QgsRectangle(
                center.x() - map_extent_width  / 2,
                center.y() - map_extent_height / 2,
                center.x() + map_extent_width  / 2,
                center.y() + map_extent_height / 2
            )
            map_item.setExtent(new_extent)
            map_item.setScale(scale_denominator)

        if pan_dx_m or pan_dy_m:
            ext = map_item.extent()
            if ext and ext.isFinite():
                map_item.setExtent(
                    QgsRectangle(
                        ext.xMinimum() + pan_dx_m,
                        ext.yMinimum() + pan_dy_m,
                        ext.xMaximum() + pan_dx_m,
                        ext.yMaximum() + pan_dy_m,
                    )
                )


        layout.renderContext().setFlag(QgsLayoutRenderContext.FlagForceVectorOutput, True)

        # Ensure changes in the layer tree are reflected in the map item
        map_item.setKeepLayerSet(False)

        map_item.attemptMove(QgsLayoutPoint(0, 0, _LAYOUT_MM))
        map_item.attemptResize(QgsLayoutSize(image_width_mm, image_height_mm, _LAYOUT_MM))

        raw_map_item_scale = safe_int_scale(map_item.scale(), default=5_000_000)

        item = layout.itemById("Map_frame_scale_denom")
        Map_frame_scale = safe_int_scale(item.currentText(), default=raw_map_item_scale)

        # One instance per preloaded layer; toggle its visibility and style.
        for base_name in PRELOADED_LAYER_NAMES:
            layer = _layer_by_name(base_name)
            node = focus_group.findLayer(layer.id()) if (focus_group and layer) else None
            visible = (base_name in included_preloaded_layers)

            if node:
                node.setItemVisibilityChecked(visible)

            if visible:
                _apply_style_size_filter_label(
                    layer, base_name,
                    preloaded_layer_sizes,
                    preloaded_definition_queries,
                    preloaded_label_toggles,
                    preloaded_label_fields,
                    preloaded_label_sizes
                )

            try:
                if preloaded_category_enable.get(base_name, False):
                    field = preloaded_category_fields.get(base_name)
                    scheme = preloaded_category_schemes.get(base_name)
                    if field and scheme:
                        apply_categorized_ramp_to_named_layers(core_project(), base_name, field, scheme, limit=None)
            except Exception as e:
                logger.warning(f"Categorized-ramp application failed on '{base_name}': {e}")

        if legend_position in ("Top right", "Bottom left"):
            logger.info(f"Legend position '{legend_position}' is not available in Supplemental graphic mode. Using 'Bottom right'.")
            legend_position = "Bottom right"

        # Configure legend only if there are entries, otherwise hide
        if dummy_legend_layer and dummy_legend_layer.featureCount() > 0:
            legend_item = configure_layout_legend(layout, map_item, dummy_legend_layer, legend_position=legend_position)
        else:
            hide_legend_if_empty(layout)

        # Beaver processor: classification is not used — blank the legacy CLASS
        # banner item so no placeholder text is printed on the product.
        try:
            top_right = layout.itemById("CLASS")
            if top_right:
                top_right.setText("")
                top_right.refresh()
        except Exception:
            pass

    item = layout.itemById("Map_frame_scale_denom")

    map_item.setKeepLayerSet(False)

    # Configure legend only if there are entries, otherwise hide
    if dummy_legend_layer and dummy_legend_layer.featureCount() > 0:
        legend_item = configure_layout_legend(layout, map_item, dummy_legend_layer, legend_position=legend_position)
        logger.debug("Legend configured.")
    else:
        hide_legend_if_empty(layout)

    print(f'CALCULATED SCALE 1:{Map_frame_scale}. ACTIVATING LAYOUT FORMATTING.')

    # 1) Gather sources from the preloaded layers that are CHECKED in the GUI
    auto_sources = []
    for lname in (included_preloaded_layers or {}).keys():
        src = PRELOADED_LAYER_SOURCES.get(lname)
        if src:
            auto_sources.append(src)

    # 2) De-duplicate while preserving order
    seen = set()
    auto_sources_unique = []
    for s in auto_sources:
        key = s.strip()
        if key and key not in seen:
            seen.add(key)
            auto_sources_unique.append(key)

    if custom_sources_text:
        auto_sources_unique.append(custom_sources_text.strip())

    # 4) Final string (use "; " as a soft separator and ensure trailing period if missing)
    def _ensure_period(s: str) -> str:
        s = s.strip()
        return s

    source = " ".join(_ensure_period(s) for s in auto_sources_unique if s)

    # Fallback in case nothing produced anything (rare: if user left everything unchecked)
    if not source:
        source = " "  # keep metadata line, but empty content

    map_item = layout.itemById("Map_frame")

    project = core_project()

    for base_name, labels_enabled in (preloaded_label_toggles or {}).items():
        for layer in project.mapLayers().values():
            if isinstance(layer, QgsRasterLayer):
                continue

            # Match layers by name
            if layer.name() != base_name:
                continue

            if not labels_enabled:
                layer.setLabelsEnabled(False)


    sync_map_item_to_gui_visibility(
        map_item,
        basemap_names=discover_basemap_names(project)
    )


    # --- Map ID: GUI-supplied, else date + unique probe (no producer codes) ----
    existing_map_id = str(locals().get("map_id", "") or "").strip()

    if not existing_map_id:
        # Build a fallback map_id if GUI didn't supply one. Use the export
        # folder as a last-resort probe location (the GUI normally supplies the
        # id at BUILD).
        date_token = datetime.now().strftime("%Y%m%d")
        try:
            unique_id = next_map_unique_id(export_folder, date_token)
        except Exception:
            unique_id = "01"
        map_id = f"{date_token}_{unique_id}"   # e.g., 20260224_01
    else:
        map_id = existing_map_id

    apply_layout_metadata_v3(
        layout,
        layout.name(),         # always "Map_layout"
        map_id=map_id,
        projection_name=projection_name,
        origin_lon_dd=lon,
        origin_lat_dd=lat,
        source_text=source,
        map_item=map_item,
        scale_denominator=Map_frame_scale,
    )

    for i, (lon, lat, label, radius_km) in enumerate(custom_points or []):

        # Create range ring if radius is valid
        if radius_km and radius_km > 0:
            # ring_layer = QgsVectorLayer(f"Polygon?crs={custom_crs.authid()}", "Range_rings", "memory")

            safe_label = (label or "").strip().replace(":", "_").replace("/", "_").replace("\\", "_")
            ring_layer_name = f"Range_ring_{i+1:02d}" + (f"_{safe_label}" if safe_label else "")
            ring_layer = QgsVectorLayer(f"Polygon?crs={custom_crs.authid()}", ring_layer_name, "memory")

            ring_provider = ring_layer.dataProvider()
            ring_provider.addAttributes([QgsField("LABEL", QVariant.String)])
            ring_layer.updateFields()

            # Transform center point to custom CRS
            wgs84_crs = QgsCoordinateReferenceSystem("EPSG:4326")
            transform = QgsCoordinateTransform(wgs84_crs, custom_crs, core_project())
            center_geom = QgsGeometry.fromPointXY(QgsPointXY(lon, lat))
            center_geom.transform(transform)

            geod = Geod(ellps="WGS84")
            num_points = 64
            azimuths = [j * (360.0 / num_points) for j in range(num_points)]
            circle_points = []
            for az in azimuths:
                lon_pt, lat_pt, _ = geod.fwd(float(lon), float(lat), az, float(radius_km) * 1000.0)
                circle_points.append(QgsPointXY(lon_pt, lat_pt))


            # Create polygon geometry
            ring_geom = QgsGeometry.fromPolygonXY([circle_points])
            ring_geom.transform(QgsCoordinateTransform(QgsCoordinateReferenceSystem("EPSG:4326"), custom_crs, core_project()))

            ring_feat = QgsFeature()
            ring_feat.setGeometry(ring_geom)
            # ring_features.append(ring_feat)
            ring_feat.setAttributes([label])
            ring_provider.addFeature(ring_feat)
            ring_layer.updateExtents()

            # Apply ring style for this ring only
            ring_style = (ring_styles or {}).get(i)
            if ring_style:
                apply_selected_style(ring_layer, ring_style, styles_dict, _GEOM_POLYGON)

            # Apply customised symbol XML when present; else fall back to
            # the legacy outline-width-only path. The Customize button on
            # the range-ring row drives this.
            ring_symbol_xml = (range_ring_symbols or {}).get(i, "")
            if ring_symbol_xml and _apply_customized_symbol(ring_layer, ring_symbol_xml):
                pass
            else:
                ring_outline_mm = (range_ring_sizes or {}).get(i)
                if ring_outline_mm is not None:
                    try:
                        r = ring_layer.renderer()
                        if r and hasattr(r, "symbol"):
                            sym = r.symbol()
                            if sym:
                                apply_size_to_symbol(sym, _GEOM_POLYGON, float(ring_outline_mm))
                    except Exception as e:
                        logger.warning(f"Failed to set ring outline width on '{ring_layer.name()}': {e}")

            core_project().addMapLayer(ring_layer)

            ring_shapefile_path = os.path.join(export_folder, f"{ring_layer_name}.shp")
            QgsVectorFileWriter.writeAsVectorFormat(
                ring_layer,
                ring_shapefile_path,
                "UTF-8",
                custom_crs,
                "ESRI Shapefile"
            )

            # Ensure visibility
            ring_node = core_project().layerTreeRoot().findLayer(ring_layer.id())
            if ring_node:
                ring_node.setItemVisibilityChecked(True)

        # Create memory point layer
        layer = QgsVectorLayer("Point?crs=EPSG:4326", label or f"Custom_Point_{i+1}", "memory")
        pr = layer.dataProvider()
        pr.addAttributes([QgsField("LABEL", QVariant.String)])
        layer.updateFields()

        # Add feature
        feat = QgsFeature()
        feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(lon, lat)))
        feat.setAttributes([label if label else ""])
        pr.addFeature(feat)
        layer.updateExtents()

        # Style the point layer
        selected_style = custom_point_styles.get(i)
        if selected_style:
            apply_selected_style(layer, selected_style, styles_dict, _GEOM_POINT)

        # Apply customised symbol XML when present; else fall back to the
        # legacy size-only path. The Customize button on the custom-point
        # row drives this.
        cp_symbol_xml = (custom_point_symbols or {}).get(i, "")
        if cp_symbol_xml and _apply_customized_symbol(layer, cp_symbol_xml):
            logger.info(f"Custom point '{label}' rendered with user-customised symbol.")
        else:
            pt_size = (custom_point_sizes or {}).get(i)
            if pt_size is not None:
                _safely_apply_size_to_layer(layer, float(pt_size))
                logger.info(f"Custom point size {pt_size} mm applied to '{label}'")

        # Apply labeling
        if label:
            label_settings = QgsPalLayerSettings()
            label_settings.fieldName = "LABEL"
            label_settings.placement = QgsPalLayerSettings.Placement.AroundPoint
            label_settings.enabled = True
            label_settings.displayAll = True
            label_settings.obstacle = False
            label_settings.priority = 10

            text_format = QgsTextFormat()
            text_format.setFont(sans_font(9))
            label_size = uploaded_layer_label_sizes.get(i)
            text_format.setSize(label_size if label_size is not None else 9)
            label_settings.setFormat(text_format)

            ddp = QgsPropertyCollection()
            ddp.setProperty(QgsPalLayerSettings.Property.Show, QgsProperty.fromExpression('"LABEL" != \'\''))
            label_settings.setDataDefinedProperties(ddp)

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

        core_project().addMapLayer(layer)

    # Temporary storage for layers by geometry type
    polygon_layers = []
    line_layers = []
    point_layers = []

    for i, path in (uploaded_layer_paths or []):
        layer = QgsVectorLayer(path, os.path.splitext(os.path.basename(path))[0], "ogr")
        if layer.isValid():
            root = core_project().layerTreeRoot()
            layer_node = root.findLayer(layer.id())
            if layer_node:
                layer_node.setItemVisibilityChecked(True)

            name_field = uploaded_layer_labels.get(i, None)
            geom_type = layer.geometryType()
            geom_str = {
                _GEOM_POINT: "Point",
                _GEOM_LINE: "LineString",
                _GEOM_POLYGON: "Polygon"
            }.get(geom_type, "Unknown")
            if geom_str == "Unknown":
                print(f"Unsupported geometry type for layer: {layer.name()}")
                continue


        src_crs = layer.crs()
        ext = os.path.splitext(path)[1].lower()

        if not src_crs.isValid():
            # KML/KMZ are always WGS84 (EPSG:4326)
            if ext in (".kml", ".kmz"):
                src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
                try:
                    layer.setCrs(src_crs)  # assign without reprojecting
                    logger.info(f"KML/KMZ with missing CRS → assuming EPSG:4326.")
                except Exception:
                    pass
            elif ext == ".shp":
                prj_path = os.path.splitext(path)[0] + ".prj"
                if os.path.exists(prj_path):
                    try:
                        with open(prj_path, "r", encoding="utf-8", errors="ignore") as f:
                            wkt = f.read()
                        guess = QgsCoordinateReferenceSystem()
                        if guess.createFromWkt(wkt):
                            src_crs = guess
                            layer.setCrs(src_crs)
                            logger.info(f"SHP .prj read → {src_crs.authid() or 'WKT'}")
                    except Exception as e:
                        logger.warning(f".prj read failed: {e}")
                if not src_crs.isValid():
                    src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
                    try:
                        layer.setCrs(src_crs)
                    except Exception:
                        pass
                    logger.info(f"SHP without valid .prj → assuming EPSG:4326.")
            else:
                # Last-resort default for other drivers
                src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
                try:
                    layer.setCrs(src_crs)
                except Exception:
                    pass
                logger.info(f"Unknown CRS → assuming EPSG:4326.")

            # --- 2) Reproject features into CUSTOM map CRS ----------------------------
            # Identify geometry type string for the memory layer
            gtype = layer.geometryType()
            geom_str = {
                _GEOM_POINT: "Point",
                _GEOM_LINE: "LineString",
                _GEOM_POLYGON: "Polygon"
            }.get(gtype, None)
            if not geom_str:
                logger.warning(f"Unsupported geometry type for: {layer.name()}")
                continue

            # Build transform; use the project for context
            xform = QgsCoordinateTransform(src_crs, custom_crs, core_project())

            repro = QgsVectorLayer(f"{geom_str}?crs={custom_crs.authid()}",
                                layer.name() + "_projected", "memory")
            prov = repro.dataProvider()
            prov.addAttributes(layer.fields())
            repro.updateFields()

            feat_count = 0
            for feature in layer.getFeatures():
                geom = feature.geometry()
                if not geom or geom.isEmpty():
                    continue
                try:
                    ok = geom.transform(xform)  # returns 0 on success
                    if ok != 0:
                        # skip if transform failed
                        continue
                except Exception as e:
                    logger.warning(f"Feature transform failed in '{layer.name()}': {e}")
                    continue
                nf = QgsFeature()
                nf.setGeometry(geom)
                nf.setAttributes(feature.attributes())
                prov.addFeature(nf)
                feat_count += 1

            repro.updateExtents()
            layer = repro  # Use the reprojected version from now on

            # --- 3) (Optional) quick introspection before styling ---------------------
            print("Uploaded layer extent (projected):", layer.extent())  # now in meters
            print("Map item extent:", map_item.extent())


            selected_style_name = uploaded_layer_styles.get(i, None)
            if uploaded_category_enable.get(i, False):
                field = uploaded_category_fields.get(i, None)
                scheme = uploaded_category_schemes.get(i, None)
                try:
                    if field and scheme and field in [f.name() for f in layer.fields()]:
                        renderer = build_categorized_renderer_from_ramp(layer, field, scheme, limit=None)
                        if renderer:
                            layer.setRenderer(renderer)
                            layer.triggerRepaint()
                            logger.info(f"Uploaded '{layer.name()}' categorized by '{field}' with ramp '{scheme}'.")
                except Exception as e:
                    logger.warning(f"Uploaded categorize failed on '{layer.name()}': {e}")

            # --- 2) Style (only if categorize was NOT applied) ---
            elif selected_style_name:
                apply_selected_style(layer, selected_style_name, styles_dict, geom_type)

            # --- 3) Customised symbol (preferred) or size-only fallback ---
            # Only honour the customised symbol when Categorize is OFF — the
            # GUI greys out the Customize button while Categorize is on, so
            # an XML entry should never coexist with a categorised renderer.
            upl_symbol_xml = uploaded_layer_symbols.get(i, "")
            if upl_symbol_xml and not uploaded_category_enable.get(i, False) \
                    and _apply_customized_symbol(layer, upl_symbol_xml):
                pass
            else:
                size_value = uploaded_layer_sizes.get(i)
                if size_value is not None:
                    _safely_apply_size_to_layer(layer, float(size_value))

            # --- 4) Filter query ---
            query = uploaded_layer_queries.get(i)
            if query:
                layer.setSubsetString(query)

            # --- 5) Labels ---
            label_on = bool(uploaded_layer_label_enabled.get(i, False))
            name_field = uploaded_layer_labels.get(i, None)
            if label_on and name_field and name_field in [f.name() for f in layer.fields()]:
                _update_label_field_and_size(
                    layer,
                    new_field=name_field,
                    new_size_pt=(uploaded_layer_label_sizes.get(i) if uploaded_layer_label_sizes.get(i) else None)
                )
            else:
                layer.setLabelsEnabled(False)
                layer.triggerRepaint()


            print("Uploaded layer extent:", layer.extent())
            print("Map item extent:", map_item.extent())

            # Sort into appropriate list
            if layer.geometryType() == _GEOM_POINT:
                point_layers.append(layer)
            elif layer.geometryType() == _GEOM_LINE:
                line_layers.append(layer)
            elif layer.geometryType() == _GEOM_POLYGON:
                polygon_layers.append(layer)
        else:
            print(f"Failed to load layer: {path}")

        for lyr in polygon_layers + line_layers + point_layers:
            core_project().addMapLayer(lyr, True)

    # ── Hex bin aggregation ──────────────────────────────────────────────────
    # For each spec in hex_bin_specs: load points, reproject, create hex grid,
    # count points in hexagons, filter empty cells, apply graduated renderer.
    try:
        import processing as _proc  # QGIS processing framework
    except ImportError:
        logger.warning(
            "Hex bin: 'processing' module not available — "
            "ensure Processing.initialize() was called at startup. "
            "Skipping all hex bin layers."
        )
        _proc = None

    for spec in (hex_bin_specs or []) if _proc is not None else []:
        _hex_path = spec.get("path", "")
        if not _hex_path or not os.path.exists(_hex_path):
            continue
        try:
            pt_layer = QgsVectorLayer(_hex_path,
                                      os.path.splitext(os.path.basename(_hex_path))[0],
                                      "ogr")
            if not pt_layer.isValid():
                logger.warning(f"Hex bin: could not load '{_hex_path}'")
                continue

            # Apply filter to input points
            _hex_query = spec.get("query", "")
            if _hex_query:
                pt_layer.setSubsetString(_hex_query)

            # Reproject input to the custom map CRS
            _src_crs = pt_layer.crs()
            if not _src_crs.isValid():
                _src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
                pt_layer.setCrs(_src_crs)

            _xform = QgsCoordinateTransform(_src_crs, custom_crs, core_project())
            _repro = QgsVectorLayer(f"Point?crs={custom_crs.authid()}",
                                    pt_layer.name() + "_hex_proj", "memory")
            _prov = _repro.dataProvider()
            _prov.addAttributes(pt_layer.fields())
            _repro.updateFields()

            for _feat in pt_layer.getFeatures():
                _g = _feat.geometry()
                if not _g or _g.isEmpty():
                    continue
                try:
                    _g.transform(_xform)
                    _nf = QgsFeature()
                    _nf.setGeometry(_g)
                    _nf.setAttributes(_feat.attributes())
                    _prov.addFeature(_nf)
                except Exception:
                    pass
            _repro.updateExtents()

            if not _repro.featureCount():
                logger.warning(f"Hex bin: no valid points after reproject for '{_hex_path}'")
                continue

            # Build buffered extent string for hex grid (10% margin each side)
            _ext = _repro.extent()
            _margin = spec.get("diameter_km", 50) * 1000 * 1.1
            _ext_str = (f"{_ext.xMinimum() - _margin},{_ext.xMaximum() + _margin},"
                        f"{_ext.yMinimum() - _margin},{_ext.yMaximum() + _margin}"
                        f" [{custom_crs.authid()}]")
            _diam_m = float(spec.get("diameter_km", 50)) * 1000.0

            # Step 1: Create hexagon grid
            _grid_res = _proc.run("native:creategrid", {
                "TYPE": 4,           # Hexagon (polygon)
                "EXTENT": _ext_str,
                "HSPACING": _diam_m,
                "VSPACING": _diam_m,
                "HOVERLAY": 0,
                "VOVERLAY": 0,
                "CRS": custom_crs,
                "OUTPUT": "TEMPORARY_OUTPUT",
            })

            # Step 2: Count points in each hexagon
            _count_res = _proc.run("native:countpointsinpolygon", {
                "POLYGONS": _grid_res["OUTPUT"],
                "POINTS": _repro,
                "FIELD": "NUMPOINTS",
                "OUTPUT": "TEMPORARY_OUTPUT",
            })

            hex_layer = _count_res["OUTPUT"]

            # Hide empty hexagons
            hex_layer.setSubsetString('"NUMPOINTS" > 0')

            # Apply graduated renderer on NUMPOINTS
            _renderer = build_graduated_renderer_from_ramp(
                hex_layer,
                "NUMPOINTS",
                spec.get("ramp", "Blues"),
                spec.get("classes", 8),
                spec.get("mode", "Equal Count (Quantile)"),
            )
            if _renderer:
                hex_layer.setRenderer(_renderer)

            # Apply customised base symbol if provided
            _hex_xml = spec.get("symbol_xml", "")
            if _hex_xml:
                _apply_customized_symbol(hex_layer, _hex_xml)

            # Labels
            _hex_fields = [f.name() for f in hex_layer.fields()]
            if spec.get("label_on") and spec.get("label_field") in _hex_fields:
                _update_label_field_and_size(
                    hex_layer,
                    new_field=spec["label_field"],
                    new_size_pt=spec.get("label_size"),
                )
            else:
                hex_layer.setLabelsEnabled(False)

            core_project().addMapLayer(hex_layer, True)
            polygon_layers.append(hex_layer)
            logger.info(f"Hex bin layer added: '{hex_layer.name()}' "
                        f"({hex_layer.featureCount()} non-empty cells)")

        except Exception as _hex_err:
            logger.warning(f"Hex bin aggregation failed for '{_hex_path}': {_hex_err}")

    # ── Distance measurement lines ─────────────────────────────────────────────
    from qgis.core import QgsDistanceArea
    _dist_wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
    _da = QgsDistanceArea()
    _da.setSourceCrs(_dist_wgs84, project.transformContext())
    _da.setEllipsoid("WGS84")

    for _dspec in (dist_specs or []):
        try:
            _grp = _dspec.get("group", 0)
            _rows = _dspec.get("rows", [])
            if len(_rows) < 2:
                continue
            _src_pts = _resolve_dist_row_points(_rows[0], project, _dist_wgs84)
            if not _src_pts:
                continue

            # Collect all features before creating the layer
            _feats = []
            for _target_row_idx in (1, 2):
                if _target_row_idx >= len(_rows):
                    continue
                _tgt_pts = _resolve_dist_row_points(_rows[_target_row_idx], project, _dist_wgs84)
                if not _tgt_pts:
                    continue
                _n_lines = min(len(_src_pts), len(_tgt_pts))
                for _j in range(_n_lines):
                    _p1, _p2 = _src_pts[_j], _tgt_pts[_j]
                    _arc_pts = _geodesic_arc(_p1, _p2)
                    _dist_m  = _da.measureLine([_p1, _p2])
                    _dist_lbl = format_distance_label(_dist_m)
                    _feat = (_arc_pts, _grp, _target_row_idx, _dist_m, _dist_lbl)
                    _feats.append(_feat)

            if not _feats:
                continue

            _dist_layer = QgsVectorLayer(
                "LineString?crs=EPSG:4326",
                f"Distance_lines_G{_grp + 1}",
                "memory",
            )
            _dist_pr = _dist_layer.dataProvider()
            _dist_pr.addAttributes([
                QgsField("group_id",   QVariant.Int),
                QgsField("from_row",   QVariant.Int),
                QgsField("to_row",     QVariant.Int),
                QgsField("distance_m", QVariant.Double),
                QgsField("dist_label", QVariant.String),
            ])
            _dist_layer.updateFields()

            _dist_feats_qgs = []
            for (_arc_pts, _grp_id, _to_row, _dist_m, _dist_lbl) in _feats:
                _f = QgsFeature(_dist_layer.fields())
                _f.setGeometry(QgsGeometry.fromPolylineXY(_arc_pts))
                _f.setAttributes([_grp_id, 0, _to_row, _dist_m, _dist_lbl])
                _dist_feats_qgs.append(_f)
            _dist_pr.addFeatures(_dist_feats_qgs)
            _dist_layer.updateExtents()

            # Style
            _dist_style = _dspec.get("style", "")
            if _dist_style:
                apply_selected_style(_dist_layer, _dist_style, styles_dict, _GEOM_LINE)
            _dist_xml = _dspec.get("symbol_xml", "")
            if _dist_xml:
                _apply_customized_symbol(_dist_layer, _dist_xml)

            # Labels
            if _dspec.get("label_on"):
                _lbl_settings = QgsPalLayerSettings()
                _lbl_settings.fieldName = "dist_label"
                _lbl_settings.placement = QgsPalLayerSettings.Line
                _lbl_settings.enabled = True
                _lbl_fmt = _lbl_settings.format()
                _lbl_size = _dspec.get("label_size")
                if _lbl_size:
                    _lbl_fmt.setSize(_lbl_size)
                    _lbl_fmt.setSizeUnit(_RENDER_PT)
                    _lbl_settings.setFormat(_lbl_fmt)
                _dist_layer.setLabeling(QgsVectorLayerSimpleLabeling(_lbl_settings))
                _dist_layer.setLabelsEnabled(True)
            else:
                _dist_layer.setLabelsEnabled(False)

            core_project().addMapLayer(_dist_layer, True)
            line_layers.append(_dist_layer)
            logger.info(f"Distance lines layer added: '{_dist_layer.name()}' "
                        f"({_dist_layer.featureCount()} lines)")

        except Exception as _dist_err:
            logger.warning(f"Distance measurement failed for group {_dspec.get('group', '?')}: {_dist_err}")

    map_item.refresh()
    layout.refresh()

    os.makedirs(export_folder, exist_ok=True)

    for entry in csv_data or []:
        filename = entry["filename"]
        mode = entry["mode"]
        style = entry["style"]
        layer_index = entry["index"]

        full_path = filename
        print(full_path)
        if not os.path.exists(full_path):
            print(f"CSV not found: {filename}")
            continue

        layer_name = f"csv_{mode}_{layer_index+1}"
        csv_base_name = os.path.splitext(os.path.basename(filename))[0]
        shapefile_path = os.path.join(export_folder, f"{csv_base_name}.shp")

        safe_overwrite(shapefile_path)

        layer_name = f"csv_{mode}_{layer_index+1}"
        csv_base_name = os.path.splitext(os.path.basename(filename))[0]
        gpkg_path = os.path.join(export_folder, f"{csv_base_name}.gpkg")
        safe_overwrite(gpkg_path)

        try:
            if mode == "point":
                # Load CSV as point layer
                raw_name_field = entry.get("name_field")
                name_field = raw_name_field.strip() if isinstance(raw_name_field, str) else None
                if not name_field or name_field == "Label field":
                    name_field = None

                layer = QgsVectorLayer("Point?crs=EPSG:4326", layer_name, "memory")
                pr = layer.dataProvider()

                label_enabled = entry.get("label_enabled", False)

                if name_field:
                    pr.addAttributes([QgsField(name_field, QVariant.String)])
                else:
                    name_field = "DUMMY"
                    pr.addAttributes([QgsField(name_field, QVariant.String)])
                layer.updateFields()

                if not csv_format_groups or layer_index >= len(csv_format_groups):
                    use_dms_local = False
                else:
                    format_group = csv_format_groups[layer_index][2]
                    use_dms_local = format_group.isChecked()

                # Read and add features
                with open(full_path, newline='', encoding='utf-8') as csvfile:
                    reader = csv.DictReader(csvfile)
                    for row in reader:
                        x_field = entry.get("x_field")
                        y_field = entry.get("y_field")
                        lon_raw = row.get(x_field, "").strip()
                        lat_raw = row.get(y_field, "").strip()

                        label_value = row.get(name_field, "").strip() if label_enabled and name_field else ""

                        lon = parse_dms(lon_raw, is_latitude=False) if use_dms_local else parse_dd(lon_raw, is_latitude=False)
                        lat = parse_dms(lat_raw, is_latitude=True) if use_dms_local else parse_dd(lat_raw, is_latitude=True)

                        if lon is None or lat is None:
                            print(f"Skipping row due to invalid parsed coordinates: {lon_raw}, {lat_raw}")
                            continue

                        feat = QgsFeature()
                        feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(lon, lat)))
                        feat.setAttributes([label_value])
                        pr.addFeature(feat)

                layer.updateExtents()
                layer.setLabelsEnabled(False)
                layer.triggerRepaint()

                print(f"Fields in layer: {[f.name() for f in layer.fields()]}")
                print("Sample feature attributes:")
                for f in layer.getFeatures():
                    print(f.attributes())

                if layer.crs() != custom_crs:
                    transform = QgsCoordinateTransform(layer.crs(), custom_crs, core_project())
                    reprojected_layer = QgsVectorLayer(f"Point?crs={custom_crs.authid()}", layer.name() + "_projected", "memory")
                    provider = reprojected_layer.dataProvider()
                    provider.addAttributes(layer.fields())
                    reprojected_layer.updateFields()

                    for feature in layer.getFeatures():
                        geom = feature.geometry()
                        try:
                            geom.transform(transform)
                        except Exception as e:
                            logger.warning(f"CSV point feature transform failed: {e}. Skipping row.")
                            continue
                        new_feature = QgsFeature()
                        new_feature.setGeometry(geom)
                        new_feature.setAttributes(feature.attributes())
                        provider.addFeature(new_feature)

                    reprojected_layer.updateExtents()
                    layer = reprojected_layer  # Use reprojected version









                # Apply labeling
                if label_enabled and name_field:
                    if name_field in [f.name() for f in layer.fields()]:
                        print(f"Applying labels using field: {name_field}")
                        label_settings = QgsPalLayerSettings()
                        label_settings.fieldName = name_field
                        label_settings.placement = QgsPalLayerSettings.Placement.AroundPoint
                        label_settings.enabled = True
                        label_settings.displayAll = True
                        label_settings.obstacle = False
                        label_settings.priority = 10

                        text_format = QgsTextFormat()
                        text_format.setFont(sans_font(9))
                        layer_index = entry.get("index", 0)
                        label_size = entry.get("label_size", 9)
                        text_format.setSize(label_size if label_size is not None else 9)
                        label_settings.setFormat(text_format)

                        ddp = QgsPropertyCollection()
                        ddp.setProperty(QgsPalLayerSettings.Property.Show, QgsProperty.fromExpression(f'"{name_field}" != \'\''))
                        label_settings.setDataDefinedProperties(ddp)

                        layer.setLabeling(QgsVectorLayerSimpleLabeling(label_settings))
                        layer.setLabelsEnabled(True)
                        layer.triggerRepaint()
                    else:
                        print(f"Label field '{name_field}' not found in layer fields.")

                # Apply style if available
                if style:
                    geom_type = layer.geometryType()

                    logger.debug(f"Calling apply_selected_style for CSV layer '{layer.name()}'")
                    print(f"  Style: {style}")
                    print(f"  Geometry type: {layer.geometryType()} ({layer.geometryType() == _GEOM_POINT})")
                    apply_selected_style(layer, style, styles_dict, geom_type)

                # Customised CSV symbol (preferred) or size-only fallback.
                csv_symbol_xml = csv_layer_symbols.get(layer_index, "")
                if csv_symbol_xml and _apply_customized_symbol(layer, csv_symbol_xml):
                    pass
                else:
                    size_value = csv_layer_sizes.get(layer_index)
                    if size_value is not None:
                        _safely_apply_size_to_layer(layer, float(size_value))

                # Save to shapefile
                QgsVectorFileWriter.writeAsVectorFormat(layer, shapefile_path, "UTF-8", custom_crs, "ESRI Shapefile")
                print(f"Shapefile saved to: {shapefile_path}")

                # Add to project and ensure visibility
                core_project().addMapLayer(layer)
                root = core_project().layerTreeRoot()
                layer_node = root.findLayer(layer.id())
                if layer_node:
                    layer_node.setItemVisibilityChecked(True)

            elif mode == "line":
                format_group = csv_format_groups[layer_index][2]
                use_dms_local = format_group.isChecked()

                # Create memory point layer
                point_layer = QgsVectorLayer("Point?crs=EPSG:4326", "temp_points", "memory")
                pr = point_layer.dataProvider()
                pr.addAttributes([QgsField("NAME", QVariant.String), QgsField("QFeatureID", QVariant.Int)])
                point_layer.updateFields()

                with open(full_path, newline='', encoding='utf-8') as csvfile:
                    reader = csv.DictReader(csvfile)
                    for i, row in enumerate(reader):
                        x_field = entry.get("x_field")
                        y_field = entry.get("y_field")
                        lon_raw = row.get(x_field, "").strip()
                        lat_raw = row.get(y_field, "").strip()
                        name = row.get("NAME", "").strip()

                        lon = parse_dms(lon_raw, is_latitude=False) if use_dms_local else parse_dd(lon_raw, is_latitude=False)
                        lat = parse_dms(lat_raw, is_latitude=True) if use_dms_local else parse_dd(lat_raw, is_latitude=True)

                        if lon is None or lat is None:
                            print(f"Skipping row due to invalid parsed coordinates: {lon_raw}, {lat_raw}")
                            continue

                        feat = QgsFeature()
                        feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(lon, lat)))
                        feat.setAttributes([name, i])
                        pr.addFeature(feat)

                point_layer.updateExtents()

                # Group and sort points
                grouped = defaultdict(list)
                for feature in point_layer.getFeatures():
                    name = feature["NAME"]
                    order = feature["QFeatureID"]
                    grouped[name].append((order, feature.geometry().asPoint()))

                # Create line layer
                line_layer = QgsVectorLayer("LineString?crs=EPSG:4326", layer_name, "memory")
                pr = line_layer.dataProvider()
                pr.addAttributes([QgsField("NAME", QVariant.String)])
                line_layer.updateFields()

                for name, points in grouped.items():
                    points.sort()
                    line_geom = QgsGeometry.fromPolylineXY([QgsPointXY(pt) for _, pt in points])
                    feat = QgsFeature()
                    feat.setGeometry(line_geom)
                    feat.setAttributes([name])
                    pr.addFeature(feat)

                line_layer.updateExtents()

                # Reproject if needed
                if line_layer.crs() != custom_crs:
                    transform = QgsCoordinateTransform(line_layer.crs(), custom_crs, core_project())
                    reprojected_layer = QgsVectorLayer(f"LineString?crs={custom_crs.authid()}", layer_name + "_projected", "memory")
                    provider = reprojected_layer.dataProvider()
                    provider.addAttributes(line_layer.fields())
                    reprojected_layer.updateFields()

                    for feature in line_layer.getFeatures():
                        geom = feature.geometry()
                        try:
                            geom.transform(transform)
                        except Exception as e:
                            logger.warning(f"CSV line feature transform failed: {e}. Skipping line.")
                            continue
                        new_feature = QgsFeature()
                        new_feature.setGeometry(geom)
                        new_feature.setAttributes(feature.attributes())
                        provider.addFeature(new_feature)

                    reprojected_layer.updateExtents()
                    line_layer = reprojected_layer

                # Save to shapefile using CSV base name
                csv_base_name = os.path.splitext(os.path.basename(filename))[0]
                shapefile_path = os.path.join(export_folder, f"{csv_base_name}.shp")
                safe_overwrite(shapefile_path)
                QgsVectorFileWriter.writeAsVectorFormat(line_layer, shapefile_path, "UTF-8", custom_crs, "ESRI Shapefile")
                print(f"Shapefile saved to: {shapefile_path}")

                # Reload and add to project
                layer = QgsVectorLayer(shapefile_path, csv_base_name, "ogr")
                if layer.isValid():
                    core_project().addMapLayer(layer)
                    root = core_project().layerTreeRoot()
                    layer_node = root.findLayer(layer.id())
                    if layer_node:
                        layer_node.setItemVisibilityChecked(True)
                
                if style:
                    apply_selected_style(layer, style, styles_dict, _GEOM_LINE)

                # Customised CSV symbol (preferred) or legacy size fallback,
                # mirroring the point-mode branch above.
                csv_symbol_xml = csv_layer_symbols.get(layer_index, "")
                if csv_symbol_xml and _apply_customized_symbol(layer, csv_symbol_xml):
                    pass
                else:
                    size_value = csv_layer_sizes.get(layer_index)
                    if size_value is not None:
                        symbol = layer.renderer().symbol()
                        geom_type = layer.geometryType()
                        apply_size_to_symbol(symbol, geom_type, size_value)
                        print(f"Applied CSV layer size {size_value} to layer {layer.name()}")

                renderer = layer.renderer()
                if renderer:
                    print(f"Final renderer type: {type(renderer)}")
                    print(f"Symbol layers: {renderer.symbol().symbolLayerCount()}")

                print(f"Shapefile saved to: {shapefile_path}")
            else:
                print("ERROR: Line layer is invalid after Points to Path.")

        except Exception as e:
            print(f"EXCEPTION during processing of {filename}: {e}")
            traceback.print_exc()

    layout.refresh()
    map_item.refresh()

    # --- User overlays (pictures + labels) ---
    try:
        OVERLAY_Z = 1500.0  # match GUI behavior so overlays sit above map/legend/grid
        added = 0

        for ov in overlays or []:
            t = (ov or {}).get("type", "").lower()
            x = float(ov.get("x", 0.0)); y = float(ov.get("y", 0.0))
            w = float(ov.get("w", 30.0)); h = float(ov.get("h", 10.0))

            if t == "builtin":
                it = layout.itemById(ov.get("id") or "")
                if it:
                    it.attemptMove(QgsLayoutPoint(x, y, _LAYOUT_MM))
                    it.attemptResize(QgsLayoutSize(w, h, _LAYOUT_MM))

            elif t == "picture":
                src = str(ov.get("path", ""))
                pic = QgsLayoutItemPicture.create(layout) or QgsLayoutItemPicture(layout)
                try:
                    # setMode() deprecated in 3.38+; guard for forward-compat.
                    if hasattr(pic, "setMode"):
                        _fmt_svg    = getattr(QgsLayoutItemPicture, "Format", None)
                        _svg_const  = (_fmt_svg.SVG    if _fmt_svg else
                                       getattr(QgsLayoutItemPicture, "FormatSVG",    None))
                        _rast_const = (_fmt_svg.Raster if _fmt_svg else
                                       getattr(QgsLayoutItemPicture, "FormatRaster", None))
                        if src.lower().endswith(".svg") or (";svg" in src.lower()):
                            if _svg_const is not None:
                                pic.setMode(_svg_const)
                        else:
                            if _rast_const is not None:
                                pic.setMode(_rast_const)
                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)
                pic.setZValue(1500.0)
                pic.refresh()

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

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

                lbl.attemptMove(QgsLayoutPoint(x, y, _LAYOUT_MM))
                lbl.attemptResize(QgsLayoutSize(w, h, _LAYOUT_MM))
                try:
                    lbl.setItemRotation(float(ov.get("rotation", 0.0)))
                except Exception:
                    pass
                lbl.setCustomProperty("beaver_overlay", True)
                lbl.setZValue(OVERLAY_Z)
                lbl.refresh()

        # After the loop
        layout.refresh()

    except Exception as e:
        logger.warning(f"Overlay injection skipped: {e}")


    exporter = QgsLayoutExporter(layout)
    ext = os.path.splitext(output_pdf_path)[1].lower()
    safe_overwrite(output_pdf_path)

    # Let pending repaints / async tile fetches settle so no layer is missing.
    layout.refresh()
    wait_for_render()

    if ext == '.pdf':
        result = exporter.exportToPdf(output_pdf_path, QgsLayoutExporter.PdfExportSettings())
    else:
        image_settings = QgsLayoutExporter.ImageExportSettings()
        image_settings.dpi = 300
        image_settings.imageFormat = 'JPG' if ext in ['.jpg', '.jpeg'] else 'PNG'
        result = exporter.exportToImage(output_pdf_path, image_settings)

    if result != _EXPORT_SUCCESS:
        raise Exception("Map export failed.")
    
    # Configure legend inside the layout
    if legend_styles and legend_labels:
        dummy_legend_layer = create_dummy_legend_layer(legend_styles, legend_labels, styles_dict)

        if dummy_legend_layer and dummy_legend_layer.featureCount() > 0:

            # Make dummy layer invisible on the map
            renderer = dummy_legend_layer.renderer()
            if isinstance(renderer, QgsCategorizedSymbolRenderer):
                for category in renderer.categories():
                    category.symbol().setOpacity(0.0)
                dummy_legend_layer.setRenderer(renderer)
            else:
                # Fallback for other renderer types
                symbol = renderer.symbol()
                symbol.setOpacity(0.0)
                dummy_legend_layer.setRenderer(QgsSingleSymbolRenderer(symbol))

            dummy_legend_layer.setLabelsEnabled(False)

            legend_item = configure_layout_legend(layout, map_item, dummy_legend_layer, legend_position=legend_position)
        else:
            hide_legend_if_empty(layout)
    else:
        hide_legend_if_empty(layout)

    return (output_pdf_path, 
            None, 
            int(Map_frame_scale), 
            legend_layers if legend_layers else [],  
            int(scale_denominator) if scale_denominator is not None else int(0),
            int(actual_map_scale))