# -*- coding: utf-8 -*-
"""
layout_builder.py  -  Build an automatic A3/A4 print layout in QGIS.

All positions are in millimetres. The design:
  - outer neatline 5 mm inside the page edge
  - centred map title
  - map at a rounded scale, with a UTM grid (grey lines, exterior ticks,
    E/N coordinate annotations) and a rectangle 5 mm outside the annotations
  - a right-hand sidebar (aligned to the map frame) holding logo + caption,
    legend, north arrow, scale bar and scale text
  - author / projection / date in the bottom-right corner
"""

import os
import math
from datetime import date

from qgis.core import (
    QgsProject, QgsPrintLayout, QgsLayoutSize, QgsLayoutPoint,
    QgsUnitTypes, QgsLayoutMeasurement, QgsRectangle,
    QgsCoordinateTransform, QgsCoordinateReferenceSystem,
    QgsLayoutItemShape, QgsLayoutItemLabel, QgsLayoutItemMap,
    QgsLayoutItemLegend, QgsLayoutItemPicture, QgsLayoutItemScaleBar,
    QgsLayoutItemMapGrid, QgsFillSymbol, QgsLineSymbol,
    QgsRuleBasedRenderer, QgsMapLayerStyle, QgsVectorLayer,
)
from qgis.PyQt.QtGui import QFont, QColor
from qgis.PyQt.QtCore import Qt, QVariant

MM = QgsUnitTypes.LayoutUnit.LayoutMillimeters
BLACK = QColor(0, 0, 0)
BLUE = QColor(0, 40, 160)
TITLE_COLOR = QColor(0x18, 0x00, 0x54)   # #180054
GREY = "128,128,128,128"          # 50% transparent grey
ASSETS = os.path.join(os.path.dirname(__file__), "assets")

PAGE_MM = {"A3": (420.0, 297.0), "A4": (297.0, 210.0)}


# ------------------------------------------------------------------ helpers
def nice_scale_up(scale):
    """Round a scale denominator UP to a value with 1-2 significant digits."""
    if scale <= 0:
        return 1000
    exp = math.floor(math.log10(scale))
    base = 10 ** exp
    mant = scale / base
    for s in (1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 8, 10):
        if mant <= s + 1e-9:
            return int(round(s * base))
    return int(round(10 * base))


def nice_interval(raw):
    """Round a raw interval to a cartographic 1/2/2.5/5 x 10^k value."""
    if raw <= 0:
        return 1000
    exp = math.floor(math.log10(raw))
    base = 10 ** exp
    mant = raw / base
    for s in (1, 2, 2.5, 5, 10):
        if mant <= s:
            return s * base
    return 10 * base


def nice_down(v):
    """Largest cartographic 1/2/2.5/5 x 10^k value that is <= v."""
    if v <= 0:
        return 0.0
    exp = math.floor(math.log10(v))
    best = 0.0
    for e in (exp - 1, exp, exp + 1):
        base = 10.0 ** e
        for s in (1, 2, 2.5, 5):
            c = s * base
            if c <= v and c > best:
                best = c
    return best


def _rect(layout, x, y, w, h, width_mm=0.3, fill=False,
          fill_color="255,255,255,255", stroke="0,0,0,255"):
    r = QgsLayoutItemShape(layout)
    r.setShapeType(QgsLayoutItemShape.Shape.Rectangle)
    props = {"outline_color": stroke, "outline_width": str(width_mm),
             "outline_width_unit": "MM"}
    props["style"] = "solid" if fill else "no"
    if fill:
        props["color"] = fill_color
    r.setSymbol(QgsFillSymbol.createSimple(props))
    layout.addLayoutItem(r)
    r.attemptMove(QgsLayoutPoint(x, y, MM))
    r.attemptResize(QgsLayoutSize(w, h, MM))
    return r


def _label(layout, text, x, y, w, h, size=10, bold=False, color=BLACK,
           halign=Qt.AlignmentFlag.AlignHCenter, valign=Qt.AlignmentFlag.AlignVCenter, family="Arial"):
    lbl = QgsLayoutItemLabel(layout)
    lbl.setText(text)
    f = QFont(family, int(round(size)))
    f.setBold(bold)
    lbl.setFont(f)
    try:
        lbl.setFontColor(color)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    lbl.setHAlign(halign)
    lbl.setVAlign(valign)
    layout.addLayoutItem(lbl)
    lbl.attemptMove(QgsLayoutPoint(x, y, MM))
    lbl.attemptResize(QgsLayoutSize(w, h, MM))
    return lbl


def _picture(layout, path, x, y, w, h):
    p = QgsLayoutItemPicture(layout)
    p.setPicturePath(path)
    try:
        p.setResizeMode(QgsLayoutItemPicture.ResizeMode.Zoom)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    layout.addLayoutItem(p)
    p.attemptMove(QgsLayoutPoint(x, y, MM))
    p.attemptResize(QgsLayoutSize(w, h, MM))
    return p


def _extent_of(layers, map_crs):
    ext = QgsRectangle()
    ext.setMinimal()
    for lyr in layers:
        le = lyr.extent()
        if le.isNull() or le.isEmpty():
            continue
        if lyr.crs().isValid() and map_crs.isValid() and lyr.crs() != map_crs:
            try:
                tr = QgsCoordinateTransform(lyr.crs(), map_crs,
                                            QgsProject.instance())
                le = tr.transformBoundingBox(le)
            except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
                continue
        ext.combineExtentWith(le)
    return ext


# ------------------------------------------------------------------ main
def build_layout(iface, params):
    project = QgsProject.instance()
    map_crs = project.crs()

    page = params.get("page", "A3")
    W, H = PAGE_MM.get(page, PAGE_MM["A3"])
    if params.get("orientation", "Landscape") == "Portrait":
        W, H = H, W

    # ---- extent ----
    all_layers = [lyr for lyr in project.mapLayers().values()
                  if hasattr(lyr, "extent")]
    if params.get("extent_rect") is not None:
        extent = params["extent_rect"]
    elif params.get("extent_source") == "canvas" and iface is not None:
        extent = iface.mapCanvas().extent()
    else:
        extent = _extent_of(all_layers, map_crs)
    if extent.isNull() or extent.isEmpty():
        raise RuntimeError("No layers with a valid extent were found. Add "
                           "data to the project before generating a layout.")

    # ---- create layout ----
    name = params.get("layout_name") or "Automatic Map Layout"
    layout = QgsPrintLayout(project)
    layout.initializeDefaults()
    layout.setName(name)
    mgr = project.layoutManager()
    dup = mgr.layoutByName(name)
    if dup is not None:
        mgr.removeLayout(dup)
    layout.pageCollection().pages()[0].setPageSize(
        QgsLayoutSize(W, H, MM))

    # ---- geometry ----
    NEAT, inner, gap = 5.0, 6.0, 6.0
    sidebar_w = min(90.0, max(60.0, 0.20 * W))
    ann_lr, ann_tb, pad = 8.0, 6.0, 5.0

    top_h = 19.0          # smaller arrow/logo -> reclaim 3 mm upward
    title_y, title_h = NEAT + 1.0, top_h
    content_top = NEAT + top_h + 3.0
    content_bottom = H - NEAT - 3.0

    nr_left = NEAT + inner
    nr_right = W - NEAT - inner - sidebar_w - gap
    nr_top, nr_bottom = content_top, content_bottom

    mf_left = nr_left + (ann_lr + pad)
    mf_right = nr_right - (ann_lr + pad)
    mf_top = nr_top + (ann_tb + pad)
    mf_bottom = nr_bottom - (ann_tb + pad)
    mf_w, mf_h = mf_right - mf_left, mf_bottom - mf_top

    # right sidebar: top & bottom equal to the left (number) rectangle
    sb_x = W - NEAT - inner - sidebar_w
    sb_y, sb_w, sb_h = nr_top, sidebar_w, nr_bottom - nr_top

    # ---- neatline / sidebar / number rectangle ----
    _rect(layout, NEAT, NEAT, W - 2 * NEAT, H - 2 * NEAT, width_mm=0.3)
    _rect(layout, sb_x, sb_y, sb_w, sb_h, width_mm=0.3, fill=True,
          fill_color="255,255,255,255")
    _rect(layout, nr_left, nr_top, nr_right - nr_left, nr_bottom - nr_top,
          width_mm=0.3)

    # ---- map ----
    m = QgsLayoutItemMap(layout)
    layout.addLayoutItem(m)
    m.attemptMove(QgsLayoutPoint(mf_left, mf_top, MM))
    m.attemptResize(QgsLayoutSize(mf_w, mf_h, MM))
    if map_crs.isValid():
        m.setCrs(map_crs)
    m.zoomToExtent(extent)
    scale_mode = params.get("scale_mode", "round")
    manual = params.get("scale")
    if scale_mode == "manual" and manual:
        m.setScale(float(manual))
    elif scale_mode == "round":
        m.setScale(nice_scale_up(m.scale()))
    # "fit" -> keep the full-extent scale
    m.setFrameEnabled(True)
    m.setFrameStrokeColor(BLACK)
    m.setFrameStrokeWidth(QgsLayoutMeasurement(0.3, MM))
    m.setBackgroundColor(QColor(255, 255, 255))
    main_ids = params.get("main_layer_ids")
    if main_ids:
        try:
            layers = [project.mapLayer(i) for i in main_ids]
            layers = [lyr for lyr in layers if lyr is not None]
            if layers:
                m.setLayers(layers)
                m.setKeepLayerSet(True)
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
    _configure_grid(m, extent, map_crs,
                    params.get("coord_format", "auto"))

    # ================= top band =================
    na_png = os.path.join(ASSETS, "north_arrow.jpg")
    if os.path.exists(na_png):
        _picture(layout, na_png, NEAT + 3.0, NEAT + 2.0, 16.0, 16.0)

    tsize = 26 if W >= 400 else 20
    _label(layout, params.get("map_title") or "MAP TITLE",
           nr_left, title_y, nr_right - nr_left, title_h,
           size=tsize, bold=True, color=TITLE_COLOR)

    # emblem: a map-drawing cartoon (falls back to any logo if present)
    logo_png = os.path.join(ASSETS, "map_drawing.png")
    if not os.path.exists(logo_png):
        logo_png = os.path.join(ASSETS, "rupp_logo.png")
    logo_w = 16.0
    logo_x = W - NEAT - 3.0 - logo_w
    logo_y = NEAT + 2.0
    if os.path.exists(logo_png):
        _picture(layout, logo_png, logo_x, logo_y, logo_w, logo_w)
    cap_w = 66.0
    _label(layout,
           params.get("institution") or "YOUR INSTITUTION NAME",
           logo_x - 3.0 - cap_w, logo_y - 1.0, cap_w, logo_w + 2.0,
           size=8, bold=True, color=BLUE,
           halign=Qt.AlignmentFlag.AlignRight, valign=Qt.AlignmentFlag.AlignVCenter)

    # ================= sidebar contents =================
    sp = 4.0
    cx, cw = sb_x + sp, sb_w - 2 * sp
    sb_bottom = sb_y + sb_h

    insets_cfg = [c for c in params.get("insets", []) if c.get("enabled")]
    n_ins = len(insets_cfg)
    ds_text = (params.get("data_sources") or "").strip()

    legend_min = 66.0
    ds_total = (16.0 + 3.0) if ds_text else 0.0
    avail_ins = sb_h - legend_min - ds_total - 9.0
    if n_ins > 0:
        slot_h = min(62.0, max(28.0, (avail_ins - (n_ins - 1) * 3.0) / n_ins))
        insets_total = n_ins * slot_h + (n_ins - 1) * 3.0
    else:
        slot_h = insets_total = 0.0
    insets_top = sb_bottom - 3.0 - insets_total

    if ds_text:
        ds_h = 16.0
        ds_y = insets_top - 3.0 - ds_h
        _rect(layout, cx, ds_y, cw, ds_h, width_mm=0.25)
        ds_label_text = "Data sources:" + "\n" + ds_text
        _label(layout, ds_label_text,
               cx + 1.5, ds_y + 1.0, cw - 3.0, ds_h - 2.0,
               size=6, halign=Qt.AlignmentFlag.AlignLeft, valign=Qt.AlignmentFlag.AlignVCenter)
        legend_bottom = ds_y - 3.0
    else:
        legend_bottom = insets_top - 3.0

    # ---- accuracy assessment: single line, bottom-center INSIDE the map
    #      frame, white background, no rectangle / no outline.
    rp_text = (params.get("report_text") or "").strip()
    if rp_text:
        from qgis.core import (QgsLayoutItemLabel as _QLbl,
                               QgsLayoutPoint as _QPt,
                               QgsLayoutSize as _QSz,
                               QgsUnitTypes as _QUt)
        from qgis.PyQt.QtGui import QFont as _QFont, QColor as _QColor
        # keep only the first summary line (e.g.
        # "Random Forest | OA: 55.9% | Kappa: 0.412")
        one_line = rp_text.splitlines()[0].strip()
        lab = _QLbl(layout)
        try:
            lab.setMode(_QLbl.ModeFont)
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
        lab.setText(one_line)
        fnt = _QFont("Arial")
        fnt.setPointSizeF(9.0)
        fnt.setBold(True)
        lab.setFont(fnt)
        try:
            lab.setHAlign(Qt.AlignmentFlag.AlignHCenter)
            lab.setVAlign(Qt.AlignmentFlag.AlignVCenter)
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
        # white background, no frame
        try:
            lab.setBackgroundEnabled(True)
            lab.setBackgroundColor(_QColor(255, 255, 255))
            lab.setFrameEnabled(False)
            lab.setMarginX(2.0)
            lab.setMarginY(1.0)
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
        box_w = min(120.0, mf_w - 20.0)
        box_h = 7.0
        lx = mf_left + (mf_w - box_w) / 2.0
        ly = mf_bottom - box_h - 4.0
        lab.attemptMove(_QPt(lx, ly, _QUt.LayoutMillimeters))
        lab.attemptResize(_QSz(box_w, box_h, _QUt.LayoutMillimeters))
        try:
            lab.setReferencePoint(_QLbl.UpperLeft)
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
        layout.addLayoutItem(lab)

    leg_y = 27.0
    _add_legend(layout, m, params.get("legend_layer_ids", []),
                cx, leg_y, cw, max(24.0, legend_bottom - leg_y),
                params.get("legend_title", "Legend"))

    inset_groups = params.get("inset_groups") or []
    for k, cfg in enumerate(insets_cfg):
        # Inset 1 (k=0) sits at the TOP, Inset 2 below it
        iy = insets_top + k * (slot_h + 3.0)
        lyr = _resolve_inset_layer(project, cfg.get("layer_id", "") or "",
                                   fresh=True)
        if lyr is None:
            continue
        grp = inset_groups[k] if k < len(inset_groups) else None
        try:
            _add_inset(layout, project, m, map_crs, lyr, cfg,
                       (cx, iy, cw, slot_h), grp)
        except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
            pass

    # ================= scale (bottom-left, inside map) =================
    _sbar = _add_scalebar(layout, m, mf_left + 4.0, mf_bottom - 20.0, 62.0,
                          map_crs, params.get("scalebar_unit", "auto"))
    _num = _add_numeric_scale(layout, m, mf_left + 4.0, mf_bottom - 9.0)
    # centre the numeric text under the graphic bar
    try:
        gw = _sbar.rect().width()
        nw = _num.rect().width()
        if gw > 0 and nw > 0:
            _num.attemptMove(QgsLayoutPoint(
                mf_left + 4.0 + (gw - nw) / 2.0, mf_bottom - 9.0, MM))
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass

    # ================= author (bottom-right, inside map) =================
    epsg = map_crs.authid() or "unknown"
    au_w, au_h = 52.0, 13.0
    _label(layout,
           "Projection: {}\nCreated: {}".format(
               epsg, date.today().isoformat()),
           mf_right - au_w - 3.0, mf_bottom - au_h - 3.0, au_w, au_h,
           size=7, halign=Qt.AlignmentFlag.AlignRight, valign=Qt.AlignmentFlag.AlignBottom)

    mgr.addLayout(layout)
    return layout


def _utm_crs_for(extent, map_crs):
    """Pick the UTM zone CRS for the centre of the extent."""
    try:
        c = extent.center()
        if map_crs.isValid() and not map_crs.isGeographic():
            ct = QgsCoordinateTransform(
                map_crs, QgsCoordinateReferenceSystem("EPSG:4326"),
                QgsProject.instance())
            c = ct.transform(c)
        lon, lat = c.x(), c.y()
        zone = int((lon + 180) / 6) + 1
        epsg = (32600 if lat >= 0 else 32700) + zone
        return QgsCoordinateReferenceSystem("EPSG:%d" % epsg)
    except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
        return map_crs


def _degree_span(extent, map_crs):
    """Approximate width of the extent in degrees."""
    try:
        if map_crs.isValid() and not map_crs.isGeographic():
            ct = QgsCoordinateTransform(
                map_crs, QgsCoordinateReferenceSystem("EPSG:4326"),
                QgsProject.instance())
            e = ct.transformBoundingBox(extent)
            return e.width()
    except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
        pass
    return extent.width()


def nice_degree_interval(raw):
    """Round a degree interval to a friendly value."""
    for step in (0.01, 0.02, 0.05, 0.1, 0.2, 0.25, 0.5, 1.0, 2.0, 5.0):
        if raw <= step:
            return step
    return 10.0


def _configure_grid(m, extent, map_crs, coord_format="auto"):
    LG = QgsLayoutItemMapGrid
    grid = m.grid()
    grid.setEnabled(True)

    # choose the grid CRS from the requested coordinate format
    geographic = map_crs.isValid() and map_crs.isGeographic()
    fmt = coord_format
    if fmt == "auto":
        fmt = "dd" if geographic else "utm"

    if fmt in ("dd", "dms"):
        grid_crs = QgsCoordinateReferenceSystem("EPSG:4326")
    elif fmt == "utm":
        grid_crs = _utm_crs_for(extent, map_crs)
    else:
        grid_crs = map_crs
    if grid_crs.isValid():
        grid.setCrs(grid_crs)

    # sensible interval for the units (degrees vs metres)
    if fmt in ("dd", "dms"):
        span = _degree_span(extent, map_crs)
        interval = nice_degree_interval(span / 6.0) if span > 0 else 0.1
    else:
        interval = (nice_interval(extent.width() / 6.0)
                    if extent.width() > 0 else 1000)
    grid.setIntervalX(interval)
    grid.setIntervalY(interval)

    # grey, thin, 50% transparent grid lines
    grid.setStyle(LG.Solid)
    try:
        grid.setLineSymbol(QgsLineSymbol.createSimple(
            {"color": GREY, "width": "0.1", "width_unit": "MM"}))
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass

    # black frame + thicker exterior ticks
    grid.setFrameStyle(LG.ExteriorTicks)
    try:
        grid.setFramePenSize(0.5)
        grid.setFramePenColor(BLACK)
        grid.setFrameWidth(2.0)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass

    # coordinate annotations - format depends on the requested type
    grid.setAnnotationEnabled(True)
    try:
        if fmt == "dms":
            grid.setAnnotationFormat(LG.DegreeMinuteSecond)
        elif fmt == "dd":
            grid.setAnnotationFormat(LG.DecimalWithSuffix)
        else:                                   # utm / projected: metres
            grid.setAnnotationFormat(LG.CustomFormat)
            grid.setAnnotationExpression(
                "format_number(@grid_number,0) || "
                "if(@grid_axis='x','mE','mN')")
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    # left/right = vertical (ascending); top/bottom = horizontal
    for border, disp, direction in (
            (LG.Left, LG.LatitudeOnly, LG.Vertical),
            (LG.Right, LG.LatitudeOnly, LG.Vertical),
            (LG.Top, LG.LongitudeOnly, LG.Horizontal),
            (LG.Bottom, LG.LongitudeOnly, LG.Horizontal)):
        try:
            grid.setAnnotationDisplay(disp, border)
            grid.setAnnotationPosition(LG.OutsideMapFrame, border)
            grid.setAnnotationDirection(direction, border)
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
    try:
        af = QFont("Arial", 8)
        af.setBold(True)
        grid.setAnnotationFont(af)
        grid.setAnnotationFontColor(BLACK)
        grid.setAnnotationFrameDistance(1.0)
        grid.setAnnotationPrecision(0)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass


def _add_scalebar(layout, m, x, y, avail_w, map_crs, unit_choice):
    """
    Add a 2 mm graphic scale bar (5 left segments, auto km/m units) with a
    segment size that fits `avail_w`, placed left-aligned at (x, y).
    """
    sb = QgsLayoutItemScaleBar(layout)
    layout.addLayoutItem(sb)
    sb.setLinkedMap(m)
    try:
        sb.setStyle("Single Box")
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass

    scale = m.scale() or 1.0
    num_right = 2
    budget_mm = (avail_w * 0.9) / (num_right + 2)
    seg_ground_m = max(1.0, budget_mm * scale / 1000.0)

    use_km = (unit_choice == "km") or \
             (unit_choice == "auto" and seg_ground_m >= 1000.0)
    if unit_choice == "m":
        use_km = False
    if use_km:
        u = QgsUnitTypes.DistanceUnit.DistanceKilometers
        seg = nice_down(seg_ground_m / 1000.0) or 1.0
        label = "km"
    else:
        u = QgsUnitTypes.DistanceUnit.DistanceMeters
        seg = nice_down(seg_ground_m) or 1.0
        label = "m"

    try:
        sb.setUnits(u)
        sb.setUnitLabel(label)
        sb.applyDefaultSize(u)
        sb.setUnitsPerSegment(seg)
        sb.setNumberOfSegments(num_right)
        sb.setNumberOfSegmentsLeft(5)
        sb.setHeight(2.0)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass

    sb.attemptMove(QgsLayoutPoint(x, y, MM))
    return sb


def _add_numeric_scale(layout, m, x, y):
    """A second scale bar in Numeric style (shows 1:scale, auto-updates)."""
    nb = QgsLayoutItemScaleBar(layout)
    layout.addLayoutItem(nb)
    nb.setLinkedMap(m)
    try:
        nb.setStyle("Numeric")
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    try:
        from qgis.core import QgsBasicNumericFormat
        fmt = QgsBasicNumericFormat()
        fmt.setShowThousandsSeparator(True)
        fmt.setNumberDecimalPlaces(0)
        nb.setNumericFormat(fmt)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    try:
        tf = nb.textFormat()
        tf.setSize(9)
        tf.setSizeUnit(QgsUnitTypes.RenderUnit.RenderPoints)
        nb.setTextFormat(tf)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    nb.attemptMove(QgsLayoutPoint(x, y, MM))
    return nb


def _add_legend(layout, m, layer_ids, x, y, w, h, title="Legend"):
    legend = QgsLayoutItemLegend(layout)
    layout.addLayoutItem(legend)
    legend.setLinkedMap(m)
    legend.setTitle(title or "")
    try:
        legend.setTitleAlignment(Qt.AlignmentFlag.AlignHCenter)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    # spacing: below the legend Title = 1.50 mm; above each subgroup = 2.00 mm
    try:
        from qgis.core import QgsLegendStyle
        st_title = legend.style(QgsLegendStyle.Style.Title)
        st_title.setMargin(QgsLegendStyle.Side.Bottom, 1.5)
        legend.setStyle(QgsLegendStyle.Style.Title, st_title)

        st_sub = legend.style(QgsLegendStyle.Style.Subgroup)
        st_sub.setMargin(QgsLegendStyle.Side.Top, 2.0)
        legend.setStyle(QgsLegendStyle.Style.Subgroup, st_sub)

        st_grp = legend.style(QgsLegendStyle.Style.Group)
        st_grp.setMargin(QgsLegendStyle.Side.Top, 2.0)
        legend.setStyle(QgsLegendStyle.Style.Group, st_grp)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    try:
        legend.setColumnCount(1)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    try:
        legend.setLegendFilterByMapEnabled(False)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass

    # Restrict to the selected layers by editing the model's OWN root group
    # in place (never setRootGroup() from Python — that crashes QGIS).
    wanted = set(layer_ids or [])
    legend.setAutoUpdateModel(False)
    root = legend.model().rootGroup()
    if root is not None:
        for node in root.findLayers():
            if node.layerId() not in wanted:
                parent = node.parent() or root
                parent.removeChildNode(node)
        # hide group titles (e.g. "Main") and drop now-empty groups
        try:
            from qgis.core import QgsLegendStyle
            for grp in list(root.findGroups()):
                if not grp.findLayers():
                    (grp.parent() or root).removeChildNode(grp)
                else:
                    try:
                        grp.setCustomProperty("legend/title-style", "hidden")
                        from qgis.core import QgsLegendRenderer
                        QgsLegendRenderer.setNodeLegendStyle(
                            grp, QgsLegendStyle.Style.Hidden)
                    except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
                        pass
        except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
            pass

    try:
        legend.setResizeToContents(False)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    # No white background box: the frame rectangle behind the legend already
    # provides the border, and a filled legend background was overlapping the
    # rectangle's top line. Transparent background + a small downward nudge.
    try:
        legend.setBackgroundEnabled(False)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    try:
        legend.setFrameEnabled(False)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass
    legend.attemptMove(QgsLayoutPoint(x, y + 1.0, MM))   # nudge down ~1 mm
    legend.attemptResize(QgsLayoutSize(w, h, MM))
    return legend


# ---- data / context layers bundled for insets -----------------------------
_BND_DIR = os.path.join(os.path.dirname(__file__), "data", "boundaries")
_SEA_PATH = os.path.join(_BND_DIR, "Sea.shp")
_WATER_PATH = os.path.join(_BND_DIR, "Waterbody.shp")

# blue fills for the automatic context layers
# Sea and Waterbody share the SAME blue so coastal maps look consistent
_WATER_RGBA = "150,200,230,255"
_SEA_RGBA = _WATER_RGBA


def _rgba(hexstr, alpha=255):
    h = str(hexstr or "").lstrip("#")
    if len(h) != 6:
        return "255,224,145,%d" % alpha
    return "%d,%d,%d,%d" % (int(h[0:2], 16), int(h[2:4], 16),
                            int(h[4:6], 16), alpha)


def _labeling(field, only_expr=None):
    """White-buffered labels on `field`. If only_expr is given, only features
    matching that expression are labelled. Returns labeling or None."""
    if not field:
        return None
    try:
        from qgis.core import (QgsPalLayerSettings, QgsVectorLayerSimpleLabeling,
                               QgsTextFormat, QgsTextBufferSettings)
        s = QgsPalLayerSettings()
        s.fieldName = field
        s.enabled = True
        # place one label centred over each polygon (default drifts off-centre)
        try:
            s.placement = QgsPalLayerSettings.PredefinedPointPosition.OverPoint
            s.centroidInside = True
        except (AttributeError, TypeError, RuntimeError):  # Safe fallback
            try:
                s.placement = QgsPalLayerSettings.Placement.Horizontal
            except (AttributeError, TypeError, RuntimeError):  # Safe fallback
                pass
        tf = QgsTextFormat()
        tf.setSize(9)
        try:
            tf.setSizeUnit(QgsUnitTypes.RenderUnit.RenderPoints)
        except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
            pass
        f = QFont("Arial", 9)
        f.setBold(True)
        tf.setFont(f)
        buf = QgsTextBufferSettings()
        buf.setEnabled(True)
        buf.setSize(0.8)
        buf.setColor(QColor(255, 255, 255))
        tf.setBuffer(buf)
        s.setFormat(tf)
        if only_expr:
            try:
                from qgis.core import QgsProperty
                s.dataDefinedProperties().setProperty(
                    QgsProperty.Property.Show,
                    QgsProperty.fromExpression(only_expr))
            except (AttributeError, TypeError):  # Qt/QGIS API compatibility
                pass
        return QgsVectorLayerSimpleLabeling(s)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        return None


def _style_from(layer, renderer, labeling=None):
    """Capture renderer (+ optional labeling) from a clone as style XML."""
    try:
        clone = layer.clone()
        clone.setRenderer(renderer)
        if labeling is not None:
            clone.setLabeling(labeling)
            clone.setLabelsEnabled(True)
        else:
            clone.setLabelsEnabled(False)
        st = QgsMapLayerStyle()
        st.readFromLayer(clone)
        return st.xmlData()
    except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
        return None


def _fill_style(layer, rgba):
    """Single blue fill style for sea / waterbody. Returns XML or None."""
    try:
        from qgis.core import QgsSingleSymbolRenderer
        sym = QgsFillSymbol.createSimple({
            "color": rgba, "outline_color": rgba,
            "outline_width": "0", "outline_width_unit": "MM"})
        return _style_from(layer, QgsSingleSymbolRenderer(sym))
    except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
        return None


def _inset_extent(layer, field, value, mode, map_crs):
    """Extent (in map_crs) for the inset: whole layer, or around the matched
    feature expanded to show its region."""
    if mode == "around" and field and value:
        try:
            from qgis.core import QgsFeatureRequest
            idx = layer.fields().indexOf(field)
            numeric = idx >= 0 and layer.fields().at(idx).type() in (
                QVariant.Int, QVariant.UInt, QVariant.LongLong,
                QVariant.ULongLong, QVariant.Double)
            if numeric:
                expr = '"%s" = %s' % (field, value)
            else:
                expr = 'lower("%s") = lower(\'%s\')' % (
                    field, str(value).replace("'", "''"))
            rect = QgsRectangle()
            rect.setMinimal()
            for feat in layer.getFeatures(QgsFeatureRequest().setFilterExpression(expr)):
                g = feat.geometry()
                if g is not None and not g.isEmpty():
                    rect.combineExtentWith(g.boundingBox())
            if not rect.isNull() and not rect.isEmpty():
                rect.scale(3.0)      # show the surrounding region
                if layer.crs().isValid() and map_crs.isValid() and \
                   layer.crs() != map_crs:
                    tr = QgsCoordinateTransform(layer.crs(), map_crs,
                                                QgsProject.instance())
                    rect = tr.transformBoundingBox(rect)
                return rect
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
    return _extent_of([layer], map_crs)


def _add_inset(layout, project, main_map, map_crs, layer, cfg, rect, group=None):
    """
    Locator map placed in the given rect (x, y, w, h) — used inside the right
    sidebar. Shows the boundary layer (matched feature highlighted in
    cfg['color']), with Sea and Waterbody as automatic blue context, and
    optional feature labels.

    FIXED: Now always includes Sea and Waterbody layers in insets, regardless
    of focus mode. Also ensures proper layer ordering for visibility.
    """
    field = cfg.get("field")
    value = cfg.get("value")
    fill_hex = cfg.get("color") or "#ffe091"
    label_mode = cfg.get("labels") or "none"
    clip_focus = bool(cfg.get("clip_focus"))
    clip_parent = cfg.get("clip_to_parent")
    # zoom and clipping are independent: honour the chosen zoom exactly.
    mode = "around" if cfg.get("zoom") == "around" else "whole"

    # clip_to_parent: restrict this layer to the children of a parent unit
    # (e.g. show only the districts inside the selected province) so Inset 2
    # displays the whole province, not the entire country.
    if clip_parent:
        _clip_to_parent(layer, clip_parent)

    # extent: when clipped to a parent, frame the whole parent, not just
    # the highlighted child
    if clip_parent:
        ext = layer.extent()
        ext = _to_map_crs_rect(layer, ext, map_crs)
    else:
        ext = _inset_extent(layer, field, value, mode, map_crs)
    if ext.isNull() or ext.isEmpty():
        return None
    ext.scale(1.02 if clip_focus else 1.06)
    x, y, box_w, box_h = rect

    ins = QgsLayoutItemMap(layout)
    layout.addLayoutItem(ins)
    ins.attemptMove(QgsLayoutPoint(x, y, MM))
    ins.attemptResize(QgsLayoutSize(box_w, box_h, MM))
    if map_crs.isValid():
        ins.setCrs(map_crs)

    # context layers (loaded once, placed in the inset's group if given)
    if group is not None:
        try:
            group.addLayer(layer)
        except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
            pass

    # FIXED: Always load sea and water for insets - they are essential context
    sea = None
    water = None
    if os.path.exists(_SEA_PATH):
        sea = _resolve_inset_layer(project, "bundled::" + _SEA_PATH, group, fresh=True)
    if os.path.exists(_WATER_PATH):
        water = _resolve_inset_layer(project, "bundled::" + _WATER_PATH, group, fresh=True)

    # optional SECOND highlight (e.g. district inside the province)
    layer2 = None
    if cfg.get("layer2_id") and cfg.get("field2") and cfg.get("value2"):
        layer2 = _resolve_inset_layer(
            project, cfg.get("layer2_id", "") or "", group, fresh=True)
        if layer2 is not None:
            _apply_highlight_only(layer2, cfg["field2"], cfg["value2"],
                                  cfg.get("color2") or "#000000",
                                  cfg.get("fill_style2") or "Solid")

    # FIXED: Layer order - water on bottom, then sea, then layer2, then main layer
    # This ensures water bodies are visible beneath boundaries
    ordered = []                      # top -> bottom
    if layer2 is not None:
        ordered.append(layer2)
    ordered.append(layer)
    if water is not None:
        ordered.insert(0, water)  # Water above sea but below boundaries
    if sea is not None:
        ordered.insert(0, sea)  # Sea at the very bottom
    try:
        ins.setLayers(ordered)
        ins.setKeepLayerSet(True)
    except (AttributeError, TypeError):  # Qt/QGIS API compatibility
        pass

    ins.zoomToExtent(ext)
    ins.setFrameEnabled(True)
    ins.setFrameStrokeColor(BLACK)
    ins.setFrameStrokeWidth(QgsLayoutMeasurement(0.3, MM))
    ins.setBackgroundColor(QColor(255, 255, 255))

    # clip_focus: show ONLY the highlighted unit (subset filter on a copy)
    if clip_focus and field and value:
        _clip_to_feature(layer, field, value)

    # style the layers DIRECTLY so the Layers panel matches the inset map
    _apply_boundary(layer, field, value, fill_hex, label_mode,
                    cfg.get("fill_style") or "Solid")
    if sea is not None:
        _apply_fill(sea, _SEA_RGBA)
    if water is not None:
        _apply_fill(water, _WATER_RGBA)

    # no highlight value -> draw an overview box marking the main map extent
    if not (field and value):
        try:
            ov = ins.overview()
            ov.setLinkedMap(main_map)
            ov.setEnabled(True)
            ov.setFrameSymbol(QgsFillSymbol.createSimple({
                "color": "227,26,28,50", "outline_color": "200,0,0,255",
                "outline_width": "0.4", "outline_width_unit": "MM"}))
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
    return ins


# QGIS simple-fill style tokens for the UI names
FILL_STYLES = {
    "Solid": "solid",
    "No fill": "no",
    "Horizontal": "horizontal",
    "Vertical": "vertical",
    "Cross": "cross",
    "BDiagonal": "b_diagonal",
    "FDiagonal": "f_diagonal",
    "Diagonal X": "diagonal_x",
    "Dense 4": "dense4",
    "Dense 6": "dense6",
}


def _fill_token(name):
    return FILL_STYLES.get(name or "Solid", "solid")


def _to_map_crs_rect(layer, rect, map_crs):
    """Transform a layer-CRS rectangle to the map CRS for the inset extent."""
    try:
        from qgis.core import (QgsCoordinateTransform, QgsProject)
        if map_crs.isValid() and layer.crs() != map_crs:
            ct = QgsCoordinateTransform(layer.crs(), map_crs,
                                        QgsProject.instance())
            return ct.transformBoundingBox(rect)
    except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
        pass
    return rect


def _clip_to_parent(layer, parent_name):
    """Restrict `layer` to features whose admin code shares the prefix of
    the parent unit named `parent_name`. Works for KH_Districts within a
    province and KH_Communes within a district.

    Falls back gracefully: if the code fields are not found, no clip is
    applied (the inset simply shows the whole layer).
    """
    try:
        fields = [f.name() for f in layer.fields()]
        # find this layer's code field and a plausible parent code
        code_field = next((c for c in ("SROK_CODE", "KHUM_CODE",
                                       "KHET_CODE", "DIS_CODE", "COM_CODE",
                                       "PRO_CODE") if c in fields), None)
        if not code_field:
            return
        # find the parent's code by locating a feature whose NAME matches
        # parent_name in the province layer bundled alongside
        import os
        prov_path = os.path.join(_BND_DIR, "KH_Provinces.shp")
        from qgis.core import QgsVectorLayer
        prov = QgsVectorLayer(prov_path, "prov_tmp", "ogr")
        parent_code = None
        if prov.isValid():
            pf = [f.name() for f in prov.fields()]
            pn = next((c for c in ("Name_EN", "NAME_EN") if c in pf), None)
            pc = next((c for c in ("KHET_CODE", "PRO_CODE") if c in pf),
                      None)
            if pn and pc:
                want = str(parent_name).strip().lower()
                for f in prov.getFeatures():
                    if str(f[pn]).strip().lower() == want:
                        parent_code = str(f[pc]).strip()
                        break
        if not parent_code:
            return
        # districts start with the 2-digit province code; communes with the
        # 4-digit district code. Match by prefix using an OGR LIKE.
        subset = "%s LIKE '%s%%'" % (code_field, parent_code)
        layer.setSubsetString(subset)
        if layer.featureCount() == 0:
            layer.setSubsetString("")
    except (AttributeError, TypeError, RuntimeError):  # Safe fallback
        try:
            layer.setSubsetString("")
        except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
            pass


def _clip_to_feature(layer, field, value):
    """Restrict a layer to the single matching feature.

    NOTE: subset strings for OGR (shapefile) layers are OGR SQL, which has
    no lower() function - QGIS expressions do, but they are not the same
    language. So we try an exact match first, then fall back to a
    case-insensitive scan that builds an explicit FID list.
    """
    try:
        idx = layer.fields().indexOf(field)
        if idx < 0:
            return False
        numeric = layer.fields().at(idx).type() in (
            QVariant.Int, QVariant.UInt, QVariant.LongLong,
            QVariant.ULongLong, QVariant.Double)
        if numeric:
            subset = '"%s" = %s' % (field, value)
        else:
            subset = '"%s" = \'%s\'' % (
                field, str(value).replace("'", "''"))
        layer.setSubsetString(subset)
        if layer.featureCount() > 0:
            return True

        # exact match failed (case/spacing): find FIDs case-insensitively
        layer.setSubsetString("")
        want = str(value).strip().lower()
        fids = [f.id() for f in layer.getFeatures()
                if str(f[field]).strip().lower() == want]
        if not fids:
            return False
        layer.setSubsetString(
            "FID IN (%s)" % ",".join(str(i) for i in fids))
        return layer.featureCount() > 0
    except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
        try:
            layer.setSubsetString("")
        except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
            pass
        return False


def _apply_boundary(layer, field, value, fill_hex, label_mode="none",
                    fill_style="Solid"):
    """Set a rule-based highlight renderer + optional labels on the layer."""
    try:
        hi = QgsFillSymbol.createSimple({
            "color": _rgba(fill_hex), "style": _fill_token(fill_style),
            "outline_color": "90,90,90,255",
            "outline_width": "0.25", "outline_width_unit": "MM"})
        bg = QgsFillSymbol.createSimple({
            "color": "233,233,233,255", "outline_color": "150,150,150,255",
            "outline_width": "0.12", "outline_width_unit": "MM"})
        root = QgsRuleBasedRenderer.Rule(None)
        root.appendChild(QgsRuleBasedRenderer.Rule(bg))
        expr = None
        if field and value:
            idx = layer.fields().indexOf(field)
            numeric = idx >= 0 and layer.fields().at(idx).type() in (
                QVariant.Int, QVariant.UInt, QVariant.LongLong,
                QVariant.ULongLong, QVariant.Double)
            if numeric:
                expr = '"%s" = %s' % (field, value)
            else:
                expr = 'lower("%s") = lower(\'%s\')' % (
                    field, str(value).replace("'", "''"))
            root.appendChild(QgsRuleBasedRenderer.Rule(hi, 0, 0, expr))
        layer.setRenderer(QgsRuleBasedRenderer(root))
        labeling = None
        if label_mode == "all" and field:
            labeling = _labeling(field)
        elif label_mode == "highlight" and field and expr:
            labeling = _labeling(field, only_expr=expr)
        if labeling is not None:
            layer.setLabeling(labeling); layer.setLabelsEnabled(True)
        else:
            layer.setLabelsEnabled(False)
    except (AttributeError, TypeError, RuntimeError):  # Safe fallback
        pass


def _apply_highlight_only(layer, field, value, fill_hex,
                          fill_style="Solid"):
    """Rule-based renderer that draws ONLY the matched feature (used for a
    second-level highlight drawn on top of the primary inset layer)."""
    try:
        hi = QgsFillSymbol.createSimple({
            "color": _rgba(fill_hex), "style": _fill_token(fill_style),
            "outline_color": "60,60,60,255",
            "outline_width": "0.3", "outline_width_unit": "MM"})
        root = QgsRuleBasedRenderer.Rule(None)
        idx = layer.fields().indexOf(field)
        numeric = idx >= 0 and layer.fields().at(idx).type() in (
            QVariant.Int, QVariant.UInt, QVariant.LongLong,
            QVariant.ULongLong, QVariant.Double)
        if numeric:
            expr = '"%s" = %s' % (field, value)
        else:
            expr = 'lower("%s") = lower(\'%s\')' % (
                field, str(value).replace("'", "''"))
        root.appendChild(QgsRuleBasedRenderer.Rule(hi, 0, 0, expr))
        layer.setRenderer(QgsRuleBasedRenderer(root))
        layer.setLabelsEnabled(False)
    except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
        pass


def _apply_fill(layer, rgba):
    try:
        from qgis.core import QgsSingleSymbolRenderer
        sym = QgsFillSymbol.createSimple({
            "color": rgba, "outline_color": rgba,
            "outline_width": "0", "outline_width_unit": "MM"})
        layer.setRenderer(QgsSingleSymbolRenderer(sym))
    except (AttributeError, TypeError, RuntimeError):  # QGIS rendering
        pass


def _resolve_inset_layer(project, spec, group=None, fresh=False):
    """
    Resolve an inset boundary layer from a project layer id or a bundled
    shapefile.

    When `fresh` is True a NEW layer object is always created (never reused).
    This is essential when the same shapefile is used by more than one inset
    or as both a primary and secondary highlight: each needs its OWN renderer
    and subset string, otherwise configuring the second inset overwrites the
    first one's highlight (the "Inset 1 stops highlighting when Inset 2 is
    ticked" bug).
    """
    import os as _os
    if not spec:
        return None
    if spec.startswith("bundled::"):
        path = spec[len("bundled::"):]
        target = _os.path.normpath(path)
        if not fresh:
            for lyr in project.mapLayers().values():
                try:
                    if isinstance(lyr, QgsVectorLayer) and \
                       _os.path.normpath(lyr.source().split("|")[0]) == target:
                        if lyr.subsetString():   # the filtered admin unit
                            continue             # don't reuse for the inset
                        return lyr
                except (AttributeError, TypeError, RuntimeError):  # QGIS geometry
                    pass
        lyr = QgsVectorLayer(
            path, _os.path.splitext(_os.path.basename(path))[0], "ogr")
        if not lyr.isValid():
            return None
        project.addMapLayer(lyr, False)   # registered, not auto-added to tree
        if group is not None:
            try:
                group.addLayer(lyr)
            except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
                pass
        return lyr
    return project.mapLayer(spec)
