# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/checks_records.py
----------------------------
Record-level S-57 **data-structure** checks — the tranche unlocked by the native
record model (``s57/``). They read ``dataset.s57`` (an ``S57RecordSet`` of parsed
cells) rather than the GDAL feature view, so they can validate things the
feature view hides: RCNM/RCID validity, record-level FOID identity, OBJL/ATTL
codes, update-instruction and primitive domains, and FSPT/VRPT pointer
integrity.

Each check no-ops gracefully when no record model is attached (e.g. headless
feature-only runs), so the engine stays usable without the reader.

Citations: these encode rules from S-57 Part 3 / ENC Product Specification
(App B.1) — value domains and mandatory-subfield rules, in our own words. The
``number`` is a *provisional* citation token (``S57.*``); the stable identity is
the plain-language ``name``. Map these to the authoritative S-58 8.0.0 numbers
(the 1–39 data-structure family) once that list is available — the names won't
change when the numbers are filled in.
"""

from .engine import check, Severity, Finding
try:                                                    # plugin context
    from ..s57 import RCNM_FE, RCNM_VF, RCNM_VI, RCNM_VC
except (ImportError, ValueError):                       # headless / top-level
    from s57 import RCNM_FE, RCNM_VF, RCNM_VI, RCNM_VC

RCID_MAX = 2 ** 32 - 2
_VALID_RUIN = {1, 2, 3}
_VALID_PRIM = {1, 2, 3, 255}        # point / line / area / none
_DSID_MANDATORY = ("DSNM", "EDTN", "ISDT", "AGEN")


def _cells(ds):
    s = getattr(ds, "s57", None)
    return list(s) if s else []


# ---------------------------------------------------------------------------
# Readability / dataset records
# ---------------------------------------------------------------------------
@check("S57.READ", Severity.CRITICAL, "Data structure",
       "Cell could not be read as a conforming ISO 8211 / S-57 file.",
       "Confirm the file is a valid S-57 base cell (.000).",
       "ISO/IEC 8211; S-57 Part 3",
       name="data-structure/cell-not-readable-as-iso-8211")
def cell_readable(ds, cat, geom):
    for cell in _cells(ds):
        if cell.parse_error:
            yield Finding(_meta("S57.READ"), cell.name, "", None,
                          detail=cell.parse_error)


@check("S57.DSID", Severity.CRITICAL, "Data structure",
       "Data Set Identification (DSID) is missing or lacks a mandatory subfield.",
       "Encode a complete DSID record.",
       "ENC PS 6.3.2.1",
       name="data-structure/dataset-identification-incomplete")
def dsid_complete(ds, cat, geom):
    for cell in _cells(ds):
        if cell.parse_error:
            continue
        if not cell.dsid:
            yield Finding(_meta("S57.DSID"), cell.name, "DSID", None,
                          detail="DSID record absent")
            continue
        for sub in _DSID_MANDATORY:
            val = cell.dsid.get(sub)
            if val is None or (isinstance(val, str) and val.strip() == ""):
                yield Finding(_meta("S57.DSID"), cell.name, "DSID", None,
                              detail=f"{sub} not populated")


@check("S57.DSPM", Severity.CRITICAL, "Data structure",
       "Data Set Parameter (DSPM) is missing or a multiplication factor is "
       "zero/absent.",
       "Encode DSPM with non-zero COMF and SOMF.",
       "ENC PS 6.3.2.3; S-57 4.4",
       name="data-structure/coordinate-multiplication-factor-invalid")
def dspm_factors(ds, cat, geom):
    for cell in _cells(ds):
        if cell.parse_error:
            continue
        if not cell.dspm:
            yield Finding(_meta("S57.DSPM"), cell.name, "DSPM", None,
                          detail="DSPM record absent")
            continue
        for sub in ("COMF", "SOMF"):
            v = cell.dspm.get(sub)
            try:
                ok = v is not None and float(v) != 0
            except (TypeError, ValueError):
                ok = False
            if not ok:
                yield Finding(_meta("S57.DSPM"), cell.name, "DSPM", None,
                              detail=f"{sub}={v!r}")


@check("S57.PROFILE", Severity.ERROR, "ENC product specification",
       "Data Set Parameter is not per the ENC profile (datum/units).",
       "Use WGS84 (HDAT=2) and metres/lat-long units per the ENC profile.",
       "ENC PS 6.3.2.3",
       name="enc-product-specification/dataset-parameter-not-per-enc-profile")
def dspm_profile(ds, cat, geom):
    expect = {"HDAT": 2, "DUNI": 1, "HUNI": 1, "PUNI": 1, "COUN": 1}
    for cell in _cells(ds):
        if cell.parse_error or not cell.dspm:
            continue
        for sub, want in expect.items():
            v = cell.dspm.get(sub)
            if v is None:
                continue                       # absence handled elsewhere
            try:
                got = int(v)
            except (TypeError, ValueError):
                got = v
            if got != want:
                yield Finding(_meta("S57.PROFILE"), cell.name, "DSPM", None,
                              detail=f"{sub}={got} (expected {want})")


@check("1549", Severity.CRITICAL, "Data structure",
       "CSCL is not populated with a value.",
       "Populate the DSPM CSCL subfield with the compilation scale.",
       "S-57 Part 3 (DSPM); ENC PS",
       name="data-structure/compilation-scale-not-populated")
def cscl_populated(ds, cat, geom):
    """S-58 #1549: the DSPM CSCL (compilation scale) subfield must carry a value.
    A valid compilation scale is a positive integer; the S-57 numeric "Null" for
    CSCL is encoded as 0 (and an absent/blank value is likewise unpopulated), so
    both 0 and None are flagged as "CSCL is Null"."""
    for cell in _cells(ds):
        if cell.parse_error or not cell.dspm:
            continue
        v = cell.dspm.get("CSCL")
        try:
            populated = v is not None and int(v) > 0
        except (TypeError, ValueError):
            populated = bool(str(v).strip())
        if not populated:
            yield Finding(_meta("1549"), cell.name, "DSPM", None,
                          detail=f"CSCL is Null ({v!r})")


@check("575", Severity.CRITICAL, "Data structure",
       "DSTR does not equal 2.",
       "Encode the dataset using chain-node topology (DSSI DSTR = 2).",
       "S-57 Part 3 (DSSI); ENC PS chain-node",
       name="data-structure/data-structure-not-chain-node")
def dstr_chain_node(ds, cat, geom):
    """S-58 #575: the DSSI DSTR (data-structure) subfield must be 2 (chain node);
    ENC uses chain-node topology exclusively."""
    for cell in _cells(ds):
        if cell.parse_error or not cell.dssi:
            continue
        v = cell.dssi.get("DSTR")
        if v is None:
            continue                       # absence is handled by DSSI checks
        try:
            got = int(v)
        except (TypeError, ValueError):
            got = v
        if got != 2:
            yield Finding(_meta("575"), cell.name, "DSSI", None,
                          detail=f"DSTR={got}")


@check("1523b", Severity.CRITICAL, "Data structure",
       "The ISDT of a base cell file precedes the UADT.",
       "For a base cell, set ISDT (issue date) on or after UADT (update "
       "application date).",
       "S-57 Part 3 (DSID); ENC PS",
       name="data-structure/base-cell-issue-date-precedes-update-date")
def base_cell_isdt_before_uadt(ds, cat, geom):
    """S-58 #1523b: for a base cell (file extension .000), the DSID ISDT (issue
    date) must not be earlier than UADT (update application date). Dates are
    YYYYMMDD strings, which order correctly lexically. DSNM is the declared file
    name (carries the extension)."""
    for cell in _cells(ds):
        if cell.parse_error or not cell.dsid:
            continue
        if not str(cell.dsid.get("DSNM") or "").lower().endswith(".000"):
            continue
        isdt, uadt = cell.dsid.get("ISDT"), cell.dsid.get("UADT")
        if isdt and uadt and str(isdt) < str(uadt):
            yield Finding(_meta("1523b"), cell.name, "DSID", None,
                          detail=f"ISDT={isdt} < UADT={uadt}")


# ---------------------------------------------------------------------------
# Record identity (RCNM / RCID)
# ---------------------------------------------------------------------------
@check("S57.FRID.RCNM", Severity.CRITICAL, "Data structure",
       "Feature record name (FRID.RCNM) is not the feature code (100).",
       "Set FRID.RCNM to 100 for feature records.",
       "ENC PS 6.3.2.9",
       name="data-structure/feature-record-name-invalid")
def frid_rcnm(ds, cat, geom):
    for cell in _cells(ds):
        for fr in cell.features:
            if fr.rcnm not in (None, RCNM_FE):
                yield Finding(_meta("S57.FRID.RCNM"), cell.name, "FRID", fr.rcid,
                              detail=f"RCNM={fr.rcnm}")


@check("S57.VRID.RCNM", Severity.CRITICAL, "Data structure",
       "Vector record name (VRID.RCNM) is not a valid code (110/120/130).",
       "Use 110 (isolated node), 120 (connected node) or 130 (edge).",
       "ENC PS 6.3.2.4",
       name="data-structure/vector-record-name-invalid")
def vrid_rcnm(ds, cat, geom):
    valid = {RCNM_VI, RCNM_VC, 130}
    for cell in _cells(ds):
        for (rcnm, rcid), _v in cell.vectors.items():
            if rcnm not in valid:
                yield Finding(_meta("S57.VRID.RCNM"), cell.name, "VRID", rcid,
                              detail=f"RCNM={rcnm}")


@check("S57.NOFA", Severity.ERROR, "ENC product specification",
       "Face records are present, which are not permitted in the chain-node "
       "structure used by ENC.",
       "Remove face (VF) records; ENC uses chain-node topology.",
       "ENC PS 6.3.2.2 (NOFA=0)",
       name="enc-product-specification/face-records-not-permitted")
def no_faces(ds, cat, geom):
    for cell in _cells(ds):
        for (rcnm, rcid), _v in cell.vectors.items():
            if rcnm == RCNM_VF:
                yield Finding(_meta("S57.NOFA"), cell.name, "VRID", rcid,
                              detail="face (VF) record")


@check("S57.RCID.RANGE", Severity.CRITICAL, "Data structure",
       "Record identifier (RCID) is missing or out of the valid range.",
       "Use a positive RCID within the 32-bit range.",
       "S-57 Part 3 (RCID)",
       name="data-structure/record-identifier-out-of-range")
def rcid_range(ds, cat, geom):
    for cell in _cells(ds):
        for fr in cell.features:
            if fr.rcid is None or fr.rcid <= 0 or fr.rcid > RCID_MAX:
                yield Finding(_meta("S57.RCID.RANGE"), cell.name, "FRID",
                              fr.rcid, detail=f"RCID={fr.rcid}")
        for (rcnm, rcid), _v in cell.vectors.items():
            if rcid is None or rcid <= 0 or rcid > RCID_MAX:
                yield Finding(_meta("S57.RCID.RANGE"), cell.name, "VRID",
                              rcid, detail=f"RCID={rcid}")


@check("S57.RCID.DUP", Severity.CRITICAL, "Data structure",
       "Duplicate record identifier (RCID) within a record type.",
       "Ensure RCIDs are unique within each record type.",
       "S-57 Part 3 (RCID)",
       name="data-structure/duplicate-record-identifier")
def rcid_dup(ds, cat, geom):
    for cell in _cells(ds):
        seen = set()
        for fr in cell.features:
            if fr.rcid in seen:
                yield Finding(_meta("S57.RCID.DUP"), cell.name, "FRID", fr.rcid,
                              detail=f"feature RCID={fr.rcid}")
            seen.add(fr.rcid)
        for key in cell.vector_dups:
            yield Finding(_meta("S57.RCID.DUP"), cell.name, "VRID", key[1],
                          detail=f"vector RCNM/RCID={key}")


@check("S57.FOID", Severity.CRITICAL, "Data structure",
       "Feature object identifier (FOID) is missing or incomplete.",
       "Ensure every feature carries a complete AGEN/FIDN/FIDS.",
       "ENC PS 6.3.2.10",
       name="data-structure/feature-object-identifier-missing-or-incomplete")
def foid_present(ds, cat, geom):
    for cell in _cells(ds):
        for fr in cell.features:
            if fr.foid is None or any(p is None for p in fr.foid):
                yield Finding(_meta("S57.FOID"), cell.name, "FOID", fr.rcid,
                              detail="FOID missing/incomplete")


@check(503, Severity.WARNING, "ENC product specification",
       "Duplicate FOID exists within the dataset.",
       "Ensure that no duplicate FOIDs exist.",
       "ENC PS 6.3.2.10; S-58 503",
       name="enc-product-specification/duplicate-foid-exists-within-the-dataset")
def foid_duplicate(ds, cat, geom):
    """FOID uniqueness, read from the native record model. Each feature is one
    record with one FOID, so soundings (split into many points by GDAL) are not
    spuriously flagged — this is the native replacement for the old GDAL-path
    #503, which counted SOUNDG multipoint parts as duplicates."""
    for cell in _cells(ds):
        seen = {}
        for fr in cell.features:
            if fr.foid is None or any(p is None for p in fr.foid):
                continue                       # presence handled by S57.FOID
            if fr.foid in seen:
                yield Finding(_meta(503), cell.name, "FOID", fr.rcid,
                              detail=f"FOID {fr.foid} duplicates rcid "
                              f"{seen[fr.foid]}")
            else:
                seen[fr.foid] = fr.rcid


def _foid_groups(ds):
    """{foid: [(cell_name, feature), ...]} across all cells (complete FOIDs only).
    Members sharing one FOID are the same real-world object — e.g. continued
    across adjacent cells — so #560a/#560b check they are encoded consistently."""
    groups = {}
    for cell in _cells(ds):
        for fr in cell.features:
            if fr.foid is None or any(p is None for p in fr.foid):
                continue
            groups.setdefault(fr.foid, []).append((cell.name, fr))
    return groups


@check("560a", Severity.CRITICAL, "ENC product specification",
       "Objects with the same FOID do not have the same feature encoding.",
       "Ensure all objects sharing a FOID have identical object class and "
       "attribute values.",
       "ENC PS; S-58 560a",
       name="enc-product-specification/same-foid-inconsistent-encoding")
def same_foid_encoding(ds, cat, geom):
    """S-58 #560a: feature objects sharing a FOID must have identical object class
    and attribute values."""
    for foid, members in _foid_groups(ds).items():
        if len(members) < 2:
            continue
        _rc, ref = members[0]
        for cname, fr in members[1:]:
            if fr.objl != ref.objl or (fr.attrs or {}) != (ref.attrs or {}):
                yield Finding(_meta("560a"), cname, "FRID", fr.rcid,
                              detail=f"FOID {foid} differs from rcid {ref.rcid}")


@check("560b", Severity.CRITICAL, "ENC product specification",
       "Objects with the same FOID are of geometric primitive point or have "
       "different geometric primitives.",
       "Ensure objects sharing a FOID are non-point and of the same geometric "
       "primitive.",
       "ENC PS; S-58 560b",
       name="enc-product-specification/same-foid-point-or-mixed-primitive")
def same_foid_primitive(ds, cat, geom):
    """S-58 #560b: objects sharing a FOID must be of the same geometric primitive
    and must not be points (a point object lives at one place in one cell, so a
    shared-FOID point — or a primitive mismatch — is an encoding error). PRIM 1
    is point."""
    for foid, members in _foid_groups(ds).items():
        if len(members) < 2:
            continue
        prims = {fr.prim for _c, fr in members}
        if 1 in prims or len(prims) > 1:
            cname, fr = members[0]
            yield Finding(_meta("560b"), cname, "FRID", fr.rcid,
                          detail=f"FOID {foid} prims="
                          f"{sorted(p for p in prims if p is not None)}")


# ---------------------------------------------------------------------------
# Topology (node/edge references) — S-58 #20b / #22 / #84a / #84b
# ---------------------------------------------------------------------------
_RCNM_VE = 130                      # edge vector record


def _edge_referenced_nodes(cell):
    """Set of (rcnm, rcid) node keys referenced by any edge's VRPT."""
    refs = set()
    for (rcnm, _rcid), v in cell.vectors.items():
        if rcnm == _RCNM_VE:
            for p in v.vrpt:
                refs.add((p[0], p[1]))
    return refs


@check("84a", Severity.CRITICAL, "Data structure",
       "Isolated node marked as connected.",
       "Re-encode the node as isolated (VI) or reference it from an edge.",
       "S-57 Part 3 (chain-node topology)",
       name="data-structure/isolated-node-marked-connected")
def isolated_node_marked_connected(ds, cat, geom):
    """S-58 #84a: a connected node (VC) that no edge references is physically
    isolated, yet it is marked connected."""
    for cell in _cells(ds):
        if cell.parse_error:
            continue
        ref = _edge_referenced_nodes(cell)
        for (rcnm, rcid) in cell.vectors:
            if rcnm == RCNM_VC and (rcnm, rcid) not in ref:
                yield Finding(_meta("84a"), cell.name, "VRID", rcid,
                              detail="connected node not referenced by any edge")


@check("84b", Severity.CRITICAL, "Data structure",
       "Connected node marked as isolated.",
       "Re-encode the node as connected (VC).",
       "S-57 Part 3 (chain-node topology)",
       name="data-structure/connected-node-marked-isolated")
def connected_node_marked_isolated(ds, cat, geom):
    """S-58 #84b: an isolated node (VI) that an edge references is physically
    connected, yet it is marked isolated."""
    for cell in _cells(ds):
        if cell.parse_error:
            continue
        ref = _edge_referenced_nodes(cell)
        for (rcnm, rcid) in cell.vectors:
            if rcnm == RCNM_VI and (rcnm, rcid) in ref:
                yield Finding(_meta("84b"), cell.name, "VRID", rcid,
                              detail="isolated node referenced by an edge")


@check("22", Severity.CRITICAL, "Data structure",
       "Beginning and end nodes are not in the correct sequence.",
       "Reference the edge's begin node (TOPI=1) before its end node (TOPI=2).",
       "S-57 Part 3 (edge VRPT)",
       name="data-structure/edge-end-node-before-beginning")
def edge_node_sequence(ds, cat, geom):
    """S-58 #22: in an edge's VRPT the begin node (TOPI=1) must be referenced
    before the end node (TOPI=2). VRPT tuple is (rcnm, rcid, ornt, usag, topi,
    mask), so TOPI is index 4."""
    for cell in _cells(ds):
        if cell.parse_error:
            continue
        for (rcnm, rcid), v in cell.vectors.items():
            if rcnm != _RCNM_VE:
                continue
            begin_i = end_i = None
            for idx, p in enumerate(v.vrpt):
                topi = p[4] if len(p) > 4 else None
                if topi == 1 and begin_i is None:
                    begin_i = idx
                elif topi == 2 and end_i is None:
                    end_i = idx
            if begin_i is not None and end_i is not None and end_i < begin_i:
                yield Finding(_meta("22"), cell.name, "VRID", rcid,
                              detail="end node (TOPI=2) referenced before begin")


@check("20b", Severity.CRITICAL, "Data structure",
       "Orphaned geometry.",
       "Remove the unreferenced spatial record, or reference it from a feature.",
       "S-57 Part 3 (FSPT/VRPT)",
       name="data-structure/orphaned-spatial-record")
def orphaned_geometry(ds, cat, geom):
    """S-58 #20b: a spatial (vector) record reachable from no feature. A feature
    references edges/isolated nodes via FSPT; an edge in turn references its
    connected nodes via VRPT. A vector reached by neither path is orphaned."""
    for cell in _cells(ds):
        if cell.parse_error:
            continue
        reached = set()
        for fr in cell.features:
            for ptr in fr.fspt:
                key = (ptr[0], ptr[1])
                reached.add(key)
                if ptr[0] == _RCNM_VE:
                    edge = cell.vectors.get(key)
                    if edge:
                        for p in edge.vrpt:
                            reached.add((p[0], p[1]))
        for key in cell.vectors:
            if key not in reached:
                yield Finding(_meta("20b"), cell.name, "VRID", key[1],
                              detail="spatial record not referenced by any feature")


# ---------------------------------------------------------------------------
# Catalogue conformance at record level
# ---------------------------------------------------------------------------
@check("S57.OBJL", Severity.ERROR, "Object catalogue",
       "Feature object label (OBJL) is not a known object class code.",
       "Use an object class code from the catalogue.",
       "S-57 Appendix A",
       name="object-catalogue/unknown-object-class-code")
def objl_known(ds, cat, geom):
    valid = set(getattr(cat, "code_to_acronym", {}))
    if not valid:
        return
    for cell in _cells(ds):
        for fr in cell.features:
            if fr.objl is None or fr.objl not in valid:
                yield Finding(_meta("S57.OBJL"), cell.name, "FRID", fr.rcid,
                              detail=f"OBJL={fr.objl}")


@check("S57.ATTL", Severity.ERROR, "Object catalogue",
       "Attribute code (ATTL) is not a known attribute.",
       "Use an attribute code from the catalogue.",
       "S-57 Appendix A",
       name="object-catalogue/unknown-attribute-code")
def attl_known(ds, cat, geom):
    valid = set(getattr(cat, "attr_code_to_acronym", {}))
    if not valid:
        return
    for cell in _cells(ds):
        for fr in cell.features:
            for code in list(fr.attrs) + list(fr.natf):
                if code not in valid:
                    yield Finding(_meta("S57.ATTL"), cell.name, "FRID", fr.rcid,
                                  detail=f"ATTL={code}")
        for (rcnm, rcid), v in cell.vectors.items():
            for code in v.attrs:
                if code not in valid:
                    yield Finding(_meta("S57.ATTL"), cell.name, "VRID", rcid,
                                  detail=f"ATTL={code}")


# ---------------------------------------------------------------------------
# Subfield value domains
# ---------------------------------------------------------------------------
@check("S57.RUIN", Severity.ERROR, "Data structure",
       "Record update instruction (RUIN) is not a valid code (1/2/3).",
       "Use 1 (insert), 2 (delete) or 3 (modify).",
       "ENC PS 6.3.2.4 / 6.4.2.3",
       name="data-structure/record-update-instruction-invalid")
def ruin_domain(ds, cat, geom):
    for cell in _cells(ds):
        for fr in cell.features:
            if fr.ruin is not None and fr.ruin not in _VALID_RUIN:
                yield Finding(_meta("S57.RUIN"), cell.name, "FRID", fr.rcid,
                              detail=f"RUIN={fr.ruin}")
        for (rcnm, rcid), v in cell.vectors.items():
            if v.ruin is not None and v.ruin not in _VALID_RUIN:
                yield Finding(_meta("S57.RUIN"), cell.name, "VRID", rcid,
                              detail=f"RUIN={v.ruin}")


@check("S57.PRIM", Severity.ERROR, "Data structure",
       "Feature geometric primitive (PRIM) is not a valid code.",
       "Use 1 (point), 2 (line), 3 (area) or 255 (none).",
       "S-57 Part 3 (FRID.PRIM)",
       name="data-structure/feature-primitive-invalid")
def prim_domain(ds, cat, geom):
    for cell in _cells(ds):
        for fr in cell.features:
            if fr.prim is not None and fr.prim not in _VALID_PRIM:
                yield Finding(_meta("S57.PRIM"), cell.name, "FRID", fr.rcid,
                              detail=f"PRIM={fr.prim}")


# ---------------------------------------------------------------------------
# Pointer integrity (provisional — depends on NAME foreign-key decoding;
# verify against a real cell before relying on it in production)
# ---------------------------------------------------------------------------
@check("S57.FSPT.REF", Severity.CRITICAL, "Data structure",
       "Feature points to a spatial record that does not exist (FSPT).",
       "Ensure each FSPT NAME references an existing vector record.",
       "ENC PS 6.3.2.14 (provisional — verify NAME decode on real cells)",
       name="data-structure/feature-to-spatial-pointer-unresolved")
def fspt_integrity(ds, cat, geom):
    for cell in _cells(ds):
        if not cell.vectors:
            continue
        for fr in cell.features:
            for (rcnm, rcid, _o, _u, _m) in fr.fspt:
                if (rcnm, rcid) not in cell.vectors:
                    yield Finding(_meta("S57.FSPT.REF"), cell.name, "FSPT",
                                  fr.rcid, detail=f"-> VRID {(rcnm, rcid)}")


@check("S57.VRPT.REF", Severity.CRITICAL, "Data structure",
       "Vector points to a vector record that does not exist (VRPT).",
       "Ensure each VRPT NAME references an existing vector record.",
       "ENC PS 6.3.2.6 (provisional — verify NAME decode on real cells)",
       name="data-structure/vector-to-vector-pointer-unresolved")
def vrpt_integrity(ds, cat, geom):
    for cell in _cells(ds):
        if not cell.vectors:
            continue
        for (rcnm, rcid), v in cell.vectors.items():
            for (trcnm, trcid, *_rest) in v.vrpt:
                if (trcnm, trcid) not in cell.vectors:
                    yield Finding(_meta("S57.VRPT.REF"), cell.name, "VRPT",
                                  rcid, detail=f"-> VRID {(trcnm, trcid)}")


@check("S57.NODE.COORD", Severity.WARNING, "Data structure",
       "An isolated or connected node does not carry exactly one coordinate.",
       "Encode exactly one coordinate per node.",
       "S-57 Part 3 (node geometry) (provisional)",
       name="data-structure/node-coordinate-count-unexpected")
def node_coord_count(ds, cat, geom):
    for cell in _cells(ds):
        for (rcnm, rcid), v in cell.vectors.items():
            n = len(v.coords)
            is_3d = bool(v.coords) and len(v.coords[0]) == 3
            # Connected nodes are a single shared point. Plain isolated nodes
            # carry one 2-D coordinate. SOUNDG is encoded as an isolated node
            # with an SG3D field holding many 3-D points, so those legitimately
            # carry more than one and are not flagged.
            if rcnm == RCNM_VC and n != 1:
                yield Finding(_meta("S57.NODE.COORD"), cell.name, "VRID", rcid,
                              detail=f"connected node has {n} coordinate(s)")
            elif rcnm == RCNM_VI and not is_3d and n != 1:
                yield Finding(_meta("S57.NODE.COORD"), cell.name, "VRID", rcid,
                              detail=f"isolated node has {n} coordinate(s)")


# ---------------------------------------------------------------------------
# metadata accessor (registered meta by number)
# ---------------------------------------------------------------------------
def _meta(number):
    from .engine import REGISTRY, CheckMeta, Severity as _S
    for m in REGISTRY:
        if m.number == number:
            return m
    return CheckMeta(number, _S.ERROR, "", "", "", "", None)
