# SPDX-FileCopyrightText: 2025 XLeitstelle Planen und Bauen <xleitstelle@gv.hamburg.de>
# SPDX-FileContributor: Anton Jacobsson <anton.jacobsson@init.com>
# SPDX-FileContributor: Michael Holzapfel <michael.holzapfel@geocledian.com>
#
# SPDX-License-Identifier: EUPL-1.2

import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Optional, Tuple, Set

from qgis.core import (
    QgsFeature,
    QgsGeometry,
    QgsPointXY,
    QgsProject,
    QgsSpatialIndex,
    QgsVectorLayer,
    QgsVectorLayerEditUtils,
    QgsVertexId,
    QgsWkbTypes,
)

from xmas_plugin.util.geom import ensure_ccw_geom
from xmas_plugin.util.metadata import PLUGIN_DIR_NAME

logger = logging.getLogger(PLUGIN_DIR_NAME)

TOL: float = 0.00002  # metres in CRS 25832. Make flexible for different CRS? Hardcoded for now but can (should) be made dynamical.
GRID: float = max(
    TOL / 2.0, 1e-9
)  # grid for dedup keys: using tol/2 is conservative (avoids near-duplicates)
SEG_LEN = max(TOL * 25, 0.01)  # 1 cm minimum, else 25×tol = 5 cm


def is_topology_feature(feat: QgsFeature, layer: QgsVectorLayer) -> bool:
    """
    test if a feature must fullfill topology rules
    """

    # all features in plan or section layers need topological points
    layer_xplan_type = layer.customProperties().value(f"{PLUGIN_DIR_NAME}/layer_type")
    if layer_xplan_type in ["plan", "section"]:
        return True

    # features in other layer types need topological points only if
    # following conditions are met
    feature_props = feat.attributeMap().get("properties", {})
    if feature_props.get("ebene", 0) != 0:
        return False
    if not feature_props.get("flaechenschluss", None):
        return False

    if feature_props.get("startBedingung", None) or feature_props.get(
        "endeBedingung", None
    ):
        return False

    return True


@dataclass(frozen=True)
class VertexInsertion:
    """Insert a vertex at 'afterVertex' (QGIS insertVertex convention) at point 'pt',
    with ordering parameter 't' along the segment [prevVertex -> afterVertex]."""

    after_vertex: int
    t: float  # Mathematical parameter notation for sequence
    pt: QgsPointXY


@dataclass(frozen=True)
class MissingVerticesForFeature:
    """Class for returning IDs and not real objects for C++ lifetime stability."""

    layer_id: str
    fid: int
    insertions: Tuple[VertexInsertion, ...]


@dataclass
class VertexBuildResult:
    """Class for storing both list of point-features (vertices) and grid based hash-table."""

    features: list[QgsFeature]
    vertex_keys: set[tuple[int, int]]
    id_to_point: dict[int, QgsPointXY]


def _round_key(pt: QgsPointXY, grid: float) -> tuple[int, int]:
    """Tolerance-based key. grid is in layer units (meters for EPSG:25832)."""
    # Using round(x / grid) is stable and avoids float issues vs round(x, ndigits)
    return (int(round(pt.x() / grid)), int(round(pt.y() / grid)))


def _segment_param_t(a: QgsPointXY, b: QgsPointXY, p: QgsPointXY) -> float:
    """Projection parameter t of p onto segment a->b. Clamped to [0,1]."""
    abx = b.x() - a.x()
    aby = b.y() - a.y()
    apx = p.x() - a.x()
    apy = p.y() - a.y()
    denom = abx * abx + aby * aby
    if denom <= 0.0:
        return 0.0
    t = (apx * abx + apy * aby) / denom
    if t < 0.0:
        return 0.0
    if t > 1.0:
        return 1.0
    return t


def _maybe_segmentize(geom: QgsGeometry, seg_len: float) -> QgsGeometry:
    try:
        if geom and geom.requiresConversionToStraightSegments():
            return geom.segmentize(seg_len)  #
    except Exception:
        pass
    return geom


def build_vertex_features(layers: list[QgsVectorLayer]) -> VertexBuildResult:
    result = VertexBuildResult(features=[], vertex_keys=set(), id_to_point={})

    next_id = 1

    for lyr in layers:
        if lyr.wkbType() == QgsWkbTypes.NoGeometry:
            continue

        for f in lyr.getFeatures():
            if not is_topology_feature(f, lyr):
                continue
            geom = f.geometry()
            if not geom or geom.isEmpty() or geom.isNull():
                continue

            geom = _maybe_segmentize(geom, SEG_LEN)
            # NOTE:
            # Curved geometries are segmentized *only* for topo detection.
            # Stored geometries remain untouched; insertions lie on original edges.

            # Ensure OGC ring orientation
            geom = ensure_ccw_geom(geom)

            # Collect all vertices (QgsPoint/QgsPointXY) from the geometry and save as point-features; add unique points to the hash-table for performant evaluation
            for v in geom.vertices():
                pt = QgsPointXY(v.x(), v.y())
                key = _round_key(pt, GRID)
                if key in result.vertex_keys:
                    continue

                result.vertex_keys.add(key)

                out_f = QgsFeature()
                out_f.setId(next_id)
                out_f.setGeometry(QgsGeometry.fromPointXY(pt))

                result.features.append(out_f)
                result.id_to_point[next_id] = pt

                next_id += 1

    return result


def find_missing_vertices(
    layers: list[QgsVectorLayer],
    vertex_build_result: VertexBuildResult,
) -> list[MissingVerticesForFeature]:
    """
    Returns per (layer, feature_id): list of VertexInsertion objects.
    - Uses projected nearestPoint on the edge (snap-to-segment).
    - Computes (after_vertex, t) so insertions can be ordered safely.
    - Dedups with tolerance grid so we don't create micro-loops/spikes.
    - Ring-aware: avoids edges across rings/parts and includes ring closing edge.
    """
    tol2 = TOL * TOL

    vsidx = QgsSpatialIndex()
    # Different QGIS versions use addFeatures and others addFeature so make it work with both
    add_features = getattr(vsidx, "addFeatures", None)
    if callable(add_features):
        add_features(vertex_build_result.features)
    else:
        for ft in vertex_build_result.features:
            vsidx.addFeature(ft)
    res: list[MissingVerticesForFeature] = []

    id_to_pt = vertex_build_result.id_to_point

    for layer in layers:
        for f in layer.getFeatures():
            if not is_topology_feature(f, layer):
                continue

            geom_orig = f.geometry()
            if not geom_orig or geom_orig.isEmpty():
                continue

            gtype = QgsWkbTypes.geometryType(geom_orig.wkbType())
            if gtype not in (QgsWkbTypes.LineGeometry, QgsWkbTypes.PolygonGeometry):
                continue

            # TODO: Evaluate if correct
            # if gtype == QgsWkbTypes.PolygonGeometry:
            #     geom_orig = ensure_ccw_geom(geom_orig)

            # segmentized copy only for detection
            geom_det = _maybe_segmentize(QgsGeometry(geom_orig), SEG_LEN)
            if gtype == QgsWkbTypes.PolygonGeometry:
                geom_det = ensure_ccw_geom(geom_det)
                # TODO: Don't ignore curves

            # Gather existing vertices as tolerance-keys for fast dedup
            existing_keys: set[tuple[int, int]] = set()
            for v in geom_orig.vertices():
                existing_keys.add(_round_key(QgsPointXY(v.x(), v.y()), GRID))

            insertions: list[VertexInsertion] = []

            # Cache vertex ids and points in vertexNr order
            vids: list[Optional[QgsVertexId]] = []
            feature_vertices: list[QgsPointXY] = []

            # NOTE: geom.vertices() enumerates vertexNr in the same order expected by vertexIdFromVertexNr(i)
            for i, v in enumerate(geom_orig.vertices()):
                ok, vid = geom_orig.vertexIdFromVertexNr(i)
                if not ok:
                    # keep alignment: still append a placeholder so indices stay in sync
                    # (safer than skipping and shifting vertexNr space)
                    vids.append(None)
                    feature_vertices.append(QgsPointXY(v.x(), v.y()))
                    continue
                vids.append(vid)
                feature_vertices.append(QgsPointXY(v.x(), v.y()))

            if len(feature_vertices) < 2:
                continue

            # Track ring starts to add closing edges
            # key: (part, ring) -> first_vertex_nr
            ring_first: dict[tuple[int, int], int] = {}
            last_seg_vnr_by_ring: dict[tuple[int, int], int] = {}

            def _process_edge(
                prev_pt: QgsPointXY, cur_pt: QgsPointXY, after_vertex: int
            ):
                edge = QgsGeometry.fromPolylineXY([prev_pt, cur_pt])

                # Expand bounding box by TOL to avoid rare edge case false negatives
                rect = edge.boundingBox()
                rect.grow(TOL)  # expands in all directions
                vertex_ids = vsidx.intersects(rect)

                if not vertex_ids:
                    return

                for vertex_id in sorted(
                    vertex_ids
                ):  # cand_vid is already the vertex feature id
                    pt = id_to_pt.get(vertex_id)
                    if pt is None:
                        continue

                    cand_key = _round_key(pt, GRID)
                    if cand_key in existing_keys:
                        continue

                    ctx = edge.closestSegmentWithContext(pt)
                    if not ctx:
                        continue

                    # Use slicing to keep things QGIS-version agnostic
                    sqr_dist = ctx[0]
                    nearest_pt = ctx[1]

                    if sqr_dist < 0:
                        continue
                    if sqr_dist > tol2:
                        continue

                    # insert the projected point on the segment
                    snapped = QgsPointXY(nearest_pt.x(), nearest_pt.y())

                    # reject if snapped is basically an endpoint of this segment
                    dx1 = snapped.x() - prev_pt.x()
                    dy1 = snapped.y() - prev_pt.y()
                    dx2 = snapped.x() - cur_pt.x()
                    dy2 = snapped.y() - cur_pt.y()
                    if (dx1 * dx1 + dy1 * dy1) <= tol2 or (
                        dx2 * dx2 + dy2 * dy2
                    ) <= tol2:
                        continue

                    snapped_key = _round_key(snapped, GRID)
                    if snapped_key in existing_keys:
                        continue

                    t = _segment_param_t(prev_pt, cur_pt, snapped)
                    insertions.append(
                        VertexInsertion(after_vertex=after_vertex, t=t, pt=snapped)
                    )
                    existing_keys.add(
                        snapped_key
                    )  # prevent near-duplicates in same feature pass

            # Walk consecutive vertexNrs, ring-aware
            for cur_vertex_nr in range(1, len(feature_vertices)):
                prev_vertex_nr = cur_vertex_nr - 1

                prev_vid = vids[prev_vertex_nr]
                cur_vid = vids[cur_vertex_nr]
                if prev_vid is None or cur_vid is None:
                    continue

                # only segment vertices (skip curve vertices)
                if (
                    prev_vid.type != QgsVertexId.SegmentVertex
                    or cur_vid.type != QgsVertexId.SegmentVertex
                ):
                    continue

                # ring key for ring-aware tracking
                prev_key = (prev_vid.part, prev_vid.ring)
                cur_key = (cur_vid.part, cur_vid.ring)

                # remember first segment-vertex per ring
                if prev_key not in ring_first:
                    ring_first[prev_key] = prev_vertex_nr
                if cur_key not in ring_first:
                    ring_first[cur_key] = cur_vertex_nr

                # if still within same ring/part, process edge; prevent edges crossing ring boundaries
                if prev_key == cur_key:
                    prev_pt = feature_vertices[prev_vertex_nr]
                    cur_pt = feature_vertices[cur_vertex_nr]
                    _process_edge(prev_pt, cur_pt, after_vertex=cur_vertex_nr)
                    last_seg_vnr_by_ring[cur_key] = cur_vertex_nr
                else:
                    # ring/part boundary: if both ends are present, close the previous ring
                    if prev_key in ring_first and prev_key in last_seg_vnr_by_ring:
                        first_vnr = ring_first[prev_key]
                        last_vnr = last_seg_vnr_by_ring[prev_key]
                        if last_vnr != first_vnr:
                            prev_pt = feature_vertices[last_vnr]
                            cur_pt = feature_vertices[first_vnr]
                            _process_edge(prev_pt, cur_pt, after_vertex=first_vnr)

            # After loop: close any rings
            for rkey, first_vnr in ring_first.items():
                last_vnr = last_seg_vnr_by_ring.get(rkey)
                if last_vnr is None:
                    continue
                if last_vnr == first_vnr:
                    continue
                prev_pt = feature_vertices[last_vnr]
                cur_pt = feature_vertices[first_vnr]
                _process_edge(prev_pt, cur_pt, after_vertex=first_vnr)

            by_key = defaultdict(list)
            for ins in insertions:
                by_key[_round_key(ins.pt, GRID)].append((ins.after_vertex, ins.t))

            amb = {k: v for k, v in by_key.items() if len(v) > 1}
            if amb:
                logger.warning(
                    "[Topo-Fix] ambiguous candidates fid=%s count=%s example=%s",
                    f.id(),
                    len(amb),
                    next(iter(amb.items())),
                )

            if insertions:
                res.append(
                    MissingVerticesForFeature(
                        layer_id=layer.id(),
                        fid=int(f.id()),
                        insertions=tuple(insertions),
                    )
                )

    return res


def insert_vertices(
    missing: list[MissingVerticesForFeature],
    commit_changes: bool = True,
) -> tuple[int, int, Set[str]]:
    """
    Inserts vertices using safe ordering:
      - sort by (after_vertex DESC, t DESC) to avoid index shifts + keep segment order
      - tolerance-based dedup check against current geometry vertices
      - inserts projected points (already snapped in find_missing)
    Returns (ok, fail, set of changed qgis layer-ids).
    """
    ok = 0
    fail = 0
    layers_changed: Set[str] = set()
    project = QgsProject.instance()

    by_layer: dict[str, list[MissingVerticesForFeature]] = defaultdict(list)
    for item in missing:
        by_layer[item.layer_id].append(item)

    for layer_id, items in by_layer.items():
        layer = project.mapLayer(layer_id)
        if not isinstance(layer, QgsVectorLayer):
            continue

        if not layer.isEditable():
            layer.startEditing()

        edit_utils = QgsVectorLayerEditUtils(layer)

        for item in items:
            fid = item.fid
            feat = layer.getFeature(fid)
            if not feat or not feat.isValid():
                continue
            if not is_topology_feature(feat, layer):
                continue
            geom = feat.geometry()
            if not geom or geom.isEmpty():
                continue

            current_keys = {
                _round_key(QgsPointXY(v.x(), v.y()), GRID) for v in geom.vertices()
            }

            ordered = sorted(
                item.insertions, key=lambda it: (it.after_vertex, it.t), reverse=True
            )

            for ins in ordered:
                k = _round_key(ins.pt, GRID)
                if k in current_keys:
                    continue
                try:
                    if edit_utils.insertVertex(
                        ins.pt.x(), ins.pt.y(), fid, ins.after_vertex
                    ):
                        ok += 1
                        current_keys.add(k)
                        layers_changed.add(layer_id)
                    else:
                        fail += 1
                except Exception:
                    fail += 1

        # commit once per layer
        if commit_changes:
            if not layer.commitChanges():
                # if commit fails, QGIS keeps an error list
                for e in layer.commitErrors():
                    logger.warning(
                        "[Topo-Fix] commit error layer=%s: %s", layer.name(), e
                    )

    return ok, fail, layers_changed
