# 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

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=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)."
                )

            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 = 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 / text-only 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}
                )
                outer_list.append(
                    {"old_object_id": self._old_object_id(f), "wkt": 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(),
                    }
                )

            # 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(),
                            }
                        )
                        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(),
                    }
                )

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

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

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

    # ---------------- 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
