# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_notmrk.py
-------------
CEVNI notice-mark portrayal for Inland ENC NOTMRK point layers.

The sign plates are the UNECE SIGNI 2019 Annex-7 boards as SVG, bundled under
``svg/repository/cevni/{A..E}/``; ``notmrk_catnmk_CEVNI.xml`` (same folder) maps
the IENC ``catnmk`` enumeration to the CEVNI sign, its head group (A prohibition,
B regulation, C restriction, D recommendation, E information) and the SVG file.

Portrayal (user spec):
  • @map_scale > 10 000  — a small square in the head-group colour (A/B/C red,
    D yellow, E blue; non-CEVNI values grey).
  • @map_scale ≤ 10 000  — the CEVNI sign SVG, rotated by the feature's ORIENT.
  • @map_scale ≤ 2 500   — notice marks sharing one position (one pole) are
    DISPLACED into a vertical stack so all are visible, each with a thin leader
    line from the source point to the sign centre (sign drawn over the line).

The scale switching is done with @map_scale FILTER expressions (not rule scale
ranges) so the same renderer translates 1:1 into vector-tile styles for the
viewer's dry tile plane (enc_tile_styles.styles_from_renderer keeps filters and
symbols, but drops rule scale ranges). The stack/leader-line maths live in
data-defined properties + a geometry-generator symbol layer, driven by two
helper fields (``nmk_i`` index / ``nmk_n`` count of marks at the same position)
that are annotated onto the layer (native paths) or into the bake's bucket rows
(tiled path). Absolute SVG paths are used (bare/relative names can fall back to
a placeholder square — the S-111 lesson).
"""

import os
import re

try:
    from .enc_xml_safe import parse_root
except (ImportError, ValueError):
    from enc_xml_safe import parse_root

try:
    from qgis.core import Qgis, QgsMessageLog
except (ImportError, ValueError):                       # headless import guard
    Qgis = None

_HERE = os.path.dirname(os.path.abspath(__file__))
_CEVNI_DIR = os.path.join(_HERE, "svg", "repository", "cevni")
_XML = os.path.join(_CEVNI_DIR, "notmrk_catnmk_CEVNI.xml")

# Head-group square colours (SIGNI board base colours): prohibition/regulation/
# restriction boards are red-bordered, recommendation boards yellow, information
# boards blue. Non-CEVNI catnmk values (Brazilian/Russian systems) show grey.
_GROUP_COLOUR = {"A": "204,0,0,255", "B": "204,0,0,255", "C": "204,0,0,255",
                 "D": "240,192,0,255", "E": "0,90,200,255"}
_OTHER_COLOUR = "136,136,136,255"

_SIGN_MM = 5.0          # sign board size at detail scales
_SQUARE_MM = 2.4        # overview square size
_LIFT_MM = 6.0          # stack: first sign this far above the source point
_STEP_MM = 10.5         # stack: spacing between stacked signs. CEVNI boards are
#                         PORTRAIT (a _SIGN_MM-wide board is ~1.4× that tall), so
#                         the step must clear the board's HEIGHT, not its width,
#                         or the stacked signs overlap.
_SCALE_SQUARE = 10000   # above this denominator: square
_SCALE_STACK = 2500     # at/below this denominator: displacement stack
_SCALE_GROW = 1000      # at/below this denominator: the board grows with zoom
# (fixed physical size; constant screen size above it)

_MAPPING = None


def _log(msg, level=None):
    try:
        QgsMessageLog.logMessage(str(msg), "ENCore GIS",
                                 level if level is not None else Qgis.Info)
    except Exception:                                   # noqa: BLE001
        pass


def mapping():
    """{catnmk int: {"svg": abs path|None, "group": "A".."E"|None,
    "cevni": str|None, "desc": str}} parsed once from the bundled XML."""
    global _MAPPING
    if _MAPPING is not None:
        return _MAPPING
    out = {}
    try:
        root = parse_root(_XML)
        for nm in root.findall("noticeMark"):
            try:
                code = int(nm.get("catnmk"))
            except (TypeError, ValueError):
                continue
            rel = nm.get("svg")
            svg = (os.path.join(_CEVNI_DIR, rel.replace("/", os.sep))
                   if rel else None)
            if svg and not os.path.isfile(svg):
                svg = None
            cevni = nm.get("cevni")
            grp = (cevni.split(".")[0].strip().upper()
                   if cevni and cevni[0].upper() in "ABCDE" else None)
            desc = (nm.findtext("description") or "").strip()
            out[code] = {"svg": svg, "group": grp, "cevni": cevni, "desc": desc}
    except Exception as e:                              # noqa: BLE001
        _log(f"NOTMRK: reading CEVNI mapping failed: {e}",
             Qgis.Warning if Qgis else None)
    _MAPPING = out
    return out


def describe(catnmk):
    """Human-readable 'CEVNI A.1 — no entry' for a catnmk value (pick report)."""
    try:
        m = mapping().get(int(float(str(catnmk).split(",")[0])))
    except (TypeError, ValueError):
        return None
    if not m:
        return None
    label = f"CEVNI {m['cevni']}" if m.get("cevni") else "notice mark"
    return f"{label} — {m['desc']}" if m.get("desc") else label


# ── near-position clustering (the displacement stack input) ──────────────────
# Marks on ONE pole share the spatial node (identical coordinates), but marks on
# NEIGHBOURING poles a few metres apart still overlap their boards on screen at
# the stack scales. Grouping is therefore by DISTANCE: everything within
# _CLUSTER_M ground metres (single-link) shares one stack, so each board gets
# its own lift height while its leader still points at its own pole.
_CLUSTER_M = 10.0
_WKT_POS = re.compile(r"\(\s*([-\d.eE]+)\s+([-\d.eE]+)")


def _cluster_ids(coords, tol_m=_CLUSTER_M):
    """[(lon, lat), …] → [cluster_root_index, …], single-link clustering within
    ``tol_m`` ground metres (grid + union-find; pure Python)."""
    import math
    n = len(coords)
    if n == 0:
        return []
    lat0 = coords[0][1]
    kx = 111320.0 * math.cos(math.radians(lat0))
    ky = 111320.0
    pts = [(x * kx, y * ky) for x, y in coords]
    parent = list(range(n))

    def find(a):
        while parent[a] != a:
            parent[a] = parent[parent[a]]
            a = parent[a]
        return a

    grid = {}
    for i, (x, y) in enumerate(pts):
        grid.setdefault((int(x // tol_m), int(y // tol_m)), []).append(i)
    t2 = tol_m * tol_m
    for (cx, cy), members in grid.items():
        neigh = []
        for dx in (-1, 0, 1):
            for dy in (-1, 0, 1):
                neigh.extend(grid.get((cx + dx, cy + dy), ()))
        for i in members:
            xi, yi = pts[i]
            for j in neigh:
                if j <= i:
                    continue
                xj, yj = pts[j]
                ddx, ddy = xi - xj, yi - yj
                if ddx * ddx + ddy * ddy <= t2:
                    ra, rb = find(i), find(j)
                    if ra != rb:
                        parent[rb] = ra
    return [find(i) for i in range(n)]


def _annotate_by_key(items):
    """[(key, named), …] → write nmk_i / nmk_n per cluster key, in order."""
    counts = {}
    for key, _named in items:
        counts[key] = counts.get(key, 0) + 1
    idx = {}
    for key, named in items:
        i = idx.get(key, 0)
        idx[key] = i + 1
        named["nmk_i"] = i
        named["nmk_n"] = counts[key]


def annotate_rows(rows):
    """QGIS-free: bucket rows [(rcid, wkt, named), …] → mutate ``named`` in
    place, adding nmk_i (0-based index in its cluster) and nmk_n (cluster
    size). Clustered by ground DISTANCE (_CLUSTER_M), so boards on neighbouring
    poles declutter too; rows whose WKT does not parse fall back to exact-WKT
    grouping."""
    coords, parsed = [], []
    for _rcid, wkt, _named in rows:
        m = _WKT_POS.search(wkt or "")
        if m:
            parsed.append(True)
            coords.append((float(m.group(1)), float(m.group(2))))
        else:
            parsed.append(False)
            coords.append(None)
    cluster = _cluster_ids([c for c in coords if c is not None])
    it = iter(cluster)
    items = []
    for (_rcid, wkt, named), ok in zip(rows, parsed):
        key = ("c", next(it)) if ok else ("w", wkt)
        items.append((key, named))
    _annotate_by_key(items)
    return rows


def annotate_buckets(buckets):
    """QGIS-free: add nmk_i/nmk_n to every NOTMRK bucket's rows (tile bake).
    Keys are (ACR, kind) tuples; only point NOTMRK is touched."""
    for key, b in (buckets or {}).items():
        try:
            if str(key[0]).upper() == "NOTMRK":
                annotate_rows(b.get("rows") or [])
        except Exception:                               # noqa: BLE001
            continue


def annotate_layer(layer):
    """Add nmk_i/nmk_n fields to a native NOTMRK QgsVectorLayer (memory or
    GPKG-backed), clustered by ground distance (_CLUSTER_M) so boards on
    neighbouring poles declutter too. Idempotent."""
    from qgis.core import QgsField
    from qgis.PyQt.QtCore import QVariant
    if layer is None or layer.fields().indexOf("nmk_n") >= 0:
        return
    order, coords = [], []
    for ft in layer.getFeatures():
        g = ft.geometry()
        try:
            p = g.asPoint()
            xy = (p.x(), p.y())
        except Exception:                               # noqa: BLE001
            m = _WKT_POS.search(g.asWkt(3) if g is not None else "")
            xy = (float(m.group(1)), float(m.group(2))) if m else None
        order.append(ft.id())
        coords.append(xy)
    if not order:
        return
    known = [c for c in coords if c is not None]
    cluster = iter(_cluster_ids(known))
    keys = [("c", next(cluster)) if c is not None else ("w", fid)
            for fid, c in zip(order, coords)]
    counts = {}
    for k in keys:
        counts[k] = counts.get(k, 0) + 1
    if not layer.isEditable() and not layer.startEditing():
        return
    layer.addAttribute(QgsField("nmk_i", QVariant.Int))
    layer.addAttribute(QgsField("nmk_n", QVariant.Int))
    layer.updateFields()
    fi = layer.fields().indexOf("nmk_i")
    fn = layer.fields().indexOf("nmk_n")
    idx = {}
    for fid, key in zip(order, keys):
        i = idx.get(key, 0)
        idx[key] = i + 1
        layer.changeAttributeValue(fid, fi, i)
        layer.changeAttributeValue(fid, fn, counts[key])
    layer.commitChanges()


# ── expression builders ──────────────────────────────────────────────────────
def _field(layer, *names):
    """The first of ``names`` present on the layer (case-sensitive), or None."""
    have = {f.name() for f in layer.fields()}
    for n in names:
        if n in have:
            return n
    return None


def _catnmk_expr(field):
    # catnmk may be a String field (viewer) — and defensively, a comma-joined
    # multi-value: take the leading number.
    return (f"to_int(regexp_substr(to_string(\"{field}\"), '^\\\\d+'))"
            if field else "NULL")


def _svg_case(cexpr):
    """CASE catnmk → absolute SVG path (generic board when no CEVNI plate)."""
    generic = os.path.join(_CEVNI_DIR, "generic.svg").replace("\\", "/")
    lines = ["CASE"]
    for code, m in sorted(mapping().items()):
        if m["svg"]:
            p = m["svg"].replace("\\", "/").replace("'", "''")
            lines.append(f" WHEN {cexpr} = {code} THEN '{p}'")
    lines.append(f" ELSE '{generic}'")
    lines.append("END")
    return "\n".join(lines)


def _colour_case(cexpr):
    """CASE catnmk → head-group square colour (grey for non-CEVNI values)."""
    lines = ["CASE"]
    for code, m in sorted(mapping().items()):
        col = _GROUP_COLOUR.get(m["group"] or "")
        if col:
            lines.append(f" WHEN {cexpr} = {code} THEN '{col}'")
    lines.append(f" ELSE '{_OTHER_COLOUR}'")
    lines.append("END")
    return "\n".join(lines)


def _lift_expr(layer):
    """Stack lift in mm for a feature (0 when not displaced): displaced only at
    denominators ≤ 2 500 AND when >1 mark shares the position."""
    ni = _field(layer, "nmk_i", "NMK_I")
    nn = _field(layer, "nmk_n", "NMK_N")
    if not ni or not nn:
        return "0"
    return (f"if(@map_scale <= {_SCALE_STACK} "
            f"AND coalesce(to_int(\"{nn}\"), 1) > 1, "
            f"{_LIFT_MM} + coalesce(to_int(\"{ni}\"), 0) * {_STEP_MM}, 0)")


# ── renderer ─────────────────────────────────────────────────────────────────
def apply_notmrk_renderer(layer):
    """Build + set the scale-aware CEVNI renderer on a NOTMRK point layer.
    Returns True on success. Call annotate_layer() first for the stack."""
    try:
        from qgis.core import (QgsRuleBasedRenderer, QgsMarkerSymbol,
                               QgsSvgMarkerSymbolLayer,
                               QgsGeometryGeneratorSymbolLayer, QgsLineSymbol,
                               QgsProperty, QgsSymbolLayer)
    except (ImportError, ValueError):
        return False
    if not mapping():
        return False
    cat = _field(layer, "catnmk", "CATNMK")
    cexpr = _catnmk_expr(cat)
    orient = _field(layer, "ORIENT", "orient")
    rot = f"coalesce(to_real(\"{orient}\"), 0)" if orient else "0"
    # Size multiplier: 1.0 (constant screen size in mm) while zoomed out; at/below
    # 1:_SCALE_GROW it grows as 1000/scale — exactly how a fixed-size physical
    # object behaves, so the board gets bigger on screen as you zoom in.
    grow = f"if(@map_scale > {_SCALE_GROW}, 1.0, {_SCALE_GROW}.0 / @map_scale)"
    lift = f"(({_lift_expr(layer)}) * ({grow}))"   # stack spacing grows with the sign

    # Rule 1 — overview square (denominator > 10 000), head-group colour.
    square = QgsMarkerSymbol.createSimple({
        "name": "square", "size": f"{_SQUARE_MM}",
        "color": "136,136,136,255", "outline_color": "0,0,0,255",
        "outline_width": "0.15"})
    square.symbolLayer(0).setDataDefinedProperty(
        QgsSymbolLayer.PropertyFillColor,
        QgsProperty.fromExpression(_colour_case(cexpr)))

    # Rule 2 — the CEVNI sign (denominator ≤ 10 000): a leader line under the
    # sign (only when displaced), then the SVG board rotated by ORIENT.
    generic = os.path.join(_CEVNI_DIR, "generic.svg")
    svg = QgsSvgMarkerSymbolLayer(generic, _SIGN_MM)
    svg.setDataDefinedProperty(QgsSymbolLayer.PropertyName,
                               QgsProperty.fromExpression(_svg_case(cexpr)))
    svg.setDataDefinedProperty(QgsSymbolLayer.PropertyAngle,
                               QgsProperty.fromExpression(rot))
    # Size in mm × the growth factor (constant screen size until 1:_SCALE_GROW,
    # then bigger as you zoom in). Unit stays mm — no CRS/metres conversion needed.
    svg.setDataDefinedProperty(QgsSymbolLayer.PropertySize,
                               QgsProperty.fromExpression(f"{_SIGN_MM} * ({grow})"))

    # The board is LIFTED into the displacement stack by translating the point
    # GEOMETRY (in map units), not by a data-defined marker offset: the geometry-
    # generator path renders reliably on the vector-tile plane (a per-feature
    # marker offset does NOT), and it seats the board exactly at the leader's tip.
    # translate(…, 0, lift_mm × @map_scale / 1000) converts mm-on-page → map units,
    # so the on-screen lift is lift_mm regardless of zoom/CRS — same maths as the
    # leader, so board and leader always agree.
    board_marker = QgsMarkerSymbol()
    board_marker.changeSymbolLayer(0, svg)
    board = QgsGeometryGeneratorSymbolLayer.create({
        "geometryModifier":
            f"translate($geometry, 0, ({lift}) * @map_scale / 1000.0)"})
    board.setSymbolType(Qgis.SymbolType.Marker)
    board.setSubSymbol(board_marker)
    board.setRenderingPass(1)                           # boards above all leaders

    leader = QgsGeometryGeneratorSymbolLayer.create({
        "geometryModifier":
            f"if(({lift}) > 0, make_line($geometry, translate($geometry, 0, "
            f"({lift}) * @map_scale / 1000.0)), NULL)"})
    leader.setSymbolType(Qgis.SymbolType.Line)
    leader.setSubSymbol(QgsLineSymbol.createSimple(
        {"line_color": "60,60,60,255", "line_width": "0.15",
         "line_width_unit": "MM"}))
    leader.setRenderingPass(0)                          # all leaders first

    sign = QgsMarkerSymbol.createSimple({"name": "circle", "size": "0"})
    sign.deleteSymbolLayer(0)
    sign.appendSymbolLayer(leader)                      # bottom: leader line
    sign.appendSymbolLayer(board)                       # top: the sign board

    root = QgsRuleBasedRenderer.Rule(None)
    r_sq = QgsRuleBasedRenderer.Rule(
        square, 0, 0, f"@map_scale > {_SCALE_SQUARE}", "NOTMRK square")
    r_sv = QgsRuleBasedRenderer.Rule(
        sign, 0, 0,
        f"@map_scale <= {_SCALE_SQUARE} OR @map_scale IS NULL", "NOTMRK sign")
    root.appendChild(r_sq)
    root.appendChild(r_sv)
    renderer = QgsRuleBasedRenderer(root)
    renderer.setUsingSymbolLevels(True)     # leaders (pass 0) under boards (1)
    layer.setRenderer(renderer)
    layer.triggerRepaint()
    return True


def apply(layer):
    """Annotate the same-position stack fields + set the CEVNI renderer.
    The single entry point for the loader and the viewer. Returns True/False."""
    try:
        annotate_layer(layer)
    except Exception as e:                              # noqa: BLE001
        _log(f"NOTMRK: stack annotation failed (portrayal continues): {e}",
             Qgis.Warning if Qgis else None)
    try:
        ok = apply_notmrk_renderer(layer)
    except Exception as e:                              # noqa: BLE001
        _log(f"NOTMRK: CEVNI renderer failed: {e}",
             Qgis.Warning if Qgis else None)
        return False
    if ok:
        _log(f"NOTMRK: CEVNI sign portrayal applied ({layer.featureCount()} "
             "mark(s))")
    return ok
