# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_tile_styles.py
------------------
Generate ``QgsVectorTileBasicRendererStyle`` lists for the Chart Viewer's tiled
skin planes, FROM our portrayal engine (not by converting QML).

A ``QgsVectorTileLayer`` is styled by a flat list of styles, each selecting a
baked source-layer (the S-57 class) by name + geometry type + a filter
expression, with one symbol. Unlike a normal layer's renderer, every matching
style draws (they stack), which suits S-52's layered look.

WET-skin styles are deliberately Chart-Control-driven: the depth-band fills
(DEPARE/DRGARE) and the depth contours (DEPCNT) filter on the LIVE
``@enc_shallow/safety/deep_contour`` and ``@enc_extra_contour*`` project
variables against the baked ``DRVAL1`` / ``VALDCO`` — so moving a Chart Control
spinner re-portrays the tiles immediately, exactly as it does the native layers.

Colours come from the same palette dict the viewer uses (Day/Dusk/Night), so a
tiled chart matches a native one shade for shade.
"""

import re

try:
    from qgis.core import (
        QgsVectorTileBasicRendererStyle as _VTStyle,
        QgsVectorTileBasicLabelingStyle as _VTLabel,
        QgsFillSymbol, QgsLineSymbol, Qgis,
        QgsCategorizedSymbolRenderer, QgsRuleBasedRenderer,
        QgsSingleSymbolRenderer, QgsVectorLayerSimpleLabeling,
        QgsRuleBasedLabeling)
except (ImportError, ValueError):                       # headless import guard
    _VTStyle = None


_GEOM = None


def _geom_enum(layer):
    """Layer geometry type → Qgis.GeometryType for a vector-tile style."""
    gt = int(layer.geometryType())                      # 0 point, 1 line, 2 poly
    return {0: Qgis.GeometryType.Point, 1: Qgis.GeometryType.Line,
            2: Qgis.GeometryType.Polygon}.get(gt, Qgis.GeometryType.Unknown)


def _eq_filter(class_attr, value):
    """Equality filter for one categorised value, quoting a bare field name and
    escaping the value. Empty/NULL categories become an IS-NULL/empty test."""
    lhs = (f'"{class_attr}"' if re.fullmatch(r"\w+", class_attr or "")
           else f"({class_attr})")
    if value in (None, ""):
        return f"{lhs} IS NULL OR {lhs} = ''"
    v = str(value).replace("'", "''")
    return f"{lhs} = '{v}'"


def styles_from_renderer(layer, source_layer):
    """Translate the renderer our engine/QML already applied to a native layer
    into vector-tile styles — one per category (S-52 engine, keyed on s52key) or
    per rule (AtoN/wrecks, keyed on real attributes). The SAME symbols are reused
    (cloned), so SVG markers and data-defined size/rotation survive and
    re-evaluate against the baked attributes on the tile. This is how the
    DRY+everything plane is portrayed: whatever the engine decided, replayed by
    the baked key — no QML→expression hand-translation."""
    if _VTStyle is None:
        return []
    r = layer.renderer()
    geom = _geom_enum(layer)
    out = []
    if isinstance(r, QgsCategorizedSymbolRenderer):
        attr = r.classAttribute()
        for cat in r.categories():
            sym = cat.symbol()
            if sym is None:
                continue
            st = _style(f"{source_layer}:{cat.value()}", source_layer, geom,
                        sym.clone(), _eq_filter(attr, cat.value()))
            out.append(st)
    elif isinstance(r, QgsRuleBasedRenderer):
        # Rule trees can NEST (a group rule with no symbol holding child rules
        # that carry the symbols — AtoN does this). Walk the whole tree; every
        # rule that has a symbol becomes a style whose filter is the AND of its
        # own filter and all its ancestors'. Each level's else rule negates its
        # siblings.
        def _walk(rule, inherited):
            kids = list(rule.children())
            sib = [c.filterExpression() for c in kids
                   if not c.isElse() and c.filterExpression()]
            for c in kids:
                if c.isElse():
                    own = ("NOT (" + " OR ".join(f"({f})" for f in sib) + ")"
                           if sib else "")
                else:
                    own = c.filterExpression() or ""
                parts = [p for p in (inherited, own) if p]
                filt = " AND ".join(f"({p})" for p in parts) if parts else ""
                sym = c.symbol()
                if sym is not None:
                    out.append(_style(
                        f"{source_layer}:{c.label() or c.ruleKey()}",
                        source_layer, geom, sym.clone(), filt))
                if c.children():
                    _walk(c, filt)
        _walk(r.rootRule(), "")
    else:                                               # single-symbol / other
        sym = None
        try:
            sym = r.symbol()
        except Exception:                               # noqa: BLE001
            sym = None
        if sym is not None:
            out.append(_style(f"{source_layer}:all", source_layer, geom,
                              sym.clone(), ""))
    return out


# Depth bands: (style suffix, filter on DRVAL1 vs contour vars, palette band key).
# Mirrors styles/DEPARE_area.qml — but the colours come from the live palette.
# The effective depth is the charted DRVAL1 PLUS the live S-104 water level
# (@enc_water_level, 0 when the tidal engine is off) — so at high water a charted-
# shallow area classifies deeper, exactly the S-104 wet-layer adjustment.
_DVAL = '(to_real("DRVAL1") + coalesce(@enc_water_level, 0))'
_DEPTH_BANDS = [
    ("lat",   f'{_DVAL} < 0', "above LAT"),
    ("vshal", f'{_DVAL} >= 0 AND {_DVAL} < @enc_shallow_contour', "above Shallow"),
    ("shal",  f'{_DVAL} >= @enc_shallow_contour AND '
              f'{_DVAL} < @enc_safety_contour', "above Safety"),
    ("safe",  f'{_DVAL} >= @enc_safety_contour AND '
              f'{_DVAL} < @enc_deep_contour', "below Safety"),
    ("deep",  f'{_DVAL} >= @enc_deep_contour', "Deep"),
]


def _style(name, source_layer, geom, symbol, filt):
    st = _VTStyle(name, source_layer, geom)
    st.setSymbol(symbol)
    st.setFilterExpression(filt or "")
    st.setEnabled(True)
    st.setMinZoomLevel(0)
    st.setMaxZoomLevel(30)
    return st


def depth_band_styles(source_layer, bands):
    """DEPARE / DRGARE depth-band fills, live on the Chart-Control contour vars.
    ``bands`` is the palette band→(r,g,b) dict."""
    out = []
    for suffix, filt, key in _DEPTH_BANDS:
        r, g, b = bands.get(key, (200, 200, 200))
        sym = QgsFillSymbol.createSimple(
            {"color": f"{r},{g},{b},255", "outline_style": "no"})
        out.append(_style(f"{source_layer}:{suffix}", source_layer,
                          Qgis.GeometryType.Polygon, sym, filt))
    return out


# Safety / extra contour selection. The native QML picks "the shallowest VALDCO
# at or below the contour setting" with a minimum(... filter:=...) AGGREGATE.
# Aggregates need a layer feature-source, which a vector-tile style does NOT
# provide — so the viewer precomputes the chosen contour value into
# @enc_safety_valdco / @enc_extra_valdco (see enc_tile_styles.contour_value), and
# the tile filter is a plain equality against it.
_SAFETY_FILTER = 'abs(to_real("VALDCO") - @enc_safety_valdco) < 0.001'
_EXTRA_FILTER = ('@enc_extra_contour_on = 1 AND '
                 'abs(to_real("VALDCO") - @enc_extra_valdco) < 0.001')
_WHOLEHALF = '(to_real("VALDCO") * 2) = floor(to_real("VALDCO") * 2)'


def contour_value(valdco_values, contour):
    """The VALDCO the native renderer would emphasise for a contour setting: the
    shallowest contour value at or deeper than ``contour`` (else the deepest).
    Reproduces minimum(VALDCO, filter:=VALDCO >= contour) without an aggregate so
    it can be stashed in a project variable for the tile filter to compare to."""
    vals = sorted({float(v) for v in valdco_values if v not in (None, "")})
    if not vals:
        return None
    at_or_below = [v for v in vals if v >= float(contour)]
    return at_or_below[0] if at_or_below else vals[-1]


# Minor-contour declutter by COMPILATION SCALE (DSPM CSCL, baked per feature as
# 'cscl'): at overview the dense minor contours render all at once (and MVT
# quantises them to the low-zoom tile grid → a crosshatch "grid" over the water).
# Standard ECDIS drops overscale detail while the SAFETY contour (the key
# navigational line) stays at all zooms. We gate each minor-contour class on the
# LIVE @map_scale vs the cell's own compilation scale × a factor — so a 1:12 000
# harbour cell sheds fine contours sooner than a 1:90 000 approach cell (per-cell,
# not a global guess). Null-safe: a cell without CSCL shows contours at all zooms.
_OVERSCALE_OTHER = 4        # sub-½m contours: hide beyond 4× the compilation scale
_OVERSCALE_WHOLEHALF = 12   # whole/half-m contours: persist to 12×


def _overscale_ok(factor):
    return ('("cscl" IS NULL OR length("cscl") = 0 OR '
            f'@map_scale <= to_real("cscl") * {factor})')


def contour_styles(source_layer, palette_name="Day"):
    """DEPCNT depth contours: whole/half-metre + other (thin), the safety contour
    emphasised (thick black), and the optional red 'extra contour' — all driven
    by Chart Control. Mirrors styles/DEPCNT_line.qml. Minor contours carry a
    per-cell compilation-scale overscale gate so the overview stays clean (see
    _overscale_ok / _OVERSCALE_*)."""
    L = Qgis.GeometryType.Line
    out = []
    other = QgsLineSymbol.createSimple(
        {"line_color": "72,72,72,255", "line_width": "0.05",
         "line_width_unit": "MM", "capstyle": "round", "joinstyle": "round"})
    out.append(_style(f"{source_layer}:other", source_layer, L, other,
                      f"(NOT ({_WHOLEHALF})) AND {_overscale_ok(_OVERSCALE_OTHER)}"))
    wholehalf = QgsLineSymbol.createSimple(
        {"line_color": "72,72,72,255", "line_width": "0.15",
         "line_width_unit": "MM", "capstyle": "round", "joinstyle": "round"})
    out.append(_style(f"{source_layer}:wholehalf", source_layer, L, wholehalf,
                      f"({_WHOLEHALF}) AND {_overscale_ok(_OVERSCALE_WHOLEHALF)}"))
    safety = QgsLineSymbol.createSimple(
        {"line_color": "0,0,0,255", "line_width": "0.25",
         "line_width_unit": "MM", "capstyle": "round", "joinstyle": "round"})
    out.append(_style(f"{source_layer}:safety", source_layer, L, safety,
                      _SAFETY_FILTER))
    extra = QgsLineSymbol.createSimple(
        {"line_color": "255,0,0,255", "line_width": "0.4",
         "line_width_unit": "MM", "capstyle": "round", "joinstyle": "round"})
    out.append(_style(f"{source_layer}:extra", source_layer, L, extra,
                      _EXTRA_FILTER))
    return out


def _label_style(name, source_layer, geom, settings, filt=""):
    st = _VTLabel()
    st.setStyleName(name)
    st.setLayerName(source_layer)
    st.setGeometryType(geom)
    st.setLabelSettings(settings)
    st.setFilterExpression(filt or "")
    st.setEnabled(True)
    st.setMinZoomLevel(0)
    st.setMaxZoomLevel(30)
    return st


def labeling_from_layer(layer, source_layer):
    """Translate a native layer's labeling (the OBJNAM etc. text our QMLs carry,
    since they are AllStyleCategories) into vector-tile labeling styles — so the
    tiled chart keeps its labels. Handles simple and rule-based labeling; the
    QgsPalLayerSettings (font, placement, field/expression) carry over unchanged
    and re-evaluate against the baked attributes."""
    if _VTStyle is None:
        return []
    lab = layer.labeling()
    if lab is None or not layer.labelsEnabled():
        return []
    geom = _geom_enum(layer)
    out = []
    if isinstance(lab, QgsVectorLayerSimpleLabeling):
        out.append(_label_style(f"{source_layer}:label", source_layer, geom,
                                lab.settings()))
    elif isinstance(lab, QgsRuleBasedLabeling):
        def _walk(rule, inherited):
            for c in rule.children():
                own = c.filterExpression() or ""
                parts = [p for p in (inherited, own) if p]
                filt = " AND ".join(f"({p})" for p in parts) if parts else ""
                s = c.settings()
                if s is not None:
                    out.append(_label_style(
                        f"{source_layer}:{c.description() or 'lbl'}",
                        source_layer, geom, s, filt))
                if c.children():
                    _walk(c, filt)
        _walk(lab.rootRule(), "")
    return out


def wet_skin_styles(bands, depare_layer="DEPARE", drgare_layer="DRGARE",
                    depcnt_layer="DEPCNT"):
    """All wet-skin plane styles: DEPARE + DRGARE depth bands + DEPCNT contours.
    ``bands`` is the active palette's band→(r,g,b) dict."""
    out = []
    out += depth_band_styles(depare_layer, bands)
    out += depth_band_styles(drgare_layer, bands)
    out += contour_styles(depcnt_layer)
    return out
