# -*- coding: utf-8 -*-
"""FSC Harvest Map — PDF Exporter (Dynamic Fields).

Batch-exports individual A4 portrait PDFs for FSC harvest plots.
Reuses layout / engine.py (LvtEngine) for all layout creation,
signature blocks, legend, north arrow, and label updates.

For each plot:
  1. Create a temp memory layer with ONLY the target feature
  2. Apply highlight renderer + fraction-style label
  3. Lock map CRS to the layer CRS
  4. Set map layers explicitly (temp_plot + aux layers)
  5. Add info block (owner, area, CRS, commune...)
  6. Create layout via LvtEngine → Export PDF at 300 dpi
  7. Remove temp layer

Author: Lộc Vũ Trung (LVT) / Slow Forest
License: GPL-3.0
"""

import os
import re
import unicodedata

from qgis.PyQt.QtCore import QCoreApplication
from qgis.PyQt.QtGui import QColor, QFont
from qgis.core import (
    QgsWkbTypes,
    QgsProject, QgsLayoutExporter, QgsLayoutItemMap, QgsLayoutItemLabel,
    QgsLayoutItemLegend, QgsLegendStyle, QgsLayoutPoint, QgsLayoutSize,
    QgsLayoutMeasurement, QgsRectangle, QgsCoordinateTransform, QgsMessageLog,
    QgsPalLayerSettings, QgsVectorLayerSimpleLabeling, QgsTextFormat, QgsTextBufferSettings,
    QgsFillSymbol, QgsSingleSymbolRenderer, QgsVectorLayer, QgsFeature,
    QgsUnitTypes, Qgis
)


# Nice scale steps for auto-fit
_NICE_SCALES = [1000, 1500, 2000, 2500, 3000, 4000, 5000, 7500,
                10000, 12500, 15000, 20000, 25000]
_NONE = "— (none) —"
_DEG_TO_M = 111320.0  # approximate meters per degree at equator


def _is_geographic(crs):
    """Check if CRS uses geographic (degree) units."""
    if not crs or not crs.isValid():
        return False
    return crs.isGeographic()


def _get_padding(bbox, crs):
    """Get appropriate padding in CRS units.

    For projected CRS: min 10m padding.
    For geographic CRS: min ~20m equivalent in degrees.
    """
    if _is_geographic(crs):
        min_pad = 0.0002  # ~22 meters
    else:
        min_pad = 10  # 10 meters
    pad_x = max(bbox.width() * 0.3, min_pad)
    pad_y = max(bbox.height() * 0.3, min_pad)
    return pad_x, pad_y


def _auto_scale(bbox, crs=None, map_w_mm=180, map_h_mm=220, padding=1.3):
    """Calculate auto-fit scale from bounding box.

    Handles both projected (meters) and geographic (degrees) CRS.
    """
    from qgis.core import QgsMessageLog
    bw = bbox.width() * padding
    bh = bbox.height() * padding
    if bw <= 0 or bh <= 0:
        return 2000

    is_geo = crs and _is_geographic(crs)
    # Also detect degree-scale bbox even if CRS claims projected
    # (happens when transform was skipped or failed)
    if not is_geo and bw < 1.0 and bh < 1.0:
        # bbox values < 1 in a projected CRS = likely still in degrees
        is_geo = True

    if is_geo:
        bw *= _DEG_TO_M
        bh *= _DEG_TO_M

    scale_x = bw / (map_w_mm / 1000.0)
    scale_y = bh / (map_h_mm / 1000.0)
    raw = max(scale_x, scale_y)

    QgsMessageLog.logMessage(
        f"[FSC] _auto_scale: bbox_w={bbox.width():.6f} "
        f"bbox_h={bbox.height():.6f} is_geo={is_geo} "
        f"raw={raw:.1f} crs={crs.authid() if crs else 'None'}",
        "LVT4U")

    for s in _NICE_SCALES:
        if s >= raw:
            return s
    return _NICE_SCALES[-1]


def _safe_filename(text):
    """Convert Vietnamese text to safe ASCII filename."""
    if not text:
        return "unknown"
    nfkd = unicodedata.normalize("NFKD", str(text))
    ascii_str = nfkd.encode("ASCII", "ignore").decode("ASCII")
    safe = re.sub(r"[^A-Za-z0-9_\-]", "_", ascii_str)
    safe = re.sub(r"_+", "_", safe).strip("_")
    return safe or "unknown"


def _fget(feat, field_name, none_marker=_NONE):
    """Safely get feature attribute value as string."""
    if not field_name or field_name == none_marker:
        return ""
    names = feat.fields().names()
    if field_name not in names:
        return ""
    v = feat[field_name]
    if v is None or str(v) == "NULL":
        return ""
    return str(v)


def _parse_month_year(month_str, year_str):
    """Parse month / year, handling Excel serial dates.

    Excel serial: 44432 = 2021-09-01, 45535 = 2024-09-01
    Returns (month_display, year_display) as strings.
    """
    from datetime import datetime, timedelta

    month = month_str
    year = year_str

    # Detect Excel serial date in month field (5-digit numbers)
    if month and month.isdigit() and len(month) >= 5:
        try:
            serial = int(month)
            # Excel epoch: Jan 1, 1900 = serial 1
            dt = datetime(1899, 12, 30) + timedelta(days=serial)
            month = str(dt.month)
            if not year:
                year = str(dt.year)
        except (ValueError, OverflowError):
            pass  # nosec B110

    # Also check year field for serial dates
    if year and year.isdigit() and len(year) >= 5:
        try:
            serial = int(year)
            dt = datetime(1899, 12, 30) + timedelta(days=serial)
            year = str(dt.year)
            if not month or month == month_str:  # month not yet parsed
                month = str(dt.month)
        except (ValueError, OverflowError):
            pass  # nosec B110

    return month, year


def _create_vertex_layer(feat, layer_crs, map_crs,
                         min_dist_m=10.0, max_count=30,
                         show_length=False):
    """Create a temporary point layer with numbered vertex markers.

    Vertices are filtered by minimum distance and max count.
    Marker style: concentric rings (outer=black, mid=white, inner=red).

    Returns (QgsVectorLayer or None, [(num, x, y, dist_to_next), ...])
    """
    import math
    from qgis.core import (
        QgsVectorLayer as _VL, QgsFeature as _Feat,
        QgsGeometry as _Geom, QgsPointXY as _Pt,
        QgsCoordinateTransform as _CT,
        QgsMarkerSymbol, QgsSimpleMarkerSymbolLayer,
        QgsSingleSymbolRenderer as _SSR,
        QgsPalLayerSettings, QgsTextFormat, QgsTextBufferSettings,
        QgsVectorLayerSimpleLabeling,
    )
    from qgis.PyQt.QtCore import Qt
    from qgis.PyQt.QtGui import QColor

    geom = feat.geometry()
    if not geom or geom.isEmpty():
        return None, []

    # Extract outer ring
    try:
        if geom.isMultipart():
            outer = geom.asMultiPolygon()[0][0]
        else:
            outer = geom.asPolygon()[0]
    except (IndexError, AttributeError):
        return None, []

    # Remove closing duplicate point
    if len(outer) > 1 and outer[0] == outer[-1]:
        outer = outer[:-1]
    if not outer:
        return None, []

    is_geo = _is_geographic(layer_crs)

    def _dist_m(p1, p2):
        """Approximate planar distance in meters between two QgsPointXY."""
        dx = p2.x() - p1.x()
        dy = p2.y() - p1.y()
        if is_geo:
            lat = (p1.y() + p2.y()) / 2.0
            dx *= 111320.0 * math.cos(math.radians(lat))
            dy *= 110540.0
        return math.sqrt(dx * dx + dy * dy)

    # Filter by min distance
    kept = [outer[0]]
    for pt in outer[1:]:
        if _dist_m(kept[-1], pt) >= min_dist_m:
            kept.append(pt)
        if len(kept) >= max_count:
            break

    # Transform to map CRS
    project = QgsProject.instance()
    xform = None
    if layer_crs.isValid() and map_crs.isValid():
        if layer_crs.authid() != map_crs.authid():
            xform = _CT(layer_crs, map_crs, project)

    # Create memory layer
    mem = _VL(
        f'Point?crs={map_crs.authid()}'
        '&field=stt:integer&field=x_coord:double&field=y_coord:double',
        'Mốc', 'memory')
    prov = mem.dataProvider()

    # Collect projected points first (needed for distance calc)
    proj_pts = []  # list of QgsPointXY in map CRS
    is_geo_map = _is_geographic(map_crs)

    for i, pt in enumerate(kept):
        pt_xy = _Pt(pt.x(), pt.y()) if hasattr(pt, 'x') else _Pt(pt[0], pt[1])
        if xform:
            try:
                pt_xy = xform.transform(pt_xy)
            except Exception:
                pt_xy = None
        proj_pts.append(pt_xy)

    vertex_list = []
    feats = []
    decimals = 6 if is_geo_map else 2

    for i, pt_xy in enumerate(proj_pts):
        if pt_xy is None:
            continue
        x_val = round(pt_xy.x(), decimals)
        y_val = round(pt_xy.y(), decimals)

        # Distance to next vertex (polygon: last→first)
        next_idx = (i + 1) % len(proj_pts)
        next_pt = proj_pts[next_idx]
        if next_pt is not None:
            d = _dist_m(pt_xy, next_pt) if is_geo_map else math.sqrt(
                (next_pt.x() - pt_xy.x())**2 + (next_pt.y() - pt_xy.y())**2)
        else:
            d = 0.0

        f = _Feat()
        f.setGeometry(_Geom.fromPointXY(pt_xy))
        f.setAttributes([i + 1, x_val, y_val])
        feats.append(f)
        vertex_list.append((i + 1, x_val, y_val, round(d, 2)))

    prov.addFeatures(feats)
    mem.updateExtents()

    # ── Symbol: 3 concentric rings ───────────────────────────────────────
    symbol = QgsMarkerSymbol()
    symbol.deleteSymbolLayer(0)  # remove default single layer
    for size_mm, hex_color in [(4.0, '#111111'), (2.8, '#ffffff'), (1.6, '#cc2200')]:
        sl = QgsSimpleMarkerSymbolLayer(
            QgsSimpleMarkerSymbolLayer.Shape.Circle)
        sl.setColor(QColor(hex_color))
        sl.setStrokeStyle(Qt.PenStyle.NoPen)
        sl.setSizeUnit(QgsUnitTypes.RenderUnit.RenderMillimeters)
        sl.setSize(size_mm)
        symbol.appendSymbolLayer(sl)
    mem.setRenderer(_SSR(symbol))

    # ── Labels: vertex number ─────────────────────────────────────
    lbl = QgsPalLayerSettings()
    lbl.fieldName = 'stt'
    lbl.placement = QgsPalLayerSettings.Placement.AroundPoint
    lbl.dist = 2.0

    tf = QgsTextFormat()
    fnt = QFont('Arial', 6)
    fnt.setBold(True)
    tf.setFont(fnt)
    tf.setSize(6)
    tf.setColor(QColor('#111111'))

    buf = QgsTextBufferSettings()
    buf.setEnabled(True)
    buf.setSize(0.6)
    buf.setColor(QColor('#ffffff'))
    tf.setBuffer(buf)
    lbl.setFormat(tf)

    mem.setLabeling(QgsVectorLayerSimpleLabeling(lbl))
    mem.setLabelsEnabled(True)

    # ── Optional: edge-length label layer ───────────────────────────────
    length_layer = None
    if show_length and len(vertex_list) >= 2:
        pass  # nosec B110
        # Create midpoint layer: one point at mid of each edge, labelled with dist
        length_layer = _VL(
            f'Point?crs={map_crs.authid()}&field=dist_label:string',
            '__fsc_edge_lengths__', 'memory')
        lprov = length_layer.dataProvider()
        lfeats = []
        n = len(proj_pts)
        for i in range(n):
            p1 = proj_pts[i]
            p2 = proj_pts[(i + 1) % n]
            if p1 is None or p2 is None:
                continue
            mx = (p1.x() + p2.x()) / 2.0
            my = (p1.y() + p2.y()) / 2.0
            d = vertex_list[i][3]
            lf = _Feat()
            lf.setGeometry(_Geom.fromPointXY(_Pt(mx, my)))
            lf.setAttributes([f"{d:.1f} m"])
            lfeats.append(lf)
        lprov.addFeatures(lfeats)
        length_layer.updateExtents()

        # Invisible marker, labelled with dist_label
        from qgis.core import QgsNullSymbolRenderer
        length_layer.setRenderer(QgsNullSymbolRenderer())

        llbl = QgsPalLayerSettings()
        llbl.fieldName = 'dist_label'
        llbl.placement = QgsPalLayerSettings.Placement.AroundPoint
        llbl.dist = 1.0
        ltf = QgsTextFormat()
        lfnt = QFont('Arial', 5)
        lfnt.setItalic(True)
        ltf.setFont(lfnt)
        ltf.setSize(5)
        ltf.setColor(QColor('#1B5E20'))
        lbuf = QgsTextBufferSettings()
        lbuf.setEnabled(True)
        lbuf.setSize(0.5)
        lbuf.setColor(QColor('#ffffff'))
        ltf.setBuffer(lbuf)
        llbl.setFormat(ltf)
        length_layer.setLabeling(QgsVectorLayerSimpleLabeling(llbl))
        length_layer.setLabelsEnabled(True)

    return mem, length_layer, vertex_list


def _add_coord_table(layout, map_item, vertex_list, is_geographic):
    """Add a floating coordinate table label.

    Fixed layout-page position:
      X = 144 mm, W = 46 mm (fixed)
      Bottom anchor = 265 mm (= 190 + 75).
      Height grows UPWARD when more vertices are present.
      Max height = 75 mm.
    """
    if not vertex_list or not map_item:
        return

    # Support both old (num, x, y) and new (num, x, y, dist) tuples
    has_dist = len(vertex_list[0]) >= 4

    # Format X/Y with consistent decimal places
    coord_fmt = ".6f" if is_geographic else ".2f"

    # ── Build HTML table ────────────────────────────────────────────
    # Tight padding so everything fits inside 46 mm width
    # background:#ffffff (fully opaque) so map grid cannot show through
    lines = []
    lines.append('<table border="1" cellspacing="0" cellpadding="1" '
                 'style="font-family:Arial;font-size:6.5pt;'
                 'border-collapse:collapse;background:#ffffff">')
    header = ('<tr style="background:#d4edda">'
              '<th align="center" style="padding:1px 3px">STT</th>'
              '<th align="center" style="padding:1px 4px">X</th>'
              '<th align="center" style="padding:1px 4px">Y</th>')
    if has_dist:
        header += '<th align="center" style="padding:1px 3px">Dài (m)</th>'
    header += '</tr>'
    lines.append(header)
    for row in vertex_list:
        num, x, y = row[0], row[1], row[2]
        dist = row[3] if has_dist else None
        x_str = format(x, coord_fmt)
        y_str = format(y, coord_fmt)
        # background:#ffffff on each data row for full opacity
        tr_str = (f'<tr style="background:#ffffff">'
                  f'<td align="center" style="padding:1px 2px">{num}</td>'
                  f'<td align="right"  style="padding:1px 2px">{x_str}</td>'
                  f'<td align="right"  style="padding:1px 2px">{y_str}</td>')
        if has_dist and dist is not None:
            tr_str += (f'<td align="right" style="padding:1px 2px">'
                       f'{dist:.2f}</td>')
        tr_str += '</tr>'
        lines.append(tr_str)
    lines.append('</table>')
    html = '\n'.join(lines)

    # ── Fixed layout-page dimensions ────────────────────────────────
    TABLE_X = 144.0   # mm from page left
    TABLE_W = 46.0    # mm (fixed width)
    TABLE_BOTTOM = 265.0   # mm from page top  (= 190 + 75, fixed anchor)
    MAX_H = 75.0    # mm maximum height

    row_h = 4.2   # mm per data row (tighter than before)
    header_h = 6.5   # mm for header row
    tbl_h = min(header_h + len(vertex_list) * row_h + 1.5, MAX_H)

    # Anchor at bottom → grow upward
    tbl_x = TABLE_X
    tbl_y = TABLE_BOTTOM - tbl_h

    # ── Create layout item ───────────────────────────────────────────
    lbl_item = QgsLayoutItemLabel(layout)
    lbl_item.setMode(QgsLayoutItemLabel.Mode.ModeHtml)
    lbl_item.setText(html)
    lbl_item.setFrameEnabled(False)
    # Background is handled by HTML table CSS (background:#ffffff on each
    # cell/row), so we disable the QGIS item background to avoid a larger
    # white rectangle appearing outside the table borders.
    lbl_item.setBackgroundEnabled(False)
    lbl_item.setId('fsc_coord_table')

    layout.addLayoutItem(lbl_item)
    lbl_item.attemptMove(
        QgsLayoutPoint(tbl_x, tbl_y, QgsUnitTypes.LayoutUnit.LayoutMillimeters))
    lbl_item.attemptResize(
        QgsLayoutSize(TABLE_W, tbl_h, QgsUnitTypes.LayoutUnit.LayoutMillimeters))
    lbl_item.refresh()


def _create_single_feature_layer(feat, crs, layer_name="__fsc_plot__"):
    """Create a temporary memory layer containing exactly ONE feature."""
    geom_type = QgsWkbTypes.displayString(feat.geometry().wkbType())
    uri = f"{geom_type}?crs={crs.authid()}"
    mem = QgsVectorLayer(uri, layer_name, "memory")
    dp = mem.dataProvider()

    # Copy all fields
    dp.addAttributes(feat.fields().toList())
    mem.updateFields()

    # Copy the feature
    new_feat = QgsFeature(mem.fields())
    new_feat.setGeometry(feat.geometry())
    for field in feat.fields():
        if field.name() in [f.name() for f in mem.fields()]:
            new_feat[field.name()] = feat[field.name()]
    dp.addFeatures([new_feat])
    mem.updateExtents()

    return mem


def _style_plot_layer(layer):
    """Apply highlight renderer: green fill + red stroke."""
    sym = QgsFillSymbol.createSimple({
        'color': '180,255,180,60',
        'outline_color': '255,0,0,255',
        'outline_width': '0.8',
    })
    layer.setRenderer(QgsSingleSymbolRenderer(sym))


def _apply_label(layer, expr):
    """Apply fraction-style label expression.

    Styled to match the existing QML label standard:
    - Arial 7pt, regular weight
    - Center-aligned multiline
    - Placed at polygon centroid (OverPoint)
    - White buffer for readability
    """
    settings = QgsPalLayerSettings()
    settings.fieldName = expr
    settings.isExpression = True
    try:
        settings.multilineAlign = Qgis.LabelMultiLineAlignment.Center
    except AttributeError:
        try:
            settings.multilineAlign = QgsPalLayerSettings.MultiLineAlign.MultiCenter
        except AttributeError:
            pass  # nosec B110

    fmt = QgsTextFormat()
    font = QFont("Arial", 7)
    fmt.setFont(font)
    fmt.setSize(7)
    fmt.setColor(QColor(0, 0, 0))

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

    settings.setFormat(fmt)
    layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
    layer.setLabelsEnabled(True)


def _add_fsc_legend(layout, map_layers, map_item=None,
                    x_mm=5, y_mm=235, w_mm=60, h_mm=30):
    """Add a legend showing ONLY the layers used in the map layout.

    - Frame: 0.03mm solid line
    - Font: Arial 7pt
    - Position: bottom-left of page
    - Filters legend to only show map_layers (not all project layers)
    """
    legend = QgsLayoutItemLegend(layout)
    legend.setTitle("Chú giải")

    # Link to map item
    if map_item:
        legend.setLinkedMap(map_item)

    # Add to layout first (stable object lifetime)
    legend.setId("fsc_legend")
    layout.addLayoutItem(legend)

    # Disable auto-update so we can manually control which layers appear
    legend.setAutoUpdateModel(False)

    # Clear all existing layers from legend, then add only map_layers
    # Exclude internal helper layers (distance labels) from legend display
    _LEGEND_EXCLUDE = {'__fsc_edge_lengths__'}
    root = legend.model().rootGroup()
    root.removeAllChildren()
    for lyr in map_layers:
        if lyr and lyr.isValid():
            if lyr.name() in _LEGEND_EXCLUDE:
                continue  # hide distance label layer from legend
            root.addLayer(lyr)

    # Font settings — Title: Arial 12pt bold, rest: Arial 7pt
    for style_type in [QgsLegendStyle.Style.Title,
                       QgsLegendStyle.Style.Group,
                       QgsLegendStyle.Style.Subgroup,
                       QgsLegendStyle.Style.SymbolLabel]:
        style = legend.style(style_type)
        if style_type == QgsLegendStyle.Style.Title:
            f = QFont("Arial", 12)
            f.setBold(True)
        else:
            f = QFont("Arial", 7)
        style.setFont(f)
        legend.setStyle(style_type, style)

    # Symbol size
    legend.setSymbolWidth(5)
    legend.setSymbolHeight(3.5)

    # Frame: 0.03mm
    legend.setFrameEnabled(True)
    legend.setFrameStrokeWidth(
        QgsLayoutMeasurement(0.03, QgsUnitTypes.LayoutUnit.LayoutMillimeters))
    legend.setFrameStrokeColor(QColor(0, 0, 0))

    # Background
    legend.setBackgroundEnabled(True)
    legend.setBackgroundColor(QColor(255, 255, 255, 230))

    # Position + size
    legend.attemptMove(
        QgsLayoutPoint(x_mm, y_mm, QgsUnitTypes.LayoutUnit.LayoutMillimeters))
    legend.attemptResize(
        QgsLayoutSize(w_mm, h_mm, QgsUnitTypes.LayoutUnit.LayoutMillimeters))
    legend.setResizeToContents(True)

    return legend


def preview_plot(iface, plugin_dir, feat, params):
    """Create a layout for ONE feature and return it for Designer preview.

    Unlike export_plots, this does NOT export to PDF. The layout stays
    in the project's layout manager so the user can inspect it in the
    QGIS Layout Designer. Temp layers are also kept alive.

    Returns:
        QgsPrintLayout: The created layout, or None on failure.
    """
    from ..layout.engine import LvtEngine

    engine = LvtEngine(iface, os.path.join(plugin_dir, "layout"))
    none_marker = params.get("none_marker", _NONE)

    main_layer = params.get("main_layer")
    if not main_layer:
        raise ValueError("No main layer provided")

    project = QgsProject.instance()
    layer_crs = main_layer.crs()

    # Map CRS: use user-selected CRS from dialog, fallback to layer CRS
    map_crs = params.get("map_crs") or main_layer.crs()
    crs_display = map_crs.description() if map_crs.isValid() else "N / A"

    # Dynamic field extraction
    owner = _fget(feat, params.get("owner_field"), none_marker)
    plot_id = _fget(feat, params.get("plot_id_field"), none_marker)
    area = _fget(feat, params.get("area_field"), none_marker)
    month_raw = _fget(feat, params.get("month_field"), none_marker)
    year_raw = _fget(feat, params.get("year_field"), none_marker)
    month, year = _parse_month_year(month_raw, year_raw)
    commune = _fget(feat, params.get("commune_field"), none_marker)
    _ = _fget(feat, "Thon", none_marker)

    # 1. Temp layer with single feature
    temp_layer = _create_single_feature_layer(
        feat, layer_crs, "Phạm vi lô rừng")
    _style_plot_layer(temp_layer)

    label_expr = params.get("label_expr", "")
    if params.get("show_label", True) and label_expr:
        _apply_label(temp_layer, label_expr)

    project.addMapLayer(temp_layer, False)

    # 2b. Vertex markers layer (optional)
    vertex_layer = None
    vertex_list = []
    length_layer = None
    if params.get('show_vertices', False):
        vertex_layer, length_layer, vertex_list = _create_vertex_layer(
            feat, layer_crs, map_crs,
            min_dist_m=float(params.get('vertex_min_dist', 10)),
            max_count=int(params.get('vertex_max_count', 20)),
            show_length=params.get('show_length', False))
        if vertex_layer:
            project.addMapLayer(vertex_layer, False)
        if length_layer:
            project.addMapLayer(length_layer, False)

    # 3. Calculate extent in map CRS
    bbox = feat.geometry().boundingBox()
    if (layer_crs.authid() != map_crs.authid()
            and layer_crs.isValid() and map_crs.isValid()):  # noqa: W503
        transform = QgsCoordinateTransform(
            layer_crs, map_crs, project)
        bbox = transform.transformBoundingBox(bbox)

    if params.get("custom_scale"):
        scale = params["custom_scale"]
    else:
        scale = _auto_scale(bbox, crs=map_crs)

    padded = QgsRectangle(bbox)
    pad_x, pad_y = _get_padding(bbox, map_crs)
    # Extra bottom padding to center polygon above signature blocks
    bottom_extra = pad_y * 1.0
    padded.setXMinimum(bbox.xMinimum() - pad_x)
    padded.setXMaximum(bbox.xMaximum() + pad_x)
    padded.setYMinimum(bbox.yMinimum() - pad_y - bottom_extra)
    padded.setYMaximum(bbox.yMaximum() + pad_y)

    # 4. Signature blocks
    sig_blocks = []
    if params.get("sig_owner", True):
        sig_blocks.append({
            "title": "CHỦ RỪNG", "date": "", "name": owner,
        })
    if params.get("sig_group", True):
        # Resolve group rep name: from field → manual text → empty
        grp_name = ""
        grp_field = params.get("group_rep_field", "")
        none_marker = params.get("none_marker", "")
        if grp_field and grp_field != none_marker:
            grp_name = _fget(feat, grp_field, none_marker)
        if not grp_name or grp_name == none_marker:
            grp_name = params.get("group_rep_name", "")
        sig_blocks.append({
            "title": "ĐẠI DIỆN NHÓM",
            "date": "", "name": grp_name,
        })

    # Build study_area: "Chủ rừng: X | mã_lô | DT ha | xã | năm | CRS"
    area_display = ""
    if area:
        try:
            area_display = f"{float(area):.2f} ha"
        except ValueError:
            area_display = str(area)

    info_parts = []
    if owner:
        info_parts.append(owner)
    if plot_id:
        info_parts.append(plot_id)
    if area_display:
        info_parts.append(area_display)
    if commune:
        info_parts.append(commune)
    if month and year:
        info_parts.append(f"T{month}/{year}")
    elif year:
        info_parts.append(str(year))
    info_parts.append(crs_display)
    study_area = " | ".join(info_parts)

    # 5. Build layout via engine
    layout_params = {
        "mode": "print",
        "lang": "VN",
        "page_width": 210,
        "page_height": 297,
        "scale": scale,
        "extent_mode": "drawn",
        "drawn_extent": padded,
        "title": params.get("title",
                            "BẢN ĐỒ TRÍCH LỤC LÔ RỪNG TRỒNG"),
        "org_name": params.get("org_name", ""),
        "study_area": study_area,
        "data_sources": params.get("subtitle",
                                   "Quản lý rừng bền vững và "
                                   "Chứng chỉ rừng FSC"),
        "show_scalebar": True,
        "show_legend": params.get("show_legend", True),
        "show_north": True,
        "north_arrow_left": 160,
        "north_arrow_top": 55,
        "north_arrow_w": 20,
        "north_arrow_h": 25,
        "signature_block": len(sig_blocks) > 0,
        "sig_blocks": sig_blocks,
        "sig_height": 40,
        "sig_width": 45,
    }

    layout = engine.create_layout(layout_params)

    # Remove any legacy fsc_info_block items
    _remove = [it for it in layout.items()
               if hasattr(it, 'id') and it.id() and  # noqa: W504
               'fsc_info' in it.id().lower()]
    for it in _remove:
        layout.removeLayoutItem(it)

    # Hide page_size label from layout
    _page_ids = {'kích thước trang in', 'print page size'}
    for it in layout.items():
        if isinstance(it, QgsLayoutItemLabel):
            if it.id() and it.id().lower() in _page_ids:
                it.setVisibility(False)

    # 6. Set map CRS + layers
    # Layer order in setLayers(): index 0 = TOP (drawn last/above)
    # Correct stack: length labels > vertex markers > polygon > aux base layers
    aux_layers = params.get("aux_layers", [])
    map_layers = list(aux_layers)   # base layers at bottom
    map_layers.insert(0, temp_layer)  # polygon boundary above base
    if vertex_layer:
        map_layers.insert(0, vertex_layer)  # vertex markers above polygon
    if length_layer:
        map_layers.insert(0, length_layer)  # length labels at the very top

    map_item_ref = None
    for item in layout.items():
        if isinstance(item, QgsLayoutItemMap):
            # Disable template data-defined scale (hardcoded 10000)
            from qgis.core import QgsLayoutObject, QgsProperty
            item.dataDefinedProperties().setProperty(
                QgsLayoutObject.DataDefinedProperty.MapScale,
                QgsProperty())
            item.setCrs(map_crs)
            item.setKeepLayerSet(True)
            item.setLayers(map_layers)
            item.zoomToExtent(padded)
            item.setScale(scale)
            map_item_ref = item
            break

    # Refresh + re-enforce scale (QGIS may revert after refresh)
    from qgis.core import QgsMessageLog
    layout.refresh()
    QCoreApplication.processEvents()
    if map_item_ref:
        map_item_ref.setScale(scale)
        layout.refresh()
        QCoreApplication.processEvents()
        QgsMessageLog.logMessage(
            f"[FSC-preview] Final scale applied: "
            f"1:{map_item_ref.scale():.0f} "
            f"(requested 1:{scale})", "LVT4U")

    # 7. Add legend (positioned right of signature blocks, same height)
    if params.get("show_legend", True) and map_item_ref:
        # Calculate legend position from map frame + sig layout
        map_x = map_item_ref.positionWithUnits().x()
        map_y = map_item_ref.positionWithUnits().y()
        map_w = map_item_ref.sizeWithUnits().width()
        map_h = map_item_ref.sizeWithUnits().height()
        sig_h = layout_params.get("sig_height", 40)
        sig_w = layout_params.get("sig_width", 45)
        sig_ml = layout_params.get("sig_margin_left", 3)
        sig_mb = layout_params.get("sig_margin_bottom", 3)
        n_sig = len(sig_blocks)

        # Same Y as signature blocks
        legend_y = (map_y + map_h) - sig_mb - sig_h
        # X = right after last sig block + small gap
        legend_x = map_x + sig_ml + (n_sig * sig_w) + 2
        # Width = remaining space to right edge of map frame - margin
        legend_w = (map_x + map_w) - legend_x - sig_ml
        legend_h = sig_h  # same height as sig blocks

        if legend_w > 15:  # only add if enough space
            _add_fsc_legend(layout, map_layers, map_item=map_item_ref,
                            x_mm=legend_x, y_mm=legend_y,
                            w_mm=legend_w, h_mm=legend_h)

    layout.refresh()
    QCoreApplication.processEvents()

    # 8. Add coordinate table (optional)
    if params.get('show_coord_table', False) and vertex_list and map_item_ref:
        _add_coord_table(layout, map_item_ref, vertex_list,
                         is_geographic=_is_geographic(map_crs))

    # NOTE: Layout + temp layers are intentionally NOT cleaned up
    # They stay in the project so the Designer can display them.
    # User closes Designer → layout remains until project close or
    # next preview replaces it.

    return layout


def export_plots(iface, plugin_dir, features, params, progress_cb=None):
    """Export PDF maps for selected features."""
    from ..layout.engine import LvtEngine

    engine = LvtEngine(iface, os.path.join(plugin_dir, "layout"))
    output_dir = params["output_dir"]
    os.makedirs(output_dir, exist_ok=True)
    none_marker = params.get("none_marker", _NONE)

    main_layer = params.get("main_layer")
    if not main_layer:
        raise ValueError("No main layer provided")

    total = len(features)
    pdf_paths = []

    for idx, feat in enumerate(features):
        owner = _fget(feat, params.get("owner_field"), none_marker)
        plot_id = _fget(feat, params.get("plot_id_field"), none_marker)

        if progress_cb:
            progress_cb(idx + 1, total,
                        f"[{idx + 1}/{total}] {owner} — {plot_id}")  # noqa: E226

        try:
            pdf_path = _export_single(
                engine, iface, feat, params, output_dir,
                none_marker, main_layer)
            if pdf_path:
                pdf_paths.append(pdf_path)
        except Exception as e:
            QgsMessageLog.logMessage(
                f"Export error for feature {feat.id()}: {e}",
                "LVT4U", Qgis.MessageLevel.Warning
            )

        QCoreApplication.processEvents()

    # Merge PDFs if requested
    if params.get("merge_pdf") and len(pdf_paths) > 1:
        merged = _merge_pdfs(pdf_paths, output_dir, params)
        if merged:
            return [merged]

    return pdf_paths


def _style_main_layer_copy(layer):
    pass  # Unused — kept as no-op for backward compat


def _export_single(engine, iface, feat, params, output_dir,
                   none_marker, main_layer):
    """Export a single feature to PDF using LvtEngine.

    Workflow:
      1. Create temp memory layer with ONLY the target feature
      2. Style + label the temp layer
      3. Add main_layer copy (all plots) for spatial context
      4. Add to project temporarily
      5. Build layout via engine
      6. Lock map CRS (WGS 84 default) + set layers
      7. Set map layers: highlight on top, main below, then aux
      8. Add info block with plot details + CRS
      9. Export PDF
     10. Remove temp layers from project

    Returns:
        str: Path to created PDF, or None on failure.
    """
    project = QgsProject.instance()
    layer_crs = main_layer.crs()

    # Determine map display CRS from user selection
    map_crs = params.get("map_crs") or layer_crs
    crs_display = map_crs.description() if map_crs.isValid() else "N / A"

    # Dynamic field extraction
    owner = _fget(feat, params.get("owner_field"), none_marker)
    plot_id = _fget(feat, params.get("plot_id_field"), none_marker)
    area = _fget(feat, params.get("area_field"), none_marker)
    month_raw = _fget(feat, params.get("month_field"), none_marker)
    year_raw = _fget(feat, params.get("year_field"), none_marker)
    month, year = _parse_month_year(month_raw, year_raw)
    commune = _fget(feat, params.get("commune_field"), none_marker)
    _ = _fget(feat, "Thon", none_marker)

    # ── 1. Create temp memory layer with this ONE feature ──
    temp_layer = _create_single_feature_layer(
        feat, layer_crs, "Phạm vi lô rừng")

    # ── 2. Style + label ──
    _style_plot_layer(temp_layer)

    label_expr = params.get("label_expr", "")
    if params.get("show_label", True) and label_expr:
        _apply_label(temp_layer, label_expr)

    # ── 3. Add temp layer to project (required for layout rendering) ──
    project.addMapLayer(temp_layer, False)

    # ── 4. Calculate extent ──
    # Transform feature bbox to map CRS if needed
    bbox = feat.geometry().boundingBox()
    from qgis.core import QgsMessageLog
    QgsMessageLog.logMessage(
        f"[FSC-export] layer_crs={layer_crs.authid()} "
        f"map_crs={map_crs.authid()} "
        f"raw_bbox=({bbox.width():.6f} x {bbox.height():.6f})",
        "LVT4U")
    if (layer_crs.authid() != map_crs.authid()
            and layer_crs.isValid() and map_crs.isValid()):  # noqa: W503
        transform = QgsCoordinateTransform(
            layer_crs, map_crs, project)
        bbox = transform.transformBoundingBox(bbox)
        QgsMessageLog.logMessage(
            f"[FSC-export] transformed bbox=({bbox.width():.2f} "
            f"x {bbox.height():.2f}) meters",
            "LVT4U")

    if params.get("custom_scale"):
        scale = params["custom_scale"]
        QgsMessageLog.logMessage(
            f"[FSC-export] Using CUSTOM scale: 1:{scale}", "LVT4U")
    else:
        scale = _auto_scale(bbox, crs=map_crs)
        QgsMessageLog.logMessage(
            f"[FSC-export] Using AUTO scale: 1:{scale}", "LVT4U")

    padded = QgsRectangle(bbox)
    pad_x, pad_y = _get_padding(bbox, map_crs)
    # Extra bottom padding to center polygon above signature blocks
    bottom_extra = pad_y * 1.0
    padded.setXMinimum(bbox.xMinimum() - pad_x)
    padded.setXMaximum(bbox.xMaximum() + pad_x)
    padded.setYMinimum(bbox.yMinimum() - pad_y - bottom_extra)
    padded.setYMaximum(bbox.yMaximum() + pad_y)

    # ── 5. Build signature blocks ──
    sig_blocks = []
    if params.get("sig_owner", True):
        sig_blocks.append({
            "title": "CHỦ RỪNG",
            "date": "",
            "name": owner,
        })
    if params.get("sig_group", True):
        # Resolve group rep name: from field → manual text → empty
        grp_name = ""
        grp_field = params.get("group_rep_field", "")
        none_marker = params.get("none_marker", "")
        if grp_field and grp_field != none_marker:
            grp_name = _fget(feat, grp_field, none_marker)
        if not grp_name or grp_name == none_marker:
            grp_name = params.get("group_rep_name", "")
        sig_blocks.append({
            "title": "ĐẠI DIỆN NHÓM",
            "date": "", "name": grp_name,
        })

    # Build study_area: "Chủ rừng: X | mã_lô | DT ha | xã | năm | CRS"
    area_display = ""
    if area:
        try:
            area_display = f"{float(area):.2f} ha"
        except ValueError:
            area_display = str(area)

    info_parts = []
    if owner:
        info_parts.append(owner)
    if plot_id:
        info_parts.append(plot_id)
    if area_display:
        info_parts.append(area_display)
    if commune:
        info_parts.append(commune)
    if month and year:
        info_parts.append(f"T{month}/{year}")
    elif year:
        info_parts.append(str(year))
    info_parts.append(crs_display)
    study_area = " | ".join(info_parts)

    # ── 6. Build layout via engine ──
    layout_params = {
        "mode": "print",
        "lang": "VN",
        "page_width": 210,
        "page_height": 297,
        "scale": scale,
        "extent_mode": "drawn",
        "drawn_extent": padded,
        "title": params.get("title",
                            "BẢN ĐỒ TRÍCH LỤC LÔ RỪNG TRỒNG"),
        "org_name": params.get("org_name", ""),
        "study_area": study_area,
        "data_sources": params.get("subtitle",
                                   "Quản lý rừng bền vững và "
                                   "Chứng chỉ rừng FSC"),
        "show_scalebar": True,
        "show_legend": params.get("show_legend", True),
        "show_north": True,
        "north_arrow_left": 160,
        "north_arrow_top": 55,
        "north_arrow_w": 20,
        "north_arrow_h": 25,
        "signature_block": len(sig_blocks) > 0,
        "sig_blocks": sig_blocks,
        "sig_height": 40,
        "sig_width": 45,
    }

    layout = engine.create_layout(layout_params)

    # Remove any legacy fsc_info_block items
    _remove = [it for it in layout.items()
               if hasattr(it, 'id') and it.id() and  # noqa: W504
               'fsc_info' in it.id().lower()]
    for it in _remove:
        layout.removeLayoutItem(it)

    # Hide page_size label from layout
    _page_ids = {'kích thước trang in', 'print page size'}
    for it in layout.items():
        if isinstance(it, QgsLayoutItemLabel):
            if it.id() and it.id().lower() in _page_ids:
                it.setVisibility(False)

    # ── 7. Set map CRS + layers (only highlighted plot + aux) ──
    aux_layers = params.get("aux_layers", [])
    # Vertex markers + optional length labels
    _vx_layer = None
    _ln_layer = None
    _vx_list = []
    if params.get('show_vertices', False):
        _vx_layer, _ln_layer, _vx_list = _create_vertex_layer(
            feat, layer_crs, map_crs,
            min_dist_m=float(params.get('vertex_min_dist', 10)),
            max_count=int(params.get('vertex_max_count', 20)),
            show_length=params.get('show_length', False))
        if _vx_layer:
            project.addMapLayer(_vx_layer, False)
        if _ln_layer:
            project.addMapLayer(_ln_layer, False)
    # Layer order: length labels > vertex markers > polygon > aux base layers
    map_layers = list(aux_layers)   # base layers at bottom
    map_layers.insert(0, temp_layer)  # polygon boundary above base
    if _vx_layer:
        map_layers.insert(0, _vx_layer)  # vertex markers above polygon
    if _ln_layer:
        map_layers.insert(0, _ln_layer)  # length labels at the very top

    map_item_ref = None
    for item in layout.items():
        if isinstance(item, QgsLayoutItemMap):
            # Disable template data-defined scale (hardcoded 10000)
            from qgis.core import QgsLayoutObject, QgsProperty
            item.dataDefinedProperties().setProperty(
                QgsLayoutObject.DataDefinedProperty.MapScale,
                QgsProperty())
            item.setCrs(map_crs)
            item.setKeepLayerSet(True)
            item.setLayers(map_layers)
            item.zoomToExtent(padded)
            item.setScale(scale)
            map_item_ref = item
            break

    # Refresh + re-enforce scale (QGIS may revert after refresh)
    layout.refresh()
    QCoreApplication.processEvents()
    if map_item_ref:
        map_item_ref.setScale(scale)
        layout.refresh()
        QCoreApplication.processEvents()
        QgsMessageLog.logMessage(
            f"[FSC-export] Final scale applied: "
            f"1:{map_item_ref.scale():.0f} "
            f"(requested 1:{scale})", "LVT4U")

    # 8. Add legend (positioned right of signature blocks, same height)
    if params.get("show_legend", True) and map_item_ref:
        map_x = map_item_ref.positionWithUnits().x()
        map_y = map_item_ref.positionWithUnits().y()
        map_w = map_item_ref.sizeWithUnits().width()
        map_h = map_item_ref.sizeWithUnits().height()
        sig_h = layout_params.get("sig_height", 40)
        sig_w = layout_params.get("sig_width", 45)
        sig_ml = layout_params.get("sig_margin_left", 3)
        sig_mb = layout_params.get("sig_margin_bottom", 3)
        n_sig = len(sig_blocks)

        legend_y = (map_y + map_h) - sig_mb - sig_h
        legend_x = map_x + sig_ml + (n_sig * sig_w) + 2
        legend_w = (map_x + map_w) - legend_x - sig_ml
        legend_h = sig_h

        if legend_w > 15:
            _add_fsc_legend(layout, map_layers, map_item=map_item_ref,
                            x_mm=legend_x, y_mm=legend_y,
                            w_mm=legend_w, h_mm=legend_h)

    # ── 8b. Coordinate table (optional) ──
    if params.get('show_coord_table', False) and _vx_list and map_item_ref:
        _add_coord_table(layout, map_item_ref, _vx_list,
                         _is_geographic(map_crs))

    layout.refresh()
    QCoreApplication.processEvents()

    # ── 9. Export PDF ──
    month_str = f"T{month}" if month else ""
    filename = f"{_safe_filename(owner)}_{_safe_filename(plot_id)}"
    if month_str and year:
        filename += f"_{month_str}_{year}"
    elif year:
        filename += f"_{year}"
    from datetime import datetime as _dt
    filename += f"_{_dt.now().strftime('%d%m%Y_%H%M%S')}.pdf"

    pdf_path = os.path.join(output_dir, filename)

    exporter = QgsLayoutExporter(layout)
    settings = QgsLayoutExporter.PdfExportSettings()
    settings.dpi = 300

    result = exporter.exportToPdf(pdf_path, settings)

    # ── 10. Cleanup ──
    project.layoutManager().removeLayout(layout)
    project.removeMapLayer(temp_layer.id())

    if result == QgsLayoutExporter.ExportResult.Success:
        return pdf_path
    else:
        QgsMessageLog.logMessage(
            f"PDF export failed for {plot_id}: error code {result}",
            "LVT4U", Qgis.MessageLevel.Warning
        )
        return None


def _merge_pdfs(pdf_paths, output_dir, params):
    """Merge multiple PDFs into one file."""
    try:
        from PyPDF2 import PdfMerger
    except ImportError:
        QgsMessageLog.logMessage(
            "PyPDF2 not installed — cannot merge PDFs. "
            "Install with: pip install PyPDF2",
            "LVT4U", Qgis.MessageLevel.Warning
        )
        return None

    from datetime import datetime as _dt
    n_plots = len(pdf_paths)
    ts = _dt.now().strftime('%d%m%Y_%H%M%S')
    merged_name = f"Xuat_{n_plots}_lo_rung_gop_{ts}.pdf"
    merged_path = os.path.join(output_dir, merged_name)

    merger = PdfMerger()
    for p in pdf_paths:
        merger.append(p)
    merger.write(merged_path)
    merger.close()

    for p in pdf_paths:
        try:
            os.remove(p)
        except OSError:
            pass  # nosec B110

    return merged_path
