# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/checks_ienc.py
-------------------------
Inland ENC **Feature-Catalogue** conformance checks — the product-specific tests
for a hybrid / Inland ENC product, driven by the IENC FC attached for the cells'
declared edition (PRED). They mirror their maritime cousins but read their rules
from the FC instead of the S-57 catalogue:

    IE547  attribute not permitted on the class        (FC AttributeBinding)
    IE20a  geometric primitive not permitted           (FC Feature primitive=)
    IE2000 attribute value not a valid code            (FC Attribute Enumeration)
    IE509  mandatory attribute missing                 (FC binding usage="M")

HYBRID precedence: each check runs ONLY on classes the FC defines
(``cat.ienc_owns`` — case-tolerant), and the matching maritime checks skip those
same classes (see checks_attributes / checks_structure). So an Inland ENC product
with added maritime objects validates each object against the right standard, and
the FC tests are the authority for the product's own classes. With no FC attached
these checks yield nothing, so a pure-maritime audit is unaffected.

The FC keys classes/attributes upper-case; the dataset may surface Inland
acronyms lower-case (GDAL gives upper-case, the native reader can give lower-case
for Inland-edition codes), so every FC lookup is done on the canonical upper-case
acronym — this is what stops valid Inland values (e.g. CATSLC=2) reading as
out-of-domain, the false-positive the maritime path produced without the FC.
"""

from .engine import check, Severity, Finding
from .model import known, list_values

_CAT = "Inland ENC Feature Catalogue"


def _canon(acronym):
    """Canonical (upper-case) acronym for an FC lookup, or '' if blank."""
    return str(acronym).upper() if acronym else ""


def _fc_attrs(feature, ienc):
    """Yield (original_attr_name, canonical_acronym, value) for the feature's
    attributes that the FC actually describes — skipping GDAL/internal fields
    (RCID, OBJL, FIDN, …) the FC does not list, exactly as the maritime checks
    skip non-catalogue fields. Case-tolerant: the FC keys inland attribute
    acronyms lower-case (catlam) while the canonical acronym is upper-case."""
    for attr, value in feature.attrs.items():
        a = _canon(attr)
        if ienc.has_attr(a):
            yield attr, a, value


# S-57's GENERAL attribute sets (Attribute_B cartographic/informational +
# Attribute_C administrative) are applicable to every object class; the IENC FC
# repeats them on many bindings but not all, and producers legitimately encode
# them everywhere (SCAMIN on every geo object is universal practice). A strict
# binding-list reading would flag hundreds of SCAMINs per cell — noise, not
# findings — so IE547 exempts these attributes (conservative under-reporting).
_GENERAL_ATTRS = frozenset({
    "INFORM", "NINFOM", "NTXTDS", "OBJNAM", "NOBJNM", "PICREP", "SCAMIN",
    "TXTDSC",                                       # Attribute_B
    "RECDAT", "RECIND", "SORDAT", "SORIND",         # Attribute_C
})


@check("IE547", Severity.CRITICAL, _CAT,
       "Attribute not permitted on this feature class (Inland ENC FC).",
       "Remove the attribute, or use the correct class.", "Inland ENC FC",
       name="inland-enc-fc/attribute-not-permitted", validated=False)
def ienc_attr_not_permitted(ds, cat, geom):
    if not cat.has_ienc():
        return
    for f in ds.features():
        ienc = cat.ienc_for(f.cell)          # the FC for THIS cell's PRED
        if ienc is None or not cat.ienc_owns(f.klass, f.cell):
            continue
        # Pass the ORIGINAL class name: its case selects between the FC's
        # dual-case twins (GATCON maritime / gatcon inland).
        for attr, a, _v in _fc_attrs(f, ienc):
            if a in _GENERAL_ATTRS:
                continue
            if not ienc.permits_attr(f.klass, a):
                yield Finding(_meta("IE547"), f.cell, f.klass, f.fid, detail=attr)


@check("IE20a", Severity.CRITICAL, _CAT,
       "Geometric primitive not permitted for this feature class "
       "(Inland ENC FC).",
       "Use a permitted geometric primitive or another class.", "Inland ENC FC",
       name="inland-enc-fc/geometric-primitive-not-permitted", validated=False)
def ienc_primitive_not_permitted(ds, cat, geom):
    if not cat.has_ienc():
        return
    for f in ds.features():
        ienc = cat.ienc_for(f.cell)
        if ienc is None or not cat.ienc_owns(f.klass, f.cell):
            continue
        if f.primitive and not ienc.permits_primitive(f.klass,
                                                      f.primitive):
            yield Finding(_meta("IE20a"), f.cell, f.klass, f.fid,
                          detail=f"primitive {f.primitive}")


@check("IE2000", Severity.ERROR, _CAT,
       "Attribute value is not a valid code for this attribute "
       "(Inland ENC FC).",
       "Use a value valid in the Inland ENC FC or the S-57 catalogue.",
       "Inland ENC FC",
       name="inland-enc-fc/attribute-value-not-a-valid-code", validated=False)
def ienc_value_not_valid(ds, cat, geom):
    """Value-domain check for FC-owned classes, against the UNION of the IENC FC
    and the S-57 catalogue (``effective_domain``) on the canonical UPPER-case
    acronym. The union is the hybrid-correct rule — the FC lists only the
    inland-relevant enumerations (e.g. it gives VERDAT just one value) and relies
    on S-57 for the rest, while it ADDS inland-specific values (e.g. an extended
    CATSLC). Canonicalising the acronym is what stops valid inland values like
    CATSLC=2 reading as out-of-domain (the bundled catalogue holds a truncated
    lower-case 'catslc' domain; the upper-case domain is correct)."""
    if not cat.has_ienc():
        return
    for f in ds.features():
        if not cat.ienc_owns(f.klass, f.cell):
            continue
        for attr, value in f.attrs.items():
            a = _canon(attr)
            if not cat.attr_is_coded(a, f.cell):
                continue
            domain = cat.effective_domain(a, f.cell)
            if not domain:
                continue
            if value is None or str(value).strip() == "":
                continue                     # empty value -> IE509's concern
            for tok in list_values(value):
                try:
                    iv = int(tok)
                except (TypeError, ValueError):
                    continue
                if iv not in domain:
                    yield Finding(_meta("IE2000"), f.cell, f.klass, f.fid,
                                  detail=f"{attr}={tok}")


@check("IE509", Severity.CRITICAL, _CAT,
       "Mandatory attribute is missing or empty for this feature class "
       "(Inland ENC FC).",
       "Populate the mandatory attribute.", "Inland ENC FC",
       name="inland-enc-fc/mandatory-attribute-missing", validated=False)
def ienc_mandatory_attr_missing(ds, cat, geom):
    if not cat.has_ienc():
        return
    for f in ds.features():
        ienc = cat.ienc_for(f.cell)
        if ienc is None or not cat.ienc_owns(f.klass, f.cell):
            continue
        # Case-folded value map so a lower-case Inland attr is still matched.
        # The ORIGINAL class name selects between dual-case FC twins
        # (DEPARE/depare) — each has its own mandatory list.
        vals = {_canon(k): v for k, v in f.attrs.items()}
        for m in ienc.mandatory_attrs(f.klass):
            if not known(vals, _canon(m)):
                yield Finding(_meta("IE509"), f.cell, f.klass, f.fid,
                              detail=f"{m} missing")


# -- citation lookup ----------------------------------------------------------
def _meta(number):
    from .engine import REGISTRY, CheckMeta
    for m in REGISTRY:
        if m.number == number:
            return m
    return CheckMeta(number, Severity.ERROR, _CAT, "", "", "", None)
