# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
s101/qgis_loader.py
-------------------
Load an S-101 cell onto the QGIS map — **native engine, no GDAL** — with basic
default portrayal. The on-canvas counterpart to the S-101 read engine
(``s101.records`` + ``s101.geometry``): it groups a cell's features by geometry
kind into in-memory ``QgsVectorLayer``s, attaches identity + attributes, and
applies a simple class-distinguishing style so the chart is legible and clickable.

This is **not** the full S-100 Part 9a portrayal (the 150+ Lua rules / exact S-52
symbology) — that is a separate, larger unit. "Basic portrayal" here = a
categorized-by-feature-type renderer with a fixed palette plus sensible per-
geometry-kind defaults (filled areas, coloured lines, point markers, sounding
markers), enough to see and interrogate an S-101 cell.

QGIS-dependent (imports ``qgis.*``), so it is deliberately NOT re-exported from
``s101/__init__`` (which stays GDAL/QGIS-free and headlessly importable). Drive it
from the QGIS Python console:

    from ENCoreGIS.s101.qgis_loader import load_s101_cell
    load_s101_cell(r"C:\\Users\\...\\101BE004_514N028E.000")

Returns the list of created layers; adds them to the project unless
``add_to_project=False``. Geometry is rendered via ``QgsGeometry.fromWkt`` on the
native engine's WKT output (the same WKT proven coordinate-correct against GDAL).
"""
import os

from qgis.core import (
    QgsVectorLayer, QgsField, QgsFeature, QgsGeometry, QgsPoint, QgsProject,
    QgsSymbol, QgsCategorizedSymbolRenderer, QgsRendererCategory,
    QgsSingleSymbolRenderer, QgsWkbTypes, Qgis, QgsMessageLog,
    QgsCoordinateReferenceSystem, QgsCoordinateTransform,
)
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtGui import QColor

try:                                                    # plugin context
    from ..iso8211 import Iso8211Reader
    from .records import S101Cell
    from .geometry import S101GeometryBuilder
    from .grouping import (is_s101_aton_structure, S101_ATON_EQUIPMENT,
                           s101_scale_band_label)
    from ..s57.geometry import (Point, MultiPoint, LineString,
                                MultiLineString, Polygon, MultiPolygon)
except (ImportError, ValueError):                       # headless / top-level
    from iso8211 import Iso8211Reader
    from s101.records import S101Cell
    from s101.geometry import S101GeometryBuilder
    from s101.grouping import (is_s101_aton_structure, S101_ATON_EQUIPMENT,
                               s101_scale_band_label)
    from s57.geometry import (Point, MultiPoint, LineString,
                              MultiLineString, Polygon, MultiPolygon)

# A fixed, high-contrast palette so distinct feature classes are distinguishable
# (deterministic by sorted class name). Stand-in for the S-101 colour profile,
# which the full Part 9a portrayal unit will use.
_PALETTE = (
    "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b",
    "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", "#aec7e8", "#ffbb78",
    "#98df8a", "#ff9896", "#c5b0d5", "#c49c94", "#f7b6d2", "#dbdb8d",
)

_FIELDS = [("rcid", QVariant.Int), ("ftype", QVariant.String),
           ("foid", QVariant.String), ("attributes", QVariant.String)]

# bucket key -> (layer-name suffix, memory provider geometry type)
_SPECS = [
    ("areas", "areas", "MultiPolygon"),
    ("lines", "lines", "MultiLineString"),
    ("soundings", "soundings", "MultiPointZ"),
    ("points", "points", "Point"),
]


def _log(msg, level=Qgis.Info):
    QgsMessageLog.logMessage(msg, "ENCore GIS", level)


def _attrs_str(feature):
    """A short, human-readable summary of a feature's top-level attributes."""
    parts = []
    for a in feature.attributes:
        if a.value is not None:
            parts.append(f"{a.name or a.code}={a.value}")
        elif a.children:
            parts.append(f"{a.name or a.code}[…]")
    return "; ".join(parts)[:254]


def _styled_symbol(geom_type, hex_color):
    """A default symbol for the geometry type, coloured; areas get a translucent
    fill so overlapping coverage stays readable."""
    sym = QgsSymbol.defaultSymbol(geom_type)
    if sym is None:
        return None
    c = QColor(hex_color)
    if geom_type == QgsWkbTypes.PolygonGeometry:
        c.setAlpha(110)
    sym.setColor(c)
    return sym


def _apply_basic_portrayal(layer, attr="ftype"):
    """Categorized-by-feature-type renderer with the fixed palette."""
    gtype = layer.geometryType()
    values = sorted({f[attr] for f in layer.getFeatures() if f[attr]})
    cats = []
    for i, v in enumerate(values):
        sym = _styled_symbol(gtype, _PALETTE[i % len(_PALETTE)])
        if sym is not None:
            cats.append(QgsRendererCategory(v, sym, str(v)))
    if cats:
        layer.setRenderer(QgsCategorizedSymbolRenderer(attr, cats))


def _crs_token(dst):
    """A memory-layer URI CRS token; falls back to EPSG:4326 when the target CRS
    has no authid (geometries are still set to dst via setCrs afterwards)."""
    return dst.authid() or "EPSG:4326"


def _geom_from_wkt(wkt, xform):
    """Parse native (WGS84) WKT and reproject to the target CRS if needed."""
    g = QgsGeometry.fromWkt(wkt)
    if g is None or g.isEmpty():
        return None
    if xform is not None:
        g.transform(xform)
    return g


def _build_layer(name, mem_type, rows, dst, xform):
    layer = QgsVectorLayer(f"{mem_type}?crs={_crs_token(dst)}", name, "memory")
    dp = layer.dataProvider()
    dp.addAttributes([QgsField(n, t) for n, t in _FIELDS])
    layer.updateFields()
    feats = []
    for rcid, ftype, foid, attrs, wkt in rows:
        g = _geom_from_wkt(wkt, xform)
        if g is None:
            continue
        feat = QgsFeature(layer.fields())
        feat.setGeometry(g)
        feat.setAttributes([rcid, ftype, foid, attrs])
        feats.append(feat)
    dp.addFeatures(feats)
    layer.setCrs(dst)
    layer.updateExtents()
    return layer, len(feats)


# ── S-57-equivalent portrayal for the depth trio ──────────────────────────
# These S-101 feature types get the SAME recipes + QML + Chart Control behaviour
# as their S-57 counterparts, via an attribute bridge (DRVAL1/DRVAL2/VALDCO alias
# fields) and the soundings' depth-in-Z geometry (identical to SOUNDG). See the
# portrayal analysis in HANDOVER. Driven live by @enc_*_contour / @enc_sounding_*.
_DEPTH_AREA, _DEPTH_CONTOUR, _SOUNDING = "DepthArea", "DepthContour", "Sounding"
_DREDGED = "DredgedArea"
_PORTRAYED = {_DEPTH_AREA, _DEPTH_CONTOUR, _SOUNDING, _DREDGED}


def _attr_value(feature, name):
    """First non-empty value of a (possibly nested) attribute by name, or None."""
    for a in feature.iter_attributes():
        if a.name == name and a.value is not None:
            return a.value
    return None


def _attr_values_all(feature, name):
    """All non-empty values of a (possibly repeating) attribute by name, in order
    — e.g. a buoy's several ``colour`` occurrences."""
    return [a.value for a in feature.iter_attributes()
            if a.name == name and a.value is not None]


def _named_attrs(feature):
    """Flat ``{attributeName: value}`` over a feature's populated LEAF attributes —
    the S-101 counterpart to ``decode_s57_buckets``' ``{acronym: value}``. Without
    this the Chart Viewer pick report on a generic S-101 feature has NO data model
    (only the class header), because the tile carries no attributes. Repeated
    attributes are comma-joined (e.g. several ``colour``); complex parents
    contribute their leaf children. S-101 attribute names are self-describing, so
    they read cleanly in the report even though the S-57 catalogue can't decode
    their coded values."""
    out = {}
    for a in feature.iter_attributes():
        if a.name and not a.children and a.value not in (None, ""):
            if a.name in out:
                out[a.name] = "%s,%s" % (out[a.name], a.value)
            else:
                out[a.name] = a.value
    return out


def _to_real(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return None


def _apply_s57_style(layer, s57_code, kind):
    """Apply the bundled S-57 QML (styles/<code>_<kind>.qml) to an S-101 layer —
    the layer carries the alias fields the QML keys on (DRVAL1/VALDCO) or uses
    z($geometry) for soundings."""
    try:
        from ..enc_config import get_config
        from ..enc_loader import _apply_portrayal
    except (ImportError, ValueError):
        from enc_config import get_config
        from enc_loader import _apply_portrayal
    _apply_portrayal(layer, get_config(), s57_code, kind, _log)


def _soundg_zoom_params():
    """The SOUNDG adaptive zoom-level params from processing.json (so S-101
    soundings thin with the same curve as S-57)."""
    try:
        from ..enc_config import get_config
    except (ImportError, ValueError):
        from enc_config import get_config
    for step in get_config().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 _collect_cell(path, want):
    """Read one cell; return (generic_buckets, depth_areas, depth_contours,
    sounding_xyz, dredged_areas, name). The depth trio + DredgedArea are split
    out for S-57-grade portrayal; everything else goes to the generic buckets."""
    recs = list(Iso8211Reader.open(path).records())
    name = os.path.splitext(os.path.basename(path))[0]
    cell = S101Cell.from_records(recs, name=name)
    if cell.parse_error:
        _log(f"S-101 load: parse error in {name}: {cell.parse_error}", Qgis.Warning)
        return {}, [], [], [], [], name
    gb = S101GeometryBuilder(recs)
    generic = {key: [] for key, _s, _m in _SPECS}
    depth_areas, depth_contours, soundings, dredged_areas = [], [], [], []
    for f in cell.features:
        ft = f.type_name or str(f.type_code)
        if want is not None and ft not in want:
            continue
        g = gb.geometry(f)
        if g is None:
            continue
        if ft == _DEPTH_AREA:
            depth_areas.append(
                (_to_real(_attr_value(f, "depthRangeMinimumValue")),
                 _to_real(_attr_value(f, "depthRangeMaximumValue")), g.wkt()))
        elif ft == _DREDGED:
            # Dredged areas portray with the SAME custom depth-band fill as
            # DEPARE (DRGARE_area.qml ≡ DEPARE_area.qml).
            dredged_areas.append(
                (_to_real(_attr_value(f, "depthRangeMinimumValue")),
                 _to_real(_attr_value(f, "depthRangeMaximumValue")), g.wkt()))
        elif ft == _DEPTH_CONTOUR:
            depth_contours.append(
                (_to_real(_attr_value(f, "valueOfDepthContour")), g.wkt()))
        elif ft == _SOUNDING:
            if isinstance(g, MultiPoint):
                soundings.extend(p for p in g.points if len(p) >= 3)
            elif isinstance(g, Point) and g.z is not None:
                soundings.append((g.x, g.y, g.z))
        else:
            row = (f.rcid, ft, f.foid_str or "", _attrs_str(f), g.wkt())
            if isinstance(g, (Polygon, MultiPolygon)):
                generic["areas"].append(row)
            elif isinstance(g, (LineString, MultiLineString)):
                generic["lines"].append(row)
            elif isinstance(g, MultiPoint) and g.z_present:
                generic["soundings"].append(row)
            else:
                generic["points"].append(row)
    return generic, depth_areas, depth_contours, soundings, dredged_areas, name


import re as _re

# Placeholder symbol colour per theme — used until the official S-101 Portrayal
# Catalogue engine lands; groups types thematically rather than at random.
_THEME_COLORS = {
    "Skin of the Earth": "#a6cee3", "Coastline & Shoreline": "#1f78b4",
    "Navigation": "#6a3d9a", "Lights & Signals": "#ff7f00",
    "Buoys & Beacons": "#e31a1c", "Ports & Harbours": "#b15928",
    "Obstructions & Hazards": "#fb9a99",
    "Regulated & Restricted Areas": "#cab2d6",
    "Topography & Land Features": "#b2df8a", "Tides & Currents": "#33a02c",
    "Metadata & Coverage": "#999999", "Other": "#7f7f7f",
}
_KIND_OF = {"areas": "area", "lines": "line",
            "soundings": "point", "points": "point"}

_MEMTYPE_OF = {"area": "MultiPolygon", "line": "MultiLineString", "point": "Point"}


def s101_compilation_scale(path):
    """The S-101 cell's optimum display scale denominator — the S-100 analogue of
    S-57's DSPM CSCL — read from the DataCoverage metadata. Used to declutter
    overscale detail in the vector tiles (see enc_tile_styles). Lightweight (no
    geometry build); QGIS-free; returns int or None, never raises."""
    try:
        recs = list(Iso8211Reader.open(path).records())
        cell = S101Cell.from_records(recs, name=os.path.basename(path))
        for f in cell.features:
            if f.type_name == "DataCoverage":
                v = (_attr_value(f, "optimumDisplayScale")
                     or _attr_value(f, "maximumDisplayScale"))
                if v not in (None, ""):
                    try:
                        return int(float(str(v).strip()))
                    except (TypeError, ValueError):
                        return None
    except Exception:                                   # noqa: BLE001
        return None
    return None


def _s101_light_sectors(feature):
    """Explode an S-101 sectored light into per-sector S-57 LIGHTS attribute dicts
    (SECTR1/SECTR2 from the sector-limit bearings, plus COLOUR/VALNMR/LITCHR/
    SIGGRP/SIGPER), or [] for a non-sectored / all-around light. S-101 nests each
    sector as sectorCharacteristics → lightSector → sectorLimit →
    sectorLimitOne/Two → sectorBearing (and a light may carry several)."""
    def _bearing(limit_node):
        for ch in limit_node.children:
            if ch.name == "sectorBearing" and ch.value is not None:
                return ch.value
        return None
    out = []
    for sc in feature.attributes:
        if sc.name != "sectorCharacteristics":
            continue
        litchr = siggrp = sigper = None
        sectors = []
        for ch in sc.children:
            if ch.name == "lightCharacteristic":
                litchr = ch.value
            elif ch.name == "signalGroup":
                siggrp = ch.value
            elif ch.name == "signalPeriod":
                sigper = ch.value
            elif ch.name == "lightSector":
                sectors.append(ch)
        for ls in sectors:
            colour = valnmr = s1 = s2 = None
            for c in ls.children:
                if c.name == "colour":
                    colour = c.value
                elif c.name == "valueOfNominalRange":
                    valnmr = c.value
                elif c.name == "sectorLimit":
                    for lim in c.children:
                        if lim.name == "sectorLimitOne":
                            s1 = _bearing(lim)
                        elif lim.name == "sectorLimitTwo":
                            s2 = _bearing(lim)
            if s1 is not None or s2 is not None:
                out.append({"COLOUR": colour, "LITCHR": litchr, "SIGGRP": siggrp,
                            "SIGPER": sigper, "VALNMR": valnmr,
                            "SECTR1": s1, "SECTR2": s2})
    return out


def _s101_geom_kind(g):
    if isinstance(g, (Polygon, MultiPolygon)):
        return "area"
    if isinstance(g, (LineString, MultiLineString)):
        return "line"
    return "point"


def decode_s101_buckets(paths, log=None, should_stop=None):
    """QGIS-FREE decode of S-101 cells into the SAME bucket shape as
    ``enc_chartview.decode_s57_buckets`` — ``(acr, kind, memtype) -> {"rows":
    [(rcid, wkt, named)], "attrs": set}`` plus a soundings list — so S-101 flows
    through the identical vector-tile bake AND the identical AtoN fold as S-57.

    Non-AtoN classes key by S-57 acronym via ``_S101_TO_S57`` (unmapped keep their
    S-101 name); the depth trio carries the wet-skin alias attrs (DEPARE/DRGARE →
    DRVAL1/DRVAL2, DEPCNT → VALDCO); soundings come back as (x, y, z).

    AtoN is emitted **fold-compatible with the S-57 engine** so both the tile bake
    (_aton_compose) and the viewer (build_navaid_overlay) produce the same folded
    composite: buoy/beacon bodies keyed by their S-57 class with real
    BOYSHP/BCNSHP/COLOUR/COLPAT/CAT* + a synthesized LNAM/LNAM_REFS; the nested
    S-101 topmark emitted as a co-located synthetic TOPMAR feature (folds by
    position); and each Light emitted as a LIGHTS feature with S-57-named
    characteristics — bound lights placed at their host structure's position and
    referenced from the buoy's LNAM_REFS so match_buoy_lights folds them (S-101
    lights are treated as non-sectored for now → sector arcs are a later refinement)."""
    log = log or _log
    buckets, soundings = {}, []
    rc = [0]

    def _add(acr, kind, wkt, named):
        if not wkt:
            return
        rc[0] += 1
        key = (acr, kind, _MEMTYPE_OF.get(kind, "Point"))
        b = buckets.setdefault(key, {"rows": [], "attrs": set()})
        b["rows"].append((str(rc[0]), wkt, named))
        b["attrs"].update(named.keys())

    def _clean(d):
        return {k: str(v) for k, v in d.items() if v not in (None, "")}

    for p in paths:
        if should_stop is not None and should_stop():
            break
        try:
            recs = list(Iso8211Reader.open(p).records())
            cell = S101Cell.from_records(
                recs, name=os.path.splitext(os.path.basename(p))[0])
            if cell.parse_error:
                log(f"S-101 tile decode: parse error {cell.name}: "
                    f"{cell.parse_error}")
                continue
            gb = S101GeometryBuilder(recs)
        except Exception as e:                          # noqa: BLE001
            log(f"S-101 tile decode: {os.path.basename(p)} failed: {e}")
            continue
        by = cell.features_by_rcid()

        # Pass 1 — AtoN structures: emit each body + its co-located topmark, and
        # remember which equipment (by rcid) each structure hosts (+ host wkt for
        # the light co-location that lets the S-57 fold bind it).
        host_wkt, structures = {}, []
        for f in cell.features:
            if not is_s101_aton_structure(f.type_name):
                continue
            g = gb.geometry(f)
            if g is None:
                continue
            swkt = g.wkt()
            structures.append((f, swkt))
            for a in f.associations:
                if a.kind == "feature":
                    eq = by.get(a.target_rcid)
                    if eq is not None and eq.type_name in S101_ATON_EQUIPMENT:
                        host_wkt[a.target_rcid] = swkt
        consumed = set(host_wkt)
        for f, swkt in structures:
            light_refs = []
            for a in f.associations:
                if a.kind == "feature":
                    eq = by.get(a.target_rcid)
                    if eq is not None and str(eq.type_name).startswith("Light"):
                        light_refs.append(eq.foid_str or str(eq.rcid))
            _add(_S101_TO_S57_CLASS.get(f.type_name, f.type_name), "point", swkt,
                 _clean({
                     "BOYSHP": _root_value(f, "buoyShape"),
                     "BCNSHP": _root_value(f, "beaconShape"),
                     "COLOUR": ",".join(str(c) for c in
                                        _root_values_all(f, "colour")) or None,
                     "COLPAT": _root_value(f, "colourPattern"),
                     "CATLAM": _root_value(f, "categoryOfLateralMark"),
                     "CATCAM": _root_value(f, "categoryOfCardinalMark"),
                     "CATSPM": _root_value(f, "categoryOfSpecialPurposeMark"),
                     "OBJNAM": _attr_value(f, "name") or _attr_value(f, "featureName"),
                     "LNAM": f.foid_str or str(f.rcid),
                     "LNAM_REFS": ",".join(light_refs) or None}))
            tshp, tcol, tpat = _topmark_fields(f)
            if tshp is not None or tcol is not None:
                _add("TOPMAR", "point", swkt,
                     _clean({"TOPSHP": tshp, "COLOUR": tcol, "COLPAT": tpat}))

        # Pass 2 — everything else (wet, generic, lights, unbound equipment).
        for f in cell.features:
            if is_s101_aton_structure(f.type_name):
                continue
            ft = f.type_name or str(f.type_code)
            g = gb.geometry(f)
            if g is None:
                continue
            if ft in (_DEPTH_AREA, _DREDGED):
                mn = _to_real(_attr_value(f, "depthRangeMinimumValue"))
                mx = _to_real(_attr_value(f, "depthRangeMaximumValue"))
                nm = _named_attrs(f)             # full data model + portrayal alias
                if mn is not None:
                    nm["DRVAL1"] = str(mn)
                if mx is not None:
                    nm["DRVAL2"] = str(mx)
                _add("DEPARE" if ft == _DEPTH_AREA else "DRGARE", "area",
                     g.wkt(), nm)
            elif ft == _DEPTH_CONTOUR:
                nm = _named_attrs(f)
                v = _to_real(_attr_value(f, "valueOfDepthContour"))
                if v is not None:
                    nm["VALDCO"] = str(v)
                _add("DEPCNT", "line", g.wkt(), nm)
            elif ft == _SOUNDING:
                if isinstance(g, MultiPoint):
                    soundings.extend(pt for pt in g.points if len(pt) >= 3)
                elif isinstance(g, Point) and g.z is not None:
                    soundings.append((g.x, g.y, g.z))
            elif str(ft).startswith("Light"):
                lnam = f.foid_str or str(f.rcid)
                sectors = _s101_light_sectors(f)
                if sectors:
                    # SECTORED light → one LIGHTS row per sector, at the light's
                    # OWN position; SECTR1/SECTR2 present → the S-57 fold leaves it
                    # in LIGHTS and LIGHTS_point.qml draws the leg-lines + arc.
                    for sec in sectors:
                        _add("LIGHTS", "point", g.wkt(),
                             _clean(dict(sec, LNAM=lnam)))
                else:
                    _add("LIGHTS", "point", host_wkt.get(f.rcid) or g.wkt(),
                         _clean({
                             "COLOUR": ",".join(str(c) for c in
                                                _attr_values_all(f, "colour")) or None,
                             "LITCHR": _attr_value(f, "lightCharacteristic"),
                             "SIGGRP": _attr_value(f, "signalGroup"),
                             "SIGPER": _attr_value(f, "signalPeriod"),
                             "VALNMR": _attr_value(f, "valueOfNominalRange"),
                             "CATLIT": _attr_value(f, "categoryOfLight"),
                             "LNAM": lnam}))
            elif f.rcid in consumed:
                continue                                # folded into its structure
            else:
                # Generic feature: carry its full S-101 data model + an LNAM
                # identity, so the pick report shows attributes (was empty → only
                # the class header) and dedups cleanly across tile boundaries.
                named = _named_attrs(f)
                named.setdefault("LNAM", f.foid_str or str(f.rcid))
                _add(_S101_TO_S57.get(ft) or ft, _s101_geom_kind(g), g.wkt(),
                     named)
    return buckets, soundings


def _humanize(ftype):
    """'LateralBuoy' -> 'Lateral buoy' for readable layer names."""
    spaced = _re.sub(r"(?<!^)(?=[A-Z])", " ", ftype or "")
    return (spaced[:1].upper() + spaced[1:].lower()) if spaced else ftype


def _tag(layer, group, acr, kind):
    """Tag an S-101 layer so the Chart Viewer groups/treats it exactly like the
    S-57 layers (same custom-property contract)."""
    layer.setCustomProperty("encore/group", group)
    layer.setCustomProperty("encore/meta", group == "Metadata & Coverage")
    layer.setCustomProperty("encore/acr", acr)
    layer.setCustomProperty("encore/kind", kind)
    layer.setCustomProperty("encore/s101type", acr)


def _apply_theme_symbol(layer, theme):
    """A single theme-coloured symbol (placeholder until the official S-101
    portrayal engine) — far cleaner than the old random per-type palette."""
    sym = QgsSymbol.defaultSymbol(layer.geometryType())
    if sym is None:
        return
    c = QColor(_THEME_COLORS.get(theme, _THEME_COLORS["Other"]))
    if layer.geometryType() == QgsWkbTypes.PolygonGeometry:
        c.setAlpha(90)
    sym.setColor(c)
    layer.setRenderer(QgsSingleSymbolRenderer(sym))


def _build_generic_layers(name, buckets, dst, xform):
    """Build one layer PER FEATURE TYPE (not per geometry bucket), named and
    grouped exactly like the S-57 object layers (theme from s101.grouping), so
    the viewer's grouped tree treats S-101 identically to S-57."""
    try:
        from .grouping import s101_theme
    except (ImportError, ValueError):
        from grouping import s101_theme
    layers = []
    for key, _suffix, mem in _SPECS:
        rows = buckets.get(key)
        if not rows:
            continue
        by_type = {}
        for r in rows:                              # r = (rcid, ftype, foid, …)
            by_type.setdefault(r[1], []).append(r)
        try:
            from ..enc_s52_portrayal import apply_s52_engine
        except (ImportError, ValueError):
            try:
                from enc_s52_portrayal import apply_s52_engine
            except Exception:                           # noqa: BLE001
                apply_s52_engine = None
        for ftype, frows in sorted(by_type.items()):
            kind = _KIND_OF.get(key, "point")
            layer, _n = _build_layer(_humanize(ftype), mem, frows, dst, xform)
            theme = s101_theme(ftype)
            # Portray via the S-52 engine through the S-101→S-57 class map, so
            # S-101 gets real ECDIS portrayal (land buff, areas/lines/symbols)
            # instead of the theme placeholder. Falls back to the placeholder.
            s57 = _S101_TO_S57.get(ftype)
            applied = False
            if s57 and apply_s52_engine is not None:
                try:
                    applied = apply_s52_engine(
                        layer, s57, kind, "Day",
                        {"shallow": 2.0, "safety": 6.0, "deep": 10.0})
                except Exception:                       # noqa: BLE001
                    applied = False
            if not applied:
                _apply_theme_symbol(layer, theme)
            _tag(layer, theme, s57 or ftype, kind)
            layers.append(layer)
    return layers


def _build_depth_area_layer(rows, dst, xform):
    """Merged S-101 Depth area layer with DRVAL1/DRVAL2 alias fields so the S-57
    DEPARE_area.qml depth-band fill + Chart Control apply unchanged."""
    if not rows:
        return []
    layer = QgsVectorLayer(f"MultiPolygon?crs={_crs_token(dst)}",
                           "S-101 — Depth area", "memory")
    dp = layer.dataProvider()
    dp.addAttributes([QgsField("DRVAL1", QVariant.Double),
                      QgsField("DRVAL2", QVariant.Double)])
    layer.updateFields()
    feats = []
    for d1, d2, wkt in rows:
        g = _geom_from_wkt(wkt, xform)
        if g is None:
            continue
        ft = QgsFeature(layer.fields())
        ft.setGeometry(g)
        ft.setAttributes([d1, d2])
        feats.append(ft)
    dp.addFeatures(feats)
    layer.setCrs(dst)
    layer.updateExtents()
    _apply_s57_style(layer, "DEPARE", "area")
    _tag(layer, "Skin of the Earth", "DEPARE", "area")
    _log(f"S-101 load: Depth area — {len(feats)} feature(s), DEPARE portrayal")
    return [layer]


def _build_dredged_area_layer(rows, dst, xform):
    """Merged S-101 Dredged area layer — portrayed with the SAME custom depth-band
    fill as DEPARE (DRGARE_area.qml ≡ DEPARE_area.qml), Chart Control-driven."""
    if not rows:
        return []
    layer = QgsVectorLayer(f"MultiPolygon?crs={_crs_token(dst)}",
                           "S-101 — Dredged area", "memory")
    dp = layer.dataProvider()
    dp.addAttributes([QgsField("DRVAL1", QVariant.Double),
                      QgsField("DRVAL2", QVariant.Double)])
    layer.updateFields()
    feats = []
    for d1, d2, wkt in rows:
        g = _geom_from_wkt(wkt, xform)
        if g is None:
            continue
        ft = QgsFeature(layer.fields())
        ft.setGeometry(g)
        ft.setAttributes([d1, d2])
        feats.append(ft)
    dp.addFeatures(feats)
    layer.setCrs(dst)
    layer.updateExtents()
    _apply_s57_style(layer, "DRGARE", "area")
    _tag(layer, "Skin of the Earth", "DRGARE", "area")
    _log(f"S-101 load: Dredged area — {len(feats)} feature(s), DRGARE depth-band portrayal")
    return [layer]


def _build_depth_contour_layer(rows, dst, xform):
    """Merged S-101 Depth contour layer with a VALDCO alias field so the S-57
    DEPCNT_line.qml (safety-contour bolding) applies unchanged."""
    if not rows:
        return []
    layer = QgsVectorLayer(f"MultiLineString?crs={_crs_token(dst)}",
                           "S-101 — Depth contour", "memory")
    dp = layer.dataProvider()
    dp.addAttributes([QgsField("VALDCO", QVariant.Double)])
    layer.updateFields()
    feats = []
    for vd, wkt in rows:
        g = _geom_from_wkt(wkt, xform)
        if g is None:
            continue
        ft = QgsFeature(layer.fields())
        ft.setGeometry(g)
        ft.setAttributes([vd])
        feats.append(ft)
    dp.addFeatures(feats)
    layer.setCrs(dst)
    layer.updateExtents()
    _apply_s57_style(layer, "DEPCNT", "line")
    _tag(layer, "Skin of the Earth", "DEPCNT", "line")
    _log(f"S-101 load: Depth contour — {len(feats)} feature(s), DEPCNT portrayal")
    return [layer]


def _build_sounding_layer(points, dst, xform):
    """Merged S-101 Soundings layer (depth in Z), run through the S-57 adaptive
    zoom-level thinning (same algorithm + params as SOUNDG) and styled with
    SOUNDG_point.qml — same Chart Control sounding portrayal + density reveal.

    Built directly in the target CRS so the thinning measures distances in that
    CRS's metres (matching S-57, which reprojects before the recipe)."""
    if not points:
        return []
    layer = QgsVectorLayer(f"PointZ?crs={_crs_token(dst)}",
                           "S-101 — Soundings", "memory")
    dp = layer.dataProvider()
    feats = []
    for x, y, z in points:
        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 (depth) untouched
        ft = QgsFeature()
        ft.setGeometry(g)
        feats.append(ft)
    dp.addFeatures(feats)
    layer.setCrs(dst)
    layer.updateExtents()
    try:
        from ..enc_loader import _assign_zoom_levels
    except (ImportError, ValueError):
        from enc_loader import _assign_zoom_levels
    layer = _assign_zoom_levels(layer, _soundg_zoom_params(), _log)
    layer.setName("S-101 — Soundings")
    layer.setCrs(dst)
    _apply_s57_style(layer, "SOUNDG", "point")
    _tag(layer, "Skin of the Earth", "SOUNDG", "point")
    _log(f"S-101 load: Soundings — {len(feats)} point(s), SOUNDG adaptive portrayal")
    return [layer]


def load_s101_cells(paths, add_to_project=True, feature_types=None,
                    target_crs="EPSG:4326"):
    """Load S-101 cells onto the QGIS map. Returns all created layers.

    All layers are built in ``target_crs`` (the dock's Load-CRS choice; native
    geometry is WGS84 and reprojected on the way in) — so soundings' adaptive
    thinning measures distances in that CRS's metres, exactly like the S-57 path.

    The depth trio — DepthArea, DepthContour, Sounding — is merged across the
    given cells into three layers portrayed exactly like S-57 DEPARE/DEPCNT/
    SOUNDG (recipes + QML + live Chart Control); soundings are merged so the
    adaptive zoom-level thinning sees the true point density and writes one
    reveal curve. Every other feature type is built per cell as a basic
    categorized layer. ``feature_types`` optionally restricts what is loaded."""
    want = set(feature_types) if feature_types is not None else None
    src = QgsCoordinateReferenceSystem("EPSG:4326")
    dst = QgsCoordinateReferenceSystem(target_crs)
    if not dst.isValid():
        _log(f"S-101 load: invalid target CRS '{target_crs}', using EPSG:4326",
             Qgis.Warning)
        dst = src
    xform = (None if dst == src
             else QgsCoordinateTransform(src, dst, QgsProject.instance()))
    _log(f"S-101 load: target CRS {dst.authid() or target_crs}")

    generic_layers = []
    all_areas, all_contours, all_soundings, all_dredged = [], [], [], []
    for p in paths:
        try:
            generic, d_areas, d_contours, soundings, dredged, name = \
                _collect_cell(p, want)
        except Exception as e:                            # noqa: BLE001
            _log(f"S-101 load: failed on {os.path.basename(p)}: {e}", Qgis.Warning)
            continue
        generic_layers += _build_generic_layers(name, generic, dst, xform)
        all_areas += d_areas
        all_contours += d_contours
        all_soundings += soundings
        all_dredged += dredged

    # z-order, bottom → top: depth-area fill, dredged-area fill, generic,
    # contours, soundings.
    ordered = (_build_depth_area_layer(all_areas, dst, xform)
               + _build_dredged_area_layer(all_dredged, dst, xform)
               + generic_layers
               + _build_depth_contour_layer(all_contours, dst, xform)
               + _build_sounding_layer(all_soundings, dst, xform))
    if add_to_project:
        proj = QgsProject.instance()
        for lyr in ordered:
            proj.addMapLayer(lyr)
    _log(f"S-101 load: {len(ordered)} layer(s) from {len(paths)} cell(s)")
    return ordered


def load_s101_cell(path, add_to_project=True, feature_types=None,
                   target_crs="EPSG:4326"):
    """Load a single S-101 cell — see load_s101_cells (this is the one-cell case
    and uses the same S-57-grade depth/contour/sounding portrayal)."""
    return load_s101_cells([path], add_to_project=add_to_project,
                           feature_types=feature_types, target_crs=target_crs)


# ── Hybrid layers (S-101 equivalents of the S-57 "Hybrid layers" group) ─────
# Two synthesized, per-scale-band layers mirroring the S-57 path:
#   • ENC Coverage  — DataCoverage dissolved per cell + DSID metadata.
#   • Aids to Navigation — buoy/beacon bodies with their equipment (lights, fog
#     signals, topmarks, racons) folded on via the S-101 StructureEquipment
#     association — the native counterpart to the S-57 position/LNAM merge.

def _dst_xform(target_crs):
    """(dst CRS, transform-or-None) for reprojecting native WGS84 geometry into
    the dock's Load-CRS, matching load_s101_cells."""
    src = QgsCoordinateReferenceSystem("EPSG:4326")
    dst = QgsCoordinateReferenceSystem(target_crs)
    if not dst.isValid():
        _log(f"S-101 hybrid: invalid target CRS '{target_crs}', using EPSG:4326",
             Qgis.Warning)
        dst = src
    xform = (None if dst == src
             else QgsCoordinateTransform(src, dst, QgsProject.instance()))
    return dst, xform


def _iso_date(raw):
    """S-101 DSRD date YYYYMMDD → ISO YYYY-MM-DD for readable maptips; pass other
    shapes through untouched."""
    s = str(raw).strip() if raw is not None else ""
    if len(s) == 8 and s.isdigit():
        return f"{s[0:4]}-{s[4:6]}-{s[6:8]}"
    return s


def _light_str(equip):
    """A short light description from an S-101 light feature's attributes, e.g.
    'char=4 grp=(3) per=10s rng=7.3' — enough to identify the light on the merged
    AtoN row (full S-52 light text is a portrayal-engine concern)."""
    parts = []
    for label, key in (("char", "lightCharacteristic"), ("grp", "signalGroup"),
                       ("per", "signalPeriod"), ("col", "colour"),
                       ("rng", "valueOfNominalRange")):
        v = _attr_value(equip, key)
        if v is not None:
            parts.append(f"{label}={v}" + ("s" if key == "signalPeriod" else ""))
    return " ".join(parts)


def _label_suffix(band):
    return f" ({s101_scale_band_label(band)})" if band is not None else ""


def _styles_dir():
    return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                        "styles")


def _apply_qml(layer, filename):
    """Apply a bundled QML style by file name (in the plugin's styles/ folder).
    The S-101 AtoN layer reuses the S-57 AtoN_point.qml verbatim — its IALA SVG
    rule renderer keys on the S-57 attribute schema the builder below emits, and
    the plugin's bundled SVGs are already on QGIS's search path."""
    path = os.path.join(_styles_dir(), filename)
    if not os.path.exists(path):
        _log(f"S-101 style not found: {path}", Qgis.Warning)
        return
    try:
        msg, ok = layer.loadNamedStyle(path)
        if ok:
            layer.triggerRepaint()
            _log(f"  ✓ Applied style {filename}")
        else:
            _log(f"  ✗ Failed to load {filename}: {msg}", Qgis.Warning)
    except Exception as e:                                # noqa: BLE001
        _log(f"  ✗ Error applying {filename}: {e}", Qgis.Warning)


# S-101 AtoN feature type → the S-57 class acronym its body symbol uses. S-101 was
# remodelled from S-57, so the buoy/beacon families correspond directly; the few
# S-101-only classes (mooring / emergency-wreck) fall back to the special-purpose
# body, exactly the closest S-57 portrayal.
# S-101 feature type → S-57 class acronym, so the S-52 engine can portray S-101
# with real ECDIS symbology. Best-effort: an acronym the LUPT doesn't know just
# falls back to the theme placeholder (apply_s52_engine returns False), so this
# is safe to extend liberally. Attribute-branching (buoy colour etc.) needs the
# S-101 attrs aliased to S-57 names — a later refinement; areas/lines/base
# symbols already render correctly from the unconditional lookup.
_S101_TO_S57 = {
    # Skin of the earth / coast
    "LandArea": "LNDARE", "Lake": "LAKARE", "Canal": "CANALS", "River": "RIVERS",
    "Coastline": "COALNE", "ShorelineConstruction": "SLCONS",
    "SeaAreaNamedWaterArea": "SEAARE", "DredgedArea": "DRGARE",
    "UnsurveyedArea": "UNSARE",
    # Topography / land features
    "BuiltUpArea": "BUAARE", "Building": "BUISGL", "Road": "ROADWY",
    "Railway": "RAILWY", "Bridge": "BRIDGE", "Landmark": "LNDMRK",
    "Vegetation": "VEGATN", "SlopingGround": "SLOGRD", "Dyke": "DYKCON",
    "Pontoon": "PONTON", "Tunnel": "TUNNEL", "SiloTank": "SILTNK",
    "AirportAirfield": "AIRARE",
    # Regulated / restricted areas
    "RestrictedArea": "RESARE", "CautionArea": "CTNARE",
    "AnchorageArea": "ACHARE", "AnchorBerth": "ACHBRT",
    "MilitaryPracticeArea": "MIPARE", "DumpingGround": "DMPGRD",
    "AdministrationArea": "ADMARE", "ProductionStorageArea": "PRDARE",
    # Navigation
    "Fairway": "FAIRWY", "NavigationLine": "NAVLNE", "RecommendedTrack": "RECTRC",
    "FerryRoute": "FERYRT", "RadioCallingInPoint": "RDOCAL",
    "PrecautionaryArea": "PRCARE", "TwoWayRoutePart": "TWRTPT",
    "TrafficSeparationSchemeLanePart": "TSSLPT",
    "RecommendedTrafficLanePart": "RCTLPT",
    # Lights & signals
    "LightAllAround": "LIGHTS", "LightSectored": "LIGHTS", "RadarStation": "RADSTA",
    "RadarTransponderBeacon": "RTPBCN", "SignalStationTraffic": "SISTAT",
    "SignalStationWarning": "SISTAW", "Daymark": "DAYMAR", "FogSignal": "FOGSIG",
    "RadioStation": "RDOSTA", "RescueStation": "RSCSTA",
    # Ports & harbours
    "Berth": "BERTHS", "Crane": "CRANES", "FloatingDock": "FLODOC",
    "DryDock": "DRYDOC", "Gate": "GATCON", "Pile": "PILPNT",
    "DistanceMark": "DISMAR", "PilotBoardingPlace": "PILBOP",
    "HarbourFacility": "HRBFAC", "DockArea": "DOCARE",
    # Obstructions & hazards
    "Obstruction": "OBSTRN", "Wreck": "WRECKS", "FoulGround": "OBSTRN",
    "CableSubmarine": "CBLSUB", "CableOverhead": "CBLOHD",
    "PipelineSubmarineOnLand": "PIPSOL", "SubmarinePipelineArea": "PIPARE",
    "OffshorePlatform": "OFSPLF", "SeabedArea": "SBDARE",
    # Buoys & beacons
    "LateralBuoy": "BOYLAT", "CardinalBuoy": "BOYCAR", "IsolatedDangerBuoy": "BOYISD",
    "SafeWaterBuoy": "BOYSAW", "SpecialPurposeGeneralBuoy": "BOYSPP",
    "LateralBeacon": "BCNLAT", "CardinalBeacon": "BCNCAR",
    "SafeWaterBeacon": "BCNSAW", "SpecialPurposeGeneralBeacon": "BCNSPP",
    "IsolatedDangerBeacon": "BCNISD",
}


_S101_TO_S57_CLASS = {
    "LateralBuoy": "BOYLAT", "CardinalBuoy": "BOYCAR", "SafeWaterBuoy": "BOYSAW",
    "SpecialPurposeGeneralBuoy": "BOYSPP", "IsolatedDangerBuoy": "BOYISD",
    "InstallationBuoy": "BOYINB", "EmergencyWreckMarkingBuoy": "BOYSPP",
    "MooringBuoy": "BOYSPP",
    "LateralBeacon": "BCNLAT", "CardinalBeacon": "BCNCAR",
    "SafeWaterBeacon": "BCNSAW", "SpecialPurposeGeneralBeacon": "BCNSPP",
    "IsolatedDangerBeacon": "BCNISD",
}


# Strict per-column datatypes for the merged AtoN layer — (name, QVariant, len,
# prec). Driven by a push to a strict, mappable data model for the Source
# Validator: single-valued S-57/S-101 enumerations become INTEGERs sized to their
# code domain (CATLAM 1 digit, CATSPM/TT_TOPSHP/light enums 2), the light's
# period/range become REALs. STRINGS are kept only where the value genuinely is
# text — names, the light signal group ('(2+1)'), and the MULTI-VALUED colour
# attributes (a buoy can be e.g. black-yellow-black = '2,6,2'; comma-joined, also
# the exact form the AtoN_point.qml rules match on, so portrayal is preserved).
_ATON_FIELDDEFS = [
    ("SRCFILE",   QVariant.String, 0, 0),
    ("S57CLASS",  QVariant.String, 0, 0),
    ("OBJNAM",    QVariant.String, 0, 0),
    ("BOYSHP",    QVariant.Int,    1, 0),
    ("BCNSHP",    QVariant.Int,    1, 0),
    ("COLOUR",    QVariant.String, 0, 0),   # multi-valued → string
    ("COLPAT",    QVariant.Int,    1, 0),
    ("CATCAM",    QVariant.Int,    1, 0),
    ("CATLAM",    QVariant.Int,    1, 0),
    ("CATSPM",    QVariant.Int,    2, 0),
    ("TT_TOPSHP", QVariant.Int,    2, 0),
    ("TT_COLOUR", QVariant.String, 0, 0),   # multi-valued → string
    ("TT_COLPAT", QVariant.Int,    1, 0),
    ("LT_COLOUR", QVariant.String, 0, 0),   # multi-valued → string
    ("LT_LITCHR", QVariant.Int,    2, 0),
    ("LT_SIGGRP", QVariant.String, 0, 0),   # e.g. '(2+1)' — coded text
    ("LT_SIGPER", QVariant.Double, 6, 1),
    ("LT_VALNMR", QVariant.Double, 6, 1),
    ("LT_CATLIT", QVariant.Int,    2, 0),
    ("ftype",     QVariant.String, 0, 0),
    ("equipment", QVariant.String, 0, 0),
    ("light",     QVariant.String, 0, 0),
]
_ATON_INT_FIELDS = {n for n, t, _l, _p in _ATON_FIELDDEFS if t == QVariant.Int}
_ATON_REAL_FIELDS = {n for n, t, _l, _p in _ATON_FIELDDEFS if t == QVariant.Double}


def _as_int(v):
    """Coerce an S-101 attribute value to int for a strictly-typed column, or
    None if it isn't a clean integer (so the column stays NULL, not 0)."""
    try:
        return int(str(v).strip())
    except (TypeError, ValueError, AttributeError):
        return None


def _root_value(feature, name):
    """First non-empty value of a TOP-LEVEL (root) attribute by name. Root-only
    so a buoy's body colour isn't confused with its topmark's colour (the topmark
    is a nested complex attribute carrying its own colour/colourPattern)."""
    for a in feature.attributes:
        if a.name == name and a.value is not None:
            return a.value
    return None


def _root_values_all(feature, name):
    """All non-empty values of a repeating TOP-LEVEL attribute (e.g. a buoy's
    several body colours), in document order."""
    return [a.value for a in feature.attributes
            if a.name == name and a.value is not None]


def _topmark_fields(feature):
    """(TT_TOPSHP, TT_COLOUR, TT_COLPAT) from a structure's nested `topmark`
    complex attribute, or (None, None, None) if it carries no topmark. Colours
    are comma-joined to match the S-57 QML's COLOUR value format."""
    for a in feature.attributes:
        if a.name != "topmark":
            continue
        shp = colpat = None
        cols = []
        for ch in a.flatten():
            if ch is a:
                continue
            if ch.name == "topmarkDaymarkShape" and ch.value is not None:
                shp = ch.value
            elif ch.name == "colour" and ch.value is not None:
                cols.append(ch.value)
            elif ch.name == "colourPattern" and ch.value is not None:
                colpat = ch.value
        return shp, (",".join(str(c) for c in cols) or None), colpat
    return None, None, None


def load_s101_coverage(paths, target_crs="EPSG:4326", add_to_project=True,
                       band=None):
    """Build ONE ENC Coverage layer over the given S-101 cells: each cell's
    DataCoverage feature(s) dissolved into one polygon carrying that cell's DSID
    metadata (dataset name/edition/issue date/product spec/title + optimum
    display scale). The S-101 analogue of the S-57 ENC Coverage hybrid; ``band``
    only labels the layer. Returns [layer] (added to the project unless
    ``add_to_project=False``), or [] if no DataCoverage was found."""
    dst, xform = _dst_xform(target_crs)
    lname = "S-101 — ENC Coverage" + _label_suffix(band)
    layer = QgsVectorLayer(f"MultiPolygon?crs={_crs_token(dst)}", lname, "memory")
    dp = layer.dataProvider()
    fields = [("DSNM", QVariant.String), ("edition", QVariant.String),
              ("issued", QVariant.String), ("product", QVariant.String),
              ("ps_edition", QVariant.String), ("title", QVariant.String),
              ("scale", QVariant.String)]
    dp.addAttributes([QgsField(n, t) for n, t in fields])
    layer.updateFields()

    feats = []
    for p in paths:
        try:
            recs = list(Iso8211Reader.open(p).records())
            cell = S101Cell.from_records(
                recs, name=os.path.splitext(os.path.basename(p))[0])
            gb = S101GeometryBuilder(recs)
        except Exception as e:                            # noqa: BLE001
            _log(f"S-101 coverage: failed on {os.path.basename(p)}: {e}",
                 Qgis.Warning)
            continue
        polys, opt_scale = [], None
        for f in cell.features:
            if f.type_name != "DataCoverage":
                continue
            g = gb.geometry(f)
            if g is None:
                continue
            gg = _geom_from_wkt(g.wkt(), xform)
            if gg is not None and not gg.isEmpty():
                polys.append(gg)
            opt_scale = opt_scale or _attr_value(f, "optimumDisplayScale")
        if not polys:
            _log(f"S-101 coverage: no DataCoverage in {cell.name}")
            continue
        merged = polys[0]
        for extra in polys[1:]:
            merged = merged.combine(extra)
        d = cell.dsid
        ft = QgsFeature(layer.fields())
        ft.setGeometry(merged)
        ft.setAttributes([
            d.get("dataset_name") or cell.name,
            d.get("dataset_edition"),
            _iso_date(d.get("reference_date")),
            d.get("product_spec"),
            d.get("ps_edition"),
            d.get("title"),
            opt_scale,
        ])
        feats.append(ft)

    dp.addFeatures(feats)
    layer.setCrs(dst)
    layer.updateExtents()
    if not feats:
        _log("S-101 coverage: no coverage polygons built.", Qgis.Warning)
        return []
    # Translated S-57 ENC Coverage portrayal: translucent footprint fill + a
    # per-chart label + chart-info map-tip (S101_COVERAGE_area.qml).
    _apply_qml(layer, "S101_COVERAGE_area.qml")
    if add_to_project:
        QgsProject.instance().addMapLayer(layer)
    _log(f"S-101 coverage: {len(feats)} chart coverage polygon(s) in {lname}")
    return [layer]


def load_s101_aton(paths, target_crs="EPSG:4326", add_to_project=True,
                   band=None):
    """Build ONE merged Aids-to-Navigation point layer over the given S-101
    cells: each buoy/beacon body (S101_ATON_STRUCTURES) becomes one point, with
    its equipment (lights, fog signals, topmarks, daymarks, racons) folded on via
    the StructureEquipment feature association (FASC). The S-101 analogue of the
    S-57 AtoN merge — but association-driven, not position-guessed. ``band`` only
    labels the layer. Returns [layer] (added unless ``add_to_project=False``), or
    [] if no AtoN structures were found."""
    dst, xform = _dst_xform(target_crs)
    lname = "S-101 — Aids to Navigation" + _label_suffix(band)
    layer = QgsVectorLayer(f"Point?crs={_crs_token(dst)}", lname, "memory")
    dp = layer.dataProvider()
    # Emit the S-57 AtoN attribute schema so the bundled AtoN_point.qml's IALA
    # rule renderer (keyed on BOYSHP/COLOUR/COLPAT/CATCAM/CATLAM/TT_*) draws S-101
    # marks with the same symbols — but STRICTLY TYPED per _ATON_FIELDDEFS (int
    # enums, real light values) so the layer is mappable in the Source Validator.
    # The QML's string-set filters (CATLAM IN ('2','6','8'), TT_TOPSHP ='33')
    # coerce against the int columns, so portrayal is unaffected.
    dp.addAttributes([QgsField(n, t, "", l, p) for n, t, l, p in _ATON_FIELDDEFS])
    layer.updateFields()

    feats = []
    for p in paths:
        try:
            recs = list(Iso8211Reader.open(p).records())
            cell = S101Cell.from_records(
                recs, name=os.path.splitext(os.path.basename(p))[0])
            gb = S101GeometryBuilder(recs)
        except Exception as e:                            # noqa: BLE001
            _log(f"S-101 AtoN: failed on {os.path.basename(p)}: {e}", Qgis.Warning)
            continue
        by = cell.features_by_rcid()
        for f in cell.features:
            if not is_s101_aton_structure(f.type_name):
                continue
            g = gb.geometry(f)
            if g is None:
                continue
            gg = _geom_from_wkt(g.wkt(), xform)
            if gg is None or gg.isEmpty():
                continue
            if gg.type() != QgsWkbTypes.PointGeometry:
                gg = gg.centroid()        # beacons may be areas; mark the centre

            # ── Equipment, folded on via the StructureEquipment association.
            equip, lights, light_feat = [], [], None
            for a in f.associations:
                if a.kind != "feature":
                    continue
                eq = by.get(a.target_rcid)
                if eq is None or eq.type_name not in S101_ATON_EQUIPMENT:
                    continue
                equip.append(eq.type_name)
                if str(eq.type_name).startswith("Light"):
                    if light_feat is None:
                        light_feat = eq
                    s = _light_str(eq)
                    if s:
                        lights.append(s)

            # ── Body (S-101 → S-57 attribute translation).
            body_cols = ",".join(str(c) for c in _root_values_all(f, "colour"))
            tt_shp, tt_col, tt_pat = _topmark_fields(f)
            lf = light_feat
            row = {
                "SRCFILE": cell.name,
                "S57CLASS": _S101_TO_S57_CLASS.get(f.type_name, f.type_name),
                "OBJNAM": _attr_value(f, "name") or _attr_value(f, "featureName"),
                "BOYSHP": _root_value(f, "buoyShape"),
                "BCNSHP": _root_value(f, "beaconShape"),
                "COLOUR": body_cols or None,
                "COLPAT": _root_value(f, "colourPattern"),
                "CATCAM": _root_value(f, "categoryOfCardinalMark"),
                "CATLAM": _root_value(f, "categoryOfLateralMark"),
                "CATSPM": _root_value(f, "categoryOfSpecialPurposeMark"),
                "TT_TOPSHP": tt_shp, "TT_COLOUR": tt_col, "TT_COLPAT": tt_pat,
                "LT_COLOUR": (",".join(str(c) for c in _attr_values_all(lf, "colour"))
                              if lf else None),
                "LT_LITCHR": _attr_value(lf, "lightCharacteristic") if lf else None,
                "LT_SIGGRP": _attr_value(lf, "signalGroup") if lf else None,
                "LT_SIGPER": _attr_value(lf, "signalPeriod") if lf else None,
                "LT_VALNMR": _attr_value(lf, "valueOfNominalRange") if lf else None,
                "LT_CATLIT": _attr_value(lf, "categoryOfLight") if lf else None,
                "ftype": f.type_name,
                "equipment": ", ".join(sorted(set(equip))) or None,
                "light": "; ".join(lights) or None,
            }
            # Coerce to the column's declared type (int/real); strings pass
            # through. A non-numeric value in a typed column becomes NULL, never 0.
            attrs = []
            for n, _t, _l, _p in _ATON_FIELDDEFS:
                v = row.get(n)
                if n in _ATON_INT_FIELDS:
                    v = _as_int(v)
                elif n in _ATON_REAL_FIELDS:
                    v = _to_real(v)
                attrs.append(v)
            ft = QgsFeature(layer.fields())
            ft.setGeometry(gg)
            ft.setAttributes(attrs)
            feats.append(ft)

    dp.addFeatures(feats)
    layer.setCrs(dst)
    layer.updateExtents()
    if not feats:
        _log("S-101 AtoN: no buoy/beacon structures found.", Qgis.Warning)
        return []
    # Translated portrayal: reuse the S-57 AtoN IALA symbol set verbatim.
    _apply_qml(layer, "AtoN_point.qml")
    if add_to_project:
        QgsProject.instance().addMapLayer(layer)
    _log(f"S-101 AtoN: {len(feats)} mark(s) merged into {lname}")
    return [layer]


def load_s101_folder(folder, add_to_project=True):
    """One-shot: scan a folder for S-101 cells (the same stager the dock uses, now
    S-101-aware) and load every winner onto the map. Console convenience that
    gives the folder→map workflow today, before the dock buttons are wired:

        from ENCoreGIS.s101.qgis_loader import load_s101_folder
        load_s101_folder(r"C:\\Users\\...\\Downloads\\S-101")
    """
    try:
        from .. import enc_scanner as scanner
    except (ImportError, ValueError):
        import enc_scanner as scanner
    best, _skipped, _validation, _staging = scanner.scan_for_charts(folder)
    s101 = [p for p in best.values() if scanner.classify_cell(p) == "S-101"]
    _log(f"S-101 folder load: {len(s101)} S-101 cell(s) of {len(best)} found")
    return load_s101_cells(s101, add_to_project=add_to_project)
