# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_chartview.py
----------------
ECDIS-style **Chart Viewer** window (proof of concept).

Opens an extra window that renders the *staged* cells the chart scanner flagged
— read with our **own iso8211 engine (no GDAL)** — on a dedicated map canvas,
portrayed with the bundled S-52 QML so it looks like an ECDIS chart. The
existing **Chart Control** spin boxes (shallow / safety / deep / sector /
soundings) drive it live, because their values are written as QgsProject
variables that the QML expressions reference — the viewer canvas shares that
expression context, so a repaint re-portrays it exactly like the main map.

Scope (PoC): look-and-feel only, not ECDIS performance-standard compliant.

Layer building
  • S-57 / Inland ENC — built here natively: ``S57RecordSet.from_paths`` (applies
    updates) → ``s57.GeometryBuilder`` per feature → in-memory ``QgsVectorLayer``
    with attribute fields named by S-57 acronym, then the bundled QML via
    ``enc_loader._apply_portrayal`` (which keys on DRVAL1/VALDCO/@enc_* etc.).
  • S-101 — reuses ``s101.qgis_loader.load_s101_cells(add_to_project=False)``,
    already native and S-57-grade portrayed.

Day / Dusk / Night is a viewer-local control: it swaps the canvas background and
best-effort recolours the depth-area bands. A full S-52 palette swap across every
symbol is the next step (the portrayal-engine palette work) and is noted as such.
"""

import os
import re

try:                                                    # plugin context
    from qgis.PyQt.QtCore import Qt
    from qgis.PyQt.QtGui import QColor, QFont
    from qgis.PyQt.QtWidgets import (
        QDialog, QVBoxLayout, QHBoxLayout, QComboBox, QLabel, QPushButton,
        QTreeWidget, QTreeWidgetItem, QSplitter, QWidget, QApplication,
        QScrollArea, QToolButton, QTableWidget, QTableWidgetItem,
        QAbstractItemView, QFrame, QCheckBox, QProgressBar)
    from qgis.gui import (QgsMapCanvas, QgsMapToolPan, QgsMapToolEmitPoint,
                          QgsScaleComboBox)
    from qgis.core import (
        Qgis, QgsMessageLog, QgsProject, QgsRectangle, QgsFeatureRequest,
        QgsVectorLayer, QgsField, QgsFeature, QgsGeometry, QgsPoint,
        QgsPointXY, QgsCoordinateReferenceSystem, QgsCoordinateTransform,
        QgsSymbol, QgsSingleSymbolRenderer, QgsWkbTypes, QgsDistanceArea,
        QgsRuleBasedRenderer, QgsExpressionContextUtils)
    from qgis.PyQt.QtCore import QVariant
except (ImportError, ValueError):                       # headless import guard
    Qt = QColor = QFont = None
    QDialog = object


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


# The enc_layers.xml group that holds meta/coverage objects (M_COVR, M_QUAL,
# M_NSYS, M_SREL…). The "Navigator" view hides exactly this group.
META_GROUP = "Metadata & Coverage"
_OTHER_GROUP = "Other"


def _tag_layer(layer, group, meta, acr=None, kind=None):
    """Tag a layer with its display group + meta flag (read by the viewer's
    grouped tree and the Navigator filter)."""
    layer.setCustomProperty("encore/group", group or _OTHER_GROUP)
    layer.setCustomProperty("encore/meta", bool(meta))
    if acr is not None:
        layer.setCustomProperty("encore/acr", acr)
    if kind is not None:
        layer.setCustomProperty("encore/kind", kind)


# ── Day / Dusk / Night palette (PoC) ────────────────────────────────────────
# Canvas background per palette, plus depth-area band fills keyed by the rule
# label authored in DEPARE_area.qml / DRGARE_area.qml. RGB values follow the
# S-52 colour tables (DEPIT/DEPVS/DEPMS/DEPMD/DEPDW) confirmed from the OpenCPN
# presentation library; "below safety" uses chart white / its dark equivalents.
_PALETTES = {
    "Day": {
        "background": QColor(220, 224, 228) if QColor else None,
        "bands": {
            "above LAT":     (131, 178, 149),   # DEPIT  (intertidal/drying)
            "above Shallow": (115, 182, 239),   # DEPVS
            "above Safety":  (152, 197, 242),   # DEPMS
            "below Safety":  (212, 234, 238),   # DEPDW-ish (deeper than safety)
            "Deep":          (236, 244, 248),
        },
    },
    "Dusk": {
        "background": QColor(36, 40, 52) if QColor else None,
        "bands": {
            "above LAT":     (21, 37, 31),
            "above Shallow": (22, 35, 47),
            "above Safety":  (21, 27, 33),
            "below Safety":  (12, 14, 15),
            "Deep":          (7, 7, 7),
        },
    },
    "Night": {
        "background": QColor(10, 10, 18) if QColor else None,
        "bands": {
            "above LAT":     (8, 11, 9),
            "above Shallow": (3, 4, 19),
            "above Safety":  (3, 4, 19),
            "below Safety":  (7, 7, 7),
            "Deep":          (5, 5, 5),
        },
    },
}


# ── Native S-57 → in-memory styled layers (GDAL-free) ───────────────────────
def _kind_and_memtype(geom):
    """Map a native geometry object → (qml-kind, memory-provider-type)."""
    cls = type(geom).__name__
    if cls in ("Polygon", "MultiPolygon"):
        return "area", "MultiPolygon"
    if cls in ("LineString", "MultiLineString"):
        return "line", "MultiLineString"
    if cls == "MultiPoint":
        return "point", ("MultiPointZ" if getattr(geom, "z_present", False)
                         else "MultiPoint")
    if cls == "Point":
        return "point", ("PointZ" if getattr(geom, "z", None) is not None
                         else "Point")
    return None, None


# Classes kept on our CUSTOM QML (the user's opt-out from the S-52 engine):
# the depth cluster. AtoN bodies are handled separately (AtoN_point.qml).
_QML_OVERRIDE = {"DEPARE", "DRGARE", "DEPCNT", "SOUNDG"}


def _s52_engine_apply(layer, acr, kind):
    """Portray a non-override S-57/IENC class via the S-52 engine (OpenCPN
    LUPTs). True if real S-52 portrayal was applied; False → caller falls back."""
    try:
        from . import enc_s52_portrayal
    except (ImportError, ValueError):
        try:
            import enc_s52_portrayal
        except Exception:                               # noqa: BLE001
            return False
    try:
        return bool(enc_s52_portrayal.apply_s52_engine(
            layer, acr, kind, "Day",
            {"shallow": 2.0, "safety": 6.0, "deep": 10.0}))
    except Exception:                                   # noqa: BLE001
        return False


def _apply_notmrk(layer):
    """CEVNI sign-board portrayal for Inland ENC notice marks (enc_notmrk):
    head-group square when zoomed out, SIGNI SVG + ORIENT rotation zoomed in,
    same-position displacement stack below 1:2500. False → caller falls back."""
    try:
        from . import enc_notmrk
    except (ImportError, ValueError):
        try:
            import enc_notmrk
        except Exception:                               # noqa: BLE001
            return False
    try:
        return bool(enc_notmrk.apply(layer))
    except Exception:                                   # noqa: BLE001
        return False


_MIN_MARKER_MM = 2.6   # render floor so point symbols stay recognisable

# In full vector-tile mode these classes render NATIVE on top of the tiles
# (their portrayal does not survive MVT tiling): SOUNDG (adaptive thinning),
# the folded AtoN composite (acr "AtoN"→"ATON"), and LIGHTS (sector legs/arc are
# QML geometry generators, clipped per tile). Compared case-folded against the
# layer's 'encore/acr'.
_NATIVE_OVER_TILES = {"SOUNDG", "ATON", "LIGHTS", "NOTMRK"}


def _sweep_min_marker_size(layer, min_mm=_MIN_MARKER_MM):
    """Bump any marker symbol-layer smaller than ``min_mm`` up to it, across
    single / categorized / rule-based renderers — a safety net so S-57/IENC point
    symbols don't render too small to recognise. Well-sized symbols are left
    untouched (only sub-minimum ones grow), so hand-tuned styles aren't distorted."""
    def bump(sym):
        if sym is None:
            return
        for i in range(sym.symbolLayerCount()):
            sl = sym.symbolLayer(i)
            try:
                if sl.subSymbol() is not None:
                    bump(sl.subSymbol())
            except Exception:                           # noqa: BLE001
                pass
            try:
                if hasattr(sl, "size") and 0 < sl.size() < min_mm:
                    sl.setSize(min_mm)
            except Exception:                           # noqa: BLE001
                pass
    try:
        r = layer.renderer()
        if r is None:
            return
        rtype = type(r).__name__
        if rtype == "QgsSingleSymbolRenderer":
            bump(r.symbol())
        elif rtype == "QgsCategorizedSymbolRenderer":
            for c in r.categories():
                bump(c.symbol())
        elif rtype == "QgsRuleBasedRenderer":
            for rule in r.rootRule().children():
                bump(rule.symbol())
        layer.triggerRepaint()
    except Exception:                                   # noqa: BLE001
        pass


def _wreck_svg(name):
    return os.path.join(os.path.dirname(os.path.abspath(__file__)),
                        "svg", "repository", "SYMBOLS", name + ".svg")


# WRECKS / OBSTRN symbol mapping (svg/repository/SYMBOLS), visually verified vs
# S-52. The ✚/✳/dotted-circle plates are the generic underwater-hazard treatments
# shared by rocks and obstructions, so OBSTRN reuses them correctly.
_UDWHAZ_SYMS = {
    "WRECKS": {"dngr_known": "Wreck_Depth_Dangerous",
               "safe_known": "Wreck_Depth_NonDangerous",
               "dngr_unknown": "Dangerous_wreck_depth_known",
               "covers": "Wreck",
               "safe_unknown": "non-Dangerous_wreck_depth_known"},
    "OBSTRN": {"dngr_known": "Rock_Depth_Dangerous",
               "safe_known": "Rock_Underwater",
               "dngr_unknown": "Dangerous_wreck_depth_known",
               "covers": "Rock_CoverUncover",
               "safe_unknown": "non-Dangerous_wreck_depth_known"},
}


def _udwhaz_danger_filter(cls):
    """The 'this feature is a danger' expression, shared by point/line/area — a
    known depth (tide-adjusted) at/above the safety contour, or an unknown-depth
    feature that is always-submerged (or, for wrecks, categorised dangerous)."""
    known = '"VALSOU" IS NOT NULL AND "VALSOU" <> \'\''
    unk = '("VALSOU" IS NULL OR "VALSOU" = \'\')'
    depth = '(to_real("VALSOU") + coalesce(@enc_water_level, 0))'
    dngr_unk = ('"WATLEV" = \'3\' OR "CATWRK" = \'2\''
                if cls == "WRECKS" else '"WATLEV" = \'3\'')
    return (f'({known} AND {depth} <= @enc_safety_contour) '
            f'OR ({unk} AND ({dngr_unk}))')


def _apply_udwhaz_lineargeom(layer, cls):
    """LINE / AREA WRECKS-OBSTRN portrayal (S-52 OBSTRN04 line/area branch): a
    dotted boundary (danger = black + a hatch fill; non-danger = grey), keyed on
    the same safety-depth danger test as the point CSP so it reacts to the safety
    contour + S-104 tide. Area = fill symbol, line = line symbol."""
    try:
        from qgis.core import (QgsRuleBasedRenderer, QgsLineSymbol, QgsFillSymbol,
                               QgsSimpleLineSymbolLayer, QgsLinePatternFillSymbolLayer)
        from qgis.PyQt.QtGui import QColor
        from qgis.PyQt.QtCore import Qt
    except (ImportError, ValueError):
        return
    is_poly = layer.geometryType() == QgsWkbTypes.PolygonGeometry

    def line_sym(dngr, w):
        return QgsLineSymbol.createSimple({
            "line_color": "0,0,0,255" if dngr else "90,90,90,255",
            "line_width": str(w), "line_style": "dot", "capstyle": "round"})

    def fill_sym(dngr):
        fs = QgsFillSymbol()
        fs.deleteSymbolLayer(0)                          # start empty (no opaque fill)
        if dngr:                                         # danger → faint hatch pattern
            hp = QgsLinePatternFillSymbolLayer()
            hp.setLineWidth(0.2)
            hp.setDistance(1.6)
            hp.setLineAngle(45)
            hp.setColor(QColor(70, 70, 70, 210))
            fs.appendSymbolLayer(hp)
        ol = QgsSimpleLineSymbolLayer(QColor(0, 0, 0) if dngr else QColor(90, 90, 90))
        ol.setWidth(0.35 if dngr else 0.3)
        ol.setPenStyle(Qt.DotLine)
        fs.appendSymbolLayer(ol)                         # dotted boundary
        return fs

    danger = _udwhaz_danger_filter(cls)
    root = QgsRuleBasedRenderer.Rule(None)

    def add(filt, sym, label, is_else=False):
        r = QgsRuleBasedRenderer.Rule(sym)
        if is_else:
            r.setIsElse(True)
        else:
            r.setFilterExpression(filt)
        r.setLabel(label)
        root.appendChild(r)

    if is_poly:
        add(danger, fill_sym(True), "dangerous obstruction area")
        add(None, fill_sym(False), "obstruction area", is_else=True)
    else:
        add(danger, line_sym(True, 0.45), "dangerous obstruction line")
        add(None, line_sym(False, 0.3), "obstruction line", is_else=True)
    layer.setRenderer(QgsRuleBasedRenderer(root))


def _apply_udwhaz_portrayal(layer, cls):
    """Standard S-52 UDWHAZ-style safety-depth portrayal for WRECKS / OBSTRN point
    layers (reconstructed CSP; this is a viewer, not a nav tool). A feature whose
    least depth VALSOU — tide-adjusted by @enc_water_level (S-104) — is AT or ABOVE
    the safety contour (@enc_safety_contour) is an ISOLATED DANGER (danger-ring
    symbol); deeper ones get the plain symbol. Depth-unknown cases fall back on
    WATLEV (covers/awash, always-submerged) and CATWRK. Symbols enlarge past 1:1000
    like notice marks. Mutually-exclusive rules → exactly one symbol draws."""
    try:
        from qgis.core import (QgsRuleBasedRenderer, QgsMarkerSymbol,
                               QgsSvgMarkerSymbolLayer, QgsProperty, QgsSymbolLayer)
    except Exception:                                   # noqa: BLE001
        return
    if layer.geometryType() != QgsWkbTypes.PointGeometry:
        _apply_udwhaz_lineargeom(layer, cls)            # line / area obstruction
        _apply_valsou_label(layer)
        return
    syms = _UDWHAZ_SYMS.get(cls, _UDWHAZ_SYMS["WRECKS"])
    base = 8.0                                          # ~1/3 smaller (was 12.0)
    grow = "if(@map_scale > 1000, 1.0, 1000.0 / @map_scale)"

    def marker(svgname):
        sym = QgsMarkerSymbol()
        sl = QgsSvgMarkerSymbolLayer(_wreck_svg(svgname))
        sl.setSize(base)
        sl.setDataDefinedProperty(                      # 1:1000 enlargement
            QgsSymbolLayer.PropertySize,
            QgsProperty.fromExpression(f"{base} * ({grow})"))
        sym.changeSymbolLayer(0, sl)
        return sym

    # SCAMIN scale suppression: a wreck/obstruction is drawn only while zoomed in
    # to at least its SCAMIN (@map_scale <= SCAMIN); no SCAMIN ⇒ always drawn. The
    # symbol rules hang UNDER a parent rule carrying this gate, so the gate applies
    # to every rule — including the catch-all (else) — in both the native renderer
    # and the replayed tile styles (styles_from_renderer inherits ancestor filters
    # and expands each else to NOT(siblings)).
    root = QgsRuleBasedRenderer.Rule(None)
    if "SCAMIN" in [f.name() for f in layer.fields()]:
        gate = ('"SCAMIN" IS NULL OR length("SCAMIN") = 0 '
                'OR @map_scale <= to_real("SCAMIN")')
        container = QgsRuleBasedRenderer.Rule(None)
        container.setFilterExpression(gate)
        root.appendChild(container)
    else:
        container = root

    def add(filt, key, label, is_else=False):
        r = QgsRuleBasedRenderer.Rule(marker(syms[key]))
        if is_else:
            r.setIsElse(True)
        else:
            r.setFilterExpression(filt)
        r.setLabel(label)
        container.appendChild(r)

    known = '"VALSOU" IS NOT NULL AND "VALSOU" <> \'\''
    unk = '("VALSOU" IS NULL OR "VALSOU" = \'\')'
    depth = '(to_real("VALSOU") + coalesce(@enc_water_level, 0))'
    dngr_unk = ('"WATLEV" = \'3\' OR "CATWRK" = \'2\''
                if cls == "WRECKS" else '"WATLEV" = \'3\'')
    add(f'{known} AND {depth} <= @enc_safety_contour', "dngr_known",
        "isolated danger (<= safety)")
    add(f'{known} AND {depth} > @enc_safety_contour', "safe_known",
        "non-dangerous (> safety)")
    add(f'{unk} AND "WATLEV" IN (\'4\',\'5\')', "covers", "covers / awash")
    add(f'{unk} AND ({dngr_unk})', "dngr_unknown", "dangerous (depth unknown)")
    add(None, "safe_unknown", "non-dangerous (depth unknown)", is_else=True)
    layer.setRenderer(QgsRuleBasedRenderer(root))
    _apply_valsou_label(layer, sym_mm=base)             # offset clear of the symbol


def _apply_valsou_label(layer, sym_mm=0.0):
    """Bold VALSOU sounding label for WRECKS / OBSTRN, using the SAME integer +
    subscript-decimal method as SOUNDG (float-safe floor split; round toward shoal),
    tide-adjusted by @enc_water_level. The label is offset CLEAR of the
    ``sym_mm``-sized symbol (to its right) so the bold value reads as the symbol's
    depth without overlapping it; the offset scales with the 1:1000 enlargement.
    Only labels features that carry a VALSOU."""
    try:
        from qgis.core import (QgsRuleBasedLabeling, QgsPalLayerSettings,
                               QgsTextFormat, QgsProperty, QgsUnitTypes)
        from qgis.PyQt.QtGui import QFont, QColor
    except (ImportError, ValueError):
        return
    d = '(to_real("VALSOU") + coalesce(@enc_water_level, 0))'
    present = '"VALSOU" IS NOT NULL AND "VALSOU" <> \'\''
    grow = "if(@map_scale > 1000, 1.0, 1000.0 / @map_scale)"
    R = sym_mm * 0.6 + 1.2                              # clearance to the symbol's right
    # Point/area labels sit over the point / polygon centroid; a line label is
    # placed horizontally at a representative point (OverPoint doesn't suit lines).
    place = (QgsPalLayerSettings.Horizontal
             if layer.geometryType() == QgsWkbTypes.LineGeometry
             else QgsPalLayerSettings.OverPoint)

    def leaf(expr, sub):
        s = QgsPalLayerSettings()
        s.fieldName = expr
        s.isExpression = True
        s.placement = place
        ox = (R + 2.4) if sub else R                    # decimal sits right of whole
        oy = 1.0 if sub else -0.4                       # decimal as a subscript
        s.xOffset, s.yOffset = ox, oy                   # mm (fallback)
        try:
            s.offsetUnits = QgsUnitTypes.RenderMillimeters
            s.dataDefinedProperties().setProperty(      # scale offset with the symbol
                QgsPalLayerSettings.OffsetXY,
                QgsProperty.fromExpression(
                    f"format('%1,%2', ({ox})*({grow}), ({oy})*({grow}))"))
        except Exception:                               # noqa: BLE001
            pass
        fmt = QgsTextFormat()
        f = QFont("Verdana")
        f.setBold(True)
        fmt.setFont(f)
        fmt.setSize(6.5 if sub else 8.0)
        try:
            from qgis.core import QgsUnitTypes
            fmt.setSizeUnit(QgsUnitTypes.RenderPoints)
        except Exception:                               # noqa: BLE001
            pass
        fmt.setColor(QColor(60, 60, 60))
        s.setFormat(fmt)
        return s

    root = QgsRuleBasedLabeling.Rule(None)
    whole = QgsRuleBasedLabeling.Rule(
        leaf(f"if({d} > 0, floor({d}), abs(ceil({d})))", False))
    whole.setFilterExpression(present)
    whole.setDescription("valsou_whole")               # distinct → survives tile dedupe
    dec = QgsRuleBasedLabeling.Rule(
        leaf(f"floor(abs({d})*10) - floor(abs({d}))*10", True))
    dec.setFilterExpression(present)
    dec.setDescription("valsou_dec")
    root.appendChild(whole)
    root.appendChild(dec)
    layer.setLabeling(QgsRuleBasedLabeling(root))
    layer.setLabelsEnabled(True)


def _apply_wrecks_portrayal(layer):
    """Back-compat shim → the shared WRECKS/OBSTRN safety-depth CSP."""
    _apply_udwhaz_portrayal(layer, "WRECKS")


def _apply_neutral(layer):
    """Fallback for a class with no bundled S-52 QML: don't let QGIS' default
    opaque fill cover the chart. Areas get a transparent fill + faint outline
    (the ECDIS treatment for meta/quality/admin coverage — M_QUAL, M_COVR,
    ADMARE, RESARE…); lines/points get a thin neutral mark."""
    gt = layer.geometryType()
    sym = QgsSymbol.defaultSymbol(gt)
    if sym is None:
        return
    if gt == QgsWkbTypes.PolygonGeometry:
        sym.setColor(QColor(0, 0, 0, 0))               # transparent fill
        sl = sym.symbolLayer(0)
        try:
            sl.setStrokeColor(QColor(130, 130, 130, 150))
            sl.setStrokeWidth(0.2)
        except Exception:                              # noqa: BLE001
            pass
    elif gt == QgsWkbTypes.LineGeometry:
        sym.setColor(QColor(110, 110, 110, 210))
        try:
            sym.setWidth(0.2)
        except Exception:                              # noqa: BLE001
            pass
    else:
        sym.setColor(QColor(90, 90, 90, 220))
        try:
            sym.setSize(1.2)
        except Exception:                              # noqa: BLE001
            pass
    layer.setRenderer(QgsSingleSymbolRenderer(sym))


def _apply_scamin(layer):
    """Per-feature SCAMIN scale suppression: a feature is drawn only when the
    map scale is at least as large as its SCAMIN (i.e. @map_scale <= SCAMIN);
    features with no SCAMIN always draw. Implemented by ANDing that condition
    into the layer's renderer rules (wrapping a non-rule renderer first), so it
    composes with the existing portrayal. Applied to non-"Skin of the Earth"
    layers only."""
    if "SCAMIN" not in [f.name() for f in layer.fields()]:
        return
    r = layer.renderer()
    if r is None:
        return
    try:
        rb = (r.clone() if isinstance(r, QgsRuleBasedRenderer)
              else QgsRuleBasedRenderer.convertFromRenderer(r))
    except Exception:                                  # noqa: BLE001
        rb = None
    if rb is None:
        return
    gate = ('"SCAMIN" IS NULL OR length("SCAMIN") = 0 '
            'OR @map_scale <= to_real("SCAMIN")')
    for rule in rb.rootRule().children():
        if rule.isElse():
            rule.setFilterExpression(gate)
        else:
            ex = (rule.filterExpression() or "").strip()
            rule.setFilterExpression(f"({ex}) AND ({gate})" if ex else gate)
    layer.setRenderer(rb)


def _build_aton_composite(buckets, dst, xform, cfg, log):
    """Reuse the project loader's Aids-to-Navigation 'smelting' ENGINE natively.

    Gathers the buoy/beacon body, TOPMAR and LIGHTS buckets the native reader
    produced and folds each co-located topmark (by position) and buoy-light
    (by position AND the buoy's LNAM_REFS, sectored lights excluded) onto the
    body row — via the SAME ``compose_aton_rows`` / ``match_buoy_lights`` core
    the QGIS project loader uses. Emits ONE "Aids to Navigation (float)" point
    layer, so the pick report reads a buoy + its topmark + its light from a
    single coupled row instead of guessing the links per-feature.

    Side effects on ``buckets``: the body + TOPMAR buckets are consumed (popped,
    so they are not also emitted per-class); claimed buoy-lights are dropped from
    the LIGHTS bucket. Unbound lights AND sectored lights stay in LIGHTS, fully
    portrayed. Returns the composite ``QgsVectorLayer`` or None.
    """
    try:
        from .enc_loader import (
            compose_aton_rows, match_buoy_lights, _apply_portrayal,
            _aton_pos_key, _parse_lnam_refs, _norm_lnam,
            _ATON_BUOY_FIELDS, _ATON_TOPMARK_FIELDS, _ATON_TT_RENAME,
            _ATON_LIGHT_FIELDS, _ATON_LT_RENAME, _ATON_IDENT_FIELDS,
            _ATON_TOPMARK_CLASS, _ATON_LIGHT_CLASS, _ATON_BODY_CLASSES_UPPER)
    except (ImportError, ValueError):
        from enc_loader import (
            compose_aton_rows, match_buoy_lights, _apply_portrayal,
            _aton_pos_key, _parse_lnam_refs, _norm_lnam,
            _ATON_BUOY_FIELDS, _ATON_TOPMARK_FIELDS, _ATON_TT_RENAME,
            _ATON_LIGHT_FIELDS, _ATON_LT_RENAME, _ATON_IDENT_FIELDS,
            _ATON_TOPMARK_CLASS, _ATON_LIGHT_CLASS, _ATON_BODY_CLASSES_UPPER)

    body_keys = [k for k in buckets
                 if k[0] in _ATON_BODY_CLASSES_UPPER and k[1] == "point"]
    tm_keys = [k for k in buckets
               if k[0] == _ATON_TOPMARK_CLASS and k[1] == "point"]
    light_keys = [k for k in buckets
                  if k[0] == _ATON_LIGHT_CLASS and k[1] == "point"]
    if not body_keys and not tm_keys:
        return None

    def G(named, key):
        v = named.get(key)
        if v in (None, ""):
            # Inland ENC encodes some acronyms LOWERCASE (e.g. catlam=attr 17011,
            # marsys=17009) — match case-insensitively, parity with the project
            # loader's _aton_attr. Without this the Inland-only SIGNI recreation
            # marks (CATLAM 11/12/15/16) lose CATLAM and mis-portray.
            lk = key.lower()
            for kk, vv in named.items():
                if kk.lower() == lk:
                    v = vv
                    break
        return None if v in (None, "") else v

    # Bodies — indexed by position; collect each buoy's referenced LNAMs.
    buoy_rows, refs_by_pos = [], {}
    for k in body_keys:
        cls = k[0]
        for _rcid, wkt, named in buckets[k]["rows"]:
            g = QgsGeometry.fromWkt(wkt)
            if g is None or g.isEmpty():
                continue
            pos = _aton_pos_key(g)
            attrs = {"S57CLASS": cls}
            for bf in _ATON_BUOY_FIELDS:
                attrs[bf] = G(named, bf)
            for idf in _ATON_IDENT_FIELDS:
                attrs[idf] = G(named, idf)
            buoy_rows.append({"geom": g, "attrs": attrs, "pos": pos})
            if pos is not None:
                refs = _parse_lnam_refs(G(named, "LNAM_REFS"))
                if refs:
                    refs_by_pos.setdefault(pos, set()).update(refs)

    # Topmarks — TT_-renamed fold dict + a full standalone-row dict.
    topmark_rows = []
    for k in tm_keys:
        for _rcid, wkt, named in buckets[k]["rows"]:
            g = QgsGeometry.fromWkt(wkt)
            if g is None or g.isEmpty():
                continue
            tt = {_ATON_TT_RENAME[tf]: G(named, tf) for tf in _ATON_TOPMARK_FIELDS}
            ident = {idf: G(named, idf) for idf in _ATON_IDENT_FIELDS}
            topmark_rows.append(
                {"geom": g, "pos": _aton_pos_key(g), "tt": tt,
                 "standalone_attrs": {"S57CLASS": _ATON_TOPMARK_CLASS,
                                      **tt, **ident}})

    # Lights — non-sectored candidates; the core decides which belong to a buoy.
    lights = []
    for k in light_keys:
        for _rcid, wkt, named in buckets[k]["rows"]:
            g = QgsGeometry.fromWkt(wkt)
            if g is None or g.isEmpty():
                continue
            if G(named, "SECTR1") is not None or G(named, "SECTR2") is not None:
                continue                                # sector arcs stay in LIGHTS
            lnam = _norm_lnam(G(named, "LNAM"))
            pos = _aton_pos_key(g)
            if not lnam or pos is None:
                continue
            lt = {_ATON_LT_RENAME[lf]: G(named, lf) for lf in _ATON_LIGHT_FIELDS}
            lights.append((lnam, pos, lt))

    claimed, fold = match_buoy_lights(refs_by_pos, lights)
    rows, stats = compose_aton_rows(buoy_rows, topmark_rows, fold)
    log(f"Chart Viewer: AtoN composite — {len(buoy_rows)} body, "
        f"{stats['paired']} w/topmark, {stats['lit']} lit, "
        f"{stats['standalone']} lone topmark")

    # Drop claimed buoy-lights from LIGHTS (unbound + sectored lights stay).
    for k in light_keys:
        kept = [r for r in buckets[k]["rows"]
                if _norm_lnam(G(r[2], "LNAM")) not in claimed]
        if kept:
            buckets[k]["rows"] = kept
        else:
            del buckets[k]
    # Consume the body + topmark buckets (folded into the composite).
    for k in body_keys + tm_keys:
        buckets.pop(k, None)
    if not rows:
        return None

    field_names = (["rcid", "S57CLASS"] + list(_ATON_BUOY_FIELDS)
                   + [_ATON_TT_RENAME[f] for f in _ATON_TOPMARK_FIELDS]
                   + [_ATON_LT_RENAME[f] for f in _ATON_LIGHT_FIELDS]
                   + list(_ATON_IDENT_FIELDS))
    layer = QgsVectorLayer(f"Point?crs={dst.authid() or 'EPSG:4326'}",
                           "Aids to Navigation (float)", "memory")
    dp = layer.dataProvider()
    dp.addAttributes([QgsField(n, QVariant.String) for n in field_names])
    layer.updateFields()
    feats = []
    for i, row in enumerate(rows):
        g = QgsGeometry(row["geom"])
        if xform is not None:
            g.transform(xform)
        ft = QgsFeature(layer.fields())
        ft.setGeometry(g)
        ft["rcid"] = str(i)
        for n in field_names:
            if n == "rcid":
                continue
            v = row["attrs"].get(n)
            if v not in (None, ""):
                ft[n] = str(v)
        feats.append(ft)
    dp.addFeatures(feats)
    layer.updateExtents()
    try:
        if cfg.style_path_for("AtoN", "point"):
            _apply_portrayal(layer, cfg, "AtoN", "point", log)
        else:
            _apply_neutral(layer)
    except Exception as e:                              # noqa: BLE001
        log(f"Chart Viewer: AtoN composite portrayal failed: {e}", Qgis.Warning)
    _sweep_min_marker_size(layer)
    _tag_layer(layer, cfg.group_for("BOYLAT"), False, acr="AtoN", kind="point")
    _apply_scamin(layer)
    return layer


def decode_s57_buckets(paths, log=_log, should_stop=None):
    """QGIS-FREE native decode of S-57 / Inland-ENC cells into portrayal buckets.

    Returns ``(buckets, soundings)`` where ``buckets`` maps
    ``(acronym, kind, memtype)`` → ``{"rows": [(rcid, wkt, attrs)], "attrs":
    set()}`` and ``soundings`` is a list of ``(x, y, z)``. Geometry is WKT in
    EPSG:4326 and attributes are plain dicts — NO QGIS objects are created, so
    this is safe to run on a background (non-GUI) thread. It is the shared decode
    behind both the Chart Viewer (build_s57_layers) and the scan-time tile bake.
    """
    try:
        from .s57.records import S57RecordSet
        from .s57.geometry import GeometryBuilder
        from .enc_loader import _s57_catalogue, _lnam, _lnam_from_ffpt
    except (ImportError, ValueError):
        from s57.records import S57RecordSet
        from s57.geometry import GeometryBuilder
        from enc_loader import _s57_catalogue, _lnam, _lnam_from_ffpt
    cat = _s57_catalogue()
    buckets = {}
    soundings = []          # individual (x,y,z) — built as one adaptive layer
    rs = S57RecordSet.from_paths(list(paths))
    for cell in rs:
        if should_stop is not None and should_stop():
            break                                       # cooperative cancel
        if getattr(cell, "parse_error", None):
            log(f"Chart Viewer: skip {cell.name}: {cell.parse_error}",
                Qgis.Warning)
            continue
        gb = GeometryBuilder(cell)
        for f in cell.features:
            if f.prim == 1:
                geom = gb.point(f)
            elif f.prim == 2:
                geom = gb.line(f)
            elif f.prim == 3:
                geom = gb.area(f)
            else:
                geom = None
            if geom is None:
                continue
            # Normalise to the STANDARD (uppercase) acronym so Inland-ENC
            # variants (e.g. OBJL 17029 → "boylat") are treated exactly like
            # their standard class ("BOYLAT") for portrayal, naming and grouping.
            acr = (cat.code_to_acronym.get(f.objl) or f"OBJL{f.objl}").upper()
            # SOUNDG is portrayed specially: explode the cluster into single
            # points so the same adaptive shoalest-first thinning + depth
            # labelling as the main loader applies (see _build_s57_sounding_layer).
            if acr == "SOUNDG":
                cls = type(geom).__name__
                if cls == "MultiPoint":
                    for p in geom.points:
                        if len(p) >= 3:
                            soundings.append((p[0], p[1], p[2]))
                elif cls == "Point" and getattr(geom, "z", None) is not None:
                    soundings.append((geom.x, geom.y, geom.z))
                continue
            kind, memtype = _kind_and_memtype(geom)
            if kind is None:
                continue
            named = {}
            for code, val in list(f.attrs.items()) + list(f.natf.items()):
                a = cat.attr_code_to_acronym.get(code, str(code))
                if val is not None:
                    named[a] = val
            # Identity + relationship links (S-57 LNAM master/slave) so the pick
            # report can connect a buoy to its topmark/light by LNAM_REFS.
            lnam = _lnam(f.foid)
            if lnam:
                named["LNAM"] = lnam
            refs = ",".join(s for s in (_lnam_from_ffpt(r[0])
                                        for r in (f.ffpt or [])) if s)
            if refs:
                named["LNAM_REFS"] = refs
            key = (acr, kind, memtype)
            b = buckets.setdefault(key, {"rows": [], "attrs": set()})
            b["rows"].append((f.rcid, geom.wkt(), named))
            b["attrs"].update(named.keys())
    return buckets, soundings


def build_s57_layers(paths, target_crs, log=_log, buckets=None, soundings=None,
                     skip_classes=None):
    """Read staged S-57 / Inland-ENC cells with the native engine and return a
    bottom→top ordered list of styled in-memory ``QgsVectorLayer``s.

    One layer per (S-57 class acronym, geometry kind). Fields are the union of
    attribute acronyms across the layer's features (all String, the form the
    bundled QML's ``to_real(...)`` expressions expect).

    ``buckets``/``soundings`` may be supplied (from the scan-time tile bake's
    decode cache) to SKIP the native decode — the expensive phase — so the Chart
    Viewer opens nimbly off pre-decoded data instead of re-reading the cells."""
    try:
        from .enc_loader import _apply_portrayal, _ATON_BODY_CLASSES_UPPER
        from .enc_config import get_config
    except (ImportError, ValueError):
        from enc_loader import _apply_portrayal, _ATON_BODY_CLASSES_UPPER
        from enc_config import get_config

    cfg = get_config()
    src = QgsCoordinateReferenceSystem("EPSG:4326")
    dst = QgsCoordinateReferenceSystem(target_crs)
    if not dst.isValid():
        dst = src
    xform = (None if dst == src
             else QgsCoordinateTransform(src, dst, QgsProject.instance()))

    # bucket key (acronym, kind, memtype) -> {"rows": [...], "attrs": set()}
    if buckets is None:
        buckets, soundings = decode_s57_buckets(paths, log)
    else:
        # Work on a private copy — the AtoN fold pops/edits buckets, and a cached
        # decode may be reused across viewer opens.
        buckets = {k: {"rows": list(v["rows"]), "attrs": set(v["attrs"])}
                   for k, v in buckets.items()}
        soundings = soundings or []

    # Skip classes that will render from the vector-tile WET plane instead of
    # being built as (dense) native layers — this is the viewer's nimbleness win.
    if skip_classes:
        skip = {c.upper() for c in skip_classes}
        for k in [k for k in buckets if k[0].upper() in skip]:
            del buckets[k]

    # Aids to Navigation: fold each buoy/beacon's co-located topmark + buoy-light
    # into ONE "Aids to Navigation (float)" layer via the shared loader engine,
    # BEFORE per-class emission (it consumes the body/TOPMAR buckets and trims
    # claimed buoy-lights from LIGHTS). This is what makes topmarks show on the
    # buoy and the pick report read the whole mark from one coupled row.
    aton_layer = _build_aton_composite(buckets, dst, xform, cfg, log)

    # A class that yields more than one geometry kind keeps a kind suffix so the
    # two layers stay distinguishable in the tree; single-geometry classes get
    # the clean human-readable name on its own.
    kinds_per_acr = {}
    for (acr, kind, _m) in buckets:
        kinds_per_acr.setdefault(acr, set()).add(kind)

    layers = []
    for (acr, kind, memtype), b in buckets.items():
        attr_names = sorted(b["attrs"])
        nice = cfg.display_name(acr)
        if len(kinds_per_acr.get(acr, ())) > 1:
            nice = f"{nice} ({kind})"
        layer = QgsVectorLayer(f"{memtype}?crs={dst.authid() or 'EPSG:4326'}",
                               nice, "memory")
        dp = layer.dataProvider()
        fields = [QgsField("rcid", QVariant.String)]
        fields += [QgsField(n, QVariant.String) for n in attr_names]
        dp.addAttributes(fields)
        layer.updateFields()
        feats = []
        for rcid, wkt, named in b["rows"]:
            g = QgsGeometry.fromWkt(wkt)
            if g is None or g.isEmpty():
                continue
            if xform is not None:
                g.transform(xform)
            ft = QgsFeature(layer.fields())
            ft.setGeometry(g)
            ft["rcid"] = str(rcid)
            for n in attr_names:
                if n in named:
                    ft[n] = str(named[n])
            feats.append(ft)
        dp.addFeatures(feats)
        layer.updateExtents()
        try:
            # Portrayal routing:
            #  • the depth cluster + AtoN bodies keep our CUSTOM QML (override) —
            #    DEPARE/DRGARE/DEPCNT (SOUNDG is built separately) + buoys/beacons;
            #  • every OTHER class is portrayed by the S-52 engine (OpenCPN LUPTs);
            #  • engine miss → bundled QML if present, else a neutral symbol.
            is_aton = acr in _ATON_BODY_CLASSES_UPPER
            if acr in ("WRECKS", "OBSTRN"):
                _apply_udwhaz_portrayal(layer, acr)     # safety-depth CSP (pt/ln/ar)
            elif acr == "NOTMRK" and kind == "point" and _apply_notmrk(layer):
                pass                                    # CEVNI sign boards
            elif acr in _QML_OVERRIDE or is_aton:
                style_code = "AtoN" if is_aton else acr
                if cfg.style_path_for(style_code, kind):
                    _apply_portrayal(layer, cfg, style_code, kind, log)
                else:
                    _apply_neutral(layer)
            elif _s52_engine_apply(layer, acr, kind):
                pass                                    # rendered by the engine
            elif cfg.style_path_for(acr, kind):
                _apply_portrayal(layer, cfg, acr, kind, log)
            else:
                _apply_neutral(layer)                   # meta/admin → no opaque fill
        except Exception as e:                          # noqa: BLE001
            log(f"Chart Viewer: portrayal failed for {acr} ({kind}): {e}",
                Qgis.Warning)
        if kind == "point":
            _sweep_min_marker_size(layer)               # keep symbols visible
        group = cfg.group_for(acr)
        _tag_layer(layer, group, group == META_GROUP, acr=acr, kind=kind)
        # SCAMIN scale-suppression for everything except Skin of the Earth.
        if group != "Skin of the Earth":
            _apply_scamin(layer)
        layers.append((acr, kind, layer))

    # The composite AtoN layer sorts with the points (buoy priority).
    if aton_layer is not None:
        layers.append(("BOYLAT", "point", aton_layer))

    # Bottom→top: areas, then lines, then points (soundings/symbols on top).
    rank = {"area": 0, "line": 1, "point": 2}
    layers.sort(key=lambda t: (rank.get(t[1], 1),
                               -cfg.priority_for(t[0])))
    ordered = [lyr for _a, _k, lyr in layers]

    # Soundings on top, with the same adaptive portrayal as the main loader.
    snd = _build_s57_sounding_layer(soundings, dst, xform, cfg, log)
    if snd is not None:
        ordered.append(snd)
    return ordered


def build_navaid_overlay(decode_cache, target_crs, log=_log):
    """Build the NATIVE navaid overlay — the Aids-to-Navigation composite (folded
    buoy/beacon + topmark + light) and the LIGHTS layer (incl. sector lights) —
    across ALL cached cells, for rendering ON TOP of the dry tiles.

    These classes are deliberately NOT drawn from the vector tiles: sector-light
    legs/arcs are QML geometry generators that MVT clips per tile, the folded
    topmark/light bodies + SCAMIN scale-suppression don't tile, and the pick
    report needs the buoy→topmark→light parent/child links the composite carries.
    Navaids are sparse, so building every cell natively is cheap. Merges the
    navaid buckets from every cell (positions are global; the fold matches the
    tile bake's whole-coverage AtoN compose) and reuses build_s57_layers so the
    portrayal is byte-identical to the QGIS project loader. Returns the navaid
    QgsVectorLayers (AtoN composite + LIGHTS), or []."""
    try:
        from .enc_loader import _ATON_BODY_CLASSES_UPPER
    except (ImportError, ValueError):
        from enc_loader import _ATON_BODY_CLASSES_UPPER
    # NOTMRK joins the native overlay: its same-position DISPLACEMENT STACK (a
    # geometry-generator leader + a lifted board) does not survive MVT tiling —
    # the vector-tile plane paints the boards at the base point, so they overlap.
    # Notice marks are sparse, so building them natively for every cell is cheap.
    keep = ({c.upper() for c in _ATON_BODY_CLASSES_UPPER}
            | {"TOPMAR", "LIGHTS", "NOTMRK"})
    merged = {}
    for data in (decode_cache or {}).values():
        for k, b in (data.get("buckets") or {}).items():
            if k[0].upper() not in keep:
                continue
            cb = merged.setdefault(k, {"rows": [], "attrs": set()})
            cb["rows"].extend(b["rows"])
            cb["attrs"].update(b["attrs"])
    if not merged:
        return []
    layers = build_s57_layers([], target_crs, log, buckets=merged, soundings=[])
    out = [ly for ly in layers
           if (ly.customProperty("encore/acr") or "").upper()
           in _NATIVE_OVER_TILES]
    log(f"Chart Viewer: native navaid overlay — {len(out)} layer(s) "
        f"from {len(merged)} merged navaid bucket(s)")
    return out


def _soundg_params(cfg):
    """The SOUNDG adaptive zoom-level params from processing.json (same curve as
    the main loader / S-101 path), with a safe default if the recipe is absent."""
    for step in cfg.recipe_for("SOUNDG", "point"):
        if isinstance(step, dict) and "zoomlevel" in step:
            return step["zoomlevel"]
    return {"spacing_multipliers": [16, 8, 4, 2, 1],
            "start_factor": 1000, "reveal_floor": 5000}


def _build_s57_sounding_layer(soundings, dst, xform, cfg, log):
    """Build the SOUNDG layer from individual points: greedy shoalest-first
    density thinning (least-depth bias) via the loader's _assign_zoom_levels —
    which also writes the reveal-curve project vars — then SOUNDG_point.qml,
    whose label is z($geometry) (the depth). Identical to the main load path."""
    if not soundings:
        return None
    try:
        from .enc_loader import _assign_zoom_levels, _apply_portrayal
    except (ImportError, ValueError):
        from enc_loader import _assign_zoom_levels, _apply_portrayal
    layer = QgsVectorLayer(f"PointZ?crs={dst.authid() or 'EPSG:4326'}",
                           cfg.display_name("SOUNDG"), "memory")
    dp = layer.dataProvider()
    feats = []
    for x, y, z in soundings:
        g = QgsGeometry(QgsPoint(x, y, z if z is not None else 0.0))
        if xform is not None:
            g.transform(xform)                         # X/Y to target CRS; Z kept
        ft = QgsFeature()
        ft.setGeometry(g)
        feats.append(ft)
    dp.addFeatures(feats)
    layer.setCrs(dst)
    layer.updateExtents()
    try:
        layer = _assign_zoom_levels(layer, _soundg_params(cfg), log)
        layer.setName(cfg.display_name("SOUNDG"))
        layer.setCrs(dst)
        _apply_portrayal(layer, cfg, "SOUNDG", "point", log)
    except Exception as e:                              # noqa: BLE001
        log(f"Chart Viewer: sounding portrayal failed: {e}", Qgis.Warning)
    _tag_layer(layer, cfg.group_for("SOUNDG"), False, acr="SOUNDG", kind="point")
    return layer


def build_chart_layers(best_files, chart_products, target_crs, log=_log):
    """Build all viewer layers from the staged set, bottom→top ordered.

    ``best_files``      {dsnm -> staged .000 path}  (the scanner's stage-1 winners)
    ``chart_products``  {dsnm -> 'S-57' | 'S-101'}
    """
    s57_paths, s101_paths = [], []
    for dsnm, path in (best_files or {}).items():
        if (chart_products or {}).get(dsnm) == "S-101":
            s101_paths.append(path)
        else:
            s57_paths.append(path)

    bottom_to_top = []
    if s57_paths:
        try:
            bottom_to_top += build_s57_layers(s57_paths, target_crs, log)
        except Exception as e:                          # noqa: BLE001
            log(f"Chart Viewer: S-57 build failed: {e}", Qgis.Critical)
    if s101_paths:
        try:
            try:
                from .s101.qgis_loader import load_s101_cells
            except (ImportError, ValueError):
                from s101.qgis_loader import load_s101_cells
            s101_layers = load_s101_cells(
                s101_paths, add_to_project=False, target_crs=target_crs)
            for lyr in s101_layers:
                _tag_s101_layer(lyr)
            bottom_to_top += s101_layers
        except Exception as e:                          # noqa: BLE001
            log(f"Chart Viewer: S-101 build failed: {e}", Qgis.Critical)
    return bottom_to_top


def build_one_cell(dsnm, path, product, target_crs, log=_log, decode_cache=None,
                   skip_classes=None):
    """Build the layers for ONE staged cell (the unit of progressive loading).
    Routes S-57/Inland-ENC through the native builder and S-101 through the
    native S-101 loader; tags every layer with its source cell name.

    ``decode_cache`` (from the scan-time tile bake) is ``{path: {"buckets":…,
    "soundings":…}}`` — when it holds this S-57 cell, the layers are built off
    the pre-decoded buckets, skipping the expensive native decode."""
    if product == "S-101":
        try:
            from .s101.qgis_loader import load_s101_cells
        except (ImportError, ValueError):
            from s101.qgis_loader import load_s101_cells
        layers = load_s101_cells([path], add_to_project=False,
                                 target_crs=target_crs)
        for lyr in layers:
            _tag_s101_layer(lyr)
    else:
        cached = (decode_cache or {}).get(path)
        if cached is not None:
            layers = build_s57_layers([path], target_crs, log,
                                      buckets=cached.get("buckets"),
                                      soundings=cached.get("soundings"),
                                      skip_classes=skip_classes)
        else:
            layers = build_s57_layers([path], target_crs, log,
                                      skip_classes=skip_classes)
    for lyr in layers:
        lyr.setCustomProperty("encore/cell", dsnm)
    return layers


def _tag_s101_layer(layer):
    """Group/meta tag for an S-101 layer. The native loader now tags layers with
    the authoritative s101_theme group (same as S-57); only fall back to
    name-keyword matching for any layer that arrives untagged."""
    if layer.customProperty("encore/group"):
        return                                          # already tagged by loader
    n = (layer.name() or "").lower()
    if any(k in n for k in ("coverage", "quality", "metadata", "data assessment")):
        group, meta = META_GROUP, True
    elif any(k in n for k in ("depth area", "depth contour", "sounding",
                              "dredged", "land area", "unsurveyed")):
        group, meta = "Skin of the Earth", False
    elif any(k in n for k in ("aids to navigation", "buoy", "beacon")):
        group, meta = "Buoys & Beacons", False
    elif any(k in n for k in ("light", "signal", "landmark")):
        group, meta = "Lights & Signals", False
    else:
        group, meta = "S-101 features", False
    _tag_layer(layer, group, meta)


# ── The viewer window ───────────────────────────────────────────────────────
class EcdisViewer(QDialog):
    """Extra window: an ECDIS-style map canvas over the staged cells, wired to
    the dock's Chart Control. Build with ``EcdisViewer.open_for(dock)``."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("ENCore GIS — Chart Viewer (ECDIS view)")
        self.resize(1100, 760)
        # Non-modal so the dock's Chart Control stays usable and drives us live.
        self.setModal(False)
        self._layers = []
        self._palette = "Day"
        self._mode = "Navigator"        # "Navigator" (no metadata) | "All layers"
        self._updating = False          # guard against recursive itemChanged
        self._loading = False           # True while charts populate cell-by-cell
        self._lnam_index = {}           # LNAM -> obj entry (for pick relations)
        self._reverse_refs = {}         # referenced LNAM -> [obj entries]
        # S-102 bathymetric surface overlay state.
        self._s102_paths = []           # consistent S-102 datasets, if any
        self._s102_on = False
        self._s102_surface = None       # QgsRasterLayer
        self._s102_contour = None       # QgsVectorLayer (safety contour)
        self._s102_soundings = None     # QgsVectorLayer
        self._s102_extra = None         # QgsVectorLayer (red extra contour)
        # Footprint-gated tide shading: clipped copies of the surface carrying
        # the tide-shifted bands INSIDE the S-104 footprint (the base surface
        # then keeps the charted shading), + a cache of the charted-cut
        # contours outside the footprint (they don't change with the tide).
        self._s102_tide_surfaces = []   # [QgsRasterLayer] clipped to S-104
        self._s102_tide_key = None      # (paths, boxes) the clips were built for
        self._s102_charted_cache = {}   # (paths, depth, crs, boxes, kind) -> layer
        # S-111 surface-current overlay state (native arrow field + time slider).
        self._s111_paths = []           # detected S-111 datasets, if any
        self._s111_on = False
        self._s111_layer = None         # QgsVectorLayer of current arrows (one time)
        self._s111_times = []           # union time axis (list of datetime)
        # S-104 water-level tidal engine state. NO layer of its own — it feeds
        # @enc_water_level (wet-layer depth bands/contours) + reddens/raises the
        # sounding labels for the selected time. Warning banner while live.
        self._s104_paths = []
        self._s104_on = False
        self._s104_times = []
        self._s104_saved_labeling = {}  # layer id -> original labeling (to restore)
        self._s104_outline = None       # coverage-outline layer (red striped)
        self._s104_boxes = []           # WGS84 footprints the tide APPLIES to
        self._tide_point = None         # last picked point (map CRS) for the graph
        # Tiled WET skin (depth areas/contours) from the scan-time bake. When
        # present, the dense depth layers are NOT built natively — they render
        # from this vector-tile layer, which is what makes the viewer nimble.
        self._wet_tiles = None          # QgsVectorTileLayer or None
        self._dry_tiles = None          # QgsVectorTileLayer (land/coastline/navaids)
        self._wet_valdco = []           # sorted DEPCNT VALDCO values (for @valdco vars)
        self._tiled = False             # True when the wet skin renders from tiles
        self._native_reason = ""        # why native fallback (shown in status)
        self._build_ui()

    # -- construction --------------------------------------------------------
    def _build_ui(self):
        root = QVBoxLayout(self)
        root.setContentsMargins(4, 4, 4, 4)
        root.setSpacing(4)

        # Top bar: view mode + palette + zoom + hint.
        bar = QHBoxLayout()
        # A clickable title at the head of the bar: double-click toggles fullscreen
        # (the native title bar can't be intercepted portably). Esc leaves it.
        self.title_label = QLabel("⚓ Chart Viewer")
        _tf = QFont()
        _tf.setBold(True)
        self.title_label.setFont(_tf)
        self.title_label.setToolTip("Double-click to toggle fullscreen (Esc to exit).")
        self.title_label.setCursor(Qt.PointingHandCursor)
        self.title_label.mouseDoubleClickEvent = lambda _e: self._toggle_fullscreen()
        bar.addWidget(self.title_label)
        bar.addSpacing(10)
        bar.addWidget(QLabel("View:"))
        self.mode_combo = QComboBox()
        self.mode_combo.addItem("Navigator (no metadata)", "Navigator")
        self.mode_combo.addItem("All layers", "All layers")
        self.mode_combo.setToolTip(
            "Navigator: the chart as a navigator wants it — coverage / quality "
            "/ administrative metadata hidden. All layers: show everything.")
        self.mode_combo.currentIndexChanged.connect(self._on_mode)
        bar.addWidget(self.mode_combo)
        bar.addSpacing(12)
        bar.addWidget(QLabel("Palette:"))
        self.palette_combo = QComboBox()
        for name in ("Day", "Dusk", "Night"):
            self.palette_combo.addItem(name)
        self.palette_combo.currentTextChanged.connect(self._on_palette)
        bar.addWidget(self.palette_combo)
        self.zoom_full_btn = QPushButton("Zoom to charts")
        self.zoom_full_btn.clicked.connect(self.zoom_full)
        bar.addWidget(self.zoom_full_btn)
        bar.addSpacing(8)
        self.pan_btn = QPushButton("✋ Pan")
        self.pan_btn.setCheckable(True)
        self.pan_btn.setChecked(True)          # pan is the default tool at load
        self.pan_btn.setToolTip("Drag to pan, wheel to zoom.")
        self.pan_btn.clicked.connect(lambda: self._set_tool("pan"))
        bar.addWidget(self.pan_btn)
        self.pick_btn = QPushButton("ⓘ Pick")
        self.pick_btn.setCheckable(True)
        self.pick_btn.setToolTip("Click a feature for a pick report.")
        self.pick_btn.clicked.connect(lambda: self._set_tool("pick"))
        bar.addWidget(self.pick_btn)
        # WARNING banner shown next to Pick while the S-104 tidal engine is live:
        # red box, white text, size 10, sized to the text. Hidden otherwise.
        self.s104_banner = QLabel("Warning: S-104 tidal engine live!")
        _bf = QFont()
        _bf.setPointSize(10)
        self.s104_banner.setFont(_bf)
        self.s104_banner.setStyleSheet(
            "background-color:#d00000; color:white; padding:3px 8px;")
        self.s104_banner.setVisible(False)
        bar.addSpacing(8)
        bar.addWidget(self.s104_banner)
        hint = QLabel("Chart Control in the dock drives this view live.")
        _hf = QFont()
        _hf.setItalic(True)
        hint.setFont(_hf)
        hint.setStyleSheet("color: gray;")
        bar.addWidget(hint)
        bar.addStretch(1)
        self.status_label = QLabel("")
        self.status_label.setStyleSheet("color: #205020; font-weight: bold;")
        bar.addWidget(self.status_label)
        # Mirrors the dock's tile-bake progress while the viewer waits for the
        # background bake (shown only during that wait).
        self.wait_progress = QProgressBar()
        self.wait_progress.setMaximumWidth(180)
        self.wait_progress.setVisible(False)
        bar.addWidget(self.wait_progress)
        root.addLayout(bar)

        # S-102 bathymetry row — shown only when a consistent S-102 dataset is
        # present in the chart source folder. All boxes default UNCHECKED.
        self.s102_row = QWidget()
        s102_l = QHBoxLayout(self.s102_row)
        s102_l.setContentsMargins(0, 0, 0, 0)
        self.s102_chk = QCheckBox("Portray S-102 bathymetry")
        self.s102_chk.setToolTip(
            "Show the S-102 bathymetric surface in the DEPARE depth-band "
            "colours. Replaces DEPARE / DEPCNT / SOUNDG / M_SREL on that area.")
        self.s102_chk.toggled.connect(self._on_s102_toggle)
        s102_l.addWidget(self.s102_chk)
        self.s102_contour_chk = QCheckBox("+ safety contour from S-102")
        self.s102_contour_chk.setToolTip(
            "Generate a contour at the Chart Control safety depth from the "
            "S-102 grid.")
        self.s102_contour_chk.setEnabled(False)
        self.s102_contour_chk.toggled.connect(self._on_s102_contour_toggle)
        s102_l.addWidget(self.s102_contour_chk)
        self.s102_snd_chk = QCheckBox("+ soundings from S-102")
        self.s102_snd_chk.setToolTip(
            "Generate soundings sampled from the S-102 grid, with the usual "
            "adaptive sounding portrayal.")
        self.s102_snd_chk.setEnabled(False)
        self.s102_snd_chk.toggled.connect(self._on_s102_soundings_toggle)
        s102_l.addWidget(self.s102_snd_chk)
        # S-111 surface currents — same row; shown only when S-111 is present.
        self.s111_chk = QCheckBox("Surface currents (S-111)")
        self.s111_chk.setToolTip(
            "Overlay S-111 surface-current arrows for the selected time "
            "(official portrayal: 9-band speed colours, arrow rotated + sized by "
            "current). Drag the time slider below the chart to scrub.")
        self.s111_chk.toggled.connect(self._on_s111_toggle)
        self.s111_chk.setVisible(False)
        s102_l.addWidget(self.s111_chk)
        self.s111_depth_combo = QComboBox()
        self.s111_depth_combo.setToolTip("S-111 depth layer to show.")
        self.s111_depth_combo.currentIndexChanged.connect(
            lambda _i: self._rebuild_s111())
        self.s111_depth_combo.setVisible(False)
        s102_l.addWidget(self.s111_depth_combo)
        # S-104 water-level tidal engine (driven from the dock; hidden here).
        self.s104_chk = QCheckBox("Water level (S-104)")
        self.s104_chk.toggled.connect(self._on_s104_toggle)
        self.s104_chk.setVisible(False)
        s102_l.addWidget(self.s104_chk)
        s102_l.addStretch(1)
        self.s102_row.setVisible(False)
        root.addWidget(self.s102_row)

        # Body: canvas (+ scale bar) | pick-report + grouped layer tree.
        split = QSplitter(Qt.Horizontal)
        self.canvas = QgsMapCanvas()
        self.canvas.setCanvasColor(_PALETTES["Day"]["background"])
        self.canvas.enableAntiAliasing(True)

        # Canvas + a bottom scale bar, mirroring QGIS' own canvas scale widget.
        canvas_box = QWidget()
        cvl = QVBoxLayout(canvas_box)
        cvl.setContentsMargins(0, 0, 0, 0)
        cvl.setSpacing(2)
        cvl.addWidget(self.canvas, 1)
        # There is no separate time slider: for time-varying S-100 products the
        # side-panel graph's vertical cursor IS the time control (see the graph
        # widget below and _on_graph_time). self._time_index holds the current
        # step; the graph drives it and re-portrays S-111 / S-104.
        self._time_index = 0

        scale_row = QHBoxLayout()
        scale_row.addStretch(1)
        scale_row.addWidget(QLabel("Scale  1:"))
        self.scale_combo = QgsScaleComboBox()
        self.scale_combo.updateScales(
            [f"1:{n}" for n in (1000, 2000, 4000, 8000, 12000, 22000, 45000,
                                90000, 180000, 350000, 700000, 1500000,
                                3000000)])
        self.scale_combo.setToolTip(
            "Map scale — pick a standard scale or type one. SCAMIN-tagged "
            "objects appear/disappear as you cross their display scale.")
        self.scale_combo.scaleChanged.connect(self._on_scale_combo)
        self.canvas.scaleChanged.connect(self._on_canvas_scale)
        scale_row.addWidget(self.scale_combo)
        cvl.addLayout(scale_row)
        split.addWidget(canvas_box)

        side = QWidget()
        side_l = QVBoxLayout(side)
        side_l.setContentsMargins(2, 2, 2, 2)
        # Top slot: the pick-report panel and the S-104 tidal graph share it —
        # the tidal engine swaps one for the other (see _on_s104_toggle).
        self.pick_panel = QWidget()
        pp_l = QVBoxLayout(self.pick_panel)
        pp_l.setContentsMargins(0, 0, 0, 0)
        pp_l.addWidget(QLabel("Pick report (click the chart):"))
        self.pick_scroll = QScrollArea()
        self.pick_scroll.setWidgetResizable(True)
        self.pick_body = QWidget()
        self.pick_vbox = QVBoxLayout(self.pick_body)
        self.pick_vbox.setAlignment(Qt.AlignTop)
        self.pick_vbox.setContentsMargins(2, 2, 2, 2)
        self.pick_scroll.setWidget(self.pick_body)
        pp_l.addWidget(self.pick_scroll, 1)
        side_l.addWidget(self.pick_panel, 1)
        self._set_pick_message("Click the chart for a pick report.")
        try:
            from .enc_tidegraph import TidalGraph
        except (ImportError, ValueError):
            from enc_tidegraph import TidalGraph
        self.tide_graph = TidalGraph(self)
        self.tide_graph.setVisible(False)
        try:
            self.tide_graph.timeIndexChanged.connect(self._on_graph_time)
        except Exception:                                # noqa: BLE001
            pass
        side_l.addWidget(self.tide_graph, 1)
        side_l.addWidget(QLabel("Layers (grouped):"))
        self.layer_tree = QTreeWidget()
        self.layer_tree.setHeaderHidden(True)
        self.layer_tree.itemChanged.connect(self._on_tree_changed)
        side_l.addWidget(self.layer_tree, 2)
        split.addWidget(side)
        split.setStretchFactor(0, 4)
        split.setStretchFactor(1, 1)
        root.addWidget(split, 1)

        # Tools: pan (drag/zoom) and pick (click → pick report). Default pan.
        self._pan = QgsMapToolPan(self.canvas)
        self._pick = QgsMapToolEmitPoint(self.canvas)
        self._pick.canvasClicked.connect(self._on_pick)
        self.canvas.setMapTool(self._pan)

    def _set_tool(self, which):
        self.pan_btn.setChecked(which == "pan")
        self.pick_btn.setChecked(which == "pick")
        self.canvas.setMapTool(self._pan if which == "pan" else self._pick)

    def _toggle_fullscreen(self):
        """Toggle fullscreen (double-click the viewer title, or Esc to leave)."""
        if self.isFullScreen():
            self.showNormal()
        else:
            self.showFullScreen()

    def keyPressEvent(self, event):
        # In fullscreen, Esc returns to a normal window instead of closing the
        # dialog (QDialog's default Esc = reject).
        if event.key() == Qt.Key_Escape and self.isFullScreen():
            self.showNormal()
            event.accept()
            return
        super().keyPressEvent(event)

    # -- public API (progressive loading) ------------------------------------
    def begin_loading(self, n_cells=0):
        """Enter the cell-by-cell loading state BEFORE the build, so the window
        opens immediately and then fills chart by chart."""
        self._loading = True
        self._layers = []
        self._n_cells = n_cells
        self.setWindowTitle("ENCore GIS — Chart Viewer (ECDIS view) — loading…")
        self.status_label.setText(f"⏳ Loading charts… (0/{n_cells})")
        self._set_pick_message(
            "Loading charts — each chart is read with the native engine and "
            "drawn as it becomes ready…")
        self._updating = True
        self.layer_tree.clear()
        self._updating = False
        self.canvas.setLayers([])
        for w in (self.mode_combo, self.palette_combo, self.zoom_full_btn):
            w.setEnabled(False)
        QApplication.processEvents()      # let the window actually paint

    def add_cell_layers(self, layers, i, n, name):
        """Add one freshly-built cell's layers and redraw — the progress step."""
        if layers:
            if not self._layers:
                self.canvas.setDestinationCrs(layers[0].crs())
            self._layers += list(layers)
        self.status_label.setText(f"⏳ Loading chart {i}/{n}: {name}")
        self._refresh_canvas_layers()
        if i == 1:
            self.zoom_full()              # show the first chart immediately
        QApplication.processEvents()      # paint this step before the next cell

    def finish_loading(self, n_layers, n_cells):
        """All cells in: build the grouped tree, apply palette, zoom, announce."""
        self._loading = False
        self.setWindowTitle("ENCore GIS — Chart Viewer (ECDIS view)")
        self._build_lnam_index()
        self._rebuild_tree()
        self._apply_mode_checks()         # Navigator → metadata entries unticked
        self._refresh_canvas_layers()
        self._apply_palette()
        self.zoom_full()
        for w in (self.mode_combo, self.palette_combo, self.zoom_full_btn):
            w.setEnabled(True)
        mode = self._depth_source_label()
        if n_layers:
            self.status_label.setText(
                f"✓ Loading finished — {n_layers} layer(s), {n_cells} chart(s) "
                f"· {mode}")
            self._set_pick_message("Click the chart for a pick report.")
        else:
            self.status_label.setText("Loading finished — no renderable layers.")

    def set_layers(self, bottom_to_top):
        """Non-progressive path (one-shot): hand all layers at once and render."""
        self._loading = False
        self._layers = list(bottom_to_top)
        if self._layers:
            self.canvas.setDestinationCrs(self._layers[0].crs())
        self._build_lnam_index()
        self._rebuild_tree()
        self._apply_mode_checks()
        self._refresh_canvas_layers()
        self._apply_palette()
        self.zoom_full()

    def _layers_top_first(self):
        """Layers in canvas z-order (topmost first): points over lines over
        areas, then by class draw priority — stable across cells."""
        try:
            from .enc_config import get_config
        except (ImportError, ValueError):
            from enc_config import get_config
        cfg = get_config()
        rank = {"area": 0, "line": 1, "point": 2}

        def key(lyr):
            k = lyr.customProperty("encore/kind") or "line"
            acr = lyr.customProperty("encore/acr") or ""
            return (rank.get(k, 1), -cfg.priority_for(acr))
        return sorted(self._layers, key=key, reverse=True)

    # -- S-102 bathymetric surface -------------------------------------------
    # S-98 interoperability: S-102 replaces the S-101/S-57 "skin of the earth"
    # depth portrayal — depth + dredged + unsurveyed areas, contours, soundings,
    # and the bathymetric-quality/reliability meta — so all are suppressed when
    # the S-102 surface is shown.
    _S102_SUPPRESS = {"DEPARE", "DRGARE", "DEPCNT", "SOUNDG", "UNSARE",
                      "M_QUAL", "M_SREL"}

    def _s102_module(self):
        try:
            from . import enc_s102
        except (ImportError, ValueError):
            import enc_s102
        return enc_s102

    def detect_s102(self, candidates):
        """Keep the S-102 datasets (already found by the scanner) that are
        coverage-consistent with the loaded charts, and reveal the S-102 controls
        if any. ``candidates`` is a list of S-102 paths."""
        self._s102_paths = []
        try:
            if candidates:
                self._s102_paths = self._s102_module().coverage_consistent(
                    candidates, self._charts_extent_wgs84())
            _log(f"S-102: {len(candidates or [])} candidate(s) → "
                 f"{len(self._s102_paths)} consistent with the loaded charts")
        except Exception as e:                          # noqa: BLE001
            _log(f"S-102 detection failed: {e}", Qgis.Warning)
        # The S-100 overlay controls live in the dock's Chart Control tab now;
        # this row is kept (as the render engine the dock drives) but hidden.
        self.s102_row.setVisible(False)
        if bool(self._s102_paths):
            self.status_label.setText(
                self.status_label.text()
                + f"  ·  {len(self._s102_paths)} S-102 dataset(s) available")
            self._rebuild_s102_extra()                  # honour a pre-checked box
            self._refresh_canvas_layers()

    # ── S-111 surface currents (time-varying arrow overlay) ─────────────────
    def _s111_module(self):
        try:
            from . import enc_s111
        except (ImportError, ValueError):
            import enc_s111
        return enc_s111

    def detect_s111(self, candidates):
        """Register S-111 datasets + their time axis, populate the depth combo,
        and show the time graph (whose cursor is the time control)."""
        self._s111_paths = list(candidates or [])
        has = bool(self._s111_paths)
        try:
            self._s111_times = (self._s111_module().time_axis(self._s111_paths)
                                if has else [])
        except Exception as e:                          # noqa: BLE001
            _log(f"S-111 time axis failed: {e}", Qgis.Warning)
            self._s111_times = []
        # S-111 controls live in the dock; keep the (hidden) depth combo populated.
        if has:
            self.s111_depth_combo.blockSignals(True)
            self.s111_depth_combo.clear()
            for p in self._s111_paths:
                self.s111_depth_combo.addItem(self._s111_depth_label(p))
            self.s111_depth_combo.blockSignals(False)
            _log(f"S-111: {len(self._s111_paths)} dataset(s), "
                 f"{len(self._s111_times)} time step(s)")
        self._sync_time_panel()

    @staticmethod
    def _s111_depth_label(path):
        # Depth range is encoded in a path folder (e.g. ".../0-5/file.h5"); the
        # depth files otherwise share a basename, so this folder is preserved
        # through zip extraction + saved-set copies (see enc_s111 / enc_tilestore).
        for part in (path or "").replace("\\", "/").split("/"):
            if re.fullmatch(r"\d+-\d+", part):
                return f"{part} m"
        return os.path.basename(path or "")[:18] or "layer"

    # ── time control: the side-panel graph's cursor IS the time slider ───────
    def _time_axis(self):
        """The time domain the graph draws — the longer of the S-111 / S-104 axes
        (they normally coincide for a matched set)."""
        a, b = self._s111_times or [], self._s104_times or []
        return a if len(a) >= len(b) else b

    def _has_time_product(self):
        return bool(self._s111_times or self._s104_times)

    def _sync_time_panel(self):
        """The side-panel graph (the time control) replaces the pick report only
        while an S-104 or S-111 overlay is SELECTED for portrayal (dock Chart
        Control) — loading a dataset alone keeps the pick report. The graph's axis
        + title are kept current so it is ready the instant an overlay is enabled."""
        axis = self._time_axis()
        self._time_index = max(0, min(self._time_index, max(0, len(axis) - 1)))
        self.tide_graph.set_palette(self._palette)
        self.tide_graph.set_time_axis(axis)
        self.tide_graph.set_index(self._time_index)
        if self._s104_times and self._s111_times:
            self.tide_graph.set_title("Time — S-104 tide · S-111 currents")
        elif self._s104_times:
            self.tide_graph.set_title("Tidal prediction (S-104)")
        else:
            self.tide_graph.set_title("Surface-current time (S-111)")
        show = bool(self._s104_on or self._s111_on)
        self.tide_graph.setVisible(show)
        self.pick_panel.setVisible(not show)

    def _on_graph_time(self, i):
        """The user dragged the graph cursor → re-portray for the new time step."""
        self._time_index = int(i)
        if self._s111_on:
            self._rebuild_s111()
        if self._s104_on:
            self._apply_s104()

    def _on_s111_toggle(self, checked):
        self._s111_on = checked
        self._sync_time_panel()                         # graph ⇄ pick report
        self._rebuild_s111()

    def _rebuild_s111(self):
        """(Re)build the S-111 current-arrow field for the selected time + depth,
        then refresh. No-op (clears the field) when the overlay is off."""
        self._s111_layer = None
        if self._s111_on and self._s111_paths and self._s111_times:
            i = max(0, min(self._time_index, len(self._s111_times) - 1))
            d = self.s111_depth_combo.currentIndex()
            paths = ([self._s111_paths[d]] if 0 <= d < len(self._s111_paths)
                     else self._s111_paths[:1])
            try:
                self._s111_layer = self._s111_module().current_field(
                    paths, self._s111_times[i], self._target_crs())
            except Exception as e:                      # noqa: BLE001
                _log(f"S-111 field build failed: {e}", Qgis.Warning)
        self._refresh_canvas_layers()

    # ── S-104 water-level tidal engine ──────────────────────────────────────
    def _s104_module(self):
        try:
            from . import enc_s104
        except (ImportError, ValueError):
            import enc_s104
        return enc_s104

    def detect_s104(self, candidates):
        """Register S-104 datasets + their time axis; show the time graph (its
        cursor unions with S-111 on the same axis)."""
        self._s104_paths = list(candidates or [])
        try:
            self._s104_times = (self._s104_module().time_axis(self._s104_paths)
                                if self._s104_paths else [])
        except Exception as e:                          # noqa: BLE001
            _log(f"S-104 time axis failed: {e}", Qgis.Warning)
            self._s104_times = []
        if self._s104_paths and self._s104_times:
            _log(f"S-104: {len(self._s104_paths)} dataset(s), "
                 f"{len(self._s104_times)} time step(s)")
        self._sync_time_panel()

    def _s104_current_time(self):
        axis = self._s104_times or self._s111_times
        if not axis:
            return None
        return axis[max(0, min(self._time_index, len(axis) - 1))]

    def _on_s104_toggle(self, checked):
        self._s104_on = checked
        self._sync_time_panel()                         # graph ⇄ pick report
        # A previously-picked point re-graphs its tide when S-104 is switched on.
        if checked and self._tide_point is not None:
            self._graph_tide_at(*self._tide_point)
        self._apply_s104()

    def _map_to_lonlat(self, mx, my):
        """(lon, lat) in EPSG:4326 for a canvas (map-CRS) coordinate."""
        from qgis.core import (QgsCoordinateTransform,
                               QgsCoordinateReferenceSystem)
        src = self.canvas.mapSettings().destinationCrs()
        dst = QgsCoordinateReferenceSystem("EPSG:4326")
        p = QgsCoordinateTransform(src, dst, QgsProject.instance()).transform(mx, my)
        return p.x(), p.y()

    def _graph_tide_at(self, mx, my):
        """Graph the S-104 tide curve for a clicked map point in the side panel."""
        self._tide_point = (mx, my)                         # map CRS, for re-graph
        try:
            lon, lat = self._map_to_lonlat(mx, my)
        except Exception as e:                              # noqa: BLE001
            _log(f"S-104 tide graph transform failed: {e}", Qgis.Warning)
            return
        try:
            series = self._s104_module().water_level_series(
                self._s104_paths, lon, lat)
        except Exception as e:                              # noqa: BLE001
            _log(f"S-104 tide series failed: {e}", Qgis.Warning)
            series = []
        ns = "N" if lat >= 0 else "S"
        ew = "E" if lon >= 0 else "W"
        self.tide_graph.set_palette(self._palette)
        self.tide_graph.set_series(
            series, f"{abs(lat):.4f}°{ns}  {abs(lon):.4f}°{ew}")
        self.tide_graph.set_index(self._time_index)

    def _apply_s104(self):
        """Set @enc_water_level for the current time (0 when off) → the wet-layer
        depth bands/contours re-classify at the adjusted depth; adjust + redden the
        sounding labels WITHIN the S-104 footprint (outside it the chart stays
        un-tided and un-reddened); draw the coverage outline (red striped border,
        no fill); show/hide the warning banner. The Chart Control depth values
        are left unchanged (only the DRVAL1/VALDCO side is offset)."""
        level = 0.0
        if self._s104_on and self._s104_paths:
            try:
                level = self._s104_module().water_level_at(
                    self._s104_paths, self._s104_current_time())
            except Exception as e:                      # noqa: BLE001
                _log(f"S-104 read failed: {e}", Qgis.Warning)
            try:
                self._s104_boxes = self._s104_module().footprints(
                    self._s104_paths)
            except Exception as e:                      # noqa: BLE001
                _log(f"S-104 footprint failed: {e}", Qgis.Warning)
                self._s104_boxes = []
        self._ensure_s104_outline(self._s104_on)
        QgsExpressionContextUtils.setProjectVariable(
            QgsProject.instance(), "enc_water_level", float(level))
        self._update_wet_contour_vars()                 # safety/extra vs (target−level)
        if self._wet_tiles is not None:
            self._wet_tiles.triggerRepaint()
        # S-102 surface + its vector contours hold CHARTED depth → re-shade / re-cut
        # them at the tide-shifted thresholds so the bathymetry follows the tide too
        # — but, like the soundings, ONLY inside the S-104 footprint (the shifted
        # shading rides on footprint-clipped copies; outside stays charted).
        if self._s102_on:
            self._recolour_s102()
            self._rebuild_s102_derived()
        self._rebuild_s102_extra()
        self._apply_s104_soundings(self._s104_on, level)
        self.s104_banner.setVisible(self._s104_on)
        # The water-level readout lives in the graph's cursor box; keep the cursor
        # synced to the current step.
        self.tide_graph.set_index(self._time_index)
        self._refresh_canvas_layers()

    def _s104_gate_expr(self, layer):
        """Boolean expression (in ``layer``'s CRS): is a point feature inside
        the S-104 footprint(s)? None when no footprint is known — the caller
        then falls back to ungated adjustment (a metadata-less grid must not
        silently lose the tide feature)."""
        if not self._s104_boxes:
            return None
        try:
            from qgis.core import (QgsCoordinateReferenceSystem,
                                   QgsCoordinateTransform, QgsRectangle)
            wgs = QgsCoordinateReferenceSystem("EPSG:4326")
            xf = (None if layer.crs() == wgs else QgsCoordinateTransform(
                wgs, layer.crs(), QgsProject.instance()))
            parts = []
            for (w, s, e, n) in self._s104_boxes:
                r = QgsRectangle(w, s, e, n)
                if xf is not None:
                    r = xf.transformBoundingBox(r)
                parts.append(
                    f"(x($geometry) >= {r.xMinimum():.8f} AND "
                    f"x($geometry) <= {r.xMaximum():.8f} AND "
                    f"y($geometry) >= {r.yMinimum():.8f} AND "
                    f"y($geometry) <= {r.yMaximum():.8f})")
            return "(" + " OR ".join(parts) + ")"
        except Exception as e:                          # noqa: BLE001
            _log(f"S-104 gate expression failed: {e}", Qgis.Warning)
            return None

    def _ensure_s104_outline(self, on):
        """Create / refresh / drop the S-104 coverage-outline layer: the
        footprint the tide adjustment applies to, drawn as a red striped
        border with NO fill (WGS84 memory layer; QGIS reprojects)."""
        if not on or not self._s104_boxes:
            self._s104_outline = None
            return
        try:
            from qgis.core import (QgsVectorLayer, QgsFeature, QgsGeometry,
                                   QgsRectangle, QgsFillSymbol,
                                   QgsSingleSymbolRenderer)
            ly = QgsVectorLayer("Polygon?crs=EPSG:4326",
                                "S-104 coverage", "memory")
            dp = ly.dataProvider()
            feats = []
            for (w, s, e, n) in self._s104_boxes:
                ft = QgsFeature()
                ft.setGeometry(QgsGeometry.fromRect(QgsRectangle(w, s, e, n)))
                feats.append(ft)
            dp.addFeatures(feats)
            ly.updateExtents()
            sym = QgsFillSymbol.createSimple({
                "style": "no",                          # no fill — outline only
                "outline_color": "200,0,0,220",
                "outline_style": "dash",
                "outline_width": "0.66"})
            ly.setRenderer(QgsSingleSymbolRenderer(sym))
            self._s104_outline = ly
        except Exception as e:                          # noqa: BLE001
            _log(f"S-104 outline failed: {e}", Qgis.Warning)
            self._s104_outline = None

    def _apply_s104_soundings(self, on, level):
        """Adjust sounding labels for the tide: add @enc_water_level to the depth
        and colour them RED — but ONLY inside the S-104 footprint. Outside it the
        depth and colour stay exactly as charted (data and portrayal consistent
        where no water-level data applies). Restores the original labeling when
        off. Preserves the adaptive density reveal (only the label expr + colour
        of each rule's settings change)."""
        try:
            from qgis.core import (QgsRuleBasedLabeling,
                                   QgsVectorLayerSimpleLabeling,
                                   QgsPalLayerSettings, QgsProperty)
            from qgis.PyQt.QtGui import QColor
        except (ImportError, ValueError):
            return
        snd = [ly for ly in self._layers
               if (ly.customProperty("encore/acr") or "").upper() == "SOUNDG"]
        if self._s102_soundings is not None:
            snd.append(self._s102_soundings)
        for ly in snd:
            lid = ly.id()
            if on:
                if lid not in self._s104_saved_labeling and ly.labeling():
                    self._s104_saved_labeling[lid] = ly.labeling().clone()
                base = self._s104_saved_labeling.get(lid)
                lab = base.clone() if base else (
                    ly.labeling().clone() if ly.labeling() else None)
                if lab is None:
                    continue
                gate = self._s104_gate_expr(ly)

                def _fix(settings):
                    tided = "(z($geometry) + coalesce(@enc_water_level, 0))"
                    if gate is not None:
                        tided = f"if({gate}, {tided}, z($geometry))"
                    if settings.isExpression or "z($geometry)" in settings.fieldName:
                        settings.fieldName = settings.fieldName.replace(
                            "z($geometry)", tided)
                        settings.isExpression = True
                    fmt = settings.format()
                    if gate is None:
                        fmt.setColor(QColor(200, 0, 0))
                    else:
                        # red only INSIDE the footprint; charted colour outside
                        orig = fmt.color().name()
                        settings.dataDefinedProperties().setProperty(
                            QgsPalLayerSettings.Color,
                            QgsProperty.fromExpression(
                                f"if({gate}, '#c80000', '{orig}')"))
                    settings.setFormat(fmt)
                    return settings

                if isinstance(lab, QgsRuleBasedLabeling):
                    def _walk(rule):
                        for c in rule.children():
                            if c.settings():
                                c.setSettings(_fix(QgsPalLayerSettings(c.settings())))
                            _walk(c)
                    _walk(lab.rootRule())
                elif isinstance(lab, QgsVectorLayerSimpleLabeling):
                    lab = QgsVectorLayerSimpleLabeling(
                        _fix(QgsPalLayerSettings(lab.settings())))
                ly.setLabeling(lab)
            elif lid in self._s104_saved_labeling:
                ly.setLabeling(self._s104_saved_labeling.pop(lid).clone())
            ly.setLabelsEnabled(True)
            ly.triggerRepaint()

    def _charts_extent_wgs84(self):
        rect = None
        for lyr in self._layers:
            e = lyr.extent()
            if e.isEmpty():
                continue
            rect = QgsRectangle(e) if rect is None else rect
            rect.combineExtentWith(e)
        if rect is None:
            return None
        x = self._to_wgs84()
        return x.transformBoundingBox(rect) if x is not None else rect

    def _contours(self):
        """(shallow, safety, deep) from the live Chart Control project vars."""
        sc = QgsExpressionContextUtils.projectScope(QgsProject.instance())

        def gv(name, default):
            try:
                return float(sc.variable(name))
            except (TypeError, ValueError):
                return default
        return (gv("enc_shallow_contour", 2.0),
                gv("enc_safety_contour", 6.0),
                gv("enc_deep_contour", 10.0))

    def _s102_suppresses(self, lyr):
        if not self._s102_on:
            return False
        acr = (lyr.customProperty("encore/acr") or "").upper()
        if acr in self._S102_SUPPRESS:
            return True
        nm = (lyr.name() or "").lower()
        return any(k in nm for k in ("depth area", "dredged area", "depth contour",
                                     "sounding", "unsurveyed area",
                                     "survey reliability",
                                     "quality of bathymetric"))

    def _target_crs(self):
        return self.canvas.mapSettings().destinationCrs().authid() or "EPSG:3857"

    def _depth_source_label(self):
        """Where the wet-layer depths come from, for the status line. When S-102 is
        portrayed it supersedes DEPARE/DEPCNT/SOUNDG on its footprint, so the depths
        there are the S-102 surface — not the vector tiles."""
        if getattr(self, "_s102_on", False):
            return ("depth from S-102 surface"
                    + (" (vector tiles elsewhere)" if self._tiled else ""))
        if self._tiled:
            return "depth from vector tiles"
        if getattr(self, "_native_reason", None):
            return f"native build (no tiles - {self._native_reason})"
        return "native build (no tiles)"

    def _on_s102_toggle(self, checked):
        self._s102_on = checked
        self.s102_contour_chk.setEnabled(checked)
        self.s102_snd_chk.setEnabled(checked)
        if checked:
            self._ensure_s102_surface()
            self._rebuild_s102_derived()
        self._refresh_canvas_layers()
        # Keep the status honest about the depth source (S-102 vs tiles).
        try:
            self.status_label.setText(
                ("S-102 bathymetry on - " if checked else "S-102 off - ")
                + self._depth_source_label())
        except (AttributeError, RuntimeError):
            pass

    def _on_s102_contour_toggle(self, _checked):
        self._rebuild_s102_derived()
        self._refresh_canvas_layers()

    def _on_s102_soundings_toggle(self, _checked):
        self._rebuild_s102_derived()
        self._refresh_canvas_layers()

    def _ensure_s102_surface(self):
        if self._s102_surface is not None or not self._s102_paths:
            return
        s102 = self._s102_module()
        self._s102_surface = s102.load_surface(self._s102_paths,  # all tiles
                                               self._target_crs())
        self._recolour_s102()

    def _water_level(self):
        """Current S-104 water level (m, 0.0 when the engine is off). The S-102
        surface/contours hold CHARTED depth, so a band/contour at target depth T
        sits at charted depth T − level once the tide is applied."""
        try:
            sc = QgsExpressionContextUtils.projectScope(QgsProject.instance())
            return float(sc.variable("enc_water_level") or 0.0)
        except (TypeError, ValueError):
            return 0.0

    def _s104_gate_boxes(self):
        """The S-104 footprint boxes the tide portrayal must be confined to,
        or [] when the S-102 side needs no gating: S-104 off, level exactly 0
        (shifted == charted), or a metadata-less grid — the latter falls back
        to the old ungated behaviour, same as the sounding labels."""
        if not (self._s104_on and self._s104_boxes
                and self._water_level() != 0.0):
            return []
        return list(self._s104_boxes)

    def _recolour_s102(self):
        if self._s102_surface is None:
            return
        shallow, safety, deep = self._contours()
        level = self._water_level()                     # S-104 tide shift
        bands = _PALETTES.get(self._palette, _PALETTES["Day"])["bands"]
        s102 = self._s102_module()
        boxes = self._s104_gate_boxes()
        if boxes:
            # Footprint-gated: the base mosaic keeps the CHARTED shading and a
            # footprint-clipped copy stacked above it carries the tide-shifted
            # bands — outside the red outline the bathymetry stays as charted.
            s102.apply_depth_shader(
                self._s102_surface, shallow, safety, deep, bands)
            key = (tuple(sorted(self._s102_paths)), tuple(boxes))
            if key != self._s102_tide_key:
                self._s102_tide_surfaces = s102.load_surface_clips(
                    self._s102_paths, boxes)
                self._s102_tide_key = key
            for ly in self._s102_tide_surfaces:
                s102.apply_depth_shader(
                    ly, shallow - level, safety - level, deep - level, bands)
        else:
            self._s102_tide_surfaces, self._s102_tide_key = [], None
            s102.apply_depth_shader(
                self._s102_surface,
                shallow - level, safety - level, deep - level, bands)

    # -- tiled WET skin (depth areas/contours from the scan-time bake) --------
    def _tile_styles_module(self):
        try:
            from . import enc_tile_styles as ts
        except (ImportError, ValueError):
            import enc_tile_styles as ts
        return ts

    def setup_wet_tiles(self, wet_mbtiles, decode_cache):
        """Load the baked WET vector-tile plane and style it from our engine.
        Returns True on success (then the caller skips the native wet build).
        DEPCNT VALDCO values are harvested from the decode cache so the safety/
        extra contour can be selected without an aggregate the tiles can't run."""
        try:
            from qgis.core import QgsVectorTileLayer
        except (ImportError, ValueError):
            return False
        vt = QgsVectorTileLayer(f"type=mbtiles&url={wet_mbtiles}", "Depth (tiled)")
        if not vt.isValid():
            _log("Chart Viewer: wet tile layer invalid — falling back to native",
                 Qgis.Warning)
            return False
        valdco = set()
        for cell in (decode_cache or {}).values():
            for key, b in (cell.get("buckets") or {}).items():
                if key[0].upper() != "DEPCNT":
                    continue
                for _rcid, _wkt, named in b["rows"]:
                    v = named.get("VALDCO")
                    try:
                        if v not in (None, ""):
                            valdco.add(float(v))
                    except (TypeError, ValueError):
                        pass
        self._wet_valdco = sorted(valdco)
        self._wet_tiles = vt
        self._update_wet_contour_vars()
        self._recolour_wet_tiles()
        return True

    def _recolour_wet_tiles(self):
        """(Re)apply the wet-skin tile styles for the current palette."""
        if self._wet_tiles is None:
            return
        try:
            from qgis.core import QgsVectorTileBasicRenderer
        except (ImportError, ValueError):
            return
        bands = _PALETTES.get(self._palette, _PALETTES["Day"])["bands"]
        rb = QgsVectorTileBasicRenderer()
        rb.setStyles(self._tile_styles_module().wet_skin_styles(bands))
        self._wet_tiles.setRenderer(rb)
        self._wet_tiles.triggerRepaint()

    def _update_wet_contour_vars(self):
        """Precompute the safety/extra contour VALDCO into @enc_safety_valdco /
        @enc_extra_valdco so the wet tile styles can select those contours by a
        plain equality (QGIS aggregates don't evaluate inside tile styles)."""
        if not self._wet_valdco:
            return
        ts = self._tile_styles_module()
        proj = QgsProject.instance()
        _shallow, safety, _deep = self._contours()
        sc = QgsExpressionContextUtils.projectScope(proj)
        try:
            extra = float(sc.variable("enc_extra_contour"))
        except (TypeError, ValueError):
            extra = safety
        # S-104: actual depth = charted VALDCO + water level, so the contour that
        # IS the safety depth at this tide is the charted VALDCO = safety − level.
        try:
            level = float(sc.variable("enc_water_level") or 0.0)
        except (TypeError, ValueError):
            level = 0.0
        QgsExpressionContextUtils.setProjectVariable(
            proj, "enc_safety_valdco",
            ts.contour_value(self._wet_valdco, safety - level) or (safety - level))
        QgsExpressionContextUtils.setProjectVariable(
            proj, "enc_extra_valdco",
            ts.contour_value(self._wet_valdco, extra - level) or (extra - level))

    @staticmethod
    def _src_name(acr):
        """Bake source-layer name for a class acronym — identical to
        enc_tile_bake._src_name so the viewer's styles/visibility match the tiles
        ('AtoN' → 'ATON' via upper-casing)."""
        return re.sub(r"\W+", "_", (acr or "")).strip("_").upper()[:30] or "X"

    def setup_dry_tiles(self, dry_mbtiles, dry_layers):
        """Load the baked DRY vector-tile plane (land, coastline, aids-to-nav,
        structures…) and style it by translating the engine renderers/labeling
        of the ``dry_layers`` (the native layers already built for the tree/pick
        index) into vector-tile styles. Source-layer names must match the bake's
        (_src_name); the AtoN composite is baked as 'ATON'. Returns True/False."""
        try:
            from qgis.core import (QgsVectorTileLayer,
                                   QgsVectorTileBasicRenderer,
                                   QgsVectorTileBasicLabeling)
        except (ImportError, ValueError):
            return False
        ts = self._tile_styles_module()
        rstyles, lstyles = [], []
        for lyr in dry_layers:
            src = self._src_name(lyr.customProperty("encore/acr") or lyr.name())
            try:
                rstyles += ts.styles_from_renderer(lyr, src)
                lstyles += ts.labeling_from_layer(lyr, src)
            except Exception as e:                      # noqa: BLE001
                _log(f"Chart Viewer: dry tile style for {src} failed: {e}",
                     Qgis.Warning)
        # DEDUPE: the per-cell native layers repeat each class (108 cells → 108×
        # LNDARE …), so the raw lists carry thousands of identical styles. A
        # QgsVectorTileBasicRenderer with that many styles crashes on clone at
        # render time. Collapse to one style per (source-layer, geometry, filter)
        # — the styles target source-layers by name, so per-cell copies are
        # redundant; distinct categories (different filters) are kept.

        def _dedupe(styles, by_name=False):
            seen, out = set(), []
            for st in styles:
                # Label styles keyed by their NAME (which carries the rule role,
                # e.g. ':valsou_whole' vs ':valsou_dec') so distinct roles on one
                # layer both survive, while per-cell copies still collapse.
                key = ((st.styleName(), st.filterExpression()) if by_name
                       else (st.layerName(), int(st.geometryType()),
                             st.filterExpression()))
                if key in seen:
                    continue
                seen.add(key)
                out.append(st)
            return out
        rstyles = _dedupe(rstyles)
        lstyles = _dedupe(lstyles, by_name=True)
        # S-52 draw order within the plane: the vector-tile renderer paints styles
        # in list order (later = on top), and the dry layers arrive in an arbitrary
        # (per-class) order — so sort by geometry: AREAS (fills) at the bottom, then
        # LINES, then POINT symbols on TOP. Without this a filled area (e.g. a canal)
        # can paint over a point symbol (e.g. a notice mark) and hide it. Stable, so
        # the relative order within each geometry (S-52 categories) is preserved.
        _grank = {2: 0, 1: 1, 0: 2}          # Polygon->0(bottom) Line->1 Point->2(top)
        rstyles.sort(key=lambda st: _grank.get(int(st.geometryType()), 3))
        _log(f"Chart Viewer: dry tiles — {len(rstyles)} style(s), "
             f"{len(lstyles)} label style(s)")
        vt = QgsVectorTileLayer(f"type=mbtiles&url={dry_mbtiles}", "Dry (tiled)")
        if not vt.isValid():
            _log("Chart Viewer: dry tile layer invalid — keeping native dry",
                 Qgis.Warning)
            return False
        rb = QgsVectorTileBasicRenderer()
        rb.setStyles(rstyles)
        vt.setRenderer(rb)
        lab = QgsVectorTileBasicLabeling()
        lab.setStyles(lstyles)
        vt.setLabeling(lab)
        self._dry_tiles = vt
        return True

    def _apply_dry_tile_visibility(self):
        """Match the dry tile styles to the tree checkboxes (+ Navigator metadata
        hide): a class unchecked in the tree is disabled in the tiles too, so tree
        toggles control the tiled dry plane. While loading / before the tree is
        built, everything shows."""
        if self._dry_tiles is None:
            return
        try:
            from qgis.core import QgsVectorTileBasicRenderer
        except (ImportError, ValueError):
            return
        rb = self._dry_tiles.renderer()
        if rb is None or not hasattr(rb, "styles"):
            return
        checked_srcs = None                              # None = show all
        if not self._loading and self.layer_tree.topLevelItemCount() > 0:
            checked = self._checked_layer_ids()
            checked_srcs = {
                self._src_name(lyr.customProperty("encore/acr") or lyr.name())
                for lyr in self._layers if lyr.id() in checked}
        styles = rb.styles()
        for st in styles:
            st.setEnabled(checked_srcs is None
                          or st.layerName() in checked_srcs)
        # Build a FRESH renderer we own and hand THAT to the layer. NEVER pass
        # the layer back its own renderer() pointer: setRenderer() takes
        # ownership and deletes the previous renderer, which — if it is the same
        # object — leaves a dangling pointer that crashes the next render's
        # createMapRenderer/clone (access violation, seen on the 108-cell U5).
        new_rb = QgsVectorTileBasicRenderer()
        new_rb.setStyles(styles)
        self._dry_tiles.setRenderer(new_rb)

    def _gated_contour(self, build, depth, level, kind):
        """An S-102 contour honouring the S-104 footprint gate: cut at the
        tide-shifted depth INSIDE the footprint, at the charted depth outside,
        merged into one layer. Ungated (single tide-shifted cut, the old
        behaviour) when no gate applies. ``build(cut_depth)`` returns the
        styled contour layer. The charted-cut half doesn't change with the
        tide, so it is cached across time-slider steps."""
        boxes = self._s104_gate_boxes()
        if not boxes:
            return build(depth - level)
        s102 = self._s102_module()
        inner = build(depth - level)
        s102.clip_lines_to_boxes(inner, boxes, inside=True)
        key = (tuple(sorted(self._s102_paths)), float(depth),
               str(self._target_crs()), tuple(boxes), kind)
        outer = self._s102_charted_cache.get(key)
        if outer is None:
            outer = build(depth)
            if outer is not None:
                s102.clip_lines_to_boxes(outer, boxes, inside=False)
                if len(self._s102_charted_cache) > 8:
                    self._s102_charted_cache.clear()
                self._s102_charted_cache[key] = outer
        return s102.merge_contours(inner, outer)

    def _rebuild_s102_derived(self):
        """(Re)build the optional safety contour + soundings from S-102 to match
        the current Chart Control. No-op for an unchecked sub-option."""
        if not (self._s102_on and self._s102_paths):
            self._s102_contour = self._s102_soundings = None
            return
        s102 = self._s102_module()
        paths, crs = self._s102_paths, self._target_crs()
        _shallow, safety, _deep = self._contours()
        level = self._water_level()                     # S-104 tide shift
        # Cut the line at the tide-shifted CHARTED depth, but tag it with the true
        # safety value so the DEPCNT QML keeps drawing it as the bold safety line.
        # The shift applies only inside the S-104 footprint (charted cut outside).
        self._s102_contour = (self._gated_contour(
            lambda cut: s102.safety_contour(paths, cut, crs, valdco=safety),
            safety, level, "safety")
            if self.s102_contour_chk.isChecked() else None)
        self._s102_soundings = (s102.soundings(paths, crs)
                                if self.s102_snd_chk.isChecked() else None)
        # If the tide engine is already live, redden + tide-adjust the just-built
        # S-102 soundings too (they were created after S-104 was switched on).
        if self._s104_on:
            self._apply_s104_soundings(True, self._water_level())

    def _rebuild_s102_extra(self):
        """(Re)build the RED extra contour from S-102 — driven by the Chart
        Control 'extra contour' box/checkbox. Shown whenever the box is checked
        and an S-102 dataset is present, independent of the surface toggle."""
        self._s102_extra = None
        if not self._s102_paths:
            return
        sc = QgsExpressionContextUtils.projectScope(QgsProject.instance())
        try:
            on = int(sc.variable("enc_extra_contour_on") or 0)
            depth = float(sc.variable("enc_extra_contour"))
        except (TypeError, ValueError):
            return
        if not on:
            return
        level = self._water_level()                     # S-104 tide shift
        # Same footprint gate as the safety contour: shifted cut inside the
        # S-104 coverage, charted cut outside.
        self._s102_extra = self._gated_contour(
            lambda cut: self._s102_module().extra_contour(
                self._s102_paths, cut, self._target_crs()),
            depth, level, "extra")

    def notify_chart_control_changed(self):
        """Called by the dock when a Chart Control value changes: re-portray
        (incl. recolouring the S-102 surface and regenerating its derivatives,
        and the red extra contour)."""
        if self._s102_on:
            self._recolour_s102()
            self._rebuild_s102_derived()
        self._rebuild_s102_extra()                      # independent of surface
        # Tiled wet skin: recompute the safety/extra contour VALDCO and repaint
        # (its styles read the live @enc_*_contour + @enc_*_valdco vars).
        if self._wet_tiles is not None:
            self._update_wet_contour_vars()
            self._wet_tiles.triggerRepaint()
        self._refresh_canvas_layers()
        self.refresh()

    def refresh(self):
        for lyr in self._layers:
            try:
                lyr.triggerRepaint()
            except Exception:                           # noqa: BLE001
                pass
        self.canvas.refresh()

    def _on_scale_combo(self, scale):
        if scale and scale > 0:
            self.canvas.zoomScale(scale)              # scale = denominator

    def _on_canvas_scale(self, *_):
        self.scale_combo.blockSignals(True)
        self.scale_combo.setScale(self.canvas.scale())
        self.scale_combo.blockSignals(False)

    def zoom_full(self):
        rect = None
        # Include the tile planes: in full-tile mode self._layers is only a
        # sample (few cells), so the true coverage comes from the tile layers.
        extra = [t for t in (self._wet_tiles, self._dry_tiles) if t is not None]
        for lyr in list(self._layers) + extra:
            ext = lyr.extent()
            if ext.isEmpty():
                continue
            rect = QgsRectangle(ext) if rect is None else rect
            rect.combineExtentWith(ext)
        if rect is not None and not rect.isEmpty():
            rect.scale(1.05)
            self.canvas.setExtent(rect)
        self.canvas.refresh()

    # -- palette -------------------------------------------------------------
    def _on_palette(self, name):
        self._palette = name
        self._apply_palette()

    def _apply_palette(self):
        pal = _PALETTES.get(self._palette, _PALETTES["Day"])
        if pal["background"] is not None:
            self.canvas.setCanvasColor(pal["background"])
        # Best-effort depth-area recolour (DEPARE / DRGARE rule renderer).
        for lyr in self._layers:
            try:
                if lyr.customProperty("encore/acr") in ("DEPARE", "DRGARE"):
                    self._recolour_depth_bands(lyr, pal["bands"])
            except Exception:                           # noqa: BLE001
                pass
        if self._s102_on:                               # surface follows palette
            self._recolour_s102()
        self._recolour_wet_tiles()                      # tiled depth follows palette
        try:
            self.tide_graph.set_palette(self._palette)  # tidal graph follows palette
        except (AttributeError, RuntimeError):
            pass
        self.refresh()

    @staticmethod
    def _recolour_depth_bands(layer, bands):
        """Override the fill colour of each depth band by its QML rule label."""
        renderer = layer.renderer()
        root = getattr(renderer, "rootRule", None)
        if root is None:
            return
        for rule in root().children():
            label = rule.label() or ""
            rgb = bands.get(label)
            sym = rule.symbol()
            if rgb is not None and sym is not None:
                sym.setColor(QColor(*rgb))

    # -- view mode -----------------------------------------------------------
    def _on_mode(self, _idx):
        # Navigator unchecks the metadata layer entries (they stay listed so the
        # user sees what's suppressed and can re-tick them); All re-ticks them.
        self._mode = self.mode_combo.currentData() or "Navigator"
        self._apply_mode_checks()
        self._refresh_canvas_layers()

    def _apply_mode_checks(self):
        """Set the metadata entries' check state from the current view mode,
        leaving the user's other choices untouched."""
        want = Qt.Unchecked if self._mode == "Navigator" else Qt.Checked
        self._updating = True
        for i in range(self.layer_tree.topLevelItemCount()):
            gi = self.layer_tree.topLevelItem(i)
            for j in range(gi.childCount()):
                ci = gi.child(j)
                if ci.data(0, Qt.UserRole + 1):       # is-meta flag
                    ci.setCheckState(0, want)
        self._updating = False

    def _is_meta(self, lyr):
        return bool(lyr.customProperty("encore/meta"))

    @staticmethod
    def _type_key(lyr):
        """A stable per-TYPE key so the same class+geometry from different charts
        collapses to one tree entry controlling all of them."""
        acr = lyr.customProperty("encore/acr")
        kind = lyr.customProperty("encore/kind")
        return (acr, kind) if acr else (lyr.name(), kind)

    # -- grouped layer tree --------------------------------------------------
    def _group_order(self):
        """Group display order from enc_layers.xml; unknown groups appended."""
        try:
            from .enc_config import get_config
        except (ImportError, ValueError):
            from enc_config import get_config
        order = {name: i for i, (name, _codes) in enumerate(get_config().groups)}
        return order

    def _rebuild_tree(self):
        """One entry per layer TYPE for the whole loaded set (collapsing the
        per-chart layers), grouped like the Object-layers tab. Unchecking an
        entry suppresses that type on every chart."""
        self._updating = True
        self.layer_tree.clear()
        order = self._group_order()
        # group -> {type_key: {"name", "meta", "ids": [layer ids across charts]}}
        groups = {}
        for lyr in self._layers_top_first():
            g = lyr.customProperty("encore/group") or _OTHER_GROUP
            tk = self._type_key(lyr)
            bucket = groups.setdefault(g, {})
            info = bucket.setdefault(tk, {"name": lyr.name(),
                                          "meta": self._is_meta(lyr),
                                          "ids": []})
            info["ids"].append(lyr.id())
        big = len(order) + 1000
        for gname in sorted(groups, key=lambda n: (order.get(n, big), n)):
            gi = QTreeWidgetItem([gname])
            gi.setFlags(gi.flags() | Qt.ItemIsUserCheckable
                        | Qt.ItemIsAutoTristate)
            self.layer_tree.addTopLevelItem(gi)
            for info in groups[gname].values():
                n = len(info["ids"])
                label = info["name"] + (f"  ({n} charts)" if n > 1 else "")
                ci = QTreeWidgetItem([label])
                ci.setFlags(ci.flags() | Qt.ItemIsUserCheckable)
                ci.setData(0, Qt.UserRole, list(info["ids"]))   # all charts
                ci.setData(0, Qt.UserRole + 1, info["meta"])    # is-meta flag
                ci.setCheckState(0, Qt.Checked)
                gi.addChild(ci)
            gi.setExpanded(True)
        self._updating = False

    def _checked_layer_ids(self):
        ids = set()
        for i in range(self.layer_tree.topLevelItemCount()):
            gi = self.layer_tree.topLevelItem(i)
            for j in range(gi.childCount()):
                ci = gi.child(j)
                if ci.checkState(0) == Qt.Checked:
                    ids.update(ci.data(0, Qt.UserRole) or [])   # all charts
        return ids

    def _refresh_canvas_layers(self):
        on = []
        # S-104 coverage outline (red striped border, no fill): shows where the
        # water-level adjustment APPLIES — topmost, above even the arrows.
        if self._s104_on and self._s104_outline is not None:
            on.append(self._s104_outline)
        # S-111 surface-current arrows are a dynamic overlay on top of everything.
        if self._s111_on and self._s111_layer is not None:
            on.append(self._s111_layer)
        # The red extra contour (from S-102) sits on top of everything.
        if self._s102_extra is not None:
            on.append(self._s102_extra)
        # S-102 derived overlays draw on top (like DEPCNT/SOUNDG).
        if self._s102_on:
            if self._s102_soundings is not None:
                on.append(self._s102_soundings)
            if self._s102_contour is not None:
                on.append(self._s102_contour)
        if self._loading or self.layer_tree.topLevelItemCount() == 0:
            # Tree not built yet: show what's loaded so far, minus metadata in
            # Navigator mode (mirrors the check state the tree will get).
            vis = [lyr for lyr in self._layers_top_first()     # topmost first
                   if not (self._mode == "Navigator" and self._is_meta(lyr))]
        else:
            checked = self._checked_layer_ids()        # type checks → layer ids
            vis = [lyr for lyr in self._layers_top_first()
                   if lyr.id() in checked]
        # FULL VECTOR-TILE mode: the dense planes render from tiles. A few classes
        # stay NATIVE on top of the tiles because their portrayal does not survive
        # MVT tiling: SOUNDG (adaptive least-depth thinning); the AtoN composite
        # (folded topmark/light bodies + SCAMIN scale-suppression); and LIGHTS
        # (sector legs/arc are QML geometry generators, which MVT clips per tile).
        # These are also the pick/tree index for those classes. Z-order (top→
        # bottom): S-102 overlays · SOUNDG/AtoN/lights · dry tiles · S-102 · wet.
        if self._dry_tiles is not None:
            self._apply_dry_tile_visibility()            # tree/Navigator → tiles
            on += [lyr for lyr in vis
                   if (lyr.customProperty("encore/acr") or "").upper()
                   in _NATIVE_OVER_TILES]
            on.append(self._dry_tiles)
            if self._s102_on and self._s102_surface is not None:
                on += self._s102_tide_surfaces   # tide bands inside S-104 only
                on.append(self._s102_surface)
            if self._wet_tiles is not None:
                on.append(self._wet_tiles)
            self.canvas.setLayers(on)
            self.canvas.refresh()
            return
        if self._s102_on and self._s102_surface is not None:
            # S-98 interleaving: the S-102 surface replaces the skin-of-the-earth
            # depiction (depth areas/contours/soundings) ONLY within its coverage.
            # It does NOT cover the whole chart — outside the surveyed channels it
            # is nodata (transparent). So rather than HIDING the vector depth
            # layers globally (which left blank holes wherever S-102 had no data —
            # "a part does not render, constrained by a bounding box"), we keep
            # them and slot the S-102 surface ABOVE the skin layers but BELOW
            # everything else (land, navaids, coastline, labels). Where S-102 has
            # data it paints over the vector skin; where it is transparent the
            # vector skin shows through — seamless, no holes.
            skin = [lyr for lyr in vis if self._s102_suppresses(lyr)]
            rest = [lyr for lyr in vis if not self._s102_suppresses(lyr)]
            on += rest
            on += self._s102_tide_surfaces       # tide bands inside S-104 only
            on.append(self._s102_surface)
            on += skin
        else:
            on += vis
        # The tiled WET skin sits at the very bottom — below the S-102 surface
        # (which paints over it within its coverage) and below the native dry
        # layers / soundings drawn on top.
        if self._wet_tiles is not None:
            on.append(self._wet_tiles)
        self.canvas.setLayers(on)
        self.canvas.refresh()

    def _on_tree_changed(self, _item, _col):
        if self._updating:
            return
        self._refresh_canvas_layers()

    # -- pick report ---------------------------------------------------------
    def _build_lnam_index(self):
        """Index every feature by its LNAM, and a reverse index of which
        features reference each LNAM (LNAM_REFS) — so a pick can connect a buoy
        to its topmark/light via the S-57 master/slave links."""
        self._lnam_index = {}
        self._reverse_refs = {}
        for lyr in self._layers:
            names = [f.name() for f in lyr.fields()]
            if "LNAM" not in names:
                continue
            acr = lyr.customProperty("encore/acr") or lyr.name()
            has_refs = "LNAM_REFS" in names
            for ft in lyr.getFeatures():
                ln = ft["LNAM"]
                if not ln:
                    continue
                ln = str(ln)
                entry = {"lnam": ln, "acr": acr,
                         "attrs": self._feature_attrs(lyr, ft)}
                self._lnam_index.setdefault(ln, entry)
                if has_refs and ft["LNAM_REFS"]:
                    for r in str(ft["LNAM_REFS"]).split(","):
                        r = r.strip()
                        if r:
                            self._reverse_refs.setdefault(r, []).append(entry)

    @staticmethod
    def _feature_attrs(layer, ft):
        out = {}
        for fld in layer.fields():
            v = ft[fld.name()]
            if v not in (None, ""):
                out[fld.name()] = v
        return out

    def _resolve_related(self, primary):
        """Linked objects via LNAM/LNAM_REFS, both directions: the primary's own
        refs (master→slave) and any feature that references the primary
        (slave→master)."""
        related, seen = [], set()

        def add(entry):
            key = entry.get("lnam")
            if key and key not in seen and key != primary.get("lnam"):
                seen.add(key)
                related.append(entry)
        for r in str(primary["attrs"].get("LNAM_REFS", "")).split(","):
            r = r.strip()
            if r and r in self._lnam_index:
                add(self._lnam_index[r])
        for entry in self._reverse_refs.get(primary.get("lnam"), []):
            add(entry)
        return related

    @staticmethod
    def _aton_folded_related(attrs):
        """Synthesise topmark/light 'related' entries from the TT_/LT_ fields the
        AtoN engine folded onto a composite buoy/beacon row — so the pick report
        still describes the whole mark even though the topmark and buoy-light are
        no longer separate features. Maps TT_*/LT_* back to the bare TOPSHP/COLOUR
        /LITCHR… names enc_describe expects."""
        out = []
        tt = {k[3:]: attrs[k] for k in ("TT_TOPSHP", "TT_COLOUR", "TT_COLPAT")
              if attrs.get(k) not in (None, "")}
        if tt:
            out.append({"acr": "TOPMAR", "attrs": tt, "lnam": None})
        lt = {k[3:]: attrs[k] for k in ("LT_COLOUR", "LT_LITCHR", "LT_SIGGRP",
                                        "LT_SIGPER", "LT_VALNMR", "LT_CATLIT")
              if attrs.get(k) not in (None, "")}
        if lt:
            out.append({"acr": "LIGHTS", "attrs": lt, "lnam": None})
        return out

    def _to_wgs84(self):
        dst = self.canvas.mapSettings().destinationCrs()
        wgs = QgsCoordinateReferenceSystem("EPSG:4326")
        if not dst.isValid() or dst == wgs:
            return None
        return QgsCoordinateTransform(dst, wgs, QgsProject.instance())

    def _lonlat(self, geom):
        """Point geometry → (lon, lat) WGS84, or None for non-point geometry."""
        if geom is None or geom.isEmpty():
            return None
        if geom.type() != QgsWkbTypes.PointGeometry:
            return None
        p = geom.centroid().asPoint()
        x = self._to_wgs84()
        if x is not None:
            p = x.transform(QgsPointXY(p.x(), p.y()))
        return (p.x(), p.y())

    def _map_units_per_metre(self, p):
        """Map units per true metre at point ``p`` (so a metric radius can be
        expressed in the canvas CRS, e.g. EPSG:3857 inflates with latitude)."""
        try:
            da = QgsDistanceArea()
            crs = self.canvas.mapSettings().destinationCrs()
            da.setSourceCrs(crs, QgsProject.instance().transformContext())
            da.setEllipsoid("WGS84")
            metres = da.measureLine(p, QgsPointXY(p.x() + 1000.0, p.y()))
            if metres and metres > 0:
                return 1000.0 / metres
        except Exception:                              # noqa: BLE001
            pass
        return 1.0

    PICK_RADIUS_M = 25.0                                # point search radius

    def _on_pick(self, point, _button=None):
        # While an S-104/S-111 overlay is selected for portrayal the side panel is
        # the time graph (not the pick report). A pick graphs that point's S-104
        # tide whenever an S-104 dataset is loaded (even if only the S-111 overlay
        # is the one currently ticked); an S-111-only set leaves the graph a pure
        # time control.
        if self._s104_on or self._s111_on:
            if self._s104_times:
                self._graph_tide_at(point.x(), point.y())
            return
        try:
            from .enc_loader import _s57_catalogue
            from .enc_config import get_config
            from . import enc_describe
        except (ImportError, ValueError):
            from enc_loader import _s57_catalogue
            from enc_config import get_config
            import enc_describe
        p = QgsPointXY(point.x(), point.y())
        pg = QgsGeometry.fromPointXY(p)
        r_pt = self.PICK_RADIUS_M * self._map_units_per_metre(p)   # 25 m
        r_line = self.canvas.mapUnitsPerPixel() * 4.0              # on/in tol
        pre = max(r_pt, r_line) + self.canvas.mapUnitsPerPixel() * 2
        rect = QgsRectangle(p.x() - pre, p.y() - pre,
                            p.x() + pre, p.y() + pre)
        on_ids = self._checked_layer_ids()
        # Gather every object at the location: points within 25 m, lines/areas
        # the cursor is on/inside. Top→bottom, deduplicated by identity.
        hits, seen = [], set()
        # In FULL vector-tile mode the dry line/area classes render from the tiles
        # and their native copies in self._layers are only a sparse style/pick
        # SAMPLE (a few cells) — so picking them natively would both miss most of
        # the extent and duplicate what _pick_tiles finds. Restrict the native
        # pass to the classes that stay native everywhere (SOUNDG/AtoN/LIGHTS) and
        # let _pick_tiles cover the tiled classes across the whole chart.
        tiled_dry = self._dry_tiles is not None
        for lyr in self._layers_top_first():
            if lyr.id() not in on_ids:
                continue
            acr = lyr.customProperty("encore/acr") or lyr.name()
            if tiled_dry and (acr or "").upper() not in _NATIVE_OVER_TILES:
                continue                               # drawn from the dry tiles
            for ft in lyr.getFeatures(QgsFeatureRequest().setFilterRect(rect)):
                g = ft.geometry()
                if g is None or g.isEmpty():
                    continue
                gt = g.type()
                if gt == QgsWkbTypes.PointGeometry:
                    if g.distance(pg) > r_pt:
                        continue
                else:
                    if g.distance(pg) > r_line:
                        continue
                attrs = self._feature_attrs(lyr, ft)
                # The composite "Aids to Navigation (float)" layer carries the
                # real class per row in S57CLASS — use it so the pick report
                # describes the body (e.g. BOYLAT) and the AtoN fold fires.
                eff_acr = (attrs.get("S57CLASS") or acr) if acr == "AtoN" else acr
                ident = attrs.get("LNAM") or attrs.get("rcid")
                if ident is not None:                  # dedup identified objects
                    key = (eff_acr, ident)
                    if key in seen:
                        continue
                    seen.add(key)
                hits.append({"acr": eff_acr, "attrs": attrs,
                             "lnam": attrs.get("LNAM"),
                             "lonlat": self._lonlat(g)})
        # Tiled classes (depth areas, coastline, land, canals, dredged areas,
        # contours…) live in the vector-tile planes, not in self._layers — pull
        # them from the tiles so lines and areas also appear in the pick report.
        hits += self._pick_tiles(p, pg, r_pt, r_line, seen)
        if not hits:
            self._set_pick_message(
                f"No objects within {int(self.PICK_RADIUS_M)} m of the cursor.")
            return
        cat, cfg = _s57_catalogue(), get_config()
        # Fold AtoN equipment (topmark/light/…) into their body via LNAM_REFS, so
        # the linked items aren't also listed as separate top-level objects.
        consumed, body_rel = set(), {}
        for h in hits:
            if (h["acr"] or "").upper() in enc_describe.ATON_BODIES:
                # Related items come from two sources now: any still-separate
                # features linked by LNAM, AND the topmark/light folded onto the
                # composite AtoN row (TT_/LT_ fields) — which are no longer
                # separate features, so they must be synthesised here.
                rel = self._resolve_related(h) + self._aton_folded_related(h["attrs"])
                body_rel[id(h)] = rel
                consumed.update(r.get("lnam") for r in rel if r.get("lnam"))
        entries = []
        for h in hits:
            is_body = (h["acr"] or "").upper() in enc_describe.ATON_BODIES
            if not is_body and h.get("lnam") in consumed:
                continue                               # folded into its body
            rel = body_rel.get(id(h), [])
            entries.append({
                "header": enc_describe.header_sentence(h, rel, cat, cfg),
                "pos": enc_describe.position_dm(h),
                "tables": enc_describe.object_tables(h, rel, cat, cfg)})
        self._show_location(entries)

    def _pick_tiles(self, p, pg, r_pt, r_line, seen):
        """Identify line/area/point features from the vector-tile planes (dry +
        wet) at the pick point ``p`` so the report covers the tiled classes —
        depth areas, coastline, land, canals, dredged areas, contours — which do
        not live in ``self._layers``. Honours the layer-tree checkboxes and the
        caller's ``seen`` dedup set (acr, identity). Returns a list of hit dicts
        in the same shape as the native pick.

        identify() returns EVERY feature within its search radius, in the TILE
        layer CRS (EPSG:3857) — not the map CRS. So each hit is transformed to the
        map CRS and distance-filtered to the cursor exactly like the native pass
        (points within r_pt, lines/areas the cursor is on/inside within r_line),
        otherwise a zoomed-out pick would list scores of neighbouring polygons."""
        tiles = [t for t in (self._dry_tiles, self._wet_tiles) if t is not None]
        if not tiles:
            return []
        try:
            from qgis.gui import QgsMapToolIdentify
        except (ImportError, ValueError):
            return []
        # Respect the tree: only report classes whose tiles are currently shown.
        checked_srcs = None                              # None = show all
        if not self._loading and self.layer_tree.topLevelItemCount() > 0:
            checked = self._checked_layer_ids()
            checked_srcs = {
                self._src_name(lyr.customProperty("encore/acr") or lyr.name())
                for lyr in self._layers if lyr.id() in checked}
        # Transform each tile geometry (3857) back to the map CRS before measuring.
        map_crs = self.canvas.mapSettings().destinationCrs()
        to_map = {}                                      # tile-CRS authid -> xform
        try:
            dev = self.canvas.getCoordinateTransform().transform(p)
            ident = QgsMapToolIdentify(self.canvas)
            results = ident.identify(int(round(dev.x())), int(round(dev.y())),
                                     tiles, QgsMapToolIdentify.TopDownAll)
        except Exception:                                # noqa: BLE001
            return []
        out = []
        for res in results:
            # For a vector-tile layer the identify result's label is the MVT
            # source-layer name — which is our _src_name(acr).
            sub = (getattr(res, "mLabel", "") or "").strip()
            if not sub:
                da = getattr(res, "mDerivedAttributes", None) or {}
                sub = (da.get("layer") or da.get("Layer") or "").strip()
            acr = sub.upper()
            if not acr:
                continue
            if checked_srcs is not None and self._src_name(acr) not in checked_srcs:
                continue
            ft = getattr(res, "mFeature", None)
            if ft is None:
                continue
            g = ft.geometry() if hasattr(ft, "geometry") else None
            if g is None or g.isEmpty():
                continue
            # tile CRS -> map CRS, then the same on/near-cursor test as native.
            lyr = getattr(res, "mLayer", None)
            src = lyr.crs() if lyr is not None else map_crs
            if src != map_crs:
                key = src.authid() or src.toWkt()
                ct = to_map.get(key)
                if ct is None:
                    ct = QgsCoordinateTransform(src, map_crs,
                                                QgsProject.instance())
                    to_map[key] = ct
                try:
                    g.transform(ct)
                except Exception:                        # noqa: BLE001
                    continue
            if g.type() == QgsWkbTypes.PointGeometry:
                if g.distance(pg) > r_pt:
                    continue
            elif g.distance(pg) > r_line:
                continue
            attrs = {}
            for fld in ft.fields():
                v = ft[fld.name()]
                if v not in (None, ""):
                    attrs[fld.name()] = v
            ident_val = attrs.get("LNAM") or attrs.get("rcid")
            if ident_val is not None:                    # dedup identified objects
                dkey = (acr, ident_val)
                if dkey in seen:
                    continue
                seen.add(dkey)
            out.append({"acr": acr, "attrs": attrs,
                        "lnam": attrs.get("LNAM"),
                        "lonlat": self._lonlat(g)})
        return out

    # -- pick panel (human header + collapsible raw-data tables) -------------
    def _clear_pick(self):
        while self.pick_vbox.count():
            it = self.pick_vbox.takeAt(0)
            w = it.widget()
            if w is not None:
                w.deleteLater()

    def _set_pick_message(self, text):
        self._clear_pick()
        lbl = QLabel(text)
        lbl.setWordWrap(True)
        lbl.setStyleSheet("color: gray;")
        self.pick_vbox.addWidget(lbl)

    def _show_location(self, entries):
        """Render every object found at the cursor: a human description line per
        object (always visible), each with its own collapsible raw-data tables."""
        self._clear_pick()
        n = len(entries)
        summary = QLabel(f"{n} object{'s' if n != 1 else ''} within "
                         f"{int(self.PICK_RADIUS_M)} m:")
        summary.setStyleSheet("color: gray; font-style: italic;")
        self.pick_vbox.addWidget(summary)
        for i, e in enumerate(entries):
            if i:
                line = QFrame()
                line.setFrameShape(QFrame.HLine)
                line.setFrameShadow(QFrame.Sunken)
                self.pick_vbox.addWidget(line)
            self._add_object_block(e["header"], e["pos"], e["tables"])

    def _add_object_block(self, header, pos, tables):
        hdr = QLabel(header)
        hdr.setWordWrap(True)
        hdr.setStyleSheet("font-weight: bold;")
        hdr.setTextInteractionFlags(Qt.TextSelectableByMouse)
        self.pick_vbox.addWidget(hdr)
        if pos:
            pl = QLabel("📍 " + pos + "  (WGS84)")
            pl.setWordWrap(True)
            pl.setTextInteractionFlags(Qt.TextSelectableByMouse)
            self.pick_vbox.addWidget(pl)

        # Collapsible "Raw data" line — hidden by default, click to expand.
        toggle = QToolButton()
        toggle.setText("Raw data (per datamodel)")
        toggle.setCheckable(True)
        toggle.setChecked(False)
        toggle.setStyleSheet("QToolButton { border: none; }")
        toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        toggle.setArrowType(Qt.RightArrow)
        container = QWidget()
        cvb = QVBoxLayout(container)
        cvb.setContentsMargins(0, 2, 0, 2)
        for t in tables:
            title = QLabel(t["title"])
            title.setStyleSheet("font-style: italic;")
            title.setWordWrap(True)
            cvb.addWidget(title)
            cvb.addWidget(self._make_attr_table(t["rows"]))
        container.setVisible(False)

        def _on_toggle(checked):
            container.setVisible(checked)
            toggle.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
        toggle.toggled.connect(_on_toggle)
        self.pick_vbox.addWidget(toggle)
        self.pick_vbox.addWidget(container)

    @staticmethod
    def _make_attr_table(rows):
        t = QTableWidget(len(rows), 3)
        t.setHorizontalHeaderLabels(["Attribute", "Value", "Meaning"])
        t.verticalHeader().setVisible(False)
        t.setEditTriggers(QAbstractItemView.NoEditTriggers)
        t.setSelectionMode(QAbstractItemView.NoSelection)
        t.setAlternatingRowColors(True)
        for i, (a, v, m) in enumerate(rows):
            t.setItem(i, 0, QTableWidgetItem(a))
            t.setItem(i, 1, QTableWidgetItem(v))
            t.setItem(i, 2, QTableWidgetItem(m))
        t.resizeColumnsToContents()
        t.horizontalHeader().setStretchLastSection(True)
        # Size to content so several tables stack without inner scrollbars.
        h = t.horizontalHeader().height() + 2
        for r in range(t.rowCount()):
            h += t.rowHeight(r)
        t.setMinimumHeight(h)
        t.setMaximumHeight(h)
        return t

    # -- factory -------------------------------------------------------------
    @classmethod
    def open_for(cls, dock):
        """Open the viewer for the dock's staged cells. The window is shown
        IMMEDIATELY in a 'loading' state, then populated once the (blocking)
        native build finishes — so the button never feels like it hung."""
        best = getattr(dock, "_best_files", None) or {}
        if not best:
            dock._set_status("Chart Viewer: scan for charts first — nothing "
                             "staged to display.", error=True)
            return None
        target_crs = dock._resolve_target_crs()
        if target_crs is None:
            return None
        products = getattr(dock, "_chart_products", {})

        # 1) Open the window right away in the loading state.
        viewer = getattr(dock, "_ecdis_viewer", None)
        if viewer is None:
            viewer = cls(parent=dock)
            dock._ecdis_viewer = viewer
        viewer.show()
        viewer.raise_()
        viewer.activateWindow()
        items = list(best.items())
        n = len(items)
        viewer.begin_loading(n)

        # If the scan-time tile bake is still running, WAIT for it before building
        # — otherwise we race it and fall back to a full native build (the "looks
        # like the old version" symptom when the viewer is opened too early). The
        # window is already up; we pump events so it stays responsive and shows
        # progress, exactly as the per-cell build does. Bounded so a stuck/failed
        # bake can never hang the viewer — it just proceeds natively.
        worker = getattr(dock, "_tile_worker", None)
        if worker is not None and worker.isRunning():
            import time
            viewer.status_label.setText(
                "⏳ Building vector tiles for portrayal, stand by…")
            viewer._set_pick_message(
                "Building vector tiles for portrayal, stand by — the chart will "
                "appear as soon as the tiles are ready.")
            viewer.wait_progress.setRange(0, 0)          # busy until first report
            viewer.wait_progress.setVisible(True)

            def _tick(done, total, msg):
                if total > 0:
                    viewer.wait_progress.setRange(0, total)
                    viewer.wait_progress.setValue(done)
                viewer.status_label.setText(f"⏳ {msg}")
            try:
                worker.progress.connect(_tick)
            except Exception:                            # noqa: BLE001
                pass
            deadline = time.monotonic() + 180
            while worker.isRunning() and time.monotonic() < deadline:
                worker.wait(100)                 # ms; releases on completion
                QApplication.processEvents()
            QApplication.processEvents()         # flush the finished→_tile_cache slot
            try:
                worker.progress.disconnect(_tick)
            except Exception:                            # noqa: BLE001
                pass
            viewer.wait_progress.setVisible(False)

        # Use the scan-time tile bake's decode cache when ready, so each cell
        # builds off pre-decoded data (no re-decode) and the viewer opens nimbly.
        decode_cache = {}
        try:
            tc = getattr(dock, "_tile_cache", None) or {}
            dcp = tc.get("decode")
            if dcp and os.path.exists(dcp):
                try:
                    from .enc_tile_bake import read_decode_cache
                except (ImportError, ValueError):
                    from enc_tile_bake import read_decode_cache
                decode_cache = read_decode_cache(dcp) or {}
                _log(f"Chart Viewer: using decode cache ({len(decode_cache)} cell(s))")
        except Exception as e:                          # noqa: BLE001
            _log(f"Chart Viewer: decode cache unavailable ({e})", Qgis.Warning)
            decode_cache = {}

        # If the scan-time bake produced a WET vector-tile plane, render the dense
        # depth areas/contours from it (nimble) instead of building them natively.
        skip_classes = None
        try:
            wet_mb = (getattr(dock, "_tile_cache", None) or {}).get("wet")
            if wet_mb and os.path.exists(wet_mb) and decode_cache:
                try:
                    from .enc_tile_bake import WET_CLASSES
                except (ImportError, ValueError):
                    from enc_tile_bake import WET_CLASSES
                if viewer.setup_wet_tiles(wet_mb, decode_cache):
                    skip_classes = WET_CLASSES
                    _log("Chart Viewer: rendering wet skin from vector tiles "
                         f"(decode cache: {len(decode_cache)} cell(s))")
        except Exception as e:                          # noqa: BLE001
            _log(f"Chart Viewer: wet tiles unavailable ({e})", Qgis.Warning)
            skip_classes = None
        viewer._tiled = skip_classes is not None
        viewer._native_reason = ""
        if not viewer._tiled:
            # Pinpoint WHY we fell back to native, so it is diagnosable at a
            # glance (shown in the finish status + logged).
            tc = getattr(dock, "_tile_cache", None) or {}
            wet_mb = tc.get("wet")
            if not tc:
                why = "no tile cache (bake unfinished or failed)"
            elif not wet_mb:
                why = "wet tile plane not built (bake error)"
            elif not os.path.exists(wet_mb):
                why = "wet tiles file missing"
            elif not decode_cache:
                why = ("decode cache failed to load" if tc.get("decode")
                       else "no decode cache (S-101 cells are not tiled yet)")
            else:
                why = "wet tile setup failed"
            viewer._native_reason = why
            _log(f"Chart Viewer: native build (no tiles) — {why}", Qgis.Info)

        try:
            from .enc_config import get_config as _get_cfg
        except (ImportError, ValueError):
            from enc_config import get_config as _get_cfg
        tc = getattr(dock, "_tile_cache", None) or {}
        dry_mb = tc.get("dry")
        full_tile = (skip_classes is not None and dry_mb
                     and os.path.exists(dry_mb))

        total = 0
        if full_tile:
            # 2) FULL VECTOR TILE: do NOT build every cell natively (the slow
            #    ~100 s phase on big sets). Derive the dry tile styles from a small
            #    representative SAMPLE of cells — styles are per-class, so a few
            #    cells cover them — stopping once every dry source-layer is
            #    covered; and build ONE SOUNDG layer from the whole decode cache.
            #    The tiles carry every feature; the sample only sources styles
            #    (+ an interim pick index).
            try:
                from .enc_tile_bake import WET_CLASSES
            except (ImportError, ValueError):
                from enc_tile_bake import WET_CLASSES
            try:
                from osgeo import gdal as _gdal
                _ds = _gdal.OpenEx(dry_mb)
                need = {_ds.GetLayer(k).GetName()
                        for k in range(_ds.GetLayerCount())}
                _ds = None
            except Exception:                           # noqa: BLE001
                need = set()
            # AtoN + LIGHTS render NATIVELY on top of the tiles (their portrayal
            # doesn't tile — see build_navaid_overlay), so they are neither a
            # coverage target nor a sample dry-style source.
            need -= _NATIVE_OVER_TILES
            # GREEDY SET-COVER over the in-memory decode buckets: styles are
            # per-class, so we only need to build the FEWEST cells whose classes
            # cover all dry source-layers — not a blind cap (which left rare
            # classes on later cells unstyled → invisible on big sets: "covered
            # 21/88"). The buckets are already decoded, so predicting a cell's
            # classes is free. AtoN bodies (buoys/beacons/topmarks/lights) are
            # folded into the 'ATON' composite in the bake, so hint that.

            def _cand(path):
                data = decode_cache.get(path) or {}
                cls = set()
                aton = False
                for key in (data.get("buckets") or {}):
                    acr = (key[0] or "").upper()
                    if acr in WET_CLASSES:
                        continue
                    cls.add(viewer._src_name(acr))
                    if (acr[:3] in ("BOY", "BCN") or acr == "TOPMAR"
                            or acr == "LIGHTS"):
                        aton = True
                if aton:
                    cls.add("ATON")
                return cls & need if need else cls
            remaining = dict(items)                      # dsnm -> path
            pred = {dsnm: _cand(path) for dsnm, path in items}
            sample_dry, covered, picked, CAP = [], set(), 0, 60
            viewer._set_pick_message(
                "Preparing vector-tile portrayal — the chart will appear in a "
                "moment…")
            while remaining and (not need or covered < need) and picked < CAP:
                # pick the cell adding the most still-uncovered classes
                dsnm = max(remaining, key=lambda k: len(pred[k] - covered))
                path = remaining.pop(dsnm)
                if not (pred[dsnm] - covered) and covered:
                    break                                # plateau: nothing new
                picked += 1
                dock._set_status(f"Chart Viewer: preparing tiles… ({picked})")
                # Keep the viewer status clearly in TILE mode (not "native"), and
                # PUMP THE EVENT LOOP each cell so the window stays responsive
                # during this main-thread sample build (QgsVectorLayer must be
                # built on the GUI thread — the crash constraint — so it cannot go
                # to a worker; without pumping, the window shows "not responding"
                # for the ~15 s build, then snaps to tiles).
                viewer.status_label.setText(
                    f"⏳ Preparing vector-tile portrayal… ({picked})")
                QApplication.processEvents()
                try:
                    lyrs = build_one_cell(dsnm, path, products.get(dsnm),
                                          target_crs, _log, decode_cache,
                                          skip_classes)
                except Exception as e:                  # noqa: BLE001
                    _log(f"Chart Viewer: sample cell {dsnm} failed: {e}",
                         Qgis.Warning)
                    lyrs = []
                for ly in lyrs:
                    if (ly.customProperty("encore/acr") or "").upper() \
                            in _NATIVE_OVER_TILES:
                        continue                         # native overlay, not tiled
                    sample_dry.append(ly)
                    covered.add(viewer._src_name(
                        ly.customProperty("encore/acr") or ly.name()))
                QApplication.processEvents()             # paint progress, stay live
            # ONE SOUNDG layer from every cell's cached soundings.
            all_snd = []
            for data in decode_cache.values():
                all_snd += (data.get("soundings") or [])
            src = QgsCoordinateReferenceSystem("EPSG:4326")
            dst = QgsCoordinateReferenceSystem(target_crs)
            if not dst.isValid():
                dst = src
            xform = (None if dst == src
                     else QgsCoordinateTransform(src, dst, QgsProject.instance()))
            snd = _build_s57_sounding_layer(all_snd, dst, xform, _get_cfg(), _log)
            # NATIVE navaid overlay (AtoN composite + LIGHTS) for ALL cells — on
            # top of the tiles, and the pick/tree index for those classes.
            navaids = build_navaid_overlay(decode_cache, target_crs, _log)
            viewer._layers = (sample_dry + navaids
                              + ([snd] if snd is not None else []))
            if viewer._layers:
                viewer.canvas.setDestinationCrs(viewer._layers[0].crs())
            viewer.setup_dry_tiles(dry_mb, sample_dry)
            total = len(viewer._layers)
            _log(f"Chart Viewer: full-tile — sampled {len(sample_dry)} dry "
                 f"layer(s) covering {len(covered)}/{len(need) or '?'} classes; "
                 "no per-cell native build")
        else:
            # Native fallback: build CHART BY CHART on the main thread.
            for i, (dsnm, path) in enumerate(items, 1):
                dock._set_status(f"Chart Viewer: loading chart {i}/{n} — {dsnm}…")
                try:
                    lyrs = build_one_cell(dsnm, path, products.get(dsnm),
                                          target_crs, _log, decode_cache,
                                          skip_classes)
                except Exception as e:                  # noqa: BLE001
                    _log(f"Chart Viewer: cell {dsnm} failed: {e}", Qgis.Warning)
                    lyrs = []
                viewer.add_cell_layers(lyrs, i, n, dsnm)
                total += len(lyrs)

        # 3) Finished: grouped tree, palette, zoom, "Loading finished".
        viewer.finish_loading(total, n)
        # 4) Offer S-102 — the scanner already found them (dock._s102_files);
        #    fall back to scanning the source if needed.
        try:
            cands = getattr(dock, "_s102_files", None)
            if cands is None:
                source = (dock.source_path()
                          if hasattr(dock, "source_path")
                          else dock.folder_edit.text().strip())
                cands = (viewer._s102_module().datasets_from_source(source)
                         if source else [])
            viewer.detect_s102(cands)
        except Exception as e:                          # noqa: BLE001
            _log(f"S-102 detection skipped: {e}", Qgis.Warning)
        # 5) Offer S-111 surface currents — the scanner already found them
        #    (dock._s111_files, which also covers saved tilesets whose original
        #    source folder is gone); fall back to scanning the source if needed.
        try:
            s111 = getattr(dock, "_s111_files", None)
            if not s111:
                source = (dock.source_path()
                          if hasattr(dock, "source_path")
                          else dock.folder_edit.text().strip())
                s111 = (viewer._s111_module().datasets_from_source(source)
                        if source else [])
            viewer.detect_s111(s111)
        except Exception as e:                          # noqa: BLE001
            _log(f"S-111 detection skipped: {e}", Qgis.Warning)
        # 6) Offer S-104 water level (tidal engine) from the same source.
        try:
            s104 = getattr(dock, "_s104_files", None)
            if s104 is None:
                source = (dock.source_path()
                          if hasattr(dock, "source_path")
                          else dock.folder_edit.text().strip())
                s104 = (viewer._s104_module().datasets_from_source(source)
                        if source else [])
            viewer.detect_s104(s104)
        except Exception as e:                          # noqa: BLE001
            _log(f"S-104 detection skipped: {e}", Qgis.Warning)
        # Apply the dock's S-100 overlay toggles to the freshly-opened viewer
        # (the dock is the single control surface for these overlays now).
        try:
            dock._apply_s100_to_viewer()
        except Exception as e:                          # noqa: BLE001
            _log(f"S-100 overlay sync skipped: {e}", Qgis.Warning)
        dock._set_status(
            f"Chart Viewer: loading finished — {total} layer(s) from {n} "
            f"chart(s)." if total else "Chart Viewer: no renderable layers "
            "from the staged cells.", error=not total)
        return viewer
