"""
Workflow editor for reusable engine-based workflow definitions.
"""

from __future__ import annotations

import copy
import json
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional

from ..models import EngineType, WorkflowStep
from ..qt_compat import (
    QCheckBox,
    QComboBox,
    QDialog,
    QFileDialog,
    QFormLayout,
    QFrame,
    QGraphicsOpacityEffect,
    QHBoxLayout,
    QHeaderView,
    QInputDialog,
    QKeySequence,
    QLabel,
    QLineEdit,
    QMenu,
    QMenuBar,
    QMessageBox,
    QPushButton,
    QScrollArea,
    QSplitter,
    QSettings,
    QShortcut,
    QStackedWidget,
    QStyle,
    QTableWidget,
    QTableWidgetItem,
    QTabWidget,
    QTextEdit,
    QTimer,
    QVBoxLayout,
    QWidget,
    Qt,
)
from ..qt_compat import exec_dialog, exec_menu
from ..workflow_definition import (
    WorkflowDefinition,
    WorkflowDefinitionStep,
    WorkflowDefinitionStepType,
)
from ..workflow_definition_executor import (
    WorkflowDefinitionExecutionError,
    WorkflowDefinitionExecutionResult,
    WorkflowDefinitionExecutor,
)
from ..workflow_graph_view import WorkflowGraphView
from ..translator import tr


# Operators supported by the IF/ELSE step (display label, internal value, short preview symbol).
_IF_OPERATORS: List[tuple] = [
    ("Equals (==)", "equals", "=="),
    ("Not Equals (!=)", "not_equals", "!="),
    ("Less Than (<)", "less_than", "<"),
    ("Less Than or Equal (<=)", "less_than_or_equals", "<="),
    ("Greater Than (>)", "greater_than", ">"),
    ("Greater Than or Equal (>=)", "greater_than_or_equals", ">="),
    ("Between (inclusive)", "between", "between"),
    ("Contains", "contains", "contains"),
    ("In List (comma-separated)", "in_list", "in"),
    ("Is Empty", "is_empty", "is empty"),
    ("Is Not Empty", "is_not_empty", "is not empty"),
    ("Has a value (truthy)", "is_truthy", "is set"),
    ("Is empty / zero (falsy)", "is_falsy", "is not set"),
]

_ZERO_VALUE_OPERATORS = {"is_empty", "is_not_empty", "is_truthy", "is_falsy"}
_TWO_VALUE_OPERATORS = {"between"}


class WorkflowUndoStack:
    """JSON-snapshot undo/redo stack for WorkflowDefinition.

    Mirrors the pattern used in the ArcGIS Pro / C# port: snapshots are
    plain dicts produced by WorkflowDefinition.to_dict(), not object
    references. That avoids reference tracking on object-oriented command
    classes and keeps undo/redo a pure value-transform operation.
    """

    def __init__(self, limit: int = 50):
        self._stack: List[tuple] = []  # list of (label, snapshot dict)
        self._cursor: int = -1
        self._limit = max(1, limit)

    def reset(self, definition: WorkflowDefinition, label: str = "initial") -> None:
        self._stack = [(label, definition.to_dict())]
        self._cursor = 0

    def push(self, label: str, definition: WorkflowDefinition) -> bool:
        snapshot = definition.to_dict()
        if self._stack and self._stack[self._cursor][1] == snapshot:
            return False  # no-op edit
        # Truncate any redo history past the current cursor.
        self._stack = self._stack[: self._cursor + 1]
        self._stack.append((label, snapshot))
        if len(self._stack) > self._limit:
            self._stack.pop(0)
        self._cursor = len(self._stack) - 1
        return True

    def can_undo(self) -> bool:
        return self._cursor > 0

    def can_redo(self) -> bool:
        return self._cursor < len(self._stack) - 1

    def undo(self) -> Optional[dict]:
        if not self.can_undo():
            return None
        self._cursor -= 1
        return self._stack[self._cursor][1]

    def redo(self) -> Optional[dict]:
        if not self.can_redo():
            return None
        self._cursor += 1
        return self._stack[self._cursor][1]

    def undo_label(self) -> str:
        return self._stack[self._cursor][0] if self.can_undo() else ""

    def redo_label(self) -> str:
        if self.can_redo():
            return self._stack[self._cursor + 1][0]
        return ""


class MockVariablesDialog(QDialog):
    """Small key/value editor for dry-run workflow inputs."""

    def __init__(
        self,
        *,
        initial_variables: Optional[Dict[str, Any]] = None,
        title: str = "Run with Mock Variables",
        parent=None,
    ):
        super().__init__(parent)
        self.setWindowTitle(title)
        self.resize(680, 420)

        layout = QVBoxLayout()
        self.setLayout(layout)

        intro = QLabel(
            "Enter one mock pipeline input per row. Values are parsed as JSON when possible, "
            "so you can use numbers, true/false, arrays, or quoted strings."
        )
        intro.setWordWrap(True)
        layout.addWidget(intro)

        self.table = QTableWidget(0, 2, self)
        self.table.setHorizontalHeaderLabels(["Variable", "Mock Value"])
        self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        layout.addWidget(self.table, 1)

        toolbar = QHBoxLayout()
        self.add_row_button = QPushButton("Add Row")
        self.add_row_button.clicked.connect(self._add_row)
        toolbar.addWidget(self.add_row_button)
        self.remove_row_button = QPushButton("Remove Selected")
        self.remove_row_button.clicked.connect(self._remove_selected_rows)
        toolbar.addWidget(self.remove_row_button)
        toolbar.addStretch()
        layout.addLayout(toolbar)

        for name, value in sorted(dict(initial_variables or {}).items()):
            self._add_row(str(name), self._format_value(value))
        if self.table.rowCount() == 0:
            self._add_row()

        button_row = QHBoxLayout()
        button_row.addStretch()
        cancel_button = QPushButton("Cancel")
        cancel_button.clicked.connect(self.reject)
        button_row.addWidget(cancel_button)
        ok_button = QPushButton("Run")
        ok_button.clicked.connect(self.accept)
        button_row.addWidget(ok_button)
        layout.addLayout(button_row)

    def _add_row(self, name: str = "", value: str = "") -> None:
        row = self.table.rowCount()
        self.table.insertRow(row)
        self.table.setItem(row, 0, QTableWidgetItem(name))
        self.table.setItem(row, 1, QTableWidgetItem(value))
        self.table.setCurrentCell(row, 0)

    def _remove_selected_rows(self) -> None:
        rows = sorted({item.row() for item in self.table.selectedItems()}, reverse=True)
        for row in rows:
            self.table.removeRow(row)
        if self.table.rowCount() == 0:
            self._add_row()

    def variables(self) -> Dict[str, Any]:
        values: Dict[str, Any] = {}
        for row in range(self.table.rowCount()):
            name_item = self.table.item(row, 0)
            value_item = self.table.item(row, 1)
            name = str(name_item.text() if name_item else "").strip()
            if not name:
                continue
            raw_value = str(value_item.text() if value_item else "")
            values[name] = self._parse_value(raw_value)
        return values

    @staticmethod
    def _format_value(value: Any) -> str:
        if isinstance(value, str):
            return value
        try:
            return json.dumps(value)
        except Exception:
            return repr(value)

    @staticmethod
    def _parse_value(raw_value: str) -> Any:
        text = str(raw_value or "").strip()
        if text == "":
            return ""
        try:
            return json.loads(text)
        except Exception:
            return raw_value


BLOCKS_STYLESHEET = """
QScrollArea#WorkflowBlocksScrollArea {
    background-color: #11111b;
    border: 1px solid #313244;
    border-radius: 8px;
}
QWidget#BlocksContainer {
    background-color: #11111b;
}
WorkflowBlockCard {
    background-color: #1e1e2e;
    border: 1px solid #313244;
    border-radius: 8px;
}
WorkflowBlockCard[selected="true"] {
    border: 2px solid #89b4fa;
    background-color: #252538;
}
WorkflowBlockCard:hover {
    background-color: #252538;
}
QLabel#CardTitle {
    font-weight: bold;
    font-size: 12px;
    color: #cdd6f4;
    margin-left: 6px;
}
QLabel#CardDetail {
    font-size: 11px;
    color: #a6adc8;
    margin-top: 4px;
}
QPushButton#CardBtn {
    background-color: transparent;
    border: none;
    border-radius: 4px;
    color: #bac2de;
    font-weight: bold;
    font-size: 12px;
    padding: 2px 6px;
}
QPushButton#CardBtn:hover {
    background-color: #313244;
}
QPushButton#InsertBtn {
    background-color: #181825;
    border: 1px dashed #45475a;
    border-radius: 10px;
    color: #a6adc8;
    font-weight: bold;
    font-size: 10px;
    padding: 2px 10px;
}
QPushButton#InsertBtn:hover {
    background-color: #313244;
    border-color: #89b4fa;
    color: #89b4fa;
}
WorkflowContainer {
    background-color: transparent;
    border: none;
}
WorkflowContainer#LoopContainer {
    border-left: 2px dashed #b4befe;
    margin-left: 14px;
    padding-left: 6px;
}
WorkflowContainer#ThenContainer {
    border-left: 2px dashed #f9e2af;
    margin-left: 14px;
    padding-left: 6px;
}
WorkflowContainer#ElseContainer {
    border-left: 2px dashed #fab387;
    margin-left: 14px;
    padding-left: 6px;
}
WorkflowContainer#TryContainer {
    border-left: 2px dashed #e78284;
    margin-left: 14px;
    padding-left: 6px;
}
WorkflowContainer#CatchContainer {
    border-left: 2px dashed #ea999c;
    margin-left: 14px;
    padding-left: 6px;
}
QFrame#EndBlockCard {
    background-color: #181825;
    border: 1px dashed #313244;
    border-radius: 6px;
    margin-top: 2px;
    margin-bottom: 2px;
}
QLabel#EndBlockLabel {
    font-size: 10px;
    font-weight: bold;
    color: #7f849c;
    padding: 2px 6px;
}
"""


class WorkflowContainer(QFrame):
    """Container for nested child steps with a custom left border as a guide track."""
    def __init__(self, parent=None):
        super().__init__(parent)


class WorkflowInsertButton(QWidget):
    """An elegant insertion button shown between steps or inside empty branches."""
    def __init__(self, parent_list: List[WorkflowDefinitionStep], index: int, editor: WorkflowEditorDialog, parent=None):
        super().__init__(parent)
        self.parent_list = parent_list
        self.index = index
        self.editor = editor

        layout = QHBoxLayout()
        layout.setContentsMargins(0, 2, 0, 2)
        layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.setLayout(layout)

        self.btn = QPushButton(self.editor._tr_pipeline("btn_insert_step"))
        self.btn.setObjectName("InsertBtn")
        self.btn.clicked.connect(self._show_menu)
        layout.addWidget(self.btn)

    def _show_menu(self) -> None:
        menu = QMenu(self)
        action_to_type: Dict[Any, str] = {}
        for label, step_type in self.editor._insert_menu_options():
            action = menu.addAction(label)
            action_to_type[action] = step_type

        action = exec_menu(menu, self.btn.mapToGlobal(self.btn.rect().bottomLeft()))
        if not action:
            return
        selected_type = action_to_type.get(action)
        if selected_type:
            new_step = self.editor._make_new_step(selected_type)
            self.parent_list.insert(self.index, new_step)
            self.editor._populate_blocks()
            self.editor._select_step_in_blocks(new_step.id)


class WorkflowBlockCard(QFrame):
    """Visual block card representing a workflow step."""
    def __init__(self, step: WorkflowDefinitionStep, parent_list: List[WorkflowDefinitionStep], editor: WorkflowEditorDialog, parent=None):
        super().__init__(parent)
        self.step = step
        self.parent_list = parent_list
        self.editor = editor

        self.setObjectName("BlockCard")
        self.setFrameShape(QFrame.Shape.StyledPanel)

        # Outer horizontal layout to hold the color bar and the content
        outer_layout = QHBoxLayout()
        outer_layout.setContentsMargins(0, 0, 0, 0)
        outer_layout.setSpacing(0)
        self.setLayout(outer_layout)

        border_colors = {
            WorkflowDefinitionStepType.TASK: "#2563eb",
            WorkflowDefinitionStepType.SET_VARIABLE: "#0e7490",
            WorkflowDefinitionStepType.FOR_LOOP: "#9333ea",
            WorkflowDefinitionStepType.IF_ELSE: "#ca8a04",
            WorkflowDefinitionStepType.LOG: "#475569",
            WorkflowDefinitionStepType.TRY_CATCH: "#c2410c",
        }

        if step.step_type == WorkflowDefinitionStepType.TASK and str(step.config.get("engine_type", "")).strip() == "python_script":
            border_color = "#16a34a"
        else:
            border_color = border_colors.get(step.step_type, "#313244")

        # Left color bar
        self.color_bar = QFrame(self)
        self.color_bar.setFixedWidth(5)
        self.color_bar.setStyleSheet(f"background-color: {border_color}; border-top-left-radius: 6px; border-bottom-left-radius: 6px;")
        outer_layout.addWidget(self.color_bar)

        # Content frame
        content_frame = QFrame(self)
        content_frame.setObjectName("CardContent")
        content_frame.setStyleSheet("background: transparent; border: none;")
        outer_layout.addWidget(content_frame, 1)

        main_layout = QVBoxLayout()
        main_layout.setContentsMargins(10, 8, 10, 8)
        main_layout.setSpacing(4)
        content_frame.setLayout(main_layout)

        # Header row: [Badge] Name [Stretch] [Up] [Down] [Copy] [Delete]
        header_layout = QHBoxLayout()
        header_layout.setContentsMargins(0, 0, 0, 0)

        # Badge — plain-English labels for the analyst audience. Internal
        # WorkflowDefinitionStepType values stay as-is; only display strings change.
        badge_text = {
            WorkflowDefinitionStepType.TASK: "🗲 RUN",
            WorkflowDefinitionStepType.SET_VARIABLE: "⚙ REMEMBER",
            WorkflowDefinitionStepType.FOR_LOOP: "↻ REPEAT",
            WorkflowDefinitionStepType.IF_ELSE: "🔀 IF",
            WorkflowDefinitionStepType.LOG: "📋 LOG",
            WorkflowDefinitionStepType.TRY_CATCH: "🛡 RECOVER",
        }.get(step.step_type, step.step_type.upper())

        self.badge_lbl = QLabel(badge_text)
        self.badge_lbl.setObjectName("CardBadge")

        # Validation badge — hidden unless this step has open issues.
        # Populated from editor._issues_by_step on update_display.
        self.issue_badge = QLabel("")
        self.issue_badge.setObjectName("CardIssueBadge")
        self.issue_badge.setStyleSheet(
            "QLabel#CardIssueBadge { background-color: #e78284; color: #1e1e2e; "
            "border-radius: 8px; padding: 1px 6px; font-weight: bold; }"
        )
        self.issue_badge.setVisible(False)
        self.badge_lbl.setStyleSheet(f"background-color: {border_color}; border-radius: 4px; color: white; font-weight: bold; font-size: 10px; padding: 2px 6px;")
        header_layout.addWidget(self.badge_lbl)
        header_layout.addWidget(self.issue_badge)

        # Name
        self.title_lbl = QLabel(step.name or "(Unnamed Step)")
        self.title_lbl.setObjectName("CardTitle")
        header_layout.addWidget(self.title_lbl)

        header_layout.addStretch()

        # Reordering & control buttons — use QStyle standard icons so they
        # render natively across Windows/Linux Qt themes instead of relying
        # on Unicode glyph font fallbacks (▲, ⎘, 🗑 etc.).
        style = self.style()

        self.up_btn = QPushButton()
        self.up_btn.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_ArrowUp))
        self.up_btn.setObjectName("CardBtn")
        self.up_btn.setToolTip("Move Step Up")
        self.up_btn.clicked.connect(self._move_up)
        header_layout.addWidget(self.up_btn)

        self.down_btn = QPushButton()
        self.down_btn.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
        self.down_btn.setObjectName("CardBtn")
        self.down_btn.setToolTip("Move Step Down")
        self.down_btn.clicked.connect(self._move_down)
        header_layout.addWidget(self.down_btn)

        self.dup_btn = QPushButton()
        self.dup_btn.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView))
        self.dup_btn.setObjectName("CardBtn")
        self.dup_btn.setToolTip("Duplicate Step (Ctrl+D)")
        self.dup_btn.clicked.connect(self._duplicate)
        header_layout.addWidget(self.dup_btn)

        self.del_btn = QPushButton()
        self.del_btn.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_TrashIcon))
        self.del_btn.setObjectName("CardBtn")
        self.del_btn.setToolTip("Delete Step (Del)")
        self.del_btn.clicked.connect(self._delete)
        header_layout.addWidget(self.del_btn)

        main_layout.addLayout(header_layout)

        # Details row
        self.detail_lbl = QLabel()
        self.detail_lbl.setObjectName("CardDetail")
        self.detail_lbl.setWordWrap(True)
        main_layout.addWidget(self.detail_lbl)

        self.update_display()

    def update_display(self) -> None:
        friendly_type = self.editor._friendly_step_type_label(self.step.step_type)
        self.title_lbl.setText(self.step.name or f"(Untitled {friendly_type})")

        desc = (self.step.description or "").strip()
        tooltip_lines: List[str] = []
        if desc:
            tooltip_lines.append(desc)
        else:
            tooltip_lines.append(f"{friendly_type}: {self.step.name or self.step.id}")

        issues = self.editor._issues_by_step.get(self.step.id, [])
        if issues:
            self.issue_badge.setText(f"! {len(issues)}")
            self.issue_badge.setToolTip("\n".join(issues))
            self.issue_badge.setVisible(True)
            tooltip_lines.append("")
            tooltip_lines.append("Issues:")
            tooltip_lines.extend(f"• {issue}" for issue in issues)
        else:
            self.issue_badge.setVisible(False)
            self.issue_badge.setToolTip("")

        self.setToolTip("\n".join(tooltip_lines))
        self.detail_lbl.setText(self.editor._get_step_detail_only(self.step))

        if not self.step.enabled:
            self.title_lbl.setText(self.title_lbl.text() + " [disabled]")
            self.setGraphicsEffect(QGraphicsOpacityEffect(self))
            self.graphicsEffect().setOpacity(0.5)
        else:
            self.setGraphicsEffect(None)

    def mousePressEvent(self, event) -> None:
        self.editor._select_step_in_blocks(self.step.id)
        super().mousePressEvent(event)

    def contextMenuEvent(self, event) -> None:
        self.editor._select_step_in_blocks(self.step.id)
        menu = QMenu(self)

        act_dup = menu.addAction("Duplicate")
        act_delete = menu.addAction("Delete")
        menu.addSeparator()
        act_up = menu.addAction("Move Up")
        act_down = menu.addAction("Move Down")
        menu.addSeparator()

        insert_above_menu = menu.addMenu("Insert step above")
        insert_below_menu = menu.addMenu("Insert step below")
        insert_inside_menu = None
        if self.step.step_type in (
            WorkflowDefinitionStepType.FOR_LOOP,
            WorkflowDefinitionStepType.IF_ELSE,
            WorkflowDefinitionStepType.TRY_CATCH,
        ):
            insert_inside_menu = menu.addMenu("Insert step inside")

        insert_actions: Dict[Any, tuple] = {}
        for submenu, bucket in (
            (insert_above_menu, "above"),
            (insert_below_menu, "below"),
        ):
            for label, step_type in self.editor._insert_menu_options():
                action = submenu.addAction(label)
                insert_actions[action] = (bucket, step_type)
        if insert_inside_menu is not None:
            for label, step_type in self.editor._insert_menu_options():
                action = insert_inside_menu.addAction(label)
                insert_actions[action] = ("inside", step_type)

        chosen = exec_menu(menu, event.globalPos())
        if chosen is None:
            return
        if chosen is act_dup:
            self._duplicate()
        elif chosen is act_delete:
            self._delete()
        elif chosen is act_up:
            self._move_up()
        elif chosen is act_down:
            self._move_down()
        elif chosen in insert_actions:
            bucket, step_type = insert_actions[chosen]
            new_step = self.editor._make_new_step(step_type)
            if bucket == "inside":
                self.step.children.append(new_step)
            else:
                try:
                    idx = self.parent_list.index(self.step)
                except ValueError:
                    return
                insert_at = idx if bucket == "above" else idx + 1
                self.parent_list.insert(insert_at, new_step)
            self.editor._populate_blocks()
            self.editor._select_step_in_blocks(new_step.id)

    def _move_up(self) -> None:
        try:
            idx = self.parent_list.index(self.step)
            if idx > 0:
                self.parent_list[idx - 1], self.parent_list[idx] = self.parent_list[idx], self.parent_list[idx - 1]
                self.editor._populate_blocks()
                self.editor._select_step_in_blocks(self.step.id)
        except ValueError:
            pass

    def _move_down(self) -> None:
        try:
            idx = self.parent_list.index(self.step)
            if idx < len(self.parent_list) - 1:
                self.parent_list[idx + 1], self.parent_list[idx] = self.parent_list[idx], self.parent_list[idx + 1]
                self.editor._populate_blocks()
                self.editor._select_step_in_blocks(self.step.id)
        except ValueError:
            pass

    def _duplicate(self) -> None:
        try:
            cloned = copy.deepcopy(self.step)
            self.editor._reassign_uuids(cloned)
            cloned.name = (cloned.name + " (copy)").strip()
            idx = self.parent_list.index(self.step)
            self.parent_list.insert(idx + 1, cloned)
            self.editor._populate_blocks()
            self.editor._select_step_in_blocks(cloned.id)
        except ValueError:
            pass

    def _delete(self) -> None:
        try:
            idx = self.parent_list.index(self.step)
            self.parent_list.pop(idx)
            self.editor._populate_blocks()
            next_select = None
            if self.parent_list:
                next_select = self.parent_list[min(idx, len(self.parent_list) - 1)].id
            self.editor._select_step_in_blocks(next_select)
        except ValueError:
            pass


class WorkflowEditorDialog(QDialog):
    """Editor for reusable workflows composed of engine steps and control logic."""

    def __init__(
        self,
        *,
        workflow_definition: WorkflowDefinition,
        available_workflows: List[Dict[str, Any]],
        wizard_context: Optional[Dict[str, Any]] = None,
        parent=None,
    ):
        super().__init__(parent)
        self.workflow_definition = copy.deepcopy(workflow_definition)
        self.available_workflows = list(available_workflows or [])
        self.wizard_context = dict(wizard_context or {})
        self._updating_ui = False
        self._save_as_requested = False
        self.selected_step_id = None
        self._card_widgets = {}
        # Per-step validation issues, keyed by step.id. Refreshed every time
        # the workflow is mutated; cards read it to paint a red ! badge.
        self._issues_by_step: Dict[str, List[str]] = {}

        # JSON-snapshot undo stack (Pattern C from the plan — mirrors the
        # ArcGIS Pro port). Field edits push via a debounce timer so a
        # burst of typing collapses into a single undo step.
        self._undo_stack = WorkflowUndoStack()
        self._undo_debounce = QTimer(self)
        self._undo_debounce.setSingleShot(True)
        self._undo_debounce.setInterval(800)
        self._undo_debounce.timeout.connect(lambda: self._snapshot_for_undo("edit"))
        self._applying_undo = False  # suppress push while we re-load a snapshot

        self.setWindowTitle(self._tr_pipeline("title"))
        self.resize(1500, 800)
        self._init_ui()
        self._load_definition(self.workflow_definition)
        # Seed the stack with the loaded state so the first edit has
        # something to undo back to.
        self._undo_stack.reset(self.workflow_definition, label="opened")
        self._wire_keyboard_shortcuts()

    @staticmethod
    def _tr_pipeline(key: str, **kwargs) -> str:
        return tr(f"pipeline_builder.{key}", **kwargs)

    def _on_pipeline_run_click(self) -> None:
        if self._pipeline_run_callback is None:
            return
        try:
            self._sync_definition_fields()
            self._pipeline_run_callback(copy.deepcopy(self.workflow_definition))
        except Exception as exc:
            QMessageBox.critical(
                self,
                self._tr_pipeline("run_title"),
                self._tr_pipeline("run_failed", error=exc),
            )

    def _on_pipeline_test_click(self) -> None:
        if self._pipeline_test_callback is None:
            return
        try:
            self._sync_definition_fields()
            self._pipeline_test_callback(copy.deepcopy(self.workflow_definition))
        except Exception as exc:
            QMessageBox.critical(
                self,
                self._tr_pipeline("test_title"),
                self._tr_pipeline("test_failed", error=exc),
            )

    def _init_ui(self) -> None:
        layout = QVBoxLayout()
        self.setLayout(layout)

        self.menu_bar = QMenuBar(self)
        file_menu = self.menu_bar.addMenu(self._tr_pipeline("menu_file"))
        file_menu.addAction(self._tr_pipeline("menu_new_pipeline"), self._new_workflow)
        file_menu.addAction(self._tr_pipeline("menu_new_from_template"), self._new_workflow_from_template)
        file_menu.addAction(self._tr_pipeline("menu_open_pipeline"), self._open_workflow)
        file_menu.addSeparator()
        file_menu.addAction(self._tr_pipeline("menu_import_json"), self._import_workflow_from_json)
        file_menu.addAction(self._tr_pipeline("menu_export_json"), self._export_workflow_to_json)
        file_menu.addSeparator()
        file_menu.addAction(self._tr_pipeline("menu_save_pipeline"), self._trigger_save)
        file_menu.addAction(self._tr_pipeline("menu_save_pipeline_as"), self._trigger_save_as)

        edit_menu = self.menu_bar.addMenu(self._tr_pipeline("menu_edit"))
        self._undo_action = edit_menu.addAction(self._tr_pipeline("menu_undo") + "\tCtrl+Z", self._handle_undo)
        self._redo_action = edit_menu.addAction(self._tr_pipeline("menu_redo") + "\tCtrl+Y", self._handle_redo)
        edit_menu.addSeparator()
        edit_menu.addAction(self._tr_pipeline("menu_duplicate_step") + "\tCtrl+D", self._duplicate_selected_step)
        edit_menu.addAction(self._tr_pipeline("menu_delete_step") + "\tDel", self._delete_selected_step)
        layout.setMenuBar(self.menu_bar)

        self._pipeline_run_callback = self.wizard_context.get("pipeline_run_callback")
        self._pipeline_test_callback = self.wizard_context.get("pipeline_test_callback")
        run_toolbar = QHBoxLayout()
        run_toolbar.setContentsMargins(0, 0, 0, 4)
        self.btn_pipeline_test = QPushButton(self._tr_pipeline("btn_test"))
        self.btn_pipeline_test.setToolTip(self._tr_pipeline("tip_test"))
        self.btn_pipeline_test.clicked.connect(self._on_pipeline_test_click)
        self.btn_pipeline_run = QPushButton(self._tr_pipeline("btn_run"))
        self.btn_pipeline_run.setToolTip(self._tr_pipeline("tip_run"))
        self.btn_pipeline_run.clicked.connect(self._on_pipeline_run_click)
        can_execute = (
            self._pipeline_run_callback is not None
            and self.wizard_context.get("provenance") is not None
        )
        self.btn_pipeline_test.setEnabled(can_execute)
        self.btn_pipeline_run.setEnabled(can_execute)
        run_toolbar.addWidget(self.btn_pipeline_test)
        run_toolbar.addWidget(self.btn_pipeline_run)
        run_toolbar.addStretch()
        layout.addLayout(run_toolbar)

        definition_form = QFormLayout()
        self.definition_name_input = QLineEdit()
        self.definition_name_input.textChanged.connect(self._sync_definition_fields)
        definition_form.addRow(self._tr_pipeline("lbl_pipeline_name"), self.definition_name_input)

        self.definition_description_input = QTextEdit()
        self.definition_description_input.setMaximumHeight(90)
        self.definition_description_input.textChanged.connect(self._sync_definition_fields)
        definition_form.addRow(self._tr_pipeline("lbl_description"), self.definition_description_input)
        layout.addLayout(definition_form)

        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        layout.addWidget(self.splitter, 1)

        # --- Left pane: Pipeline Steps (cards-list, editor of record) -------
        left_host = QWidget(self)
        left_layout = QVBoxLayout()
        left_host.setLayout(left_layout)
        self.splitter.addWidget(left_host)

        left_layout.addWidget(QLabel(self._tr_pipeline("lbl_pipeline_steps")))
        self.scroll_area = QScrollArea()
        self.scroll_area.setObjectName("WorkflowBlocksScrollArea")
        self.scroll_area.setWidgetResizable(True)

        self.blocks_container = QWidget()
        self.blocks_container.setObjectName("BlocksContainer")
        self.blocks_layout = QVBoxLayout()
        self.blocks_layout.setContentsMargins(10, 10, 10, 10)
        self.blocks_layout.setSpacing(6)
        self.blocks_container.setLayout(self.blocks_layout)

        self.scroll_area.setWidget(self.blocks_container)
        left_layout.addWidget(self.scroll_area, 1)

        self.setStyleSheet(BLOCKS_STYLESHEET)

        # --- Middle pane: tabbed Properties + Execution Preview -------------
        # Properties sits immediately adjacent to the cards-list so users
        # don't eye-zigzag across a graph pane on every selection. Execution
        # Preview lives as a sibling tab in the same pane; the graph moves
        # to the rightmost (collapsible) pane.
        middle_host = QWidget(self)
        middle_layout = QVBoxLayout()
        middle_layout.setContentsMargins(0, 0, 0, 0)
        middle_host.setLayout(middle_layout)
        self.splitter.addWidget(middle_host)

        self.properties_tabs = QTabWidget(self)
        middle_layout.addWidget(self.properties_tabs)

        # --- Properties tab -------------------------------------------------
        right_host = QWidget(self)
        right_layout = QVBoxLayout()
        right_host.setLayout(right_layout)
        self.properties_tabs.addTab(right_host, self._tr_pipeline("tab_step_properties"))

        # R3-3b: ancestor-path breadcrumb. For a step nested inside
        # branches, shows where it lives, e.g.
        # "Workflow > IF-1 (THEN) > FOR-2 (DO)". Hidden for top-level steps.
        self.step_path_label = QLabel("")
        self.step_path_label.setStyleSheet(
            "color: #a6adc8; font-style: italic; font-size: 11px; padding: 2px 0px;"
        )
        self.step_path_label.setWordWrap(True)
        self.step_path_label.setVisible(False)
        right_layout.addWidget(self.step_path_label)

        self.empty_properties_label = QLabel(self._tr_pipeline("msg_select_step"))
        self.empty_properties_label.setWordWrap(True)
        right_layout.addWidget(self.empty_properties_label)

        self.properties_host = QWidget(self)
        self.properties_layout = QFormLayout()
        self.properties_host.setLayout(self.properties_layout)
        right_layout.addWidget(self.properties_host)

        self.step_name_input = QLineEdit()
        self.step_name_input.textChanged.connect(self._sync_selected_step)
        self.properties_layout.addRow("Step Name", self.step_name_input)

        self.step_description_input = QTextEdit()
        self.step_description_input.setPlaceholderText(
            "Optional notes about this step — purpose, caveats, author, etc. Shown as tree tooltip."
        )
        self.step_description_input.setMaximumHeight(70)
        self.step_description_input.textChanged.connect(self._sync_selected_step)
        self.properties_layout.addRow("Description", self.step_description_input)

        self.step_enabled_checkbox = QCheckBox("Enabled")
        self.step_enabled_checkbox.toggled.connect(self._sync_selected_step)
        self.properties_layout.addRow("", self.step_enabled_checkbox)

        self.step_continue_on_error_checkbox = QCheckBox("Continue on error (skip this step if it fails)")
        self.step_continue_on_error_checkbox.toggled.connect(self._sync_selected_step)
        self.properties_layout.addRow("", self.step_continue_on_error_checkbox)

        self.step_type_label = QLabel("")
        self.properties_layout.addRow("Step Type", self.step_type_label)

        self.step_config_stack = QStackedWidget()
        self.properties_layout.addRow("Configuration", self.step_config_stack)

        self._build_task_page()
        self._build_variable_page()
        self._build_loop_page()
        self._build_if_page()
        self._build_log_page()
        self._build_try_catch_page()
        self._build_empty_page()

        self.step_notes = QLabel(
            "Task steps launch a specific EMDS engine; use 'Configure with Wizard...' to fill in "
            "engine-specific details (model, mappings, budgets, etc.). "
            "Variable steps declare workflow inputs or manage local runtime values. "
            "If / Else steps branch into explicit Then and Else blocks. "
            "Log steps emit a message to the execution log for debugging or audit. "
            "Try / Catch steps protect a block of children — if any child fails, execution "
            "continues in the Catch block and the error message is stored in a variable. "
            "Enable 'Continue on error' on any step to swallow failures without a catch block."
        )
        self.step_notes.setWordWrap(True)
        right_layout.addWidget(self.step_notes)
        right_layout.addStretch(1)

        # --- Execution Preview tab (moved out of the cards pane) ------------
        preview_host = QWidget(self)
        preview_layout = QVBoxLayout()
        preview_host.setLayout(preview_layout)
        self.properties_tabs.addTab(preview_host, self._tr_pipeline("tab_execution_preview"))

        preview_header = QHBoxLayout()
        preview_header.addStretch()
        self.btn_simulate = QPushButton(self._tr_pipeline("btn_run_mock"))
        self.btn_simulate.clicked.connect(self._simulate_workflow)
        preview_header.addWidget(self.btn_simulate)
        self.btn_simulate_selected = QPushButton(self._tr_pipeline("btn_run_selected_step"))
        self.btn_simulate_selected.clicked.connect(self._simulate_selected_step)
        preview_header.addWidget(self.btn_simulate_selected)
        preview_layout.addLayout(preview_header)

        self.execution_preview = QTextEdit()
        self.execution_preview.setReadOnly(True)
        preview_layout.addWidget(self.execution_preview, 1)

        # --- Right pane: read-only graph preview (collapsible) --------------
        # The cards-list on the left is the editor of record; the graph gives
        # a top-down picture of branches, loops, and try/catch blocks.
        # Clicking a node selects the matching card, and selecting a card
        # highlights the matching graph node.
        graph_host = QWidget(self)
        graph_layout = QVBoxLayout()
        graph_host.setLayout(graph_layout)
        self.splitter.addWidget(graph_host)

        graph_header = QHBoxLayout()
        graph_header.addWidget(QLabel(self._tr_pipeline("lbl_pipeline_graph")))
        graph_header.addStretch()
        self.btn_refresh_graph = QPushButton(self._tr_pipeline("btn_refresh"))
        self.btn_refresh_graph.setToolTip(self._tr_pipeline("tip_refresh_graph"))
        self.btn_refresh_graph.clicked.connect(self._refresh_workflow_graph)
        graph_header.addWidget(self.btn_refresh_graph)
        graph_layout.addLayout(graph_header)

        self.workflow_graph_view = WorkflowGraphView(self)
        self.workflow_graph_view.nodeClicked.connect(self._on_graph_node_clicked)
        graph_layout.addWidget(self.workflow_graph_view, 1)

        graph_legend = QLabel(self._tr_pipeline("graph_legend"))
        graph_legend.setWordWrap(True)
        graph_layout.addWidget(graph_legend)

        # Cards and properties are always visible; the graph can be collapsed
        # via the splitter handle when users want more room for editing.
        self.splitter.setCollapsible(0, False)
        self.splitter.setCollapsible(1, False)
        self.splitter.setCollapsible(2, True)
        self.splitter.setStretchFactor(0, 2)
        self.splitter.setStretchFactor(1, 3)
        self.splitter.setStretchFactor(2, 2)

        # Restore user-preferred splitter geometry across sessions; otherwise
        # fall back to a 35% / 40% / 25% split (cards / properties / graph).
        self._splitter_settings_key = "emds/workflow_editor/splitter_state_v4"
        if not self._restore_splitter_state():
            self.splitter.setSizes([525, 600, 375])
        self.splitter.splitterMoved.connect(self._save_splitter_state)

        button_row = QHBoxLayout()
        # Validation pill — hidden when there are no issues. Clicking it
        # walks to the next offending card so users don't have to scan a
        # wall of error text.
        self.validation_pill = QPushButton("")
        self.validation_pill.setVisible(False)
        self.validation_pill.setStyleSheet(
            "QPushButton { background-color: #e78284; color: #1e1e2e; "
            "border-radius: 10px; padding: 4px 12px; font-weight: bold; }"
            "QPushButton:hover { background-color: #f38ba8; }"
        )
        self.validation_pill.clicked.connect(self._jump_to_next_issue)
        button_row.addWidget(self.validation_pill)
        button_row.addStretch()
        cancel_button = QPushButton(self._tr_pipeline("btn_cancel"))
        cancel_button.clicked.connect(self.reject)
        button_row.addWidget(cancel_button)
        save_button = QPushButton(self._tr_pipeline("btn_save"))
        save_button.clicked.connect(self._trigger_save)
        button_row.addWidget(save_button)
        layout.addLayout(button_row)

    def _restore_splitter_state(self) -> bool:
        try:
            settings = QSettings()
            state = settings.value(self._splitter_settings_key)
        except Exception:
            return False
        if not state:
            return False
        try:
            return bool(self.splitter.restoreState(state))
        except Exception:
            return False

    def _save_splitter_state(self, *_args) -> None:
        try:
            settings = QSettings()
            settings.setValue(self._splitter_settings_key, self.splitter.saveState())
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Undo / redo + keyboard shortcuts
    # ------------------------------------------------------------------
    def _wire_keyboard_shortcuts(self) -> None:
        QShortcut(QKeySequence("Ctrl+Z"), self, self._handle_undo)
        QShortcut(QKeySequence("Ctrl+Y"), self, self._handle_redo)
        QShortcut(QKeySequence("Ctrl+Shift+Z"), self, self._handle_redo)
        QShortcut(QKeySequence("Ctrl+D"), self, self._duplicate_selected_step)
        QShortcut(QKeySequence("Delete"), self, self._delete_selected_step)

    def _snapshot_for_undo(self, label: str) -> None:
        """Push the current workflow_definition onto the undo stack.

        Called for structural changes immediately, and for field edits via
        the debounce timer so a burst of typing collapses into one undo
        step. Suppressed while we're in the middle of applying an undo/redo.
        """
        if self._applying_undo:
            return
        self._undo_stack.push(label, self.workflow_definition)

    def _schedule_edit_snapshot(self) -> None:
        """Restart the debounce timer; snapshot fires 800ms after the last edit."""
        if self._applying_undo:
            return
        self._undo_debounce.start()

    def _handle_undo(self) -> None:
        # Flush any pending debounced field edit first so the user can undo
        # back to the state just before the typing burst.
        if self._undo_debounce.isActive():
            self._undo_debounce.stop()
            self._snapshot_for_undo("edit")
        snapshot = self._undo_stack.undo()
        if snapshot is not None:
            self._apply_snapshot(snapshot)

    def _handle_redo(self) -> None:
        snapshot = self._undo_stack.redo()
        if snapshot is not None:
            self._apply_snapshot(snapshot)

    def _apply_snapshot(self, snapshot: dict) -> None:
        """Replace the current workflow_definition with a snapshot and refresh."""
        self._applying_undo = True
        try:
            previous_selected = self.selected_step_id
            self.workflow_definition = WorkflowDefinition.from_dict(snapshot)
            self._load_definition(self.workflow_definition)
            # Best-effort: re-select the previously selected step if it still exists.
            if previous_selected and self._resolve_step_by_id(previous_selected):
                self._select_step_in_blocks(previous_selected)
        finally:
            self._applying_undo = False

    def _duplicate_selected_step(self) -> None:
        step = self._selected_step()
        if step is None:
            return
        card = self._card_widgets.get(step.id)
        if card is not None:
            card._duplicate()

    def _delete_selected_step(self) -> None:
        step = self._selected_step()
        if step is None:
            return
        card = self._card_widgets.get(step.id)
        if card is not None:
            card._delete()

    def _build_empty_page(self) -> None:
        page = QWidget(self)
        page_layout = QVBoxLayout()
        page.setLayout(page_layout)
        page_layout.addWidget(QLabel("No additional configuration for this step type."))
        self.empty_page = page
        self.step_config_stack.addWidget(page)

    def _build_task_page(self) -> None:
        page = QWidget(self)
        form = QFormLayout()
        page.setLayout(form)
        self._task_form = form
        # Rows that only apply when the selected engine is an EMDS engine
        # (NetWeaver / CDP / Portfolio). Hidden when the Python script
        # engine is selected so the user only sees relevant fields.
        self._task_engine_only_widgets: List[QWidget] = []
        # Rows that only apply when the selected engine is the Python
        # script runner — hidden for EMDS engines.
        self._task_python_only_widgets: List[QWidget] = []

        self.task_engine_combo = QComboBox()
        self.task_engine_combo.addItem("NetWeaver", EngineType.NETWEAVER.value)
        self.task_engine_combo.addItem("CDP", EngineType.CDP.value)
        self.task_engine_combo.addItem("Portfolio", EngineType.TEMPLATE_2.value)
        self.task_engine_combo.addItem("Python Script", EngineType.PYTHON_SCRIPT.value)
        self.task_engine_combo.currentIndexChanged.connect(self._on_task_engine_changed)
        self.task_engine_combo.currentIndexChanged.connect(self._sync_selected_step)
        form.addRow("Engine", self.task_engine_combo)

        # Wizard configuration — launches the same rich wizard used when adding a task
        # directly to a provenance. When no wizard context is available (editor opened
        # without a resolved assessment) the button is disabled with a tooltip.
        wizard_row = QHBoxLayout()
        self.task_configure_button = QPushButton(self._tr_pipeline("btn_configure_wizard"))
        self.task_configure_button.clicked.connect(self._launch_task_wizard)
        wizard_row.addWidget(self.task_configure_button)
        self.task_clear_wizard_button = QPushButton("Clear Wizard Config")
        self.task_clear_wizard_button.clicked.connect(self._clear_task_wizard_config)
        wizard_row.addWidget(self.task_clear_wizard_button)
        wizard_row.addStretch(1)
        wizard_host = QWidget(page)
        wizard_host.setLayout(wizard_row)
        form.addRow("Engine Config", wizard_host)
        self._task_engine_only_widgets.append(wizard_host)

        self.task_wizard_status_label = QLabel("")
        self.task_wizard_status_label.setWordWrap(True)
        form.addRow("", self.task_wizard_status_label)
        self._task_engine_only_widgets.append(self.task_wizard_status_label)

        self.task_model_input = QLineEdit()
        self.task_model_input.setPlaceholderText("Model string or file path")
        self.task_model_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Model", self.task_model_input)
        self._task_engine_only_widgets.append(self.task_model_input)

        self.task_mapping_file_input = QLineEdit()
        self.task_mapping_file_input.setPlaceholderText("CSV path with lines: modelName,databaseName")
        self.task_mapping_file_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Data Mapping CSV", self.task_mapping_file_input)
        self._task_engine_only_widgets.append(self.task_mapping_file_input)

        self.task_input_source_combo = QComboBox()
        self.task_input_source_combo.addItem("Study Output", "assessment")
        self.task_input_source_combo.addItem("Previous Task Result", "previous_result")
        self.task_input_source_combo.addItem("Named Result Variable", "named_result")
        self.task_input_source_combo.currentIndexChanged.connect(self._on_task_input_source_changed)
        self.task_input_source_combo.currentIndexChanged.connect(self._sync_selected_step)
        form.addRow("Input Source", self.task_input_source_combo)
        self._task_engine_only_widgets.append(self.task_input_source_combo)

        self.task_input_variable_input = QLineEdit()
        self.task_input_variable_input.setPlaceholderText("Stored result variable name")
        self.task_input_variable_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Input Variable", self.task_input_variable_input)
        self._task_engine_only_widgets.append(self.task_input_variable_input)

        # --- Python script engine fields -----------------------------------
        # The script runner subprocess takes the path, optional shell-style
        # args, an optional working directory, optional env var pairs, an
        # optional alternate Python interpreter, and an optional timeout.
        # All of these are persisted in the step config and consumed by
        # ``python_script_runner.run_python_script_from_step_config``.
        script_row = QHBoxLayout()
        self.task_python_script_input = QLineEdit()
        self.task_python_script_input.setPlaceholderText("Path to .py script")
        self.task_python_script_input.textChanged.connect(self._sync_selected_step)
        script_row.addWidget(self.task_python_script_input, 1)
        self.task_python_script_browse = QPushButton("Browse...")
        self.task_python_script_browse.clicked.connect(self._browse_for_python_script)
        script_row.addWidget(self.task_python_script_browse)
        script_host = QWidget(page)
        script_host.setLayout(script_row)
        form.addRow("Script Path", script_host)
        self._task_python_only_widgets.append(script_host)

        self.task_python_args_input = QLineEdit()
        self.task_python_args_input.setPlaceholderText(
            "Shell-style arguments, e.g. --threshold 0.5 --out {{output_dir}}"
        )
        self.task_python_args_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Arguments", self.task_python_args_input)
        self._task_python_only_widgets.append(self.task_python_args_input)

        cwd_row = QHBoxLayout()
        self.task_python_cwd_input = QLineEdit()
        self.task_python_cwd_input.setPlaceholderText("Optional working directory (defaults to script folder)")
        self.task_python_cwd_input.textChanged.connect(self._sync_selected_step)
        cwd_row.addWidget(self.task_python_cwd_input, 1)
        self.task_python_cwd_browse = QPushButton("Browse...")
        self.task_python_cwd_browse.clicked.connect(self._browse_for_python_cwd)
        cwd_row.addWidget(self.task_python_cwd_browse)
        cwd_host = QWidget(page)
        cwd_host.setLayout(cwd_row)
        form.addRow("Working Directory", cwd_host)
        self._task_python_only_widgets.append(cwd_host)

        self.task_python_executable_input = QLineEdit()
        self.task_python_executable_input.setPlaceholderText(
            "Optional python.exe path (defaults to the QGIS/OSGeo4W Python)"
        )
        self.task_python_executable_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Python Interpreter", self.task_python_executable_input)
        self._task_python_only_widgets.append(self.task_python_executable_input)

        self.task_python_env_input = QLineEdit()
        self.task_python_env_input.setPlaceholderText("Extra env vars, KEY=VALUE separated by ';'")
        self.task_python_env_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Environment", self.task_python_env_input)
        self._task_python_only_widgets.append(self.task_python_env_input)

        self.task_python_timeout_input = QLineEdit()
        self.task_python_timeout_input.setPlaceholderText("Optional timeout in seconds")
        self.task_python_timeout_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Timeout (s)", self.task_python_timeout_input)
        self._task_python_only_widgets.append(self.task_python_timeout_input)

        self.task_python_help_label = QLabel(
            "The script runs in its own Python subprocess. Workflow variables "
            "are delivered as a JSON object on stdin and via the "
            "EMDS_WORKFLOW_VARIABLES_JSON environment variable. "
            "Anything the script writes to stderr is streamed to the "
            "workflow log; if the script writes a JSON object to stdout it "
            "becomes the task result."
        )
        self.task_python_help_label.setWordWrap(True)
        form.addRow("", self.task_python_help_label)
        self._task_python_only_widgets.append(self.task_python_help_label)
        # -------------------------------------------------------------------

        self.task_store_result_input = QLineEdit()
        self.task_store_result_input.setPlaceholderText("Optional variable name")
        self.task_store_result_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Save result as", self.task_store_result_input)

        self.task_flow_summary_label = QLabel("")
        self.task_flow_summary_label.setWordWrap(True)
        form.addRow("Flow Summary", self.task_flow_summary_label)

        self.task_available_results_label = QLabel("")
        self.task_available_results_label.setWordWrap(True)
        form.addRow("Available Inputs", self.task_available_results_label)

        self.task_page = page
        self.step_config_stack.addWidget(page)

        self._refresh_task_wizard_availability()
        self._update_task_engine_visibility()

    def _build_variable_page(self) -> None:
        page = QWidget(self)
        form = QFormLayout()
        page.setLayout(form)

        self.variable_operation_combo = QComboBox()
        self.variable_operation_combo.addItem("Set Variable", "set")
        self.variable_operation_combo.addItem("Delete Variable", "delete")
        self.variable_operation_combo.currentIndexChanged.connect(self._on_variable_operation_changed)
        self.variable_operation_combo.currentIndexChanged.connect(self._sync_selected_step)
        form.addRow("Operation", self.variable_operation_combo)

        self.variable_scope_combo = QComboBox()
        self.variable_scope_combo.addItem("Pipeline Input (default for run)", "workflow_input")
        self.variable_scope_combo.addItem("Local Variable (this run only)", "local")
        self.variable_scope_combo.addItem("Project (persists across runs)", "project")
        self.variable_scope_combo.addItem("Assessment (persists in assessment)", "assessment")
        self.variable_scope_combo.currentIndexChanged.connect(self._sync_selected_step)
        form.addRow("Variable Scope", self.variable_scope_combo)

        self.variable_name_input = QLineEdit()
        self.variable_name_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Variable Name", self.variable_name_input)

        self.variable_value_input = QLineEdit()
        self.variable_value_input.setPlaceholderText(
            "Default value, {{token}}, or {{= expression }}"
        )
        self.variable_value_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Value / Default", self.variable_value_input)

        self.variable_scope_note = QLabel("")
        self.variable_scope_note.setWordWrap(True)
        form.addRow("", self.variable_scope_note)

        self.variable_page = page
        self.step_config_stack.addWidget(page)

    def _build_loop_page(self) -> None:
        page = QWidget(self)
        form = QFormLayout()
        page.setLayout(form)

        self.loop_type_combo = QComboBox()
        self.loop_type_combo.addItem("Numeric Range (start/end/step)", "range")
        self.loop_type_combo.addItem("For Each (iterate over a list)", "each")
        self.loop_type_combo.addItem("For each feature in a QGIS layer", "each_feature")
        self.loop_type_combo.currentIndexChanged.connect(self._on_loop_type_changed)
        self.loop_type_combo.currentIndexChanged.connect(self._sync_selected_step)
        form.addRow("Repeat style", self.loop_type_combo)

        self.loop_variable_input = QLineEdit()
        self.loop_variable_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Each item is called", self.loop_variable_input)

        # Range-specific fields
        self.loop_start_input = QLineEdit()
        self.loop_start_input.setPlaceholderText("1")
        self.loop_start_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Start", self.loop_start_input)

        self.loop_end_input = QLineEdit()
        self.loop_end_input.setPlaceholderText("5")
        self.loop_end_input.textChanged.connect(self._sync_selected_step)
        form.addRow("End", self.loop_end_input)

        self.loop_step_input = QLineEdit()
        self.loop_step_input.setPlaceholderText("1")
        self.loop_step_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Step", self.loop_step_input)

        # For-each-specific fields
        self.loop_list_value_input = QLineEdit()
        self.loop_list_value_input.setPlaceholderText(
            "e.g. {{my_list}} — or a literal: a, b, c — or a JSON array: [1, 2, 3]"
        )
        self.loop_list_value_input.textChanged.connect(self._sync_selected_step)
        form.addRow("List Value", self.loop_list_value_input)

        self.loop_index_variable_input = QLineEdit()
        self.loop_index_variable_input.setPlaceholderText("Optional: variable to receive the iteration index")
        self.loop_index_variable_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Position number is called", self.loop_index_variable_input)

        # "For each feature in a QGIS layer" — analyst-friendly loop that
        # iterates real features instead of free-text {{var}} interpolation.
        self.loop_layer_input = QLineEdit()
        self.loop_layer_input.setPlaceholderText("QGIS layer name (e.g. watersheds) — defaults to the assessment input layer")
        self.loop_layer_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Layer to iterate", self.loop_layer_input)

        self.loop_feature_filter_input = QLineEdit()
        self.loop_feature_filter_input.setPlaceholderText(
            "Optional QGIS expression to filter features, e.g. \"area_ha\" > 100"
        )
        self.loop_feature_filter_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Filter expression", self.loop_feature_filter_input)

        self.loop_page = page
        self.step_config_stack.addWidget(page)

    def _build_if_page(self) -> None:
        page = QWidget(self)
        form = QFormLayout()
        page.setLayout(form)

        self.if_variable_input = QLineEdit()
        self.if_variable_input.setPlaceholderText("Runtime variable name")
        self.if_variable_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Variable", self.if_variable_input)

        self.if_operator_combo = QComboBox()
        for label, value, _preview in _IF_OPERATORS:
            self.if_operator_combo.addItem(label, value)
        self.if_operator_combo.currentIndexChanged.connect(self._on_if_operator_changed)
        self.if_operator_combo.currentIndexChanged.connect(self._sync_selected_step)
        form.addRow("Operator", self.if_operator_combo)

        self.if_expected_value_input = QLineEdit()
        self.if_expected_value_input.setPlaceholderText("Comparison value")
        self.if_expected_value_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Compare To", self.if_expected_value_input)

        self.if_expected_value_2_input = QLineEdit()
        self.if_expected_value_2_input.setPlaceholderText("Upper bound (Between only)")
        self.if_expected_value_2_input.textChanged.connect(self._sync_selected_step)
        form.addRow("And (upper)", self.if_expected_value_2_input)

        self.if_branch_help_label = QLabel("")
        self.if_branch_help_label.setWordWrap(True)
        form.addRow("Branches", self.if_branch_help_label)

        self.if_page = page
        self.step_config_stack.addWidget(page)

    def _build_log_page(self) -> None:
        page = QWidget(self)
        form = QFormLayout()
        page.setLayout(form)

        self.log_level_combo = QComboBox()
        self.log_level_combo.addItem("Debug", "debug")
        self.log_level_combo.addItem("Info", "info")
        self.log_level_combo.addItem("Warning", "warning")
        self.log_level_combo.addItem("Error", "error")
        self.log_level_combo.currentIndexChanged.connect(self._sync_selected_step)
        form.addRow("Level", self.log_level_combo)

        self.log_message_input = QLineEdit()
        self.log_message_input.setPlaceholderText(
            "Message (supports {{variable}} and {{= expression }} substitution)"
        )
        self.log_message_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Message", self.log_message_input)

        help_label = QLabel(
            "Log steps append the resolved message to the execution log. "
            "Use {{variable_name}} to substitute workflow variables, or "
            "{{= expression }} to evaluate a safe Python expression "
            "(e.g. {{= len(my_list) }}, {{= count + 1 }})."
        )
        help_label.setWordWrap(True)
        form.addRow("", help_label)

        self.log_page = page
        self.step_config_stack.addWidget(page)

    def _build_try_catch_page(self) -> None:
        page = QWidget(self)
        form = QFormLayout()
        page.setLayout(form)

        self.try_catch_error_variable_input = QLineEdit()
        self.try_catch_error_variable_input.setPlaceholderText("last_error")
        self.try_catch_error_variable_input.textChanged.connect(self._sync_selected_step)
        form.addRow("Error Variable", self.try_catch_error_variable_input)

        self.try_catch_branch_help_label = QLabel("")
        form.addRow("", self.try_catch_branch_help_label)

        help_label = QLabel(
            "Try block steps are in 'Try' children. If any step in the try block raises an error, "
            "execution jumps to the Catch block. The error message is stored in the Error Variable "
            "so catch-block steps (e.g. a Log step) can reference {{last_error}}."
        )
        help_label.setWordWrap(True)
        form.addRow("", help_label)

        self.try_catch_page = page
        self.step_config_stack.addWidget(page)

    def _load_definition(self, workflow_definition: WorkflowDefinition) -> None:
        self.workflow_definition = copy.deepcopy(workflow_definition)
        self._save_as_requested = False
        self._updating_ui = True
        try:
            self.definition_name_input.setText(self.workflow_definition.name)
            self.definition_description_input.setPlainText(self.workflow_definition.description)
            self.selected_step_id = None
            self._populate_blocks()
        finally:
            self._updating_ui = False

    def _new_workflow(self) -> None:
        self._load_definition(WorkflowDefinition())

    def _new_workflow_from_template(self) -> None:
        """Load a starter pipeline from emds_qgis_plugin/workflow_templates/.

        Templates are JSON files shipped with the plugin. They're loaded
        the same way as Import, but with a friendly picker instead of a
        file dialog so analysts don't need to know the install path.
        """
        templates_dir = Path(__file__).resolve().parent.parent / "workflow_templates"
        if not templates_dir.is_dir():
            QMessageBox.information(
                self,
                "New from Template",
                f"No templates directory found at:\n{templates_dir}",
            )
            return

        template_paths = sorted(templates_dir.glob("*.json"))
        if not template_paths:
            QMessageBox.information(
                self,
                "New from Template",
                f"No template JSON files found in:\n{templates_dir}",
            )
            return

        # Read each template's display name for the picker.
        picker_entries: List[tuple] = []
        for path in template_paths:
            try:
                with open(path, "r", encoding="utf-8") as handle:
                    payload = json.load(handle)
                label = str(payload.get("name") or path.stem)
                description = str(payload.get("description") or "")
            except Exception:
                label = path.stem
                description = ""
                payload = None
            picker_entries.append((label, description, path, payload))

        labels = [entry[0] for entry in picker_entries]
        choice_label, accepted = QInputDialog.getItem(
            self,
            "New from Template",
            "Start from which template?",
            labels,
            0,
            False,
        )
        if not accepted:
            return

        match = next((e for e in picker_entries if e[0] == choice_label), None)
        if match is None:
            return
        _label, _description, path, payload = match
        if payload is None:
            try:
                with open(path, "r", encoding="utf-8") as handle:
                    payload = json.load(handle)
            except Exception as exc:
                QMessageBox.critical(self, "New from Template", f"Could not read template:\n{exc}")
                return

        try:
            definition = WorkflowDefinition.from_dict(payload)
        except Exception as exc:
            QMessageBox.critical(self, "New from Template", f"Template is not a valid pipeline:\n{exc}")
            return

        # Fresh IDs so a template can be instantiated many times without
        # colliding with prior loads.
        definition.id = str(uuid.uuid4())
        self._reassign_uuids_in_definition(definition)
        self._load_definition(definition)

    @staticmethod
    def _reassign_uuids_in_definition(definition: WorkflowDefinition) -> None:
        """Walk every step in the definition and give each a fresh UUID."""
        def _walk(steps: List[WorkflowDefinitionStep]) -> None:
            for step in steps or []:
                step.id = str(uuid.uuid4())
                _walk(step.children or [])
                _walk(step.else_children or [])
        _walk(definition.steps)

    def _open_workflow(self) -> None:
        if not self.available_workflows:
            QMessageBox.information(
                self,
                self._tr_pipeline("title"),
                self._tr_pipeline("msg_no_saved_pipelines"),
            )
            return

        labels = [str(item.get("name") or "(Unnamed pipeline)") for item in self.available_workflows]
        selected_label, accepted = QInputDialog.getItem(
            self,
            "Open Pipeline",
            "Select a saved pipeline:",
            labels,
            0,
            False,
        )
        if not accepted:
            return

        selected = next(
            (item for item in self.available_workflows if str(item.get("name") or "") == str(selected_label)),
            None,
        )
        if selected is None:
            return

        payload = dict(selected.get("payload", {}) or {})
        self._load_definition(WorkflowDefinition.from_dict(payload))

    def _trigger_save(self) -> None:
        self._save_as_requested = False
        self.accept()

    def _trigger_save_as(self) -> None:
        self._save_as_requested = True
        self.accept()

    def _import_workflow_from_json(self) -> None:
        """Load a workflow definition from a JSON file on disk."""
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Import Pipeline from JSON",
            "",
            "JSON files (*.json);;All files (*.*)",
        )
        if not path:
            return
        try:
            with open(path, "r", encoding="utf-8") as handle:
                payload = json.load(handle)
        except OSError as exc:
            QMessageBox.critical(self, "Import Pipeline", f"Could not read the file:\n\n{exc}")
            return
        except json.JSONDecodeError as exc:
            QMessageBox.critical(self, "Import Pipeline", f"The file is not valid JSON:\n\n{exc}")
            return

        if not isinstance(payload, dict):
            QMessageBox.critical(
                self,
                "Import Pipeline",
                "Expected the JSON root to be an object describing a WorkflowDefinition.",
            )
            return

        # Assign a fresh ID so an import never overwrites the source workflow
        imported = WorkflowDefinition.from_dict(payload)
        imported.id = str(uuid.uuid4())
        if not imported.name:
            imported.name = Path(path).stem
        self._load_definition(imported)
        QMessageBox.information(
            self,
            "Import Pipeline",
            f"Imported '{imported.name}' ({len(imported.steps)} top-level step(s)) from:\n{path}",
        )

    def _export_workflow_to_json(self) -> None:
        """Save the current in-memory workflow definition to a JSON file on disk."""
        # Flush any pending edits into the in-memory definition first
        self._sync_definition_fields()
        if not self.workflow_definition.steps and not self.workflow_definition.name.strip():
            QMessageBox.information(
                self,
                "Export Pipeline",
                "The pipeline is empty — add a name and at least one step before exporting.",
            )
            return

        suggested_name = (self.workflow_definition.name.strip() or "workflow").replace(" ", "_")
        default_path = str(Path.home() / f"{suggested_name}.json")
        path, _ = QFileDialog.getSaveFileName(
            self,
            "Export Pipeline to JSON",
            default_path,
            "JSON files (*.json);;All files (*.*)",
        )
        if not path:
            return

        payload = self.workflow_definition.to_dict()
        try:
            with open(path, "w", encoding="utf-8") as handle:
                json.dump(payload, handle, indent=2, sort_keys=False)
        except OSError as exc:
            QMessageBox.critical(self, "Export Pipeline", f"Could not write the file:\n\n{exc}")
            return

        QMessageBox.information(
            self,
            "Export Pipeline",
            f"Exported '{self.workflow_definition.name or '(unnamed)'}' to:\n{path}",
        )

    def _populate_tree(self) -> None:
        self._populate_blocks()

    def _populate_blocks(self) -> None:
        # Preserve scroll position across the rebuild so users don't lose
        # their place after a reorder/insert/delete. (Full diff-based reuse
        # of card widgets is a future optimization; scroll preservation is
        # the highest-perceived-impact piece of it.)
        try:
            prior_scroll = self.scroll_area.verticalScrollBar().value()
        except Exception:
            prior_scroll = 0

        self._card_widgets.clear()
        self._clear_layout(self.blocks_layout)

        self._build_block_list(self.workflow_definition.steps, self.blocks_layout)
        self.blocks_layout.addStretch(1)

        # Structural change — push immediately (skip when re-applying an undo).
        self._snapshot_for_undo("structural change")

        # Restore scroll position after re-layout settles.
        try:
            QTimer.singleShot(0, lambda: self.scroll_area.verticalScrollBar().setValue(prior_scroll))
        except Exception:
            pass

        # Select the first step if possible and nothing is currently selected
        if self.workflow_definition.steps:
            if not self.selected_step_id or self.selected_step_id not in self._card_widgets:
                self._select_step_in_blocks(self.workflow_definition.steps[0].id)
            else:
                self._select_step_in_blocks(self.selected_step_id)
        else:
            self._select_step_in_blocks(None)

        self._refresh_workflow_graph()
        self._refresh_execution_preview()

    def _clear_layout(self, layout) -> None:
        if layout is not None:
            while layout.count():
                item = layout.takeAt(0)
                widget = item.widget()
                if widget is not None:
                    widget.deleteLater()
                else:
                    self._clear_layout(item.layout())

    def _build_block_list(
        self,
        steps_list: List[WorkflowDefinitionStep],
        parent_layout: QVBoxLayout,
        parent_step: Optional[WorkflowDefinitionStep] = None,
        branch_name: Optional[str] = None,
    ) -> None:
        # First, add the insertion button at index 0
        insert_btn = WorkflowInsertButton(steps_list, 0, self)
        parent_layout.addWidget(insert_btn)

        # Empty-branch ghost hint: when the list has no real steps, render a
        # muted label so an empty THEN / ELSE / LOOP body doesn't read as
        # broken. The leading [+] above (and the trailing [+] below) are
        # still the entry points.
        if not steps_list:
            empty_hint = QLabel("(empty — click + to add a step here)")
            empty_hint.setStyleSheet(
                "color: #6c7086; font-style: italic; font-size: 10px; padding: 4px 8px;"
            )
            parent_layout.addWidget(empty_hint)
            insert_btn_tail = WorkflowInsertButton(steps_list, 0, self)
            parent_layout.addWidget(insert_btn_tail)
            return

        for idx, step in enumerate(steps_list):
            # Create a card for this step
            card = WorkflowBlockCard(step, steps_list, self)
            self._card_widgets[step.id] = card
            parent_layout.addWidget(card)

            # If the step is a container, build its sub-blocks
            if step.step_type == WorkflowDefinitionStepType.FOR_LOOP:
                container = WorkflowContainer(self)
                container.setObjectName("LoopContainer")
                container_layout = QVBoxLayout()
                container_layout.setContentsMargins(10, 0, 0, 0)
                container.setLayout(container_layout)
                parent_layout.addWidget(container)

                # "↻ LOOP BODY" branch header for parity with IF/TRY layouts.
                loop_hdr = QFrame()
                loop_hdr.setObjectName("EndBlockCard")
                loop_hdr_lay = QHBoxLayout()
                loop_hdr_lay.setContentsMargins(8, 4, 8, 4)
                loop_hdr.setLayout(loop_hdr_lay)
                loop_lbl = QLabel("↻ LOOP BODY")
                loop_lbl.setObjectName("EndBlockLabel")
                loop_hdr_lay.addWidget(loop_lbl)
                container_layout.addWidget(loop_hdr)

                self._build_block_list(step.children, container_layout, parent_step=step, branch_name="then")

                end_card = QFrame()
                end_card.setObjectName("EndBlockCard")
                end_layout = QHBoxLayout()
                end_layout.setContentsMargins(8, 4, 8, 4)
                end_card.setLayout(end_layout)

                end_label = QLabel(f"↩ END FOR LOOP ({step.name or step.id})")
                end_label.setObjectName("EndBlockLabel")
                end_layout.addWidget(end_label)
                end_layout.addStretch()
                parent_layout.addWidget(end_card)

            elif step.step_type == WorkflowDefinitionStepType.IF_ELSE:
                then_container = WorkflowContainer(self)
                then_container.setObjectName("ThenContainer")
                then_layout = QVBoxLayout()
                then_layout.setContentsMargins(10, 0, 0, 0)
                then_container.setLayout(then_layout)
                parent_layout.addWidget(then_container)

                then_hdr = QFrame()
                then_hdr.setObjectName("EndBlockCard")
                then_hdr_lay = QHBoxLayout()
                then_hdr_lay.setContentsMargins(8, 4, 8, 4)
                then_hdr.setLayout(then_hdr_lay)
                then_lbl = QLabel("▶ THEN BRANCH")
                then_lbl.setObjectName("EndBlockLabel")
                then_hdr_lay.addWidget(then_lbl)
                then_layout.addWidget(then_hdr)

                self._build_block_list(step.children, then_layout, parent_step=step, branch_name="then")

                # Per-branch closing marker — makes it unambiguous where THEN
                # ends before ELSE BRANCH begins.
                then_end = QFrame()
                then_end.setObjectName("EndBlockCard")
                then_end_lay = QHBoxLayout()
                then_end_lay.setContentsMargins(8, 4, 8, 4)
                then_end.setLayout(then_end_lay)
                then_end_lbl = QLabel("└ END THEN")
                then_end_lbl.setObjectName("EndBlockLabel")
                then_end_lay.addWidget(then_end_lbl)
                then_layout.addWidget(then_end)

                else_container = WorkflowContainer(self)
                else_container.setObjectName("ElseContainer")
                else_layout = QVBoxLayout()
                else_layout.setContentsMargins(10, 0, 0, 0)
                else_container.setLayout(else_layout)
                parent_layout.addWidget(else_container)

                else_hdr = QFrame()
                else_hdr.setObjectName("EndBlockCard")
                else_hdr_lay = QHBoxLayout()
                else_hdr_lay.setContentsMargins(8, 4, 8, 4)
                else_hdr.setLayout(else_hdr_lay)
                else_lbl = QLabel("▶ ELSE BRANCH")
                else_lbl.setObjectName("EndBlockLabel")
                else_hdr_lay.addWidget(else_lbl)
                else_layout.addWidget(else_hdr)

                self._build_block_list(step.else_children, else_layout, parent_step=step, branch_name="else")

                else_end = QFrame()
                else_end.setObjectName("EndBlockCard")
                else_end_lay = QHBoxLayout()
                else_end_lay.setContentsMargins(8, 4, 8, 4)
                else_end.setLayout(else_end_lay)
                else_end_lbl = QLabel("└ END ELSE")
                else_end_lbl.setObjectName("EndBlockLabel")
                else_end_lay.addWidget(else_end_lbl)
                else_layout.addWidget(else_end)

                end_card = QFrame()
                end_card.setObjectName("EndBlockCard")
                end_layout = QHBoxLayout()
                end_layout.setContentsMargins(8, 4, 8, 4)
                end_card.setLayout(end_layout)

                end_label = QLabel(f"↩ END IF / ELSE ({step.name or step.id})")
                end_label.setObjectName("EndBlockLabel")
                end_layout.addWidget(end_label)
                end_layout.addStretch()
                parent_layout.addWidget(end_card)

            elif step.step_type == WorkflowDefinitionStepType.TRY_CATCH:
                try_container = WorkflowContainer(self)
                try_container.setObjectName("TryContainer")
                try_layout = QVBoxLayout()
                try_layout.setContentsMargins(10, 0, 0, 0)
                try_container.setLayout(try_layout)
                parent_layout.addWidget(try_container)

                try_hdr = QFrame()
                try_hdr.setObjectName("EndBlockCard")
                try_hdr_lay = QHBoxLayout()
                try_hdr_lay.setContentsMargins(8, 4, 8, 4)
                try_hdr.setLayout(try_hdr_lay)
                try_lbl = QLabel("🛡 TRY BLOCK")
                try_lbl.setObjectName("EndBlockLabel")
                try_hdr_lay.addWidget(try_lbl)
                try_layout.addWidget(try_hdr)

                self._build_block_list(step.children, try_layout, parent_step=step, branch_name="then")

                try_end = QFrame()
                try_end.setObjectName("EndBlockCard")
                try_end_lay = QHBoxLayout()
                try_end_lay.setContentsMargins(8, 4, 8, 4)
                try_end.setLayout(try_end_lay)
                try_end_lbl = QLabel("└ END TRY")
                try_end_lbl.setObjectName("EndBlockLabel")
                try_end_lay.addWidget(try_end_lbl)
                try_layout.addWidget(try_end)

                catch_container = WorkflowContainer(self)
                catch_container.setObjectName("CatchContainer")
                catch_layout = QVBoxLayout()
                catch_layout.setContentsMargins(10, 0, 0, 0)
                catch_container.setLayout(catch_layout)
                parent_layout.addWidget(catch_container)

                catch_hdr = QFrame()
                catch_hdr.setObjectName("EndBlockCard")
                catch_hdr_lay = QHBoxLayout()
                catch_hdr_lay.setContentsMargins(8, 4, 8, 4)
                catch_hdr.setLayout(catch_hdr_lay)
                catch_lbl = QLabel("🛡 CATCH BLOCK")
                catch_lbl.setObjectName("EndBlockLabel")
                catch_hdr_lay.addWidget(catch_lbl)
                catch_layout.addWidget(catch_hdr)

                self._build_block_list(step.else_children, catch_layout, parent_step=step, branch_name="else")

                catch_end = QFrame()
                catch_end.setObjectName("EndBlockCard")
                catch_end_lay = QHBoxLayout()
                catch_end_lay.setContentsMargins(8, 4, 8, 4)
                catch_end.setLayout(catch_end_lay)
                catch_end_lbl = QLabel("└ END CATCH")
                catch_end_lbl.setObjectName("EndBlockLabel")
                catch_end_lay.addWidget(catch_end_lbl)
                catch_layout.addWidget(catch_end)

                end_card = QFrame()
                end_card.setObjectName("EndBlockCard")
                end_layout = QHBoxLayout()
                end_layout.setContentsMargins(8, 4, 8, 4)
                end_card.setLayout(end_layout)

                end_label = QLabel(f"↩ END TRY / CATCH ({step.name or step.id})")
                end_label.setObjectName("EndBlockLabel")
                end_layout.addWidget(end_label)
                end_layout.addStretch()
                parent_layout.addWidget(end_card)

            # Add the insertion button after this step
            insert_btn = WorkflowInsertButton(steps_list, idx + 1, self)
            parent_layout.addWidget(insert_btn)

    _ENGINE_DISPLAY_NAMES: Dict[str, str] = {
        "netweaver": "NetWeaver",
        "cdp": "CDP",
        "template_2": "Portfolio",
        "portfolio": "Portfolio",
        "prioritization": "Prioritization",
        "python_script": "Python Script",
    }

    def _get_step_detail_only(self, step: WorkflowDefinitionStep) -> str:
        if step.step_type == WorkflowDefinitionStepType.TASK:
            engine_type = str(step.config.get("engine_type", "") or "").strip()
            engine_label = self._ENGINE_DISPLAY_NAMES.get(engine_type.lower(), engine_type or "engine?")
            wizard_task_name = str(step.config.get("wizard_task_name", "") or "").strip()
            model_reference = str(step.config.get("model_reference", "") or "").strip()
            # Show only the basename so the card stays scannable for long paths.
            model_label = Path(model_reference).name if model_reference else ""
            input_source = str(step.config.get("input_source", "assessment") or "assessment").strip()
            source_label = {
                "assessment": "from assessment",
                "previous_result": "from previous step",
                "named_result": (
                    "from "
                    + (str(step.config.get("input_variable", "") or "").strip() or "?")
                ),
            }.get(input_source, input_source)
            store_as = str(step.config.get("store_result_as", "") or "").strip()
            store_label = f"→ {store_as}" if store_as else ""

            if engine_type.lower() == "python_script":
                script_path = str(step.config.get("script_path", "") or "").strip()
                script_label = Path(script_path).name if script_path else "script?"
                bits = [engine_label, script_label, source_label, store_label]
            else:
                bits = [engine_label]
                if wizard_task_name:
                    bits.append(f"“{wizard_task_name}”")
                if model_label:
                    bits.append(model_label)
                bits.extend([source_label, store_label])

            if not (engine_type or wizard_task_name or model_reference):
                return "Not configured — click 'Configure with Wizard…' or pick an engine."
            return " · ".join(part for part in bits if part)

        if step.step_type == WorkflowDefinitionStepType.SET_VARIABLE:
            scope = str(step.config.get("scope", "local") or "local").strip().lower()
            operation = str(step.config.get("operation", "set") or "set").strip().lower()
            scope_label = {
                "workflow_input": "input",
                "local": "local",
                "project": "project",
                "assessment": "assessment",
            }.get(scope, scope)
            variable_name = str(step.config.get("variable_name", "") or "").strip()
            op_label = "delete" if operation == "delete" else f"set to {step.config.get('value', '')}"
            return f"Variable '{variable_name}' [{scope_label}]: {op_label}"

        if step.step_type == WorkflowDefinitionStepType.FOR_LOOP:
            variable_name = str(step.config.get("variable_name", "") or "").strip()
            loop_type = str(step.config.get("loop_type", "range") or "range").strip().lower()
            if loop_type == "each":
                list_value = str(step.config.get("list_value", "") or "").strip()
                index_variable = str(step.config.get("index_variable_name", "") or "").strip()
                source_label = list_value if list_value else "list?"
                detail = f"For each {variable_name or 'item?'} in {source_label}"
                if index_variable:
                    detail += f" (index: {index_variable})"
                return detail
            if loop_type == "each_feature":
                layer = str(step.config.get("layer_name", "") or "").strip() or "layer?"
                feature_filter = str(step.config.get("feature_filter", "") or "").strip()
                detail = f"For each feature in '{layer}' as {variable_name or 'feature'}"
                if feature_filter:
                    detail += f" where {feature_filter}"
                return detail
            start = step.config.get("start", "1")
            end = step.config.get("end", "5")
            loop_step = step.config.get("step", "1")
            return f"For {variable_name or 'i'} from {start} to {end} by {loop_step}"

        if step.step_type == WorkflowDefinitionStepType.IF_ELSE:
            variable_name = str(step.config.get("variable_name", "") or "").strip()
            operator = str(step.config.get("operator", "equals") or "equals").strip()
            expected_value = str(step.config.get("expected_value", "") or "").strip()
            detail = self._format_condition_detail(
                variable_name or "variable?",
                operator,
                expected_value,
                str(step.config.get("expected_value_2", "") or "").strip(),
            )
            return f"If {detail}"

        if step.step_type == WorkflowDefinitionStepType.LOG:
            level = str(step.config.get("level", "info") or "info").strip().lower()
            message = str(step.config.get("message", "") or "").strip()
            snippet = (message[:60] + "...") if len(message) > 60 else message
            return f"Log [{level}]: {snippet}"

        if step.step_type == WorkflowDefinitionStepType.TRY_CATCH:
            err_var = str(step.config.get("error_variable", "last_error") or "last_error")
            return f"Try block protected (stores error in {err_var})"

        return ""

    # Plain-English names for the six step types. Internal enum strings
    # (TASK / SET_VARIABLE / ...) are kept; only display labels change.
    _FRIENDLY_STEP_TYPE_LABELS: Dict[str, str] = {
        WorkflowDefinitionStepType.TASK: "Run EMDS Task",
        WorkflowDefinitionStepType.SET_VARIABLE: "Remember a value",
        WorkflowDefinitionStepType.FOR_LOOP: "Repeat for each",
        WorkflowDefinitionStepType.IF_ELSE: "Only if",
        WorkflowDefinitionStepType.LOG: "Log message",
        WorkflowDefinitionStepType.TRY_CATCH: "Recover from error",
    }

    @staticmethod
    def _friendly_step_type_label(step_type: str) -> str:
        return WorkflowEditorDialog._FRIENDLY_STEP_TYPE_LABELS.get(
            step_type, (step_type or "step").title()
        )

    @staticmethod
    def _insert_menu_options() -> List[tuple]:
        """Single source of truth for the insert-step menu (used by + buttons
        and the right-click context menu on cards)."""
        labels = {
            WorkflowDefinitionStepType.TASK: "🗲 " + WorkflowEditorDialog._tr_pipeline("insert_run_task"),
            WorkflowDefinitionStepType.SET_VARIABLE: "⚙ " + WorkflowEditorDialog._tr_pipeline("insert_remember_value"),
            WorkflowDefinitionStepType.FOR_LOOP: "↻ " + WorkflowEditorDialog._tr_pipeline("insert_repeat_each"),
            WorkflowDefinitionStepType.IF_ELSE: "🔀 " + WorkflowEditorDialog._tr_pipeline("insert_only_if"),
            WorkflowDefinitionStepType.LOG: "📋 " + WorkflowEditorDialog._tr_pipeline("insert_log_message"),
            WorkflowDefinitionStepType.TRY_CATCH: "🛡 " + WorkflowEditorDialog._tr_pipeline("insert_on_error"),
        }
        return [
            (labels[step_type], step_type)
            for step_type in WorkflowDefinitionStepType.UI_INSERT_TYPES
        ]

    @staticmethod
    def _step_label(step: WorkflowDefinitionStep) -> str:
        prefix = WorkflowEditorDialog._friendly_step_type_label(step.step_type)
        suffix = " [disabled]" if not step.enabled else ""
        if step.step_type == WorkflowDefinitionStepType.TASK:
            engine_type = str(step.config.get("engine_type", "") or "").strip()
            model_reference = str(step.config.get("model_reference", "") or "").strip()
            input_source = str(step.config.get("input_source", "assessment") or "assessment").strip()
            source_label = {
                "assessment": "assessment",
                "previous_result": "previous",
                "named_result": f"var:{str(step.config.get('input_variable', '') or '').strip() or '?'}",
            }.get(input_source, input_source)
            configured_via_wizard = bool(step.config.get("configured_via_wizard"))
            wizard_tag = " | wizard" if configured_via_wizard else ""
            detail = " | ".join(part for part in [engine_type, model_reference, source_label] if part)
            if detail:
                return f"{prefix}: {step.name or step.id} [{detail}{wizard_tag}]{suffix}"
        if step.step_type == WorkflowDefinitionStepType.SET_VARIABLE:
            scope = str(step.config.get("scope", "local") or "local").strip().lower()
            operation = str(step.config.get("operation", "set") or "set").strip().lower()
            scope_label = {
                "workflow_input": "input",
                "local": "local",
                "project": "project",
                "assessment": "assessment",
            }.get(scope, scope)
            variable_name = str(step.config.get("variable_name", "") or "").strip()
            op_label = "delete" if operation == "delete" else scope_label
            return f"{prefix}: {step.name or variable_name or step.id} [{op_label}]{suffix}"
        if step.step_type == WorkflowDefinitionStepType.FOR_LOOP:
            variable_name = str(step.config.get("variable_name", "") or "").strip()
            loop_type = str(step.config.get("loop_type", "range") or "range").strip().lower()
            if loop_type == "each":
                list_value = str(step.config.get("list_value", "") or "").strip()
                index_variable = str(step.config.get("index_variable_name", "") or "").strip()
                source_label = list_value if list_value else "list?"
                detail = f"for each {variable_name or 'item?'} in {source_label}"
                if index_variable:
                    detail += f" (index: {index_variable})"
                return f"{prefix}: {step.name or step.id} [{detail}]{suffix}"
            return f"{prefix}: {step.name or variable_name or step.id} [range]{suffix}"
        if step.step_type == WorkflowDefinitionStepType.IF_ELSE:
            variable_name = str(step.config.get("variable_name", "") or "").strip()
            operator = str(step.config.get("operator", "equals") or "equals").strip()
            expected_value = str(step.config.get("expected_value", "") or "").strip()
            detail = WorkflowEditorDialog._format_condition_detail(
                variable_name or "variable?",
                operator,
                expected_value,
                str(step.config.get("expected_value_2", "") or "").strip(),
            )
            return f"{prefix}: {step.name or step.id} [{detail}]{suffix}"
        if step.step_type == WorkflowDefinitionStepType.LOG:
            level = str(step.config.get("level", "info") or "info").strip().lower()
            message = str(step.config.get("message", "") or "").strip()
            snippet = (message[:30] + "...") if len(message) > 30 else message
            detail = f"{level}: {snippet}" if snippet else level
            return f"{prefix}: {step.name or step.id} [{detail}]{suffix}"
        return f"{prefix}: {step.name or step.id}{suffix}"

    @staticmethod
    def _format_condition_detail(variable_label: str, operator: str, expected: str, second: str) -> str:
        preview_map = {value: preview for _label, value, preview in _IF_OPERATORS}
        preview = preview_map.get(operator, operator)
        if operator in _ZERO_VALUE_OPERATORS:
            return f"{variable_label} {preview}"
        if operator == "between":
            return f"{variable_label} between {expected or '?'} and {second or '?'}"
        return f"{variable_label} {preview} {expected or '?'}"

    def _selected_step(self) -> Optional[WorkflowDefinitionStep]:
        return self._resolve_step_by_id(self.selected_step_id)

    def _compute_step_path(self, step_id: str) -> str:
        """
        Build a breadcrumb-style ancestor path for the selected step.
        For a TASK inside the THEN body of an IF inside a FOR, returns
        "Workflow > IF-1 (THEN) > FOR-2 (DO)". The selected step is NOT
        included (it's the one being edited). Empty string for top-level.
        """
        ancestors: List[str] = []

        def _label_for(step_type: str, is_else: bool) -> Optional[str]:
            t = (step_type or "").lower()
            if t == WorkflowDefinitionStepType.IF_ELSE:
                return "ELSE" if is_else else "THEN"
            if t == WorkflowDefinitionStepType.TRY_CATCH:
                return "CATCH" if is_else else "TRY"
            if t == WorkflowDefinitionStepType.FOR_LOOP:
                return "DO"
            return None

        def _walk(steps: List[WorkflowDefinitionStep]) -> bool:
            for step in steps:
                if step.id == step_id:
                    return True
                name = step.name or (step.step_type or "step").upper()
                then_label = _label_for(step.step_type, is_else=False)
                if _walk(step.children or []):
                    crumb = f"{name} ({then_label})" if then_label else name
                    ancestors.append(crumb)
                    return True
                else_label = _label_for(step.step_type, is_else=True)
                if _walk(step.else_children or []):
                    crumb = f"{name} ({else_label})" if else_label else name
                    ancestors.append(crumb)
                    return True
            return False

        if not _walk(self.workflow_definition.steps):
            return ""
        ancestors.reverse()
        if not ancestors:
            return ""
        return "Pipeline > " + " > ".join(ancestors)

    def _resolve_step_by_id(self, step_id: Optional[str]) -> Optional[WorkflowDefinitionStep]:
        if not step_id:
            return None

        def _walk(steps: List[WorkflowDefinitionStep]) -> Optional[WorkflowDefinitionStep]:
            for step in steps:
                if step.id == step_id:
                    return step
                res = _walk(step.children or [])
                if res:
                    return res
                res = _walk(step.else_children or [])
                if res:
                    return res
            return None

        return _walk(self.workflow_definition.steps)

    def _select_step_in_blocks(self, step_id: Optional[str]) -> None:
        for s_id, card in self._card_widgets.items():
            is_selected = (s_id == step_id)
            card.setProperty("selected", "true" if is_selected else "false")
            card.style().unpolish(card)
            card.style().polish(card)

        self.selected_step_id = step_id
        step = self._resolve_step_by_id(step_id)

        # R3-3b: breadcrumb. Show the ancestor chain for nested steps,
        # hide for top-level (no path to display).
        path = self._compute_step_path(step_id) if step_id else ""
        if path:
            self.step_path_label.setText(path)
            self.step_path_label.setToolTip(path)
            self.step_path_label.setVisible(True)
        else:
            self.step_path_label.setVisible(False)

        view = getattr(self, "workflow_graph_view", None)
        if view is not None:
            view.select_step(step_id)

        self._updating_ui = True
        try:
            if step is None:
                self.empty_properties_label.setVisible(True)
                self.properties_host.setEnabled(False)
                self.step_type_label.setText("")
                self.step_name_input.clear()
                self.step_description_input.setPlainText("")
                self.step_enabled_checkbox.setChecked(True)
                self.step_config_stack.setCurrentWidget(self.empty_page)
                return

            self.empty_properties_label.setVisible(False)
            self.properties_host.setEnabled(True)
            self.step_name_input.setText(step.name)
            self.step_description_input.setPlainText(step.description or "")
            self.step_enabled_checkbox.setChecked(step.enabled)
            self.step_continue_on_error_checkbox.setChecked(step.continue_on_error)
            self.step_type_label.setText(self._friendly_step_type_label(step.step_type))
            self._bind_step_to_properties(step)
        finally:
            self._updating_ui = False

        if step is not None and step.step_type == WorkflowDefinitionStepType.TASK:
            self._refresh_task_wizard_availability()

        if step_id and step_id in self._card_widgets:
            self.scroll_area.ensureWidgetVisible(self._card_widgets[step_id])

    def _refresh_workflow_graph(self) -> None:
        """Re-layout the read-only graph preview from the current definition."""
        view = getattr(self, "workflow_graph_view", None)
        if view is None:
            return
        view.set_workflow(self.workflow_definition)
        # Re-apply highlight after the scene is rebuilt.
        step = self._selected_step()
        view.select_step(step.id if step is not None else None)

    def _on_graph_node_clicked(self, step) -> None:
        """Forward a graph-node click to the blocks editor so the properties pane updates."""
        if step is None or not isinstance(step, WorkflowDefinitionStep):
            return
        self._select_step_in_blocks(step.id)

    def _bind_step_to_properties(self, step: WorkflowDefinitionStep) -> None:
        if step.step_type == WorkflowDefinitionStepType.TASK:
            self.step_config_stack.setCurrentWidget(self.task_page)
            self._set_combo_by_data(self.task_engine_combo, str(step.config.get("engine_type", "")).strip())
            self.task_model_input.setText(str(step.config.get("model_reference", "") or ""))
            self.task_mapping_file_input.setText(str(step.config.get("mapping_file", "") or ""))
            self._set_combo_by_data(self.task_input_source_combo, str(step.config.get("input_source", "assessment") or "assessment"))
            self.task_input_variable_input.setText(str(step.config.get("input_variable", "") or ""))
            self._update_task_input_source_ui()
            self.task_store_result_input.setText(str(step.config.get("store_result_as", "") or ""))
            self.task_python_script_input.setText(str(step.config.get("script_path", "") or ""))
            arguments_value = step.config.get("arguments", "")
            if isinstance(arguments_value, list):
                arguments_value = " ".join(str(arg) for arg in arguments_value)
            self.task_python_args_input.setText(str(arguments_value or ""))
            self.task_python_cwd_input.setText(str(step.config.get("working_directory", "") or ""))
            self.task_python_executable_input.setText(str(step.config.get("python_executable", "") or ""))
            self.task_python_env_input.setText(self._format_env_pairs(step.config.get("environment", {})))
            timeout_value = step.config.get("timeout_seconds", "")
            self.task_python_timeout_input.setText("" if timeout_value in (None, "") else str(timeout_value))
            self._update_task_engine_visibility()
            self._refresh_task_flow_summary(step)
        elif step.step_type == WorkflowDefinitionStepType.SET_VARIABLE:
            self.step_config_stack.setCurrentWidget(self.variable_page)
            self._set_combo_by_data(
                self.variable_operation_combo,
                str(step.config.get("operation", "set") or "set"),
            )
            self._set_combo_by_data(self.variable_scope_combo, str(step.config.get("scope", "local") or "local"))
            self.variable_name_input.setText(str(step.config.get("variable_name", "") or ""))
            self.variable_value_input.setText(str(step.config.get("value", "") or ""))
            self._update_variable_operation_ui()
        elif step.step_type == WorkflowDefinitionStepType.FOR_LOOP:
            self.step_config_stack.setCurrentWidget(self.loop_page)
            self._set_combo_by_data(self.loop_type_combo, str(step.config.get("loop_type", "range") or "range"))
            self.loop_variable_input.setText(str(step.config.get("variable_name", "") or ""))
            self.loop_start_input.setText(str(step.config.get("start", 1) or "1"))
            self.loop_end_input.setText(str(step.config.get("end", 1) or "1"))
            self.loop_step_input.setText(str(step.config.get("step", 1) or "1"))
            self.loop_list_value_input.setText(str(step.config.get("list_value", "") or ""))
            self.loop_index_variable_input.setText(str(step.config.get("index_variable_name", "") or ""))
            # each_feature fields — fall back to assessment input layer if unset.
            layer_default = str(self.wizard_context.get("input_layer_name") or "")
            self.loop_layer_input.setText(str(step.config.get("layer_name", "") or layer_default))
            self.loop_feature_filter_input.setText(str(step.config.get("feature_filter", "") or ""))
            self._update_loop_type_ui()
        elif step.step_type == WorkflowDefinitionStepType.IF_ELSE:
            self.step_config_stack.setCurrentWidget(self.if_page)
            self.if_variable_input.setText(str(step.config.get("variable_name", "") or ""))
            self._set_combo_by_data(self.if_operator_combo, str(step.config.get("operator", "equals") or "equals"))
            self.if_expected_value_input.setText(str(step.config.get("expected_value", "") or ""))
            self.if_expected_value_2_input.setText(str(step.config.get("expected_value_2", "") or ""))
            self._update_if_operator_ui()
            self.if_branch_help_label.setText(
                f"Then branch steps: {len(list(step.children or []))} | Else branch steps: {len(list(step.else_children or []))}"
            )
        elif step.step_type == WorkflowDefinitionStepType.LOG:
            self.step_config_stack.setCurrentWidget(self.log_page)
            self._set_combo_by_data(self.log_level_combo, str(step.config.get("level", "info") or "info"))
            self.log_message_input.setText(str(step.config.get("message", "") or ""))
        elif step.step_type == WorkflowDefinitionStepType.TRY_CATCH:
            self.step_config_stack.setCurrentWidget(self.try_catch_page)
            self.try_catch_error_variable_input.setText(
                str(step.config.get("error_variable", "last_error") or "last_error")
            )
            self.try_catch_branch_help_label.setText(
                f"Try block steps: {len(list(step.children or []))} | "
                f"Catch block steps: {len(list(step.else_children or []))}"
            )
        else:
            self.step_config_stack.setCurrentWidget(self.empty_page)

    def _sync_definition_fields(self, *_args) -> None:
        if self._updating_ui:
            return
        self.workflow_definition.name = self.definition_name_input.text().strip()
        self.workflow_definition.description = self.definition_description_input.toPlainText().strip()
        self._refresh_execution_preview()
        self._schedule_edit_snapshot()

    def _sync_selected_step(self, *_args) -> None:
        if self._updating_ui:
            return

        step = self._selected_step()
        if step is None:
            return

        step.name = self.step_name_input.text().strip()
        step.description = self.step_description_input.toPlainText()
        step.enabled = self.step_enabled_checkbox.isChecked()
        step.continue_on_error = self.step_continue_on_error_checkbox.isChecked()

        if step.step_type == WorkflowDefinitionStepType.TASK:
            # Preserve the opaque wizard payload (rich engine params produced by a
            # Create*Dialog) across simple-field edits. The form fields still drive
            # the raw-param path; the wizard payload is kept under task_parameters.
            previous_config = dict(step.config or {})
            engine_value = str(self.task_engine_combo.currentData() or "").strip()
            new_config: Dict[str, Any] = {
                "engine_type": engine_value,
                "store_result_as": self.task_store_result_input.text().strip(),
            }
            if engine_value == EngineType.PYTHON_SCRIPT.value:
                timeout_text = self.task_python_timeout_input.text().strip()
                timeout_value: Any = ""
                if timeout_text:
                    try:
                        timeout_value = float(timeout_text)
                    except ValueError:
                        timeout_value = timeout_text
                new_config.update({
                    "script_path": self.task_python_script_input.text().strip(),
                    "arguments": self.task_python_args_input.text(),
                    "working_directory": self.task_python_cwd_input.text().strip(),
                    "python_executable": self.task_python_executable_input.text().strip(),
                    "environment": self._parse_env_pairs(self.task_python_env_input.text()),
                    "timeout_seconds": timeout_value,
                })
            else:
                new_config.update({
                    "model_reference": self.task_model_input.text().strip(),
                    "mapping_file": self.task_mapping_file_input.text().strip(),
                    "input_source": str(self.task_input_source_combo.currentData() or "assessment").strip(),
                    "input_variable": self.task_input_variable_input.text().strip(),
                })
                if "task_parameters" in previous_config:
                    new_config["task_parameters"] = previous_config.get("task_parameters")
                if "configured_via_wizard" in previous_config:
                    new_config["configured_via_wizard"] = previous_config.get("configured_via_wizard")
                if "wizard_task_name" in previous_config:
                    new_config["wizard_task_name"] = previous_config.get("wizard_task_name")
            step.config = new_config
            self._refresh_task_flow_summary(step)
            self._refresh_task_wizard_status(step)
        elif step.step_type == WorkflowDefinitionStepType.SET_VARIABLE:
            step.config = {
                "operation": str(self.variable_operation_combo.currentData() or "set").strip(),
                "scope": str(self.variable_scope_combo.currentData() or "local").strip(),
                "variable_name": self.variable_name_input.text().strip(),
                "value": self.variable_value_input.text(),
            }
            self._update_variable_operation_ui()
        elif step.step_type == WorkflowDefinitionStepType.FOR_LOOP:
            loop_type = str(self.loop_type_combo.currentData() or "range").strip()
            step.config = {
                "loop_type": loop_type,
                "variable_name": self.loop_variable_input.text().strip(),
                "start": self.loop_start_input.text().strip() or "1",
                "end": self.loop_end_input.text().strip() or "1",
                "step": self.loop_step_input.text().strip() or "1",
                "list_value": self.loop_list_value_input.text(),
                "index_variable_name": self.loop_index_variable_input.text().strip(),
                "layer_name": self.loop_layer_input.text().strip(),
                "feature_filter": self.loop_feature_filter_input.text().strip(),
            }
            self._update_loop_type_ui()
        elif step.step_type == WorkflowDefinitionStepType.IF_ELSE:
            step.config = {
                "variable_name": self.if_variable_input.text().strip(),
                "operator": str(self.if_operator_combo.currentData() or "equals").strip(),
                "expected_value": self.if_expected_value_input.text(),
                "expected_value_2": self.if_expected_value_2_input.text(),
            }
            self._update_if_operator_ui()
            self.if_branch_help_label.setText(
                f"Then branch steps: {len(list(step.children or []))} | Else branch steps: {len(list(step.else_children or []))}"
            )
        elif step.step_type == WorkflowDefinitionStepType.LOG:
            step.config = {
                "level": str(self.log_level_combo.currentData() or "info").strip(),
                "message": self.log_message_input.text(),
            }
        elif step.step_type == WorkflowDefinitionStepType.TRY_CATCH:
            step.config = {
                "error_variable": self.try_catch_error_variable_input.text().strip() or "last_error",
            }
            self.try_catch_branch_help_label.setText(
                f"Try block steps: {len(list(step.children or []))} | "
                f"Catch block steps: {len(list(step.else_children or []))}"
            )

        if step.id in self._card_widgets:
            self._card_widgets[step.id].update_display()
        self._refresh_execution_preview()
        self._schedule_edit_snapshot()

    def _make_new_step(self, step_type: str) -> WorkflowDefinitionStep:
        if step_type == WorkflowDefinitionStepType.TASK:
            return WorkflowDefinitionStep(
                name="Run Engine",
                step_type=step_type,
                config={
                    "engine_type": EngineType.NETWEAVER.value,
                    "model_reference": "",
                    "mapping_file": "",
                    "input_source": "assessment",
                    "input_variable": "",
                    "store_result_as": "",
                },
            )
        if step_type == WorkflowDefinitionStepType.SET_VARIABLE:
            return WorkflowDefinitionStep(
                name="Pipeline Input",
                step_type=step_type,
                config={"scope": "workflow_input", "variable_name": "input_name", "value": ""},
            )
        if step_type == WorkflowDefinitionStepType.IF_ELSE:
            return WorkflowDefinitionStep(
                name="Condition",
                step_type=step_type,
                config={
                    "variable_name": "input_name",
                    "operator": "equals",
                    "expected_value": "value",
                    "expected_value_2": "",
                },
            )
        if step_type == WorkflowDefinitionStepType.LOG:
            return WorkflowDefinitionStep(
                name="Log Message",
                step_type=step_type,
                config={"level": "info", "message": "Checkpoint reached"},
            )
        if step_type == WorkflowDefinitionStepType.TRY_CATCH:
            return WorkflowDefinitionStep(
                name="Try / Catch",
                step_type=step_type,
                config={"error_variable": "last_error"},
            )
        return WorkflowDefinitionStep(
            name="For Loop",
            step_type=step_type,
            config={
                "loop_type": "range",
                "variable_name": "year",
                "start": "1",
                "end": "5",
                "step": "1",
                "list_value": "",
                "index_variable_name": "",
            },
        )

    @staticmethod
    def _reassign_uuids(step: WorkflowDefinitionStep) -> None:
        """Recursively assign fresh UUIDs to a cloned step tree."""
        step.id = str(uuid.uuid4())
        for child in list(step.children or []):
            WorkflowEditorDialog._reassign_uuids(child)
        for child in list(step.else_children or []):
            WorkflowEditorDialog._reassign_uuids(child)

    def _on_variable_operation_changed(self, *_args) -> None:
        self._update_variable_operation_ui()

    def _update_variable_operation_ui(self) -> None:
        operation = str(self.variable_operation_combo.currentData() or "set").strip().lower()
        is_delete = operation == "delete"
        self.variable_value_input.setEnabled(not is_delete)
        if is_delete:
            self.variable_value_input.setPlaceholderText("(not used for delete)")
            scope = str(self.variable_scope_combo.currentData() or "local").strip().lower()
            if scope in ("project", "assessment"):
                self.variable_scope_note.setText(
                    f"Deletes '{self.variable_name_input.text().strip() or 'variable'}' "
                    f"from the {scope} scope (calls registered {scope} callback)."
                )
            else:
                self.variable_scope_note.setText(
                    "Removes the variable from the current in-memory run scope."
                )
        else:
            self.variable_value_input.setPlaceholderText(
                "Default value, {{token}}, or {{= expression }}"
            )
            scope = str(self.variable_scope_combo.currentData() or "local").strip().lower()
            scope_notes = {
                "workflow_input": "Only set if not already provided — safe default for run inputs.",
                "local": "Set every time this step runs.",
                "project": "Persists to Workspace metadata (requires Workspace scope callback).",
                "assessment": "Persists to Study metadata (requires Study scope callback).",
            }
            self.variable_scope_note.setText(scope_notes.get(scope, ""))

    def _simulate_workflow(self) -> None:
        self._run_debug_session(selected_step_only=False)

    def _simulate_selected_step(self) -> None:
        self._run_debug_session(selected_step_only=True)

    def _run_debug_session(self, *, selected_step_only: bool) -> None:
        """Run the full workflow or the selected step with mock variables and trace output."""
        self._sync_definition_fields()
        selected_step = self._selected_step()
        if selected_step_only and selected_step is None:
            QMessageBox.information(
                self,
                "Run Selected Step",
                "Select a pipeline step before running a step-level dry run.",
            )
            return
        if not self.workflow_definition.steps:
            QMessageBox.information(
                self,
                "Run with Mock Variables",
                "Add at least one step before starting a dry run.",
            )
            return

        mock_variables = self._prompt_for_mock_variables(selected_step_only=selected_step_only)
        if mock_variables is None:
            return

        debug_definition = self._build_debug_definition(
            selected_step=selected_step if selected_step_only else None
        )
        logs: List[str] = []

        def stub_task_runner(step: WorkflowDefinitionStep, ctx: Dict[str, Any]) -> Dict[str, Any]:
            engine_type = str(step.config.get("engine_type", "") or "").strip() or "engine?"
            received_keys = sorted(str(key) for key in dict(ctx or {}).keys())
            if engine_type == EngineType.PYTHON_SCRIPT.value:
                script_path = str(step.config.get("script_path", "") or "").strip() or "script?"
                logs.append(
                    f"  [STUB] Would run Python script task '{step.name or step.id}' using {script_path}"
                )
                return {
                    "dry_run": True,
                    "step_id": step.id,
                    "step_name": step.name or step.id,
                    "engine_type": engine_type,
                    "script_path": script_path,
                    "received_variable_keys": received_keys,
                }
            model_reference = str(step.config.get("model_reference", "") or "").strip() or "model?"
            input_source = str(step.config.get("input_source", "assessment") or "assessment").strip()
            logs.append(
                f"  [STUB] Would run {engine_type} task '{step.name or step.id}' using {model_reference}"
            )
            return {
                "dry_run": True,
                "step_id": step.id,
                "step_name": step.name or step.id,
                "engine_type": engine_type,
                "model_reference": model_reference,
                "input_source": input_source,
                "received_variable_keys": received_keys,
            }

        def project_cb(op: str, name: str, value: Any) -> None:
            logs.append(f"  [WORKSPACE SCOPE] {op} '{name}' = {value!r}")

        def assessment_cb(op: str, name: str, value: Any) -> None:
            logs.append(f"  [STUDY SCOPE] {op} '{name}' = {value!r}")

        executor = WorkflowDefinitionExecutor(
            task_runner=stub_task_runner,
            log_callback=logs.append,
            project_scope_callback=project_cb,
            assessment_scope_callback=assessment_cb,
        )

        try:
            result = executor.execute(
                debug_definition,
                initial_variables=dict(mock_variables or {}),
            )
            error = None
        except WorkflowDefinitionExecutionError as exc:
            result = None
            error = str(exc)
        except Exception as exc:
            result = None
            error = f"Unexpected error: {exc}"

        self._show_simulation_result(
            logs=logs,
            result=result,
            error=error,
            mock_variables=mock_variables,
            run_title="Selected Step Dry Run" if selected_step_only else "Pipeline Dry Run",
        )

    def _prompt_for_mock_variables(self, *, selected_step_only: bool) -> Optional[Dict[str, Any]]:
        defaults = self._default_mock_variables()
        dialog = MockVariablesDialog(
            initial_variables=defaults,
            title="Run Selected Step with Mock Variables"
            if selected_step_only
            else "Run Pipeline with Mock Variables",
            parent=self,
        )
        if exec_dialog(dialog) != QDialog.DialogCode.Accepted:
            return None
        return dialog.variables()

    def _default_mock_variables(self) -> Dict[str, Any]:
        defaults: Dict[str, Any] = {}
        for step in self._iter_steps_in_execution_order():
            if step.step_type != WorkflowDefinitionStepType.SET_VARIABLE:
                continue
            if str(step.config.get("scope", "local") or "local").strip() != "workflow_input":
                continue
            variable_name = str(step.config.get("variable_name", "") or "").strip()
            if not variable_name or variable_name in defaults:
                continue
            defaults[variable_name] = self._parse_mock_literal(step.config.get("value", ""))
        return defaults

    def _build_debug_definition(
        self,
        *,
        selected_step: Optional[WorkflowDefinitionStep] = None,
    ) -> WorkflowDefinition:
        if selected_step is None:
            return copy.deepcopy(self.workflow_definition)
        return WorkflowDefinition(
            name=f"{self.workflow_definition.name or 'Pipeline'} - {selected_step.name or selected_step.id}",
            description=f"Dry-run focus on step '{selected_step.name or selected_step.id}'",
            steps=[copy.deepcopy(selected_step)],
        )

    @staticmethod
    def _parse_mock_literal(value: Any) -> Any:
        if isinstance(value, str):
            text = value.strip()
            if text == "":
                return ""
            try:
                return json.loads(text)
            except Exception:
                return value
        return value

    def _show_simulation_result(
        self,
        logs: List[str],
        result: Optional[WorkflowDefinitionExecutionResult],
        error: Optional[str],
        mock_variables: Optional[Dict[str, Any]],
        run_title: str,
    ) -> None:
        dlg = QDialog(self)
        dlg.setWindowTitle(run_title)
        dlg.resize(1120, 680)
        outer = QVBoxLayout()
        dlg.setLayout(outer)

        summary_bits = [
            f"Mock variables: {len(dict(mock_variables or {}))}",
            f"Step traces: {len(list(result.step_events or []) if result else [])}",
        ]
        if error:
            summary_bits.append("Status: failed")
        else:
            summary_bits.append("Status: completed")
        summary_label = QLabel(" | ".join(summary_bits))
        outer.addWidget(summary_label)

        splitter = QSplitter(Qt.Orientation.Horizontal)
        outer.addWidget(splitter, 1)

        trace_host = QWidget()
        trace_layout = QVBoxLayout()
        trace_host.setLayout(trace_layout)
        trace_layout.addWidget(QLabel("Per-Step Results"))
        trace_table = QTableWidget(0, 5, trace_host)
        trace_table.setHorizontalHeaderLabels(
            ["#", "Step", "Type", "Status", "Changed Variables"]
        )
        trace_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        trace_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
        step_events = list(result.step_events or []) if result else []
        trace_table.setRowCount(len(step_events))
        for row, event in enumerate(step_events):
            changed = ", ".join(list(event.get("changed_variables", []) or [])[:6]) or "-"
            trace_table.setItem(row, 0, QTableWidgetItem(str(row + 1)))
            trace_table.setItem(
                row,
                1,
                QTableWidgetItem(str(event.get("step_name") or event.get("step_id") or "?")),
            )
            trace_table.setItem(row, 2, QTableWidgetItem(str(event.get("step_type") or "")))
            trace_table.setItem(row, 3, QTableWidgetItem(str(event.get("status") or "")))
            trace_table.setItem(row, 4, QTableWidgetItem(changed))
        trace_layout.addWidget(trace_table)
        splitter.addWidget(trace_host)

        right_splitter = QSplitter(Qt.Orientation.Vertical)
        splitter.addWidget(right_splitter)

        input_host = QWidget()
        input_layout = QVBoxLayout()
        input_host.setLayout(input_layout)
        input_layout.addWidget(QLabel("Mock Variables"))
        input_text = QTextEdit()
        input_text.setReadOnly(True)
        input_text.setMaximumHeight(120)
        input_text.setPlainText(
            self._format_debug_json(dict(mock_variables or {}), empty_message="No mock variables were provided.")
        )
        input_layout.addWidget(input_text)
        right_splitter.addWidget(input_host)

        inspector_host = QWidget()
        inspector_layout = QVBoxLayout()
        inspector_host.setLayout(inspector_layout)
        self._simulation_inspector_label = QLabel("Variable Inspector")
        inspector_layout.addWidget(self._simulation_inspector_label)
        inspector_text = QTextEdit()
        inspector_text.setReadOnly(True)
        inspector_layout.addWidget(inspector_text)
        right_splitter.addWidget(inspector_host)

        final_host = QWidget()
        final_layout = QVBoxLayout()
        final_host.setLayout(final_layout)
        final_layout.addWidget(QLabel("Final Variable Values"))
        variables = dict((result.variables if result else {}) or {})
        table = QTableWidget(len(variables), 3)
        table.setHorizontalHeaderLabels(["Variable", "Type", "Value"])
        for row, (name, value) in enumerate(sorted(variables.items())):
            table.setItem(row, 0, QTableWidgetItem(str(name)))
            table.setItem(row, 1, QTableWidgetItem(type(value).__name__))
            table.setItem(
                row,
                2,
                QTableWidgetItem(repr(value) if not isinstance(value, str) else value),
            )
        table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
        final_layout.addWidget(table)
        right_splitter.addWidget(final_host)

        log_host = QWidget()
        log_layout = QVBoxLayout()
        log_host.setLayout(log_layout)
        log_layout.addWidget(QLabel("Execution Log"))
        log_text = QTextEdit()
        log_text.setReadOnly(True)
        log_text.setPlainText("\n".join(logs))
        if error:
            log_text.append(f"\n--- FAILED ---\n{error}")
        log_layout.addWidget(log_text)
        right_splitter.addWidget(log_host)

        def _render_inspector_from_event(row_index: int) -> None:
            if row_index < 0 or row_index >= len(step_events):
                self._simulation_inspector_label.setText("Variable Inspector")
                inspector_text.setPlainText(
                    self._format_debug_json(
                        dict(result.variables or {}) if result else {},
                        empty_message="No variable state is available.",
                    )
                )
                return
            event = step_events[row_index]
            self._simulation_inspector_label.setText(
                f"Variable Inspector: {event.get('step_name') or event.get('step_id') or '?'}"
            )
            details = {
                "status": event.get("status"),
                "changed_variables": event.get("changed_variables", []),
                "error": event.get("error"),
                "variables_before": event.get("variables_before", {}),
                "variables_after": event.get("variables_after", {}),
            }
            inspector_text.setPlainText(
                self._format_debug_json(details, empty_message="No step trace details are available.")
            )

        trace_table.currentCellChanged.connect(
            lambda current_row, _current_col, _previous_row, _previous_col: _render_inspector_from_event(current_row)
        )
        if step_events:
            trace_table.setCurrentCell(len(step_events) - 1, 0)
            _render_inspector_from_event(len(step_events) - 1)
        else:
            _render_inspector_from_event(-1)

        splitter.setStretchFactor(0, 1)
        splitter.setStretchFactor(1, 1)
        right_splitter.setStretchFactor(1, 1)
        right_splitter.setStretchFactor(2, 1)
        right_splitter.setStretchFactor(3, 1)

        close_row = QHBoxLayout()
        close_row.addStretch()
        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dlg.accept)
        close_row.addWidget(close_btn)
        outer.addLayout(close_row)

        exec_dialog(dlg)

    @staticmethod
    def _format_debug_json(value: Any, *, empty_message: str) -> str:
        if value in (None, "", {}, []):
            return empty_message
        try:
            return json.dumps(value, indent=2, sort_keys=True, default=str)
        except Exception:
            return repr(value)

    def _on_task_input_source_changed(self, *_args) -> None:
        self._update_task_input_source_ui()

    def _on_task_engine_changed(self, *_args) -> None:
        # Engine switch generally invalidates any wizard-specific config
        self._refresh_task_wizard_availability()
        self._update_task_engine_visibility()

    def _update_task_engine_visibility(self) -> None:
        """Toggle task-page rows based on whether the engine is Python or EMDS."""
        engine_value = str(self.task_engine_combo.currentData() or "").strip()
        is_python = engine_value == EngineType.PYTHON_SCRIPT.value
        for widget in getattr(self, "_task_engine_only_widgets", []):
            self._set_form_row_visible(widget, not is_python)
        for widget in getattr(self, "_task_python_only_widgets", []):
            self._set_form_row_visible(widget, is_python)

    def _set_form_row_visible(self, field_widget: QWidget, visible: bool) -> None:
        """Hide or show a QFormLayout row by toggling both label and field."""
        form = getattr(self, "_task_form", None)
        if form is None or field_widget is None:
            return
        try:
            label = form.labelForField(field_widget)
        except Exception:
            label = None
        field_widget.setVisible(visible)
        if label is not None:
            label.setVisible(visible)

    def _browse_for_python_script(self) -> None:
        start_dir = str(self.task_python_script_input.text() or "").strip()
        if not start_dir:
            start_dir = str(Path.home())
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Select Python Script",
            start_dir,
            "Python files (*.py);;All files (*.*)",
        )
        if path:
            self.task_python_script_input.setText(path)

    def _browse_for_python_cwd(self) -> None:
        start_dir = str(self.task_python_cwd_input.text() or "").strip()
        if not start_dir:
            start_dir = str(Path.home())
        path = QFileDialog.getExistingDirectory(
            self,
            "Select Working Directory",
            start_dir,
        )
        if path:
            self.task_python_cwd_input.setText(path)

    @staticmethod
    def _parse_env_pairs(text: str) -> Dict[str, str]:
        """Parse a 'KEY=VALUE; KEY2=VALUE2' string into a dict."""
        result: Dict[str, str] = {}
        for chunk in (text or "").split(";"):
            chunk = chunk.strip()
            if not chunk or "=" not in chunk:
                continue
            key, _, value = chunk.partition("=")
            key = key.strip()
            if key:
                result[key] = value.strip()
        return result

    @staticmethod
    def _format_env_pairs(env: Any) -> str:
        if not isinstance(env, dict):
            return ""
        return "; ".join(
            f"{name}={value}" for name, value in env.items() if str(name).strip()
        )

    def _on_if_operator_changed(self, *_args) -> None:
        self._update_if_operator_ui()

    def _on_loop_type_changed(self, *_args) -> None:
        self._update_loop_type_ui()

    def _update_loop_type_ui(self) -> None:
        loop_type = str(self.loop_type_combo.currentData() or "range").strip().lower()
        is_range = loop_type == "range"
        is_each = loop_type == "each"
        is_each_feature = loop_type == "each_feature"
        # range fields
        self.loop_start_input.setEnabled(is_range)
        self.loop_end_input.setEnabled(is_range)
        self.loop_step_input.setEnabled(is_range)
        # list / index — used by both "each" and "each_feature" (index)
        self.loop_list_value_input.setEnabled(is_each)
        self.loop_index_variable_input.setEnabled(is_each or is_each_feature)
        # each_feature fields
        self.loop_layer_input.setEnabled(is_each_feature)
        self.loop_feature_filter_input.setEnabled(is_each_feature)

        if is_range:
            self.loop_start_input.setPlaceholderText("1")
            self.loop_end_input.setPlaceholderText("5")
            self.loop_step_input.setPlaceholderText("1")
        else:
            self.loop_start_input.setPlaceholderText("Not used for this loop style")
            self.loop_end_input.setPlaceholderText("Not used for this loop style")
            self.loop_step_input.setPlaceholderText("Not used for this loop style")

        if is_each:
            self.loop_list_value_input.setPlaceholderText(
                "e.g. {{my_list}} — or a literal: a, b, c — or a JSON array: [1, 2, 3]"
            )
        else:
            self.loop_list_value_input.setPlaceholderText("Not used for this loop style")

        if is_each_feature:
            self.loop_layer_input.setPlaceholderText(
                "QGIS layer name (e.g. watersheds) — defaults to the assessment input layer"
            )
            self.loop_feature_filter_input.setPlaceholderText(
                "Optional QGIS expression to filter features, e.g. \"area_ha\" > 100"
            )
            self.loop_variable_input.setPlaceholderText(
                "e.g. feature — referenced inside the loop as {{feature.attribute_name}}"
            )
            self.loop_index_variable_input.setPlaceholderText("Optional: variable to receive the iteration index")
        else:
            self.loop_layer_input.setPlaceholderText("Not used for this loop style")
            self.loop_feature_filter_input.setPlaceholderText("Not used for this loop style")
            if is_range:
                self.loop_variable_input.setPlaceholderText("e.g. i")
            elif is_each:
                self.loop_index_variable_input.setPlaceholderText("Optional: variable to receive the iteration index")

    # ------------------------------------------------------------------
    # "Configure with Wizard..." integration for task steps
    # ------------------------------------------------------------------
    def _refresh_task_wizard_availability(self) -> None:
        """Enable/disable the wizard button based on context and current engine."""
        engine_value = str(self.task_engine_combo.currentData() or "").strip()
        supported = engine_value in {
            EngineType.NETWEAVER.value,
            EngineType.CDP.value,
            EngineType.TEMPLATE_2.value,
        }
        has_context = bool(self.wizard_context.get("assessment"))

        if not supported:
            self.task_configure_button.setEnabled(False)
            self.task_configure_button.setToolTip(
                "The wizard is only available for NetWeaver, CDP, and Portfolio engines."
            )
        elif not has_context:
            self.task_configure_button.setEnabled(False)
            self.task_configure_button.setToolTip(
                "Open the editor from inside an Analysis to enable the configuration wizard."
            )
        else:
            self.task_configure_button.setEnabled(True)
            self.task_configure_button.setToolTip("Launch the engine-specific wizard to fill in detailed parameters.")

        step = self._selected_step()
        if step is not None and step.step_type == WorkflowDefinitionStepType.TASK:
            self._refresh_task_wizard_status(step)
        else:
            self.task_wizard_status_label.setText("")
            self.task_clear_wizard_button.setEnabled(False)

    def _refresh_task_wizard_status(self, step: Optional[WorkflowDefinitionStep]) -> None:
        if step is None or step.step_type != WorkflowDefinitionStepType.TASK:
            self.task_wizard_status_label.setText("")
            self.task_clear_wizard_button.setEnabled(False)
            return

        if bool(step.config.get("configured_via_wizard")):
            params_summary = self._summarize_task_parameters(
                dict(step.config.get("task_parameters") or {})
            )
            display_name = str(step.config.get("wizard_task_name", "") or step.name or "").strip()
            text = "Configured via wizard"
            if display_name:
                text += f" as '{display_name}'"
            if params_summary:
                text += f" ({params_summary})"
            self.task_wizard_status_label.setText(text)
            self.task_clear_wizard_button.setEnabled(True)
        else:
            self.task_wizard_status_label.setText(
                "No wizard configuration — task will run from the raw fields below."
            )
            self.task_clear_wizard_button.setEnabled(False)

    @staticmethod
    def _summarize_task_parameters(parameters: Dict[str, Any]) -> str:
        """Short human-readable summary of the most interesting wizard-produced params."""
        if not parameters:
            return ""
        bits: List[str] = []
        model_file = str(parameters.get("model_file", "") or "").strip()
        if model_file:
            # Last path segment is usually what the user recognizes
            bits.append(Path(model_file).name)
        mappings = parameters.get("field_mappings")
        if isinstance(mappings, list) and mappings:
            bits.append(f"{len(mappings)} mapping(s)")
        strategy = parameters.get("budget_strategy")
        if strategy:
            bits.append(f"strategy={strategy}")
        return ", ".join(bits)

    def _launch_task_wizard(self) -> None:
        step = self._selected_step()
        if step is None or step.step_type != WorkflowDefinitionStepType.TASK:
            QMessageBox.information(
                self,
                self._tr_pipeline("title"),
                self._tr_pipeline("msg_select_task_first"),
            )
            return

        engine_value = str(self.task_engine_combo.currentData() or "").strip()
        if engine_value not in {
            EngineType.NETWEAVER.value,
            EngineType.CDP.value,
            EngineType.TEMPLATE_2.value,
        }:
            QMessageBox.information(
                self,
                self._tr_pipeline("title"),
                self._tr_pipeline("msg_wizard_engines_only"),
            )
            return

        ctx = self.wizard_context
        project = ctx.get("project")
        assessment = ctx.get("assessment")
        if project is None or assessment is None:
            QMessageBox.information(
                self,
                self._tr_pipeline("title"),
                self._tr_pipeline("msg_open_from_analysis"),
            )
            return

        try:
            # Pass the existing step so the wizard can prime its fields if
            # the step was already configured (Pattern A from the plan —
            # mirrors the C# PrimeXxxConfigureOnlyAsync hydration pattern).
            workflow_step = self._invoke_wizard_for_engine(
                engine_value,
                existing_step=step if bool(step.config.get("configured_via_wizard")) else None,
            )
        except Exception as exc:
            QMessageBox.critical(
                self,
                self._tr_pipeline("title"),
                self._tr_pipeline("msg_wizard_open_failed", error=exc),
            )
            return

        if workflow_step is None:
            return  # user cancelled

        step.name = workflow_step.name or step.name
        step.config = dict(step.config or {})
        step.config["engine_type"] = engine_value
        # Surface a human-readable model reference from the wizard params
        parameters = dict(workflow_step.parameters or {})
        model_reference = str(parameters.get("model_file") or parameters.get("model_reference") or "").strip()
        if model_reference:
            step.config["model_reference"] = model_reference
        step.config["task_parameters"] = parameters
        step.config["configured_via_wizard"] = True
        step.config["wizard_task_name"] = workflow_step.name

        self._updating_ui = True
        try:
            self.step_name_input.setText(step.name)
            if model_reference:
                self.task_model_input.setText(model_reference)
        finally:
            self._updating_ui = False

        if step.id in self._card_widgets:
            self._card_widgets[step.id].update_display()
        self._refresh_task_wizard_status(step)
        self._refresh_task_flow_summary(step)
        self._refresh_execution_preview()

    def _invoke_wizard_for_engine(self, engine_value: str, existing_step: Optional[WorkflowDefinitionStep] = None):
        """Open the appropriate CreateXxxDialog and return its WorkflowStep (or None).

        If existing_step is provided AND the wizard exposes prime_from_step,
        the wizard is hydrated from the step's config before being shown.
        Mirrors the C# PrimeXxxConfigureOnlyAsync flow:
            construct → prime_from_step(step) → show → harvest.
        """
        # Imports are local to avoid a circular import at module load
        ctx = self.wizard_context
        project = ctx["project"]
        assessment = ctx["assessment"]
        input_layer_name = str(ctx.get("input_layer_name") or "")
        input_table_name = str(ctx.get("input_table_name") or input_layer_name)
        input_fields = list(ctx.get("input_fields") or [])
        input_field_options = list(ctx.get("input_field_options") or [])
        database_path = str(ctx.get("database_path") or "")
        metadata_manager = ctx.get("metadata_manager")

        workflow_step: Optional[WorkflowStep] = None

        if engine_value == EngineType.NETWEAVER.value:
            from .create_netweaver_analysis_dialog import CreateNetWeaverAnalysisDialog
            engine_path = str(ctx.get("netweaver_engine_path") or "")
            dialog = CreateNetWeaverAnalysisDialog(
                project_id=project.id,
                assessment_id=assessment.id,
                input_layer_name=input_layer_name,
                input_table_name=input_table_name,
                input_fields=input_fields,
                engine_path=engine_path,
                metadata_manager=metadata_manager,
                database_path=database_path,
                parent=self,
            )
        elif engine_value == EngineType.CDP.value:
            from .create_prioritization_dialog import CreatePrioritizationDialog
            engine_path = str(ctx.get("cdp_engine_path") or "")
            dialog = CreatePrioritizationDialog(
                project_id=project.id,
                assessment_id=assessment.id,
                input_layer_name=input_layer_name,
                input_table_name=input_table_name,
                input_fields=input_fields,
                engine_path=engine_path,
                input_field_options=input_field_options,
                metadata_manager=metadata_manager,
                parent=self,
            )
        elif engine_value == EngineType.TEMPLATE_2.value:
            from .create_portfolio_dialog import CreatePortfolioDialog
            dialog = CreatePortfolioDialog(
                project_id=project.id,
                assessment_id=assessment.id,
                input_layer_name=input_layer_name,
                input_table_name=input_table_name,
                input_fields=input_fields,
                metadata_manager=metadata_manager,
                parent=self,
            )
        else:
            return None

        # Hydrate the wizard from the existing step BEFORE exec_dialog.
        prime = getattr(dialog, "prime_from_step", None)
        if existing_step is not None and callable(prime):
            try:
                prime(existing_step)
            except Exception:
                # Priming is best-effort; never block the user.
                pass

        workflow_step = dialog.get_workflow_step()

        if workflow_step is not None and database_path:
            # Mirror the hand-created task path (see dock_widget._create_*_workflow_step)
            workflow_step.parameters["database_path"] = database_path
        return workflow_step

    def _clear_task_wizard_config(self) -> None:
        step = self._selected_step()
        if step is None or step.step_type != WorkflowDefinitionStepType.TASK:
            return
        if not bool(step.config.get("configured_via_wizard")):
            return
        answer = QMessageBox.question(
            self,
            self._tr_pipeline("title"),
            self._tr_pipeline("msg_clear_wizard_confirm"),
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if answer != QMessageBox.StandardButton.Yes:
            return
        step.config = dict(step.config or {})
        step.config.pop("task_parameters", None)
        step.config.pop("configured_via_wizard", None)
        step.config.pop("wizard_task_name", None)
        self._refresh_task_wizard_status(step)
        if step.id in self._card_widgets:
            self._card_widgets[step.id].update_display()
        self._refresh_execution_preview()

    def _update_task_input_source_ui(self) -> None:
        input_source = str(self.task_input_source_combo.currentData() or "assessment").strip()
        requires_variable = input_source == "named_result"
        self.task_input_variable_input.setEnabled(requires_variable)
        self.task_input_variable_input.setPlaceholderText(
            "Stored result variable name" if requires_variable else "Not used for this input source"
        )
        step = self._selected_step()
        if step is not None and step.step_type == WorkflowDefinitionStepType.TASK:
            self._refresh_task_flow_summary(step)

    def _update_if_operator_ui(self) -> None:
        operator = str(self.if_operator_combo.currentData() or "equals").strip()
        requires_primary = operator not in _ZERO_VALUE_OPERATORS
        requires_secondary = operator in _TWO_VALUE_OPERATORS

        self.if_expected_value_input.setEnabled(requires_primary)
        self.if_expected_value_2_input.setEnabled(requires_secondary)

        if requires_primary:
            placeholder_map = {
                "less_than": "Numeric threshold",
                "less_than_or_equals": "Numeric threshold",
                "greater_than": "Numeric threshold",
                "greater_than_or_equals": "Numeric threshold",
                "between": "Lower bound",
                "contains": "Substring or list item",
                "in_list": "Comma-separated values",
            }
            self.if_expected_value_input.setPlaceholderText(
                placeholder_map.get(operator, "Comparison value")
            )
        else:
            self.if_expected_value_input.setPlaceholderText("Not used for this operator")

        if requires_secondary:
            self.if_expected_value_2_input.setPlaceholderText("Upper bound")
        else:
            self.if_expected_value_2_input.setPlaceholderText("Upper bound (Between only)")

    @staticmethod
    def _set_combo_by_data(combo: QComboBox, target_data: str) -> None:
        for index in range(combo.count()):
            if str(combo.itemData(index) or "").strip() == str(target_data or "").strip():
                combo.setCurrentIndex(index)
                return
        combo.setCurrentIndex(0)

    def _iter_steps_in_execution_order(
        self,
        steps: Optional[List[WorkflowDefinitionStep]] = None,
    ) -> List[WorkflowDefinitionStep]:
        ordered: List[WorkflowDefinitionStep] = []

        def _walk(items: List[WorkflowDefinitionStep]) -> None:
            for step in list(items or []):
                ordered.append(step)
                _walk(list(step.children or []))
                _walk(list(step.else_children or []))

        _walk(list(steps if steps is not None else self.workflow_definition.steps or []))
        return ordered

    def _available_result_variables_before_step(self, target_step: Optional[WorkflowDefinitionStep]) -> List[str]:
        if target_step is None:
            return []

        available: List[str] = []
        for step in self._iter_steps_in_execution_order():
            if step is target_step:
                break
            if step.step_type != WorkflowDefinitionStepType.TASK or not step.enabled:
                continue
            variable_name = str(step.config.get("store_result_as", "") or "").strip()
            if variable_name and variable_name not in available:
                available.append(variable_name)
        return available

    def _has_previous_task_before_step(self, target_step: Optional[WorkflowDefinitionStep]) -> bool:
        if target_step is None:
            return False
        for step in self._iter_steps_in_execution_order():
            if step is target_step:
                return False
            if step.step_type == WorkflowDefinitionStepType.TASK and step.enabled:
                return True
        return False

    def _refresh_task_flow_summary(self, step: Optional[WorkflowDefinitionStep]) -> None:
        if step is None or step.step_type != WorkflowDefinitionStepType.TASK:
            self.task_flow_summary_label.setText("")
            self.task_available_results_label.setText("")
            return

        config = dict(step.config or {})
        engine_type = str(config.get("engine_type", "") or "").strip() or "(engine not selected)"
        store_result_as = str(config.get("store_result_as", "") or "").strip()

        if engine_type == EngineType.PYTHON_SCRIPT.value:
            script_path = str(config.get("script_path", "") or "").strip() or "(script not set)"
            script_name = Path(script_path).name if script_path != "(script not set)" else script_path
            summary = f"Python script {script_name}"
            if store_result_as:
                summary += f" — its parsed JSON stdout (or stdout/stderr fallback) is stored as '{store_result_as}'."
            else:
                summary += " — its parsed JSON stdout is exposed as last_task_result."
            self.task_flow_summary_label.setText(summary)
        else:
            model_reference = str(config.get("model_reference", "") or "").strip() or "(model not set)"
            input_source = str(config.get("input_source", "assessment") or "assessment").strip()
            input_variable = str(config.get("input_variable", "") or "").strip()

            input_label = {
                "assessment": "Study output",
                "previous_result": "previous task result",
                "named_result": f"named result '{input_variable or '?'}'",
            }.get(input_source, input_source)

            summary = f"{engine_type} using {model_reference} reads from {input_label}"
            if store_result_as:
                summary += f" and stores its persisted result as '{store_result_as}'."
            else:
                summary += "."
            self.task_flow_summary_label.setText(summary)

        available_variables = self._available_result_variables_before_step(step)
        if available_variables:
            available_text = ", ".join(available_variables)
        else:
            available_text = "No stored result variables are available before this step yet."
        self.task_available_results_label.setText(available_text)

    def _refresh_validation_indicators(self, *, total_errors: int) -> None:
        """Refresh inline validation UI: per-card badges + footer pill."""
        # Per-card: each card reads from self._issues_by_step on update_display.
        for card in list(self._card_widgets.values()):
            try:
                card.update_display()
            except Exception:
                pass

        # Footer pill — shows count and acts as a jump-to-next-issue button.
        if total_errors > 0:
            self.validation_pill.setText(
                f"! {total_errors} issue{'s' if total_errors != 1 else ''} — click to view"
            )
            self.validation_pill.setVisible(True)
        else:
            self.validation_pill.setVisible(False)

    def _jump_to_next_issue(self) -> None:
        """Select the next card that has validation issues, wrapping to the
        first if we're past the last."""
        if not self._issues_by_step:
            return
        ordered_ids = [step.id for step in self._iter_steps_in_execution_order()]
        offending = [sid for sid in ordered_ids if sid in self._issues_by_step]
        if not offending:
            return
        if self.selected_step_id in offending:
            current_pos = offending.index(self.selected_step_id)
            next_id = offending[(current_pos + 1) % len(offending)]
        else:
            next_id = offending[0]
        self._select_step_in_blocks(next_id)

    def _refresh_execution_preview(self) -> None:
        self._refresh_workflow_graph()
        workflow_name = self.workflow_definition.name.strip() or "(unnamed pipeline)"
        if not self.workflow_definition.steps:
            self.execution_preview.setPlainText(
                f"Pipeline: {workflow_name}\n\nNo steps defined yet."
            )
            self._issues_by_step = {}
            self._refresh_validation_indicators(total_errors=0)
            return

        errors, issues_by_step = self._collect_validation_details()
        self._issues_by_step = issues_by_step
        self._refresh_validation_indicators(total_errors=len(errors))
        workflow_inputs = [
            step
            for step in self._iter_steps_in_execution_order()
            if step.step_type == WorkflowDefinitionStepType.SET_VARIABLE
            and str(step.config.get("scope", "local") or "local").strip() == "workflow_input"
        ]

        lines = [f"Pipeline: {workflow_name}", ""]
        if workflow_inputs:
            lines.append("Pipeline Inputs")
            for step in workflow_inputs:
                variable_name = str(step.config.get("variable_name", "") or "").strip() or "variable?"
                default_value = str(step.config.get("value", "") or "").strip()
                if default_value:
                    lines.append(f"  - {variable_name} (default: {default_value})")
                else:
                    lines.append(f"  - {variable_name}")
            lines.append("")

        lines.append("Execution Plan")
        counter = {"value": 1}

        def _append_step(step: WorkflowDefinitionStep, depth: int) -> None:
            indent = "  " * depth
            step_number = counter["value"]
            counter["value"] += 1

            if step.step_type == WorkflowDefinitionStepType.TASK:
                input_source = str(step.config.get("input_source", "assessment") or "assessment").strip()
                input_label = {
                "assessment": "Study output",
                    "previous_result": "previous task result",
                    "named_result": f"named result '{str(step.config.get('input_variable', '') or '').strip() or '?'}'",
                }.get(input_source, input_source)
                store_name = str(step.config.get("store_result_as", "") or "").strip()
                engine_type = str(step.config.get("engine_type", "") or "").strip() or "Engine?"
                model_reference = str(step.config.get("model_reference", "") or "").strip() or "model?"
                line = f"{step_number}. {indent}{step.name or step.id}: {engine_type} [{model_reference}] from {input_label}"
                if store_name:
                    line += f" -> store as '{store_name}'"
                lines.append(line)
            elif step.step_type == WorkflowDefinitionStepType.SET_VARIABLE:
                scope = str(step.config.get("scope", "local") or "local").strip()
                operation = str(step.config.get("operation", "set") or "set").strip().lower()
                variable_name = str(step.config.get("variable_name", "") or "").strip() or "variable?"
                if operation == "delete":
                    lines.append(f"{step_number}. {indent}{step.name or step.id}: delete {scope} '{variable_name}'")
                else:
                    value = str(step.config.get("value", "") or "").strip()
                    val_suffix = f" = {value}" if value else ""
                    lines.append(f"{step_number}. {indent}{step.name or step.id}: set {scope} '{variable_name}'{val_suffix}")
            elif step.step_type == WorkflowDefinitionStepType.IF_ELSE:
                variable_name = str(step.config.get("variable_name", "") or "").strip() or "variable?"
                operator = str(step.config.get("operator", "equals") or "equals").strip()
                expected_value = str(step.config.get("expected_value", "") or "").strip()
                second_value = str(step.config.get("expected_value_2", "") or "").strip()
                condition_text = WorkflowEditorDialog._format_condition_detail(
                    variable_name, operator, expected_value, second_value
                )
                lines.append(f"{step_number}. {indent}{step.name or step.id}: IF {condition_text}")
            elif step.step_type == WorkflowDefinitionStepType.LOG:
                level = str(step.config.get("level", "info") or "info").strip().lower()
                message = str(step.config.get("message", "") or "").strip() or "(empty message)"
                lines.append(f"{step_number}. {indent}{step.name or step.id}: LOG [{level}] {message}")
            elif step.step_type == WorkflowDefinitionStepType.FOR_LOOP:
                loop_type = str(step.config.get("loop_type", "range") or "range").strip().lower()
                variable_name = str(step.config.get("variable_name", "") or "").strip() or "loop_index"
                if loop_type == "each":
                    list_value = str(step.config.get("list_value", "") or "").strip() or "list?"
                    index_variable = str(step.config.get("index_variable_name", "") or "").strip()
                    index_suffix = f", index {index_variable}" if index_variable else ""
                    lines.append(
                        f"{step_number}. {indent}{step.name or step.id}: FOR EACH {variable_name} IN {list_value}{index_suffix}"
                    )
                elif loop_type == "each_feature":
                    layer_name = str(step.config.get("layer_name", "") or "").strip() or "layer?"
                    feature_filter = str(step.config.get("feature_filter", "") or "").strip()
                    filter_suffix = f" WHERE {feature_filter}" if feature_filter else ""
                    lines.append(
                        f"{step_number}. {indent}{step.name or step.id}: FOR EACH FEATURE {variable_name} IN LAYER '{layer_name}'{filter_suffix}"
                    )
                else:
                    start_value = str(step.config.get("start", "") or "").strip() or "?"
                    end_value = str(step.config.get("end", "") or "").strip() or "?"
                    step_value = str(step.config.get("step", "") or "").strip() or "?"
                    lines.append(
                        f"{step_number}. {indent}{step.name or step.id}: FOR {variable_name} = {start_value}..{end_value} STEP {step_value}"
                    )
            elif step.step_type == WorkflowDefinitionStepType.TRY_CATCH:
                error_var = str(step.config.get("error_variable", "last_error") or "last_error").strip()
                lines.append(
                    f"{step_number}. {indent}{step.name or step.id}: TRY (error -> '{error_var}')"
                )
            else:
                lines.append(f"{step_number}. {indent}{step.name or step.id}: {step.step_type}")

            for issue in issues_by_step.get(step.id, []):
                lines.append(f"   {indent}! {issue}")

            if step.step_type == WorkflowDefinitionStepType.FOR_LOOP:
                for child_step in list(step.children or []):
                    _append_step(child_step, depth + 1)
                loop_type = str(step.config.get("loop_type", "range") or "range").strip().lower()
                loop_variable = str(step.config.get("variable_name", "") or "").strip() or (
                    "item" if loop_type == "each" else "loop_index"
                )
                if loop_type == "each":
                    lines.append(f"   {indent}END FOR EACH {loop_variable}")
                elif loop_type == "each_feature":
                    lines.append(f"   {indent}END FOR EACH FEATURE {loop_variable}")
                else:
                    lines.append(f"   {indent}END LOOP {loop_variable}")
            elif step.step_type == WorkflowDefinitionStepType.IF_ELSE:
                lines.append(f"   {indent}THEN")
                for child_step in list(step.children or []):
                    _append_step(child_step, depth + 1)
                lines.append(f"   {indent}ELSE")
                for child_step in list(step.else_children or []):
                    _append_step(child_step, depth + 1)
                lines.append(f"   {indent}END IF")
            elif step.step_type == WorkflowDefinitionStepType.TRY_CATCH:
                for child_step in list(step.children or []):
                    _append_step(child_step, depth + 1)
                lines.append(f"   {indent}CATCH")
                for child_step in list(step.else_children or []):
                    _append_step(child_step, depth + 1)
                lines.append(f"   {indent}END TRY")
            else:
                for child_step in list(step.children or []):
                    _append_step(child_step, depth + 1)

        for root_step in list(self.workflow_definition.steps or []):
            _append_step(root_step, 0)

        if errors:
            lines.append("")
            lines.append(f"Validation Summary: {len(errors)} issue(s) currently need attention.")

        self.execution_preview.setPlainText("\n".join(lines))

    def _step_depth(self, target_step: WorkflowDefinitionStep) -> int:
        def _walk(steps: List[WorkflowDefinitionStep], depth: int) -> Optional[int]:
            for step in list(steps or []):
                if step is target_step:
                    return depth
                child_depth = _walk(list(step.children or []), depth + 1)
                if child_depth is not None:
                    return child_depth
                child_depth = _walk(list(step.else_children or []), depth + 1)
                if child_depth is not None:
                    return child_depth
            return None

        resolved_depth = _walk(list(self.workflow_definition.steps or []), 0)
        return int(resolved_depth or 0)

    def _validate_workflow_definition(self) -> List[str]:
        errors, _issues_by_step = self._collect_validation_details()
        return errors

    def _collect_validation_details(self) -> tuple[List[str], Dict[str, List[str]]]:
        errors: List[str] = []
        issues_by_step: Dict[str, List[str]] = {}

        def _add_issue(step: Optional[WorkflowDefinitionStep], message: str) -> None:
            errors.append(message)
            if step is not None:
                issues_by_step.setdefault(step.id, []).append(message)

        if not self.workflow_definition.name.strip():
            errors.append("Pipeline name is required.")

        if not list(self.workflow_definition.steps or []):
            errors.append("Add at least one pipeline step before saving.")
            return errors, issues_by_step

        available_named_results: List[str] = []
        has_prior_task = False

        for step in self._iter_steps_in_execution_order():
            step_name = step.name or step.id
            if step.step_type == WorkflowDefinitionStepType.TASK:
                engine_type = str(step.config.get("engine_type", "") or "").strip()
                store_result_as = str(step.config.get("store_result_as", "") or "").strip()

                if not engine_type:
                    _add_issue(step, f"Task '{step_name}' is missing an engine type.")

                if engine_type == EngineType.PYTHON_SCRIPT.value:
                    script_path = str(step.config.get("script_path", "") or "").strip()
                    if not script_path:
                        _add_issue(step, f"Python script task '{step_name}' is missing a script path.")
                    elif not Path(script_path).exists():
                        _add_issue(step, f"Python script task '{step_name}' references a missing script: {script_path}")
                    cwd = str(step.config.get("working_directory", "") or "").strip()
                    if cwd and not Path(cwd).is_dir():
                        _add_issue(step, f"Python script task '{step_name}' references a missing working directory: {cwd}")
                    interpreter = str(step.config.get("python_executable", "") or "").strip()
                    if interpreter and not Path(interpreter).exists():
                        _add_issue(
                            step,
                            f"Python script task '{step_name}' references a missing interpreter path: {interpreter}",
                        )
                    elif interpreter and Path(interpreter).stem.lower().startswith("qgis"):
                        _add_issue(
                            step,
                            f"Python script task '{step_name}' points its interpreter at the QGIS GUI "
                            f"executable ({interpreter}). Use a real python.exe "
                            "(e.g. C:\\OSGeo4W\\apps\\Python312\\python.exe).",
                        )
                else:
                    model_reference = str(step.config.get("model_reference", "") or "").strip()
                    mapping_file = str(step.config.get("mapping_file", "") or "").strip()
                    input_source = str(step.config.get("input_source", "assessment") or "assessment").strip()
                    input_variable = str(step.config.get("input_variable", "") or "").strip()

                    if not model_reference:
                        _add_issue(step, f"Task '{step_name}' is missing a model reference.")
                    if mapping_file and not Path(mapping_file).exists():
                        _add_issue(step, f"Task '{step_name}' references a missing mapping CSV: {mapping_file}")
                    if input_source == "previous_result" and not has_prior_task:
                        _add_issue(step, f"Task '{step_name}' uses Previous Task Result, but no earlier task produces one.")
                    if input_source == "named_result":
                        if not input_variable:
                            _add_issue(step, f"Task '{step_name}' uses Named Result Variable, but no variable name is set.")
                        elif input_variable not in available_named_results:
                            _add_issue(
                                step,
                                f"Task '{step_name}' references named result '{input_variable}', but no earlier task stores that result."
                            )

                if store_result_as:
                    if store_result_as in available_named_results:
                        _add_issue(
                            step,
                            f"Task '{step_name}' stores result as '{store_result_as}', but that variable name is already used by an earlier task."
                        )
                    else:
                        available_named_results.append(store_result_as)
                has_prior_task = True

            elif step.step_type == WorkflowDefinitionStepType.SET_VARIABLE:
                variable_name = str(step.config.get("variable_name", "") or "").strip()
                operation = str(step.config.get("operation", "set") or "set").strip().lower()
                if not variable_name:
                    _add_issue(step, f"Variable step '{step_name}' is missing a variable name.")
                if operation == "set":
                    value = str(step.config.get("value", "") or "")
                    scope = str(step.config.get("scope", "local") or "local").strip().lower()
                    if not value and scope not in ("workflow_input",):
                        # Warn (not error) that value is empty for non-input steps
                        _add_issue(step, f"Variable step '{step_name}' has an empty value. Consider using a default.")

            elif step.step_type == WorkflowDefinitionStepType.FOR_LOOP:
                loop_type = str(step.config.get("loop_type", "range") or "range").strip().lower()
                variable_name = str(step.config.get("variable_name", "") or "").strip()
                if not variable_name:
                    if loop_type == "each":
                        _add_issue(step, f"For-each loop '{step_name}' is missing an item variable name.")
                    elif loop_type == "each_feature":
                        _add_issue(step, f"For-each-feature loop '{step_name}' is missing a feature variable name.")
                    else:
                        _add_issue(step, f"Loop step '{step_name}' is missing a loop variable name.")
                if loop_type == "each":
                    list_value = str(step.config.get("list_value", "") or "").strip()
                    if not list_value:
                        _add_issue(
                            step,
                            f"For-each loop '{step_name}' is missing a list source (variable token, JSON array, or comma-separated list)."
                        )
                    index_variable = str(step.config.get("index_variable_name", "") or "").strip()
                    if index_variable and index_variable == variable_name:
                        _add_issue(
                            step,
                            f"For-each loop '{step_name}' has the same name for the item variable and the index variable ('{variable_name}')."
                        )
                elif loop_type == "each_feature":
                    layer_name = str(step.config.get("layer_name", "") or "").strip()
                    if not layer_name:
                        _add_issue(
                            step,
                            f"For-each-feature loop '{step_name}' is missing a layer name."
                        )
                    index_variable = str(step.config.get("index_variable_name", "") or "").strip()
                    if index_variable and index_variable == variable_name:
                        _add_issue(
                            step,
                            f"For-each-feature loop '{step_name}' has the same name for the feature variable and the index variable ('{variable_name}')."
                        )
            elif step.step_type == WorkflowDefinitionStepType.IF_ELSE:
                variable_name = str(step.config.get("variable_name", "") or "").strip()
                operator = str(step.config.get("operator", "equals") or "equals").strip()
                if not variable_name:
                    _add_issue(step, f"If / Else step '{step_name}' is missing a variable name.")
                if operator not in _ZERO_VALUE_OPERATORS:
                    if str(step.config.get("expected_value", "") or "") == "":
                        _add_issue(step, f"If / Else step '{step_name}' requires a comparison value for operator '{operator}'.")
                if operator in _TWO_VALUE_OPERATORS:
                    if str(step.config.get("expected_value_2", "") or "") == "":
                        _add_issue(step, f"If / Else step '{step_name}' requires an upper-bound value for operator '{operator}'.")
            elif step.step_type == WorkflowDefinitionStepType.LOG:
                message = str(step.config.get("message", "") or "").strip()
                if not message:
                    _add_issue(step, f"Log step '{step_name}' has an empty message.")
            elif step.step_type == WorkflowDefinitionStepType.TRY_CATCH:
                if not list(step.children or []):
                    _add_issue(step, f"Try/Catch step '{step_name}' has no steps in the try block.")

        return errors, issues_by_step

    def get_workflow_definition(self) -> Optional[WorkflowDefinition]:
        while True:
            if exec_dialog(self) != QDialog.DialogCode.Accepted:
                return None

            self._sync_definition_fields()
            validation_errors = self._validate_workflow_definition()
            if validation_errors:
                # Show every issue (no truncation). Inline badges on each
                # offending card and the footer pill also surface these.
                QMessageBox.warning(
                    self,
                    self._tr_pipeline("title"),
                    self._tr_pipeline(
                        "msg_resolve_before_saving",
                        issues="• " + "\n• ".join(validation_errors),
                    ),
                )
                continue

            if self._save_as_requested:
                self.workflow_definition.id = str(uuid.uuid4())

            self.workflow_definition.touch()
            return self.workflow_definition
