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

import logging
import time
from collections import Counter, defaultdict
from dataclasses import dataclass
from typing import Optional, Set, cast

from qgis.core import (
    Qgis,
    QgsCoordinateTransform,
    QgsFeature,
    QgsGeometry,
    QgsLayerTreeGroup,
    QgsMapLayerStyle,
    QgsMessageLog,
    QgsPointXY,
    QgsProcessing,
    QgsProcessingAlgorithm,
    QgsProcessingContext,
    QgsProcessingException,
    QgsProcessingFeedback,
    QgsProcessingOutputString,
    QgsProcessingParameterBoolean,
    QgsProcessingParameterEnum,
    QgsProcessingParameterVectorLayer,
    QgsProject,
    QgsVectorLayer,
    QgsVectorLayerJoinInfo,
    QgsWkbTypes,
)
from qgis.PyQt import QtCore, sip
from qgis.PyQt.QtCore import QCoreApplication, QThread

from xmas_plugin.util.geom import (
    ensure_ccw_geom,
    evaluate_make_valid_on_layers,
)
from xmas_plugin.util.metadata import PLUGIN_DIR_NAME, PLUGIN_NAME
from xmas_plugin.util.topo_vertices import (
    TOL,
    build_vertex_features,
    find_missing_vertices,
    insert_vertices,
)

logger = logging.getLogger(PLUGIN_DIR_NAME)


# Success signal
class SplitFinished(QtCore.QObject):
    split_done = QtCore.pyqtSignal(object)


SPLIT_BUS = SplitFinished()

# Core plugin constants used to define custom property keys in layers.
ORIG_ID_FIELD = "id"
PLUGIN_KEY = PLUGIN_DIR_NAME  # Base key used for namespacing plugin properties
APP_KEY = f"{PLUGIN_KEY}/appschema"
VER_KEY = f"{PLUGIN_KEY}/appschema_version"
MODEL_KEY = f"{PLUGIN_KEY}/layer_type"  # Expected layer model key
PLAN_KEY = f"{PLUGIN_KEY}/plan_id"  # Plan identifier key
BEREICH_KEY = f"{PLUGIN_KEY}/bereich_id"  # Area/sector identifier key


@dataclass(frozen=True)
class AppSchema:
    type: str
    version: str


class SplitPlanByBoundaryAlgorithm(QgsProcessingAlgorithm):
    """
    Implements an algorithm to split a given plan into "inner" and "outer" layers
    based on a set of boundary geometries. The split results are accumulated and sent
    using a formatted payload to downstream processes.

    Key Features:
    - Handles CRS-aware boundary geometries for robust layer splitting.
    - Supports JSON serialization of geometries for integration with external systems.
    - Validates that split geometries intersect source layers before proceeding.

    Major Methods:
    - `processAlgorithm`: Core execution logic triggering the splitting operation.
    - `_new_feature_lists`: Initialize accumulators for collecting split geometry features.
    - `_acc_has_data`: Validation utility to check if accumulators hold any features.
    """

    # ---------------- Parameters / Metadata ----------------
    PLAN_GROUP = "PLAN_GROUP"
    CUT_LAYER = "CUT_LAYER"
    USE_SELECTION = "USE_SELECTION"

    # Improve crash-stability by making sure algorithm runs on the main GUI-thread, and methods like addGroup(...) etc. don't run concurrently with the layer tree
    def flags(self):
        return (
            QgsProcessingAlgorithm.FlagNoThreading
            | QgsProcessingAlgorithm.FlagRequiresProject
        )

    def initAlgorithm(self, config=None) -> None:
        # Build a list of top-level groups for convenience
        root = QgsProject.instance().layerTreeRoot()
        group_names = [g.name() for g in root.children() if g.nodeType() == g.NodeGroup]

        # Keep for later lookup
        self._group_names = ["— Plan-Gruppe wählen —"] + group_names

        self.addParameter(
            QgsProcessingParameterEnum(
                self.PLAN_GROUP,
                "Plan-Gruppe (erforderlich)",
                self._group_names,  # options
                False,  # allowMultiple
                None,  # defaultValue (omit -> None; UI shows index 0)
                False,  # optional=False -> required
            )
        )
        self.addParameter(
            QgsProcessingParameterVectorLayer(
                self.CUT_LAYER,
                "Schnitt-Layer (Polygon)",
                [QgsProcessing.TypeVectorPolygon],
            )
        )
        self.addParameter(
            QgsProcessingParameterBoolean(
                self.USE_SELECTION, "Nur Auswahl verwenden", defaultValue=False
            )
        )
        self.addOutput(QgsProcessingOutputString("SUMMARY", "Ergebnis-Zusammenfassung"))

    def name(self) -> str:
        return "split_plan_by_boundary"

    def displayName(self) -> str:
        return "Plan nach Schnitt-Layer aufteilen (Innen/Außen)"

    def group(self) -> str:
        return "Plan"

    def groupId(self) -> str:
        return "plan"

    def shortHelpString(self) -> str:
        return (
            "Teilt den Plan in zwei Ausgaben (Innen/Außen) anhand eines Polygon-Schnitt-Layers. "
            "Bei aktiver Option werden nur ausgewählte Features des Schnitt-Layers verwendet."
        )

    def createInstance(self) -> "SplitPlanByBoundaryAlgorithm":
        return SplitPlanByBoundaryAlgorithm()

    # ---------------- Processing entry ----------------

    def processAlgorithm(
        self, params, context: QgsProcessingContext, feedback: QgsProcessingFeedback
    ):
        """
        Main execution entry point for the algorithm.

        Coordinates the splitting of plan layers into 'inner' and 'outer' parts
        and prepares payload for downstream operations.

        Args:
            parameters (dict): Input parameters passed to the algorithm.
            context (QgsProcessingContext): Context containing project-related information.
            feedback (QgsProcessingFeedback): Feedback object for reporting progress/status.

        Returns:
            dict: Results showing success and payload details.
        """
        # ---- Crash-safety harness ----------------------------------------------------
        app = QCoreApplication.instance()
        on_gui = QThread.currentThread() == app.thread()
        if not on_gui:
            logger.warning("[Split] Not on GUI thread (unexpected).")

        self._cancelled = False
        proj = QgsProject.instance()

        def _abort(reason: str):
            if not self._cancelled:
                feedback.reportError(reason)
                self._cancelled = True
                feedback.cancel()

        def _on_layers_will_be_removed(ids):
            _abort(
                "Layers are being removed; aborting split to avoid invalid pointers."
            )

        def _on_project_clearing():
            _abort("Project is being cleared/closed; aborting split.")

        proj.layersWillBeRemoved.connect(_on_layers_will_be_removed)
        proj.cleared.connect(_on_project_clearing)
        # -----------------------------------------------------------------------

        try:
            # --- Resolve required plan group (no auto-detect fallback) ---
            chosen_idx = self.parameterAsEnum(params, self.PLAN_GROUP, context)
            if chosen_idx in (None, 0):
                raise QgsProcessingException(
                    "Bitte wählen Sie die Plan-Gruppe, die aufgeteilt werden soll."
                )

            group_name = self._group_names[chosen_idx]
            plan_group = self._find_group_by_name(group_name)
            if plan_group is None:
                raise QgsProcessingException(
                    f"Ausgewählte Plan-Gruppe '{group_name}' wurde im Projektbaum nicht gefunden."
                )

            # --- Cut layer ---
            cut_layer: QgsVectorLayer = self.parameterAsVectorLayer(
                params, self.CUT_LAYER, context
            )

            if not cut_layer or cut_layer.type() != QgsVectorLayer.VectorLayer:
                raise QgsProcessingException(
                    "Schnitt-Layer fehlt oder ist kein Vektorlayer."
                )
            if (
                QgsWkbTypes.geometryType(cut_layer.wkbType())
                != QgsWkbTypes.PolygonGeometry
            ):
                raise QgsProcessingException(
                    "Schnitt-Layer muss ein Polygon-Layer sein."
                )

            # --- Build boundary features list ---
            all_feats = list(cut_layer.getFeatures())
            sel_feats = list(cut_layer.getSelectedFeatures())
            use_sel: bool = self.parameterAsBoolean(params, self.USE_SELECTION, context)

            # Effective set of boundary polygons used for splitting
            feats = sel_feats if use_sel else all_feats

            # Only treat boundary features as “selected” when the checkbox is ON
            selected_fids: Set[int] = {f.id() for f in sel_feats} if use_sel else set()

            # Guard: empty input
            if not feats:
                total = len(all_feats)
                if use_sel and total > 0:
                    # User chose "selection only" but none are selected
                    raise QgsProcessingException(
                        f"Im Schnitt-Layer '{cut_layer.name()}' sind 0 ausgewählte Polygone, "
                        f"aber {total} Polygone insgesamt vorhanden.\n"
                        f"• Auswahl aufheben oder 'Nur Auswahl verwenden' deaktivieren.\n"
                        f"• Layer-CRS: {cut_layer.crs().authid()}."
                    )
                # Truly no polygons at all
                raise QgsProcessingException(
                    f"Keine Polygone im Schnitt-Layer '{cut_layer.name()}'. "
                    f"Layer-CRS: {cut_layer.crs().authid()}."
                )
            geoms = [
                QgsGeometry(f.geometry())
                for f in feats
                if f.geometry() and not f.geometry().isEmpty()
            ]
            if not geoms:
                raise QgsProcessingException(
                    f"Alle (ausgewählten) Geometrien im Schnitt-Layer '{cut_layer.name()}' sind leer/ungültig."
                )

            boundary_src = QgsGeometry.unaryUnion(geoms)  # in cut_layer.crs()

            # Quick eligibility check: require at least one layer whose custom property *contains* "XPlanPlugin"
            if not self._group_has_xplanplugin_fast(plan_group):
                raise QgsProcessingException(
                    f"Die ausgewählte Plan-Gruppe enthält keinen Layer mit einer benutzerdefinierten "
                    f"Eigenschaft, die '{PLUGIN_KEY}' enthält."
                )
            # --- Early overlap diagnostic (helps spot CRS/extent mismatches) ---
            proj_crs = QgsProject.instance().crs()
            plan_ext_proj = self._plan_group_extent_in(plan_group, proj_crs)
            if plan_ext_proj and not plan_ext_proj.isEmpty():
                mask_ext_proj = self._transform_extent(
                    boundary_src.boundingBox(), cut_layer.crs(), proj_crs
                )
                if not mask_ext_proj.intersects(plan_ext_proj):
                    raise QgsProcessingException(
                        "Die Schnitt-Geometrie überlappt den gewählten Plan nicht.\n"
                        f"• Projekt-CRS: {proj_crs.authid()}\n"
                        f"• Plan-Ausdehnung: {plan_ext_proj.toString()}\n"
                        f"• Schnitt-Layer '{cut_layer.name()}' CRS: {cut_layer.crs().authid()}\n"
                        f"• Schnitt-Ausdehnung (in Projekt-CRS): {mask_ext_proj.toString()}\n\n"
                        "Tipps:\n"
                        "  – Prüfen Sie, ob der Schnitt-Layer im richtigen Koordinatensystem vorliegt (Layer-CRS vs. Projekt/Plan-CRS).\n"
                        "  – Reprojizieren Sie den Schnitt-Layer (Vektor → Geometrie → Reprojizieren) oder zeichnen Sie die Maske im Plan-CRS.\n"
                        "  – Vergewissern Sie sich, dass Sie die richtige Plan-Gruppe gewählt haben."
                    )

            feedback.pushInfo(f"[Split] Plan-Gruppe: {plan_group.name()}")
            feedback.pushInfo(
                f"[Split] Schnitt-Layer: {cut_layer.name()} CRS={cut_layer.crs().authid()}"
            )

            # Create output groups at root
            root = QgsProject.instance().layerTreeRoot()
            src_name = plan_group.name()

            # Create top-level destination groups (fresh objects, short-lived handles)
            grp_innen = root.addGroup(f"{src_name} – Innen")
            grp_aussen = root.addGroup(f"{src_name} – Außen")
            grp_innen.setExpanded(False)
            grp_aussen.setExpanded(False)

            # Snapshot the plan structure to avoid holding live group refs
            plan_snapshot = self._snapshot_group(plan_group)
            plan_id = self._find_plan_id(plan_group)

            # Initialize payload accumulators, handle splitting, and validate results.
            feature_lists = self._new_feature_lists()  # Ensures eventual payload has valid accumulated data; raises exception otherwise.

            def materialize_groups(
                parent_dst: QgsLayerTreeGroup, snap: dict
            ) -> QgsLayerTreeGroup:
                """
                Ensure a subgroup exists under parent_dst matching snap['name'],
                and return it. Short-lived handle; re-derive as needed.
                """
                sub = parent_dst.addGroup(snap["name"])
                sub.setExpanded(False)
                return sub

            def process_snapshot(
                snapshot: dict,
                dst_in: QgsLayerTreeGroup,
                dst_out: QgsLayerTreeGroup,
                created_layers: dict[str, list[QgsVectorLayer]],
            ):
                # Process layers by ID (resolve live layer right before use)
                project = QgsProject.instance()
                for snapshot_layer_id in snapshot["layers"]:
                    if feedback.isCanceled() or self._cancelled:
                        return
                    snapshot_layer = project.mapLayer(snapshot_layer_id)
                    if (
                        not self._is_alive(snapshot_layer)
                        or snapshot_layer.type() != QgsVectorLayer.VectorLayer
                        or not snapshot_layer.customProperty(
                            f"{PLUGIN_DIR_NAME}/appschema"
                        )
                    ):
                        continue

                    # Split the resolved layer
                    self._split_layer_to_groups(
                        src_layer=cast(QgsVectorLayer, snapshot_layer),
                        boundary_geom_src=boundary_src,
                        boundary_src_crs=cut_layer.crs(),
                        boundary_src_layer=cut_layer,
                        selected_boundary_fids=selected_fids,
                        grp_innen=dst_in,
                        grp_aussen=dst_out,
                        feedback=feedback,
                        feature_lists=feature_lists,
                        created_layers=created_layers,
                    )

                # Recurse into subgroups (create fresh destination groups by name)
                for sub_snapshot in snapshot["groups"]:
                    sub_in = materialize_groups(dst_in, sub_snapshot)
                    sub_out = materialize_groups(dst_out, sub_snapshot)
                    process_snapshot(sub_snapshot, sub_in, sub_out, created_layers)

            created_layers = {"inner": [], "outer": []}
            process_snapshot(plan_snapshot, grp_innen, grp_aussen, created_layers)
            feedback.pushInfo(f"Plan '{src_name}' in Innen/Außen aufgeteilt.")

            layers_inner = created_layers["inner"]
            layers_outer = created_layers["outer"]

            # TODO: refactor all "topological fixing" into a new method
            layers_to_fix = layers_inner + layers_outer
            if layers_to_fix:
                # Empirically evaluate if .makeValid() does anything unwanted or not
                mv_summary = evaluate_make_valid_on_layers(
                    layers_to_fix, tol=TOL, log_limit=10
                )
                logger.info(
                    "[makeValid eval] changed_topo=%d changed_type=%d changed_orientation=%d became_valid=%d became_invalid=%d",
                    mv_summary["changed_topo"],
                    mv_summary["changed_type"],
                    mv_summary["changed_orientation"],
                    mv_summary["became_valid"],
                    mv_summary["became_invalid"],
                )
                logger.info(
                    "[makeValid eval] worst symdiff: %s", mv_summary["worst_symdiff"]
                )
                logger.info(
                    "[makeValid eval] worst hausdorff: %s",
                    mv_summary["worst_hausdorff"],
                )

                for layer_name, fid, headline, details in mv_summary["examples"][:20]:
                    logger.warning(
                        "[makeValid eval] %s fid=%s %s | %s",
                        layer_name,
                        fid,
                        headline,
                        details,
                    )

                t0 = time.perf_counter()
                vertex_build_result = build_vertex_features(layers_to_fix)
                logger.info(
                    "[Topo-Fix] build_vertex_features before: %.2fs",
                    time.perf_counter() - t0,
                )

                t0 = time.perf_counter()
                missing_before = find_missing_vertices(
                    layers_to_fix, vertex_build_result
                )
                logger.info(
                    "[Topo-Fix] find_missing_vertices before: %.2fs",
                    time.perf_counter() - t0,
                )
                logger.info(
                    "[Topo-Fix] missing vertex insertions to apply: %d",
                    sum(len(item.insertions) for item in missing_before),
                )

                project = QgsProject.instance()
                per_layer = Counter()
                top_feats = []

                for item in missing_before:
                    layer = project.mapLayer(item.layer_id)
                    layer_name = (
                        layer.name()
                        if isinstance(layer, QgsVectorLayer)
                        else item.layer_id
                    )
                    n = len(item.insertions)
                    per_layer[layer_name] += n
                    top_feats.append((n, layer_name, item.fid))

                logger.info("[Topo-Fix] per-layer: %s", per_layer.most_common())
                logger.info(
                    "[Topo-Fix] top features: %s", sorted(top_feats, reverse=True)[:10]
                )

                errors_before = {}
                project = QgsProject.instance()

                for item in missing_before:
                    layer = project.mapLayer(item.layer_id)
                    if not isinstance(layer, QgsVectorLayer):
                        continue
                    g = layer.getFeature(item.fid).geometry()
                    if not g:
                        continue
                    errs = g.validateGeometry()
                    if errs:
                        errors_before[(item.layer_id, item.fid)] = errs

                ok, fail, _layers_changed = insert_vertices(missing_before)

                errors_after = {}
                for item in missing_before:
                    layer = project.mapLayer(item.layer_id)
                    if not isinstance(layer, QgsVectorLayer):
                        continue
                    g = layer.getFeature(item.fid).geometry()
                    if not g:
                        continue
                    errs = g.validateGeometry()
                    if errs:
                        errors_after[(item.layer_id, item.fid)] = errs

                newly_invalid = set(errors_after) - set(errors_before)
                still_invalid = set(errors_after) & set(errors_before)

                logger.info(
                    "[Topo-Fix] invalid before=%d after=%d newly_invalid=%d still_invalid=%d",
                    len(errors_before),
                    len(errors_after),
                    len(newly_invalid),
                    len(still_invalid),
                )

                t0 = time.perf_counter()

                def _fmt_err(e):
                    # QGIS error objects vary by version
                    try:
                        return str(e.what())
                    except Exception:
                        return str(e)

                for key in list(newly_invalid)[:20]:
                    layer_id, fid = key
                    layer = QgsProject.instance().mapLayer(layer_id)
                    logger.warning(
                        "[Topo-Fix] NEW invalid: layer=%s fid=%s errs=%s",
                        layer.name() if layer else layer_id,
                        fid,
                        [_fmt_err(e) for e in errors_after[key]][:3],
                    )
                logger.info("Invalid loop took: %.2fs", time.perf_counter() - t0)

                t0 = time.perf_counter()
                vertex_build_result2 = build_vertex_features(layers_to_fix)
                missing_after = find_missing_vertices(
                    layers_to_fix, vertex_build_result2
                )
                logger.info(
                    "[Topo-Fix] find_missing after: %.2fs", time.perf_counter() - t0
                )
                logger.info(
                    "[Topo-Fix] missing vertex insertions after: %d",
                    sum(len(item.insertions) for item in missing_after),
                )
                for item in missing_after:
                    layer = QgsProject.instance().mapLayer(item.layer_id)
                    layer_name = (
                        layer.name()
                        if isinstance(layer, QgsVectorLayer)
                        else item.layer_id
                    )
                    logger.warning(
                        "[Topo-Fix] STILL missing: layer=%s fid=%s count=%d sample=%s",
                        layer_name,
                        item.fid,
                        len(item.insertions),
                        [
                            (i.after_vertex, round(i.t, 6), (i.pt.x(), i.pt.y()))
                            for i in item.insertions[:3]
                        ],
                    )

            s_in = patch_feature_list_from_layers(
                layers_inner,
                feature_lists["inner"],
                role_label="inner",
                debug_ids=DEBUG_UUIDS,
            )
            s_out = patch_feature_list_from_layers(
                layers_outer,
                feature_lists["outer"],
                role_label="outer",
                debug_ids=DEBUG_UUIDS,
            )

            logger.info("[Split] payload patch summaries: %s | %s", s_in, s_out)

            # TODO: ignore snapping for now
            # self.post_snap_created_layers(layers_to_fix, boundary_src, cut_layer.crs(), TOL, feedback)
            # self.post_snap_to_cut_boundary(layers_to_fix, boundary_src, cut_layer.crs(), TOL, feedback)

            if not (
                self._acc_has_data(feature_lists["inner"])
                or self._acc_has_data(feature_lists["outer"])
            ):
                raise QgsProcessingException(
                    "Schnitt-Geometrie überlappt keine Layer des Plans. Vorgang abgebrochen; nichts zu senden."
                )

            if self._cancelled:
                raise QgsProcessingException(
                    "Vorgang abgebrochen (Projekt/Layers wurden geändert)."
                )

            valid, error = self._validate_result(feature_lists)
            if not valid:
                raise QgsProcessingException(error)

            summary_lines = [
                f"Plan '{src_name}' erfolgreich aufgeteilt.",
                f"Neue Gruppen: '{grp_innen.name()}' und '{grp_aussen.name()}'.",
            ]
            summary = " ".join(summary_lines)

            # Show success
            feedback.pushInfo("[Split] ✅ " + summary)  # Processing feedback panel
            feedback.setProgress(100)  # make sure the bar hits 100%
            QgsMessageLog.logMessage(
                summary, PLUGIN_NAME, Qgis.Info
            )  # Log Messages panel

            schema = self._extract_appschema_firsthit_from_group(plan_group)

            logger.info(
                "[Split] INNER ids (sample): %s",
                [it["old_object_id"] for it in feature_lists["inner"][:20]],
            )
            logger.info(
                "[Split] OUTER ids (sample): %s",
                [it["old_object_id"] for it in feature_lists["outer"][:20]],
            )

            payload = {
                "schema_version": "split-import-v1-min",
                "src_plan_group": plan_group.name(),  # Optional
                "src_plan_id": plan_id,
                "crs": proj_crs.authid(),  # Optional (the server currently only needs srid, stored in the features)
                "appschema": {"type": schema.type, "version": schema.version},
                "inner": {
                    "group_name": grp_innen.name(),
                    "items": feature_lists["inner"],
                },
                "outer": {
                    "group_name": grp_aussen.name(),
                    "items": feature_lists["outer"],
                },
            }

            # Emit success signal
            SPLIT_BUS.split_done.emit(payload)

            def _count_bereich(items: list[dict]) -> int:
                # your FeaturePatch dicts only have old_object_id + wkt;
                # we can only check via src_by_id (plan_collection) if you have it here,
                # otherwise just log all ids and inspect quickly.
                return sum(
                    1 for it in items if it["old_object_id"]
                )  # crude; better: log IDs

            logger.warning("[Split] INNER item count=%d", len(feature_lists["inner"]))
            logger.warning("[Split] OUTER item count=%d", len(feature_lists["outer"]))
            logger.warning(
                "[Split] OUTER ids (all): %s",
                [it["old_object_id"] for it in feature_lists["outer"]],
            )

            # Return a declared output so the dialog shows it in the results area
            return {"SUMMARY": summary}
        finally:
            # Always disconnect signals to avoid leaks
            try:
                proj.layersWillBeRemoved.disconnect(_on_layers_will_be_removed)
            except Exception:
                pass
            try:
                proj.cleared.disconnect(_on_project_clearing)
            except Exception:
                pass

    # ---------------- Helpers: plan detection ----------------

    def _find_group_by_name(self, name: str) -> Optional[QgsLayerTreeGroup]:
        root = QgsProject.instance().layerTreeRoot()
        for child in root.children():
            if child.nodeType() == child.NodeGroup and child.name() == name:
                return child
        return None

    def _find_plan_group_by_geometry(
        self, split_geom: "QgsGeometry"
    ) -> Optional[QgsLayerTreeGroup]:
        """Return the plan group whose polygon 'Geltungsbereich' intersects the split geometry."""
        root = QgsProject.instance().layerTreeRoot()
        for group in root.children():
            if group.nodeType() != group.NodeGroup:
                continue
            for child in group.children():
                if child.nodeType() != child.NodeLayer:
                    continue
                try:
                    lyr = child.layer()
                except Exception:
                    lyr = None
                if not self._is_alive(lyr) or lyr.type() != lyr.VectorLayer:
                    continue
                if (
                    QgsWkbTypes.geometryType(lyr.wkbType())
                    != QgsWkbTypes.PolygonGeometry
                ):
                    continue
                for feat in lyr.getFeatures():
                    try:
                        if feat.geometry() and feat.geometry().intersects(split_geom):
                            logger.info("[Split] Plan group detected: %s", group.name())
                            return group
                    except Exception:
                        continue
        return None

    # ---------------- Helpers: layer split ----------------

    def _split_layer_to_groups(
        self,
        src_layer: QgsVectorLayer,
        boundary_geom_src: QgsGeometry,
        boundary_src_crs,  # CRS of boundary_geom_src
        boundary_src_layer: QgsVectorLayer,
        selected_boundary_fids: Set[int],
        grp_innen: QgsLayerTreeGroup,
        grp_aussen: QgsLayerTreeGroup,
        feedback: QgsProcessingFeedback,
        feature_lists: list,
        created_layers: dict[str, list[QgsVectorLayer]],
    ) -> None:
        """
        Split the source layer into 'inner' and 'outer' layers based on the boundary geometry.

        Args:
            src_layer (QgsVectorLayer): Layer to be split.
            boundary_geom_src (QgsGeometry): Boundary geometry used for splitting.
            boundary_src_crs (CRS): CRS of the boundary geometry.
            boundary_src_layer (QgsVectorLayer): Layer containing the boundary features.
            selected_boundary_fids (Set[int]): IDs of selected boundary features for splitting.
            grp_innen (QgsLayerTreeGroup): Group where 'inner' split layers are added.
            grp_aussen (QgsLayerTreeGroup): Group where 'outer' split layers are added.
            feedback (QgsProcessingFeedback): Feedback object for reporting progress.
            feature_lists (list): Accumulator for collecting split features.

        Important Notes:
        - Layers whose geometries do not intersect the boundary will not be split.
        - Features are serialized into JSON for integration with downstream systems.
        """
        # Bail out early for invalid or disappearing layers (runtime safety)
        if not self._is_alive(src_layer):
            return
        # Safety: re-resolve by ID (layer might have been replaced)
        project = QgsProject.instance()
        live = project.mapLayer(src_layer.id()) if hasattr(src_layer, "id") else None
        if not self._is_alive(live):
            return

        src_layer = cast(
            QgsVectorLayer, live
        )  # Ensure we work with updated layer reference
        # Prepare feature lists for POST accumulation (per role, per feature)
        inner_list = feature_lists["inner"]
        outer_list = feature_lists["outer"]

        layer_name = src_layer.name()
        logger.info("[Split] Splitting layer '%s'.", layer_name)

        # Transform boundary into this layer's CRS
        boundary_in_layer = self._ensure_geom_crs(
            boundary_geom_src, boundary_src_crs, src_layer.crs()
        )

        boundary_in_layer = ensure_ccw_geom(boundary_in_layer)

        # Validate / repair if needed
        if not boundary_in_layer or boundary_in_layer.isEmpty():
            raise QgsProcessingException(
                f"Masken-Geometrie leer nach Transform ({boundary_src_crs.authid()} → {src_layer.crs().authid()})."
            )
        try:
            # .isGeosValid() is medium expensive, only run when necessary
            if not boundary_in_layer.isGeosValid():
                # .makeValid() is expensive and can change geometries in unforeseen ways; use sparingly
                boundary_in_layer = boundary_in_layer.makeValid()
        except Exception:
            # Don't crash on validator hiccups
            pass

        # If the cutting shape doesn't overlap a layer at all, that layer must belong to outer split plan.
        mask_bbox = boundary_in_layer.boundingBox()
        layer_ext = src_layer.extent()
        if not mask_bbox.intersects(layer_ext):
            feedback.pushInfo(
                f"[Split] Maske schneidet Layer '{layer_name}' nicht -> kopiere unverändert nach Außen."
            )
            # Geometry-less features still belong to both plans
            self._copy_geomless_features(
                src_layer=src_layer,
                dst_group=grp_innen,
                created_layers=created_layers,
                role="inner",
                feature_list=inner_list,
            )
            self._copy_all_features(
                src_layer=src_layer,
                dst_group=grp_aussen,
                created_layers=created_layers,
                role="outer",
                feature_list=outer_list,
            )
            return

        mem_in = self._empty_mem_clone(src_layer, layer_name)
        mem_out = self._empty_mem_clone(src_layer, layer_name)
        prov_in, prov_out = mem_in.dataProvider(), mem_out.dataProvider()
        feats_in, feats_out = [], []

        geom_type = QgsWkbTypes.geometryType(src_layer.wkbType())

        # Extra safety
        if not self._is_alive(grp_innen) or not self._is_alive(grp_aussen):
            return

        for f in src_layer.getFeatures():
            if feedback.isCanceled() or self._cancelled:
                break
            g = f.geometry()
            if not g or g.isEmpty():
                # Geometry-less objects belong to both plans
                logger.debug(
                    "[Split] Geom-less feature -> both sides: layer='%s' oid=%s",
                    layer_name,
                    self._old_object_id(f),
                )
                nf_in = QgsFeature(f)
                nf_out = QgsFeature(f)
                feats_in.append(nf_in)
                feats_out.append(nf_out)
                inner_list.append(
                    {
                        "old_object_id": self._old_object_id(f),
                        "wkt": None,
                        "has_z": False,
                        "geom_type": None,
                        "expected_geom_type": None,
                    }
                )
                outer_list.append(
                    {
                        "old_object_id": self._old_object_id(f),
                        "wkt": None,
                        "has_z": False,
                        "geom_type": None,
                        "expected_geom_type": None,
                    }
                )
                continue
            g = ensure_ccw_geom(g)

            # Robust split via intersection/difference
            try:
                expected_type = QgsWkbTypes.geometryType(src_layer.wkbType())

                if QgsWkbTypes.hasZ(boundary_in_layer.wkbType()):
                    boundary_in_layer = QgsGeometry(
                        boundary_in_layer.constGet().clone()
                    )
                    boundary_in_layer.get().dropZValue()

                g_in = boundary_in_layer.intersection(g)
                g_out = g.difference(boundary_in_layer)

                # Cast to right geometry type
                if g_in and not g_in.isEmpty():
                    if QgsWkbTypes.geometryType(g_in.wkbType()) != expected_type:
                        logger.warning(
                            "[Split] Type mismatch INNER – layer='%s' fid=%s expected=%s got=%s",
                            layer_name,
                            f.id(),
                            QgsWkbTypes.displayString(src_layer.wkbType()),
                            QgsWkbTypes.displayString(g_in.wkbType()),
                        )
                        g_in = self._extract_matching_parts(
                            g_in, expected_type, layer_name, f.id()
                        )

                if g_out and not g_out.isEmpty():
                    if QgsWkbTypes.geometryType(g_out.wkbType()) != expected_type:
                        logger.warning(
                            "[Split] Type mismatch OUTER – layer='%s' fid=%s expected=%s got=%s",
                            layer_name,
                            f.id(),
                            QgsWkbTypes.displayString(src_layer.wkbType()),
                            QgsWkbTypes.displayString(g_out.wkbType()),
                        )
                        g_out = self._extract_matching_parts(g_out, expected_type)

                # TODO: ignore for now
                # geom_params = QgsGeometryParameters()
                # geom_params.setGridSize(0.1e-5)  # set GEOS tolerance/grid to 1/1000mm
                # g_in = boundary_in_layer.intersection(g, geom_params)
                # g_out = g.difference(boundary_in_layer, geom_params)

                # TODO: only for debug
                # if is_debug_feature(f):
                #     logger.debug("TOL=%g  layer_crs=%s", TOL, src_layer.crs().authid())
                #
                #     # BEFORE snap
                #     leak_before = g_out.intersection(boundary_in_layer)
                #     logger.debug(
                #         "LEAK_BEFORE_SNAP: empty=%s len=%.12g area=%.12g wkb=%s",
                #         leak_before.isEmpty(),
                #         leak_before.length(),
                #         leak_before.area(),
                #         QgsWkbTypes.displayString(leak_before.wkbType()),
                #     )

                # g_in = snap_to_boundary(g_in, boundary_in_layer, TOL)
                # g_out = snap_to_boundary(g_out, boundary_in_layer, TOL)
                #
                # if is_debug_feature(f):
                #     # AFTER snap
                #     leak_after = g_out.intersection(boundary_in_layer)
                #     logger.debug(
                #         "LEAK_AFTER_SNAP: empty=%s len=%.12g area=%.12g wkb=%s",
                #         leak_after.isEmpty(),
                #         leak_after.length(),
                #         leak_after.area(),
                #         QgsWkbTypes.displayString(leak_after.wkbType()),
                #     )

                # if is_debug_feature(f):
                #     def leak_stats(tag: str, candidate_out: QgsGeometry):
                #         leak = candidate_out.intersection(boundary_in_layer)
                #         logger.debug(
                #             "%s: empty=%s wkb=%s len=%.12g area=%.12g",
                #             tag,
                #             leak.isEmpty(),
                #             QgsWkbTypes.displayString(leak.wkbType()),
                #             leak.length(),
                #             leak.area(),
                #         )
                #
                #     def inside_outside_stats(tag: str, candidate_in: QgsGeometry):
                #         outside = candidate_in.difference(boundary_in_layer)
                #         logger.debug(
                #             "%s outside(boundary): empty=%s wkb=%s len=%.12g area=%.12g",
                #             tag,
                #             outside.isEmpty(),
                #             QgsWkbTypes.displayString(outside.wkbType()),
                #             outside.length(),
                #             outside.area(),
                #         )
                #
                #     # 1) Intersection operand order (should be equivalent, but can differ numerically)
                #     g_in_A = boundary_in_layer.intersection(g)  # current
                #     g_in_B = g.intersection(boundary_in_layer)  # swapped
                #     inside_outside_stats("IN A (boundary ∩ g)", g_in_A)
                #     inside_outside_stats("IN B (g ∩ boundary)", g_in_B)
                #
                #     # 2) Difference operand order — BUT interpret correctly:
                #     # g_out must be "g minus boundary". That's the only valid 'out' for your split.
                #     # We'll still test operand sensitivity by computing an equivalent 'out' via symDifference.
                #     g_out_A = g.difference(boundary_in_layer)  # current
                #     g_in_for_out = g.intersection(boundary_in_layer)
                #     g_out_B = g.symDifference(g_in_for_out)  # equivalent "outside part" construction
                #
                #     leak_stats("OUT A leak (diff)", g_out_A)
                #     leak_stats("OUT B leak (symDiff)", g_out_B)
                # try:
                #     hd = leak_out.hausdorffDistance(boundary_in_layer)
                # except Exception:
                #     hd = None
                # logger.debug("AFTER_BOOLEAN leak hausdorffToBoundary=%.12g", hd if hd is not None else -1)

            except Exception as e:
                logger.warning(
                    "[Split] GEOS op failed on layer '%s', fid=%s: %r",
                    layer_name,
                    f.id(),
                    e,
                )
                feats_out.append(QgsFeature(f))
                continue

            g_in_ccw = ensure_ccw_geom(g_in)
            g_out_ccw = ensure_ccw_geom(g_out)

            if g_in_ccw and not g_in_ccw.isEmpty():
                if not g_in_ccw.isGeosValid():
                    g_in_ccw = g_in_ccw.makeValid()
                    logger.info(
                        f"Geometry in inner plan was invalid and repaired with makeValid():  {g_in_ccw}"
                    )
                nf = QgsFeature(f)
                nf.setGeometry(g_in_ccw)
                feats_in.append(nf)
                inner_list.append(
                    {
                        "old_object_id": self._old_object_id(f),
                        "wkt": g_in_ccw.asWkt(),
                        "has_z": QgsWkbTypes.hasZ(g_in_ccw.wkbType()),
                        "geom_type": QgsWkbTypes.geometryType(g_in_ccw.wkbType()),
                        "expected_geom_type": QgsWkbTypes.geometryType(
                            src_layer.wkbType()
                        ),
                    }
                )

            # TODO: Add edge case handling
            # if 'in' is empty but intersects, push inside; otherwise outside
            if geom_type == QgsWkbTypes.PointGeometry and (
                not g_in_ccw or g_in_ccw.isEmpty()
            ):
                try:
                    if boundary_in_layer.intersects(g):
                        feats_in.append(QgsFeature(f))
                        inner_list.append(
                            {
                                "old_object_id": self._old_object_id(f),
                                "wkt": g.asWkt(),
                                "has_z": QgsWkbTypes.hasZ(g),
                                "geom_type": QgsWkbTypes.geometryType(g),
                                "expected_geom_type": QgsWkbTypes.geometryType(
                                    src_layer.wkbType()
                                ),
                            }
                        )
                        continue
                except Exception:
                    pass

            if g_out_ccw and not g_out_ccw.isEmpty():
                if not g_out_ccw.isGeosValid():
                    g_out_ccw = g_out_ccw.makeValid()
                    logger.info(
                        f"Geometry in inner plan was invalid and repaired with makeValid():  {g_out_ccw}"
                    )
                nf = QgsFeature(f)
                nf.setGeometry(g_out_ccw)
                feats_out.append(nf)
                outer_list.append(
                    {
                        "old_object_id": self._old_object_id(f),
                        "wkt": g_out_ccw.asWkt(),
                        "has_z": QgsWkbTypes.hasZ(g_out_ccw.wkbType()),
                        "geom_type": QgsWkbTypes.geometryType(g_out_ccw.wkbType()),
                        "expected_geom_type": QgsWkbTypes.geometryType(
                            src_layer.wkbType()
                        ),
                    }
                )

        if feedback.isCanceled() or self._cancelled:
            return
        # Add non-empty outputs with full style intact
        if feats_in:
            prov_in.addFeatures(feats_in)
            mem_in.updateExtents()
            project.addMapLayer(mem_in, False)
            grp_innen.addLayer(mem_in)
            created_layers["inner"].append(mem_in)
            logger.info(
                "[Split]   → Innen: %d features in '%s'.", len(feats_in), layer_name
            )
        if feats_out:
            prov_out.addFeatures(feats_out)
            mem_out.updateExtents()
            project.addMapLayer(mem_out, False)
            grp_aussen.addLayer(mem_out)
            created_layers["outer"].append(mem_out)
            logger.info(
                "[Split]   → Außen: %d features in '%s'.", len(feats_out), layer_name
            )

    # ---------------- Geometry utils ----------------

    def _copy_geomless_features(
        self,
        src_layer: QgsVectorLayer,
        dst_group: QgsLayerTreeGroup,
        created_layers: dict[str, list[QgsVectorLayer]],
        role: str,
        feature_list: list,
    ):
        mem = self._empty_mem_clone(src_layer, src_layer.name())
        prov = mem.dataProvider()

        feats = []
        for f in src_layer.getFeatures():
            g = f.geometry()
            if g and not g.isEmpty():
                continue
            nf = QgsFeature(f)
            feats.append(nf)
            feature_list.append(
                {
                    "old_object_id": self._old_object_id(f),
                    "wkt": None,
                    "has_z": False,
                    "geom_type": None,
                    "expected_geom_type": None,
                }
            )

        if feats:
            prov.addFeatures(feats)
            mem.updateExtents()
            QgsProject.instance().addMapLayer(mem, False)
            dst_group.addLayer(mem)
            created_layers[role].append(mem)

    def _ensure_geom_crs(self, geom: "QgsGeometry", src_crs, dst_crs) -> "QgsGeometry":
        """Copy/transform geometry to destination CRS."""
        if src_crs == dst_crs:
            return QgsGeometry(geom)
        g = QgsGeometry(geom)
        ct = QgsCoordinateTransform(src_crs, dst_crs, QgsProject.instance())
        g.transform(ct)
        return g

    def _is_geometrycollection(self, geom: "QgsGeometry") -> bool:
        """
        True if the passed geometry is a (multi-)GeometryCollection.
        Safe-guards against NULL / empty inputs.
        """
        try:
            return (
                geom
                and not geom.isEmpty()
                and QgsWkbTypes.flatType(geom.wkbType())
                == QgsWkbTypes.GeometryCollection
            )
        except Exception:
            return False

    def _extract_matching_parts(
        self,
        gc: "QgsGeometry",
        desired_type: int,
        layer_name: str = "?",
        fid: int = -1,
    ) -> "QgsGeometry | None":
        try:
            if not gc or gc.isEmpty():
                return None

            if QgsWkbTypes.geometryType(gc.wkbType()) == desired_type:
                return QgsGeometry(gc)

            if QgsWkbTypes.flatType(gc.wkbType()) != QgsWkbTypes.GeometryCollection:
                return None

            parts = []
            discarded = []
            for part in gc.constParts():
                if not part or part.isEmpty():
                    continue
                if QgsWkbTypes.geometryType(part.wkbType()) == desired_type:
                    parts.append(QgsGeometry(part.clone()))
                else:
                    discarded.append(QgsGeometry(part.clone()))

            if discarded:
                type_counts = Counter(
                    QgsWkbTypes.displayString(d.wkbType()) for d in discarded
                )
                max_length = max((d.length() for d in discarded), default=0)
                max_area = max((abs(d.area()) for d in discarded), default=0)

                if max_area > TOL:
                    logger.error(
                        "[Split] DISCARDING parts with non-zero area! "
                        "layer='%s' fid=%s count=%d types=%s max_area=%.10g max_length=%.4g",
                        layer_name,
                        fid,
                        len(discarded),
                        dict(type_counts),
                        max_area,
                        max_length,
                    )
                else:
                    logger.debug(
                        "[Split] Discarded %d degenerate parts from layer='%s' fid=%s "
                        "(types: %s, max_length=%.4g)",
                        len(discarded),
                        layer_name,
                        fid,
                        dict(type_counts),
                        max_length,
                    )

            if not parts:
                return None
            if len(parts) == 1:
                return parts[0]

            combined = parts[0]
            for p in parts[1:]:
                try:
                    combined = combined.combine(p)
                except Exception:
                    combined = QgsGeometry.collectGeometry([combined, p])
            return combined

        except Exception as e:
            logger.error("[Split] _extract_matching_parts failed: %r", e, exc_info=True)
            return None

    # ---------------- Layer cloning / helpers ----------------

    def _empty_mem_clone(self, orig: QgsVectorLayer, new_name: str) -> QgsVectorLayer:
        """
        Create an empty in-memory layer matching schema/CRS/WKB and copy:
        full style, display expression, custom properties, joins.
        """
        crs_auth = orig.crs().authid()
        wkb = orig.wkbType()
        mem = QgsVectorLayer(
            f"{QgsWkbTypes.displayString(wkb)}?crs={crs_auth}",
            new_name,
            "memory",
        )
        prov = mem.dataProvider()
        prov.addAttributes(orig.fields())
        mem.updateFields()

        # Full style (renderer + labeling + symbol settings)
        try:
            style = QgsMapLayerStyle()
            style.readFromLayer(orig)
            style.writeToLayer(mem)
        except Exception:
            if orig.renderer():
                mem.setRenderer(orig.renderer().clone())

        mem.setDisplayExpression(orig.displayExpression())

        # Custom properties (version-safe)
        try:
            props = orig.customProperties()
            keys = (
                list(props.keys())
                if hasattr(props, "keys")
                else list(getattr(props, "propertyKeys")())
            )
            for k in keys:
                mem.setCustomProperty(k, orig.customProperty(k))
        except Exception:
            pass

        # Joins
        try:
            for j in orig.vectorJoins():
                mem.addJoin(QgsVectorLayerJoinInfo(j))
        except Exception:
            pass

        return mem

    def _layer_has_customprop_substr(self, lyr: QgsVectorLayer, needle: str) -> bool:
        """True if ANY custom property key or value contains `needle` (case-insensitive)."""
        try:
            n = needle.lower()
            # QGIS API varies: in some versions you have customPropertyKeys(), in others customProperties()
            if hasattr(lyr, "customPropertyKeys"):
                for k in lyr.customPropertyKeys():
                    v = lyr.customProperty(k)
                    if n in str(k).lower() or n in str(v).lower():
                        return True
            else:
                for k, v in getattr(lyr, "customProperties")().items():
                    if n in str(k).lower() or n in str(v).lower():
                        return True
        except Exception:
            pass
        return False

    def _group_has_xplanplugin_fast(self, grp: QgsLayerTreeGroup) -> bool:
        """Short-circuiting DFS: returns True as soon as a matching layer is found."""
        stack = [grp]
        n = PLUGIN_KEY
        while stack:
            g = stack.pop()
            for child in g.children():
                if not self._is_alive(child):
                    continue
                t = child.nodeType()
                if t == child.NodeLayer:
                    try:
                        lyr = child.layer()
                    except Exception:
                        lyr = None
                    if self._is_alive(lyr) and lyr.type() == lyr.VectorLayer:
                        if self._layer_has_customprop_substr(lyr, n):
                            return True  # exit early to save compute
                elif t == child.NodeGroup:
                    stack.append(child)
        return False

    def _transform_extent(self, extent, src_crs, dst_crs):
        if src_crs == dst_crs:
            return extent
        ct = QgsCoordinateTransform(src_crs, dst_crs, QgsProject.instance())
        return ct.transformBoundingBox(extent)

    def _plan_group_extent_in(self, grp: QgsLayerTreeGroup, target_crs):
        """Union of extents of all vector layers in the group (recursively), transformed to target_crs."""
        ext = None
        stack = [grp]
        while stack:
            g = stack.pop()
            for child in g.children():
                if child.nodeType() == child.NodeLayer:
                    lyr = child.layer()
                    if lyr and lyr.type() == lyr.VectorLayer:
                        try:
                            e = self._transform_extent(
                                lyr.extent(), lyr.crs(), target_crs
                            )
                            ext = e if ext is None else ext.united(e)
                        except Exception:
                            pass
                elif child.nodeType() == child.NodeGroup:
                    stack.append(child)
        return ext

    # Helper to make sure no non-existing C++ object is being touched to avoid ACCESS_VIOLATION_ERROR-crashes
    def _is_alive(self, obj) -> bool:
        try:
            return obj is not None and not sip.isdeleted(obj)
        except Exception:
            return False

    # Use a name/id reference of objects instead of using the objects themselves to avoid long living C++-object references
    def _snapshot_group(self, grp: QgsLayerTreeGroup) -> dict:
        """
        Take a pure-Python snapshot of a group:
        { 'name': str, 'layers': [layer_id, ...], 'groups': [subgroup_dict, ...] }
        """
        snap = {"name": grp.name(), "layers": [], "groups": []}
        for child in grp.children():
            try:
                # Guard in case node vanished mid-iteration
                if not self._is_alive(child):
                    continue
                t = child.nodeType()
                if t == child.NodeLayer:
                    # Store layer ID only; no live layer object
                    lid = (
                        child.layerId()
                        if hasattr(child, "layerId")
                        else (
                            child.layer().id()
                            if self._is_alive(child.layer())
                            else None
                        )
                    )
                    if lid:
                        snap["layers"].append(lid)
                elif t == child.NodeGroup:
                    snap["groups"].append(self._snapshot_group(child))
            except Exception:
                continue
        return snap

    def _first_customprop_value(self, lyr: QgsVectorLayer, key: str):
        try:
            if hasattr(lyr, "customProperty"):
                val = lyr.customProperty(key, None)
                return val if val not in ("", None) else None
        except Exception:
            pass
        return None

    def _find_plan_id(
        self, grp: QgsLayerTreeGroup, key: str = PLAN_KEY
    ) -> Optional[str]:
        """Walk group; return first non-empty custom property `plan_id` found on any layer."""
        stack = [grp]
        while stack:
            g = stack.pop()
            for child in g.children():
                if not self._is_alive(child):
                    continue
                if child.nodeType() == child.NodeLayer:
                    lyr = child.layer()
                    if self._is_alive(lyr) and lyr.type() == lyr.VectorLayer:
                        pid = self._first_customprop_value(lyr, key)
                        if pid:
                            return str(pid)
                elif child.nodeType() == child.NodeGroup:
                    stack.append(child)
        return None

    def _extract_appschema_firsthit_from_group(
        self, group: QgsLayerTreeGroup
    ) -> AppSchema:
        """Depth-first walk through all vector layers in the group.
        Return the first layer that carries both App-Schema custom properties."""
        project = QgsProject.instance()
        group_stack: list[QgsLayerTreeGroup] = [group]

        while group_stack:
            current_group = group_stack.pop()
            for child_item in current_group.children():
                if isinstance(child_item, QgsLayerTreeGroup):
                    group_stack.append(child_item)
                else:  # QgsLayerTreeLayer
                    layer = project.mapLayer(child_item.layerId())
                    if not isinstance(layer, QgsVectorLayer):
                        continue
                    app_name = layer.customProperty(APP_KEY, None)
                    app_vers = layer.customProperty(VER_KEY, None)
                    if app_name and app_vers:
                        return AppSchema(str(app_name), str(app_vers))

        raise QgsProcessingException("Kein Appschema in Plan-Gruppe gefunden.")

    def _new_feature_lists(self):
        """
        Create a fresh accumulator for processing split layer features.

        Returns:
            list: Flat lists for accumulating features segregated by their roles
                  ('inner' and 'outer').
        """
        return {"inner": [], "outer": []}

    def _old_object_id(self, feature: QgsFeature) -> str:
        idx = feature.fields().indexOf(ORIG_ID_FIELD)  # ORIG_ID_FIELD = "id"
        layer_name = (
            feature.layer().name()
            if hasattr(feature, "layer") and feature.layer()
            else "?"
        )
        if idx == -1:
            raise QgsProcessingException(
                f"Layer '{layer_name}' hat kein benötigtes ID-Feld '{ORIG_ID_FIELD}'. "
                "Bitte sicherstellen, dass die Original-UUID im Layer vorhanden ist."
            )
        val = feature[ORIG_ID_FIELD]
        if val in (None, ""):
            raise QgsProcessingException(
                f"Feature in Layer '{layer_name}' hat leeres '{ORIG_ID_FIELD}'."
            )
        return str(val)

    def _acc_has_data(self, role_list: list) -> bool:
        """
        Check whether any layer list under the given role contains features.

        Args:
            role_list (dict): Dictionary representing either the 'inner' or 'outer'
                              role's accumulation data.

        Returns:
            bool: True if any list contains features, False otherwise.
        """
        return bool(role_list)

    def _has_vertex(self, geom: QgsGeometry, pt: QgsPointXY, tol2: float) -> bool:
        for v in geom.vertices():
            dx = v.x() - pt.x()
            dy = v.y() - pt.y()
            if dx * dx + dy * dy <= tol2:
                return True
        return False

    def _copy_all_features(
        self,
        src_layer: QgsVectorLayer,
        dst_group: QgsLayerTreeGroup,
        created_layers: dict[str, list[QgsVectorLayer]],
        role: str,  # "inner" or "outer"
        feature_list: list,
    ):
        mem = self._empty_mem_clone(src_layer, src_layer.name())
        prov = mem.dataProvider()

        feats = []
        for f in src_layer.getFeatures():
            g = f.geometry()
            if not g or g.isEmpty():
                nf = QgsFeature(f)
                feats.append(nf)
                feature_list.append(
                    {
                        "old_object_id": self._old_object_id(f),
                        "wkt": None,
                        "has_z": False,
                        "geom_type": None,
                        "expected_geom_type": None,
                    }
                )
                continue
            g = ensure_ccw_geom(g)
            nf = QgsFeature(f)
            nf.setGeometry(g)
            feats.append(nf)
            feature_list.append(
                {
                    "old_object_id": self._old_object_id(f),
                    "wkt": g.asWkt(),
                    "has_z": QgsWkbTypes.hasZ(g.wkbType()),
                    "geom_type": QgsWkbTypes.geometryType(g.wkbType()),
                    "expected_geom_type": QgsWkbTypes.geometryType(src_layer.wkbType()),
                }
            )

        if feats:
            prov.addFeatures(feats)
            mem.updateExtents()
            QgsProject.instance().addMapLayer(mem, False)
            dst_group.addLayer(mem)
            created_layers[role].append(mem)

    def _validate_result(self, feature_lists: dict) -> tuple[bool, Optional[str]]:
        """
        Validate split result to make sure no geometry types have been changed from the split or ZPolygons end up in the payload..
        Clean up extra validation attributes after validation is completed.

        Args:
            feature_lists (dict): Dictionary containing the 'inner' and 'outer' feature lists.

        Returns:
            bool: True if no feature is of wrong type, False otherwise.
            Optional[str]: Error message for specific validation error.
        """
        for role, items in feature_lists.items():
            for it in items:
                if it["wkt"] is None:
                    it.pop("has_z", None)
                    it.pop("geom_type", None)
                    it.pop("expected_geom_type", None)
                    continue
                if it["has_z"]:
                    return False, (
                        f"Z-Geometrie in '{role}': oid={it['old_object_id']}"
                    )
                if it["geom_type"] != it["expected_geom_type"]:
                    return False, (
                        f"Geometrietyp geändert in '{role}': "
                        f"oid={it['old_object_id']} "
                        f"expected={it['expected_geom_type']} "
                        f"got={it['geom_type']}"
                    )
                it.pop("has_z", None)
                it.pop("geom_type", None)
                it.pop("expected_geom_type", None)
        return True, None

    # ---------------- Snapping helpers ----------------

    # TODO: Ignore snapping for now

    # def _make_polygon_ref_layer(self, geom: QgsGeometry, crs) -> QgsVectorLayer:
    #     vl = QgsVectorLayer(f"MultiPolygon?crs={crs.authid()}", "snap_ref", "memory")
    #     pr = vl.dataProvider()
    #     feat = QgsFeature()
    #     feat.setGeometry(QgsGeometry(geom))
    #     pr.addFeatures([feat])
    #     vl.updateExtents()
    #     return vl
    #
    # def _snap_entire_layer_to_layer(
    #         self,
    #         layer: QgsVectorLayer,
    #         ref_layer: QgsVectorLayer,
    #         tol: float,
    #         label: str,
    #         feedback: QgsProcessingFeedback,
    # ) -> None:
    #     if feedback.isCanceled() or self._cancelled:
    #         return
    #
    #     snapper = QgsGeometrySnapper(ref_layer)
    #     feats = list(layer.getFeatures())
    #     if not feats:
    #         return
    #
    #     snapped = snapper.snapFeatures(feats, tol, QgsGeometrySnapper.PreferNodes)
    #
    #     changed = 0
    #     layer.startEditing()
    #     for i, sf in enumerate(snapped):
    #         if not feats[i].geometry().equals(sf.geometry()):
    #             layer.changeGeometry(sf.id(), sf.geometry())
    #             changed += 1
    #     layer.commitChanges()
    #
    #     logger.info("[SnapSimple] %s layer='%s' feats=%d changed=%d tol=%.6f",
    #                 label, layer.name(), len(feats), changed, tol)
    #
    # def post_snap_to_cut_boundary(
    #         self,
    #         layers_to_fix: list[QgsVectorLayer],
    #         cutting_boundary_src: QgsGeometry,
    #         cutting_boundary_src_crs: QgsCoordinateReferenceSystem,
    #         tol: float,
    #         feedback: QgsProcessingFeedback,
    # ) -> None:
    #     ref_by_crs: dict[str, QgsVectorLayer] = {}
    #
    #     for lyr in layers_to_fix:
    #         if feedback.isCanceled() or self._cancelled:
    #             return
    #         crs = lyr.crs()
    #         key = crs.authid()
    #
    #         if key not in ref_by_crs:
    #             b = self._ensure_geom_crs(cutting_boundary_src, cutting_boundary_src_crs, crs)
    #             b = ensure_ccw_geom(b)
    #             ref_by_crs[key] = self._make_polygon_ref_layer(b, crs)
    #
    #         self._snap_entire_layer_to_layer(
    #             lyr, ref_by_crs[key], tol, label="all->cut", feedback=feedback
    #         )
    #
    # def snap_layers_to_layer(
    #         self,
    #         layers_to_snap: list[QgsVectorLayer],
    #         snap_layer: QgsVectorLayer,
    #         tolerance: float,
    #         feedback: QgsProcessingFeedback,
    #         label: str,
    # ) -> None:
    #     snapper = QgsGeometrySnapper(snap_layer)
    #
    #     for layer in layers_to_snap:
    #         if feedback.isCanceled() or self._cancelled:
    #             return
    #         if layer.id() == snap_layer.id():
    #             continue
    #
    #         feats = list(layer.getFeatures())
    #         if not feats:
    #             continue
    #
    #         snapped_feats = snapper.snapFeatures(feats, tolerance)
    #
    #         snapped_count = 0
    #         layer.startEditing()
    #         for i, sf in enumerate(snapped_feats):
    #             if not feats[i].geometry().equals(sf.geometry()):
    #                 layer.changeGeometry(sf.id(), sf.geometry())
    #                 snapped_count += 1
    #         layer.commitChanges()
    #
    #         logger.info(
    #             "[SnapSimple] %s layer='%s' -> ref='%s' tol=%.6f snapped=%d/%d",
    #             label, layer.name(), snap_layer.name(), tolerance, snapped_count, len(feats)
    #         )
    #
    # def post_snap_created_layers(
    #         self,
    #         created_layers: list[QgsVectorLayer],
    #         cutting_boundary_src: QgsGeometry,
    #         cutting_boundary_src_crs: QgsCoordinateReferenceSystem,
    #         snap_tol: float,
    #         feedback: QgsProcessingFeedback,
    # ) -> None:
    #     # Build one per-CRS boundary geometry (cutting boundary transformed once per CRS)
    #     boundary_by_crs: dict[str, QgsGeometry] = {}
    #
    #     def boundary_in_crs(crs: QgsCoordinateReferenceSystem) -> QgsGeometry:
    #         key = crs.authid()
    #         if key not in boundary_by_crs:
    #             g = self._ensure_geom_crs(
    #                 cutting_boundary_src, cutting_boundary_src_crs, crs
    #             )
    #             boundary_by_crs[key] = ensure_ccw_geom(g)
    #         return boundary_by_crs[key]
    #
    #     # Identify plan layers (keep your heuristic for now)
    #     def is_plan_layer(layer: QgsVectorLayer) -> bool:
    #         return (
    #                 QgsWkbTypes.geometryType(layer.wkbType()) == QgsWkbTypes.PolygonGeometry
    #                 and "Plan" in layer.name()
    #         )
    #
    #     all_layers = created_layers
    #     logger.info("[Snap] layers=%d tol=%.6f", len(all_layers), snap_tol)
    #
    #     plan_layers = [lyr for lyr in all_layers if is_plan_layer(lyr)]
    #     logger.info("[Snap] plan_layers=%d", len(plan_layers))
    #
    #     if not plan_layers:
    #         logger.warning("[Snap] No plan layers detected; skipping post-snap.")
    #         return
    #
    #     # --- helper: union all polygons of a layer (returns QgsGeometry or None)
    #     def layer_union_geom(layer: QgsVectorLayer) -> QgsGeometry | None:
    #         u = None
    #         for f in layer.getFeatures():
    #             g = f.geometry()
    #             if g and not g.isEmpty():
    #                 g = ensure_ccw_geom(g)
    #                 u = g if u is None else u.combine(g)
    #         return u
    #
    #     # --- helper: create polygon reference layer from geometry (no boundary conversion)
    #     def make_polygon_ref_layer(geom: QgsGeometry, crs: QgsCoordinateReferenceSystem) -> QgsVectorLayer:
    #         vl = QgsVectorLayer(f"MultiPolygon?crs={crs.authid()}", "snap_ref", "memory")
    #         pr = vl.dataProvider()
    #         feat = QgsFeature()
    #         feat.setGeometry(QgsGeometry(geom))
    #         pr.addFeatures([feat])
    #         vl.updateExtents()
    #         return vl
    #
    #     # Snap plan layers to clipped cutting boundary
    #     # Also collect per-CRS union of plan polygons for snapping other layers.
    #     plan_union_by_crs: dict[str, QgsGeometry] = {}
    #
    #     for plan in plan_layers:
    #         if feedback.isCanceled() or self._cancelled:
    #             return
    #
    #         plan_geom = layer_union_geom(plan)
    #         if not plan_geom or plan_geom.isEmpty():
    #             continue
    #
    #         key = plan.crs().authid()
    #         plan_union_by_crs[key] = (
    #             plan_geom if key not in plan_union_by_crs else plan_union_by_crs[key].combine(plan_geom)
    #         )
    #
    #         b = boundary_in_crs(plan.crs())
    #
    #         ref = b.intersection(plan_geom)  # critical: clips away boundary outside the plan
    #         if not ref or ref.isEmpty():
    #             continue
    #
    #         ref_layer = make_polygon_ref_layer(ref, plan.crs())
    #         self._snap_layer_to_reference(plan, ref_layer, snap_tol, label="plan->cutclip")
    #
    #     # Snap all other layers to plan polygon union (edges act as snapping target)
    #     # Build one polygon ref layer per CRS
    #     plan_ref_by_crs: dict[str, QgsVectorLayer] = {}
    #     for crs_authid, plan_union in plan_union_by_crs.items():
    #         if plan_union and not plan_union.isEmpty():
    #             # We need a CRS object; reuse from any plan layer with same authid
    #             any_plan = next((p for p in plan_layers if p.crs().authid() == crs_authid), None)
    #             if any_plan is None:
    #                 continue
    #             plan_ref_by_crs[crs_authid] = make_polygon_ref_layer(plan_union, any_plan.crs())
    #
    #     for lyr in all_layers:
    #         if lyr in plan_layers:
    #             continue
    #         if feedback.isCanceled() or self._cancelled:
    #             return
    #
    #         ref_layer = plan_ref_by_crs.get(lyr.crs().authid())
    #         if not ref_layer:
    #             continue
    #
    #         self._snap_layer_to_reference(lyr, ref_layer, snap_tol, label="other->plan")
    #
    # def _make_reference_layer_from_geom(self, geom: QgsGeometry, crs) -> QgsVectorLayer:
    #     # Polygon ref layer is simplest + avoids boundary/line conversion issues
    #     vl = QgsVectorLayer(f"MultiPolygon?crs={crs.authid()}", "snap_ref", "memory")
    #     pr = vl.dataProvider()
    #     feat = QgsFeature()
    #     feat.setGeometry(QgsGeometry(geom))
    #     pr.addFeatures([feat])
    #     vl.updateExtents()
    #     return vl
    #
    # def _snap_layer_to_reference(
    #         self,
    #         layer: QgsVectorLayer,
    #         ref_layer: QgsVectorLayer,
    #         tol: float,
    #         label: str = "",
    #         log_examples: int = 5,
    # ) -> None:
    #     snapper = QgsGeometrySnapper(ref_layer)
    #
    #     feats = list(layer.getFeatures())
    #     before_by_id = {f.id(): QgsGeometry(f.geometry()) for f in feats if f.geometry() and not f.geometry().isEmpty()}
    #
    #     snapped = snapper.snapFeatures(feats, tol, QgsGeometrySnapper.PreferNodes)
    #
    #     changed = 0
    #     max_bbox_shift = 0.0
    #     examples = []
    #
    #     for f in snapped:
    #         fid = f.id()
    #         after = f.geometry()
    #         before = before_by_id.get(fid)
    #         if before is None or after is None or after.isEmpty():
    #             continue
    #
    #         # Fast “did it change” check
    #         if before.asWkb() == after.asWkb():
    #             continue
    #
    #         changed += 1
    #
    #         # Cheap movement proxy: bbox corner distance
    #         b0 = before.boundingBox()
    #         b1 = after.boundingBox()
    #         dx = max(abs(b0.xMinimum() - b1.xMinimum()), abs(b0.xMaximum() - b1.xMaximum()))
    #         dy = max(abs(b0.yMinimum() - b1.yMinimum()), abs(b0.yMaximum() - b1.yMaximum()))
    #         bbox_shift = (dx * dx + dy * dy) ** 0.5
    #         if bbox_shift > max_bbox_shift:
    #             max_bbox_shift = bbox_shift
    #
    #         if len(examples) < log_examples:
    #             # Optional: try a more meaningful distance if available
    #             haus = None
    #             try:
    #                 haus = before.hausdorffDistance(after)
    #             except Exception:
    #                 pass
    #             examples.append((fid, bbox_shift, haus))
    #
    #     logger.info(
    #         "[Snap] %s layer='%s' feats=%d changed=%d max_bbox_shift=%.9g tol=%.9g",
    #         label,
    #         layer.name(),
    #         len(feats),
    #         changed,
    #         max_bbox_shift,
    #         tol,
    #     )
    #     if examples:
    #         logger.info("[Snap] %s examples (fid, bbox_shift, hausdorff): %s", label, examples)
    #
    #     # Write geometries back
    #     layer.startEditing()
    #     for f in snapped:
    #         layer.changeGeometry(f.id(), f.geometry())
    #     layer.commitChanges()


def log_outside(logger, tag: str, geom: QgsGeometry, ref_poly: QgsGeometry):
    if not geom or geom.isEmpty():
        logger.debug("%s: geom empty", tag)
        return
    out = geom.difference(ref_poly)
    logger.debug(
        "%s: outEmpty=%s outLen=%.12g outArea=%.12g geomValid=%s outValid=%s "
        "geomBBox=%s outBBox=%s",
        tag,
        out.isEmpty(),
        out.length() if not out.isEmpty() else 0.0,
        out.area() if not out.isEmpty() else 0.0,
        geom.isGeosValid(),
        out.isGeosValid(),
        geom.boundingBox().toString(),
        out.boundingBox().toString(),
    )


def patch_feature_list_from_layers(
    layers: list[QgsVectorLayer],
    feature_list: list[dict],
    *,
    id_field: str = ORIG_ID_FIELD,  # "id"
    role_label: str = "?",
    debug_ids: set[str] | None = None,
) -> dict:
    """
    Overwrite feature_list[*]["wkt"] with the *current* geometries from layers.

    Defensive behavior:
    - Patches ALL items for a given old_object_id (handles duplicates in list).
    - Logs duplicates in payload.
    - Reports payload IDs not found in layers and layer IDs not found in payload.
    """
    # old_object_id -> [item1, item2, ...]  (supports duplicates)
    items_by_oid: dict[str, list[dict]] = defaultdict(list)
    payload_oids: list[str] = []

    for it in feature_list:
        oid = it.get("old_object_id")
        if not oid:
            continue
        oid = str(oid)
        payload_oids.append(oid)
        items_by_oid[oid].append(it)

    dup_payload = {
        oid: len(items) for oid, items in items_by_oid.items() if len(items) > 1
    }
    if dup_payload:
        # keep log small
        sample = list(sorted(dup_payload.items(), key=lambda x: x[1], reverse=True))[
            :10
        ]
        logger.warning(
            "[Split] payload duplicates role=%s count=%d sample=%s",
            role_label,
            len(dup_payload),
            sample,
        )

    seen_in_layers: set[str] = set()
    patched_items = 0
    patched_oids = 0

    for lyr in layers:
        for f in lyr.getFeatures():
            try:
                oid = str(f[id_field])
            except Exception:
                continue

            seen_in_layers.add(oid)
            bucket = items_by_oid.get(oid)
            if not bucket:
                continue

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

            wkt = ensure_ccw_geom(g).asWkt()

            # patch all duplicates
            for it in bucket:
                it["wkt"] = wkt
                patched_items += 1
            patched_oids += 1

            if debug_ids and oid in debug_ids:
                logger.warning(
                    "[Split][DEBUG] patched role=%s oid=%s wkt=%s", role_label, oid, wkt
                )

    payload_oid_set = set(items_by_oid.keys())

    payload_not_in_layers = sorted(payload_oid_set - seen_in_layers)
    layers_not_in_payload = sorted(seen_in_layers - payload_oid_set)

    if payload_not_in_layers:
        logger.warning(
            "[Split] payload ids not found in layers role=%s n=%d sample=%s",
            role_label,
            len(payload_not_in_layers),
            payload_not_in_layers[:10],
        )

    # This can be totally normal (layers include features that aren’t part of the payload),
    # but it’s still useful for diagnosing “I patched nothing”.
    if layers_not_in_payload:
        logger.info(
            "[Split] layer ids not present in payload role=%s n=%d sample=%s",
            role_label,
            len(layers_not_in_payload),
            layers_not_in_payload[:10],
        )

    logger.info(
        "[Split] patched role=%s patched_oids=%d patched_items=%d payload_items=%d",
        role_label,
        patched_oids,
        patched_items,
        len(feature_list),
    )

    return {
        "role": role_label,
        "patched_oids": patched_oids,
        "patched_items": patched_items,
        "payload_items": len(feature_list),
        "dup_payload_ids": len(dup_payload),
        "payload_ids_not_in_layers": len(payload_not_in_layers),
        "layer_ids_not_in_payload": len(layers_not_in_payload),
    }


# One LineString and one Überlagerungsobjekt
DEBUG_UUIDS = {
    "5355860b-25fa-401c-a2ea-bb70654d17ab",
    "e0c8b0e8-7b8c-4ead-ae77-f2f1551b9136",
}


def is_debug_feature(f: QgsFeature) -> bool:
    try:
        return str(f[ORIG_ID_FIELD]) in DEBUG_UUIDS
    except Exception:
        return False
