# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
s101/records.py
---------------
The native **S-101 record model** — the layer above the shared ``iso8211`` reader
that interprets the ISO 8211 record stream of an S-101 (S-100 Part 10a) cell as
dataset / feature / information-type records, with complex attributes assembled.

This is the S-101 sibling of ``s57.records``, and it deliberately reuses the same
``iso8211.Iso8211Reader`` byte layer unchanged: that reader was verified to read
real S-101 cells (Part 10a) without modification — DDR + directory + field decode
all hold (35/35 Belgian test cells, 63 688 records). So only the *record model*
is new, not the byte reader. (See HANDOVER S-101 section.)

What it models (read side):

    S101Cell
      .name                     dataset name (file stem / DSNM)
      .dsid     {standard, edition, product_spec, ps_edition, dataset_name, ...}
      .feature_types     {numeric code -> catalogue name}   (from in-cell FTCS)
      .attribute_names   {numeric code -> catalogue name}    (from in-cell ATCS)
      .information_type_names {code -> name}                  (from in-cell ITCS)
      .association_names {code -> name}                       (FACS/IACS/ARCS)
      .features          [S101Feature, ...]
      .information_types [S101InformationType, ...]

The big S-100/S-101 departures from S-57 that this captures:

  * **Self-describing codes.** An S-101 cell carries its own code↔name dictionaries
    inline (the ``*CS`` records), unlike S-57's purely external catalogue. So a
    feature's numeric type code (FRID.NFTC) and each attribute's code (ATTR.NATC)
    resolve to names from the cell itself.
  * **Complex (nested) attributes.** ATTR records carry ``PAIX`` — the 1-based
    ordinal of the *parent* ATTR within the same feature record (0 = top level) —
    so attributes form a tree (e.g. ``surveyDateRange`` → ``dateStart``/``dateEnd``).
    Verified self-consistent on the real cells (every nonzero PAIX references an
    earlier attribute in the same record).
  * **Information types.** IRID records (information types, e.g. ``ContactDetails``)
    are first-class alongside features, each with their own attribute tree.

DELIBERATELY NOT YET MODELLED (no GDAL S-101 oracle available — flagged unverified):
  * **Geometry assembly** (S-100 Part 7: PRID/CRID/SRID + C2IT/C2IL/C3IL coordinate
    lists, the ``\\*``-delimited repeating coordinate groups). A feature's spatial
    pointers are kept raw on ``S101Feature.spatial_refs`` but not assembled.
  * **Feature / information associations** (FASC/INAS): the repeating association
    group beyond the header is left for a later unit.

FORMER UPSTREAM LIMITATION (reader layer) — **FIXED in v1.25.2.** The shared
``iso8211`` decoder used to drift on the S-101 *multilingual text* attribute (e.g.
``featureName`` with its language tag): the ATTR cycle mis-aligned, surfacing as an
impossible attribute code (NATC=256) with binary-looking ATVL and ATIX values that are
multiples of 256 (~5% of ATTR occurrences). The cause was the stray-terminator skip
firing after a delimited trailing subfield (ATVL) that had already consumed the cycle
terminator, eating the next cycle's first binary byte when it was 0x1e/0x1f (NATC=30/31)
— same byte-alignment class as the v1.24.1 SG2D fix. After the fix, every ATTR.NATC
across all Belgian test cells resolves against the in-cell ATCS dictionary (0 unresolved;
reproduce on cell ``101BE004_514N028E`` feature rcid=22 — the complex ``featureName``).
The safe-degradation logic below (unknown NATC → ``name=None``, drifted PAIX → root) is
retained defensively but no longer exercised by this attribute family on the test cells.

GDAL-free and import-safe without QGIS.
"""

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


# S-101 record-name (RCNM) codes, observed in the Belgian Ed 2.0.0 test cells.
RCNM_DSID = 10      # Data Set Identification
RCNM_CSID = 15      # Coordinate Reference System
RCNM_FRID = 100     # Feature (type) record
RCNM_PRID = 110     # Point record
RCNM_MRID = 115     # Multi-point record
RCNM_CRID = 120     # Curve record
RCNM_CCID = 125     # Composite curve record
RCNM_SRID = 130     # Surface record
RCNM_IRID = 150     # Information type record

# Spatial-pointer field tags kept (raw) on a feature pending Part 7 assembly.
SPATIAL_TAGS = ("SPAS", "PTAS", "RIAS", "CUCO", "MASK", "SEGH")

# In-cell code dictionaries: tag -> (name subfield, numeric-code subfield).
_CODE_FIELDS = {
    "FTCS": ("FTCD", "FTNC"),   # feature types
    "ATCS": ("ATCD", "ANCD"),   # attributes
    "ITCS": ("ITCD", "ITNC"),   # information types
    "IACS": ("IACD", "IANC"),   # information associations
    "FACS": ("FACD", "FANC"),   # feature associations
    "ARCS": ("ARCD", "ARNC"),   # association roles
}


def _i(value):
    """Best-effort int from a decoded subfield value (str/int/None)."""
    if value is None:
        return None
    if isinstance(value, int):
        return value
    try:
        return int(str(value).strip())
    except (ValueError, TypeError):
        return None


class S101Attribute:
    """One attribute occurrence: its numeric code, resolved catalogue name, raw
    string value (``None`` when empty — typical for a complex parent), and any
    nested child attributes (complex attribute)."""
    __slots__ = ("code", "name", "value", "children")

    def __init__(self, code, name, value):
        self.code = code
        self.name = name
        self.value = value
        self.children = []

    @property
    def is_complex(self):
        return bool(self.children)

    def flatten(self):
        """Yield this attribute and all its descendants, depth-first."""
        yield self
        for child in self.children:
            yield from child.flatten()

    def __repr__(self):
        kids = f" +{len(self.children)}" if self.children else ""
        return f"<S101Attribute {self.name or self.code}={self.value!r}{kids}>"


class S101Association:
    """One association from a feature to another record: a feature→feature link
    (FASC) or a feature→information-type link (INAS). Carries the target record's
    name/id and the association + target-role resolved against the cell's code
    dictionaries (e.g. assoc='StructureEquipment', role='theEquipment' for a buoy
    that carries a light). This is what lets the AtoN merge fold a structure's
    equipment (lights, fog signals, topmarks, …) onto its body the S-100-native
    way, rather than guessing co-location by position as the S-57 path must."""
    __slots__ = ("kind", "target_rcnm", "target_rcid", "assoc_code",
                 "assoc_name", "role_code", "role_name")

    def __init__(self, kind, target_rcnm, target_rcid, assoc_code, assoc_name,
                 role_code, role_name):
        self.kind = kind                  # 'feature' | 'information'
        self.target_rcnm = target_rcnm    # RCNM of the referenced record
        self.target_rcid = target_rcid    # RCID of the referenced record
        self.assoc_code = assoc_code
        self.assoc_name = assoc_name      # FACS/IACS name (e.g. StructureEquipment)
        self.role_code = role_code
        self.role_name = role_name        # ARCS name (e.g. theEquipment)

    def __repr__(self):
        return (f"<S101Association {self.kind} -> rcid={self.target_rcid} "
                f"{self.assoc_name}/{self.role_name}>")


class S101Feature:
    """One S-101 feature: its FRID identity, resolved feature-type name, FOID,
    its attribute tree (list of root attributes), the raw spatial-pointer fields
    (geometry assembly deferred), and its outgoing associations (FASC/INAS)."""
    __slots__ = ("rcid", "type_code", "type_name", "foid", "attributes",
                 "spatial_refs", "associations")

    def __init__(self, rcid, type_code, type_name, foid, attributes,
                 spatial_refs, associations=None):
        self.rcid = rcid
        self.type_code = type_code
        self.type_name = type_name
        self.foid = foid                 # (AGEN, FIDN, FIDS) or None
        self.attributes = attributes     # list of root S101Attribute
        self.spatial_refs = spatial_refs  # list of (tag, occ-dict)
        self.associations = associations or []  # list of S101Association

    @property
    def foid_str(self):
        return "/".join(str(p) for p in self.foid) if self.foid else None

    def iter_attributes(self):
        """Yield every attribute (roots and nested), depth-first."""
        for a in self.attributes:
            yield from a.flatten()

    def __repr__(self):
        return (f"<S101Feature rcid={self.rcid} "
                f"{self.type_name or self.type_code} "
                f"attrs={len(self.attributes)}>")


class S101InformationType:
    """An S-101 information type record (IRID), e.g. ``ContactDetails`` — a
    feature-less carrier of attributes, referenced by features/other info types
    via associations."""
    __slots__ = ("rcid", "type_code", "type_name", "attributes")

    def __init__(self, rcid, type_code, type_name, attributes):
        self.rcid = rcid
        self.type_code = type_code
        self.type_name = type_name
        self.attributes = attributes

    def __repr__(self):
        return (f"<S101InformationType rcid={self.rcid} "
                f"{self.type_name or self.type_code} "
                f"attrs={len(self.attributes)}>")


def _build_code_map(records, tag):
    """Build a {numeric code -> name} dict from the in-cell ``*CS`` code records."""
    name_lbl, num_lbl = _CODE_FIELDS[tag]
    out = {}
    for rec in records:
        for occ in rec.fields.get(tag, []):
            num = _i(occ.get(num_lbl))
            name = occ.get(name_lbl)
            if num is not None:
                out[num] = name
    return out


def _build_attribute_tree(attr_occs, attr_names):
    """Assemble a feature/info-type's flat ATTR occurrences into a complex-attribute
    tree, returning the list of root attributes.

    ``PAIX`` is the 1-based ordinal of the parent attribute within this record's
    ATTR sequence (0 = top level). A PAIX that does not reference an earlier, valid
    attribute is treated as top-level (defensive — keeps a malformed cell readable).
    """
    nodes = [S101Attribute(_i(o.get("NATC")),
                           attr_names.get(_i(o.get("NATC"))),
                           o.get("ATVL"))
             for o in attr_occs]
    roots = []
    for idx, occ in enumerate(attr_occs):
        paix = _i(occ.get("PAIX")) or 0
        if 1 <= paix <= idx:            # parent must be an earlier node (1-based)
            nodes[paix - 1].children.append(nodes[idx])
        else:
            roots.append(nodes[idx])
    return roots


def _foid(rec):
    o = rec.first("FOID")
    if not o:
        return None
    return (_i(o.get("AGEN")), _i(o.get("FIDN")), _i(o.get("FIDS")))


def _build_associations(rec, feature_assoc_names, info_assoc_names, role_names):
    """Parse a feature record's FASC (feature→feature) and INAS (feature→info)
    association fields into S101Association objects, resolving the association
    name (FACS/IACS) and target role (ARCS) from the cell's code dictionaries.

    Both fields are ISO 8211 cartesian fields whose link lives in the header
    subfields; the iso8211 reader returns that header as a single occurrence
    even when the trailing per-association attribute array is empty (the usual
    case). FASC uses NFAC for the association code, INAS uses NIAC; both use NARC
    for the role."""
    out = []
    for occ in rec.fields.get("FASC", []):
        code = _i(occ.get("NFAC"))
        role = _i(occ.get("NARC"))
        out.append(S101Association(
            "feature", _i(occ.get("RRNM")), _i(occ.get("RRID")),
            code, feature_assoc_names.get(code),
            role, role_names.get(role)))
    for occ in rec.fields.get("INAS", []):
        code = _i(occ.get("NIAC"))
        role = _i(occ.get("NARC"))
        out.append(S101Association(
            "information", _i(occ.get("RRNM")), _i(occ.get("RRID")),
            code, info_assoc_names.get(code),
            role, role_names.get(role)))
    return out


class S101Cell:
    """A parsed S-101 cell: metadata, in-cell code dictionaries, features (with
    complex attributes), and information types. Geometry and associations are
    out of scope for this unit (see module docstring)."""

    def __init__(self, name):
        self.name = name
        self.dsid = {}
        self.feature_types = {}
        self.attribute_names = {}
        self.information_type_names = {}
        # Association code dictionaries, kept SEPARATE because their numeric code
        # spaces overlap (FACS and ARCS both start at 1) — merging them, as the
        # old single ``association_names`` did, is ambiguous. ``association_names``
        # is retained (merged) for display/back-compat; resolution uses these.
        self.feature_assoc_names = {}    # FACS: e.g. {1: 'StructureEquipment'}
        self.info_assoc_names = {}       # IACS
        self.role_names = {}             # ARCS: e.g. {3: 'theEquipment'}
        self.association_names = {}
        self.features = []
        self.information_types = []
        self.parse_error = None

    # -- construction ------------------------------------------------------
    @classmethod
    def from_path(cls, path):
        """Read one S-101 ``.000`` cell from disk. On a malformed ISO 8211 stream
        the returned cell carries ``.parse_error`` rather than raising, mirroring
        the per-cell read isolation the S-57 side uses."""
        import os
        name = os.path.splitext(os.path.basename(path))[0]
        cell = cls(name)
        try:
            records = list(Iso8211Reader.open(path).records())
        except Iso8211Error as exc:
            cell.parse_error = str(exc)
            return cell
        return cls.from_records(records, name=name)

    @classmethod
    def from_records(cls, records, name=None):
        """Build the model from already-decoded ``iso8211`` ``Record`` objects.
        This is the seam the tests drive directly (record doubles), so the model
        logic is verified without hand-rolling ISO 8211 bytes."""
        cell = cls(name or "")

        # In-cell code dictionaries first (features/attrs resolve against them).
        cell.feature_types = _build_code_map(records, "FTCS")
        cell.attribute_names = _build_code_map(records, "ATCS")
        cell.information_type_names = _build_code_map(records, "ITCS")
        cell.feature_assoc_names = _build_code_map(records, "FACS")
        cell.info_assoc_names = _build_code_map(records, "IACS")
        cell.role_names = _build_code_map(records, "ARCS")
        cell.association_names = {}
        for tag in ("IACS", "FACS", "ARCS"):
            cell.association_names.update(_build_code_map(records, tag))

        for rec in records:
            dsid = rec.first("DSID")
            if dsid is not None and not cell.dsid:
                cell.dsid = {
                    "standard": dsid.get("ENSP"),       # "S-100 Part 10a"
                    "edition": dsid.get("ENED"),        # framework edition
                    "product_spec": dsid.get("PRSP"),   # "INT.IHO.S-101.2.0.0"
                    "ps_edition": dsid.get("PRED"),     # "2.0.0"
                    "profile": dsid.get("PROF"),
                    "dataset_name": dsid.get("DSNM"),
                    "dataset_edition": dsid.get("DSED"),  # dataset edition no.
                    "reference_date": dsid.get("DSRD"),   # issue date (YYYYMMDD)
                    "title": dsid.get("DSTL"),            # dataset title
                }

            if "FRID" in rec.fields:
                frid = rec.first("FRID")
                code = _i(frid.get("NFTC"))
                attrs = _build_attribute_tree(rec.fields.get("ATTR", []),
                                              cell.attribute_names)
                spatial = [(tag, occ)
                           for tag in SPATIAL_TAGS
                           for occ in rec.fields.get(tag, [])]
                cell.features.append(S101Feature(
                    rcid=_i(frid.get("RCID")),
                    type_code=code,
                    type_name=cell.feature_types.get(code),
                    foid=_foid(rec),
                    attributes=attrs,
                    spatial_refs=spatial,
                    associations=_build_associations(
                        rec, cell.feature_assoc_names,
                        cell.info_assoc_names, cell.role_names),
                ))
            elif "IRID" in rec.fields:
                irid = rec.first("IRID")
                code = _i(irid.get("NITC"))
                attrs = _build_attribute_tree(rec.fields.get("ATTR", []),
                                              cell.attribute_names)
                cell.information_types.append(S101InformationType(
                    rcid=_i(irid.get("RCID")),
                    type_code=code,
                    type_name=cell.information_type_names.get(code),
                    attributes=attrs,
                ))

        return cell

    # -- convenience -------------------------------------------------------
    def features_by_rcid(self):
        """{rcid -> S101Feature} for resolving association targets (a feature's
        FASC link gives the target's RCID; this maps it back to the feature)."""
        return {f.rcid: f for f in self.features}

    def feature_type_histogram(self):
        """{type_name (or code) -> count} over all features — a quick content read."""
        hist = {}
        for f in self.features:
            key = f.type_name or f.type_code
            hist[key] = hist.get(key, 0) + 1
        return hist

    def __repr__(self):
        return (f"<S101Cell {self.name!r} features={len(self.features)} "
                f"info_types={len(self.information_types)} "
                f"feature_types={len(self.feature_types)}>")
