# SPDX-FileCopyrightText: 2025 XLeitstelle Planen und Bauen <xleitstelle@gv.hamburg.de>
# SPDX-FileContributor: Michael Holzapfel <michael.holzapfel@geocledian.com>
#
# SPDX-License-Identifier: EUPL-1.2
import re
from collections import defaultdict
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Set, Tuple

from qgis.core import (
    Qgis,
    QgsApplication,
    QgsCoordinateReferenceSystem,
    QgsExpression,
    QgsFeature,
    QgsFeatureRequest,
    QgsGeometry,
    QgsLayerTreeGroup,
    QgsLayerTreeLayer,
    QgsMapLayer,
    QgsPointXY,
    QgsProject,
    QgsSpatialIndex,
    QgsTask,
    QgsVectorLayer,
    QgsWkbTypes,
)
from qgis.PyQt import uic
from qgis.PyQt.QtWidgets import (
    QAbstractItemView,
    QComboBox,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QTableView,
    QTabWidget,
    QWidget,
)
from qgis.utils import iface

from xmas_plugin.topo_check.table_bounds import BoundsTableView
from xmas_plugin.topo_check.table_gaps import GapTableView
from xmas_plugin.topo_check.table_overlaps import OverlapTableView, logger
from xmas_plugin.util.geom import get_all_plan_layers
from xmas_plugin.util.metadata import PLUGIN_DIR_NAME
from xmas_plugin.util.topo_vertices import (
    build_vertex_features,
    find_missing_vertices,
    insert_vertices,
)

if TYPE_CHECKING:
    from xmas_plugin.dock import XMASPluginDockWidget  # Only for IDE/type checker


class TopologyCheckerMsgError(Exception):
    """
    Custom Error for displaying MessageBar Messages outside Background Task
    """

    def __init__(self, title: str, text: str, level: Qgis.MessageLevel):
        super().__init__()
        self.title = title
        self.text = text
        self.level = level

    def get_params(self):
        return {"title": self.title, "text": self.text, "level": self.level}


class TopologyChecker(QWidget):
    def __init__(self, parent: "XMASPluginDockWidget") -> None:
        super().__init__(parent)
        self.parent = parent
        self.iface = parent.iface
        self._topology_errors: Dict[str, List[Any]] | None = None
        self.task_topo_check: QgsTask | None = None
        self.task_topo_vtx: QgsTask | None = None
        self._commit_callbacks: dict[str, object] = {}

        # noinspection PyUnresolvedReferences
        ui_file = Path(__file__).parent.parent / "ui" / "topo_check_ui.ui"
        uic.loadUi(str(ui_file), self)

        # progressBar and Cancle Buttons for topo-check
        self.progressBar_check: QProgressBar = self.findChild(
            QProgressBar, "progressBar_check"
        )
        self.progressBar_check.setMinimum(0)
        self.progressBar_check.setMaximum(100)
        self.progressBar_check.hide()

        self.pb_run_check = self.findChild(QPushButton, "pb_run_check")
        self.pb_cancel_check = self.findChild(QPushButton, "pb_cancel_check")
        self.pb_cancel_check.hide()

        # progressBar and Cancle Buttons for topo-vertices
        self.progressBar_vtx: QProgressBar = self.findChild(
            QProgressBar, "progressBar_vtx"
        )
        self.progressBar_vtx.setMinimum(0)
        self.progressBar_vtx.setMaximum(100)
        self.progressBar_vtx.hide()

        self.pb_run_vtx = self.findChild(QPushButton, "pb_run_vtx")
        self.pb_run_vtx.clicked.connect(self.run_topo_vtx_in_background)
        self.pb_cancel_vtx = self.findChild(QPushButton, "pb_cancel_vtx")
        self.pb_cancel_vtx.hide()

        # initialize gaps-tableview
        _placeholder = self.findChild(QTableView, "table_gaps")
        _layout = _placeholder.parentWidget().layout()
        self.table_gaps = GapTableView(self.iface)
        _layout.replaceWidget(_placeholder, self.table_gaps)
        _placeholder.deleteLater()
        del _placeholder

        # initialize overlaps-tableview
        _placeholder = self.findChild(QTableView, "table_overlaps")
        _layout = _placeholder.parentWidget().layout()
        self.table_overlaps = OverlapTableView(self.iface)
        _layout.replaceWidget(_placeholder, self.table_overlaps)
        _placeholder.deleteLater()
        del _placeholder

        # initialize bounds-tableview
        _placeholder = self.findChild(QTableView, "table_bounds")
        _layout = _placeholder.parentWidget().layout()
        self.table_bounds = BoundsTableView(self.iface)
        _layout.replaceWidget(_placeholder, self.table_bounds)
        _placeholder.deleteLater()
        del _placeholder

        self.pb_run_check.clicked.connect(self.run_topo_check_in_background)
        self.findChild(QPushButton, "pb_gap_rm_filter").clicked.connect(
            self.table_gaps.clear_subset_filters
        )

        # combobox handling
        self.comboBox_plans: QComboBox = self.parent.findChild(
            QComboBox, "comboBox_plans"
        )
        QgsProject.instance().layersAdded.connect(self.on_layers_added)
        QgsProject.instance().layersWillBeRemoved.connect(self.on_layers_removed)
        self.comboBox_plans.currentIndexChanged.connect(self.on_current_plan_changed)

        # initial population of combobox
        self.on_layers_added(list(QgsProject.instance().mapLayers().values()))

    def on_layers_added(self, layers: list[QgsVectorLayer]) -> None:
        """Update combobox items when plan layers are added."""
        for layer in layers:
            if layer.customProperty(f"{PLUGIN_DIR_NAME}/layer_type") != "plan":
                continue
            plan_data = {
                "plan_id": layer.customProperty(f"{PLUGIN_DIR_NAME}/plan_id"),
                "qgis_layerid": layer.id(),
            }
            try:
                plan_name = next(layer.getFeatures())["properties"]["name"]
                self.comboBox_plans.addItem(plan_name, plan_data)
            except StopIteration:
                self.comboBox_plans.addItem("Neuer Plan", plan_data)
            # connect signal to track and handle plan name changes
            callback = partial(self.on_plan_plan_layer_commit, layer)
            self._commit_callbacks[layer.id()] = callback
            layer.afterCommitChanges.connect(callback)

    def on_plan_plan_layer_commit(self, layer: QgsVectorLayer):
        """Update plan name in combobox on change."""
        try:
            feature = next(layer.getFeatures())
        except StopIteration:
            return
        attribute_map = feature.attributeMap()
        if not isinstance(
            attribute_map.get("properties"), dict
        ) or not attribute_map.get("id"):
            return
        plan_data = {"plan_id": feature["id"], "qgis_layerid": layer.id()}
        try:
            data_index = self.comboBox_plans.findData(plan_data)
            if data_index >= 0:
                self.comboBox_plans.setItemText(
                    data_index, feature["properties"].get("name", "Neuer Plan")
                )
        except RuntimeError:
            pass

    def on_layers_removed(self, layer_ids: list[str]) -> None:
        """Update combobox items when plan layers are removed and clear web view if currently selected plan is among them."""
        for layer_id in layer_ids:
            layer = QgsProject.instance().mapLayer(layer_id)
            if (
                not layer
                or layer.customProperty(f"{PLUGIN_DIR_NAME}/layer_type") != "plan"
            ):
                continue
            plan_data = {
                "plan_id": layer.customProperty(f"{PLUGIN_DIR_NAME}/plan_id"),
                "qgis_layerid": layer.id(),
            }
            data_index = self.comboBox_plans.findData(plan_data)
            if data_index == -1:
                continue
            # disconnect the afterCommitChanges callback before removal
            callback = self._commit_callbacks.pop(layer_id, None)
            if callback:
                try:
                    layer.afterCommitChanges.disconnect(callback)
                except (TypeError, RuntimeError):
                    pass
            if self.comboBox_plans.currentIndex() == data_index:
                self.reset_topology_result()
            self.comboBox_plans.removeItem(data_index)

    def on_current_plan_changed(self, index: int):
        # reset all tables
        self.reset_topology_result()

    def reset_topology_result(self):
        self.topology_errors = {
            "bounds": [],
            "overlaps": [],
            "gaps": [],
            "gap_features": [],
            "crs": None,
            "plan_id": None,
        }

    @property
    def topology_errors(self) -> Dict[str, List[Any]] | None:
        return self._topology_errors

    @topology_errors.setter
    def topology_errors(
        self, value: Dict[str, List[Any] | str | QgsCoordinateReferenceSystem] | None
    ):
        self._topology_errors = value

        # send new data to tables
        self.table_bounds.repopulate(self.topology_errors["bounds"])
        self.table_bounds.plan_id = self.topology_errors["plan_id"]
        self.table_overlaps.repopulate(self.topology_errors["overlaps"])
        self.table_overlaps.plan_id = self.topology_errors["plan_id"]
        self.table_gaps.repopulate(self.topology_errors["gaps"])
        self.table_gaps.gap_features = self.topology_errors["gap_features"]
        self.table_gaps.plan_id = self.topology_errors["plan_id"]

        # set crs for table views
        self.table_bounds.plan_crs = self._topology_errors["crs"]
        self.table_gaps.plan_crs = self._topology_errors["crs"]
        self.table_overlaps.plan_crs = self._topology_errors["crs"]

        # apply number of violations to tab header
        tabwidget: QTabWidget = self.findChild(QTabWidget, "tabWidget")
        for i in range(tabwidget.count()):
            _tab_text = tabwidget.tabText(i)
            if _tab_text.startswith("Bereichsverletzungen"):
                if (_nr := len(self.topology_errors["bounds"])) > 0:
                    tabwidget.setTabText(i, f"Bereichsverletzungen [{_nr}]")
                else:
                    tabwidget.setTabText(i, "Bereichsverletzungen")
            elif _tab_text.startswith("Überlappungen"):
                if (_nr := len(self.topology_errors["overlaps"])) > 0:
                    tabwidget.setTabText(i, f"Überlappungen [{_nr}]")
                else:
                    tabwidget.setTabText(i, "Überlappungen")
            elif _tab_text.startswith("Lücken"):
                if (_nr := len(self.topology_errors["gaps"])) > 0:
                    tabwidget.setTabText(i, f"Lücken [{_nr}]")
                else:
                    tabwidget.setTabText(i, "Lücken")

    def run_topo_vtx_in_background(self) -> None:
        self.task_topo_vtx = QgsTask.fromFunction(
            description="Topologische Stützpunkte Background-Task",
            function=self.add_topological_vertices,
            on_finished=self.topo_vtx_task_finished,
        )

        self.task_topo_vtx.begun.connect(
            lambda: (
                self.progressBar_vtx.setValue(0),
                self.pb_run_vtx.setDisabled(True),
                self.pb_cancel_vtx.show(),
                self.progressBar_vtx.show(),
                self.pb_cancel_vtx.setDisabled(False),
                self.comboBox_plans.setDisabled(True),
            )
        )

        self.task_topo_vtx.progressChanged.connect(self.on_task_vtx_progress_changed)
        self.task_topo_vtx.taskTerminated.connect(self.topp_vtx_task_terminated)
        self.pb_cancel_vtx.clicked.connect(
            lambda: (self.task_topo_vtx.cancel(), self.pb_cancel_vtx.setDisabled(True))
        )

        QgsApplication.taskManager().addTask(self.task_topo_vtx)

    def topo_vtx_task_finished(self, ex, val=None) -> None:
        if not ex and isinstance(val, tuple):  # task finished regular
            ok, fail, layers_changed = val
            if ok == 0:
                msg_text = "Keine fehlenden topologischen Stützpunkte gefunden."
            elif ok > 0:
                if ok == 1:
                    msg_text = (
                        f"{ok} topologischer Stützpunkt wurde erfolgreich ergänzt."
                    )
                else:
                    msg_text = (
                        f"{ok} topologische Stützpunkte wurden erfolgreich ergänzt."
                    )
            if fail > 0:
                msg_text = f" {fail} Stützpunkte konnten nicht eingefügt werden"
            self.iface.messageBar().pushMessage(msg_text)
            if ok != 0 or fail != 0:
                self.reset_topology_result()

            if layers_changed and len(layers_changed) > 0:
                view = iface.layerTreeView()
                view.clearSelection()

                # select gap layers in layer tree view
                view.setSelectionMode(QAbstractItemView.MultiSelection)

                for layer_id in layers_changed:
                    layer = QgsProject.instance().mapLayer(layer_id)
                    if not layer:
                        continue
                    view.setCurrentLayer(layer)
                view.setSelectionMode(QAbstractItemView.ExtendedSelection)

        elif ex:  # raised Exception
            self.iface.messageBar().pushMessage(
                f"Fehler beim einfügen von topologischen Stützpunkten: {str(ex)}"
            )

        # clean up ui and task connections
        try:
            self.pb_cancel_vtx.setDisabled(True)
            self.task_topo_vtx.begun.disconnect()
            self.task_topo_vtx.progressChanged.disconnect()
            self.task_topo_vtx.taskTerminated.disconnect()
            self.task_topo_vtx.deleteLater()
            self.task_topo_vtx = None
        except Exception as e:
            logger.debug(f"Background-Task cleanup failed with: {e}")
        finally:
            self.progressBar_vtx.hide()
            self.pb_cancel_vtx.hide()
            self.pb_run_vtx.setDisabled(False)
            self.comboBox_plans.setDisabled(False)

        return

    def run_topo_check_in_background(self) -> None:
        self.task_topo_check = QgsTask.fromFunction(
            description="Topologie-Prüfung Background-Task",
            function=self.run_topo_check,
            on_finished=self.topo_check_task_finished,
        )

        self.task_topo_check.begun.connect(
            lambda: (
                self.reset_topology_result(),
                self.progressBar_check.setValue(0),
                self.pb_run_check.setDisabled(True),
                self.pb_cancel_check.show(),
                self.progressBar_check.show(),
                self.pb_cancel_check.setDisabled(False),
                self.comboBox_plans.setDisabled(True),
            )
        )
        self.task_topo_check.progressChanged.connect(
            self.on_task_check_progress_changed
        )
        self.task_topo_check.taskTerminated.connect(self.topp_check_task_terminated)
        self.pb_cancel_check.clicked.connect(
            lambda: (
                self.task_topo_check.cancel(),
                self.pb_cancel_check.setDisabled(True),
            )
        )

        QgsApplication.taskManager().addTask(self.task_topo_check)

    def on_task_check_progress_changed(self, val: float | str) -> None:
        if type(val) in [int, float]:
            self.progressBar_check.setValue(int(val))

    def on_task_vtx_progress_changed(self, val: float | str) -> None:
        if type(val) in [int, float]:
            self.progressBar_vtx.setValue(int(val))

    def topo_check_task_finished(self, ex, val=None) -> None:
        if not ex and isinstance(val, dict):  # regular
            self.topology_errors = val
        elif not ex and val == "no_errors":
            msg = QMessageBox()
            msg.setWindowTitle("XPlan Topologie-Prüfung")
            msg.setText("Topologie Prüfung abgeschlossen. Keine Beanstandungen.")
            msg.setIcon(
                QMessageBox.Information
            )  # Shows an info icon, can be replaced with custom icon
            msg.setStandardButtons(QMessageBox.Ok)
            msg.exec_()
        else:  # raised Exception
            if isinstance(ex, TopologyCheckerMsgError):
                self.iface.messageBar().pushMessage(**ex.get_params())

        # display message if gap_check has been skipped
        if isinstance(val, dict) and val.get("msg", None):
            self.iface.messageBar().pushMessage(
                title="XPlan Topologie-Prüfung",
                text=val["msg"],
                level=Qgis.MessageLevel.Info,
            )

        # clean up ui and task connections
        try:
            self.pb_cancel_check.setDisabled(True)
            self.task_topo_check.begun.disconnect()
            self.task_topo_check.progressChanged.disconnect()
            self.task_topo_check.taskTerminated.disconnect()
            self.task_topo_check.deleteLater()
            self.task_topo_check = None
        except Exception as e:
            logger.debug(f"Background-Task cleanup failed with: {e}")
        finally:
            self.progressBar_check.hide()
            self.pb_cancel_check.hide()
            self.pb_run_check.setDisabled(False)
            self.comboBox_plans.setDisabled(False)
        return

    def topp_check_task_terminated(self):
        if ex := self.task_topo_check.exception:
            logger.error(f"Background Task topo-check terminated with exception: {ex}")

    def topp_vtx_task_terminated(self):
        if ex := self.task_topo_vtx.exception:
            logger.error(f"Background Task topo-vtx terminated with exception: {ex}")

    def run_topo_check(
        self, task: QgsTask | None = None
    ) -> Dict[str, List | str] | str | None:
        """
        Funktion die eine Prüfung der Topolgie aller Flächenschlußobjekte durchführt
        :return:
        """
        if task:
            task.setProgress(10)

        # TODO: check that section-geometries do not intersect or overlap each other
        # TODO: check that plan-geometry covers all section-geometries

        def extractc_polygons_from_collection(geom: QgsGeometry) -> QgsGeometry:
            """
            Extracts all polygon and multipolygon parts from a QgsGeometry (including GeometryCollections)
            and returns them as a single MultiPolygon QgsGeometry.
            Returns an empty QgsGeometry if no polygons are found.
            """
            # Convert to geometry collection (list of QgsGeometry)
            parts = geom.asGeometryCollection()
            polygons = []

            for part in parts:
                wkb_type = part.wkbType()
                if QgsWkbTypes.geometryType(wkb_type) == QgsWkbTypes.PolygonGeometry:
                    # If it's a multipolygon, decompose into polygons
                    if QgsWkbTypes.isMultiType(wkb_type):
                        polygons.extend(
                            [
                                QgsGeometry.fromPolygonXY(p)
                                for p in part.asMultiPolygon()
                            ]
                        )
                    else:
                        polygons.append(QgsGeometry.fromPolygonXY(part.asPolygon()))

            if polygons:
                # Combine all polygons into a single MultiPolygon geometry
                return QgsGeometry.collectGeometry(polygons)
            else:
                # Return an empty geometry if no polygons found
                return QgsGeometry()

        def get_overlaps_and_gaps(
            overlap_features: List[Tuple[QgsFeature, QgsVectorLayer]],
            gap_features: List[Tuple[QgsFeature, QgsVectorLayer]],
            bounding_geom: QgsGeometry,
            task: QgsTask | None = None,
        ) -> Dict[str, List[Any]] | None:
            """
            :param overlap_features:
            :param gap_features:
            :param bounding_geom:
            :param task:

            :return:
            """
            topology_errors = {
                "bounds": [],
                "overlaps": [],
                "gaps": [],
                "gap_features": [],
            }

            # Check for overlaps
            # store features by layer and id
            overlap_feature_dict: Dict[QgsVectorLayer, Dict[int, QgsFeature]] = (
                defaultdict(dict)
            )
            for _feature, _layer in overlap_features:
                overlap_feature_dict[_layer][_feature.id()] = _feature

            layers = overlap_feature_dict.keys()

            # create spatial index for every layer
            spatial_indices: Dict[QgsVectorLayer, QgsSpatialIndex] = {}
            for _layer in overlap_feature_dict.keys():
                spatial_indices[_layer] = QgsSpatialIndex()
                spatial_indices[_layer].addFeatures(
                    overlap_feature_dict[_layer].values()
                )

            if task:
                task.setProgress(71)

            _hashes = []  # holds custom hashes to avoid mirrored/duplicated overlaps
            for layer1 in layers:
                for id_feature1, feature1 in overlap_feature_dict[layer1].items():
                    geom1 = feature1.geometry()

                    for layer2 in layers:
                        potential_overlaping_feature_ids = spatial_indices[
                            layer2
                        ].intersects(geom1.boundingBox())
                        for feature2_fid in potential_overlaping_feature_ids:
                            if task.isCanceled():
                                return None
                            feature2 = overlap_feature_dict[layer2][feature2_fid]
                            geom2 = feature2.geometry()

                            if geom1.overlaps(geom2):
                                geom_intersect_collection = geom2.intersection(geom1)
                                geom_intersect = extractc_polygons_from_collection(
                                    geom_intersect_collection
                                )
                                if geom_intersect_collection.area() < 0:
                                    # this is an edge case that can occur while editing gaps and overlaps
                                    continue

                                # check if this overlap already exists
                                _hash = sorted(
                                    [
                                        id(feature1) + id(layer1),
                                        id(feature2) + id(layer2),
                                    ]
                                )
                                if _hash not in _hashes:
                                    _hashes.append(_hash)
                                    topology_errors["overlaps"].append(
                                        (
                                            geom_intersect,
                                            feature1,
                                            layer1,
                                            feature2,
                                            layer2,
                                        )
                                    )

            if task:
                task.setProgress(75)

            # Check for gaps
            # union_geom = QgsGeometry().fromWkt(wkt="POLYGON EMPTY")
            # for feat, _ in gap_features:
            #     if task.isCanceled():
            #         return None
            #     if union_geom.isEmpty():
            #         union_geom = feat.geometry()
            #     else:
            #         union_geom = union_geom.combine(feat.geometry())

            # Check for gaps TODO: way faster but slightly different results for large plan
            geometries = [feat.geometry() for feat, _ in gap_features]
            union_geom = QgsGeometry.unaryUnion(geometries)

            if task:
                task.setProgress(76)

            gap_geom = bounding_geom.difference(union_geom)

            if task:
                task.setProgress(77)

            if not gap_geom.isEmpty():
                if gap_geom.isMultipart():
                    for p in gap_geom.constParts():
                        topology_errors["gaps"].append(QgsGeometry.fromWkt(p.asWkt()))
                else:
                    topology_errors["gaps"].append(gap_geom)

            topology_errors["gap_features"] = gap_features
            return topology_errors

        def _is_vector_poly(layer) -> bool:
            # test if a layer is vector and has polygon-like geometry
            return (
                isinstance(layer, QgsVectorLayer)
                and QgsWkbTypes.geometryType(layer.wkbType())
                == QgsWkbTypes.PolygonGeometry
            )

        def _collect_layers(group) -> List[QgsVectorLayer]:
            # filter all polygon layers from a layergroup recursive
            result = []
            for child in group.findLayers():
                layer = child.layer()
                if _is_vector_poly(layer):
                    result.append(layer)
            return result

        def _search_layer_feature_props(
            layer: QgsVectorLayer, propkey: str
        ) -> List[str | int | None]:
            """
            get a list of values for a given xplan property key of all features of a layer
            :param layer: QgsVectorlayer which features are searched
            :param propkey: name of the xplan property to look for
            :return:
            """
            ret = []
            for feature in layer.getFeatures():
                props = feature.attributeMap().get("properties", {})
                try:
                    ret.append(props[propkey])
                except KeyError:
                    pass
            return ret

        def _layer_features_contained_by_geom(
            layers_list: List[QgsMapLayer] | Set[QgsMapLayer],
            reference_geom: QgsGeometry,
        ) -> list[dict[str, Any]]:
            """
            check whether geometries in a list of layers are outside a given reference polygon.

            :param layers_list (list): List of QgsVectorLayer objects to process.
            :param reference_geom (QgsGeometry): Reference polygon as a QgsGeometry.

            :returns dict: Dictionary mapping layer to a list of dicts with feature and xplan IDs.
            """
            # Check reference polygon validity and emptiness
            wkb_type = reference_geom.wkbType()
            if not QgsWkbTypes.geometryType(wkb_type) == QgsWkbTypes.PolygonGeometry:
                raise ValueError("Reference geometry is not of type Polygon")
            if reference_geom is None or reference_geom.isEmpty():
                raise ValueError("Reference polygon geometry is empty or None.")
            if not reference_geom.isGeosValid():
                raise ValueError(
                    "Reference polygon geometry is invalid. Please fix geometry before proceeding."
                )

            # GEOS geometry engine for performance
            geometry_engine = QgsGeometry.createGeometryEngine(
                reference_geom.constGet()
            )
            geometry_engine.prepareGeometry()

            results = []

            for layer in layers_list:
                if layer is None or not layer.isValid():
                    continue

                for feature in layer.getFeatures():
                    feat_geom = feature.geometry()
                    if feat_geom is None or feat_geom.isEmpty():
                        continue
                    if not feat_geom.isGeosValid():
                        continue

                    # if geometry_engine.contains(feat_geom.constGet()): # TODO: Allow minimal deviance for now, then either make it work with QgsGeometryParameters.setGridSize() or QgsGeometrySnapper.snapFeatures()
                    #     continue

                    outside = feat_geom.difference(reference_geom)

                    ok = False
                    if outside.isEmpty():
                        ok = True
                    elif outside.area() < 1e-8:
                        ok = True

                    if not ok:
                        # check if all vertices in outside geom and reference geom are identical
                        vertex_identical = []
                        vertex_dist_id = []
                        for i, _v in enumerate(list(outside.vertices())):
                            # squared_dist, next_idx = reference_geom.closestVertexWithContext(QgsPointXY(_v))
                            closest_point, closest_vidx, prev_idx, next_idx, sqdist = (
                                reference_geom.closestVertex(QgsPointXY(_v))
                            )
                            if (
                                closest_point
                                and _v.x() == closest_point.x()
                                and _v.y() == closest_point.y()
                            ):
                                vertex_identical.append(True)
                            else:
                                vertex_identical.append(False)
                            vertex_dist_id.append((sqdist, closest_vidx))
                        if all(vertex_identical):
                            ok = True

                    if ok:
                        continue

                    results.append({"feature": feature, "layer": layer})

            return results

        # get information about selected plan from combo box
        cb_item = self.comboBox_plans.currentData()
        if not cb_item:
            return None
        selected_xplan_id = cb_item["plan_id"]
        selected_qgis_layerid = cb_item["qgis_layerid"]

        # make sure the selected plan is still present
        plan_layer = QgsProject.instance().mapLayer(selected_qgis_layerid)
        if not plan_layer:
            raise TopologyCheckerMsgError(
                title="XPlan Topologie-Prüfung",
                text="Plan-Layer nicht geladen. Die Topologie-Prüfung kann nicht ausgeführt werden.",
                level=Qgis.MessageLevel.Warning,
            )

        # check that the plan-layer has only one feature
        if plan_layer.featureCount() == 0:
            raise TopologyCheckerMsgError(
                title="XPlan Topologie-Prüfung",
                text=f"Keine Geometrie im Plan Layer: '{plan_layer.name()}'. "
                f"Die Topologie-Prüfung kann nicht ausgeführt werden.",
                level=Qgis.MessageLevel.Warning,
            )
        elif plan_layer.featureCount() > 1:
            raise TopologyCheckerMsgError(
                title="XPlan Topologie-Prüfung",
                text=f"Mehr als eine Geometrie im Plan Layer: '{plan_layer.name()}'. "
                f"Die Topologie-Prüfung kann nicht ausgeführt werden.",
                level=Qgis.MessageLevel.Warning,
            )

        # check that there is no "flächenschlussobjekt" in plan layer
        plan_feature: QgsFeature = next(
            plan_layer.getFeatures()
        )  # alreday checked that this layer contains only a single feature
        plan_feature_properties = plan_feature.attributeMap().get("properties", {})
        plan_geom = plan_feature.geometry()
        if plan_feature_properties.get("flaechenschluss", None) == "true":
            raise TopologyCheckerMsgError(
                title="XPlan Topologie-Prüfung",
                text="Plan-Layer enthält ein Flächenschlußobjekt. Die Topologie-Prüfung kann nicht ausgeführt werden.",
                level=Qgis.MessageLevel.Warning,
            )

        # get all sections and all applicable layers
        sections: Dict[QgsLayerTreeGroup, List[QgsVectorLayer]] = {}
        rex_sections = re.compile(r"^Bereich \d+$")
        plan_layergroup: QgsLayerTreeGroup = (
            QgsProject.instance().layerTreeRoot().findLayer(plan_layer).parent()
        )
        if not plan_layergroup:
            raise TopologyCheckerMsgError(
                title="XPlan Topologie-Prüfung",
                text="Plan-Layer nicht Teil einer Gruppe. Die Topologie-Prüfung kann nicht ausgeführt werden.",
                level=Qgis.MessageLevel.Warning,
            )

        xplan_layers = []
        for layer_node in plan_layergroup.findLayers():
            layer = layer_node.layer()
            if "xmas_plugin/plan_id" in layer.customProperties().keys():
                xplan_layers.append(layer)

        # make sure all layers are in the same crs
        _crss: List[str] = [plan_layer.crs().toWkt()]
        for layer in xplan_layers:
            _wkt = layer.crs().toWkt()
            if _wkt != "":  # layers without geometry might not have a crs set
                _crss.append(layer.crs().toWkt())

        if len(set(_crss)) != 1:
            raise TopologyCheckerMsgError(
                title="XPlan Topologie-Prüfung",
                text="Layer mit unterschiedlichen Koordinatenreferenzsystemen. "
                "Topologie-Prüfung kann nicht durchgeführt werden",
                level=Qgis.MessageLevel.Warning,
            )

        if task and task.isCanceled():
            return None
        # collect layers of type section
        tree_nodes = [plan_layergroup]
        while tree_nodes:
            node = tree_nodes.pop()  # get next element to check
            for child in node.children():
                if isinstance(child, QgsLayerTreeGroup):
                    if rex_sections.search(child.name()):
                        sections[child] = _collect_layers(child)
                    else:
                        tree_nodes.append(
                            child
                        )  # add back to stack for recursive traversing

        # section layer features must not have attribute flächenschluss == true
        for section_node, layers in sections.items():
            for child in section_node.children():
                if (
                    isinstance(child, QgsLayerTreeLayer)
                    and child.layer()
                    .customProperties()
                    .value(f"{PLUGIN_DIR_NAME}/layer_type")
                    == "section"
                ):
                    if _fs := list(child.layer().getFeatures()):
                        for _f in _fs:
                            if task and task.isCanceled():
                                return None
                            _f: QgsFeature
                            props = _f.attributeMap().get("properties", {})
                            if props.get("flaechenschluss", None):
                                raise TopologyCheckerMsgError(
                                    title="XPlan Topologie-Prüfung",
                                    text=f"Bereichsobjekt in '{section_node.name()}' enthält ein "
                                    f"Flächenschlußobjekt. Die Topologie-Prüfung kann nicht "
                                    f"ausgeführt werden.",
                                    level=Qgis.MessageLevel.Warning,
                                )

        if task:
            task.setProgress(20)

        # test that every geometry object is inside Plan
        _bound_errors = []
        layers_to_check = [
            layer
            for layer in xplan_layers
            if layer != plan_layer and isinstance(layer, QgsMapLayer)
        ]
        try:
            res = _layer_features_contained_by_geom(layers_to_check, plan_geom)
        except ValueError as e:
            logger.error(f'topology error in "inside plan/section"-check: {e}')
            raise TopologyCheckerMsgError(
                title="XPlan Topologie-Prüfung",
                text=f"interner Fehler: {e}",
                level=Qgis.MessageLevel.Error,
            )

        if res:
            for i, _e in enumerate(res):
                res[i].update({"Typ": "Plan"})
            _bound_errors += res

        # for sections with geometry test that all section features are inside section
        for section_node in sections.keys():
            # divide section-layer and data-layers
            section_layers = [_l.layer() for _l in section_node.findLayers()]
            section_layer = None
            subject_layers = set()

            for layer in section_layers:
                layer_type = layer.customProperties().value(
                    f"{PLUGIN_DIR_NAME}/layer_type"
                )
                if layer_type == "section":
                    if not section_layer:
                        section_layer = layer
                    else:
                        raise TopologyCheckerMsgError(
                            title="XPlan Topologie-Prüfung",
                            text=f"Der Bereich {section_node.name()} enthält mehr als ein "
                            f"'Bereichsobjekt'-Layer.\n"
                            f"Die Topologie-Prüfung kann nicht durchgeführt werden",
                            level=Qgis.MessageLevel.Error,
                        )
                elif layer_type:
                    subject_layers.add(layer)

            # check if section-layer has a geom
            if not section_layer or section_layer.featureCount() != 1:
                logger.debug("Section_layer has a feature count of != 1!")
                continue

            # check features inside section-geom
            section_feat = next(section_layer.getFeatures(), None)
            if not section_feat:
                logger.debug(
                    "section_layer has featureCount==1 but getFeatures() returned nothing"
                )
                continue

            section_geom = section_feat.geometry()
            if not section_geom or section_geom.isEmpty():
                logger.debug(
                    "section_layer single feature has empty geometry: layer=%s fid=%s",
                    section_layer.name(),
                    section_feat.id(),
                )
                continue

            try:
                res = _layer_features_contained_by_geom(subject_layers, section_geom)
            except ValueError as e:
                logger.error(f"interner Fehler: {e}")
                raise TopologyCheckerMsgError(
                    title="XPlan Topologie-Prüfung",
                    text=f"interner Fehler: {e}",
                    level=Qgis.MessageLevel.Error,
                )

            if res:
                if res:
                    for i, _e in enumerate(res):
                        res[i].update({"Typ": "Bereich"})
                    _bound_errors += res

        if task:
            task.setProgress(40)

        # collect all features that must fulfill the topology rules
        _layers: List[QgsVectorLayer] = [x for k, v in sections.items() for x in v]
        topology_features: List[Tuple[QgsFeature, QgsVectorLayer]] = []
        for _layer in _layers:
            _layer_props = _layer.customProperties()
            _layer_plan_id = _layer_props.value(f"{PLUGIN_DIR_NAME}/plan_id")
            if not _layer_plan_id == selected_xplan_id:
                continue
            # Improvement: collect features by expression and request instead of layer.getFEatures(), should speed up
            for f in _layer.getFeatures():
                if task and task.isCanceled():
                    return None
                f: QgsFeature
                feature_props = f.attributeMap().get("properties", {})
                if feature_props.get("ebene", 0) != 0:
                    continue
                if not feature_props.get("flaechenschluss", None):
                    continue
                topology_features.append((f, _layer))

        # collect all features that must fullfill the overlap rules
        overlap_features = []
        for f, layer in topology_features:
            if task and task.isCanceled():
                return None
            feature_props = f.attributeMap().get("properties", {})
            if feature_props.get("startBedingung", None) or feature_props.get(
                "endeBedingung", None
            ):
                raise TopologyCheckerMsgError(
                    title="XPlan Topologie-Prüfung",
                    text="Fläche mit 'startBedingung' oder 'endeBedingung' gefunden. "
                    "Die Topologie-Prüfung kann nicht ausgeführt werden.",
                    level=Qgis.MessageLevel.Warning,
                )

            overlap_features.append((f, layer))

        if task:
            task.setProgress(60)

        # collect all features and bounding_geoms for the gap checking
        skip_gap_check = False
        skip_gap_msg = "Überprüfung auf Flächenschluß wurde nicht ausgeführt: "

        # skip gap check if type is Landschaftsplan
        if plan_feature.attributeMap().get("featuretype", None) == "LP_Plan":
            skip_gap_check = True
            skip_gap_msg += "Landschaftsplan."
            logger.debug(
                "skipped topology gap-checks because plan is of type LP (Landschaftsplan) "
            )

        # skip gap check if plan or section has relation aendertPlan | aendertPlanBereich
        node_stack = [plan_layergroup]
        try:
            while node_stack:
                node = node_stack.pop()
                for child in node.children():
                    if isinstance(child, QgsLayerTreeGroup):
                        node_stack.append(child)
                    elif (
                        isinstance(child, QgsLayerTreeLayer)
                        and isinstance(child.layer(), QgsVectorLayer)
                        and child.layer()
                        .customProperties()
                        .value(f"{PLUGIN_DIR_NAME}/plan_id")
                        == selected_xplan_id
                        and child.layer()
                        .customProperties()
                        .value(f"{PLUGIN_DIR_NAME}/layer_type")
                        in ["plan", "section"]
                    ):
                        # make sure there are no features containing "aendertPlan" or "andertPlanBereich"
                        # checking for gaps is not specified in this case
                        expr = QgsExpression(
                            "\"properties\"['aendertPlan'] IS NOT NULL "
                            "OR \"properties\"['aendertPlanBereich'] IS NOT NULL"
                        )
                        request = QgsFeatureRequest(expr)
                        _features = child.layer().getFeatures(request)
                        if next(_features, None):
                            raise AttributeError

        except AttributeError:
            skip_gap_msg += 'Feature mit Attribut "aendertPlan" bzw. "aenderPlanBereich" vorhanden. '
            skip_gap_check = True

        # exclude features which are part of a section with bedeutung 1800|2000
        _flat_section_layers = [layer for k, v in sections.items() for layer in v]
        _flat_section_layers = [
            layer
            for layer in _flat_section_layers
            if layer.customProperties().value(f"{PLUGIN_DIR_NAME}/layer_type")
            == "section"
        ]
        _skip_layers_ = [
            layer
            for layer in _flat_section_layers
            if 1800 in _search_layer_feature_props(layer, "bedeutung")
            or 2000 in _search_layer_feature_props(layer, "bedeutung")
        ]

        gap_features = []
        if not skip_gap_check:
            for f, layer in topology_features:
                if task and task.isCanceled():
                    return None
                if layer in _skip_layers_:
                    continue
                gap_features.append((f, layer))

        if task:
            task.setProgress(70)

        _topology_errors = get_overlaps_and_gaps(
            overlap_features, gap_features, plan_geom, task
        )
        if not _topology_errors:  # task got canceled
            return None
        _topology_errors["bounds"] = _bound_errors
        _topology_errors["crs"] = plan_layer.crs()
        _topology_errors["plan_id"] = _layer_plan_id

        if skip_gap_check:
            _topology_errors["msg"] = skip_gap_msg

        # message to user when no errors found
        if (
            len(_topology_errors["bounds"]) == 0
            and len(_topology_errors["gaps"]) == 0
            and len(_topology_errors["overlaps"]) == 0
        ):
            if task:
                task.setProgress(80)

            return "no_errors"
        else:
            if task:
                task.setProgress(80)

            return _topology_errors

    def add_topological_vertices(self, task: QgsTask | None = None) -> None:
        plan_qgis_layer_id = self.comboBox_plans.currentData().get("qgis_layerid", None)
        if plan_qgis_layer_id:
            layers = get_all_plan_layers(plan_qgis_layer_id)
            if task:
                if task.isCanceled():
                    return None, None, None
                task.setProgress(25)

            result = build_vertex_features(layers)
            if task:
                if task.isCanceled():
                    return None, None, None
                task.setProgress(50)

            missing = find_missing_vertices(layers, result)
            if task:
                if task.isCanceled():
                    return None, None, None
                task.setProgress(75)

            ok, fail, layers_changed = insert_vertices(missing, commit_changes=False)
            if task:
                if task.isCanceled():
                    return None, None, None
                task.setProgress(99)
            return ok, fail, layers_changed
        else:
            raise RuntimeError("plan-id not available")
