# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/ienc_catalogue.py

Inland ENC catalogue, parsed from an official IEHG **Feature Catalogue** XML
(IENC_FC_*.xml). The FC XML is the machine-readable source for everything the
IENC attribute and object-class checks need, per object class:

  * permissible attributes         (AttributeBinding present)        -> 547-like
  * mandatory / conditional ones   (usage="M" / "C")                 -> mandatory-attr
  * allowable value subset         (binding valueList="4,5,6")       -> allowable-value
  * value domains                  (Attribute/Enumeration)           -> enumerated-value
  * permitted geometric primitives (Feature primitive="P,L,A")       -> 20a-like
  * value types E/F/I/L/S/T        (Attribute valueType)             -> multi-value (1679)

The catalogue is **edition-keyed**: a hybrid / Inland product declares (or the
user specifies) an edition, and the matching catalogue's rules apply. This
object mirrors the maritime ``Catalogue`` interface so the validation engine
consumes either identically; only the data source and citations differ.

Licensing: this module is the *parser*. It reads the office's official FC XML at
run time; no IEHG catalogue data is bundled in the plugin.
"""
import os
import re

try:
    from ..enc_xml_safe import parse_root, from_string
except (ImportError, ValueError):
    from enc_xml_safe import parse_root, from_string

_NS = "{http://ienc.openecdis.org}"


def _local(tag):
    return tag.split("}", 1)[-1]


def _split_list(value):
    """'P,L,A' or '4,5,6' -> ['P','L','A'] / ['4','5','6'] (no empties).
    Numeric RANGES are expanded: the FC writes e.g. ``valueList="1-27"`` on the
    boylat/catlam binding, meaning every code 1..27 is allowable."""
    if not value:
        return []
    out = []
    for t in value.split(","):
        t = t.strip()
        if not t:
            continue
        m = re.fullmatch(r"(\d+)\s*-\s*(\d+)", t)
        if m and int(m.group(1)) <= int(m.group(2)):
            out.extend(str(v) for v in
                       range(int(m.group(1)), int(m.group(2)) + 1))
        else:
            out.append(t)
    return out


def find_fc_xml(folder):
    """Return the path to an IENC Feature Catalogue XML the office has placed in
    ``folder`` (the newest by name if several editions are present), or None.
    Lets the overlay engage by convention -- drop the FC in the folder -- with
    no bundled IEHG data and no UI required. An explicit setting still overrides.
    """
    try:
        names = [n for n in os.listdir(folder) if n.lower().endswith(".xml")]
    except OSError:
        return None
    if not names:
        return None
    return os.path.join(folder, sorted(names)[-1])


def _read_fc_edition(path):
    """Cheaply read an FC's Header ``edition`` (e.g. '2.4') without a full parse —
    just enough bytes to find the Header element. Returns None if not found."""
    try:
        with open(path, "rb") as fh:
            head = fh.read(8192).decode("latin-1", "replace")
    except OSError:
        return None
    import re
    m = re.search(r"<[^>]*Header\b[^>]*\bedition=\"([^\"]+)\"", head)
    return m.group(1).strip() if m else None


def list_fc_editions(folder):
    """{edition -> path} for every readable FC XML in ``folder`` (newest path
    wins on a duplicate edition). Lets the auditor pick the FC matching a cell's
    PRED rather than just the newest file."""
    out = {}
    try:
        names = sorted(n for n in os.listdir(folder) if n.lower().endswith(".xml"))
    except OSError:
        return out
    for n in names:
        p = os.path.join(folder, n)
        ed = _read_fc_edition(p)
        if ed:
            out[ed] = p
    return out


def _major_minor(version):
    """First two dotted components of a version, e.g. '2.3.6' -> '2.3'."""
    return ".".join(str(version).strip().split(".")[:2])


def find_fc_for_edition(folder, edition):
    """Path to the FC in ``folder`` whose Header edition matches ``edition`` (a
    cell's PRED, e.g. '2.4'), or None. The XML's own declared edition is matched
    (so a '..._corr2.xml' filename is irrelevant). Falls back to a same
    MAJOR.MINOR match in either direction when there is no exact one — so PRED
    '2.5' accepts FC '2.5.1', and a longer PRED like '2.3.6' (the Encoding-Guide
    edition style) still selects FC '2.3' rather than dropping to the newest."""
    if not edition:
        return None
    want = str(edition).strip()
    editions = list_fc_editions(folder)
    if want in editions:
        return editions[want]
    want_mm = _major_minor(want)
    for ed, path in sorted(editions.items()):
        if _major_minor(ed) == want_mm:
            return path
    return None


class IencBinding:
    """One feature -> attribute binding from the Feature Catalogue."""
    __slots__ = ("acronym", "usage", "value_list", "fmt", "decimal_digits", "unit")

    def __init__(self, el):
        g = el.get
        self.acronym = (g("acronym") or "").strip()
        self.usage = (g("usage") or "").strip().upper()       # M / C / O
        self.value_list = _split_list(g("valueList"))         # allowable subset
        self.fmt = g("format")
        self.decimal_digits = g("decimalDigits")
        self.unit = g("unit")


class IencFeature:
    __slots__ = ("acronym", "code", "ftype", "primitives", "name", "bindings",
                 "_bind_ci")

    def __init__(self, el):
        g = el.get
        self.acronym = (g("acronym") or "").strip()
        self.code = g("code")
        self.ftype = (g("type") or "").strip().upper()        # G / M / O
        self.primitives = _split_list(g("primitive"))         # e.g. ['P','L','A']
        self.name = None
        self.bindings = {}                                    # acronym -> IencBinding
        self._bind_ci = {}                                    # lower() -> IencBinding
        for child in el:
            lt = _local(child.tag)
            if lt == "AttributeBinding":
                b = IencBinding(child)
                if b.acronym:
                    self.bindings[b.acronym] = b
                    self._bind_ci[b.acronym.lower()] = b
            elif lt == "DataDictionaryReference" and self.name is None:
                nm = child.find(_NS + "Name")
                if nm is not None:
                    self.name = (nm.text or "").strip()

    def binding(self, attr):
        """The binding for ``attr``, case-tolerantly: the FC writes inland
        attribute acronyms lower-case (catlam) and maritime ones upper-case
        (BOYSHP), while the audit works on a canonical upper-case acronym."""
        b = self.bindings.get(attr)
        if b is None and attr:
            b = self._bind_ci.get(str(attr).lower())
        return b


class IencAttribute:
    __slots__ = ("acronym", "code", "use_type", "value_type", "name", "enum")

    def __init__(self, el):
        g = el.get
        self.acronym = (g("acronym") or "").strip()
        self.code = g("code")
        self.use_type = (g("useType") or "").strip()          # F / F,S / N
        self.value_type = (g("valueType") or "").strip().upper()   # E/F/I/L/S/T
        self.name = None
        self.enum = {}                                        # value -> name
        for child in el:
            lt = _local(child.tag)
            if lt == "DataDictionaryReference" and self.name is None:
                nm = child.find(_NS + "Name")
                if nm is not None:
                    self.name = (nm.text or "").strip()
            elif lt == "Enumeration":
                val = child.get("value")
                if val is None:
                    continue
                nm = child.find(_NS + "DataDictionaryReference/" + _NS + "Name")
                self.enum[str(val)] = (nm.text or "").strip() if nm is not None else ""


class IencCatalogue:
    """Parsed IENC Feature Catalogue with a maritime-``Catalogue``-compatible API."""

    def __init__(self):
        self.edition = None
        self.date = None
        self.source = None
        self.features = {}        # acronym -> IencFeature
        self.attributes = {}      # acronym -> IencAttribute
        self._attr_ci = {}        # acronym.lower() -> [IencAttribute, ...]
        self._feat_ci = {}        # acronym.lower() -> IencFeature

    # -- construction ---------------------------------------------------------
    @classmethod
    def from_xml(cls, source):
        """Parse from a filesystem path, or from raw bytes/str of an FC XML."""
        cat = cls()

        # Security: a Feature Catalogue never needs a DTD. stdlib ElementTree
        # expands *internal* entities, so a crafted DTD could mount a "billion
        # laughs" entity-expansion DoS. (External entities are NOT resolved by
        # ElementTree, so there is no file-disclosure/XXE exposure.) Reject any
        # DTD/entity declaration up front — cheap, dependency-free, and a real FC
        # has none.
        def _guard(blob):
            head = blob[:8192]
            if isinstance(head, (bytes, bytearray)):
                head = head.decode("latin-1", "replace")
            up = head.upper()
            if "<!DOCTYPE" in up or "<!ENTITY" in up:
                raise ValueError(
                    "Feature Catalogue XML contains a DTD/entity declaration, "
                    "which is not permitted (rejected to prevent "
                    "entity-expansion attacks).")

        # enc_xml_safe re-screens the WHOLE document (the guard above reads only
        # the head) and parses without the stdlib's direct parse entry points.
        if isinstance(source, (bytes, bytearray)):
            _guard(source)
            root = from_string(source)
        elif isinstance(source, str) and source.lstrip()[:1] == "<":
            _guard(source)
            root = from_string(source)
        else:
            with open(source, "rb") as fh:
                _guard(fh.read(8192))
            root = parse_root(source)
        hdr = root.find(_NS + "Header")
        if hdr is not None:
            cat.edition = hdr.get("edition")
            cat.date = hdr.get("date")
        src = root.find(_NS + "Source")
        if src is not None:
            cat.source = src.get("name")
        for el in root.iter(_NS + "Attribute"):
            a = IencAttribute(el)
            if a.acronym:
                cat.attributes[a.acronym] = a
                cat._attr_ci.setdefault(a.acronym.lower(), []).append(a)
        for el in root.iter(_NS + "Feature"):
            f = IencFeature(el)
            if f.acronym:
                cat.features[f.acronym] = f
                cat._feat_ci.setdefault(f.acronym.lower(), []).append(f)
        return cat

    # -- case-tolerant resolution ----------------------------------------------
    # The FC distinguishes maritime and Inland definitions BY CASE: it defines
    # e.g. maritime CATLAM (code 36, values 1-4) AND inland catlam (code 17011,
    # values 1-27) as SEPARATE attributes — and likewise 15 DUAL-CASE classes
    # (GATCON/gatcon, DEPARE/depare, RESARE/resare, …). The dataset side keeps
    # the distinction (the native reader surfaces inland classes/attributes
    # lower-case), so per-class queries resolve the EXACT case first and fall
    # back case-insensitively; attribute domain/typing queries — where callers
    # hold only a canonical acronym — consider EVERY case-variant and union the
    # domains (the hybrid-correct rule; the catlam=8 false positive of
    # 2026-07-06 came from 'CATLAM' resolving to the maritime 1-4 domain).
    def _attr_defs(self, attr):
        """All FC attribute definitions matching ``attr`` case-insensitively."""
        if not attr:
            return []
        return self._attr_ci.get(str(attr).lower(), [])

    def _feature_defs(self, klass):
        """All FC class definitions matching ``klass`` case-insensitively."""
        if not klass:
            return []
        return self._feat_ci.get(str(klass).lower(), [])

    def _feature(self, klass):
        """The FC feature class for ``klass``: exact case first (the dataset's
        casing IS the maritime/inland selector), else the case-insensitive
        match (first definition when several exist)."""
        f = self.features.get(klass)
        if f is None:
            defs = self._feature_defs(klass)
            f = defs[0] if defs else None
        return f

    def has_attr(self, attr):
        """Does the FC describe this attribute (any case-variant)?"""
        return bool(self._attr_defs(attr))

    # -- attribute typing (mirrors maritime Catalogue) ------------------------
    def attr_type(self, attr):
        a = self.attributes.get(attr)          # exact case wins when present
        if a is not None:
            return a.value_type
        defs = self._attr_defs(attr)
        return defs[0].value_type if defs else None

    def is_enumerated(self, attr):
        """True for enumerated-code attributes (type E) — any case-variant."""
        return any(a.value_type == "E" for a in self._attr_defs(attr))

    def is_list(self, attr):
        """True for list attributes (type L, comma-separated codes) — any
        case-variant."""
        return any(a.value_type == "L" for a in self._attr_defs(attr))

    def is_single_valued(self, attr):
        """Types where a comma separates distinct values, so more than one is an
        encoding error (check 1679): enumerated (E), float (F), integer (I).
        Free-text (T) and coded-string (S) are excluded -- their commas are
        content, not separators (e.g. SORIND 'agency,country,source,...').
        Case-variant tolerant: only single-valued when EVERY variant is (a list
        variant makes commas legitimate)."""
        defs = self._attr_defs(attr)
        return bool(defs) and all(a.value_type in ("E", "F", "I")
                                  for a in defs)

    def enum_domain(self, attr):
        """value -> name map for an enumerated attribute ({} if none). UNIONS
        every case-variant: the FC's maritime CATLAM (1-4) and inland catlam
        (1-27) are both legitimate in a hybrid product, and the audit's
        canonical acronym cannot tell which one the dataset encoded."""
        out = {}
        for a in self._attr_defs(attr):
            out.update(a.enum)
        return out

    # -- per-object-class rules ----------------------------------------------
    def permits_attr(self, klass, attr):
        """Is ``attr`` bound to object class ``klass``? Unknown class -> True,
        so we never raise a false positive on a class the catalogue does not
        describe (same contract as the maritime check 547). When the class name
        arrives canonicalised (upper) and the FC holds DUAL-CASE definitions
        (GATCON + gatcon), permitted-by-EITHER wins — conservative, never a
        false positive from picking the wrong twin."""
        defs = self._feature_defs(klass)
        if not defs:
            return True
        return any(d.binding(attr) is not None for d in defs)

    def permits_primitive(self, klass, primitive):
        """Is geometric ``primitive`` ('P'/'L'/'A'/'N') permitted for ``klass``?
        Unknown class -> True (no false positive); dual-case definitions union
        (see permits_attr)."""
        defs = self._feature_defs(klass)
        if not defs:
            return True
        return any(primitive in d.primitives for d in defs)

    def _attrs_with_usage(self, klass, usage):
        f = self._feature(klass)
        if f is None:
            return []
        return [b.acronym for b in f.bindings.values() if b.usage == usage]

    def mandatory_attrs(self, klass):
        """Attributes that MUST be encoded on ``klass`` (binding usage M)."""
        return self._attrs_with_usage(klass, "M")

    def conditional_attrs(self, klass):
        """Attributes mandatory on ``klass`` only if conditions hold (usage C)."""
        return self._attrs_with_usage(klass, "C")

    def optional_attrs(self, klass):
        return self._attrs_with_usage(klass, "O")

    def allowable_values(self, klass, attr):
        """Permitted value set for ``attr`` on ``klass``: the bindings'
        ``valueList`` if any narrows the domain, else the attribute's full
        enumeration domain. Dual-case class twins (BOYLAT/boylat) UNION their
        valueLists — the conservative hybrid rule (see permits_attr). Empty
        list => not restricted / not enumerated."""
        vals = []
        for d in self._feature_defs(klass):
            b = d.binding(attr)
            if b and b.value_list:
                vals.extend(v for v in b.value_list if v not in vals)
        if vals:
            return vals
        return list(self.enum_domain(attr).keys())

    def feature_type(self, klass):
        """'G' geo / 'M' meta / 'O' collection, or None if unknown."""
        f = self._feature(klass)
        return f.ftype if f else None

    def known_class(self, klass):
        return self._feature(klass) is not None
