# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/checks_attributes.py
-------------------------------
First batch of S-58 attribute/value checks. These need no geometry, so they are
fully exercised by the headless test suite.

Each check is registered with its S-58 8.0.0 number and severity. Where S-58
expresses a family of near-identical checks (e.g. "VERDAT/VERACC prohibited on
class X" repeated dozens of times) we drive them from a data table rather than
writing dozens of identical functions -- one registered check iterates the
table and emits the correct per-class message.

Value semantics follow S-58 8.0.0 1.4.3 via model.present / known / unknown.
"""

import re

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


def _cells(ds):
    """Native S-57 record cells (GDAL-free) if the reader is attached, else []."""
    s = getattr(ds, "s57", None)
    return list(s) if s else []


def _real_attrs(feature, cat):
    """Yield (attr, value) only for genuine S-57 attributes (those in the
    attribute catalogue). GDAL surfaces identifier/internal fields on every
    feature -- AGEN, FIDN, FIDS, LNAM, OBJL, RVER, GRUP, etc. -- which are not
    catalogue attributes; the generic attribute checks must ignore them so they
    don't masquerade as 'attribute not permitted' (547) or bad values."""
    for attr, value in feature.attrs.items():
        if attr in cat.attr_acronym_to_code:
            yield attr, value


# ---------------------------------------------------------------------------
# Generic, catalogue-driven checks (apply to every feature)
# ---------------------------------------------------------------------------

@check(547, Severity.CRITICAL, "Object catalogue",
       "Attribute not permitted on feature object class.",
       "Remove attribute.", "3.2 and Supplement No.3 Ch.2")
def attr_not_permitted(ds, cat, geom):
    for f in ds.features():
        if cat.ienc_owns(f.klass, f.cell):
            continue                     # FC-owned class -> IE547 is authority
        for attr, _ in _real_attrs(f, cat):
            if not cat.permits_attr(f.klass, attr):
                yield Finding(_meta(547), f.cell, f.klass, f.fid,
                              detail=attr)


@check("20a", Severity.CRITICAL, "Data structure",
       "Geometric primitive of this type is not permitted for this object "
       "class.",
       "Use alternative geometric primitive or alternative object class.",
       "Part 3 (4.2.1), Appendix B.1 (3.3)")
def primitive_not_permitted(ds, cat, geom):
    for f in ds.features():
        if cat.ienc_owns(f.klass, f.cell):
            continue                     # FC-owned class -> IE20a is authority
        if f.primitive and not cat.permits_primitive(f.klass, f.primitive):
            yield Finding(_meta("20a"), f.cell, f.klass, f.fid,
                          detail=f"primitive {f.primitive}")


@check("2000d", Severity.ERROR, "Allowable attribute values",
       "Attribute value is not a valid code for this attribute.",
       "Remove disallowed attribute value.", "Appendix A, Chapter 2")
def value_outside_domain(ds, cat, geom):
    """Flag enumerated/list attribute values that are not valid codes for the
    attribute (outside its whole domain, unioned across the maritime catalogue
    and any attached IENC overlay).

    Read from the native ISO 8211 record model (ATTF/ATVL), not GDAL: an EMPTY
    ATVL is seen as empty and skipped here -- it is a value-*presence* matter for
    507 -- rather than coerced to a literal code 0 the way a typed OGR integer
    field does. That coercion was the source of the ambiguous '=0' findings."""
    for cell in _cells(ds):
        for fr in cell.features:
            klass = cat.code_to_acronym.get(fr.objl) or str(fr.objl)
            if cat.ienc_owns(klass, cell.name):
                continue                  # FC-owned class -> IE2000 is authority
            fid = fr.foid[1] if fr.foid else fr.rcid
            for code, atvl in fr.attrs.items():
                attr = cat.attr_code_to_acronym.get(code)
                if attr is None:
                    continue
                if atvl is None or str(atvl).strip() == "":
                    continue                     # empty value -> 507's domain
                if not cat.attr_is_coded(attr):
                    continue
                domain = cat.effective_domain(attr)
                if not domain:
                    continue
                for tok in list_values(str(atvl)):
                    try:
                        v = int(tok)
                    except ValueError:
                        continue
                    if v not in domain:
                        yield Finding(_meta("2000d"), cell.name, klass, fid,
                                      detail=f"{attr}={tok}")


@check(1679, Severity.CRITICAL, "Allowable attribute values",
       "More than one value present for a single-valued attribute "
       "(enumerated, float, integer or code-string).",
       "Remove unnecessary attribute values.", "Appendix A, Ch.2 (2.1)")
def single_valued_has_many(ds, cat, geom):
    for f in ds.features():
        for attr, value in _real_attrs(f, cat):
            if value is None or not cat.is_single_valued(attr):
                continue
            if len(list_values(value)) > 1:
                yield Finding(_meta(1679), f.cell, f.klass, f.fid,
                              detail=attr)


@check("73a", Severity.WARNING, "Logical consistency",
       "Attribute value contains leading or trailing spaces.",
       "Remove leading or trailing spaces.", "Logical consistency")
def leading_trailing_spaces(ds, cat, geom):
    for f in ds.features():
        for attr, value in _real_attrs(f, cat):
            if isinstance(value, str) and value != value.strip():
                yield Finding(_meta("73a"), f.cell, f.klass, f.fid,
                              detail=attr)


@check("73b", Severity.WARNING, "Logical consistency",
       "List attribute value contains spaces.",
       "Remove spaces.", "Logical consistency")
def list_value_spaces(ds, cat, geom):
    for f in ds.features():
        for attr, value in _real_attrs(f, cat):
            if value is None or not cat.is_list(attr):
                continue
            if isinstance(value, str) and " " in value.strip():
                yield Finding(_meta("73b"), f.cell, f.klass, f.fid,
                              detail=attr)


@check(512, Severity.ERROR, "ENC product specification",
       "Numeric value padded with non-significant zeroes "
       "(e.g. 02.500 instead of 2.5).",
       "Remove non-significant zeroes.", "3.5.4")
def nonsignificant_zeroes(ds, cat, geom):
    for f in ds.features():
        for attr, value in _real_attrs(f, cat):
            if value is None:
                continue
            if cat.attr_type.get(attr) not in ("F", "I"):
                continue
            # 512 judges the ENCODED representation. OGR returns parsed numbers
            # for F/I fields, discarding the original string, so we can only
            # assess padding when the value reaches us as a raw string. With
            # parsed floats (10.0, 0.0) we must NOT fire -- that would flag
            # every clean depth. Raw-subfield access (ISO 8211) is required to
            # cover 512 fully; tracked in COVERAGE.md.
            if not isinstance(value, str):
                continue
            s = str(value).strip()
            if _has_nonsignificant_zero(s):
                yield Finding(_meta(512), f.cell, f.klass, f.fid,
                              detail=f"{attr}={s}")


# ---------------------------------------------------------------------------
# Date / time format checks
# ---------------------------------------------------------------------------

_DATE_ATTRS_CRITICAL = ("DATEND", "DATSTA", "PEREND", "PERSTA")   # 1515a
_DATE_ATTRS_ERROR = ("SORDAT", "SUREND", "SURSTA")                # 1515b


@check("1515a", Severity.CRITICAL, "Object catalogue",
       "Date attribute not formatted according to ISO 8601:1988.",
       "Amend formatting to conform to ISO 8601:1988.", "2.1.5")
def date_format_critical(ds, cat, geom):
    yield from _date_format(ds, _DATE_ATTRS_CRITICAL, "1515a")


@check("1515b", Severity.ERROR, "Object catalogue",
       "Date attribute not formatted according to ISO 8601:1988.",
       "Amend formatting to conform to ISO 8601:1988.", "2.1.5")
def date_format_error(ds, cat, geom):
    yield from _date_format(ds, _DATE_ATTRS_ERROR, "1515b")


@check(1517, Severity.ERROR, "Object catalogue",
       "TIMEND or TIMSTA is not formatted correctly.",
       "Amend the formatting of TIMEND or TIMSTA.", "2.1.6")
def time_format(ds, cat, geom):
    for f in ds.features():
        for attr in ("TIMEND", "TIMSTA"):
            if known(f.attrs, attr) and not _is_iso_datetime(str(f.attrs[attr])):
                yield Finding(_meta(1517), f.cell, f.klass, f.fid, detail=attr)


# ---------------------------------------------------------------------------
# Logical pair / dependency checks
# ---------------------------------------------------------------------------

@check(46, Severity.ERROR, "Logical consistency",
       "DATEND is less than or equal to DATSTA.",
       "Amend values of DATEND or DATSTA accordingly.", "Logical consistency")
def datend_le_datsta(ds, cat, geom):
    for f in ds.features():
        a, b = f.attrs.get("DATSTA"), f.attrs.get("DATEND")
        if a is not None and b is not None and _as_date(b) is not None \
                and _as_date(a) is not None and _as_date(b) <= _as_date(a):
            yield Finding(_meta(46), f.cell, f.klass, f.fid)


@check(49, Severity.ERROR, "Logical consistency",
       "DRVAL2 is less than DRVAL1.",
       "Amend the values of DRVAL1 or DRVAL2 as required.",
       "Logical consistency")
def drval2_lt_drval1(ds, cat, geom):
    for f in ds.features():
        a, b = _num(f.attrs.get("DRVAL1")), _num(f.attrs.get("DRVAL2"))
        if a is not None and b is not None and b < a:
            yield Finding(_meta(49), f.cell, f.klass, f.fid,
                          detail=f"DRVAL1={a}, DRVAL2={b}")


@check(568, Severity.ERROR, "Logical consistency",
       "Object has identical values of PERSTA and PEREND.",
       "Ensure values of PERSTA and PEREND are logical.", "Logical consistency")
def persta_eq_perend(ds, cat, geom):
    for f in ds.features():
        a, b = f.attrs.get("PERSTA"), f.attrs.get("PEREND")
        if a is not None and b is not None and str(a) == str(b):
            yield Finding(_meta(568), f.cell, f.klass, f.fid)


@check(522, Severity.ERROR, "ENC product specification",
       "NOBJNM is populated without OBJNAM.",
       "Populate OBJNAM.", "3.11.1")
def nobjnm_without_objnam(ds, cat, geom):
    for f in ds.features():
        if known(f.attrs, "NOBJNM") and not known(f.attrs, "OBJNAM"):
            yield Finding(_meta(522), f.cell, f.klass, f.fid)


# National-language attribute populated without its base attribute (572-574).
_NAT_PAIRS = {                # national : base    : check
    ("NINFOM", "INFORM"): 572,
    ("NPLDST", "PILDST"): 573,
    ("NTXTDS", "TXTDSC"): 574,
}


@check(572, Severity.ERROR, "ENC product specification",
       "National-language attribute populated without its base attribute.",
       "Populate the base (English) attribute.", "3.11.1")
def national_without_base(ds, cat, geom):
    for f in ds.features():
        for (nat, base), num in _NAT_PAIRS.items():
            if known(f.attrs, nat) and not known(f.attrs, base):
                yield Finding(_meta(572), f.cell, f.klass, f.fid,
                              detail=f"{nat} without {base}")


# National == base value (521a-d): the national field merely repeats English.
_NAT_EQUAL_PAIRS = (("OBJNAM", "NOBJNM"), ("INFORM", "NINFOM"),
                    ("PILDST", "NPLDST"), ("TXTDSC", "NTXTDS"))


@check("521", Severity.WARNING, "ENC product specification",
       "National-language attribute is identical to its base attribute.",
       "Ensure national-language attributes carry the correct values.",
       "3.11.1")
def national_equals_base(ds, cat, geom):
    for f in ds.features():
        for base, nat in _NAT_EQUAL_PAIRS:
            if known(f.attrs, base) and known(f.attrs, nat) \
                    and str(f.attrs[base]) == str(f.attrs[nat]):
                yield Finding(_meta("521"), f.cell, f.klass, f.fid,
                              detail=f"{base}=={nat}")


# ---------------------------------------------------------------------------
# Prohibited-attribute family (data-driven) — RETIRED for UOC 4.4.0
# ---------------------------------------------------------------------------
# The previous table marked VERACC/VERDAT (and BURDEP/CONDTN/NATCON/NATQUA/
# HORACC) as unconditionally prohibited on ~50 object classes. Verification
# against UOC 4.4.0 (October 2024) found this inverted: all 90 (class,
# attribute) pairs are listed as ALLOWABLE in 4.4.0 — 80 confirmed by automatic
# extraction of the per-object allowable lists, the remaining 10 (the beacons)
# confirmed by hand where the extractor missed their shared attribute table.
# The rules were carried over from an older edition and false-positived on
# conformant 4.4.0 cells.
#
# The table is therefore empty: the PROH check still runs but emits nothing.
# Planned replacements: a *conditional* successor (e.g. VERDAT only where a
# vertical measurement is present) and per-object allowable/mandatory checks
# driven by the UOC 4.4.0 extraction.
#   (class, (attrs...), check_number, conformity)
_PROHIBITED = []


@check("PROH", Severity.ERROR, "Object catalogue",
       "Prohibited attribute populated for this object class.",
       "Remove the prohibited attribute.", "see Conformity per row")
def prohibited_attributes(ds, cat, geom):
    for klass, attrs, number, conformity in _PROHIBITED:
        for f in ds.features(klass):
            for attr in attrs:
                if present(f.attrs, attr):
                    meta = _meta_custom(
                        number, Severity.ERROR, "Object catalogue",
                        f"Prohibited attribute {attr} populated for a "
                        f"{klass} object.",
                        f"Remove {attr} from {klass} object.", conformity)
                    yield Finding(meta, f.cell, f.klass, f.fid, detail=attr)


# ---------------------------------------------------------------------------
# small utilities
# ---------------------------------------------------------------------------
_ISO_DATE_RE = re.compile(
    r"^\d{4}(\d{2})?(\d{2})?$"      # CCYY[MM[DD]]  basic
    r"|^\d{4}-\d{2}(-\d{2})?$"      # CCYY-MM[-DD]  extended
    r"|^--\d{2}(\d{2})?$"           # --MM[DD]      year omitted, basic (recurring)
    r"|^--\d{2}-\d{2}$"            # --MM-DD       year omitted, extended (recurring)
)
_ISO_TIME_RE = re.compile(r"^\d{4}(\d{2})?(\d{2})?T?\d{0,6}Z?$")


def _is_iso_date(value):
    return bool(_ISO_DATE_RE.match(value.strip()))


def _is_iso_datetime(value):
    return bool(_ISO_TIME_RE.match(value.strip()))


def _date_format(ds, attrs, number):
    for f in ds.features():
        for attr in attrs:
            if known(f.attrs, attr) and not _is_iso_date(str(f.attrs[attr])):
                yield Finding(_meta(number), f.cell, f.klass, f.fid,
                              detail=f"{attr}={f.attrs[attr]}")


def _as_date(value):
    """Parse YYYYMMDD / YYYY-MM-DD into a comparable int YYYYMMDD, else None."""
    s = str(value).strip().replace("-", "")
    if not s.isdigit():
        return None
    if len(s) == 4:
        s += "0101"
    elif len(s) == 6:
        s += "01"
    if len(s) != 8:
        return None
    return int(s)


def _num(value):
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _has_nonsignificant_zero(s):
    """True if a numeric string carries a leading zero before the integer part
    or a trailing zero after the decimal fraction (S-58 512)."""
    if not s or not re.match(r"^-?\d*\.?\d+$", s):
        return False
    neg = s.startswith("-")
    body = s[1:] if neg else s
    if "." in body:
        intpart, frac = body.split(".", 1)
    else:
        intpart, frac = body, ""
    if len(intpart) > 1 and intpart.startswith("0"):
        return True
    if frac and frac.endswith("0"):
        return True
    return False


# -- metadata accessors -------------------------------------------------------
# Findings reference the CheckMeta of the registered function. For the
# data-driven families we synthesise per-row metas; for the rest we look the
# registered meta up by number.
def _meta(number):
    from .engine import REGISTRY
    for m in REGISTRY:
        if m.number == number:
            return m
    return _meta_custom(number, Severity.ERROR, "", "", "", "")


def _meta_custom(number, severity, category, message, solution, conformity):
    from .engine import CheckMeta
    return CheckMeta(number, severity, category, message, solution,
                     conformity, None)
