# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/catalogue.py
-----------------------
Loads the bundled S-57 object/attribute catalogue (the three CSVs already
shipped in ``s57catalogue/``) and exposes the metadata the S-58 checks need:

    * acronym  <-> numeric code   (object classes and attributes)
    * permissible attribute set per object class   (Attribute_A u B u C)
    * geometric primitives allowed per object class (P / L / A)
    * attribute data type           (E enum, L list, F float, I int, A/S string)
    * enumerated value domain per attribute  (from s57expectedinput.csv)

Why reuse the plugin's own CSVs rather than re-keying S-57 Appendix A: the
bundled catalogue is the *superset* (standard classes 1-504 PLUS the Inland-ENC
extensions) that GDAL already decodes against, so the validator sees exactly the
same class/attribute universe the loader does -- no drift between what is loaded
and what is validated.

This module is pure stdlib (csv only) so it is fully testable outside QGIS.
"""

import os
import csv

# Geometric primitive single-letter codes used throughout the checks.
P, L, A = "P", "L", "A"

_PRIMITIVE_MAP = {
    "Point": P,
    "Line": L,
    "Area": A,
}


class Catalogue:
    """In-memory view over the s57*.csv catalogue files.

    Construct once per validation run and pass to the engine; lookups are plain
    dict access so it is cheap to query inside tight per-feature loops.
    """

    def __init__(self, catalogue_dir):
        self.dir = catalogue_dir

        # Object classes -------------------------------------------------------
        self.code_to_acronym = {}        # 71      -> "LNDARE"
        self.acronym_to_code = {}        # "LNDARE"-> 71
        self.class_name = {}             # "LNDARE"-> "Land area"
        self.permissible_attrs = {}      # "LNDARE"-> frozenset(acronyms)
        self.primitives = {}             # "LNDARE"-> frozenset({"A","P","L"})
        self.class_group = {}            # "LNDARE"-> "G" (geo) / "M" (meta) ...

        # Attributes -----------------------------------------------------------
        self.attr_code_to_acronym = {}   # 87      -> "DRVAL1"
        self.attr_acronym_to_code = {}   # "DRVAL1"-> 87
        self.attr_type = {}              # "DRVAL1"-> "F"
        self.enum_domain = {}            # "WATLEV"-> {1,2,3,4,5,6,7}
        self.enum_meaning = {}           # "COLOUR"-> {1:"white", 3:"red", ...}

        # Optional Inland ENC overlay (an IencCatalogue). When attached, the
        # value-domain checks accept a code valid in EITHER standard, so inland
        # values stop reading as out-of-domain on hybrid / Inland ENC products.
        # ``ienc`` is the primary/fallback FC (single-edition sets, or a cell
        # whose PRED has no matching FC). For a MIXED-edition set (e.g. cells of
        # PRED 2.3 AND 2.4) ``ienc_by_pred`` maps each PRED to its FC and
        # ``cell_pred`` maps each cell to its PRED, so every cell is validated
        # against the edition it declares — see ``ienc_for``.
        self.ienc = None
        self.ienc_by_pred = {}           # PRED -> IencCatalogue
        self.cell_pred = {}              # cell name -> PRED

        self._load_object_classes()
        self._load_attributes()
        self._load_expected_input()

    # -- loaders --------------------------------------------------------------
    def _path(self, name):
        return os.path.join(self.dir, name)

    def _load_object_classes(self):
        path = self._path("s57objectclasses.csv")
        with open(path, newline="", encoding="utf-8-sig") as fh:
            for row in csv.DictReader(fh):
                acr = (row.get("Acronym") or "").strip()
                if not acr:
                    continue
                try:
                    code = int(row["Code"])
                except (KeyError, TypeError, ValueError):
                    code = None
                self.acronym_to_code[acr] = code
                if code is not None:
                    self.code_to_acronym[code] = acr
                self.class_name[acr] = (row.get("ObjectClass") or "").strip()
                self.class_group[acr] = (row.get("Class") or "").strip()

                attrs = set()
                for col in ("Attribute_A", "Attribute_B", "Attribute_C"):
                    attrs.update(self._split_semis(row.get(col)))
                self.permissible_attrs[acr] = frozenset(attrs)

                prims = set()
                for token in self._split_semis(row.get("Primitives")):
                    code_letter = _PRIMITIVE_MAP.get(token.strip())
                    if code_letter:
                        prims.add(code_letter)
                self.primitives[acr] = frozenset(prims)

    def _load_attributes(self):
        path = self._path("s57attributes.csv")
        with open(path, newline="", encoding="utf-8-sig") as fh:
            for row in csv.DictReader(fh):
                acr = (row.get("Acronym") or "").strip()
                if not acr:
                    continue
                try:
                    code = int(row["Code"])
                except (KeyError, TypeError, ValueError):
                    code = None
                self.attr_acronym_to_code[acr] = code
                if code is not None:
                    self.attr_code_to_acronym[code] = acr
                self.attr_type[acr] = (row.get("Attributetype") or "").strip()

    def _load_expected_input(self):
        """Build the enumerated value domain per attribute from
        s57expectedinput.csv (Code = attribute code, ID = allowed value)."""
        path = self._path("s57expectedinput.csv")
        if not os.path.isfile(path):
            return
        by_code = {}
        meaning = {}
        with open(path, newline="", encoding="utf-8-sig") as fh:
            for row in csv.DictReader(fh):
                try:
                    code = int(row["Code"])
                    value = int(row["ID"])
                except (KeyError, TypeError, ValueError):
                    continue
                by_code.setdefault(code, set()).add(value)
                label = (row.get("Meaning") or "").strip()
                if label:
                    meaning.setdefault(code, {})[value] = label
        for code, values in by_code.items():
            acr = self.attr_code_to_acronym.get(code)
            if acr:
                self.enum_domain[acr] = values
                if code in meaning:
                    self.enum_meaning[acr] = meaning[code]

    # -- helpers --------------------------------------------------------------
    @staticmethod
    def _split_semis(value):
        """Split a 'A;B;C;' catalogue cell into clean tokens (no empties)."""
        if not value:
            return []
        return [tok.strip() for tok in value.split(";") if tok.strip()]

    def is_enumerated(self, attr):
        """True for attributes whose value is an enumerated code (type E)."""
        return self.attr_type.get(attr) == "E"

    # -- human-readable enum labels (for the Source Validator recipe builder) --
    def enum_labels(self, attr):
        """``{code:int -> meaning:str}`` for an enumerated attribute, e.g.
        COLOUR -> {1:"white", 3:"red", ...}. Empty dict if not enumerated or
        no meanings are bundled. Reads only the maritime catalogue meanings;
        inland-only codes (overlay) fall back to their numeric code via
        ``label_for``."""
        return dict(self.enum_meaning.get(attr, {}))

    def label_for(self, attr, code):
        """The textual meaning of an enumerated ``code`` (e.g. (COLOUR, 3) ->
        "red"). Falls back to the stringified code when no meaning is known, so
        a label is always available."""
        try:
            code = int(code)
        except (TypeError, ValueError):
            return str(code)
        return self.enum_meaning.get(attr, {}).get(code, str(code))

    def code_for_label(self, attr, label):
        """Inverse of ``label_for`` — the integer code whose meaning matches
        ``label`` (case-insensitive). ``None`` if no match. Used to resolve a
        recipe that was authored against human-readable labels back to the code
        the ENC actually stores."""
        if label is None:
            return None
        want = str(label).strip().casefold()
        for code, meaning in self.enum_meaning.get(attr, {}).items():
            if meaning.casefold() == want:
                return code
        return None

    def is_list(self, attr):
        """True for list attributes (type L) — space/comma separated codes."""
        return self.attr_type.get(attr) == "L"

    def is_single_valued(self, attr):
        """True for the types where a comma separates distinct values, so more
        than one is an encoding error (check 1679): enumerated (E), float (F)
        and integer (I). Free-text (A) and coded-string (S) attributes are
        excluded — their commas are content, not separators (e.g. SORIND is
        encoded as 'agency,country,source,...' in a single value)."""
        return self.attr_type.get(attr) in ("E", "F", "I")

    def permits_attr(self, klass, attr):
        """Is ``attr`` permissible on object class ``klass`` (S-58 check 547)?
        Unknown classes return True so we never raise a false positive on a
        class the catalogue does not describe."""
        allowed = self.permissible_attrs.get(klass)
        return True if allowed is None else attr in allowed

    def permits_primitive(self, klass, primitive):
        """Is geometric ``primitive`` ('P'/'L'/'A') allowed for ``klass``
        (S-58 check 20a)? Unknown classes return True (no false positive)."""
        allowed = self.primitives.get(klass)
        return True if allowed is None else primitive in allowed

    # -- Inland ENC overlay ---------------------------------------------------
    def attach_ienc(self, ienc):
        """Attach a single parsed IENC Feature Catalogue as the overlay (or None
        to detach). Value-domain checks then accept a code valid in either the
        maritime catalogue or the IENC catalogue, so inland-specific codes stop
        reading as out-of-domain on hybrid and Inland ENC products. Use this for
        a single-edition set; for a mixed-edition set use ``attach_ienc_set``."""
        self.ienc = ienc

    def attach_ienc_set(self, ienc_by_pred, cell_pred, primary=None):
        """Attach a PER-CELL set of IENC Feature Catalogues for a mixed-edition
        source: ``ienc_by_pred`` maps each PRED to its FC, ``cell_pred`` maps each
        cell name to its PRED, and ``primary`` is the fallback FC (the dominant
        edition) for cells whose PRED has no installed FC. Every cell is then
        validated against the edition it declares — see ``ienc_for``."""
        self.ienc_by_pred = dict(ienc_by_pred or {})
        self.cell_pred = dict(cell_pred or {})
        self.ienc = primary if primary is not None else self.ienc

    def has_ienc(self):
        """True if any IENC FC is attached (single or per-cell set)."""
        return self.ienc is not None or bool(self.ienc_by_pred)

    def ienc_for(self, cell=None):
        """The IENC FC governing a given cell, or None.

        A cell mapped in ``cell_pred`` to a FALSY PRED ('' / None) is a MARITIME
        cell (PRSP not Inland) and gets NO FC — so a maritime cell in a mixed
        maritime+Inland set is never validated against an Inland FC. A cell mapped
        to a real PRED gets that edition's FC (or the primary as fallback). A cell
        NOT in the map (single-edition runs, non-cell callers) returns the
        primary, preserving prior behaviour."""
        if cell is not None and cell in self.cell_pred:
            pred = self.cell_pred[cell]
            if not pred:
                return None                 # explicit maritime marker -> no FC
            return self.ienc_by_pred.get(pred) or self.ienc
        return self.ienc

    def ienc_owns(self, klass, cell=None):
        """True if the IENC FC governing ``cell`` defines this object class
        (case-tolerant: maritime resolves Inland codes to lower-case acronyms,
        the FC keys upper-case).

        This routes HYBRID products (Inland ENC with added maritime objects): the
        IENC FC is authoritative for the classes it defines, so the FC-conformance
        checks own them and the maritime equivalents (547 / 20a / 2000d / 507)
        skip them; classes the FC does not define fall back to the maritime
        catalogue. Returns False when no FC governs the cell (pure-maritime
        audit), leaving every maritime check unchanged."""
        ienc = self.ienc_for(cell)
        if ienc is None or not klass:
            return False
        k = str(klass)
        return ienc.known_class(k) or ienc.known_class(k.upper())

    def attr_is_coded(self, attr, cell=None):
        """True if the attribute is enumerated or list in the maritime catalogue
        or the IENC FC governing ``cell``."""
        if self.is_enumerated(attr) or self.is_list(attr):
            return True
        ienc = self.ienc_for(cell)
        if ienc is not None:
            return ienc.is_enumerated(attr) or ienc.is_list(attr)
        return False

    def effective_domain(self, attr, cell=None):
        """Set of valid integer codes for an enumerated/list attribute, unioned
        across the maritime catalogue and the IENC FC governing ``cell``. Empty
        set if the attribute is enumerated in neither standard."""
        codes = set()
        dom = self.enum_domain.get(attr)
        if dom:
            codes.update(dom)
        ienc = self.ienc_for(cell)
        if ienc is not None:
            for v in ienc.enum_domain(attr):
                try:
                    codes.add(int(v))
                except (TypeError, ValueError):
                    pass
        return codes
