# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
sourceval/sources.py — the external-source side.
================================================================================

The reconciliation engine consumes an abstract list of ``SourceFeature`` (an id,
a lon/lat position, and an attribute dict). How those are obtained is an adapter
concern:

    MemorySource      plain list — used by the headless tests and scripting.
    QgisVectorSource  wraps a QgsVectorLayer QGIS already knows; reprojects to
                      lon/lat (EPSG:4326). Lazy-imports qgis so this module
                      stays importable (and testable) without QGIS.

Per the agreed v1 scope, only **point** geometry is consumed; non-point source
features are skipped (and counted) rather than silently dropped.
"""


class SourceFeature:
    """One external-source feature, normalised to lon/lat + attributes. LINE
    features additionally carry ``coords`` = [(lon, lat), …]; their lon/lat is
    the line midpoint (representative point for coverage clipping)."""

    __slots__ = ("ident", "lon", "lat", "attrs", "coords", "areas")

    def __init__(self, ident, lon, lat, attrs=None, coords=None, areas=None):
        self.ident = ident
        self.lon = lon
        self.lat = lat
        self.attrs = dict(attrs or {})
        self.coords = coords
        self.areas = areas              # [[exterior, *holes], …] for polygons

    def __repr__(self):
        return f"<SourceFeature {self.ident} ({self.lon:.6f},{self.lat:.6f})>"


class SourceProvider:
    """Interface: yield SourceFeature, and scan a column's distinct values."""

    def features(self):
        raise NotImplementedError

    def fields(self):
        """List of available attribute field names."""
        raise NotImplementedError

    def distinct_values(self, field, cap=500):
        """Sorted list of distinct non-empty values in ``field`` (one per value),
        capped at ``cap`` so a large source cannot stall the recipe builder."""
        seen = []
        seen_set = set()
        for f in self.features():
            v = f.attrs.get(field)
            if v is None or str(v).strip() == "":
                continue
            key = str(v)
            if key not in seen_set:
                seen_set.add(key)
                seen.append(v)
                if len(seen) >= cap:
                    break
        return sorted(seen, key=lambda x: str(x))


class MemorySource(SourceProvider):
    """In-memory provider built from a list of SourceFeature (tests/scripting)."""

    def __init__(self, features, fields=None):
        self._features = list(features)
        if fields is not None:
            self._fields = list(fields)
        else:
            keys = []
            for f in self._features:
                for k in f.attrs:
                    if k not in keys:
                        keys.append(k)
            self._fields = keys

    def features(self):
        return list(self._features)

    def fields(self):
        return list(self._fields)


def looks_like_lonlat(lon, lat):
    """True if a coordinate pair is a plausible WGS84 lon/lat. After every read
    is normalised to EPSG:4326, a value outside this envelope means the CRS was
    wrong or missing — a cheap guard against silently matching garbage."""
    return -180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0


def coverage_polygons_from_layer(layer, target_epsg=4326):
    """Extract a polygon layer's geometry as lon/lat rings for the engine's
    point-in-coverage clip. Returns a list of ``(exterior_ring, [hole_rings])``
    with rings as (lon, lat) vertex lists, reprojected to EPSG:target_epsg.

    Used to clip "in source, not in chart" to the real chart footprint (M_COVR /
    the hybrid ENC Coverage layer) instead of a bounding box. Requires QGIS."""
    from qgis.core import (
        QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject,
    )
    target = QgsCoordinateReferenceSystem(f"EPSG:{target_epsg}")
    src_crs = layer.crs()
    xform = None
    if src_crs.isValid() and src_crs != target:
        xform = QgsCoordinateTransform(src_crs, target, QgsProject.instance())

    def _pt(p):
        if xform is not None:
            p = xform.transform(p)
        return (p.x(), p.y())

    polygons = []
    for feat in layer.getFeatures():
        geom = feat.geometry()
        if geom is None or geom.isEmpty():
            continue
        multi = geom.isMultipart()
        polys = geom.asMultiPolygon() if multi else [geom.asPolygon()]
        for poly in polys:
            if not poly:
                continue
            rings = [[_pt(pt) for pt in ring] for ring in poly]
            ext = rings[0]
            holes = rings[1:]
            if ext:
                polygons.append((ext, holes))
    return polygons


class QgisVectorSource(SourceProvider):
    """Adapter over a QgsVectorLayer (a layer QGIS already knows) — used for both
    the external source and a loaded hybrid ENC object layer.

    Reprojects each feature's point to EPSG:4326 (geographic lon/lat) so the
    engine always reconciles in one frame and the metre buffer is true geodesic
    distance — never a planar buffer in a projected CRS (which would mis-scale
    with latitude, e.g. ~1.6x in EPSG:3857 at 51 N). The CRS reconciliation lives
    here so the engine stays frame-agnostic.

    Only point geometries are consumed in v1; lines/areas are skipped and tallied.
    Diagnostics (``crs_note``) report what happened so the UI can warn on a layer
    with no CRS or coordinates that fall outside the lon/lat envelope.

    NOTE: requires QGIS at run time; not exercised by the headless tests (the
    import is lazy). The pure range check ``looks_like_lonlat`` is tested.
    """

    def __init__(self, layer, id_field=None, target_epsg=4326):
        self.layer = layer
        self.id_field = id_field
        self.target_epsg = target_epsg
        self._fields = [f.name() for f in layer.fields()]
        # diagnostics, filled by features()
        self.skipped = 0              # non-point features ignored
        self.transform_failures = 0   # features whose reprojection raised
        self.out_of_range = 0         # features outside the lon/lat envelope
        self.src_authid = ""          # source CRS authid, e.g. "EPSG:28992"
        self.reprojected = False      # a transform was applied
        self.crs_ok = True            # layer has a valid CRS

    def crs_note(self):
        """One-line human summary of how this layer's CRS was handled, for the
        UI to surface (and warn on)."""
        name = self.layer.name()
        if not self.crs_ok:
            return (f"{name}: NO valid CRS — coordinates used as-is; verify they "
                    "are WGS84 lon/lat or the buffer match will be meaningless.")
        if self.reprojected:
            note = f"{name}: reprojected {self.src_authid} → EPSG:{self.target_epsg}."
        else:
            note = f"{name}: already EPSG:{self.target_epsg}, no reprojection."
        flags = []
        if self.skipped:
            flags.append(f"{self.skipped} non-point skipped")
        if self.transform_failures:
            flags.append(f"{self.transform_failures} failed to reproject")
        if self.out_of_range:
            flags.append(f"{self.out_of_range} outside lon/lat range")
        if flags:
            note += "  (" + "; ".join(flags) + ")"
        return note

    def fields(self):
        return list(self._fields)

    def features(self):
        from qgis.core import (
            QgsCoordinateReferenceSystem, QgsCoordinateTransform,
            QgsProject, QgsWkbTypes,
        )
        target = QgsCoordinateReferenceSystem(f"EPSG:{self.target_epsg}")
        src_crs = self.layer.crs()
        self.crs_ok = src_crs.isValid()
        self.src_authid = src_crs.authid() if self.crs_ok else ""
        xform = None
        if self.crs_ok and src_crs != target:
            xform = QgsCoordinateTransform(
                src_crs, target, QgsProject.instance())
            self.reprojected = True
        out = []
        for feat in self.layer.getFeatures():
            geom = feat.geometry()
            if geom is None or geom.isEmpty():
                continue
            gtype = QgsWkbTypes.geometryType(geom.wkbType())
            attrs = {name: feat[name] for name in self._fields}
            ident = feat[self.id_field] if self.id_field else feat.id()
            if gtype == QgsWkbTypes.LineGeometry:
                # One SourceFeature per part; coords transformed vertex-wise,
                # lon/lat = the part's midpoint.
                parts = ([list(p) for p in geom.asMultiPolyline()]
                         if geom.isMultipart() else [geom.asPolyline()])
                for n, part in enumerate(parts):
                    coords = []
                    bad = False
                    for pt in part:
                        if xform is not None:
                            try:
                                pt = xform.transform(pt)
                            except Exception:     # noqa: BLE001 — one bad vertex
                                bad = True
                                break
                        coords.append((pt.x(), pt.y()))
                    if bad or len(coords) < 2:
                        self.transform_failures += 1 if bad else 0
                        continue
                    mid = coords[len(coords) // 2]
                    if not looks_like_lonlat(mid[0], mid[1]):
                        self.out_of_range += 1
                    part_id = (f"{ident}#{n}" if len(parts) > 1 else ident)
                    out.append(SourceFeature(part_id, mid[0], mid[1], attrs,
                                             coords=coords))
                continue
            if gtype == QgsWkbTypes.PolygonGeometry:
                # One SourceFeature per feature; each part = one ring set.
                raw = (geom.asMultiPolygon() if geom.isMultipart()
                       else [geom.asPolygon()])
                areas = []
                bad = False
                for part in raw:
                    rings = []
                    for ring in part:
                        rr = []
                        for pt in ring:
                            if xform is not None:
                                try:
                                    pt = xform.transform(pt)
                                except Exception:       # noqa: BLE001
                                    bad = True
                                    break
                            rr.append((pt.x(), pt.y()))
                        if bad:
                            break
                        if len(rr) >= 4:
                            rings.append(rr)
                    if bad:
                        break
                    if rings:
                        areas.append(rings)
                if bad or not areas:
                    self.transform_failures += 1 if bad else 0
                    continue
                ext = areas[0][0]
                mid = ext[len(ext) // 2]
                if not looks_like_lonlat(mid[0], mid[1]):
                    self.out_of_range += 1
                out.append(SourceFeature(ident, mid[0], mid[1], attrs,
                                         areas=areas))
                continue
            if gtype != QgsWkbTypes.PointGeometry:
                self.skipped += 1
                continue
            pt = geom.centroid().asPoint()
            lon, lat = pt.x(), pt.y()
            if xform is not None:
                try:
                    p = xform.transform(pt)
                    lon, lat = p.x(), p.y()
                except Exception:                 # noqa: BLE001 — one bad point
                    self.transform_failures += 1
                    continue
            if not looks_like_lonlat(lon, lat):
                self.out_of_range += 1
            out.append(SourceFeature(ident, lon, lat, attrs))
        return out
