# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
iso8211/reader.py
-----------------
A dependency-free ISO/IEC 8211:1994 reader — the shared container layer that
S-57 (S-58 / Inland ENC) and, later, S-101 all sit on. It turns the raw octet
stream of a cell into records, fields and named, typed subfields, ready for a
record-model layer above it to interpret.

It implements the *method* described by ISO/IEC 8211 (leader → directory →
field area; DDR field descriptors driving DR decoding). It contains no text
from the standard — clauses are referenced by number only — so it is clean to
ship under an open-source licence.

Scope: the S-57/S-101 subset of 8211 — character-mode lengths, the DDR data
descriptive fields, ASCII subfield formats (A/I/R) and the binary forms (b1n
unsigned / b2n signed, little-endian as S-57 uses), repeating (array) fields,
and the dropped-leader "R" variant. Long records (record length = 0) are
detected and read field-by-field.

Terminators: FT = 0x1e (field terminator), UT = 0x1f (unit terminator).
"""

FT = 0x1e          # field terminator
UT = 0x1f          # unit/subfield terminator
_FT_BYTES = bytes([FT])
LEADER_LEN = 24


# ---------------------------------------------------------------------------
# Format controls
# ---------------------------------------------------------------------------
class Fmt:
    """One decoded subfield format: kind in {'A','I','R','b'}, optional fixed
    width, and signedness for binary."""
    __slots__ = ("kind", "width", "signed")

    def __init__(self, kind, width=None, signed=False):
        self.kind = kind
        self.width = width        # fixed octet width, or None = delimited
        self.signed = signed

    def __repr__(self):
        w = self.width if self.width is not None else ""
        return f"{self.kind}{w}{'s' if self.signed else ''}"


def parse_format(fmt_str):
    """Expand an 8211 format-control string into a flat list of Fmt objects.

    Handles repeat factors (``3A(2)``), nested groups (``(I(2),A)``) and the
    binary forms ``b11``/``b24``. The expansion is one *cycle*; repeating
    (array) fields apply the cycle as many times as the field bytes allow.
    """
    s = fmt_str.strip()
    if s.startswith("(") and s.endswith(")"):
        s = s[1:-1]
    return _parse_seq(s)


def _split_top(s):
    """Split a comma-separated format sequence at top level (respect parens)."""
    out, depth, cur = [], 0, ""
    for ch in s:
        if ch == "(":
            depth += 1
            cur += ch
        elif ch == ")":
            depth -= 1
            cur += ch
        elif ch == "," and depth == 0:
            out.append(cur)
            cur = ""
        else:
            cur += ch
    if cur.strip():
        out.append(cur)
    return out


def _parse_seq(s):
    items = []
    for tok in _split_top(s):
        tok = tok.strip()
        if not tok:
            continue
        # leading repeat factor
        i = 0
        while i < len(tok) and tok[i].isdigit():
            i += 1
        repeat = int(tok[:i]) if i else 1
        rest = tok[i:].strip()
        if rest.startswith("("):
            items.extend(_parse_seq(rest[1:-1]) * repeat)
        else:
            items.extend([_parse_one(rest)] * repeat)
    return items


def _parse_one(tok):
    """Parse a single format code like A(3), I, R, b14, B(40)."""
    tok = tok.strip()
    if tok[0] == "b":
        # binary: b<form><width>, form 1=unsigned 2=signed, width in octets
        digits = "".join(c for c in tok[1:] if c.isdigit())
        form = int(digits[0]) if digits else 1
        width = int(digits[1:]) if len(digits) > 1 else 1
        return Fmt("b", width=width, signed=(form == 2))
    if tok[0] == "B":
        # bit-width binary B(n): n bits -> n/8 octets, unsigned little-endian.
        # Used for the FSPT/VRPT/FFPT foreign keys (NAME/LNAM = B(40) = 5 bytes).
        inner = tok[tok.index("(") + 1:tok.index(")")].strip() if "(" in tok else ""
        bits = int(inner) if inner.isdigit() else 8
        return Fmt("b", width=(bits + 7) // 8, signed=False)
    # ASCII forms A / I / R (S/C treated as ASCII text)
    code = tok[0].upper()
    kind = "A" if code in ("A", "S", "C") else code
    width = None
    if "(" in tok and ")" in tok:
        inner = tok[tok.index("(") + 1:tok.index(")")].strip()
        width = int(inner) if inner.isdigit() else None
    return Fmt(kind, width=width)


def _parse_array_descriptor(label_str):
    """Split an 8211 array descriptor into (labels, array_start).

    The '*' is the ISO 8211 cartesian marker: it flags where the repeating
    (array) sub-group starts. Subfields before it are a once-only header.
    Examples:
      ``YCOO!XCOO``                 -> (['YCOO','XCOO'], None)   # no array
      ``*YCOO!XCOO``                -> (['YCOO','XCOO'], 0)      # whole-cycle array
      ``VCID\\*YCOO!XCOO!ZCOO``     -> (['VCID','YCOO','XCOO','ZCOO'], 1)  # header+array
    The '\\' that S-100 writes between the header and the '*' is just a
    delimiter, dropped here. array_start is the index (into the returned label
    list) of the first repeating subfield; None means no cartesian marker.
    """
    if not label_str:
        return [], None
    if "*" not in label_str:
        return [lbl for lbl in label_str.split("!")], None
    pre, post = label_str.split("*", 1)
    pre = pre.rstrip("\\")                       # drop the header/array delimiter
    header = [lbl for lbl in pre.split("!") if lbl]
    array = [lbl for lbl in post.split("!") if lbl]
    return header + array, len(header)


# ---------------------------------------------------------------------------
# Field definitions (from the DDR)
# ---------------------------------------------------------------------------
class FieldDef:
    """Descriptor for one field tag, taken from the DDR: its name, the ordered
    subfield labels, and the matching format cycle.

    ``array_start`` marks the ISO 8211 cartesian ('*') boundary: subfields before
    it form a once-only header, subfields from it on form the repeating (array)
    group. ``None`` = no cartesian marker (the whole cycle repeats, the common
    case); ``0`` = the whole cycle is the array (leading '*', e.g. C2IL); ``>0`` =
    a header precedes the array (e.g. C3IL ``VCID\\*YCOO!XCOO!ZCOO`` → header
    [VCID], array [YCOO,XCOO,ZCOO])."""
    __slots__ = ("tag", "name", "labels", "fmts", "structure", "array_start")

    def __init__(self, tag, name, labels, fmts, structure, array_start=None):
        self.tag = tag
        self.name = name
        self.labels = labels
        self.fmts = fmts
        self.structure = structure   # data structure code from field controls
        self.array_start = array_start

    def __repr__(self):
        return f"<FieldDef {self.tag} {self.name!r} labels={self.labels}>"


# ---------------------------------------------------------------------------
# Records
# ---------------------------------------------------------------------------
class Record:
    """One logical record: its leader, and decoded fields as
    ``{tag: [ {label: value, ...}, ... ]}`` (a list because fields may repeat).
    Raw field bytes are kept too, for checks that need the unparsed octets."""
    __slots__ = ("leader", "fields", "raw")

    def __init__(self, leader):
        self.leader = leader
        self.fields = {}     # tag -> list of dict(label->value)
        self.raw = {}        # tag -> raw bytes (without trailing FT)

    def first(self, tag):
        """Convenience: the first occurrence dict for a tag, or None."""
        vals = self.fields.get(tag)
        return vals[0] if vals else None


# ---------------------------------------------------------------------------
# Reader
# ---------------------------------------------------------------------------
class Iso8211Error(Exception):
    """Raised on malformed ISO 8211 input (bad leader, truncated record, etc.)
    so callers can catch a read failure instead of an opaque IndexError."""


class Iso8211Reader:
    """Parse an ISO 8211 file into a DDR (field definitions) and a stream of
    data records decoded against those definitions."""

    def __init__(self, data):
        if isinstance(data, (bytes, bytearray)):
            self._data = bytes(data)
        else:
            with open(data, "rb") as fh:
                self._data = fh.read()
        self.fielddefs = {}        # tag -> FieldDef (from the DDR)
        self.ddr_leader = None
        self._pos = 0

    @classmethod
    def open(cls, path):
        return cls(path)

    # -- leader/directory --------------------------------------------------
    @staticmethod
    def _parse_leader(buf):
        if len(buf) < LEADER_LEN:
            raise Iso8211Error("record shorter than 24-octet leader")

        def _int(s, default=0):
            s = s.strip()
            return int(s) if s.isdigit() else default
        return {
            "record_length": _int(buf[0:5].decode("latin-1")),
            "leader_id": chr(buf[6]),
            "version": chr(buf[8]),
            "field_control_length": _int(buf[10:12].decode("latin-1"), 9),
            "base_address": _int(buf[12:17].decode("latin-1")),
            "size_field_length": _int(buf[20:21].decode("latin-1")),
            "size_field_position": _int(buf[21:22].decode("latin-1")),
            "size_field_tag": _int(buf[23:24].decode("latin-1"), 4),
        }

    @staticmethod
    def _parse_directory(buf, leader):
        """Return list of (tag, length, position) from the directory area."""
        entry_len = (leader["size_field_tag"] + leader["size_field_length"]
                     + leader["size_field_position"])
        out = []
        i = LEADER_LEN
        end = leader["base_address"] - 1   # directory ends with FT
        while i + entry_len <= end + 1:
            if buf[i] == FT:
                break
            t = leader["size_field_tag"]
            tag = buf[i:i + t].decode("latin-1")
            n = leader["size_field_length"]
            p = leader["size_field_position"]
            length = int(buf[i + t:i + t + n].decode("latin-1"))
            pos = int(buf[i + t + n:i + t + n + p].decode("latin-1"))
            out.append((tag, length, pos))
            i += entry_len
        return out

    # -- DDR field descriptors --------------------------------------------
    def _build_fielddef(self, tag, field_bytes, fcl):
        """Parse one DDR data descriptive field into a FieldDef.

        Layout: [field controls (fcl octets)][field name] UT
                [array descriptor (labels)] UT [format controls] FT
        The field-control field (tag 0000) is structural and skipped here.
        """
        body = field_bytes
        if body.endswith(bytes([FT])):
            body = body[:-1]
        controls = body[:fcl]
        structure = chr(controls[0]) if controls else "0"
        rest = body[fcl:]
        parts = rest.split(bytes([UT]))
        name = parts[0].decode("latin-1").strip() if parts else ""
        labels = []
        fmts = []
        array_start = None
        if len(parts) >= 3:
            label_str = parts[1].decode("latin-1")
            fmt_str = parts[2].decode("latin-1")
            labels, array_start = _parse_array_descriptor(label_str)
            if fmt_str:
                try:
                    fmts = parse_format(fmt_str)
                except Exception:                    # noqa: BLE001
                    fmts = []
        return FieldDef(tag, name, labels, fmts, structure, array_start)

    def _parse_ddr(self):
        leader = self._parse_leader(self._data[:LEADER_LEN])
        if leader["leader_id"] != "L":
            raise Iso8211Error("first record is not a DDR (leader id != 'L')")
        self.ddr_leader = leader
        directory = self._parse_directory(self._data, leader)
        base = leader["base_address"]
        fcl = leader["field_control_length"]
        for tag, length, pos in directory:
            start = base + pos
            field_bytes = self._data[start:start + length]
            if tag == "0000":
                continue                      # field control field — structural
            self.fielddefs[tag] = self._build_fielddef(tag, field_bytes, fcl)
        self._pos = leader["record_length"]

    # -- DR decoding -------------------------------------------------------
    def _decode_field(self, tag, raw):
        """Decode a DR field's raw bytes into a list of {label: value} dicts,
        repeating the label/format cycle until the bytes run out."""
        fd = self.fielddefs.get(tag)
        body = raw[:-1] if raw.endswith(bytes([FT])) else raw
        if fd is None or not fd.labels:
            # Unknown field or no descriptor: best-effort ASCII split on UT.
            text = body.replace(bytes([UT]), b"|").decode("latin-1", "replace")
            return [{"_raw": text}]
        cycles = []
        i = 0
        n = len(body)
        labels, fmts = fd.labels, fd.fmts
        # pad fmts if shorter than labels (variable ASCII default)
        if len(fmts) < len(labels):
            fmts = fmts + [Fmt("A")] * (len(labels) - len(fmts))
        # Cartesian header (ISO 8211 '*'): subfields before array_start are a
        # once-only header; only the part from array_start on repeats. C3IL =
        # VCID (count/header, b1) then a repeating (YCOO,XCOO,ZCOO) tuple group,
        # i.e. the S-101 3-D sounding multipoint. Without this the VCID byte would
        # be re-read as a coordinate every tuple, drifting the whole list. (A
        # leading '*' with no header is array_start=0 -> whole cycle repeats, the
        # existing path; None -> no cartesian marker, also the existing path.)
        header = {}
        if fd.array_start:
            for label, fmt in zip(labels[:fd.array_start], fmts[:fd.array_start]):
                value, i = self._read_subfield(body, i, fmt)
                header[label] = value
            labels, fmts = labels[fd.array_start:], fmts[fd.array_start:]
            if not labels:
                return [dict(header)]
        # A cycle made entirely of fixed-width binary subfields (e.g. SG2D's
        # YCOO/XCOO as b24) is packed back-to-back: there are no field-internal
        # UT/FT terminators between cycles, and a byte equal to 0x1e/0x1f is
        # legitimate coordinate data, not a terminator. Consuming a "stray
        # terminator" there (below) eats a real coordinate byte and drifts every
        # following cycle into garbage — observed on real SG2D edges where a
        # coordinate's low byte was 0x1e. So suppress that heuristic for
        # all-binary cycles, and never read a short trailing cycle.
        all_binary = bool(fmts) and all(f.kind == "b" for f in fmts)
        cyc_w = sum((f.width or 1) for f in fmts) if all_binary else 0
        # A delimited (variable-width) *trailing* subfield consumes the cycle's
        # own UT/FT terminator as part of its own read (see _read_subfield's
        # delimiter consume), so there is no "stray" terminator left after the
        # cycle to skip. The S-101 multilingual text attribute is exactly this
        # shape — ATTR = [b NATC, b ATIX, b PAIX, b ATIN, A ATVL] — and skipping
        # after the delimited ATVL ate the *next* cycle's first binary byte
        # whenever that byte was 0x1e/0x1f (e.g. NATC=30 -> 0x1e00, NATC=31 ->
        # 0x1f00), drifting every following cycle into garbage (impossible
        # NATC=256, ATIX multiples of 256, binary-looking ATVL). Same byte-
        # alignment bug class as the v1.24.1 SG2D all-binary fix, one cycle-shape
        # out: there it was an all-binary cycle, here a mixed cycle ending in a
        # delimited field. So only run the stray-terminator skip when the cycle's
        # last subfield is fixed-width (it leaves the terminator unconsumed).
        last_delimited = (bool(fmts) and fmts[-1].width is None
                          and fmts[-1].kind in ("A", "I", "R"))
        guard = 0
        while i < n and guard < 100000:
            if all_binary and n - i < cyc_w:
                break
            guard += 1
            row = dict(header)
            for label, fmt in zip(labels, fmts):
                value, i = self._read_subfield(body, i, fmt)
                row[label] = value
            cycles.append(row)
            # consume a stray field-internal terminator — only for cycles whose
            # last subfield is fixed-width (so the terminator is still unread).
            # Never for all-binary cycles (binary data may legitimately contain
            # UT/FT byte values) nor for cycles ending in a delimited subfield
            # (which already consumed the terminator — see last_delimited above).
            if not all_binary and not last_delimited and i < n and body[i] in (UT, FT):
                i += 1
            if i >= n:
                break
        # A cartesian field whose array part has ZERO cycles still carries its
        # header, which we fully read above — don't discard it. This is the
        # FASC/INAS shape: the association link lives entirely in the header
        # subfields (RRNM/RRID/NFAC/NARC), and the trailing per-association
        # attribute array is normally empty, so the loop yields no cycles. Only
        # fires when a header was actually read (array_start set); plain
        # (non-cartesian) fields keep returning [] on an empty body as before.
        if not cycles and fd.array_start and header:
            return [dict(header)]
        return cycles

    @staticmethod
    def _read_subfield(body, i, fmt):
        n = len(body)
        if fmt.kind == "b":
            w = fmt.width or 1
            chunk = body[i:i + w]
            i += w
            val = int.from_bytes(chunk, "little", signed=fmt.signed)
            return val, i
        # ASCII forms
        if fmt.width:                      # fixed width
            chunk = body[i:i + fmt.width]
            i += fmt.width
        else:                              # delimited by UT/FT
            j = i
            while j < n and body[j] not in (UT, FT):
                j += 1
            chunk = body[i:j]
            i = j
            if i < n and body[i] == UT:    # consume the delimiter
                i += 1
        text = chunk.decode("latin-1").rstrip("\x00").strip()
        if fmt.kind == "I":
            return (int(text) if text.lstrip("-").isdigit() else None), i
        if fmt.kind == "R":
            try:
                return float(text), i
            except ValueError:
                return None, i
        return (text if text != "" else None), i

    # -- public iteration --------------------------------------------------
    def records(self, only_tags=None):
        """Yield every data record (the DRs) decoded against the DDR.

        Handles the dropped-leader 'R' variant: when a DR carries leader id
        'R', the previous leader/directory are reused for the rest of the file.

        ``only_tags`` (a set of field tags) restricts decoding to those fields —
        other fields are skipped entirely (not sliced or decoded). A scan that
        only needs, say, FRID.OBJL can pass ``{"FRID"}`` and avoid decoding the
        bulky coordinate fields, a large speed-up.
        """
        self._parse_ddr()
        data = self._data
        total = len(data)
        pos = self.ddr_leader["record_length"]
        held_leader = None
        held_dir = None
        while pos < total:
            if data[pos] == FT:           # trailing padding / EOF marker
                break
            leader = self._parse_leader(data[pos:pos + LEADER_LEN])
            lid = leader["leader_id"]
            ba = leader["base_address"]
            if lid == "R" and held_leader is not None:
                leader = held_leader
                directory = held_dir
                ba = leader["base_address"]
                rec_len = leader["record_length"]
                # 'R' records reuse leader+directory; field area follows inline
            else:
                # Slice ONLY the directory span (the field-area offsets are
                # relative to base_address), not data[pos:] — copying the whole
                # remaining file per record was O(file²) and the load's dominant
                # cost.
                directory = self._parse_directory(data[pos:pos + ba], leader)
                rec_len = leader["record_length"]
                if lid == "R":
                    held_leader, held_dir = leader, directory
            rec = Record(leader)
            fbase = pos + ba
            for tag, length, fpos in directory:
                if only_tags is not None and tag not in only_tags:
                    continue
                start = fbase + fpos
                raw = data[start:start + length]
                rec.raw[tag] = raw[:-1] if raw.endswith(_FT_BYTES) else raw
                rec.fields.setdefault(tag, []).extend(self._decode_field(tag, raw))
            yield rec
            if rec_len <= 0:
                raise Iso8211Error("zero record length (long-record variant "
                                   "not yet handled for this cell)")
            pos += rec_len
