# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/s101.py
------------------
The S-158-101 validation engine for S-101 ENC (vector) cells.

Unlike the coverage products (``validation/s100.py`` reads HDF5 via GDAL), S-101
is a **vector** product and is read with our OWN native ISO 8211 engine — never
GDAL. The chain is ``iso8211.Iso8211Reader`` → ``s101.records.S101Cell`` (the same
byte layer the S-57 auditor uses); the S-158-101 predicates then run against that
native feature/attribute model. No OGR/GDAL S-101 driver at any point.

It reuses the S-158 catalogue + report machinery from :mod:`validation.s100`
(``catalogue``/``catalogue_index``/``SEVERITY``/``S100Finding``/``S100Report`` are
product-neutral), so the 456 official S-158-101 checks are bundled and every check
is reported FAIL / PASS / NOT-IMPLEMENTED exactly as for the HDF5 products.
"""

import math as _math
import os
import re

from . import s100


# -- native S-101 read (own ISO 8211 engine, GDAL-free) -----------------------
def _S101Cell():
    try:                                                # plugin context
        from ..s101.records import S101Cell
    except (ImportError, ValueError):                   # headless / top-level
        from s101.records import S101Cell
    return S101Cell


def _reader():
    try:                                                # plugin context
        from ..iso8211 import Iso8211Reader, Iso8211Error
    except (ImportError, ValueError):                   # headless / top-level
        from iso8211 import Iso8211Reader, Iso8211Error
    return Iso8211Reader, Iso8211Error


def read_cells(cell_paths):
    """Parse S-101 ``.000`` cells with the native engine, retaining each cell's
    raw ISO 8211 records on ``cell._records`` so the geometry-topology checks
    (:mod:`validation.s101_geom`) can assemble geometry natively. A malformed cell
    comes back carrying ``.parse_error`` (never raises), mirroring the S-57 side —
    and one bad cell never aborts a multi-cell dataset."""
    S101Cell = _S101Cell()
    Reader, _Err = _reader()
    cells = []
    for p in cell_paths:
        name = os.path.splitext(os.path.basename(p))[0]
        try:
            records = list(Reader.open(p).records())
            cell = S101Cell.from_records(records, name=name)
            cell._records = records
        except Exception as exc:                        # noqa: BLE001
            cell = S101Cell(name)
            cell.parse_error = str(exc)
        cell._path = p                                   # for the file-level checks
        cells.append(cell)
    return cells


# -- check registry -----------------------------------------------------------
# CHECKS_101 = [(check_id, predicate)]; predicate(cells, ctx) -> iter of
# (location, detail). Predicates read ONLY the native S101Cell model.
CHECKS_101 = []


def check(cid):
    """Register a predicate as the implementation of one S-158-101 catalogue
    check (e.g. ``101_0005``)."""
    def _wrap(fn):
        CHECKS_101.append((cid, fn))
        fn._s158_id = cid
        return fn
    return _wrap


def _all_features(cells):
    """Yield (cell, feature) over every geo/meta feature in every parsed cell."""
    for cell in cells:
        for f in getattr(cell, "features", []):
            yield cell, f


def _walk_attrs(attrs):
    """Depth-first over an S101Attribute tree (root list)."""
    for a in attrs or []:
        yield a
        for sub in _walk_attrs(getattr(a, "children", None)):
            yield sub


def _fc():
    """The bundled S-101 Feature Catalogue (parsed + cached), or None. This is the
    production (newest) edition — prefer ``_fc_for(cell)`` in feature checks so a
    dataset is validated against the FC edition it was authored with."""
    from . import s101_fc
    return s101_fc.catalogue()


def _fc_for(cell):
    """The FC edition matching a cell's content (cached per cell). S-101 renamed
    many feature types between FC 1.1.0 and 2.1.0 (e.g. BeaconCardinal ->
    CardinalBeacon); binding a cell against the wrong edition makes ``type_for``
    return None and silently drops every mandatory/optional-attribute finding for
    the renamed types. Picking the edition that recognises the cell's type names
    fixes that, while modern cells still resolve to the production catalogue."""
    if not hasattr(cell, "_fc_edition"):
        from . import s101_fc
        try:
            types = [getattr(f, "type_name", None)
                     for f in getattr(cell, "features", None) or []]
            cell._fc_edition = s101_fc.select_for_types(types)
        except Exception:                                # noqa: BLE001
            try:
                cell._fc_edition = s101_fc.catalogue()
            except Exception:                            # noqa: BLE001
                cell._fc_edition = None
    return getattr(cell, "_fc_edition", None)


def _is_populated(a):
    """A root attribute is populated if it has a non-empty value OR sub-attrs."""
    if getattr(a, "children", None):
        return True
    v = getattr(a, "value", None)
    return v is not None and str(v).strip() != ""


def _root_attr_map(feature):
    """{code: S101Attribute} for a feature's ROOT attributes (last one wins)."""
    return {a.name: a for a in getattr(feature, "attributes", None) or [] if a.name}


def _flat_values(feature):
    """{code: value} over ALL leaf attributes of a feature (root + nested)."""
    out = {}
    for a in _walk_attrs(getattr(feature, "attributes", None)):
        if a.name and not getattr(a, "children", None):
            out.setdefault(a.name, getattr(a, "value", None))
    return out


def _to_num(v):
    """Best-effort float from an attribute value, or None."""
    if v is None:
        return None
    try:
        return float(str(v).strip())
    except (TypeError, ValueError):
        return None


def _leaf_values(feature, name):
    """Every populated leaf value with this attribute code, across the whole tree
    (root + nested)."""
    return [a.value for a in _walk_attrs(getattr(feature, "attributes", None))
            if a.name == name and not getattr(a, "children", None)
            and a.value is not None and str(a.value).strip() != ""]


def _root_values(feature, name):
    """Every populated ROOT (top-level) attribute value with this code — so a
    feature's own attribute is not confused with the same-named sub-attribute of a
    nested complex (e.g. a buoy's body colour vs its topmark's colour)."""
    return [a.value for a in getattr(feature, "attributes", None) or []
            if a.name == name and not getattr(a, "children", None)
            and a.value is not None and str(a.value).strip() != ""]


def _has_attr(feature, name):
    """True if an attribute with this code appears anywhere in the feature — as a
    populated leaf OR a complex (has children) parent."""
    for a in _walk_attrs(getattr(feature, "attributes", None)):
        if a.name == name:
            if getattr(a, "children", None):
                return True
            if a.value is not None and str(a.value).strip() != "":
                return True
    return False


def _child_value(feature, parent, child):
    """First populated value of ``parent.child`` (a leaf nested under a complex
    attribute), or None — path-aware so e.g. fixedDateRange.dateStart is not
    confused with surveyDateRange.dateStart."""
    for a in _walk_attrs(getattr(feature, "attributes", None)):
        if a.name == parent:
            for c in _walk_attrs(getattr(a, "children", None)):
                if c.name == child and not getattr(c, "children", None):
                    v = c.value
                    if v is not None and str(v).strip() != "":
                        return v
    return None


def _parent_present(feature, parent):
    """True if a complex attribute ``parent`` is present on the feature."""
    return any(a.name == parent for a in
               _walk_attrs(getattr(feature, "attributes", None)))


def _pv(feature, name):
    """First POPULATED leaf value (root or nested) with this attribute code, or
    None — the "is Known / is Present" accessor. Empty strings count as absent."""
    for a in _walk_attrs(getattr(feature, "attributes", None)):
        if a.name == name and not getattr(a, "children", None):
            if a.value is not None and str(a.value).strip() != "":
                return a.value
    return None


def _eq(v, *codes):
    """True if a populated value equals one of the given codes (compared as
    strings). Absent/empty never matches — so a "not Equal to V" test built on
    ``not _eq(...)`` still requires the attribute to be present (see the checks)."""
    if v is None:
        return False
    s = str(v).strip()
    return s in {str(c) for c in codes}


def _is_true(v):
    return str(v).strip().lower() in ("true", "1")


def _is_false(v):
    return str(v).strip().lower() in ("false", "0")


def _attr_signature(feature, exclude=()):
    """A hashable, order-independent signature of ALL of a feature's leaf attribute
    (name, value) pairs — a MULTISET, so repeated attributes (e.g. several
    ``colour`` occurrences) are kept and distinguish otherwise-similar features.
    Two features with the same signature carry identical attribution. ``exclude``
    drops named attributes from the comparison (e.g. reportedDate/scaleMinimum for
    101_0013). Deliberately complete — comparing only first values could read two
    genuinely different features as equal (a false positive)."""
    excl = set(exclude)
    sig = []
    for a in _walk_attrs(getattr(feature, "attributes", None)):
        if a.name and not getattr(a, "children", None) and a.name not in excl:
            v = getattr(a, "value", None)
            sig.append((a.name, None if v is None else str(v)))
    return tuple(sorted(sig))


# non-significant leading zero (e.g. 05, 007, -05.5) — but NOT a bare "0" or the
# leading zero of "0.5"; and a trailing zero after a significant decimal digit
# (e.g. 5.50, 1.230) — but NOT "5.0" / "5.00".
_LEADING_ZERO = re.compile(r"^-?0[0-9]")
_TRAILING_ZERO = re.compile(r"\.\d*[1-9]0+$")


def _loc(cell, feature):
    """The standard finding location for a feature: ``<cell>/rcid=<rcid>``."""
    return "%s/rcid=%s" % (getattr(cell, "name", "?"),
                           getattr(feature, "rcid", "?"))


def _spatial(cells, ctx):
    """The run's assembled S-101 geometry + spatial operators (built once, cached
    on ``ctx``). ``None`` when GDAL is absent or nothing assembles — the geometry
    checks then no-op, so the mechanical checks are unaffected and the engine still
    runs headlessly."""
    if "_spatial" in ctx:
        return ctx["_spatial"]
    sp = None
    try:
        from . import s101_geom
        sp = s101_geom.S101Spatial.build(cells)
    except Exception:                                    # noqa: BLE001
        sp = None
    ctx["_spatial"] = sp
    return sp


def _curve_graphs(cells, ctx):
    """The per-cell curve-usage graphs (built once, cached on ``ctx``) as a dict
    ``{id(cell): (cell, CurveGraph)}``. GDAL-free (reads only native records), so
    the curve-topology checks run headlessly. Cells with no graph are omitted."""
    if "_curve_graphs" in ctx:
        return ctx["_curve_graphs"]
    graphs = {}
    try:
        from . import s101_geom
        for cell in cells or []:
            g = s101_geom.build_curve_graph(cell)
            if g is not None:
                graphs[id(cell)] = (cell, g)
    except Exception:                                    # noqa: BLE001
        graphs = {}
    ctx["_curve_graphs"] = graphs
    return graphs


# -- checks (native model only; catalogue IDs are authoritative) --------------
@check("101_0005")
def chk_duplicate_foids(cells, ctx):
    """Duplicate FOIDs exist within the dataset. A Feature Object Identifier
    (AGEN/FIDN/FIDS) must be unique across the whole dataset."""
    seen, dup = {}, set()
    for cell, f in _all_features(cells):
        foid = getattr(f, "foid_str", None)              # property, not a call
        if not foid:
            continue
        if foid in seen:
            dup.add(foid)
        seen[foid] = seen.get(foid, 0) + 1
    for foid in sorted(dup):
        yield (foid, "FOID appears %d times in the dataset" % seen[foid])


@check("101_1004")
def chk_mandatory_attribute(cells, ctx):
    """A mandatory attribute has not been populated with a value. 'Mandatory' is
    read from the S-101 Feature Catalogue: an attribute bound to the feature type
    with a multiplicity lower bound >= 1. Reports a bound attribute that is either
    absent or present-but-empty. No-op if the FC is unavailable."""
    for cell, f in _all_features(cells):
        fc = _fc_for(cell)
        if fc is None:
            continue
        ft = fc.type_for(getattr(f, "type_name", None) or "")
        if ft is None:
            continue
        amap = _root_attr_map(f)
        for code in sorted(ft.mandatory()):
            a = amap.get(code)
            if a is None or not _is_populated(a):
                loc = "%s/rcid=%s" % (cell.name, getattr(f, "rcid", "?"))
                yield (loc, "%s: mandatory attribute '%s' %s"
                       % (ft.code, code, "missing" if a is None else "empty"))


@check("101_1006")
def chk_empty_attribute(cells, ctx):
    """A non-mandatory attribute is present but has an empty value. The Feature
    Catalogue distinguishes mandatory (→ 101_1004) from optional bindings, so this
    reports only the OPTIONAL root attributes that are encoded empty (when the FC
    is unavailable, every empty root attribute is reported)."""
    for cell, f in _all_features(cells):
        fc = _fc_for(cell)
        ft = fc.type_for(getattr(f, "type_name", None) or "") if fc else None
        mandatory = ft.mandatory() if ft is not None else set()
        for a in getattr(f, "attributes", None) or []:
            if not a.name or a.name in mandatory:        # mandatory → 101_1004
                continue
            if _is_populated(a):
                continue
            loc = "%s/rcid=%s" % (cell.name, getattr(f, "rcid", "?"))
            yield (loc, "optional attribute '%s' present but empty" % a.name)


def _has_feature_type(cells, type_name):
    for cell, f in _all_features(cells):
        if (getattr(f, "type_name", None) or "") == type_name:
            return True
    return False


@check("101_1054")
def chk_datacoverage_present(cells, ctx):
    """Mandatory DataCoverage feature is missing — every S-101 dataset must carry
    at least one DataCoverage meta feature."""
    if cells and not _has_feature_type(cells, "DataCoverage"):
        yield ("dataset", "no DataCoverage feature found in the dataset")


@check("101_1056")
def chk_navsys_present(cells, ctx):
    """Mandatory NavigationalSystemOfMarks feature is missing."""
    if cells and not _has_feature_type(cells, "NavigationalSystemOfMarks"):
        yield ("dataset",
               "no NavigationalSystemOfMarks feature found in the dataset")


@check("101_0019")
def chk_integer_padding(cells, ctx):
    """An Integer-type attribute value is padded with a non-significant leading
    zero (e.g. '007'). The attribute's type comes from the Feature Catalogue."""
    for cell, f in _all_features(cells):
        fc = _fc_for(cell)
        if fc is None:
            continue
        for a in _walk_attrs(getattr(f, "attributes", None)):
            if not a.name or getattr(a, "children", None):
                continue
            attr = fc.attributes.get(a.name)
            if not (attr and attr.value_type == "integer"):
                continue
            v = ("" if a.value is None else str(a.value)).strip()
            if v and _LEADING_ZERO.match(v):
                yield ("%s/rcid=%s" % (cell.name, getattr(f, "rcid", "?")),
                       "integer '%s' padded with leading zero: %r" % (a.name, v))


@check("101_0018")
def chk_real_padding(cells, ctx):
    """A Real-type attribute value is padded with non-significant zeroes — a
    leading zero (e.g. '05.5') or a trailing zero after a significant decimal
    digit (e.g. '5.50'). Conservative: it does NOT flag '5.0' / '5.00'."""
    for cell, f in _all_features(cells):
        fc = _fc_for(cell)
        if fc is None:
            continue
        for a in _walk_attrs(getattr(f, "attributes", None)):
            if not a.name or getattr(a, "children", None):
                continue
            attr = fc.attributes.get(a.name)
            if not (attr and attr.value_type == "real"):
                continue
            v = ("" if a.value is None else str(a.value)).strip()
            if v and (_LEADING_ZERO.match(v) or _TRAILING_ZERO.search(v)):
                yield ("%s/rcid=%s" % (cell.name, getattr(f, "rcid", "?")),
                       "real '%s' has a non-significant zero: %r" % (a.name, v))


@check("101_1101")
def chk_measurement_distance_order(cells, ctx):
    """measurementDistanceMinimum is greater than measurementDistanceMaximum."""
    for cell, f in _all_features(cells):
        vals = _flat_values(f)
        lo, hi = vals.get("measurementDistanceMinimum"), \
            vals.get("measurementDistanceMaximum")
        try:
            if lo is not None and hi is not None and float(lo) > float(hi):
                yield ("%s/rcid=%s" % (cell.name, getattr(f, "rcid", "?")),
                       "measurementDistanceMinimum (%s) > Maximum (%s)" % (lo, hi))
        except (TypeError, ValueError):
            continue


# -- geometry-topology checks (101_00xx; the spatial band) --------------------
# These read assembled geometry via `_spatial` (native geometry + GEOS/OGR
# predicates). Each scopes PER CELL — adjacent cells legitimately abut/overlap at
# shared borders, so a dataset-wide sweep would raise false positives. All no-op
# when geometry is unavailable (no GDAL, or feature/attribute-only doubles).

# Skin-of-the-Earth surface classes: together they tile the DataCoverage without
# gaps or overlaps (S-101 PS 4.3.2.1.1).
_SOTE_SURFACE = ("DepthArea", "DredgedArea", "LandArea", "UnsurveyedArea")

# SPAS scale subfields carry the ISO 8211 "missing" sentinels on valid data —
# SMIN = 0xFFFFFFFF (unsigned-int missing), SMAX = 0 — so a value OUTSIDE this set
# is a genuine, and therefore prohibited, scale attribution (101_0015).
_ABSENT_SCALE = (0, 4294967295)


def _scale_value(raw):
    """A genuine SPAS scale value, or ``None`` for absent/sentinel/unparseable."""
    if raw in (None, ""):
        return None
    try:
        iv = int(str(raw).strip())
    except (TypeError, ValueError):
        return None
    return None if iv in _ABSENT_SCALE else iv


@check("101_0016")
def chk_mixed_primitive_refs(cells, ctx):
    """A feature references geometries of different geometric primitives. A
    well-formed S-101 feature references a single primitive (point, curve or
    surface); a mix is prohibited (PS 4.8.1). Read from the native spatial
    pointers — no geometry assembly needed."""
    from .s101_geom import feature_primitive_classes
    for cell, f in _all_features(cells):
        classes = feature_primitive_classes(f)
        if len(classes) > 1:
            yield (_loc(cell, f),
                   "references mixed geometric primitives: %s"
                   % ", ".join(sorted(classes)))


@check("101_0015")
def chk_spatial_scale_present(cells, ctx):
    """Scale minimum/maximum on a spatial association is present. S-101 forbids
    SPAS.SMIN / SPAS.SMAX (PS 4.8.2); on valid data they carry the ISO 8211
    missing sentinels, so only a genuine value is reported."""
    for cell, f in _all_features(cells):
        for tag, occ in getattr(f, "spatial_refs", None) or []:
            if tag != "SPAS":
                continue
            present = [sub for sub in ("SMIN", "SMAX")
                       if _scale_value(occ.get(sub)) is not None]
            if present:
                yield (_loc(cell, f),
                       "SPAS scale value present (%s)" % "/".join(present))


@check("101_0007")
def chk_crosses_antimeridian(cells, ctx):
    """The cell crosses the 180° meridian (PS 4.5.2). S-101 coordinates are WGS84
    lon/lat, so a cell whose assembled geometry spans more than 180° of longitude
    must wrap the antimeridian — no ordinary cell is that wide otherwise. Scoped
    per cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    for cell in sp.cells_seen():
        lon_min = lon_max = None
        for e in sp.entries_for(cell):
            minx, maxx = e.envelope[0], e.envelope[1]
            lon_min = minx if lon_min is None else min(lon_min, minx)
            lon_max = maxx if lon_max is None else max(lon_max, maxx)
        if lon_min is not None and (lon_max - lon_min) > 180.0:
            yield (getattr(cell, "name", "dataset"),
                   "cell longitude span %.3f° exceeds 180° "
                   "(crosses the 180° meridian)" % (lon_max - lon_min))


@check("101_0001")
def chk_sote_edge_pairing(cells, ctx):
    """Holes or overlaps exist in Skin-of-the-Earth coverage (PS 4.3.2.1.1). In a
    valid SotE partition every INTERIOR boundary curve is shared by exactly two
    SotE surfaces — once forward, once reversed. So a curve referenced by SotE
    surfaces but NOT by a DataCoverage (i.e. interior) that is not used exactly
    twice with opposite orientations marks a hole (used once) or an overlap (used
    twice the same way, or more). Reads the native curve-usage graph — GDAL-free.
    DataCoverage-boundary curves are 101_0004's domain. Per cell."""
    for cell, g in _curve_graphs(cells, ctx).values():
        for curid, uses in g.usage.items():
            sote = [rev for (f, rev) in uses
                    if getattr(f, "type_name", None) in _SOTE_SURFACE]
            if not sote:
                continue
            if any(getattr(f, "type_name", None) == "DataCoverage"
                   for (f, _rev) in uses):
                continue                                 # boundary → 101_0004
            if not (len(sote) == 2 and sote[0] != sote[1]):
                yield ("%s/curve=%s" % (getattr(cell, "name", "?"), curid),
                       "Skin-of-the-Earth boundary curve used %d time(s) with "
                       "orientations %s (expected exactly 2, one forward one "
                       "reverse) — hole or overlap"
                       % (len(sote),
                          "/".join("rev" if r else "fwd" for r in sote)))


@check("101_0004")
def chk_datacoverage_boundary_shared(cells, ctx):
    """The boundary of a DataCoverage is shared by more than one Skin-of-the-Earth
    feature (PS 4.3.2.1). A curve referenced by exactly one DataCoverage lies on
    the cell limit and should border exactly ONE SotE surface (on the inside);
    more than one SotE feature on it is an overlap at the data limit. Per cell."""
    for cell, g in _curve_graphs(cells, ctx).values():
        for curid, uses in g.usage.items():
            cov = sum(1 for (f, _rev) in uses
                      if getattr(f, "type_name", None) == "DataCoverage")
            sote_feats = {id(f) for (f, _rev) in uses
                          if getattr(f, "type_name", None) in _SOTE_SURFACE}
            if cov == 1 and len(sote_feats) > 1:
                yield ("%s/curve=%s" % (getattr(cell, "name", "?"), curid),
                       "DataCoverage-boundary curve shared by %d Skin-of-the-Earth "
                       "features (expected 1)" % len(sote_feats))


@check("101_0014")
def chk_curve_interpolation(cells, ctx):
    """A curve segment's interpolation is not Loxodromic (PS 4.8.1): the curve
    (CRID) segment header SEGH.INTP must equal 4. Reads the native curve records —
    GDAL-free. Per cell."""
    for cell, g in _curve_graphs(cells, ctx).values():
        for key, rec in (g.curves or {}).items():
            segh = rec.first("SEGH") if hasattr(rec, "first") else None
            if segh is None:
                continue
            intp = segh.get("INTP")
            try:
                loxodromic = int(str(intp).strip()) == 4
            except (TypeError, ValueError):
                loxodromic = False
            if not loxodromic:
                rcid = key[1] if isinstance(key, tuple) else key
                yield ("%s/curve=%s" % (getattr(cell, "name", "?"), rcid),
                       "curve interpolation INTP=%r (must be 4 = Loxodromic)"
                       % (intp,))


@check("101_0002")
def chk_sote_equals_coverage(cells, ctx):
    """The combined coverage of the Skin-of-the-Earth surfaces (DepthArea,
    DredgedArea, LandArea, UnsurveyedArea) must equal the DataCoverage extent
    (PS 4.3.2.1.1) — SotE tiles the coverage exactly, with no gap or spill. Tested
    as the symmetric-difference area of the two unions relative to the coverage
    area; native integer-coordinate assembly makes a valid tiling exact (0 on all
    sample cells). Skipped when a cell has no SotE or no DataCoverage. Per cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    from .s101_geom import union_geoms
    for cell in sp.cells_seen():
        ents = sp.entries_for(cell)
        sote = [e.geom for e in ents if e.primitive == "surface"
                and getattr(e.feature, "type_name", None) in _SOTE_SURFACE]
        cov = [e.geom for e in ents if e.primitive == "surface"
               and getattr(e.feature, "type_name", None) == "DataCoverage"]
        if not sote or not cov:
            continue
        us, uc = union_geoms(sote), union_geoms(cov)
        if us is None or uc is None:
            continue
        try:
            sd = us.SymDifference(uc)
            sd_area = sd.Area() if sd is not None else 0.0
            cov_area = uc.Area()
        except Exception:                                # noqa: BLE001
            continue
        if cov_area > 0 and sd_area > cov_area * 1e-6:
            yield (getattr(cell, "name", "dataset"),
                   "Skin-of-the-Earth coverage differs from the DataCoverage "
                   "extent by %.3f%% of the coverage area (gap/overlap/spill)"
                   % (100.0 * sd_area / cov_area))


@check("101_0003")
def chk_sote_overlap(cells, ctx):
    """Skin-of-the-Earth surfaces overlap. Each DepthArea / DredgedArea / LandArea
    / UnsurveyedArea surface must not OVERLAP or lie WITHIN another such surface
    (PS 4.3.2.1.1) — together they partition the coverage. Merely sharing a
    boundary (TOUCHES) is correct and is not reported. Scoped per cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    ops = sp.ops
    from .s101_geom import envelopes_overlap
    for cell in sp.cells_seen():
        surfs = [e for e in sp.entries_for(cell)
                 if e.primitive == "surface"
                 and getattr(e.feature, "type_name", None) in _SOTE_SURFACE]
        for i in range(len(surfs)):
            a = surfs[i]
            for j in range(i + 1, len(surfs)):
                b = surfs[j]
                if not envelopes_overlap(a.envelope, b.envelope):
                    continue
                if (ops.overlaps(a.geom, b.geom)
                        or ops.within(a.geom, b.geom)
                        or ops.within(b.geom, a.geom)):
                    yield (_loc(cell, a.feature),
                           "%s overlaps %s (rcid=%s)"
                           % (getattr(a.feature, "type_name", "?"),
                              getattr(b.feature, "type_name", "?"),
                              getattr(b.feature, "rcid", "?")))


# Curve feature types that MAY legitimately run alongside another of their kind
# (cables, routes, tracks, berths, piles…): for these, coincident same-type curves
# are only a fault when their attributes ALSO match (101_0012). Every OTHER curve
# type faults on any same-type coincidence (101_0011). The two lists partition the
# curve features between the two checks.
_COINCIDENCE_ATTR_TYPES = frozenset((
    "Berth", "CableOverhead", "CableSubmarine", "Conveyor",
    "DeepWaterRouteCentreline", "FerryRoute", "MarineFarmCulture",
    "NavigationLine", "Pile", "PipelineSubmarineOnLand",
    "RecommendedRouteCentreline", "RecommendedTrack"))

# 101_0010 never applies to these classes (a point coincident with a same-class
# surface is expected for them — e.g. a landmass point inside its LandArea).
_0010_EXCLUDE = frozenset(("LandArea", "Obstruction", "Wreck"))

# 101_0013 ignores these attributes when comparing a curve to a surface.
_0013_EXCLUDE = ("reportedDate", "scaleMinimum")

# Light classes excluded from 101_1019 (their colour multiplicity is handled by
# the light-sector model, not colourPattern).
_1019_LIGHT_EXCLUDE = frozenset((
    "LightAllAround", "LightSectored", "LightFogDetector",
    "LightAirObstruction"))


def _by_primitive(sp, cell, primitive):
    return [e for e in sp.entries_for(cell) if e.primitive == primitive]


@check("101_0010")
def chk_point_on_same_class_surface(cells, ctx):
    """A point feature is covered by a surface feature of the same class with the
    same attribute values (PS 4.8.1) — a redundant duplicate. Excludes LandArea,
    Obstruction and Wreck (a point-on-surface of those classes is legitimate).
    COVERED_BY is tolerant (boundary-coincident counts); attribution must match
    exactly. Scoped per cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    ops = sp.ops
    from .s101_geom import envelopes_overlap
    for cell in sp.cells_seen():
        surfaces = _by_primitive(sp, cell, "surface")
        for pe in _by_primitive(sp, cell, "point"):
            tn = getattr(pe.feature, "type_name", None)
            if tn is None or tn in _0010_EXCLUDE:
                continue
            psig = _attr_signature(pe.feature)
            for se in surfaces:
                if getattr(se.feature, "type_name", None) != tn:
                    continue
                if not envelopes_overlap(pe.envelope, se.envelope):
                    continue
                if _attr_signature(se.feature) != psig:
                    continue
                if ops.covered_by(pe.geom, se.geom):
                    yield (_loc(cell, pe.feature),
                           "point %s covered by a same-class surface (rcid=%s) "
                           "with identical attributes"
                           % (tn, getattr(se.feature, "rcid", "?")))


def _coincident_curve_pairs(sp, cell, type_filter, need_same_attrs):
    """Yield (a, b) coincident same-type curve entry pairs in a cell, grouped by
    feature type with an envelope pre-filter. ``type_filter(type_name)`` selects
    which curve types to consider; ``need_same_attrs`` additionally requires equal
    attribution."""
    ops = sp.ops
    from .s101_geom import envelopes_overlap
    curves = [e for e in _by_primitive(sp, cell, "curve")
              if type_filter(getattr(e.feature, "type_name", None))]
    by_type = {}
    for e in curves:
        by_type.setdefault(e.feature.type_name, []).append(e)
    for grp in by_type.values():
        sigs = ([_attr_signature(e.feature) for e in grp]
                if need_same_attrs else None)
        for i in range(len(grp)):
            for j in range(i + 1, len(grp)):
                a, b = grp[i], grp[j]
                if need_same_attrs and sigs[i] != sigs[j]:
                    continue
                if not envelopes_overlap(a.envelope, b.envelope):
                    continue
                if ops.coincident(a.geom, b.geom):
                    yield a, b


@check("101_0011")
def chk_coincident_curves(cells, ctx):
    """A curve feature is coincident with another curve feature of the same type
    (PS 4.8.1) — duplicate geometry. Applies to every curve type EXCEPT those that
    may legitimately share a line (see 101_0012). Coincidence = a shared linear
    portion (a shared node alone does not count). Scoped per cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    for cell in sp.cells_seen():
        for a, b in _coincident_curve_pairs(
                sp, cell,
                lambda tn: tn is not None and tn not in _COINCIDENCE_ATTR_TYPES,
                need_same_attrs=False):
            yield (_loc(cell, a.feature),
                   "%s curve coincident with another same-type curve (rcid=%s)"
                   % (a.feature.type_name, getattr(b.feature, "rcid", "?")))


@check("101_0012")
def chk_coincident_curves_same_attrs(cells, ctx):
    """A curve feature of a type that may legitimately run alongside its kind
    (cable, route, track, berth, pile…) is coincident with another of the same
    type AND the same attribute values (PS 4.8.1) — a true duplicate. Scoped per
    cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    for cell in sp.cells_seen():
        for a, b in _coincident_curve_pairs(
                sp, cell,
                lambda tn: tn in _COINCIDENCE_ATTR_TYPES,
                need_same_attrs=True):
            yield (_loc(cell, a.feature),
                   "%s curve coincident with an identically-attributed same-type "
                   "curve (rcid=%s)"
                   % (a.feature.type_name, getattr(b.feature, "rcid", "?")))


@check("101_0013")
def chk_curve_coincident_surface(cells, ctx):
    """A curve feature is coincident with a surface feature of the same class with
    the same attribute values, ignoring reportedDate and scaleMinimum (PS 4.8.1) —
    the same real-world feature encoded redundantly as both a line and an area.
    Scoped per cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    ops = sp.ops
    from .s101_geom import envelopes_overlap
    for cell in sp.cells_seen():
        surfaces = _by_primitive(sp, cell, "surface")
        for ce in _by_primitive(sp, cell, "curve"):
            tn = getattr(ce.feature, "type_name", None)
            if tn is None:
                continue
            csig = _attr_signature(ce.feature, exclude=_0013_EXCLUDE)
            for se in surfaces:
                if getattr(se.feature, "type_name", None) != tn:
                    continue
                if not envelopes_overlap(ce.envelope, se.envelope):
                    continue
                if _attr_signature(se.feature, exclude=_0013_EXCLUDE) != csig:
                    continue
                if ops.coincident(ce.geom, se.geom):
                    yield (_loc(cell, ce.feature),
                           "%s curve coincident with a same-class surface "
                           "(rcid=%s) with identical attributes"
                           % (tn, getattr(se.feature, "rcid", "?")))


@check("101_0017")
def chk_data_limit_masking(cells, ctx):
    """A curve coinciding with the dataset limit is not masked (PS 4.8.3). A curve
    running along the DataCoverage limit must carry MASK MIND = 1 (truncated by the
    coverage limit) so it is not portrayed as a real feature edge. Reports a MASK
    on such a border curve whose MIND is not 1 (e.g. 2 = show). Conservative — only
    curves the encoder explicitly MASK-ed are examined. Per cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    from .s101_geom import data_limit_mask_violations
    for cell in sp.cells_seen():
        for feature, rrid, mind in data_limit_mask_violations(sp, cell):
            yield (_loc(cell, feature),
                   "curve (rrid=%s) coincides with the data limit but MASK MIND=%s "
                   "(must be 1 = truncated by the coverage limit)" % (rrid, mind))


# -- file-level checks (dataset name / size / update) -------------------------
@check("101_0009")
def chk_file_size(cells, ctx):
    """Dataset is larger than the permitted file size — 10 MB (PS 4.5.4)."""
    limit = 10 * 1024 * 1024
    for cell in cells:
        path = getattr(cell, "_path", None)
        if not path:
            continue
        try:
            size = os.path.getsize(path)
        except OSError:
            continue
        if size > limit:
            yield (getattr(cell, "name", "dataset"),
                   "dataset is %.1f MB (exceeds the 10 MB limit)"
                   % (size / (1024.0 * 1024.0)))


# -- discovery-metadata (exchange-set catalogue) check ------------------------
# On valid data the DataCoverage extent equals the discovery boundingBox EXACTLY,
# so a tight tolerance is safe; skipped in loose mode (no catalogue).
_EXTENT_TOL = 1e-5


def _catalog_for(cell, ctx):
    """The exchange-set catalogue governing a cell (parsed once, cached on ctx), or
    ``None`` in loose mode. A test double may inject ``cell.catalogue``."""
    pre = getattr(cell, "catalogue", None)
    if pre is not None:
        return pre
    cache = ctx.setdefault("_catalogs", {})
    if id(cell) in cache:
        return cache[id(cell)]
    cat = None
    path = getattr(cell, "_path", None)
    if path:
        try:
            from . import s158_catalog
            cat = s158_catalog.load_for(path)
        except Exception:                                # noqa: BLE001
            cat = None
    cache[id(cell)] = cat
    return cat


@check("101_0008")
def chk_extent_matches_discovery(cells, ctx):
    """For a base cell, the DataCoverage extent must equal the boundingBox in the
    exchange-set discovery metadata (PS 4.5.3). Compared against the union of the
    cell's DataCoverage envelopes; skipped in loose mode (no catalogue). Per cell."""
    sp = _spatial(cells, ctx)
    if sp is None:
        return
    for cell in sp.cells_seen():
        cat = _catalog_for(cell, ctx)
        if cat is None:
            continue
        dd = cat.for_file(getattr(cell, "_path", "") or getattr(cell, "name", ""))
        if dd is None or dd.bbox is None:
            continue
        covs = [e for e in sp.entries_for(cell)
                if e.primitive == "surface"
                and getattr(e.feature, "type_name", None) == "DataCoverage"]
        if not covs:
            continue
        cell_bbox = (min(e.envelope[0] for e in covs),
                     max(e.envelope[1] for e in covs),
                     min(e.envelope[2] for e in covs),
                     max(e.envelope[3] for e in covs))
        for lbl, disc, val in zip(("west", "east", "south", "north"),
                                  dd.bbox, cell_bbox):
            if abs(disc - val) > _EXTENT_TOL:
                yield (getattr(cell, "name", "dataset"),
                       "DataCoverage %s extent (%.7f) differs from the discovery "
                       "boundingBox (%.7f)" % (lbl, val, disc))
                break


# -- self-contained single-feature attribute checks (1xxx band) ---------------
# Each reads ONE feature's own attributes (no geometry, no cross-feature refs), so
# they are cheap and calibration-clean. They must yield 0 findings on valid data.
@check("101_1001")
def chk_multiplicity_known_false(cells, ctx):
    """multiplicityOfFeatures.numberOfFeatures is known but multiplicityKnown is
    False or absent — a contradiction (PS attribute logic)."""
    for cell, f in _all_features(cells):
        n = _child_value(f, "multiplicityOfFeatures", "numberOfFeatures")
        if n is None:
            continue
        mk = _child_value(f, "multiplicityOfFeatures", "multiplicityKnown")
        if mk is None or _is_false(mk):
            yield (_loc(cell, f),
                   "numberOfFeatures present (%s) but multiplicityKnown is %s"
                   % (n, "absent" if mk is None else mk))


@check("101_1002")
def chk_multiplicity_known_true(cells, ctx):
    """multiplicityOfFeatures.multiplicityKnown is True but numberOfFeatures is
    absent — a contradiction (PS attribute logic)."""
    for cell, f in _all_features(cells):
        mk = _child_value(f, "multiplicityOfFeatures", "multiplicityKnown")
        if mk is not None and _is_true(mk) and \
                _child_value(f, "multiplicityOfFeatures", "numberOfFeatures") is None:
            yield (_loc(cell, f),
                   "multiplicityKnown is True but numberOfFeatures is absent")


@check("101_1018")
def chk_fixed_date_range_empty(cells, ctx):
    """A feature carries fixedDateRange but neither fixedDateRange.dateStart nor
    fixedDateRange.dateEnd has a value."""
    for cell, f in _all_features(cells):
        if not _parent_present(f, "fixedDateRange"):
            continue
        if (_child_value(f, "fixedDateRange", "dateStart") is None
                and _child_value(f, "fixedDateRange", "dateEnd") is None):
            yield (_loc(cell, f),
                   "fixedDateRange present but has neither dateStart nor dateEnd")


@check("101_1019")
def chk_multi_colour_no_pattern(cells, ctx):
    """A feature (other than the Light classes) encodes more than one colour but no
    colourPattern — the pattern is needed to portray the multi-colour mark."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _1019_LIGHT_EXCLUDE:
            continue
        if len(_root_values(f, "colour")) > 1 and not _has_attr(f, "colourPattern"):
            yield (_loc(cell, f),
                   "%d colour values but no colourPattern"
                   % len(_root_values(f, "colour")))


@check("101_1023")
def chk_max_gt_optimum_scale(cells, ctx):
    """DataCoverage maximumDisplayScale is a smaller scale (larger denominator)
    than optimumDisplayScale."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "DataCoverage":
            continue
        vals = _flat_values(f)
        mx = _to_num(vals.get("maximumDisplayScale"))
        op = _to_num(vals.get("optimumDisplayScale"))
        if mx is not None and op is not None and mx > op:
            yield (_loc(cell, f),
                   "maximumDisplayScale (%g) is a smaller scale than "
                   "optimumDisplayScale (%g)" % (mx, op))


@check("101_1027")
def chk_height_sum(cells, ctx):
    """height is not equal to elevation + verticalLength when all three are
    known."""
    for cell, f in _all_features(cells):
        vals = _flat_values(f)
        h = _to_num(vals.get("height"))
        e = _to_num(vals.get("elevation"))
        vl = _to_num(vals.get("verticalLength"))
        if None in (h, e, vl):
            continue
        if abs(h - (e + vl)) > 1e-6:
            yield (_loc(cell, f),
                   "height (%g) != elevation (%g) + verticalLength (%g)"
                   % (h, e, vl))


@check("101_1043")
def chk_intertidal_datums_equal(cells, ctx):
    """An intertidal DepthArea (depthRangeMaximumValue <= 0) has verticalDatum
    equal to soundingDatum."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "DepthArea":
            continue
        vals = _flat_values(f)
        mx = _to_num(vals.get("depthRangeMaximumValue"))
        if mx is None or mx > 0:
            continue
        vd, sd = vals.get("verticalDatum"), vals.get("soundingDatum")
        if vd is not None and sd is not None and str(vd).strip() == str(sd).strip():
            yield (_loc(cell, f),
                   "intertidal DepthArea has verticalDatum == soundingDatum (%s)"
                   % vd)


@check("101_1181")
def chk_gate_vdatum_no_clearance(cells, ctx):
    """A Gate carries verticalDatum but verticalClearanceOpen.verticalClearanceValue
    is not present."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "Gate":
            continue
        if _has_attr(f, "verticalDatum") and \
                _child_value(f, "verticalClearanceOpen",
                             "verticalClearanceValue") is None:
            yield (_loc(cell, f),
                   "Gate has verticalDatum but no verticalClearanceOpen."
                   "verticalClearanceValue")


@check("101_1182")
def chk_gate_clearance_required(cells, ctx):
    """A Gate where verticalClearanceOpen.verticalClearanceUnlimited is False must
    carry verticalClearanceOpen.verticalClearanceValue."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "Gate":
            continue
        unl = _child_value(f, "verticalClearanceOpen", "verticalClearanceUnlimited")
        if unl is not None and _is_false(unl) and \
                _child_value(f, "verticalClearanceOpen",
                             "verticalClearanceValue") is None:
            yield (_loc(cell, f),
                   "Gate verticalClearanceUnlimited is False but "
                   "verticalClearanceValue is absent")


# ── Wave A: AtoN (IALA) colour/shape, wreck/obstruction, light rules ─────────
def _colours(feature):
    """A feature's ordered ROOT colour values as a list of strings."""
    return [str(c).strip() for c in _root_values(feature, "colour")]


def _colour_not(feature, *allowed):
    """True if the feature HAS colour(s) but the ordered sequence is none of the
    allowed sequences (each a tuple of codes). No colour → not reported (a missing
    colour is a different check)."""
    cols = _colours(feature)
    if not cols:
        return False
    allow = {tuple(str(x) for x in seq) for seq in allowed}
    return tuple(cols) not in allow


_ISOL_DANGER = ("IsolatedDangerBuoy", "IsolatedDangerBeacon")
_SAFE_WATER = ("SafeWaterBuoy", "SafeWaterBeacon")
_SPECIAL_GEN = ("SpecialPurposeGeneralBuoy", "SpecialPurposeGeneralBeacon")
_CARDINAL = ("CardinalBuoy", "CardinalBeacon")
_SOUNDING_HAZARD = ("Wreck", "Obstruction", "UnderwaterAwashRock")


@check("101_1341")
def chk_lateral_port_shape(cells, ctx):
    """Port LateralBuoy (categoryOfLateralMark = 1) with a conical or spherical
    buoyShape (1 or 3)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "LateralBuoy":
            continue
        v = _flat_values(f)
        if _eq(v.get("categoryOfLateralMark"), 1) and _eq(v.get("buoyShape"), 1, 3):
            yield (_loc(cell, f), "port LateralBuoy has buoyShape %s (1/3 invalid)"
                   % v.get("buoyShape"))


@check("101_1342")
def chk_lateral_stbd_shape(cells, ctx):
    """Starboard LateralBuoy (categoryOfLateralMark = 2) with a can or spherical
    buoyShape (2 or 3)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "LateralBuoy":
            continue
        v = _flat_values(f)
        if _eq(v.get("categoryOfLateralMark"), 2) and _eq(v.get("buoyShape"), 2, 3):
            yield (_loc(cell, f),
                   "starboard LateralBuoy has buoyShape %s (2/3 invalid)"
                   % v.get("buoyShape"))


@check("101_1344")
def chk_cardinal_n_colour(cells, ctx):
    """North CardinalBuoy/Beacon (categoryOfCardinalMark = 1) colour is not 2,6."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _CARDINAL and \
                _eq(_pv(f, "categoryOfCardinalMark"), 1) and _colour_not(f, (2, 6)):
            yield (_loc(cell, f), "North cardinal colour %s (expected 2,6)"
                   % ",".join(_colours(f)))


@check("101_1345")
def chk_cardinal_e_colour(cells, ctx):
    """East CardinalBuoy/Beacon (categoryOfCardinalMark = 2) colour is not 2,6,2."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _CARDINAL and \
                _eq(_pv(f, "categoryOfCardinalMark"), 2) and _colour_not(f, (2, 6, 2)):
            yield (_loc(cell, f), "East cardinal colour %s (expected 2,6,2)"
                   % ",".join(_colours(f)))


@check("101_1346")
def chk_cardinal_s_colour(cells, ctx):
    """South CardinalBuoy/Beacon (categoryOfCardinalMark = 3) colour is not 6,2."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _CARDINAL and \
                _eq(_pv(f, "categoryOfCardinalMark"), 3) and _colour_not(f, (6, 2)):
            yield (_loc(cell, f), "South cardinal colour %s (expected 6,2)"
                   % ",".join(_colours(f)))


@check("101_1347")
def chk_cardinal_w_colour(cells, ctx):
    """West CardinalBuoy/Beacon (categoryOfCardinalMark = 4) colour is not 6,2,6."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _CARDINAL and \
                _eq(_pv(f, "categoryOfCardinalMark"), 4) and _colour_not(f, (6, 2, 6)):
            yield (_loc(cell, f), "West cardinal colour %s (expected 6,2,6)"
                   % ",".join(_colours(f)))


@check("101_1348")
def chk_isolated_danger_colour(cells, ctx):
    """IsolatedDangerBuoy/Beacon colour is not 2,3 or 3,2."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _ISOL_DANGER and _colour_not(f, (2, 3), (3, 2)):
            yield (_loc(cell, f), "isolated-danger colour %s (expected 2,3/3,2)"
                   % ",".join(_colours(f)))


@check("101_1349")
def chk_safe_water_colour(cells, ctx):
    """SafeWaterBuoy/Beacon colour is not 1,3 or 3,1."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _SAFE_WATER and _colour_not(f, (1, 3), (3, 1)):
            yield (_loc(cell, f), "safe-water colour %s (expected 1,3/3,1)"
                   % ",".join(_colours(f)))


@check("101_1350")
def chk_safe_water_pattern(cells, ctx):
    """SafeWaterBuoy/Beacon colourPattern is present but not 2."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _SAFE_WATER:
            cp = _pv(f, "colourPattern")
            if cp is not None and not _eq(cp, 2):
                yield (_loc(cell, f), "safe-water colourPattern %s (expected 2)" % cp)


@check("101_1352")
def chk_ewmb_shape(cells, ctx):
    """EmergencyWreckMarkingBuoy buoyShape is present but not 4 or 5."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "EmergencyWreckMarkingBuoy":
            bs = _pv(f, "buoyShape")
            if bs is not None and not _eq(bs, 4, 5):
                yield (_loc(cell, f), "emergency-wreck buoyShape %s (expected 4/5)"
                       % bs)


@check("101_1353")
def chk_ewmb_colour(cells, ctx):
    """EmergencyWreckMarkingBuoy colour is not 5,6 or 6,5."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "EmergencyWreckMarkingBuoy" and \
                _colour_not(f, (5, 6), (6, 5)):
            yield (_loc(cell, f), "emergency-wreck colour %s (expected 5,6/6,5)"
                   % ",".join(_colours(f)))


@check("101_1354")
def chk_ewmb_pattern(cells, ctx):
    """EmergencyWreckMarkingBuoy colourPattern is present but not 2."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "EmergencyWreckMarkingBuoy":
            cp = _pv(f, "colourPattern")
            if cp is not None and not _eq(cp, 2):
                yield (_loc(cell, f),
                       "emergency-wreck colourPattern %s (expected 2)" % cp)


@check("101_1229")
def chk_sounding_and_default_clearance(cells, ctx):
    """A Wreck/Obstruction/UnderwaterAwashRock has both valueOfSounding and
    defaultClearanceDepth (only one may be present)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in _SOUNDING_HAZARD and \
                _pv(f, "valueOfSounding") is not None and \
                _pv(f, "defaultClearanceDepth") is not None:
            yield (_loc(cell, f),
                   "both valueOfSounding and defaultClearanceDepth present")


@check("101_1268")
def chk_sounding_and_height(cells, ctx):
    """An Obstruction/Wreck/MarineFarmCulture has both valueOfSounding and height
    (only one may be present)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in ("Obstruction", "Wreck", "MarineFarmCulture") \
                and _pv(f, "valueOfSounding") is not None \
                and _pv(f, "height") is not None:
            yield (_loc(cell, f), "both valueOfSounding and height present")


@check("101_1251")
def chk_wreck_sounding_and_category(cells, ctx):
    """A Wreck has both valueOfSounding and categoryOfWreck (only one may be
    present)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "Wreck" and \
                _pv(f, "valueOfSounding") is not None and \
                _pv(f, "categoryOfWreck") is not None:
            yield (_loc(cell, f), "both valueOfSounding and categoryOfWreck present")


@check("101_1249")
def chk_wreck_height_wle(cells, ctx):
    """A Wreck has height but waterLevelEffect is present and is not 1 or 2."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "Wreck" and _pv(f, "height") is not None:
            wle = _pv(f, "waterLevelEffect")
            if wle is not None and not _eq(wle, 1, 2):
                yield (_loc(cell, f),
                       "height present but waterLevelEffect is %s (not 1/2)" % wle)


@check("101_1266")
def chk_obstruction_height_wle(cells, ctx):
    """An Obstruction has height but waterLevelEffect is present and is not 1 or
    2."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "Obstruction" and _pv(f, "height") is not None:
            wle = _pv(f, "waterLevelEffect")
            if wle is not None and not _eq(wle, 1, 2):
                yield (_loc(cell, f),
                       "height present but waterLevelEffect is %s (not 1/2)" % wle)


@check("101_1263")
def chk_obstruction_wle_needs_height(cells, ctx):
    """An Obstruction with waterLevelEffect 1 or 2 has no height."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "Obstruction" and \
                _eq(_pv(f, "waterLevelEffect"), 1, 2) and _pv(f, "height") is None:
            yield (_loc(cell, f),
                   "waterLevelEffect 1/2 but height not present")


@check("101_1273")
def chk_marinefarm_wle_needs_height(cells, ctx):
    """A MarineFarmCulture with waterLevelEffect 1 or 2 has no height."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "MarineFarmCulture" and \
                _eq(_pv(f, "waterLevelEffect"), 1, 2) and _pv(f, "height") is None:
            yield (_loc(cell, f),
                   "waterLevelEffect 1/2 but height not present")


@check("101_1264")
def chk_obstruction_floating_height(cells, ctx):
    """An Obstruction with waterLevelEffect 7 (floating) has a height (should be
    unknown)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "Obstruction" and \
                _eq(_pv(f, "waterLevelEffect"), 7) and _pv(f, "height") is not None:
            yield (_loc(cell, f), "floating Obstruction has height present")


@check("101_1262")
def chk_obstruction_product_category(cells, ctx):
    """An Obstruction has product but categoryOfObstruction is present and not 2
    (wellhead) or 3 (diffuser)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "Obstruction" and _pv(f, "product") is not None:
            co = _pv(f, "categoryOfObstruction")
            if co is not None and not _eq(co, 2, 3):
                yield (_loc(cell, f),
                       "product present but categoryOfObstruction is %s (not 2/3)"
                       % co)


@check("101_1163")
def chk_landmark_wingless(cells, ctx):
    """A Landmark with condition 4 (wingless) whose categoryOfLandmark is present
    and not 18 (windmill)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "Landmark" and _eq(_pv(f, "condition"), 4):
            cl = _pv(f, "categoryOfLandmark")
            if cl is not None and not _eq(cl, 18):
                yield (_loc(cell, f),
                       "condition wingless but categoryOfLandmark is %s (not 18)"
                       % cl)


@check("101_1340")
def chk_lightsectored_no_sector(cells, ctx):
    """A LightSectored has neither directionalCharacter nor sectorLimit."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "LightSectored" and \
                not _has_attr(f, "directionalCharacter") and \
                not _has_attr(f, "sectorLimit"):
            yield (_loc(cell, f),
                   "LightSectored has neither directionalCharacter nor sectorLimit")


@check("101_1338")
def chk_lightsectored_all_restricted(cells, ctx):
    """A LightSectored whose every sector has lightVisibility 6 (deliberately
    restricted)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "LightSectored":
            continue
        vis = _leaf_values(f, "lightVisibility")
        if vis and all(_eq(v, 6) for v in vis):
            yield (_loc(cell, f),
                   "all %d sectors have lightVisibility 6 (restricted)" % len(vis))


# ── Wave B: dates, value order, conditional presence ─────────────────────────
def _digits(v):
    """Digits-only form of a date value, for lexical ordering (YYYYMMDD…)."""
    return "".join(ch for ch in str(v) if ch.isdigit()) if v is not None else ""


def _multi_codes(feature, name):
    """The set of enumerated codes for a (possibly repeating / comma-joined)
    attribute — so ``status`` = "1,4" or two status instances both read as
    {"1","4"}."""
    out = set()
    for v in _leaf_values(feature, name):
        for tok in str(v).split(","):
            if tok.strip():
                out.add(tok.strip())
    return out


def _complex_instances(feature, parent):
    """One ``{child_name: value}`` dict per ROOT instance of a complex attribute
    (e.g. each featureName), for multi-instance checks."""
    out = []
    for a in getattr(feature, "attributes", None) or []:
        if a.name == parent:
            d = {}
            for c in _walk_attrs(getattr(a, "children", None)):
                if not getattr(c, "children", None) and c.name:
                    d.setdefault(c.name, c.value)
            out.append(d)
    return out


def _date_pair(feature, parent):
    return (_digits(_child_value(feature, parent, "dateStart")),
            _digits(_child_value(feature, parent, "dateEnd")))


@check("101_1383")
def chk_fixed_daterange_order(cells, ctx):
    """fixedDateRange.dateEnd is earlier than or equal to fixedDateRange.dateStart."""
    for cell, f in _all_features(cells):
        s, e = _date_pair(f, "fixedDateRange")
        if s and e and len(s) == len(e) and e <= s:
            yield (_loc(cell, f), "fixedDateRange dateEnd (%s) <= dateStart (%s)"
                   % (e, s))


@check("101_1384")
def chk_survey_daterange_order(cells, ctx):
    """surveyDateRange.dateEnd is earlier than surveyDateRange.dateStart."""
    for cell, f in _all_features(cells):
        s, e = _date_pair(f, "surveyDateRange")
        if s and e and len(s) == len(e) and e < s:
            yield (_loc(cell, f), "surveyDateRange dateEnd (%s) < dateStart (%s)"
                   % (e, s))


@check("101_1385")
def chk_periodic_daterange_equal(cells, ctx):
    """periodicDateRange.dateStart and dateEnd have identical values."""
    for cell, f in _all_features(cells):
        s, e = _date_pair(f, "periodicDateRange")
        if s and e and s == e:
            yield (_loc(cell, f), "periodicDateRange dateStart == dateEnd (%s)" % s)


@check("101_1386")
def chk_periodic_start_no_end(cells, ctx):
    """periodicDateRange.dateStart is known but dateEnd is not."""
    for cell, f in _all_features(cells):
        if _child_value(f, "periodicDateRange", "dateStart") is not None and \
                _child_value(f, "periodicDateRange", "dateEnd") is None:
            yield (_loc(cell, f), "periodicDateRange has dateStart but no dateEnd")


@check("101_1387")
def chk_periodic_end_no_start(cells, ctx):
    """periodicDateRange.dateEnd is known but dateStart is not."""
    for cell, f in _all_features(cells):
        if _child_value(f, "periodicDateRange", "dateEnd") is not None and \
                _child_value(f, "periodicDateRange", "dateStart") is None:
            yield (_loc(cell, f), "periodicDateRange has dateEnd but no dateStart")


@check("101_1392")
def chk_permanent_with_periodic(cells, ctx):
    """A feature with status 1 (permanent) also carries periodicDateRange."""
    for cell, f in _all_features(cells):
        if "1" in _multi_codes(f, "status") and _parent_present(f, "periodicDateRange"):
            yield (_loc(cell, f), "status permanent but periodicDateRange present")


@check("101_1105")
def chk_qos_scale_order(cells, ctx):
    """QualityOfSurvey scaleValueMinimum is not a larger scale (smaller value) than
    scaleValueMaximum."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "QualityOfSurvey":
            continue
        mx = _to_num(_pv(f, "scaleValueMaximum"))
        mn = _to_num(_pv(f, "scaleValueMinimum"))
        if mx is not None and mn is not None and mn <= mx:
            yield (_loc(cell, f),
                   "scaleValueMinimum (%g) <= scaleValueMaximum (%g)" % (mn, mx))


@check("101_1107")
def chk_local_magnetic_small(cells, ctx):
    """LocalMagneticAnomaly with magneticAnomalyValue less than 3."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "LocalMagneticAnomaly":
            continue
        v = _to_num(_pv(f, "magneticAnomalyValue"))
        if v is not None and v < 3:
            yield (_loc(cell, f), "magneticAnomalyValue %g < 3" % v)


@check("101_1202")
def chk_dredged_equal_depths(cells, ctx):
    """DredgedArea depthRangeMaximumValue equals depthRangeMinimumValue."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "DredgedArea":
            continue
        vals = _flat_values(f)
        mx = _to_num(vals.get("depthRangeMaximumValue"))
        mn = _to_num(vals.get("depthRangeMinimumValue"))
        if mx is not None and mn is not None and mx == mn:
            yield (_loc(cell, f),
                   "depthRangeMaximumValue == depthRangeMinimumValue (%g)" % mx)


@check("101_1389")
def chk_depth_range_order(cells, ctx):
    """depthRangeMaximumValue is less than depthRangeMinimumValue."""
    for cell, f in _all_features(cells):
        vals = _flat_values(f)
        mx = _to_num(vals.get("depthRangeMaximumValue"))
        mn = _to_num(vals.get("depthRangeMinimumValue"))
        if mx is not None and mn is not None and mx < mn:
            yield (_loc(cell, f),
                   "depthRangeMaximumValue (%g) < depthRangeMinimumValue (%g)"
                   % (mx, mn))


@check("101_1064")
def chk_qob_hpu_with_depthmin(cells, ctx):
    """QualityOfBathymetricData has both horizontalPositionUncertainty.uncertaintyFixed
    and depthRangeMinimumValue known."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "QualityOfBathymetricData":
            continue
        if _child_value(f, "horizontalPositionUncertainty", "uncertaintyFixed") \
                is not None and _pv(f, "depthRangeMinimumValue") is not None:
            yield (_loc(cell, f),
                   "horizontalPositionUncertainty.uncertaintyFixed and "
                   "depthRangeMinimumValue both present")


@check("101_1095")
def chk_vdatum_no_measure(cells, ctx):
    """A feature (other than VerticalDatumOfData/SoundingDatum) has verticalDatum
    but none of elevation, height or verticalClearanceValue."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in ("VerticalDatumOfData", "SoundingDatum"):
            continue
        if _pv(f, "verticalDatum") is not None and \
                _pv(f, "elevation") is None and _pv(f, "height") is None and \
                _pv(f, "verticalClearanceValue") is None:
            yield (_loc(cell, f),
                   "verticalDatum present but no elevation/height/"
                   "verticalClearanceValue")


@check("101_1156")
def chk_cableoverhead_vdatum(cells, ctx):
    """A CableOverhead has verticalDatum but neither verticalClearanceFixed nor
    verticalClearanceSafe."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "CableOverhead" and \
                _pv(f, "verticalDatum") is not None and \
                not _parent_present(f, "verticalClearanceFixed") and \
                not _parent_present(f, "verticalClearanceSafe"):
            yield (_loc(cell, f),
                   "verticalDatum present but no verticalClearanceFixed/Safe")


@check("101_1158")
def chk_disused_pipeline_overhead(cells, ctx):
    """A disused (status 4) PipelineOverhead still carries categoryOfPipelinePipe or
    product."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "PipelineOverhead" and "4" in _multi_codes(f, "status") \
                and (_pv(f, "categoryOfPipelinePipe") is not None
                     or _pv(f, "product") is not None):
            yield (_loc(cell, f),
                   "disused PipelineOverhead has categoryOfPipelinePipe/product")


@check("101_1275")
def chk_disused_cable_submarine(cells, ctx):
    """A disused (status 4) CableSubmarine still carries categoryOfCable."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "CableSubmarine" and "4" in _multi_codes(f, "status") \
                and _pv(f, "categoryOfCable") is not None:
            yield (_loc(cell, f), "disused CableSubmarine has categoryOfCable")


@check("101_1277")
def chk_disused_pipeline_submarine(cells, ctx):
    """A disused (status 4) PipelineSubmarineOnLand/SubmarinePipelineArea still
    carries categoryOfPipelinePipe."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") in ("PipelineSubmarineOnLand", "SubmarinePipelineArea") \
                and "4" in _multi_codes(f, "status") \
                and _pv(f, "categoryOfPipelinePipe") is not None:
            yield (_loc(cell, f), "disused pipeline has categoryOfPipelinePipe")


@check("101_1169")
def chk_floating_windturbine_height(cells, ctx):
    """A floating offshore WindTurbine (inTheWater True, waterLevelEffect 7) has
    height (should use verticalLength)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "WindTurbine" and \
                _is_true(_pv(f, "inTheWater") or "") and \
                _eq(_pv(f, "waterLevelEffect"), 7) and _pv(f, "height") is not None:
            yield (_loc(cell, f), "floating WindTurbine has height (use verticalLength)")


@check("101_1170")
def chk_fixed_windturbine_length(cells, ctx):
    """A fixed offshore WindTurbine (inTheWater True, waterLevelEffect present and
    not 7) has verticalLength (should use height)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "WindTurbine":
            continue
        wle = _pv(f, "waterLevelEffect")
        if _is_true(_pv(f, "inTheWater") or "") and wle is not None and \
                not _eq(wle, 7) and _pv(f, "verticalLength") is not None:
            yield (_loc(cell, f), "fixed WindTurbine has verticalLength (use height)")


@check("101_1315")
def chk_speed_restriction_no_limit(cells, ctx):
    """A feature with restriction 27 (speed restricted) has no vesselSpeedLimit."""
    for cell, f in _all_features(cells):
        if "27" in _multi_codes(f, "restriction") and \
                _pv(f, "vesselSpeedLimit") is None:
            yield (_loc(cell, f), "restriction speed-restricted but no vesselSpeedLimit")


@check("101_1327")
def chk_disposed_light_multiplicity(cells, ctx):
    """A light with categoryOfLight 19/20 (horizontally/vertically disposed) has no
    multiplicityOfFeatures.numberOfFeatures > 1."""
    for cell, f in _all_features(cells):
        if not str(f.type_name or "").startswith("Light"):
            continue
        if _eq(_pv(f, "categoryOfLight"), 19, 20):
            n = _to_num(_child_value(f, "multiplicityOfFeatures", "numberOfFeatures"))
            if n is None or n <= 1:
                yield (_loc(cell, f),
                       "disposed light without numberOfFeatures > 1")


@check("101_1312")
def chk_cautionarea_info(cells, ctx):
    """A CautionArea has neither information nor pictorialRepresentation."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "CautionArea" and \
                not _parent_present(f, "information") and \
                _pv(f, "pictorialRepresentation") is None:
            yield (_loc(cell, f),
                   "CautionArea has neither information nor pictorialRepresentation")


@check("101_1313")
def chk_informationarea_info(cells, ctx):
    """An InformationArea has neither information nor pictorialRepresentation."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "InformationArea" and \
                not _parent_present(f, "information") and \
                _pv(f, "pictorialRepresentation") is None:
            yield (_loc(cell, f),
                   "InformationArea has neither information nor pictorialRepresentation")


@check("101_1333")
def chk_directional_and_sectorlimit(cells, ctx):
    """A LightSectored has both a directional orientationValue and a sectorLimit."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "LightSectored" and \
                _has_attr(f, "orientationValue") and \
                (_has_attr(f, "sectorLimitOne") or _has_attr(f, "sectorLimitTwo")):
            yield (_loc(cell, f),
                   "orientationValue and sectorLimit both present")


@check("101_1028")
def chk_duplicate_featurename(cells, ctx):
    """A feature has multiple featureName instances with equal name and language."""
    for cell, f in _all_features(cells):
        pairs = [(d.get("name"), d.get("language"))
                 for d in _complex_instances(f, "featureName")]
        pairs = [p for p in pairs if p != (None, None)]
        if len(pairs) != len(set(pairs)):
            yield (_loc(cell, f), "duplicate featureName (same name + language)")


@check("101_1029")
def chk_multiple_default_names(cells, ctx):
    """More than one featureName instance has nameUsage 1 (default display)."""
    for cell, f in _all_features(cells):
        n = sum(1 for d in _complex_instances(f, "featureName")
                if _eq(d.get("nameUsage"), 1))
        if n > 1:
            yield (_loc(cell, f), "%d featureName instances with nameUsage 1" % n)


# ── Wave C: more conditional / lexical single-feature checks ─────────────────
def _decimals(v):
    """Number of decimal places encoded in a numeric string value."""
    s = str(v).strip()
    return len(s.split(".", 1)[1]) if "." in s else 0


@check("101_1100")
def chk_qos_full_coverage(cells, ctx):
    """QualityOfSurvey with measurementDistanceMaximum 0 but fullSeafloorCoverage-
    Achieved not True."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "QualityOfSurvey":
            continue
        mdm = _to_num(_pv(f, "measurementDistanceMaximum"))
        if mdm == 0 and not _is_true(_pv(f, "fullSeafloorCoverageAchieved") or ""):
            yield (_loc(cell, f),
                   "measurementDistanceMaximum 0 but fullSeafloorCoverageAchieved "
                   "not True")


@check("101_1106")
def chk_magnetic_year_format(cells, ctx):
    """MagneticVariation referenceYearForMagneticVariation is not a year-only
    value (more than 4 digits)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "MagneticVariation":
            continue
        v = _pv(f, "referenceYearForMagneticVariation")
        if v is not None and len(_digits(v)) > 4:
            yield (_loc(cell, f),
                   "referenceYearForMagneticVariation %r is not year-only" % v)


@check("101_1151")
def chk_clearance_decimals_small(cells, ctx):
    """A verticalClearanceValue less than 10 has more than one decimal place."""
    for cell, f in _all_features(cells):
        for v in _leaf_values(f, "verticalClearanceValue"):
            n = _to_num(v)
            if n is not None and n < 10 and _decimals(v) > 1:
                yield (_loc(cell, f),
                       "verticalClearanceValue %s < 10 has >1 decimal place" % v)
                break


@check("101_1152")
def chk_clearance_decimals_large(cells, ctx):
    """A verticalClearanceValue >= 10 has one or more decimal places."""
    for cell, f in _all_features(cells):
        for v in _leaf_values(f, "verticalClearanceValue"):
            n = _to_num(v)
            if n is not None and n >= 10 and _decimals(v) > 0:
                yield (_loc(cell, f),
                       "verticalClearanceValue %s >= 10 has a decimal place" % v)
                break


@check("101_1227")
def chk_seabed_surface_unknown(cells, ctx):
    """A SeabedArea surfaceCharacteristics instance has neither natureOfSurface nor
    natureOfSurfaceQualifyingTerms."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "SeabedArea":
            continue
        for d in _complex_instances(f, "surfaceCharacteristics"):
            if not d.get("natureOfSurface") and not d.get("natureOfSurfaceQualifyingTerms"):
                yield (_loc(cell, f),
                       "surfaceCharacteristics with no natureOfSurface/"
                       "natureOfSurfaceQualifyingTerms")
                break


@check("101_1363")
def chk_frequency_pair_empty(cells, ctx):
    """A frequencyPair has neither frequencyShoreStationReceives nor
    frequencyShoreStationTransmits."""
    for cell, f in _all_features(cells):
        if _parent_present(f, "frequencyPair") and \
                _child_value(f, "frequencyPair", "frequencyShoreStationReceives") is None and \
                _child_value(f, "frequencyPair", "frequencyShoreStationTransmits") is None:
            yield (_loc(cell, f), "frequencyPair has neither receive nor transmit")


@check("101_1391")
def chk_single_file_reference(cells, ctx):
    """A pictorialRepresentation or information.fileReference names more than one
    file."""
    for cell, f in _all_features(cells):
        for v in _leaf_values(f, "pictorialRepresentation") + \
                _leaf_values(f, "fileReference"):
            if len([x for x in str(v).replace(";", ",").split(",") if x.strip()]) > 1:
                yield (_loc(cell, f), "attribute names more than one file: %r" % v)
                break


# ── Wave D: orientationValue vs the line's actual bearing ────────────────────
# Reads native line geometry (GDAL-free) and compares the encoded orientationValue
# to the bearing of the line. Per S-158, the TWO-WAY checks (and NavigationLine)
# compare to orientationValue OR its reciprocal (a line digitised in reverse is
# accepted); the ONE-WAY route checks (1286/1293/1295) compare to orientationValue
# directly — a one-way route running against its stated direction IS an error.
# Calibrated against the S-158/V1 orientation test datasets (00AA_00004/00005).


def _geom_builder(cell):
    """A native S101GeometryBuilder for the cell (cached), or None. GDAL-free."""
    gb = getattr(cell, "_geom_builder", None)
    if gb is not None:
        return gb
    recs = getattr(cell, "_records", None)
    if not recs:
        return None
    try:
        from ..s101.geometry import S101GeometryBuilder
    except (ImportError, ValueError):
        from s101.geometry import S101GeometryBuilder
    gb = S101GeometryBuilder(recs)
    try:
        cell._geom_builder = gb
    except Exception:                                    # noqa: BLE001
        pass
    return gb


def _line_coords(cell, feature):
    """The (lon, lat) vertex list of a feature's line geometry, or None. A test
    double may pre-attach ``feature.line_coords``."""
    pre = getattr(feature, "line_coords", None)
    if pre is not None:
        return pre
    gb = _geom_builder(cell)
    if gb is None:
        return None
    g = gb.geometry(feature)
    if g is None or not hasattr(g, "coords"):
        return None
    return g.coords()


def _bearing(lon1, lat1, lon2, lat2):
    """Initial great-circle bearing from point 1 to point 2, in degrees [0,360)."""
    p1, p2 = _math.radians(lat1), _math.radians(lat2)
    dl = _math.radians(lon2 - lon1)
    y = _math.sin(dl) * _math.cos(p2)
    x = _math.cos(p1) * _math.sin(p2) - _math.sin(p1) * _math.cos(p2) * _math.cos(dl)
    return (_math.degrees(_math.atan2(y, x)) + 360.0) % 360.0


def _angle_diff(a, b):
    """Smallest absolute difference between two bearings, in [0,180]."""
    d = abs((a - b) % 360.0)
    return min(d, 360.0 - d)


def _bearing_violation(cell, feature, tol, reciprocal=True):
    """(bearing, orientationValue, delta) if the line's bearing differs from
    orientationValue by more than ``tol`` degrees, else None. When ``reciprocal``
    the reverse direction (orientationValue + 180°) is also accepted (two-way
    routes / NavigationLine); one-way route checks pass ``reciprocal=False`` so a
    route digitised against its stated direction is reported. Requires a known
    orientationValue and a >=2-vertex line."""
    ov = _to_num(_pv(feature, "orientationValue"))
    if ov is None:
        return None
    coords = _line_coords(cell, feature)
    if not coords or len(coords) < 2:
        return None
    (lon1, lat1), (lon2, lat2) = coords[0][:2], coords[-1][:2]
    b = _bearing(lon1, lat1, lon2, lat2)
    d = _angle_diff(b, ov)
    if reciprocal:
        d = min(d, _angle_diff(b, (ov + 180.0) % 360.0))
    return (b, ov, d) if d > tol else None


def _emit_bearing(cell, f, tol, reciprocal=True):
    r = _bearing_violation(cell, f, tol, reciprocal)
    if r:
        return (_loc(cell, f),
                "line bearing %.1f° vs orientationValue %.1f° (%.1f° > %g° tol)"
                % (r[0], r[1], r[2], tol))
    return None


@check("101_1279")
def chk_navline_bearing(cells, ctx):
    """NavigationLine orientationValue does not correspond to the bearing of the
    line (> 5°)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "NavigationLine":
            v = _emit_bearing(cell, f, 5.0)
            if v:
                yield v


@check("101_1286")
def chk_rectrack_bearing_oneway(cells, ctx):
    """RecommendedTrack (one-way trafficFlow 1/2/3) orientationValue does not
    correspond to the bearing of the line (> 3°, direction-sensitive)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "RecommendedTrack" and \
                _eq(_pv(f, "trafficFlow"), 1, 2, 3):
            v = _emit_bearing(cell, f, 3.0, reciprocal=False)
            if v:
                yield v


@check("101_1287")
def chk_rectrack_bearing_twoway(cells, ctx):
    """RecommendedTrack (two-way trafficFlow 4) orientationValue does not
    correspond to the bearing of the line (> 5°)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "RecommendedTrack" and \
                _eq(_pv(f, "trafficFlow"), 4):
            v = _emit_bearing(cell, f, 5.0)
            if v:
                yield v


@check("101_1293")
def chk_recroute_bearing_oneway(cells, ctx):
    """RecommendedRouteCentreline (one-way trafficFlow 1/2/3) orientationValue does
    not correspond to the bearing of the line (> 5°, direction-sensitive)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "RecommendedRouteCentreline" and \
                _eq(_pv(f, "trafficFlow"), 1, 2, 3):
            v = _emit_bearing(cell, f, 5.0, reciprocal=False)
            if v:
                yield v


@check("101_1294")
def chk_recroute_bearing_twoway(cells, ctx):
    """RecommendedRouteCentreline (two-way trafficFlow 4) orientationValue does not
    correspond to the bearing of the line (> 5°)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "RecommendedRouteCentreline" and \
                _eq(_pv(f, "trafficFlow"), 4):
            v = _emit_bearing(cell, f, 5.0)
            if v:
                yield v


@check("101_1295")
def chk_dwroute_bearing_oneway(cells, ctx):
    """DeepWaterRouteCentreline (one-way trafficFlow 1/2/3) orientationValue does
    not correspond to the bearing of the line (> 5°, direction-sensitive)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "DeepWaterRouteCentreline" and \
                _eq(_pv(f, "trafficFlow"), 1, 2, 3):
            v = _emit_bearing(cell, f, 5.0, reciprocal=False)
            if v:
                yield v


@check("101_1296")
def chk_dwroute_bearing_twoway(cells, ctx):
    """DeepWaterRouteCentreline (two-way trafficFlow 4) orientationValue does not
    correspond to the bearing of the line (> 5°)."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") == "DeepWaterRouteCentreline" and \
                _eq(_pv(f, "trafficFlow"), 4):
            v = _emit_bearing(cell, f, 5.0)
            if v:
                yield v


# ── Wave E: feature associations (FASC graph) ────────────────────────────────
# The scaleMinimum-consistency family below is CALIBRATED: 0 findings on the 32
# official IHO S-101_1.2 maritime cells + the 12 S-158/V1 test cells (which DO
# keep one scaleMinimum across aggregation/structure-equipment members), while it
# catches the inland BE/NL cells that assign members different scaleMinimum — a
# genuine deviation from the S-101 rule, not a false positive.
def _cell_by_rcid(cell):
    """{rcid: feature} for a cell (cached), to resolve association targets."""
    m = getattr(cell, "_by_rcid", None)
    if m is None:
        m = {f.rcid: f for f in getattr(cell, "features", None) or []}
        try:
            cell._by_rcid = m
        except Exception:                                # noqa: BLE001
            pass
    return m


def _cell_rev_assoc(cell):
    """{target_rcid: [(assoc_name, role, source_feature)]} — the REVERSE index, so
    a feature can be asked which associations point AT it."""
    m = getattr(cell, "_rev_assoc", None)
    if m is None:
        m = {}
        for f in getattr(cell, "features", None) or []:
            for a in getattr(f, "associations", None) or []:
                m.setdefault(a.target_rcid, []).append(
                    (a.assoc_name, a.role_name, f))
        try:
            cell._rev_assoc = m
        except Exception:                                # noqa: BLE001
            pass
    return m


def _assoc_targets(cell, feature, assoc_name=None):
    """Resolved target FEATURES of a feature's outgoing associations, optionally
    filtered by association name."""
    by = _cell_by_rcid(cell)
    out = []
    for a in getattr(feature, "associations", None) or []:
        if assoc_name and a.assoc_name != assoc_name:
            continue
        if getattr(a, "kind", "feature") == "feature":
            t = by.get(a.target_rcid)
            if t is not None:
                out.append(t)
    return out


# The 7 aggregation associations whose members must share one scaleMinimum.
_AGG_SCALEMIN = {
    "101_1033": "RangeSystemAggregation", "101_1034": "AslAggregation",
    "101_1035": "BridgeAggregation", "101_1036": "DeepWaterRouteAggregation",
    "101_1037": "FairwayAggregation", "101_1038": "TwoWayRouteAggregation",
    "101_1039": "TrafficSeparationSchemeAggregation",
}


def _make_agg_scalemin(assoc_name):
    def _chk(cells, ctx):
        for cell, f in _all_features(cells):
            comps = _assoc_targets(cell, f, assoc_name)
            if len(comps) < 2:
                continue
            sms = {str(_pv(c, "scaleMinimum")) for c in comps
                   if _pv(c, "scaleMinimum") is not None}
            if len(sms) > 1:
                yield (_loc(cell, f),
                       "%s members have differing scaleMinimum: %s"
                       % (assoc_name, sorted(sms)))
    _chk.__doc__ = ("Members of a %s have different scaleMinimum values."
                    % assoc_name)
    return _chk


for _cid, _an in _AGG_SCALEMIN.items():
    check(_cid)(_make_agg_scalemin(_an))


@check("101_1032")
def chk_structure_equipment_scalemin(cells, ctx):
    """An equipment feature in a StructureEquipment association has a scaleMinimum
    different from its structure feature's scaleMinimum."""
    for cell, f in _all_features(cells):
        eqs = _assoc_targets(cell, f, "StructureEquipment")
        if not eqs:
            continue
        ssm = _pv(f, "scaleMinimum")
        if ssm is None:
            continue
        for e in eqs:
            esm = _pv(e, "scaleMinimum")
            if esm is not None and str(esm) != str(ssm):
                yield (_loc(cell, f),
                       "equipment scaleMinimum %s != structure scaleMinimum %s"
                       % (esm, ssm))
                break


@check("101_1320")
def chk_equipment_multiple_structures(cells, ctx):
    """An equipment feature is referenced by more than one structure feature via
    StructureEquipment."""
    for cell, f in _all_features(cells):
        srcs = {id(src) for (an, _role, src)
                in _cell_rev_assoc(cell).get(getattr(f, "rcid", None), [])
                if an == "StructureEquipment"}
        if len(srcs) > 1:
            yield (_loc(cell, f),
                   "equipment referenced by %d structure features" % len(srcs))


# ── Wave F: checks RESTORED after official-oracle calibration ────────────────
# These four fired on the inland BE/NL cells (so were provisionally dropped during
# Waves A/B) but are CLEAN on the 32 official IHO S-101_1.2 maritime cells + the 12
# S-158/V1 test cells — i.e. the inland firings are genuine S-101 deviations, not
# false positives. (The sibling drops 1316/1325/1351/1381/1382 stayed dropped: they
# also fire on the conformant oracle, confirming those rules don't hold as written.)
_LIGHT_CLASSES = ("LightAllAround", "LightSectored", "LightFogDetector",
                  "LightAirObstruction")


def _ipop(inst, name):
    """A populated value from a _complex_instances() dict, or None."""
    v = inst.get(name)
    return v if (v is not None and str(v).strip() != "") else None


def _rhythm_instances(feature):
    """One dict per rhythmOfLight instance (falls back to root-level light attrs
    if the light characteristic is not modelled under a rhythmOfLight complex)."""
    inst = _complex_instances(feature, "rhythmOfLight")
    if inst:
        return inst
    return [{"lightCharacteristic": _pv(feature, "lightCharacteristic"),
             "signalGroup": _pv(feature, "signalGroup"),
             "signalPeriod": _pv(feature, "signalPeriod"),
             "signalSequence": _pv(feature, "signalSequence")}]


@check("101_1030")
def chk_featurename_default_language(cells, ctx):
    """A feature has an alternate-language featureName (nameUsage = 2) but no
    default-language one (nameUsage = 1)."""
    for cell, f in _all_features(cells):
        usages = [str(_ipop(d, "nameUsage")) for d in
                  _complex_instances(f, "featureName") if _ipop(d, "nameUsage")]
        if "2" in usages and "1" not in usages:
            yield (_loc(cell, f),
                   "featureName has nameUsage=2 but none with nameUsage=1 "
                   "(no default-language name)")


@check("101_1150")
def chk_opening_bridge_category(cells, ctx):
    """A Bridge with openingBridge = True but no categoryOfOpeningBridge."""
    for cell, f in _all_features(cells):
        if ((f.type_name or "") == "Bridge"
                and _is_true(_pv(f, "openingBridge") or "")
                and not _has_attr(f, "categoryOfOpeningBridge")):
            yield (_loc(cell, f),
                   "openingBridge is True but categoryOfOpeningBridge is absent")


@check("101_1323")
def chk_fixed_light_signal_attrs(cells, ctx):
    """A fixed light (lightCharacteristic = 1) carrying signalGroup, signalPeriod
    or signalSequence — attributes that only apply to rhythmic lights."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") not in _LIGHT_CLASSES:
            continue
        for d in _rhythm_instances(f):
            if _eq(_ipop(d, "lightCharacteristic"), 1) and (
                    _ipop(d, "signalGroup") or _ipop(d, "signalPeriod")
                    or _ipop(d, "signalSequence")):
                yield (_loc(cell, f),
                       "fixed light (lightCharacteristic=1) has "
                       "signalGroup/signalPeriod/signalSequence populated")
                break


@check("101_1359")
def chk_mrcc_flag(cells, ctx):
    """A CoastGuardStation whose featureName contains "MRCC" but isMRCC is not
    True."""
    for cell, f in _all_features(cells):
        if (f.type_name or "") != "CoastGuardStation":
            continue
        names = [str(d.get("name") or "")
                 for d in _complex_instances(f, "featureName")]
        if (any("MRCC" in n.upper() for n in names)
                and not _is_true(_pv(f, "isMRCC") or "")):
            yield (_loc(cell, f),
                   'featureName contains "MRCC" but isMRCC is not True')


# -- runner -------------------------------------------------------------------
def run(cell_paths, source=None, only=None):
    """Validate S-101 cells against the S-158-101 catalogue with the native
    engine. Returns a :class:`validation.s100.S100Report` (product '101').
    ``only`` optionally restricts to a subset of catalogue check ids."""
    source = source or (cell_paths[0] if cell_paths else "")
    report = s100.S100Report("101", source)
    try:
        cells = read_cells(cell_paths)
    except Exception as exc:                             # noqa: BLE001
        report.error = "cannot read S-101 cells natively: %s" % exc
        return report
    report.cells = cells
    idx = s100.catalogue_index("101")
    ctx = {"product": "101"}
    for cid, fn in CHECKS_101:
        rec = idx.get(cid)
        if rec is None:                                  # cite mismatch — skip
            continue
        if only is not None and cid not in only:         # user deselected it
            continue
        report.ran_ids.add(cid)
        try:
            for location, detail in fn(cells, ctx):
                report.findings.append(
                    s100.S100Finding(rec, "101", location, detail))
        except Exception:                                # noqa: BLE001
            continue
    order = {"C": 0, "E": 1, "W": 2}
    report.findings.sort(key=lambda f: (order.get(f.severity, 3), f.check_id))
    return report


def coverage():
    """(implemented, total) executable-check coverage for S-158-101."""
    total = len(s100.catalogue("101"))
    impl = len({cid for cid, _ in CHECKS_101})
    return impl, total


# ── S-158-100: the Universal Model core checks, run on native S-101 cells ─────
# The S-158-100 catalogue is product-neutral S-100 Part-1/5/10a conformance. The
# checks that apply to a VECTOR product (feature/attribute/association binding vs
# the Feature Catalogue) run here on the native S-101 model; the HDF5-only core
# checks are future work. They report under product code '100' so they show as a
# distinct "S-100 core" section alongside the S-158-101 findings.
CHECKS_100 = []


def core_check(cid):
    """Register a predicate as the implementation of one S-158-100 catalogue check."""
    def _wrap(fn):
        CHECKS_100.append((cid, fn))
        return fn
    return _wrap


_FC_EDITIONS = None


def _fc_all_editions():
    """Every bundled Feature Catalogue edition (cached). Used by the binding
    checks so an attribute permitted in ANY edition is accepted — the S-101 cells
    we see are conversions that straddle FC editions, so a single-edition test
    false-positives (see the memory note)."""
    global _FC_EDITIONS
    if _FC_EDITIONS is None:
        try:
            from . import s101_fc
        except (ImportError, ValueError):
            import s101_fc
        vers = ("1.1.0", "2.1.0")
        _FC_EDITIONS = [fc for fc in (s101_fc.catalogue(version=v) for v in vers)
                        if fc is not None]
    return _FC_EDITIONS


@core_check("100_0001")
def chk_element_not_in_fc(cells, ctx):
    """A feature carries a root attribute that no bundled Feature Catalogue edition
    permits on its type — an element not described in the Feature Catalogue
    (S-100 Part 5). Union-of-editions: an attribute valid in EITHER FC edition is
    accepted, which avoids false positives on cells that straddle editions and
    conservatively under-reports edition-ambiguous cases."""
    editions = _fc_all_editions()
    if not editions:
        return
    for cell, f in _all_features(cells):
        tname = getattr(f, "type_name", None) or ""
        binding_sets = [set(ft.bindings) for ft in
                        (fc.type_for(tname) for fc in editions) if ft is not None]
        if not binding_sets:                             # type unknown to every FC
            continue
        permitted = set().union(*binding_sets)
        for a in getattr(f, "attributes", None) or []:
            nm = getattr(a, "name", None)
            if nm and nm not in permitted:
                yield (_loc(cell, f),
                       "%s: attribute '%s' is not described in the Feature "
                       "Catalogue" % (tname, nm))


def run_core(cell_paths, source=None, only=None):
    """Validate S-101 cells against the S-158-100 (Universal Model) core catalogue
    with the native engine. Returns a :class:`validation.s100.S100Report`
    (product '100'). ``only`` optionally restricts to a subset of check ids."""
    source = source or (cell_paths[0] if cell_paths else "")
    report = s100.S100Report("100", source)
    try:
        cells = read_cells(cell_paths)
    except Exception as exc:                             # noqa: BLE001
        report.error = "cannot read S-101 cells natively: %s" % exc
        return report
    report.cells = cells
    idx = s100.catalogue_index("100")
    ctx = {"product": "100"}
    for cid, fn in CHECKS_100:
        rec = idx.get(cid)
        if rec is None:                                  # cite mismatch — skip
            continue
        if only is not None and cid not in only:
            continue
        report.ran_ids.add(cid)
        try:
            for location, detail in fn(cells, ctx):
                report.findings.append(
                    s100.S100Finding(rec, "100", location, detail))
        except Exception:                                # noqa: BLE001
            continue
    order = {"C": 0, "E": 1, "W": 2}
    report.findings.sort(key=lambda f: (order.get(f.severity, 3), f.check_id))
    return report


def core_coverage():
    """(implemented, total) executable-check coverage for the S-158-100 core
    catalogue (only the vector-applicable checks are run on S-101 today)."""
    total = len(s100.catalogue("100"))
    impl = len({cid for cid, _ in CHECKS_100})
    return impl, total
