# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/model.py
-------------------
The data model the checks run against.

A ``Feature`` is one S-57 feature object: its class acronym, geometric
primitive, attribute map, optional geometry, and identity. The attribute map
encodes S-58 8.0.0 value semantics directly:

    key absent      -> attribute NOT Present
    value is None   -> Present but Unknown   (encoded, empty)
    value not None  -> Known                 (encoded, populated)

A ``Dataset`` exposes features grouped by class. Two implementations:

    OgrDataset  -- reads real .000 cells via GDAL's S-57 driver (used in QGIS).
    DictDataset -- builds features from plain dicts (used by the headless tests
                   and by anyone scripting the engine without GDAL).

Only ``OgrDataset`` imports osgeo; ``Feature``/``DictDataset`` are pure stdlib,
so the engine and the attribute/structure checks are testable without GDAL.
"""


# -- value helpers (S-58 8.0.0 1.4.3) -----------------------------------------
def present(attrs, key):
    """Attribute is encoded at all (populated or empty)."""
    return key in attrs


def known(attrs, key):
    """Attribute is Present AND populated with a value."""
    return attrs.get(key) is not None


def unknown(attrs, key):
    """Attribute is Present but empty (encoded with no value)."""
    return key in attrs and attrs[key] is None


def list_values(value):
    """Split a list/enum attribute value into individual tokens.

    S-57 list attributes arrive either already split (a real list) or as a
    delimited string; normalise both to a list of stripped string tokens.
    """
    if value is None:
        return []
    if isinstance(value, (list, tuple)):
        return [str(v).strip() for v in value if str(v).strip() != ""]
    return [tok.strip() for tok in str(value).replace("/", ",").split(",")
            if tok.strip() != ""]


# -- feature ------------------------------------------------------------------
class Feature:
    """One S-57 feature object."""

    __slots__ = ("klass", "primitive", "attrs", "geom", "fid", "cell")

    def __init__(self, klass, primitive, attrs, geom=None, fid=None, cell=""):
        self.klass = klass            # acronym, e.g. "DEPARE"
        self.primitive = primitive    # 'P' / 'L' / 'A'
        self.attrs = attrs            # dict: see module docstring for semantics
        self.geom = geom              # OGR geometry or None (None in headless)
        self.fid = fid
        self.cell = cell

    def __repr__(self):
        return f"<Feature {self.klass}/{self.primitive} fid={self.fid}>"


# -- dataset base -------------------------------------------------------------
class Dataset:
    """Common feature-access surface the checks rely on."""

    def __init__(self):
        self._by_class = {}      # acronym -> [Feature]
        self.cells = []          # cell names included

    def add(self, feature):
        self._by_class.setdefault(feature.klass, []).append(feature)

    def features(self, klass=None):
        """All features, or just those of one class acronym."""
        if klass is None:
            for items in self._by_class.values():
                yield from items
        else:
            yield from self._by_class.get(klass, [])

    def classes(self):
        return set(self._by_class)

    def classes_by_cell(self):
        """{cell_name: set(class_acronym)} for every cell in the dataset,
        including cells that contain no features (mapped to an empty set).
        Lets per-cell checks (e.g. mandatory meta objects) evaluate each cell
        in its own right rather than against the whole exchange set."""
        by_cell = {c: set() for c in self.cells}
        for items in self._by_class.values():
            for f in items:
                by_cell.setdefault(f.cell, set()).add(f.klass)
        return by_cell

    def count(self, klass=None):
        if klass is None:
            return sum(len(v) for v in self._by_class.values())
        return len(self._by_class.get(klass, []))


class DictDataset(Dataset):
    """Test/scripting double built from plain dicts.

    Example::

        ds = DictDataset(cell="DEMO")
        ds.feature("BUAARE", "A", {"OBJNAM": "Town"})
    """

    def __init__(self, cell="TEST"):
        super().__init__()
        self.cells = [cell]
        self._cell = cell
        self._next_fid = 1

    def feature(self, klass, primitive, attrs, geom=None):
        f = Feature(klass, primitive, dict(attrs), geom=geom,
                    fid=self._next_fid, cell=self._cell)
        self._next_fid += 1
        self.add(f)
        return f


# -- native-reader-backed dataset (GDAL-free) ---------------------------------
class NativeDataset(Dataset):
    """Feature/attribute model sourced from the **native** S-57 record reader
    (``s57.S57RecordSet``) instead of GDAL.

    Maps each ``FeatureRecord``'s numeric OBJL/ATTL codes to acronyms via the
    catalogue and applies the same S-58 present/known/unknown semantics as
    ``OgrDataset``, so the attribute and structure checks run identically —
    without importing osgeo. A cell the reader stubbed (``parse_error``) is
    recorded in ``load_errors`` and contributes no features, mirroring
    ``OgrDataset``.

    Geometry: assembled natively (points, lines, areas) and attached when
    ``build_geometry`` is set. By default ``feature.geom`` then holds a *native*
    geometry value (``s57.geometry`` Point/LineString/Polygon/…), which carries
    its primitive/coordinates but is NOT an OGR geometry — so the spatial checks
    (which call GEOS through OGR) will not run against it.

    Set ``ogr_geometry=True`` to additionally bridge each native geometry to an
    **OGR** geometry via its WKT (``ogr.CreateGeometryFromWkt(g.wkt())``). This
    makes ``NativeDataset`` a true drop-in for ``OgrDataset`` across *every*
    check: the S-57 is read natively, but the geometry predicates still run on
    GEOS — exactly as they do for ``OgrDataset`` — so a GDAL-vs-native report
    diff isolates the *reader*, holding the predicate engine constant. The OGR
    bridge is a geometry *constructor* only (no S-57 driver), imported lazily, so
    the GDAL-free import contract is preserved unless the bridge is requested.
    """

    _PRIM = {1: "P", 2: "L", 3: "A"}

    def __init__(self, record_set, catalogue, build_geometry=False,
                 ogr_geometry=False):
        super().__init__()
        self.load_errors = []
        # An OGR bridge needs the native geometry to exist first.
        if ogr_geometry:
            build_geometry = True
        self._ogr = None
        if ogr_geometry:
            from osgeo import ogr            # lazy: geometry constructor only
            self._ogr = ogr
        builder_cls = None
        if build_geometry:
            try:
                from ..s57 import GeometryBuilder
            except (ImportError, ValueError):
                from s57 import GeometryBuilder
            builder_cls = GeometryBuilder
        for cell in record_set.cells:
            self.cells.append(cell.name)
            if getattr(cell, "parse_error", None):
                self.load_errors.append((cell.name, cell.parse_error))
                continue
            builder = builder_cls(cell) if builder_cls else None
            for fr in cell.features:
                feat = self._to_feature(fr, cell, catalogue, builder)
                # Skip synthetic/cartographic classes the S-57 catalogue marks
                # with a leading underscore (e.g. _texto = OBJL 135 'Text',
                # _extgn). GDAL's S-57 driver does not surface these as feature
                # layers either, so excluding them keeps NativeDataset a faithful
                # drop-in for OgrDataset and avoids running object-class checks
                # (547 / 20a) on non-standard synthetic objects.
                if feat.klass.startswith("_"):
                    continue
                self.add(feat)

    def _to_feature(self, fr, cell, cat, builder):
        objl = fr.objl
        try:
            objl = int(objl)
        except (TypeError, ValueError):
            pass
        klass = cat.code_to_acronym.get(objl) or f"OBJL{objl}"
        prim = self._PRIM.get(fr.prim)
        attrs = {}
        for attl, atvl in (fr.attrs or {}).items():
            acr = cat.attr_code_to_acronym.get(attl)
            if acr is None:
                continue                       # unknown attribute code; skip
            if atvl is None or (isinstance(atvl, str) and atvl == ""):
                attrs[acr] = None              # Present but Unknown
            else:
                attrs[acr] = atvl              # Known
        geom = builder.geometry(fr) if builder is not None else None
        if geom is not None and self._ogr is not None:
            geom = self._to_ogr_geom(geom)
        return Feature(klass, prim, attrs, geom=geom,
                       fid=fr.rcid, cell=cell.name)

    def _to_ogr_geom(self, native_geom):
        """Bridge a native geometry value to an OGR geometry through its WKT, so
        the GEOS-backed spatial checks run on it unchanged. Returns None if the
        value cannot emit WKT or OGR cannot parse it (the feature then has no
        geometry, as a GDAL-unreadable feature would)."""
        try:
            wkt = native_geom.wkt()
        except Exception:                       # noqa: BLE001
            return None
        if not wkt:
            return None
        try:
            return self._ogr.CreateGeometryFromWkt(wkt)
        except Exception:                       # noqa: BLE001
            return None

    @classmethod
    def from_paths(cls, cell_paths, catalogue, build_geometry=False,
                   ogr_geometry=False):
        """Build a NativeDataset straight from base-cell paths, applying S-57
        updates via the native reader — the native counterpart to
        ``OgrDataset(cell_paths)``. The same merged record set is also a fine
        value for ``dataset.s57`` (the record/exchange checks)."""
        try:
            from ..s57 import S57RecordSet
        except (ImportError, ValueError):
            from s57 import S57RecordSet
        rs = S57RecordSet.from_paths(cell_paths)
        return cls(rs, catalogue, build_geometry=build_geometry,
                   ogr_geometry=ogr_geometry)


# -- OGR-backed dataset (QGIS / GDAL) -----------------------------------------
class OgrDataset(Dataset):
    """Reads one or more S-57 cells via GDAL's S-57 driver.

    Opens each cell with the options the relational checks need
    (RETURN_PRIMITIVES / RETURN_LINKAGES / LNAM_REFS) and splits multipoint
    soundings so SOUNDG features are per-point. Feature attribute maps honour
    S-58 8.0.0 present/known semantics via OGR's IsFieldSet /
    IsFieldSetAndNotNull.

    Note: requires osgeo; imported lazily so the rest of the package stays
    importable without GDAL.
    """

    OPEN_OPTIONS = [
        "RETURN_PRIMITIVES=ON",
        "RETURN_LINKAGES=ON",
        "LNAM_REFS=ON",
        "SPLIT_MULTIPOINT=ON",
        "ADD_SOUNDG_DEPTH=ON",
        "RECODE_BY_DSSI=ON",
    ]

    # OGR geometry type (flattened) -> S-57 primitive letter.
    _PRIM_BY_OGR = None   # built lazily once ogr is imported

    def __init__(self, cell_paths):
        super().__init__()
        from osgeo import ogr, gdal
        self._ogr = ogr
        self.load_errors = []      # [(cell, message)] cells GDAL could not read
        gdal.UseExceptions()
        if OgrDataset._PRIM_BY_OGR is None:
            OgrDataset._PRIM_BY_OGR = {
                ogr.wkbPoint: "P", ogr.wkbMultiPoint: "P",
                ogr.wkbLineString: "L", ogr.wkbMultiLineString: "L",
                ogr.wkbPolygon: "A", ogr.wkbMultiPolygon: "A",
            }
        import os
        for path in cell_paths:
            try:
                self._read_cell(path, ogr, gdal)
            except Exception as exc:                # noqa: BLE001
                # One cell GDAL cannot read (e.g. it chokes on an arc/ARCC
                # field) must not abort the whole audit. Skip it for the
                # GDAL-based checks — the native record reader still audits it —
                # and record the failure so the report can surface it.
                cell = os.path.splitext(os.path.basename(path))[0]
                self.load_errors.append((cell, str(exc)))
                try:
                    gdal.ErrorReset()
                except Exception:                   # noqa: BLE001
                    pass

    def _read_cell(self, path, ogr, gdal):
        import os
        cell = os.path.splitext(os.path.basename(path))[0]
        ds = gdal.OpenEx(path, gdal.OF_VECTOR,
                         open_options=self.OPEN_OPTIONS)
        if ds is None:
            raise IOError(f"GDAL could not open S-57 cell: {path}")
        # Primitive helper layers we do not surface as features.
        skip = {"IsolatedNode", "ConnectedNode", "Edge", "Face",
                "DSID"}
        collected = []
        for i in range(ds.GetLayerCount()):
            layer = ds.GetLayer(i)
            name = layer.GetName()
            if name in skip:
                continue
            defn = layer.GetLayerDefn()
            field_names = [defn.GetFieldDefn(j).GetName()
                           for j in range(defn.GetFieldCount())]
            for feat in layer:
                collected.append(
                    self._to_feature(feat, name, field_names, cell, ogr))
            layer.ResetReading()
        # Commit only once the whole cell has read without error, so a cell that
        # fails midway leaves no partial features behind.
        self.cells.append(cell)
        for f in collected:
            self.add(f)

    def _to_feature(self, feat, klass, field_names, cell, ogr):
        attrs = {}
        for fname in field_names:
            # Skip GDAL's internal/derived helper columns.
            idx = feat.GetFieldIndex(fname)
            if idx < 0:
                continue
            if not feat.IsFieldSet(idx):
                continue                       # attribute NOT present
            if feat.IsFieldNull(idx):
                attrs[fname] = None            # Present but Unknown
                continue
            value = feat.GetField(idx)
            # GDAL returns "" for present-empty ATTF subfields; treat as Unknown.
            if isinstance(value, str) and value == "":
                attrs[fname] = None
            else:
                attrs[fname] = value
        geom = feat.GetGeometryRef()
        geom = geom.Clone() if geom is not None else None
        prim = None
        if geom is not None:
            prim = self._PRIM_BY_OGR.get(ogr.GT_Flatten(geom.GetGeometryType()))
        return Feature(klass, prim, attrs, geom=geom,
                       fid=feat.GetFID(), cell=cell)
