# -*- coding: utf-8 -*-
"""
adminmap.py  -  Turnkey "Admin Map": the user picks a province/district/commune
and the plugin loads its boundary, clips roads / water / points to it, styles
everything from bundled QML, optionally adds a Google Satellite basemap, and
builds the finished layout with locator insets. No data is added by the user.

UPDATED: Simplified focus modes, original full layer for surroundings,
         auto-labeling, Admin 0-3 support for global use.
"""

import os
from qgis.core import (
    QgsProject, QgsVectorLayer, QgsRasterLayer, QgsRectangle, QgsFeatureRequest,
    QgsFillSymbol, QgsSingleSymbolRenderer, QgsSimpleFillSymbolLayer,
    QgsMarkerSymbol, QgsUnitTypes,
    QgsInvertedPolygonRenderer, QgsPalLayerSettings,
    QgsVectorLayerSimpleLabeling, QgsTextFormat,
    QgsTextBufferSettings,
)
from qgis.PyQt.QtGui import QColor, QFont
from qgis.PyQt.QtCore import Qt, QVariant

_DIR = os.path.dirname(__file__)
BND = os.path.join(_DIR, "data", "boundaries")
CTX = os.path.join(_DIR, "data", "context")
STY = os.path.join(_DIR, "data", "styles")

GOOGLE_SAT = ("type=xyz&url=https://mt1.google.com/vt/"
              "lyrs%3Ds%26x%3D%7Bx%7D%26y%3D%7By%7D%26z%3D%7Bz%7D"
              "&zmax=20&zmin=0")

# ---------------------------------------------------------------------------
# ADMIN CONFIGURATION - supports both Cambodia bundled and global Admin 0-3
# ---------------------------------------------------------------------------

# Cambodia bundled admin polygon layers: level -> (shp, name_field, code_field, parent_code_field)
ADMIN = {
    "province": ("KH_Provinces.shp", "Name_EN", "KHET_CODE", None),
    "district": ("KH_Districts.shp", "Name_EN", "SROK_CODE", "KHET_CODE"),
    "commune":  ("KH_Communes.shp",  "Name_EN", "KHUM_CODE", "SROK_CODE"),
}

UNIT_LAYER_NAME = {"province": "Provincial Boundary",
                   "district": "District Boundary",
                   "commune": "Commune Boundary"}

# Global admin levels (Admin 0-3) for generic use
# These map to the same structure but with different field names
# Users can configure these via settings or auto-detect from shapefile
GLOBAL_ADMIN = {
    "admin0": ("Admin0.shp", "NAME_0", "GID_0", None),       # Country
    "admin1": ("Admin1.shp", "NAME_1", "GID_1", "GID_0"),    # Province/State
    "admin2": ("Admin2.shp", "NAME_2", "GID_2", "GID_1"),    # District/County
    "admin3": ("Admin3.shp", "NAME_3", "GID_3", "GID_2"),    # Commune/Ward
}

# Detect if we have Cambodia bundled data or generic global data
_has_cambodia = os.path.exists(os.path.join(BND, "KH_Provinces.shp"))
_has_global = os.path.exists(os.path.join(BND, "Admin0.shp"))

# Use Cambodia data if available, otherwise fall back to global
if _has_cambodia:
    ACTIVE_ADMIN = ADMIN
    ADMIN_LEVELS = ["province", "district", "commune"]
    LEVEL_LABELS = {"province": "Province", "district": "District", "commune": "Commune"}
elif _has_global:
    ACTIVE_ADMIN = GLOBAL_ADMIN
    ADMIN_LEVELS = ["admin0", "admin1", "admin2", "admin3"]
    LEVEL_LABELS = {"admin0": "Country", "admin1": "Province/State",
                    "admin2": "District/County", "admin3": "Commune/Ward"}
else:
    ACTIVE_ADMIN = ADMIN  # Default to Cambodia structure even if files missing
    ADMIN_LEVELS = ["province", "district", "commune"]
    LEVEL_LABELS = {"province": "Province", "district": "District", "commune": "Commune"}

# context layers: display name -> (folder, shp, qml or None, kind)
# Always loaded for insets regardless of "Layers to include"
CONTEXT = {
    "Water bodies":        (BND, "Waterbody.shp",        None,                 "water"),
}


# --------------------------------------------------------------------------- #
def read_units(level, parent_code=None):
    """Return sorted [(label, code, name)] for a level, filtered by parent code.
    Deduplicated by code (the polygon layers hold multipart pieces, so a
    province/district can otherwise appear several times)."""
    shp, nf, cf, pf = ACTIVE_ADMIN[level]
    path = os.path.join(BND, shp)
    if not os.path.exists(path):
        return []
    lyr = QgsVectorLayer(path, "tmp", "ogr")
    if not lyr.isValid():
        return []
    seen = {}
    for feat in lyr.getFeatures():
        if pf and parent_code is not None:
            if str(feat[pf]) != str(parent_code):
                continue
        code = str(feat[cf])
        if code not in seen:
            seen[code] = str(feat[nf])
    out = [("%s (%s)" % (name, code), code, name)
           for code, name in seen.items()]
    out.sort(key=lambda t: t[2])
    return out


def _unit_layer(level, code):
    """Load the admin polygon layer filtered to the one selected unit."""
    shp, nf, cf, pf = ACTIVE_ADMIN[level]
    path = os.path.join(BND, shp)
    if not os.path.exists(path):
        return None, nf, cf
    lyr = QgsVectorLayer(path,
                         UNIT_LAYER_NAME.get(level, LEVEL_LABELS.get(level, "Boundary")), "ogr")
    if not lyr.isValid():
        return None, nf, cf
    try:
        lyr.setSubsetString('"%s" = \'%s\'' % (cf, str(code).replace("'", "''")))
    except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
        pass
    return lyr, nf, cf


def _mask_geometry(mask):
    from qgis.core import QgsGeometry
    geoms = [QgsGeometry(f.geometry()) for f in mask.getFeatures()
             if f.hasGeometry() and not f.geometry().isEmpty()]
    if not geoms:
        return None
    g = geoms[0]
    for x in geoms[1:]:
        try:
            g = g.combine(x)
        except (AttributeError, TypeError, RuntimeError):  # Safe fallback
            pass
    return g


def _clip(layer, mask):
    """Clip `layer` to `mask` in pure Python (no processing dependency)."""
    from qgis.core import (QgsFeature, QgsFeatureRequest as _FR,
                           QgsCoordinateTransform, QgsProject, QgsGeometry)
    try:
        mgeom = _mask_geometry(mask)
        if mgeom is None or mgeom.isEmpty():
            return layer
        mask_crs = mask.crs()
        lyr_crs = layer.crs()
        if mask_crs.isValid() and lyr_crs.isValid()                 and mask_crs.authid() and lyr_crs.authid()                 and mask_crs.authid() != lyr_crs.authid():
            try:
                ct = QgsCoordinateTransform(mask_crs, lyr_crs,
                                            QgsProject.instance())
                mgeom = QgsGeometry(mgeom)
                mgeom.transform(ct)
            except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
                return layer
        tstr = {0: "MultiPoint", 1: "MultiLineString",
                2: "MultiPolygon"}.get(int(layer.geometryType()), "MultiPolygon")
        crs = layer.crs().authid() or "EPSG:32648"
        mem = QgsVectorLayer("%s?crs=%s" % (tstr, crs), layer.name(), "memory")
        mem.dataProvider().addAttributes(layer.fields().toList())
        mem.updateFields()
        feats = []
        touched = []
        for f in layer.getFeatures(_FR().setFilterRect(mgeom.boundingBox())):
            g = f.geometry()
            if g is None or g.isEmpty() or not g.intersects(mgeom):
                continue
            nf0 = QgsFeature(mem.fields())
            nf0.setGeometry(g)
            nf0.setAttributes(f.attributes())
            touched.append(nf0)
            ig = g.intersection(mgeom)
            if ig is None or ig.isEmpty():
                continue
            nf = QgsFeature(mem.fields())
            nf.setGeometry(ig)
            nf.setAttributes(f.attributes())
            feats.append(nf)
        if feats:
            mem.dataProvider().addFeatures(feats)
            return mem
        if touched:
            mem.dataProvider().addFeatures(touched)
            return mem
        return layer
    except (AttributeError, TypeError, RuntimeError):  # Safe fallback
        return layer


def _prune_categories_to_data(layer):
    """For a categorized renderer, drop categories whose value does not occur
    in the layer's features."""
    try:
        from qgis.core import QgsCategorizedSymbolRenderer
        r = layer.renderer()
        if not isinstance(r, QgsCategorizedSymbolRenderer):
            return
        field = r.classAttribute()
        idx = layer.fields().indexOf(field)
        if idx < 0:
            return
        present = set()
        for f in layer.getFeatures():
            present.add(str(f[field]))
        cats = r.categories()
        for i in range(len(cats) - 1, -1, -1):
            val = str(cats[i].value())
            if val not in present:
                try:
                    r.deleteCategory(i)
                except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
                    pass
    except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
        pass


def _style_water(layer):
    try:
        layer.setRenderer(QgsSingleSymbolRenderer(QgsFillSymbol.createSimple({
            "color": "150,200,230,255", "outline_color": "120,170,210,255",
            "outline_width": "0.1", "outline_width_unit": "MM"})))
    except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
        pass


def _fill_stroke(color, width_mm, style=Qt.PenStyle.SolidLine):
    sl = QgsSimpleFillSymbolLayer()
    try:
        sl.setBrushStyle(Qt.BrushStyle.NoBrush)
        sl.setStrokeColor(QColor(color))
        sl.setStrokeWidth(float(width_mm))
        sl.setStrokeWidthUnit(QgsUnitTypes.RenderUnit.RenderMillimeters)
        sl.setStrokeStyle(style)
    except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
        pass
    return sl


def _boundary_symbol(level):
    sym = QgsFillSymbol()
    if level in ("province", "admin1"):
        sym.changeSymbolLayer(0, _fill_stroke("211,211,211,255", 1.2,
                                              Qt.PenStyle.DashDotDotLine))
    elif level in ("district", "admin2"):
        sym.changeSymbolLayer(0, _fill_stroke("211,211,211,255", 0.8,
                                              Qt.PenStyle.DashDotDotLine))
    else:  # commune / admin3
        sym.changeSymbolLayer(0, _fill_stroke("221,221,221,255", 0.4))
    return sym


def _qml_for_level(level):
    qml_map = {
        "province": "Provincial_Boundary.qml",
        "district": "District_Boundary.qml",
        "commune": "Commune_Boundary.qml",
        "admin0": "Admin0_Boundary.qml",
        "admin1": "Admin1_Boundary.qml",
        "admin2": "Admin2_Boundary.qml",
        "admin3": "Admin3_Boundary.qml",
    }
    fname = qml_map.get(level)
    if not fname:
        return None
    path = os.path.join(STY, fname)
    return path if os.path.exists(path) else None


def _style_points_default(layer):
    try:
        layer.setRenderer(QgsSingleSymbolRenderer(QgsMarkerSymbol.createSimple({
            "name": "circle", "size": "1.4", "size_unit": "MM",
            "color": "90,90,90,255", "outline_color": "255,255,255,255",
            "outline_width": "0.2"})))
    except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
        pass


def _focus_mask(project, unit, opacity=0.55):
    """Inverted-polygon mask: veil everything OUTSIDE the unit."""
    try:
        mask = unit.materialize(QgsFeatureRequest())
        mask.setName("Focus (dim outside)")
        alpha = max(0, min(255, int(round(opacity * 255))))
        fill = QgsFillSymbol.createSimple({
            "color": "255,255,255,%d" % alpha,
            "outline_style": "no",
        })
        inv = QgsInvertedPolygonRenderer()
        inv.setEmbeddedRenderer(QgsSingleSymbolRenderer(fill))
        mask.setRenderer(inv)
        project.addMapLayer(mask, False)
        return mask
    except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
        return None


def _surrounding_units_full_layer(project, level, code, label_opacity=0.55):
    """Load the ORIGINAL FULL admin layer for the SAME level as the focus unit.

    This shows ALL units at the focus level (e.g. all communes if focus is commune)
    with the focus unit distinguished. Labels are auto-enabled for all features.

    Uses the ORIGINAL shapefile, not a clipped or parent-filtered version.
    """
    try:
        from qgis.core import (QgsVectorLayer, QgsPalLayerSettings,
                               QgsTextFormat, QgsVectorLayerSimpleLabeling,
                               QgsTextBufferSettings)
        shp, nf, cf, pf = ACTIVE_ADMIN[level]
        path = os.path.join(BND, shp)
        if not os.path.exists(path):
            return None

        # Load the FULL original layer - no subset string
        lyr = QgsVectorLayer(path, "Surrounding areas", "ogr")
        if not lyr.isValid():
            return None

        # Style: focus unit gets a light highlight, others get very light grey
        from qgis.core import QgsRuleBasedRenderer

        # Focus unit fill - slightly more visible
        focus_fill = QgsFillSymbol.createSimple({
            "color": "255,255,255,40",
            "outline_color": "100,100,100,255",
            "outline_width": "0.4", "outline_width_unit": "MM"})

        # Other units fill - very light, almost transparent
        other_fill = QgsFillSymbol.createSimple({
            "color": "230,230,230,50",
            "outline_color": "160,160,160,120",
            "outline_width": "0.15", "outline_width_unit": "MM"})

        root = QgsRuleBasedRenderer.Rule(None)
        root.appendChild(QgsRuleBasedRenderer.Rule(other_fill))

        # Add rule for focus unit
        if code:
            idx = lyr.fields().indexOf(cf)
            numeric = idx >= 0 and lyr.fields().at(idx).type() in (
                QVariant.Int, QVariant.UInt, QVariant.LongLong,
                QVariant.ULongLong, QVariant.Double)
            if numeric:
                expr = '"%s" = %s' % (cf, code)
            else:
                expr = 'lower("%s") = lower(\'%s\')' % (
                    cf, str(code).replace("'", "''"))
            root.appendChild(QgsRuleBasedRenderer.Rule(focus_fill, 0, 0, expr))

        lyr.setRenderer(QgsRuleBasedRenderer(root))

        # Labels for ALL features - bold, larger, visible
        pal = QgsPalLayerSettings()
        pal.fieldName = nf
        pal.enabled = True
        fmt = QgsTextFormat()
        fmt.setSize(10)
        fmt.setSizeUnit(QgsUnitTypes.RenderUnit.RenderPoints)
        grey = QColor(40, 40, 40)
        grey.setAlphaF(max(0.4, min(1.0, 1.0 - label_opacity + 0.35)))
        fmt.setColor(grey)
        f = QFont("Arial", 10)
        f.setBold(True)
        fmt.setFont(f)
        try:
            buf = QgsTextBufferSettings()
            buf.setEnabled(True)
            buf.setSize(1.0)
            buf.setColor(QColor(255, 255, 255, 200))
            fmt.setBuffer(buf)
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
        pal.setFormat(fmt)
        lyr.setLabeling(QgsVectorLayerSimpleLabeling(pal))
        lyr.setLabelsEnabled(True)

        project.addMapLayer(lyr, False)
        return lyr
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        import traceback
        traceback.print_exc()
        return None


def _style_unit_with_qml(layer, level, name_field, label=True):
    if level not in ACTIVE_ADMIN:
        level = None
    qml_path = _qml_for_level(level)
    if qml_path:
        try:
            layer.loadNamedStyle(qml_path)
        except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
            try:
                layer.setRenderer(QgsSingleSymbolRenderer(_boundary_symbol(level)))
            except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
                pass
    else:
        try:
            layer.setRenderer(QgsSingleSymbolRenderer(_boundary_symbol(level)))
        except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
            pass

    if not label:
        return
    try:
        s = QgsPalLayerSettings()
        s.fieldName = name_field
        s.enabled = True
        s.placement = QgsPalLayerSettings.PredefinedPointPosition.OverPoint
        s.centroidInside = True

        tf = QgsTextFormat()
        f = QFont("Arial", 14)
        f.setBold(True)
        tf.setFont(f)
        tf.setColor(QColor(0, 0, 0))

        buf = QgsTextBufferSettings()
        buf.setEnabled(True)
        buf.setSize(1.2)
        buf.setColor(QColor(255, 255, 255))
        tf.setBuffer(buf)

        s.setFormat(tf)
        layer.setLabeling(QgsVectorLayerSimpleLabeling(s))
        layer.setLabelsEnabled(True)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass


def _apply_district_labeling(layer, field):
    try:
        s = QgsPalLayerSettings()
        s.fieldName = field
        s.enabled = True
        s.placement = QgsPalLayerSettings.PredefinedPointPosition.OverPoint
        s.centroidInside = True

        tf = QgsTextFormat()
        f = QFont("Arial", 11)
        f.setBold(True)
        f.setItalic(False)
        tf.setFont(f)
        tf.setColor(QColor(0, 0, 128))

        buf = QgsTextBufferSettings()
        buf.setEnabled(True)
        buf.setSize(0.8)
        buf.setColor(QColor(255, 255, 255))
        tf.setBuffer(buf)

        s.setFormat(tf)
        layer.setLabeling(QgsVectorLayerSimpleLabeling(s))
        layer.setLabelsEnabled(True)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass


def _apply_commune_labeling(layer, field):
    try:
        s = QgsPalLayerSettings()
        s.fieldName = field
        s.enabled = True
        s.placement = QgsPalLayerSettings.PredefinedPointPosition.OverPoint
        s.centroidInside = True

        tf = QgsTextFormat()
        f = QFont("Arial", 9)
        f.setBold(True)
        f.setItalic(False)
        tf.setFont(f)
        tf.setColor(QColor(0, 0, 0))

        buf = QgsTextBufferSettings()
        buf.setEnabled(True)
        buf.setSize(0.8)
        buf.setColor(QColor(255, 255, 255))
        tf.setBuffer(buf)

        s.setFormat(tf)
        layer.setLabeling(QgsVectorLayerSimpleLabeling(s))
        layer.setLabelsEnabled(True)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass


# Sub-levels: for each level, list (sublevel_name, parent_field, display_name)
# These are used to show boundaries WITHIN the focus unit
SUBLEVELS = {
    "province": [("commune", "KHET_CODE", "Commune Boundary"),
                 ("district", "KHET_CODE", "District Boundary")],
    "district": [("commune", "SROK_CODE", "Commune Boundary")],
    "commune": [],
    "admin0": [("admin1", "GID_0", "Admin1 Boundary"),
               ("admin2", "GID_0", "Admin2 Boundary"),
               ("admin3", "GID_0", "Admin3 Boundary")],
    "admin1": [("admin2", "GID_1", "Admin2 Boundary"),
               ("admin3", "GID_1", "Admin3 Boundary")],
    "admin2": [("admin3", "GID_2", "Admin3 Boundary")],
    "admin3": [],
}


def _sublevel_layer(sublevel, parent_field, parent_code, name):
    shp, nf, cf, pf = ACTIVE_ADMIN[sublevel]
    path = os.path.join(BND, shp)
    if not os.path.exists(path):
        return None
    lyr = QgsVectorLayer(path, name, "ogr")
    if not lyr.isValid():
        return None
    try:
        lyr.setSubsetString('"%s" = \'%s\'' % (parent_field,
                                               str(parent_code).replace("'", "''")))
        lyr = lyr.materialize(QgsFeatureRequest())
        lyr.setName(name)

        qml_path = _qml_for_level(sublevel)
        if qml_path:
            try:
                lyr.loadNamedStyle(qml_path)
            except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
                try:
                    lyr.setRenderer(QgsSingleSymbolRenderer(_boundary_symbol(sublevel)))
                except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
                    pass
        else:
            try:
                lyr.setRenderer(QgsSingleSymbolRenderer(_boundary_symbol(sublevel)))
            except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
                pass

        if sublevel in ("district", "admin2"):
            _apply_district_labeling(lyr, nf)
        elif sublevel in ("commune", "admin3"):
            _apply_commune_labeling(lyr, nf)

    except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
        pass
    return lyr


def _make_theme(project, name, layers):
    try:
        from qgis.core import QgsMapThemeCollection
        rec = QgsMapThemeCollection.MapThemeRecord()
        for lyr in layers:
            if lyr is None:
                continue
            rec.addLayerRecord(QgsMapThemeCollection.MapThemeLayerRecord(lyr))
        mtc = project.mapThemeCollection()
        if mtc.hasMapTheme(name):
            mtc.update(name, rec)
        else:
            mtc.insert(name, rec)
    except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
        pass


def custom_unit_layer(geom_wgs84, name="AOI"):
    """Wrap any AOI geometry in a one-feature memory layer."""
    from qgis.core import (QgsVectorLayer, QgsFeature, QgsField,
                           QgsGeometry)
    lyr = QgsVectorLayer("Polygon?crs=EPSG:4326", name or "AOI", "memory")
    pr = lyr.dataProvider()
    pr.addAttributes([QgsField("Name_EN", QVariant.String),
                      QgsField("CODE", QVariant.String)])
    lyr.updateFields()
    f = QgsFeature(lyr.fields())
    f.setGeometry(QgsGeometry(geom_wgs84))
    f["Name_EN"] = name or "AOI"
    f["CODE"] = ""
    pr.addFeatures([f])
    lyr.updateExtents()
    return lyr, "Name_EN", "CODE"


def build_admin_map(iface, p, build_layout):
    """Build the admin map layout.

    Simplified: always uses "surround" mode internally.
    - Context layers (water, points) are always loaded for main map
    - Water bodies and Sea are always loaded for insets
    - Surroundings uses the ORIGINAL FULL layer at the same level
    - Labels are auto-enabled for all surrounding features
    """
    project = QgsProject.instance()
    level = p["level"]
    code = p["unit_code"]

    custom = p.get("custom_geom")
    if custom is not None:
        unit, nf, cf = custom_unit_layer(custom, p.get("unit_name") or "AOI")
    else:
        unit, nf, cf = _unit_layer(level, code)
    if unit is None:
        raise RuntimeError("Could not load the selected admin unit.")
    if unit.crs().isValid():
        project.setCrs(unit.crs())
    try:
        mask = unit.materialize(QgsFeatureRequest())
    except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
        mask = unit

    ext = QgsRectangle(unit.extent())
    if ext.isNull() or ext.isEmpty():
        raise RuntimeError("Selected unit has no extent (check the code).")
    ext.scale(1.08)

    # Always use "surround" behavior internally
    dim_opacity = p.get("dim_opacity", 0.55)

    root = project.layerTreeRoot()
    for gname in ("Main", "Inset 1", "Inset 2"):
        ex = root.findGroup(gname)
        if ex is not None:
            root.removeChildNode(ex)
    main_grp = root.insertGroup(0, "Main")

    def register(lyr):
        project.addMapLayer(lyr, False)

    point_layers, line_layers, water_layers, boundary_layers = [], [], [], []
    basemap = mask_layer = None

    if p.get("basemap"):
        try:
            g = QgsRasterLayer(GOOGLE_SAT, "Google Satellite", "wms")
            if g.isValid():
                register(g)
                basemap = g
        except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
            pass

    # ALWAYS load context layers for main map (no "Layers to include" filtering)
    # Points are essential context; water bodies are shown in insets only
    for disp in CONTEXT:
        folder, shp, qml, kind = CONTEXT[disp]
        # Skip water bodies for main map - they appear in insets only
        if kind == "water":
            continue
        path = os.path.join(folder, shp)
        if not os.path.exists(path):
            continue
        lyr = QgsVectorLayer(path, disp, "ogr")
        if not lyr.isValid():
            continue
        # Always clip context to the focus area for cleaner main map
        lyr = _clip(lyr, mask)
        lyr.setName(disp)
        if kind == "point":
            if qml:
                try:
                    lyr.loadNamedStyle(os.path.join(STY, qml))
                except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
                    _style_points_default(lyr)
            else:
                _style_points_default(lyr)
            point_layers.append(lyr)
        else:
            if qml:
                try:
                    lyr.loadNamedStyle(os.path.join(STY, qml))
                except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
                    pass
            _prune_categories_to_data(lyr)
            line_layers.append(lyr)
        register(lyr)

    # Sub-level boundaries within the focus unit (always shown)
    for sublevel, pfield, sname in SUBLEVELS.get(level, []):
        sl = _sublevel_layer(sublevel, pfield, code, sname)
        if sl is None or sl.featureCount() == 0:
            continue
        register(sl)
        boundary_layers.append(sl)

    # Selected unit boundary (always shown)
    UNIT_LAYER_NAME.get(level, LEVEL_LABELS.get(level, "Boundary"))
    _style_unit_with_qml(unit, level, nf, label=p.get("label_unit", True))
    register(unit)
    boundary_layers.append(unit)

    # Surrounding areas: use ORIGINAL FULL layer at the SAME level
    # This shows ALL units at the focus level (e.g. all communes)
    surround_layer = None
    if level in ACTIVE_ADMIN and level not in ("admin0",):
        surround_layer = _surrounding_units_full_layer(
            project, level, code, label_opacity=dim_opacity)
        if surround_layer is not None:
            boundary_layers.insert(0, surround_layer)

    # Focus mask (dim outside) - always applied
    mask_layer = _focus_mask(project, mask, dim_opacity)

    # Add to Main group: points, lines, water, boundaries (boundaries on top)
    for lyr in point_layers + line_layers + water_layers + boundary_layers:
        main_grp.addLayer(lyr)
    if basemap is not None:
        main_grp.addLayer(basemap)
    if mask_layer is not None:
        try:
            main_grp.insertLayer(0, mask_layer)
        except (AttributeError, TypeError, RuntimeError):  # Layer tree
            main_grp.addLayer(mask_layer)

    point_ids = [lyr.id() for lyr in point_layers]
    line_ids = [lyr.id() for lyr in line_layers]
    water_ids = [lyr.id() for lyr in water_layers]
    boundary_ids = [lyr.id() for lyr in boundary_layers]

    # Legend: boundaries first (skip surrounding layer), then points and lines
    # Surrounding layer renders but does not appear in legend
    legend_boundary_ids = boundary_ids[1:] if surround_layer is not None else boundary_ids
    legend_ids = legend_boundary_ids + point_ids + line_ids

    # Render order: boundaries on top, then mask, context, basemap at bottom
    render_ids = boundary_ids[:]
    if mask_layer is not None:
        render_ids.append(mask_layer.id())
    render_ids += point_ids + line_ids + water_ids
    if basemap is not None:
        render_ids.append(basemap.id())

    # Insets
    insets, inset_groups = [], []
    unit_shp = ACTIVE_ADMIN.get(level, ACTIVE_ADMIN.get("province", ACTIVE_ADMIN.get("admin1")))[0]
    n = 0
    for cfg in p.get("insets", []):
        if not cfg.get("enabled"):
            continue
        cfg = dict(cfg)
        if cfg.get("auto_unit"):
            # Auto-configure based on level hierarchy
            if level == "commune" and p.get("dist_name"):
                # Two-level: district in province view, commune on top
                cfg["layer_id"] = "bundled::" + os.path.join(
                    BND, ACTIVE_ADMIN["district"][0])
                cfg["field"] = ACTIVE_ADMIN["district"][1]
                cfg["value"] = p.get("dist_name", "")
                cfg["layer2_id"] = "bundled::" + os.path.join(BND, unit_shp)
                cfg["field2"] = nf
                cfg["value2"] = p.get("unit_name", "")
                cfg.setdefault("color2", "#d7191c")
                cfg["level"] = "district"
            elif level == "district" and p.get("prov_name"):
                cfg["layer_id"] = "bundled::" + os.path.join(
                    BND, ACTIVE_ADMIN["province"][0])
                cfg["field"] = ACTIVE_ADMIN["province"][1]
                cfg["value"] = p.get("prov_name", "")
                cfg["layer2_id"] = "bundled::" + os.path.join(BND, unit_shp)
                cfg["field2"] = nf
                cfg["value2"] = p.get("unit_name", "")
                cfg.setdefault("color2", "#d7191c")
                cfg["level"] = "province"
            else:
                cfg["layer_id"] = "bundled::" + os.path.join(BND, unit_shp)
                cfg["field"] = nf
                cfg["value"] = p.get("unit_name", "")
                cfg["level"] = level
        insets.append(cfg)
        n += 1
        inset_groups.append(root.addGroup("Inset %d" % n))

    lp = {
        "layout_name": "Map Layout - " + p.get("unit_name", ""),
        "map_title": p.get("title") or (p.get("unit_name", "") or "MAP TITLE"),
        "institution": p.get("institution") or "YOUR INSTITUTION NAME",
        "coord_format": p.get("coord_format", "auto"),
        "legend_title": "Legend",
        "data_sources": p.get("data_sources", ""),
        "legend_layer_ids": legend_ids,
        "boundary_layer_ids": boundary_ids,
        "main_layer_ids": render_ids,
        "inset_groups": inset_groups,
        "page": "A3", "orientation": "Landscape",
        "extent_rect": ext, "scalebar_unit": "auto",
        "scale": None, "scale_mode": "round",
        "insets": insets,
    }
    layout = build_layout(iface, lp)

    # Map themes
    main_layers = [project.mapLayer(i) for i in render_ids]
    _make_theme(project, "Main", [lyr for lyr in main_layers if lyr])
    for i, grp in enumerate(inset_groups, start=1):
        try:
            _make_theme(project, "Inset %d" % i,
                        [n.layer() for n in grp.findLayers()])
        except (AttributeError, TypeError, RuntimeError):  # Layer tree
            pass
    return layout


def locate_unit(point_wgs84):
    """Find the bundled admin unit containing a WGS84 point."""
    from qgis.core import (QgsVectorLayer, QgsGeometry, QgsPointXY,
                           QgsCoordinateTransform, QgsProject,
                           QgsCoordinateReferenceSystem)
    wgs = QgsCoordinateReferenceSystem("EPSG:4326")
    found = {}
    for level in ADMIN_LEVELS:
        shp, name_f, code_f, _parent = ACTIVE_ADMIN[level]
        path = os.path.join(BND, shp)
        if not os.path.exists(path):
            continue
        lyr = QgsVectorLayer(path, "tmp_locate", "ogr")
        if not lyr.isValid():
            continue
        pt = QgsPointXY(point_wgs84)
        if lyr.crs() != wgs:
            try:
                ct = QgsCoordinateTransform(wgs, lyr.crs(),
                                            QgsProject.instance())
                pt = ct.transform(pt)
            except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
                continue
        g = QgsGeometry.fromPointXY(pt)
        names = [fl.name() for fl in lyr.fields()]
        for f in lyr.getFeatures():
            if f.hasGeometry() and f.geometry().contains(g):
                found[level] = (
                    str(f[code_f]) if code_f in names else "",
                    str(f[name_f]) if name_f in names else "")
                break

    if not found:
        return {}
    # Get deepest level found
    for lvl in reversed(ADMIN_LEVELS):
        if lvl in found:
            deepest = lvl
            break
    else:
        deepest = ADMIN_LEVELS[0]
    code, name = found[deepest]

    # Build parent names
    result = {"level": deepest, "code": code, "name": name}
    if "province" in found or "admin1" in found:
        k = "province" if "province" in found else "admin1"
        result["prov_name"] = found[k][1]
    if "district" in found or "admin2" in found:
        k = "district" if "district" in found else "admin2"
        result["dist_name"] = found[k][1]
    return result
