# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
s57/records.py
--------------
The native S-57 **record model** — the layer above ``iso8211`` that interprets
ISO 8211 records as S-57 dataset/feature/vector records, ready for the
data-structure checks that GDAL's feature view hides.

It reads the raw ``.000`` octet stream via ``iso8211.Iso8211Reader`` (no GDAL),
classifies each record, and exposes a typed structure:

    S57Cell
      .name                       cell name (file stem)
      .dsid    {RCNM,RCID,DSNM,EDTN,UPDN,ISDT,AGEN,...}   (Data Set Identification)
      .dssi    {NOMR,NOCN,NOED,NOFA,...}                  (Data Set Structure Info)
      .dspm    {RCNM,HDAT,VDAT,CSCL,DUNI,HUNI,COMF,SOMF,...} (Data Set Parameter)
      .features  [FeatureRecord, ...]
      .vectors   {(rcnm, rcid): VectorRecord, ...}

A ``FeatureRecord`` carries its FRID identity (RCID/PRIM/GRUP/OBJL/RVER/RUIN),
its FOID (AGEN/FIDN/FIDS), attribute codes (ATTF/NATF), and its pointers to
spatial (FSPT) and other feature (FFPT) records. A ``VectorRecord`` carries its
VRID identity, attributes, pointers (VRPT) and coordinates (SG2D/SG3D).

This module is GDAL-free and import-safe without QGIS. Field/subfield layouts
follow S-57 Part 3 / ENC Product Specification (App B.1) — encoded here as rules
and value domains only, no standard text.

Note: ``S57Cell.from_path`` reads a single file's records (a base ``.000`` or a
lone ``.00n`` update). The update-merged, as-delivered view is produced by
``s57.update`` (``merged_cell`` / ``S57RecordSet.from_paths``), which applies the
sibling ``.001/.002…`` updates onto the base -- so the native reader now provides
the same merged feature/attribute state GDAL does. (In-place pointer/coordinate
field controls remain Tier 2; see s57/update.py.)
"""

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

# S-57 record-name (RCNM) codes.
RCNM_DS = 10        # Data Set General Information
RCNM_DP = 20        # Data Set Geographic Reference (Parameter)
RCNM_FE = 100       # Feature record
RCNM_VI = 110       # Isolated node
RCNM_VC = 120       # Connected node
RCNM_VE = 130       # Edge
RCNM_VF = 140       # Face
VECTOR_RCNM = {RCNM_VI, RCNM_VC, RCNM_VE, RCNM_VF}
RCNM_LABEL = {RCNM_VI: "VI", RCNM_VC: "VC", RCNM_VE: "VE", RCNM_VF: "VF",
              RCNM_FE: "FE", RCNM_DS: "DS", RCNM_DP: "DP"}


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 (TypeError, ValueError):
        return None


def _name_to_pointer(value):
    """Decode an FSPT/VRPT NAME foreign key into (rcnm, rcid).

    NAME is a 40-bit value: 1-octet RCNM + 4-octet RCID (little-endian). The
    8211 reader hands it over as bytes (B(40)) or, if a producer encoded it
    differently, as an int or string; handle all three defensively.
    """
    if value is None:
        return None
    if isinstance(value, (bytes, bytearray)):
        b = bytes(value)
        if len(b) < 5:
            return None
        return b[0], int.from_bytes(b[1:5], "little")
    if isinstance(value, int):
        return value & 0xFF, (value >> 8) & 0xFFFFFFFF
    s = str(value)
    if len(s) >= 5 and not s.strip().lstrip("-").isdigit():
        b = s.encode("latin-1", "replace")
        if len(b) >= 5:
            return b[0], int.from_bytes(b[1:5], "little")
    return None


class FeatureRecord:
    """One S-57 feature record (FRID/FOID/ATTF/FSPT): object class, identity,
    attributes, and its spatial pointers."""
    __slots__ = ("rcnm", "rcid", "prim", "grup", "objl", "rver", "ruin",
                 "foid", "attrs", "natf", "fspt", "ffpt")

    def __init__(self):
        self.rcnm = None
        self.rcid = None
        self.prim = None
        self.grup = None
        self.objl = None
        self.rver = None
        self.ruin = None
        self.foid = None            # (AGEN, FIDN, FIDS) or None
        self.attrs = {}             # {ATTL(int): ATVL}
        self.natf = {}              # {ATTL(int): ATVL}
        self.fspt = []              # [(rcnm, rcid, ornt, usag, mask)]
        self.ffpt = []              # [(lnam, rind, comt)]


class VectorRecord:
    """One S-57 vector record (VRID/SG2D/VRPT): a node or edge with its
    coordinates and pointers."""
    __slots__ = ("rcnm", "rcid", "rver", "ruin", "attrs", "vrpt", "coords")

    def __init__(self):
        self.rcnm = None
        self.rcid = None
        self.rver = None
        self.ruin = None
        self.attrs = {}             # {ATTL(int): ATVL}
        self.vrpt = []              # [(rcnm, rcid, ornt, usag, topi, mask)]
        self.coords = []            # [(y, x) or (y, x, z)]


class S57Cell:
    """Parsed record structure of one S-57 base cell."""

    def __init__(self, name=""):
        self.name = name
        self.dsid = None
        self.dssi = None
        self.dspm = None
        self.features = []
        self.vectors = {}           # (rcnm, rcid) -> VectorRecord
        self.vector_dups = []       # [(rcnm, rcid)] seen more than once
        self.parse_error = None     # set if the file could not be read

    # -- builders -------------------------------------------------------------
    @classmethod
    def from_path(cls, path):
        import os
        cell = cls(name=os.path.splitext(os.path.basename(path))[0])
        try:
            reader = Iso8211Reader.open(path)
            cell._consume(reader.records())
        except Exception as exc:                        # noqa: BLE001
            cell.parse_error = str(exc)
        return cell

    @classmethod
    def from_records(cls, name, records):
        """Build from an iterable of iso8211 Record objects (used by tests)."""
        cell = cls(name=name)
        cell._consume(records)
        return cell

    def _consume(self, records):
        for rec in records:
            f = rec.fields
            if "DSID" in f:
                self.dsid = rec.first("DSID")
                if "DSSI" in f:
                    self.dssi = rec.first("DSSI")
                continue
            if "DSPM" in f:
                self.dspm = rec.first("DSPM")
                continue
            if "FRID" in f:
                self.features.append(self._feature(rec))
                continue
            if "VRID" in f:
                v = self._vector(rec)
                key = (v.rcnm, v.rcid)
                if key in self.vectors:
                    self.vector_dups.append(key)
                self.vectors[key] = v
                continue

    # -- per-record parsers ---------------------------------------------------
    @staticmethod
    def _feature(rec):
        fr = FeatureRecord()
        frid = rec.first("FRID") or {}
        fr.rcnm = _i(frid.get("RCNM"))
        fr.rcid = _i(frid.get("RCID"))
        fr.prim = _i(frid.get("PRIM"))
        fr.grup = _i(frid.get("GRUP"))
        fr.objl = _i(frid.get("OBJL"))
        fr.rver = _i(frid.get("RVER"))
        fr.ruin = _i(frid.get("RUIN"))
        foid = rec.first("FOID")
        if foid:
            fr.foid = (_i(foid.get("AGEN")), _i(foid.get("FIDN")),
                       _i(foid.get("FIDS")))
        for a in rec.fields.get("ATTF", []):
            code = _i(a.get("ATTL"))
            if code is not None:
                fr.attrs[code] = a.get("ATVL")
        for a in rec.fields.get("NATF", []):
            code = _i(a.get("ATTL"))
            if code is not None:
                fr.natf[code] = a.get("ATVL")
        for p in rec.fields.get("FSPT", []):
            ptr = _name_to_pointer(p.get("NAME"))
            if ptr is not None:
                fr.fspt.append((ptr[0], ptr[1], _i(p.get("ORNT")),
                                _i(p.get("USAG")), _i(p.get("MASK"))))
        for p in rec.fields.get("FFPT", []):
            fr.ffpt.append((p.get("LNAM"), _i(p.get("RIND")), p.get("COMT")))
        return fr

    @staticmethod
    def _vector(rec):
        vr = VectorRecord()
        vrid = rec.first("VRID") or {}
        vr.rcnm = _i(vrid.get("RCNM"))
        vr.rcid = _i(vrid.get("RCID"))
        vr.rver = _i(vrid.get("RVER"))
        vr.ruin = _i(vrid.get("RUIN"))
        for a in rec.fields.get("ATTV", []):
            code = _i(a.get("ATTL"))
            if code is not None:
                vr.attrs[code] = a.get("ATVL")
        for p in rec.fields.get("VRPT", []):
            ptr = _name_to_pointer(p.get("NAME"))
            if ptr is not None:
                vr.vrpt.append((ptr[0], ptr[1], _i(p.get("ORNT")),
                                _i(p.get("USAG")), _i(p.get("TOPI")),
                                _i(p.get("MASK"))))
        for c in rec.fields.get("SG2D", []):
            y, x = _i(c.get("YCOO")), _i(c.get("XCOO"))
            if y is not None and x is not None:
                vr.coords.append((y, x))
        for c in rec.fields.get("SG3D", []):
            y, x = _i(c.get("YCOO")), _i(c.get("XCOO"))
            z = _i(c.get("VE3D"))
            if y is not None and x is not None:
                vr.coords.append((y, x, z))
        return vr


class S57RecordSet:
    """The record model attached to a Dataset as ``ds.s57``: the parsed cells
    plus convenience accessors the record-level checks iterate over."""

    def __init__(self, cells):
        self.cells = list(cells)          # [S57Cell]
        self.read_errors = []             # [(path, exception)] for skipped cells

    @classmethod
    def from_paths(cls, paths, on_error=None):
        # Each path is a base .000; apply its sibling .001/.002 updates so the
        # native view matches GDAL's update-merged feature/attribute state.
        # A cell that fails to read is kept as a parse_error stub (so it's still
        # counted and reported by the S57.READ check) rather than aborting the
        # whole set — one corrupt cell in a large exchange set must not sink the
        # entire run. The failures are also collected in ``read_errors``.
        import os
        from .update import merged_cell
        cells, errors = [], []
        for p in paths:
            try:
                cells.append(merged_cell(p))
            except Exception as exc:                  # noqa: BLE001
                name = os.path.splitext(os.path.basename(str(p)))[0]
                stub = S57Cell(name=name)
                stub.parse_error = f"could not read cell: {exc}"
                cells.append(stub)
                errors.append((p, exc))
                if on_error is not None:
                    try:
                        on_error(p, exc)
                    except Exception:                 # noqa: BLE001
                        pass
        rs = cls(cells)
        rs.read_errors = errors
        return rs

    def __iter__(self):
        return iter(self.cells)

    def __bool__(self):
        return bool(self.cells)
