# 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 logging
import re
import time
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Set, Tuple

import processing
from qgis.core import (
    Qgis,
    QgsAbstractGeometry,
    QgsApplication,
    QgsCoordinateReferenceSystem,
    QgsExpression,
    QgsFeature,
    QgsFeatureIterator,
    QgsFeatureRequest,
    QgsField,
    QgsFields,
    QgsGeometry,
    QgsLayerTreeGroup,
    QgsLayerTreeLayer,
    QgsMapLayer,
    QgsPoint,
    QgsPointXY,
    QgsProject,
    QgsRectangle,
    QgsSpatialIndex,
    QgsTask,
    QgsVectorLayer,
    QgsVectorLayerEditUtils,
    QgsWkbTypes,
)
from qgis.PyQt import uic
from qgis.PyQt.QtCore import QMetaType, Qt
from qgis.PyQt.QtWidgets import (
    QAbstractItemView,
    QCheckBox,
    QComboBox,
    QDoubleSpinBox,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QRadioButton,
    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_invalids import InvalidsTableView
from xmas_plugin.topo_check.table_overlaps import OverlapTableView
from xmas_plugin.util.geom import get_all_plan_layers, get_layer_segments
from xmas_plugin.util.metadata import PLUGIN_DIR_NAME
from xmas_plugin.util.plan_combobox import PlanComboboxManager
from xmas_plugin.util.topo_vertices import (
    _segment_param_t,
    build_vertex_features,
    find_missing_vertices,
    insert_vertices,
)

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

logger = logging.getLogger(PLUGIN_DIR_NAME)


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 invalids-tableview
        _placeholder = self.findChild(QTableView, "table_invalids")
        _layout = _placeholder.parentWidget().layout()
        self.table_invalids = InvalidsTableView(self.iface)
        _layout.replaceWidget(_placeholder, self.table_invalids)
        _placeholder.deleteLater()
        del _placeholder

        # 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)
        # clearing layers subsetfilters is connectect only to one table, it will clear
        # filters applied in the other tables as well
        self.findChild(QPushButton, "pb_clear_subset_filters").clicked.connect(
            self.table_gaps.clear_subset_filters
        )

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

        # ui elements for vertex cluster tool
        self.pb_search_clusters = self.findChild(QPushButton, "pb_search_clusters")
        self.pb_search_clusters.clicked.connect(self.search_vertex_clusters)
        self.sb_cluster_epsilon = self.findChild(QDoubleSpinBox, "sb_cluster_epsilon")
        self.cb_cluster_layers = self.findChild(QComboBox, "cb_cluster_layers")
        self.pb_merge_clusters = self.findChild(QPushButton, "pb_merge_clusters")
        self.pb_merge_clusters.clicked.connect(
            lambda: self.merge_clusters(mode="merge")
        )
        self.pb_undo_clusters = self.findChild(QPushButton, "pb_undo_clusters")
        self.pb_undo_clusters.clicked.connect(lambda: self.merge_clusters(mode="undo"))

        self.pb_delete_cluster_layer = self.findChild(
            QPushButton, "pb_delete_cluster_layer"
        )
        self.pb_delete_cluster_layer.clicked.connect(self.remove_cluster_layers)

        # ui elements for segmentize curves tool
        self.pb_segementize_curves = self.findChild(
            QPushButton, "pb_segementize_curves"
        )
        self.rb_curves_all_layer = self.findChild(QRadioButton, "rb_curves_all_layer")
        self.rb_curves_active_layer = self.findChild(
            QRadioButton, "rb_curves_active_layer"
        )
        self.cb_curves_selection = self.findChild(QCheckBox, "cb_curves_selection")
        self.dsb_curves_distance = self.findChild(QDoubleSpinBox, "dsb_curves_distance")

        self.pb_segementize_curves.clicked.connect(self.segmentize_curves)

        # ui elements for duplicate vertex tool
        self.pb_remove_duplicates = self.findChild(QPushButton, "pb_remove_duplicates")
        self.rb_dupl_all_layer = self.findChild(QRadioButton, "rb_dupl_all_layer")
        self.rb_dupl_active_layer = self.findChild(QRadioButton, "rb_dupl_active_layer")
        self.cb_dupl_selection = self.findChild(QCheckBox, "cb_dupl_selection")
        self.dsb_dupl_distance = self.findChild(QDoubleSpinBox, "dsb_dupl_distance")

        self.pb_remove_duplicates.clicked.connect(self.remove_duplicate_vertices)

        # ui elements for densify vertex tool
        self.pb_densify_search = self.findChild(QPushButton, "pb_densify_search")
        self.pb_densify_search.clicked.connect(self.densify_search_vertices)
        self.dsb_densify_dist = self.findChild(QDoubleSpinBox, "dsb_densify_dist")
        self.cb_densify_layer = self.findChild(QComboBox, "cb_densify_layer")
        self.pb_densify_insert = self.findChild(QPushButton, "pb_densify_insert")
        self.pb_densify_insert.clicked.connect(
            lambda: self.densify_insert_vertices(mode="insert")
        )
        self.pb_densify_undo = self.findChild(QPushButton, "pb_densify_undo")
        self.pb_densify_undo.clicked.connect(
            lambda: self.densify_insert_vertices(mode="undo")
        )
        self.rb_densify_all = self.findChild(QRadioButton, "rb_densify_all")
        self.rb_densify_selection = self.findChild(QRadioButton, "rb_densify_selection")
        self.rb_densify_invert = self.findChild(QRadioButton, "rb_densify_invert")

        self.pb_densify_delete_layers = self.findChild(
            QPushButton, "pb_densify_delete_layers"
        )
        self.pb_densify_delete_layers.clicked.connect(self.remove_densify_layers)

        # ui elements for roundig coords tool
        self.pb_round_coords = self.findChild(QPushButton, "pb_round_coords")
        self.pb_round_coords.clicked.connect(self.round_coords)
        self.dsb_round_coords = self.findChild(QDoubleSpinBox, "dsb_round_coords")

        # ui elements for general tools
        self.pb_edit_all_layers = self.findChild(QPushButton, "pb_edit_all_layers")
        self.pb_edit_all_layers.clicked.connect(self.edit_all_layers)
        self.cb_edit_mode = self.findChild(QCheckBox, "cb_edit_mode")
        self.cb_edit_mode.clicked.connect(self.switch_styles)

    def round_coords(self):
        """

        :return:
        """
        # get layer_id of current plan
        try:
            plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                "qgis_layerid", None
            )
        except AttributeError:
            return
        if not plan_qgis_layer_id:
            return

        resolution = self.dsb_round_coords.value() / 1000

        layers_to_process: List[QgsVectorLayer] = get_all_plan_layers(
            plan_qgis_layer_id
        )
        if not layers_to_process:
            return

        app = QgsApplication.instance()
        app.setOverrideCursor(Qt.WaitCursor)
        try:
            for layer in layers_to_process:
                if not isinstance(layer, QgsVectorLayer):
                    logger.debug(
                        f"round_coords: layer {layer.name()}/{layer.id()} is not a vector layer"
                    )
                    continue
                if not layer.isValid():
                    logger.debug(
                        f"round_coords: layer {layer.name()}/{layer.id()} is not a valid layer"
                    )
                    continue
                layer.beginEditCommand("round coords")
                try:
                    fids = layer.allFeatureIds()
                    if not layer.isEditable():
                        layer.startEditing()
                    edit_buffer = layer.editBuffer()
                    for fid in fids:
                        geom = layer.getGeometry(fid)
                        new_geom = geom.snappedToGrid(resolution, resolution)
                        edit_buffer.changeGeometry(fid, new_geom)
                finally:
                    layer.endEditCommand()
            self.iface.messageBar().pushMessage(
                f"Präzision: Koordinaten gerundet,  {resolution * 1000} mm"
            )
        finally:
            app.restoreOverrideCursor()

    def densify_search_vertices(self):
        """
        insert additional vertices to segements that are close to other existing vertices
        :return:
        """
        # get layer_id of current plan
        try:
            plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                "qgis_layerid", None
            )
        except AttributeError:
            return
        if not plan_qgis_layer_id:
            return

        # get layers that should be segmentized
        layers_to_process: List[QgsVectorLayer] = get_all_plan_layers(
            plan_qgis_layer_id
        )
        if not layers_to_process:
            return

        app = QgsApplication.instance()
        app.setOverrideCursor(Qt.WaitCursor)
        try:
            # get all segments from all layers
            segments = []
            for layer in layers_to_process:
                segments += get_layer_segments(layer)

            # get distance threshold from user ui input
            thresh_dist = self.dsb_densify_dist.value() / 1000.0
            thresh_dist_sqd = thresh_dist * thresh_dist

            # build spatial index from segment bounding boxes
            segments_sidx = QgsSpatialIndex()
            segments_bbox_features = []
            for i, seg in enumerate(segments):
                f = QgsFeature(i)
                seg_bbox = seg["segment_geometry"].boundingBox()
                bbox = QgsRectangle(
                    seg_bbox.xMinimum() - thresh_dist,
                    seg_bbox.yMinimum() - thresh_dist,
                    seg_bbox.xMaximum() + thresh_dist,
                    seg_bbox.yMaximum() + thresh_dist,
                )
                f.setGeometry(QgsGeometry.fromRect(bbox))
                segments_bbox_features.append(f)
            segments_sidx.addFeatures(segments_bbox_features)

            # loop all vertices and find nearby segments and points to insert
            vertices_to_insert = []
            for i, seg in enumerate(segments):
                cur_vtx_point = seg["start_point"]
                search_rect = QgsRectangle(
                    cur_vtx_point.x() - 2 * thresh_dist,
                    cur_vtx_point.y() - 2 * thresh_dist,
                    cur_vtx_point.x() + 2 * thresh_dist,
                    cur_vtx_point.y() + 2 * thresh_dist,
                )
                candidate_segments = segments_sidx.intersects(search_rect)
                candidate_segments.remove(i)
                for c_seg in candidate_segments:
                    seg_geom = segments[c_seg]["segment_geometry"]
                    cur_seg = segments[c_seg]
                    sqd_dist, point_on_seg, seg_vtx_id_after, left_of = (
                        seg_geom.closestSegment(cur_vtx_point)
                    )
                    # skip if there is already a point on this position
                    if (
                        point_on_seg.distance(cur_seg["start_point"]) < thresh_dist
                        or point_on_seg.distance(cur_seg["end_point"]) < thresh_dist
                    ):
                        continue
                    # insert vertex when closer then threshold
                    if sqd_dist <= thresh_dist_sqd and sqd_dist != 0:
                        dist_from_start_vtx = _segment_param_t(
                            cur_seg["start_point"],
                            cur_seg["end_point"],
                            QgsPointXY(point_on_seg),
                        )
                        vertices_to_insert.append(
                            {
                                "layer_id": cur_seg["layer_id"],
                                "layer_name": cur_seg["layer_name"],
                                "layer_type": cur_seg["layer_type"],
                                "fid": cur_seg["fid"],
                                "xplan_id": cur_seg["xplan_id"],
                                "new_vtx_x": point_on_seg.x(),
                                "new_vtx_y": point_on_seg.y(),
                                "insert_before": cur_seg["end_vertex_id"],
                                "dist_from_start_vtx": dist_from_start_vtx,
                                "new_vtx_pos": point_on_seg,
                            }
                        )

            # add new vertices as map layer
            target_crs = layers_to_process[0].crs()
            layer_name = self.generate_densify_layer_name()
            geom_type = "Point?crs={}".format(target_crs.authid())
            mem_layer = QgsVectorLayer(geom_type, layer_name, "memory")
            fields = QgsFields()
            fields.append(QgsField("layer_id", QMetaType.Type.QString))
            fields.append(QgsField("layer_name", QMetaType.Type.QString))
            fields.append(QgsField("layer_type", QMetaType.Type.QString))
            fields.append(QgsField("fid", QMetaType.Type.Int))
            fields.append(QgsField("xplan_id", QMetaType.Type.QString))
            fields.append(QgsField("new_vtx_x", QMetaType.Type.Double))
            fields.append(QgsField("new_vtx_y", QMetaType.Type.Double))
            fields.append(QgsField("insert_before", QMetaType.Type.Int))
            fields.append(QgsField("dist_from_start_vtx", QMetaType.Type.Double))
            mem_layer.dataProvider().addAttributes(fields)
            mem_layer.updateFields()

            if not mem_layer.isValid():
                raise RuntimeError("Failed to create the temporary layer")

            _features = []
            for i, vertex_to_insert in enumerate(vertices_to_insert):
                f = QgsFeature()
                f.setFields(mem_layer.fields())
                f.setId(i)
                f.setGeometry(QgsGeometry.fromPoint(vertex_to_insert["new_vtx_pos"]))
                f.setAttribute("layer_id", vertex_to_insert["layer_id"])
                f.setAttribute("layer_name", vertex_to_insert["layer_name"])
                f.setAttribute("layer_type", vertex_to_insert["layer_type"])
                f.setAttribute("fid", vertex_to_insert["fid"])
                f.setAttribute("xplan_id", vertex_to_insert["xplan_id"])
                f.setAttribute("new_vtx_x", vertex_to_insert["new_vtx_x"])
                f.setAttribute("new_vtx_y", vertex_to_insert["new_vtx_y"])
                f.setAttribute("insert_before", vertex_to_insert["insert_before"])
                f.setAttribute(
                    "dist_from_start_vtx", vertex_to_insert["dist_from_start_vtx"]
                )
                _features.append(f)
            mem_layer.startEditing()
            mem_layer.dataProvider().addFeatures(_features)
            mem_layer.commitChanges()
            mem_layer.updateExtents()
            mem_layer.setCustomProperty(f"{PLUGIN_DIR_NAME}/xplan_densify", True)
            qml_path = Path(__file__).parent / "densify_vertices_style.qml"
            success, msg = mem_layer.loadNamedStyle(
                qml_path.as_posix(), loadFromLocalDb=False
            )
            self.insert_layer_at_top_of_plan_group(plan_qgis_layer_id, mem_layer)
        finally:
            app.restoreOverrideCursor()

    def densify_insert_vertices(self, mode: str):
        app = QgsApplication.instance()
        app.setOverrideCursor(Qt.WaitCursor)

        try:
            densify_layer: QgsVectorLayer = self.cb_densify_layer.currentData()
            if not densify_layer:
                app.restoreOverrideCursor()
                return

            # get fids according to selection-option radio buttons
            fids: List
            if self.rb_densify_all.isChecked():
                fids = densify_layer.allFeatureIds()
            elif self.rb_densify_selection.isChecked():
                fids = densify_layer.selectedFeatureIds()
            elif self.rb_densify_invert.isChecked():
                _all = densify_layer.allFeatureIds()
                _sel = densify_layer.selectedFeatureIds()
                fids = list(set(_all).difference(set(_sel)))
            else:
                app.restoreOverrideCursor()
                return

            # collect new vtx info
            new_vtx_data = []
            for feature in densify_layer.getFeatures(fids):
                feature_data = feature.attributeMap()
                new_vtx_data.append(feature_data)

            # sort new vertices by layer
            new_vtx_by_layer_id = defaultdict(list)
            for vtx in new_vtx_data:
                new_vtx_by_layer_id[vtx["layer_id"]].append(vtx)

            # insert new vertices by layer
            _cnt = 0
            for layer_id, vertices in new_vtx_by_layer_id.items():
                current_layer: QgsVectorLayer = QgsProject.instance().mapLayer(layer_id)
                if not current_layer.isEditable():
                    current_layer.startEditing()
                edit_utils = QgsVectorLayerEditUtils(current_layer)
                vtxs_by_fid = defaultdict(list)
                for vertex_to_insert in vertices:
                    vtxs_by_fid[vertex_to_insert["fid"]].append(vertex_to_insert)
                current_layer.beginEditCommand("add nodes to closeby segments")
                try:
                    # process vertices by feature
                    for fid, vtxs in vtxs_by_fid.items():
                        feature = current_layer.getFeature(fid)
                        geom = feature.geometry()
                        if (
                            not geom
                            or geom.isEmpty()
                            or geom.isNull()
                            or not geom.isGeosValid()
                        ):
                            logger.debug(
                                f"densify vertices: source-geometry is empty/null/invalid {layer_id}/{fid}"
                            )
                            continue

                        # filter duplicates
                        non_duplicates = []
                        _knowns = set()
                        for vtx in vtxs:
                            _hash = (vtx["insert_before"], vtx["dist_from_start_vtx"])
                            if _hash not in _knowns:
                                _knowns.add(_hash)
                                non_duplicates.append(vtx)

                        # sort vertices descending by vertex_id and position on segment to avoid index shifts
                        non_duplicates.sort(
                            key=lambda x: (
                                x["insert_before"],
                                x["dist_from_start_vtx"],
                            ),
                            reverse=True,
                        )
                        if mode == "insert":
                            # insert vertices
                            for j, vtx in enumerate(non_duplicates):
                                edit_utils.insertVertex(
                                    vtx["new_vtx_x"],
                                    vtx["new_vtx_y"],
                                    fid,
                                    vtx["insert_before"],
                                )
                                _cnt += 1
                        elif mode == "undo":
                            geom = current_layer.getGeometry(fid)
                            for vtx in non_duplicates:
                                vtx_p = QgsPointXY(vtx["new_vtx_x"], vtx["new_vtx_y"])
                                _cp, closest_vtx_index, _pvidx, _nvidx, sqrdst = (
                                    geom.closestVertex(vtx_p)
                                )
                                if sqrdst < 1e-9:
                                    edit_utils.deleteVertex(fid, closest_vtx_index)
                                    _cnt += 1

                finally:
                    current_layer.endEditCommand()
            self.iface.messageBar().pushMessage(
                f"Stützpunktverdichtung: {_cnt} Feature(s) modifiziert"
            )
        finally:
            iface.mapCanvas().setUpdatesEnabled(True)
            app.restoreOverrideCursor()

    def get_cluster_layers(self):
        # collect all vertex cluster layers in plan group
        try:
            plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                "qgis_layerid", None
            )
        except AttributeError:
            return []
        if not plan_qgis_layer_id:
            return []
        layers = get_all_plan_layers(plan_qgis_layer_id, only_xplan_layers=False)

        cluster_layers: List[QgsVectorLayer] = []
        for _l in layers:
            if _l.customProperties().value(f"{PLUGIN_DIR_NAME}/xplan_vertex_cluster"):
                cluster_layers.append(_l)
        return cluster_layers

    def get_densify_layers(self):
        # collect all densify layers in plan group
        try:
            plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                "qgis_layerid", None
            )
        except AttributeError:
            return []
        if not plan_qgis_layer_id:
            return []
        layers = get_all_plan_layers(plan_qgis_layer_id, only_xplan_layers=False)

        densify_layers: List[QgsVectorLayer] = []
        for _l in layers:
            if _l.customProperties().value(f"{PLUGIN_DIR_NAME}/xplan_densify"):
                densify_layers.append(_l)
        return densify_layers

    def edit_all_layers(self):
        try:
            plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                "qgis_layerid", None
            )
        except AttributeError:
            return
        if not plan_qgis_layer_id:
            return
        layers = get_all_plan_layers(plan_qgis_layer_id)
        for _l in layers:
            if not _l.isEditable():
                _l.startEditing()

    def remove_cluster_layers(self):
        layers = self.get_cluster_layers()
        for _l in layers:
            QgsProject.instance().removeMapLayer(_l.id())
        iface.mapCanvas().refresh()

    def remove_densify_layers(self):
        layers = self.get_densify_layers()
        for _l in layers:
            QgsProject.instance().removeMapLayer(_l.id())
        iface.mapCanvas().refresh()

    def populate_cluster_maplayer_combobox(self):
        # get all layers in current plan group
        layers = self.get_cluster_layers()
        self.cb_cluster_layers.clear()
        for _l in layers:
            if _l.customProperties().value(f"{PLUGIN_DIR_NAME}/xplan_vertex_cluster"):
                # add cluster layers
                self.cb_cluster_layers.addItem(_l.name(), _l)

    def populate_densify_maplayer_combobox(self):
        # get all layers in current plan group
        layers = self.get_densify_layers()
        self.cb_densify_layer.clear()
        for _l in layers:
            if _l.customProperties().value(f"{PLUGIN_DIR_NAME}/xplan_densify"):
                # add cluster layers
                self.cb_densify_layer.addItem(_l.name(), _l)

    def generate_cluster_layer_name(self):
        cluster_layers = self.get_cluster_layers()
        epsilon = self.sb_cluster_epsilon.value()

        base_name = f"Stützpunktreduktion_{epsilon:.2f}mm"

        # Stützpunktreduktion_2_012
        # Stützpunktreduktion_10.2_1
        # Stützpunktreduktion_100.4_11
        rex = re.compile("^Stützpunktreduktion_[\d\.m]+_(\d+)$")

        if len(cluster_layers) > 0:
            max_val = -1
            existing_names = []
            for _l in cluster_layers:
                layer_name = _l.name()
                existing_names.append(layer_name)
                try:
                    _cnt = int(rex.match(layer_name).groups()[0])
                    max_val = max(max_val, _cnt)
                except Exception:
                    pass
            proposed_name = f"{base_name}_{max_val + 1:02d}"
            if proposed_name not in existing_names:
                return proposed_name
            else:
                raise ValueError
        else:
            return f"{base_name}_01"

    def generate_densify_layer_name(self):
        densify_layers = self.get_densify_layers()
        epsilon = self.dsb_densify_dist.value()

        base_name = f"Stützpunktverdichtung_{epsilon:.2f}mm"

        rex = re.compile("^Stützpunktverdichtung_[\d\.m]+_(\d+)$")

        if len(densify_layers) > 0:
            max_val = -1
            existing_names = []
            for _l in densify_layers:
                layer_name = _l.name()
                existing_names.append(layer_name)
                try:
                    _cnt = int(rex.match(layer_name).groups()[0])
                    max_val = max(max_val, _cnt)
                except Exception:
                    pass
            proposed_name = f"{base_name}_{max_val + 1:02d}"
            if proposed_name not in existing_names:
                return proposed_name
            else:
                raise ValueError
        else:
            return f"{base_name}_01"

    def on_layers_added(self, layers: list[QgsVectorLayer]) -> None:
        """Update combobox items when plan layers are added."""
        self.populate_cluster_maplayer_combobox()
        self.populate_densify_maplayer_combobox()

    def on_layers_removed(self) -> None:
        """Update combobox items when plan layers are removed and clear web view if currently selected plan is among them."""
        self.populate_cluster_maplayer_combobox()
        self.populate_densify_maplayer_combobox()

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

    def reset_topology_result(self):
        self.topology_errors = {
            "bounds": [],
            "overlaps": [],
            "gaps": [],
            "gap_features": [],
            "invalids": [],
            "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"]
        self.table_invalids.repopulate(self.topology_errors["invalids"])
        self.table_invalids.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"]
        self.table_invalids.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")
            elif _tab_text.startswith("invalide Geometrien"):
                if (_nr := len(self.topology_errors["invalids"])) > 0:
                    tabwidget.setTabText(i, f"invalide Geometrien [{_nr}]")
                else:
                    tabwidget.setTabText(i, "invalide Geometrien")

    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)}"
            )

        self.activate_vtx_task_ui_elements()

        return

    def activate_vtx_task_ui_elements(self):
        # 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)

    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}")
        self.task_topo_check.deleteLater()
        self.task_topo_check = None

    def topp_vtx_task_terminated(self):
        if ex := self.task_topo_vtx.exception:
            logger.error(f"Background Task topo-vtx terminated with exception: {ex}")
        # self.task_topo_vtx.deleteLater()
        # self.task_topo_vtx = None
        # TODO: does not work, after canceling task ui stays greyed out
        self.activate_vtx_task_ui_elements()

    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)

        def _check_feature_validity(plan_id: str):
            """
            checks every feature of every xplan-layer if geometry is GEOS valid
            :param plan_id:
            :return:
            """
            layers = get_all_plan_layers(plan_id)
            invalids = []
            for layer in layers:
                for fid in layer.allFeatureIds():
                    geom = layer.getGeometry(fid)
                    errors = geom.validateGeometry(Qgis.GeometryValidationEngine.Geos)
                    if errors:
                        feature = layer.getFeature(fid)
                        feature_xplan_id = feature.attributeMap().get("id")
                        ret_val = {
                            "xplan_id": feature_xplan_id,
                            "qgis_fid": fid,
                            "layer_id": layer.id(),
                            "layer_name": layer.name(),
                            "errors": [],
                        }
                        for e in errors:
                            e: QgsGeometry.Error
                            ret_val["errors"].append(
                                {"what": e.what(), "where": e.where().asWkt()}
                            )
                        invalids.append(ret_val)

            return invalids

        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
            # TODO: improvement, check if layer/provider already has a spatial index and use this?
            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()
                    geom1_ge = QgsGeometry.createGeometryEngine(geom1.constGet())
                    geom1_ge.prepareGeometry()

                    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
                            if id_feature1 == feature2_fid:
                                continue
                            feature2 = overlap_feature_dict[layer2][feature2_fid]
                            geom2 = feature2.geometry()

                            # if geom1.overlaps(geom2):
                            if geom1_ge.overlaps(geom2.constGet()):
                                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

                                #     # DEBUG
                                #     from qgis.core import QgsJsonExporter
                                #     exporter = QgsJsonExporter()
                                #     exporter.setDestinationCrs(layer1.crs())
                                #     exporter.setPrecision(64)
                                #     _features = []
                                #     for _i, _p in enumerate(geom_intersect_collection.parts()):
                                #         _f = QgsFeature(_i)
                                #         _f.setGeometry(_p)
                                #         _features.append(_f)
                                #     geojson = exporter.exportFeatures(_features, indent=2)
                                #     with open('/tmp/test.geojson', 'w') as dst:
                                #         dst.write(geojson)

                                # check if this overlap already exists
                                _hash = sorted(
                                    [
                                        id(feature1) + id(layer1),
                                        id(feature2) + id(layer2),
                                    ]
                                )
                                # TODO: check if it's possible to check hash before doing test, could be faaster
                                if _hash not in _hashes and not geom_intersect.isNull():
                                    _hashes.append(_hash)
                                    topology_errors["overlaps"].append(
                                        (
                                            geom_intersect,
                                            feature1,
                                            layer1,
                                            feature2,
                                            layer2,
                                        )
                                    )

            if task:
                task.setProgress(75)

            # Check for gaps
            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
            ref_geom_engine = QgsGeometry.createGeometryEngine(
                reference_geom.constGet()
            )
            ref_geom_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

                    # do "contains" check first to avoid costly call to geom.difference if not needed,
                    # contains is true if geoms are equal or feat_geom is inside reference_geom
                    if ref_geom_engine.contains(feat_geom.constGet()):
                        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(60)

        # check all features for validity
        invalid_errors = _check_feature_validity(selected_qgis_layerid)

        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
        _topology_errors["invalids"] = invalid_errors

        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:
        # TODO: implement layer hierarchy for insert positions (dont move plan/section and insert vertice on subject)
        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, ignore_topo_filter=True)
            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")

    def search_vertex_clusters(self) -> None:
        def _get_prio_by_type(xplan_type: str) -> int:
            """
            define layer hierarchy priorities, smaller value -> more important
            # TODO: this may be moved to settings or config
            """
            match xplan_type:
                case "plan":
                    return 1
                case "section":
                    return 2
                case "subject":
                    return 3
                case _:
                    return 99

        app = QgsApplication.instance()
        app.setOverrideCursor(Qt.WaitCursor)

        try:
            import time

            alpha = time.time()
            try:
                plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                    "qgis_layerid", None
                )
            except AttributeError:
                app.restoreOverrideCursor()
                return
            if not plan_qgis_layer_id:
                app.restoreOverrideCursor()
                return

            layers = get_all_plan_layers(plan_qgis_layer_id)
            # check all layers have same crs:

            if not len(set([_l.crs().toWkt() for _l in layers])) == 1:
                raise RuntimeError("Plan layers differ in CRS")

            # create memory layer to hold vertices
            target_crs = layers[0].crs()
            layer_name = "all_vertices"
            geom_type = "Point?crs={}".format(target_crs.authid())
            mem_layer = QgsVectorLayer(geom_type, layer_name, "memory")

            if not mem_layer.isValid():
                raise RuntimeError("Failed to create the temporary layer")

            fields = QgsFields()
            fields.append(QgsField("layer_name", QMetaType.Type.QString))
            fields.append(QgsField("layer_id", QMetaType.Type.QString))
            fields.append(QgsField("layer_type", QMetaType.Type.QString))
            fields.append(QgsField("layer_prio", QMetaType.Type.Int))
            fields.append(QgsField("src_fid", QMetaType.Type.Int))
            fields.append(QgsField("src_xplan_id", QMetaType.Type.QString))
            fields.append(QgsField("src_vertex_idx", QMetaType.Type.Int))
            fields.append(QgsField("cluster_moved", QMetaType.Type.Int))
            fields.append(QgsField("extracted_fid", QMetaType.Type.Int))
            mem_layer.dataProvider().addAttributes(fields)
            mem_layer.updateFields()

            # get all vertices
            extracted_vertex_features = []
            for lyr in layers:
                if lyr.wkbType() == QgsWkbTypes.NoGeometry:
                    continue

                lyr_id = lyr.id()
                lyr_name = lyr.name()
                lyr_xplan_type = lyr.customProperties().value(
                    f"{PLUGIN_DIR_NAME}/layer_type"
                )
                changed_geoms = {}
                if lyr.isEditable() and lyr.editBuffer().changedGeometries():
                    changed_geoms = lyr.editBuffer().changedGeometries()
                for f in lyr.getFeatures():
                    fid = f.id()
                    xplan_fid = f.attributeMap().get("id", None)
                    geom = changed_geoms.get(fid, f.geometry())
                    if not geom or geom.isEmpty() or geom.isNull():
                        continue

                    for i, v in enumerate(geom.vertices()):
                        feat = QgsFeature()
                        feat.setFields(mem_layer.fields())
                        feat.setAttribute("layer_name", lyr_name)
                        feat.setAttribute("layer_id", lyr_id)
                        feat.setAttribute("layer_type", lyr_xplan_type)
                        feat.setAttribute(
                            "layer_prio", _get_prio_by_type(lyr_xplan_type)
                        )
                        feat.setAttribute("src_fid", fid)
                        feat.setAttribute("src_xplan_id", xplan_fid)
                        feat.setAttribute("src_vertex_idx", i)
                        feat.setAttribute("cluster_moved", 0)
                        feat.setAttribute(
                            "extracted_fid", len(extracted_vertex_features)
                        )
                        feat.setGeometry(QgsGeometry.fromPoint(v))
                        extracted_vertex_features.append(feat)

            mem_layer.dataProvider().addFeatures(extracted_vertex_features)
            mem_layer.updateExtents()
            mem_layer.dataProvider().createSpatialIndex()

            # do DBSCAN clustering
            dbscan_params = {
                "INPUT": mem_layer,
                "MIN_SIZE": 2,
                "EPS": self.sb_cluster_epsilon.value() / 1000.0,
                "DBSCAN*": True,
                "FIELD_NAME": "CLUSTER_ID",
                "SIZE_FIELD_NAME": "CLUSTER_SIZE",
                "OUTPUT": "TEMPORARY_OUTPUT",
            }
            proc_res = processing.run("native:dbscanclustering", dbscan_params)
            dbscan_layer: QgsVectorLayer = proc_res["OUTPUT"]

            # remove points not belonging to any cluster
            filter_expression = QgsExpression('"CLUSTER_ID" IS NULL')
            request = QgsFeatureRequest(filter_expression)
            request.setFlags(QgsFeatureRequest.NoGeometry)
            ids_to_delete = [feat.id() for feat in dbscan_layer.getFeatures(request)]
            dbscan_layer.dataProvider().deleteFeatures(ids_to_delete)

            # add aditional fields for storing movements
            vertex_movement_fields = [
                QgsField("merge_state", QMetaType.Type.Bool),
                QgsField("move_from_x", QMetaType.Type.Double),
                QgsField("move_from_y", QMetaType.Type.Double),
                QgsField("move_to_x", QMetaType.Type.Double),
                QgsField("move_to_y", QMetaType.Type.Double),
            ]
            dbscan_layer.startEditing()
            for _f in vertex_movement_fields:
                dbscan_layer.addAttribute(_f)
            dbscan_layer.commitChanges()
            attr_idx_cluster_id = dbscan_layer.fields().lookupField("CLUSTER_ID")
            attr_idx_cluster_moved = dbscan_layer.fields().lookupField("cluster_moved")
            attr_idx_merge_state = dbscan_layer.fields().lookupField("merge_state")
            attr_idx_move_from_x = dbscan_layer.fields().lookupField("move_from_x")
            attr_idx_move_from_y = dbscan_layer.fields().lookupField("move_from_y")
            attr_idx_move_to_x = dbscan_layer.fields().lookupField("move_to_x")
            attr_idx_move_to_y = dbscan_layer.fields().lookupField("move_to_y")

            # process each cluster
            dbscan_layer.dataProvider().createAttributeIndex(attr_idx_cluster_id)
            cluster_ids = dbscan_layer.uniqueValues(attr_idx_cluster_id)
            dbscan_layer.startEditing()

            # find out with cluster have all identical points and can be skipped
            dbscan_geoms = defaultdict(list)
            dbscan_features = defaultdict(list)
            request = QgsFeatureRequest()
            for feat in dbscan_layer.getFeatures(
                request
            ):  # Performance tip: always reduce getFeature calls!
                dbscan_geoms[feat[attr_idx_cluster_id]].append(
                    feat.geometry().asPoint()
                )
                dbscan_features[feat[attr_idx_cluster_id]].append(feat)

            clusters_to_skip = []
            for cid, points in dbscan_geoms.items():
                if len(set(points)) == 1:
                    clusters_to_skip.append(cid)
            clusters_to_process = set(cluster_ids).difference(clusters_to_skip)

            for cid in clusters_to_process:
                # set cluster target point by layer priority
                cluster_features = [
                    {
                        "attr": feat.attributeMap(),
                        "geom": feat.geometry().asPoint(),
                        "fid": feat.id(),
                    }
                    for feat in dbscan_features[cid]
                ]
                cluster_features = sorted(
                    cluster_features,
                    key=lambda x: (x["attr"]["layer_prio"], x["attr"]["src_fid"]),
                )

                new_pos_source = cluster_features[0]["geom"]
                new_pos = QgsPoint(x=new_pos_source.x(), y=new_pos_source.y())

                dbscan_layer.changeAttributeValue(
                    cluster_features[0]["fid"], attr_idx_cluster_moved, 2
                )
                for vtx_to_move in cluster_features[1:]:
                    try:
                        cur_vtx_point = (
                            extracted_vertex_features[
                                vtx_to_move["attr"]["extracted_fid"]
                            ]
                            .geometry()
                            .constGet()
                        )
                        dbscan_layer.changeAttributeValue(
                            vtx_to_move["fid"], attr_idx_cluster_moved, 1
                        )
                        dbscan_layer.changeAttributeValue(
                            vtx_to_move["fid"], attr_idx_merge_state, False
                        )
                        dbscan_layer.changeAttributeValue(
                            vtx_to_move["fid"], attr_idx_move_from_x, cur_vtx_point.x()
                        )
                        dbscan_layer.changeAttributeValue(
                            vtx_to_move["fid"], attr_idx_move_from_y, cur_vtx_point.y()
                        )
                        dbscan_layer.changeAttributeValue(
                            vtx_to_move["fid"], attr_idx_move_to_x, new_pos.x()
                        )
                        dbscan_layer.changeAttributeValue(
                            vtx_to_move["fid"], attr_idx_move_to_y, new_pos.y()
                        )

                    except Exception as e:
                        print(e)

            dbscan_layer.commitChanges()
            dbscan_layer.updateExtents()
            dbscan_layer.dataProvider().setSubsetString(
                '"cluster_moved" >= 1'
            )  # hide points that don't belong to any cluster
            dbscan_layer.setName(self.generate_cluster_layer_name())
            dbscan_layer.setCustomProperty(
                f"{PLUGIN_DIR_NAME}/xplan_vertex_cluster", True
            )

            # style and add result layer
            qml_path = Path(__file__).parent / "merged_vertices_style.qml"
            success, msg = dbscan_layer.loadNamedStyle(
                qml_path.as_posix(), loadFromLocalDb=False
            )
            self.insert_layer_at_top_of_plan_group(plan_qgis_layer_id, dbscan_layer)
            dbscan_layer.triggerRepaint()

            logger.debug(
                f"search vertex cluster: search_cluster duration: {time.time() - alpha:.2f}"
            )
        finally:
            app.restoreOverrideCursor()
        return

    @staticmethod
    def insert_layer_at_top_of_plan_group(plan_layer_id, layer_to_insert) -> None:
        project = QgsProject.instance()
        target_layer = project.mapLayer(plan_layer_id)
        if not target_layer:
            return

        root = project.layerTreeRoot()
        target_node = root.findLayer(target_layer.id())
        if not target_node:
            return
        parent_group = target_node.parent()
        if parent_group is None:
            return

        insert_layer_node = QgsLayerTreeLayer(layer_to_insert)
        parent_group.insertChildNode(0, insert_layer_node)
        QgsProject.instance().addMapLayer(layer_to_insert, False)

    def switch_styles(self, cb_state: bool) -> None:
        """
        switch styles for all layers in current plans group
        TODO: get styles names from settings
        :return:
        """
        try:
            plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                "qgis_layerid", None
            )
        except AttributeError:
            return
        if not plan_qgis_layer_id:
            return
        layers = get_all_plan_layers(plan_qgis_layer_id)
        for layer in layers:
            sm = layer.styleManager()
            styles = sm.styles()
            if not cb_state:
                if "Standard" in styles:
                    sm.setCurrentStyle("Standard")
                    continue
                if "default" in styles:
                    sm.setCurrentStyle("default")
                    continue
            else:
                if "Editieren" in styles:
                    sm.setCurrentStyle("Editieren")

    def merge_clusters(self, mode: str) -> None:
        app = QgsApplication.instance()
        app.setOverrideCursor(Qt.WaitCursor)

        try:
            cluster_layer: QgsVectorLayer = self.cb_cluster_layers.currentData()
            if not cluster_layer:
                app.restoreOverrideCursor()
                return

            # get fids according to selection-option radio buttons
            fids: List | set
            if self.rb_all.isChecked():
                fids = cluster_layer.allFeatureIds()
            elif self.rb_sel.isChecked():
                fids = cluster_layer.selectedFeatureIds()
            elif self.rb_inv.isChecked():
                _all = cluster_layer.allFeatureIds()
                _sel = cluster_layer.selectedFeatureIds()
                fids = set(_all).difference(set(_sel))
            else:
                app.restoreOverrideCursor()
                return

            # get cluster_ids based on selection fids
            attr_idx_cluster_id = cluster_layer.fields().lookupField("CLUSTER_ID")
            cluster_ids = set()
            for fid in fids:
                feature = cluster_layer.getFeature(fid)
                if feature.isValid():
                    cluster_ids.add(feature[attr_idx_cluster_id])

            # get all vertices for all selected clusters (maybe cluster is partially selected)
            id_str = ",".join([str(x) for x in cluster_ids])
            expression = QgsExpression(f'"CLUSTER_ID" IN ({id_str})')
            request = QgsFeatureRequest(expression)
            request.setFlags(QgsFeatureRequest.NoGeometry)

            attr_idx_move_to_x = cluster_layer.fields().lookupField("move_to_x")
            vertices = {}

            for f in cluster_layer.getFeatures(request):
                if f[
                    attr_idx_move_to_x
                ]:  # cheap check if feature has to be moved, cluster targets are NULL here
                    vertices[f.id()] = f.attributeMap()

            # make layers editable
            unique_layer_ids = set([v["layer_id"] for k, v in vertices.items()])
            layers: Dict[str, Tuple[QgsVectorLayer, QgsVectorLayerEditUtils]] = {}
            for l_id in unique_layer_ids:
                _l: QgsVectorLayer = QgsProject.instance().mapLayer(l_id)
                layers[l_id] = _l
                if not _l.isEditable():
                    _l.startEditing()
            iface.mapCanvas().setUpdatesEnabled(False)

            # movements by layer
            layer_movements = defaultdict(list)
            for k, v in vertices.items():
                layer_movements[v["layer_id"]].append(v)

            _cnt = 0
            for lid, movs in layer_movements.items():
                layer: QgsVectorLayer = layers[lid]
                edit_buffer = layer.editBuffer()
                layer.beginEditCommand("merging vertices")
                try:
                    for mov in movs:
                        if mode == "merge":
                            new_pos = QgsPoint(x=mov["move_to_x"], y=mov["move_to_y"])
                        elif mode == "undo":
                            new_pos = QgsPoint(
                                x=mov["move_from_x"], y=mov["move_from_y"]
                            )
                        else:
                            continue
                        feat = layer.getFeature(mov["src_fid"])
                        geom = feat.geometry()
                        geom.moveVertex(new_pos, mov["src_vertex_idx"])
                        edit_buffer.changeGeometry(mov["src_fid"], geom)
                        _cnt += 1
                finally:
                    layer.endEditCommand()

            self.iface.messageBar().pushMessage(
                f"Stützpunktreduktion: {_cnt} Feature(s) modifiziert"
            )
        finally:
            iface.mapCanvas().setUpdatesEnabled(True)
            app.restoreOverrideCursor()

    def segmentize_curves(self):
        import time

        a = time.time()
        # collect all layers for current plan group
        try:
            plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                "qgis_layerid", None
            )
        except AttributeError:
            return
        if not plan_qgis_layer_id:
            return

        # what layers and features to include
        process_all_layers = self.rb_curves_all_layer.isChecked()
        process_only_selected_features = bool(self.cb_curves_selection.checkState())

        # get layers that should be segmentized
        layers_to_process: List[QgsVectorLayer] = []
        if process_all_layers:
            layers_to_process = get_all_plan_layers(plan_qgis_layer_id)
        else:
            tree_view = iface.layerTreeView()
            layers_to_process = tree_view.selectedLayers()
        if not layers_to_process:
            return

        app = QgsApplication.instance()
        app.setOverrideCursor(Qt.WaitCursor)
        iface.mapCanvas().setUpdatesEnabled(False)
        _cnt = 0
        try:
            for layer in layers_to_process:
                if not layer.isEditable():
                    layer.startEditing()
                request = QgsFeatureRequest()
                request.setSubsetOfAttributes([])
                if process_only_selected_features:
                    selected_fids = layer.selectedFeatureIds()
                    request.setFilterFids(selected_fids)
                features_iterator: QgsFeatureIterator = layer.getFeatures(request)
                layer.beginEditCommand(f"segmentizing curves: {layer.id()}")
                try:
                    for feature in features_iterator:
                        geom = feature.geometry()
                        if not geom.constGet().hasCurvedSegments():
                            continue
                        segmentized_abstract_geom = geom.constGet().segmentize(
                            tolerance=self.dsb_curves_distance.value() / 1000.0,
                            toleranceType=QgsAbstractGeometry.SegmentationToleranceType.MaximumDifference,
                        )
                        new_geom = QgsGeometry(segmentized_abstract_geom)
                        layer.editBuffer().changeGeometry(feature.id(), new_geom)
                        _cnt += 1
                finally:
                    layer.endEditCommand()
        finally:
            iface.mapCanvas().setUpdatesEnabled(True)
            app.restoreOverrideCursor()

        if _cnt > 0:
            self.iface.messageBar().pushMessage(
                f"Kurven: {_cnt} Feature(s) segmentiert"
            )
        elif _cnt == 0:
            self.iface.messageBar().pushMessage("Kurven: keine Kurven vorhanden")
        logger.debug(f"segmentizing: {time.time() - a:0.4f}")

    def remove_duplicate_vertices(self):
        a = time.time()
        # collect all layers for current plan group
        try:
            plan_qgis_layer_id = self.comboBox_plans.currentData().get(
                "qgis_layerid", None
            )
        except AttributeError:
            return
        if not plan_qgis_layer_id:
            return

        # what layers and features to include
        process_all_layers = self.rb_dupl_all_layer.isChecked()
        process_only_selected_features = bool(self.cb_dupl_selection.checkState())

        # get layers that should be segmentized
        layers_to_process: List[QgsVectorLayer] = []
        if process_all_layers:
            layers_to_process = get_all_plan_layers(plan_qgis_layer_id)
        else:
            tree_view = iface.layerTreeView()
            layers_to_process = tree_view.selectedLayers()
        if not layers_to_process:
            return

        app = QgsApplication.instance()
        app.setOverrideCursor(Qt.WaitCursor)
        _cnt = 0
        try:
            for layer in layers_to_process:
                if not layer.isEditable():
                    layer.startEditing()
                request = QgsFeatureRequest()
                request.setSubsetOfAttributes([])
                if process_only_selected_features:
                    selected_fids = layer.selectedFeatureIds()
                    request.setFilterFids(selected_fids)
                features_iterator: QgsFeatureIterator = layer.getFeatures(request)
                layer.beginEditCommand("removing duplicate vertices")
                try:
                    for feature in features_iterator:
                        geom = feature.geometry()
                        check = geom.removeDuplicateNodes(
                            epsilon=self.dsb_dupl_distance.value() / 1000.0
                        )
                        if check:
                            layer.editBuffer().changeGeometry(feature.id(), geom)
                            _cnt += 1
                finally:
                    layer.endEditCommand()
        finally:
            app.restoreOverrideCursor()

        if _cnt > 0:
            self.iface.messageBar().pushMessage(
                f"Duplikate: duplizierte Stützpunkte wurden aus {_cnt} feature(s) entfernt"
            )
        elif _cnt == 0:
            self.iface.messageBar().pushMessage(
                "Duplikate: keine duplizierten Stützpunkte vorhanden"
            )
        logger.debug(
            f"removing duplicate vertices: {time.time() - a:0.4f}, features: {_cnt}"
        )
