# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
s57/geomcheck.py
----------------
Native geometry **health diagnostics** — an independent read on why a feature's
geometry is problematic.

When a producer's pipeline reports something like "feature held back from the
area table" or a geometry conformance check fires, the useful question is *why*:
did the topology fail to assemble, is the ring open, is the polygon degenerate
or self-intersecting, does the primitive disagree with the object class? This
module answers that from the native side, built on:

* ``GeometryBuilder`` — assembles the feature's geometry (points/lines/areas),
* ``GeomOps`` — measures and predicates over the assembled values,
* the catalogue — the class's permissible primitives.

Two tiers, matching ``GeomOps``: the structural/measure checks are pure-Python
and always run (unassemblable topology, open ring, too-few vertices, degenerate
zero area, primitive-not-permitted). The set-theoretic checks (self-intersection
/ OGC validity) need a GEOS backend; when none is present they are skipped and
the report says so, rather than failing silently.

This is read-only over the record model and never raises on one bad feature — a
feature it cannot even classify becomes an issue, not an exception.
"""

try:                                            # package context
    from .geometry import GeometryBuilder
    from .geomops import GeomOps
    from .records import S57RecordSet
except (ImportError, ValueError):               # headless / top-level
    from geometry import GeometryBuilder        # noqa: F401
    from geomops import GeomOps                  # noqa: F401
    from records import S57RecordSet             # noqa: F401

_PRIM_LETTER = {1: "P", 2: "L", 3: "A"}
_AREAL = ("POLYGON", "MULTIPOLYGON")
_LINEAR = ("LINESTRING", "MULTILINESTRING")


class GeomIssue:
    """One geometry-health finding for a feature.

    ``severity`` is ``"error"`` (geometry the producer would rightly hold back —
    unassemblable, degenerate, invalid) or ``"warning"`` (defensible but worth a
    look — e.g. a primitive the catalogue doesn't list for the class, which may
    be a national extension)."""

    __slots__ = ("rcid", "klass", "kind", "severity", "detail")

    def __init__(self, rcid, klass, kind, severity, detail):
        self.rcid = rcid
        self.klass = klass
        self.kind = kind
        self.severity = severity
        self.detail = detail

    def __repr__(self):
        return (f"<GeomIssue {self.severity} {self.kind} "
                f"rcid={self.rcid} {self.klass}: {self.detail}>")


def _acronym(catalogue, objl):
    if catalogue is None:
        return None
    try:
        return catalogue.code_to_acronym.get(int(objl))
    except (TypeError, ValueError):
        return catalogue.code_to_acronym.get(objl)


def check_feature(feature, builder, ops, catalogue=None):
    """Return a list of ``GeomIssue`` for one ``FeatureRecord``. Empty list means
    the native engine sees nothing wrong with its geometry."""
    issues = []
    prim = feature.prim
    klass = _acronym(catalogue, feature.objl) or f"OBJL{feature.objl}"
    rcid = feature.rcid

    if prim not in _PRIM_LETTER:
        # Only a defect if the class is supposed to carry geometry. Collection
        # / meta objects (C_AGGR, C_ASSO, ...) have an empty primitive set in
        # the catalogue and legitimately have no spatial primitive.
        allowed = catalogue.primitives.get(klass) if catalogue else None
        if allowed:
            issues.append(GeomIssue(rcid, klass, "no-primitive", "error",
                                    f"PRIM={prim!r} is not point/line/area"))
        return issues

    # Primitive vs the class's permissible primitives (catalogue).
    if catalogue is not None:
        allowed = catalogue.primitives.get(klass)
        if allowed and _PRIM_LETTER[prim] not in allowed:
            issues.append(GeomIssue(
                rcid, klass, "primitive-not-permitted", "warning",
                f"PRIM {_PRIM_LETTER[prim]} not in permitted {sorted(allowed)}"))

    geom = builder.geometry(feature)
    if geom is None:
        issues.append(GeomIssue(
            rcid, klass, "unassemblable", "error",
            "geometry could not be assembled (dangling/empty spatial refs)"))
        return issues
    if not geom.coords():
        issues.append(GeomIssue(rcid, klass, "empty", "error",
                                "assembled geometry has no coordinates"))
        return issues

    if geom.kind in _AREAL:
        issues.extend(_check_area(rcid, klass, geom, ops))
    elif geom.kind in _LINEAR:
        issues.extend(_check_line(rcid, klass, geom, ops))
    return issues


def _check_area(rcid, klass, geom, ops):
    issues = []
    rings = []
    if geom.kind == "POLYGON":
        rings = [geom.rings]
    else:
        rings = [poly for poly in geom.polygons]
    for poly in rings:
        for r in poly:
            if len(r) < 4:
                issues.append(GeomIssue(
                    rcid, klass, "ring-too-few-vertices", "error",
                    f"ring has {len(r)} vertices (need >= 4 incl. closure)"))
            elif r[0] != r[-1]:
                issues.append(GeomIssue(
                    rcid, klass, "ring-not-closed", "error",
                    "ring first vertex != last vertex"))
    if ops.area(geom) <= 0.0:
        issues.append(GeomIssue(rcid, klass, "degenerate-area", "error",
                                "polygon area is zero (collinear/degenerate)"))
    # GEOS-only validity/simplicity.
    if ops.has_geos():
        try:
            if not ops.is_valid(geom):
                issues.append(GeomIssue(rcid, klass, "invalid", "error",
                                        "geometry is not OGC-valid (GEOS)"))
        except Exception:                       # noqa: BLE001
            pass
    return issues


def _check_line(rcid, klass, geom, ops):
    issues = []
    parts = [geom.points] if geom.kind == "LINESTRING" else geom.parts
    for p in parts:
        if len(p) < 2:
            issues.append(GeomIssue(rcid, klass, "line-too-few-vertices",
                                    "error", f"line part has {len(p)} vertex"))
    if ops.has_geos():
        try:
            if not ops.is_simple(geom):
                issues.append(GeomIssue(rcid, klass, "self-intersection",
                                        "warning",
                                        "line is not simple (GEOS)"))
        except Exception:                       # noqa: BLE001
            pass
    return issues


def check_cell(cell, catalogue=None, rcids=None, prefer_geos=True):
    """Walk a parsed ``S57Cell`` and return ``(issues, summary)``.

    ``rcids`` optionally restricts the walk to specific feature RCIDs (e.g. the
    ones a loader flagged). ``summary`` is a dict with per-kind counts, the
    number of features checked, and whether a GEOS backend was available (so the
    caller knows if the set-theoretic checks actually ran)."""
    builder = GeometryBuilder(cell)
    ops = GeomOps(prefer_geos=prefer_geos)
    want = set(rcids) if rcids is not None else None

    issues, checked = [], 0
    for f in cell.features:
        if want is not None and f.rcid not in want:
            continue
        checked += 1
        issues.extend(check_feature(f, builder, ops, catalogue))

    by_kind = {}
    for it in issues:
        by_kind[it.kind] = by_kind.get(it.kind, 0) + 1
    summary = {
        "cell": cell.name,
        "features_checked": checked,
        "issues": len(issues),
        "by_kind": by_kind,
        "geos_available": ops.has_geos(),
    }
    return issues, summary


def check_paths(paths, catalogue=None, rcids=None, prefer_geos=True):
    """Convenience for a GDAL/QGIS environment: read one or more cell files with
    the native reader and run ``check_cell`` on each. Returns
    ``[(summary, issues), ...]`` — one entry per cell. ``rcids`` applies to every
    cell (handy when re-checking the same flagged ids across an exchange set)."""
    rs = S57RecordSet.from_paths(paths)
    out = []
    for cell in rs.cells:
        if getattr(cell, "parse_error", None):
            out.append(({"cell": cell.name, "parse_error": cell.parse_error},
                        []))
            continue
        issues, summary = check_cell(cell, catalogue=catalogue, rcids=rcids,
                                     prefer_geos=prefer_geos)
        out.append((summary, issues))
    return out
