"""
Main dock widget for EMDS for QGIS
"""

from .qt_compat import (
    QDockWidget, QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
    QTreeWidget, QMessageBox, QMenu, QApplication, QLabel, QProgressDialog, QFrame,
    QTabWidget, QListWidget, QDialog, QDialogButtonBox, QScrollArea, QSplitter,
)
from .qt_compat import Qt, pyqtSignal, QCursor
from typing import Optional, Callable, Dict, Any, List
import copy
import csv
import json
import uuid
import sqlite3
from pathlib import Path
import platform
import re
import xml.etree.ElementTree as ET

from .layer_kind import LayerKindResolver
from .models import (
    Project, Assessment, Provenance, WorkflowStep, Results, ProjectManager,
    EngineType, LayerKind
)
from .tree_model import EMDSTreeModel, TreeNodeData
from .dialogs import (
    CreateGuidedProjectDialog, CreateProjectDialog, CreateAssessmentDialog, CreateAssessmentBenchmarkDialog, CreateProvenanceDialog,
    CreateNetWeaverAnalysisDialog, CreatePortfolioDialog, CreatePrioritizationDialog, CreateTaskDialog,
    WorkflowEditorDialog,
)
from .dialogs.compare_results_dialog import CompareCandidate, CompareResultsDialog
from .dialogs.edit_node_dialog import EditNodeDialog
from .executor import WorkflowExecutor
from .dock_widget_executor import ExecutionHistoryDialog, ExecutionProgressDialog, ResultsViewerDialog
from .results_histogram_dialog import ResultsHistogramDialog
from .cdp_results_dialog import CDPResultsDialog
from .cdp_results_service import CDPResultsService
from .lpa_results_dialog import LPAResultsDialog
from .netweaver_results_dialog import NetWeaverResultsDialog
from .netweaver_results_service import NetWeaverResultsService
from .netweaver_scheme import numeric_or_none
from .portfolio_results_dialog import PortfolioResultsDialog
from .layer_creator import ResultsLayerCreator
from .project_handler import ProjectHandler
from .workbench_metadata_manager import WorkbenchMetadataManager as ProjectMetadataManager
from .spatialite_manager import SpatialiteManager
from .workflow_definition import (
    WorkflowDefinition,
    WorkflowDefinitionStep,
    build_default_workflow_definition,
)
from .workflow_definition_executor import WorkflowDefinitionExecutor
from . import debug_logger
from .qt_compat import exec_dialog, exec_menu
from .onboarding_ui import create_external_links_label, create_hint_panel, tutorials_available
from .theme import info_panel_colors, muted_text_color
from .comment_formats import ARCGIS_PLAIN_KEY, ARCGIS_RTF_KEY, SIDECAR_HTML_KEY
from .translator import tr, set_language, get_language, add_listener, supported_languages


class EMDSDockWidget(QDockWidget):
    """Main dock widget for EMDS for QGIS"""

    PLUGIN_VERSION = "0.1.2"

    # Signals
    project_created = pyqtSignal(Project)
    assessment_created = pyqtSignal(Assessment)
    provenance_created = pyqtSignal(Provenance)
    workflow_step_created = pyqtSignal(WorkflowStep)
    item_selected = pyqtSignal(dict)  # Path with IDs
    item_deleted = pyqtSignal(str, str)  # node_type, item_id
    results_visibility_toggled = pyqtSignal(str, bool)  # layer_name, visible

    def __init__(self, iface, parent=None):
        super().__init__(tr("dock.window_title"), parent)
        self.iface = iface
        self.canvas = iface.mapCanvas()
        self.project_manager = ProjectManager()
        self.project_handler = ProjectHandler()
        self.metadata_manager = ProjectMetadataManager()
        self.selected_item = None
        self.selected_path = None
        self.active_execution = None
        self.active_execution_context = None
        self.progress_dialog = None
        self.executor = WorkflowExecutor(
            engine_paths={
                "netweaver": self._resolve_netweaver_engine_path(),
                "cdp": self._resolve_cdp_engine_path(),
            },
            results_db_path=None,
        )
        # Provenance A1: live runs execute the DB-embedded model snapshot bytes.
        self.executor.model_blob_resolver = self._resolve_model_blob_for_run
        self.cdp_results_service = CDPResultsService(self._resolve_cdp_engine_path())
        self.netweaver_results_service = NetWeaverResultsService(
            netweaver_engine_path=self._resolve_netweaver_engine_path()
        )
        self._open_viewers: list = []

        # Get layers from QGIS
        self.available_layers = self._get_available_layers()

        self.init_ui()
        self.connect_signals()

        # Apply current language and subscribe to runtime changes.
        self.retranslateUi()
        add_listener(self.retranslateUi)

        # Load saved projects from metadata database
        self._load_projects_from_database()

    def init_ui(self):
        """Initialize the UI"""
        dock_widget = QWidget()
        dock_layout = QVBoxLayout()
        dock_layout.setContentsMargins(4, 4, 4, 4)
        dock_layout.setSpacing(4)

        # Language picker row at the top of the dock.
        lang_row = QHBoxLayout()
        lang_row.setContentsMargins(0, 0, 0, 0)
        lang_row.setSpacing(4)
        self.language_label = QLabel(tr("language.label"))
        lang_row.addWidget(self.language_label)
        self._lang_buttons: Dict[str, QPushButton] = {}
        for code in supported_languages():
            btn = QPushButton(tr(f"language.{code}"))
            btn.setCheckable(True)
            btn.setMaximumWidth(36)
            btn.clicked.connect(lambda _checked=False, c=code: self._apply_language(c))
            lang_row.addWidget(btn)
            self._lang_buttons[code] = btn
        lang_row.addStretch(1)
        dock_layout.addLayout(lang_row)
        self._update_lang_button_styles()

        self.tabs = QTabWidget()
        self.tabs.addTab(self._create_guided_tab(), tr("dock.tab_guided"))
        dock_layout.addWidget(self.tabs)

        widget = QWidget()
        main_layout = QVBoxLayout()
        main_layout.setContentsMargins(6, 6, 6, 6)
        main_layout.setSpacing(6)

        self.summary_label = QLabel(tr("dock.summary"))
        self.summary_label.setWordWrap(True)
        main_layout.addWidget(self.summary_label)

        main_layout.addWidget(self._create_separator())

        advanced_splitter = QSplitter(Qt.Orientation.Vertical)
        self.advanced_splitter = advanced_splitter
        advanced_splitter.setHandleWidth(10)
        advanced_splitter.setChildrenCollapsible(False)
        tree_host = QWidget()
        # Keep this small so the splitter handle has real drag range; the
        # splitter's initial sizes ([520, 340]) still set a comfortable default.
        tree_host.setMinimumHeight(80)
        tree_layout = QVBoxLayout()
        tree_layout.setContentsMargins(0, 0, 0, 0)
        tree_layout.setSpacing(6)
        tree_host.setLayout(tree_layout)

        # Tree view
        self.tree_widget = QTreeWidget()
        self.tree_widget.setColumnCount(1)
        self.tree_widget.setHeaderLabel(tr("dock.tree_header"))
        self.tree_widget.setAlternatingRowColors(True)
        self.tree_widget.setUniformRowHeights(True)
        self.tree_widget.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        self.tree_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        self.tree_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.tree_widget.customContextMenuRequested.connect(self._on_context_menu)
        self.tree_widget.itemSelectionChanged.connect(self._on_item_selected)
        self.tree_widget.itemDoubleClicked.connect(self._on_tree_item_activated)
        tree_layout.addWidget(self.tree_widget, 1)

        tree_layout.addWidget(self._create_separator())

        self.action_hint_label = QLabel(tr("dock.action_hint"))
        self.action_hint_label.setWordWrap(True)
        self.action_hint_label.setStyleSheet("font-weight: bold; font-style: italic;")
        tree_layout.addWidget(self.action_hint_label)
        advanced_splitter.addWidget(tree_host)

        self.inspector_frame = QFrame()
        self.inspector_frame.setFrameShape(QFrame.Shape.StyledPanel)
        inspector_layout = QVBoxLayout()
        inspector_layout.setContentsMargins(8, 8, 8, 8)
        inspector_layout.setSpacing(6)

        self.inspector_name_label = QLabel(tr("dock.inspector_name_default"))
        self.inspector_name_label.setWordWrap(True)
        inspector_layout.addWidget(self.inspector_name_label)

        self.inspector_type_label = QLabel(tr("dock.inspector_type_default"))
        self.inspector_type_label.setWordWrap(True)
        inspector_layout.addWidget(self.inspector_type_label)

        self.inspector_help_label = QLabel(tr("dock.inspector_help_default"))
        self.inspector_help_label.setWordWrap(True)
        inspector_layout.addWidget(self.inspector_help_label)

        inspector_layout.addWidget(self._create_separator())

        self.action_groups_layout = QVBoxLayout()
        self.action_groups_layout.setSpacing(8)

        self.btn_new_project = QPushButton(tr("dock.btn_new_project"))
        self.btn_new_project.setToolTip(tr("dock.tip_new_project"))
        self.btn_new_project.clicked.connect(self._create_project)
        self.btn_new_assessment = QPushButton(tr("dock.btn_new_assessment"))
        self.btn_new_assessment.setToolTip(tr("dock.tip_new_assessment"))
        self.btn_new_assessment.setEnabled(False)
        self.btn_new_assessment.clicked.connect(self._create_assessment)
        self.btn_new_task_workflow = QPushButton(tr("dock.btn_new_task_workflow"))
        self.btn_new_task_workflow.setToolTip(tr("dock.tip_new_task_workflow"))
        self.btn_new_task_workflow.setEnabled(False)
        self.btn_new_task_workflow.clicked.connect(self._create_provenance)
        self.btn_new_task = QPushButton(tr("dock.btn_new_task"))
        self.btn_new_task.setToolTip(tr("dock.tip_new_task"))
        self.btn_new_task.setEnabled(False)
        self.btn_new_task.clicked.connect(self._create_workflow_step)
        self.btn_launch_workflow_editor = QPushButton(tr("dock.btn_launch_workflow_editor"))
        self.btn_launch_workflow_editor.setToolTip(tr("dock.tip_launch_workflow_editor"))
        self.btn_launch_workflow_editor.setEnabled(False)
        self.btn_launch_workflow_editor.clicked.connect(self._edit_selected_workflow_definition)
        self.btn_project_edit = QPushButton(tr("dock.btn_edit_selected"))
        self.btn_project_edit.setToolTip(tr("dock.tip_project_edit"))
        self.btn_project_edit.setEnabled(False)
        self.btn_project_edit.clicked.connect(self._edit_selected_item)
        self.btn_project_delete = QPushButton(tr("dock.btn_delete_selected"))
        self.btn_project_delete.setToolTip(tr("dock.tip_project_delete"))
        self.btn_project_delete.setEnabled(False)
        self.btn_project_delete.clicked.connect(self._delete_selected)

        self.btn_assessment_edit = QPushButton(tr("dock.btn_edit_selected"))
        self.btn_assessment_edit.setToolTip(tr("dock.tip_assessment_edit"))
        self.btn_assessment_edit.setEnabled(False)
        self.btn_assessment_edit.clicked.connect(self._edit_selected_item)
        self.btn_assessment_delete = QPushButton(tr("dock.btn_delete_selected"))
        self.btn_assessment_delete.setToolTip(tr("dock.tip_assessment_delete"))
        self.btn_assessment_delete.setEnabled(False)
        self.btn_assessment_delete.clicked.connect(self._delete_selected)

        self.btn_path_edit = QPushButton(tr("dock.btn_edit_selected"))
        self.btn_path_edit.setToolTip(tr("dock.tip_path_edit"))
        self.btn_path_edit.setEnabled(False)
        self.btn_path_edit.clicked.connect(self._edit_selected_item)
        self.btn_path_delete = QPushButton(tr("dock.btn_delete_selected"))
        self.btn_path_delete.setToolTip(tr("dock.tip_path_delete"))
        self.btn_path_delete.setEnabled(False)
        self.btn_path_delete.clicked.connect(self._delete_selected)
        self.btn_open_results = QPushButton(tr("dock.btn_open_results"))
        self.btn_open_results.setToolTip(tr("dock.tip_open_results"))
        self.btn_open_results.setEnabled(False)
        self.btn_open_results.clicked.connect(self._view_results)
        self.btn_engine_results = QPushButton(tr("dock.btn_engine_results"))
        self.btn_engine_results.setToolTip(tr("dock.tip_engine_results"))
        self.btn_engine_results.setEnabled(False)
        self.btn_engine_results.clicked.connect(self._open_engine_specific_results_viewer)
        self.btn_result_chart = QPushButton(tr("dock.btn_result_chart"))
        self.btn_result_chart.setToolTip(tr("dock.tip_result_chart"))
        self.btn_result_chart.setEnabled(False)
        self.btn_result_chart.clicked.connect(self._open_results_chart)
        self.btn_execution_history = QPushButton(tr("dock.btn_execution_history"))
        self.btn_execution_history.setToolTip(tr("dock.tip_execution_history"))
        self.btn_execution_history.setEnabled(False)
        self.btn_execution_history.clicked.connect(self._open_execution_history)
        self.btn_show_map = QPushButton(tr("dock.btn_show_map"))
        self.btn_show_map.setToolTip(tr("dock.tip_show_map"))
        self.btn_show_map.setEnabled(False)
        self.btn_show_map.clicked.connect(self._show_selected_results_on_map)
        self.btn_toggle_visibility = QPushButton(tr("dock.btn_toggle_visibility"))
        self.btn_toggle_visibility.setToolTip(tr("dock.tip_toggle_visibility"))
        self.btn_toggle_visibility.setEnabled(False)
        self.btn_toggle_visibility.clicked.connect(self._toggle_results_visibility)

        self.adv_heading_workspaces = self._create_group_heading(tr("dock.group_workspaces"))
        inspector_layout.addWidget(self.adv_heading_workspaces)
        project_row = QHBoxLayout()
        project_row.setSpacing(6)
        project_row.addWidget(self.btn_new_project)
        project_row.addWidget(self.btn_project_edit)
        project_row.addWidget(self.btn_project_delete)
        project_row.addStretch(1)
        inspector_layout.addLayout(project_row)

        self.adv_heading_study_areas = self._create_group_heading(tr("dock.group_study_areas"))
        inspector_layout.addWidget(self.adv_heading_study_areas)
        assessment_row = QHBoxLayout()
        assessment_row.setSpacing(6)
        assessment_row.addWidget(self.btn_new_assessment)
        assessment_row.addWidget(self.btn_assessment_edit)
        assessment_row.addWidget(self.btn_assessment_delete)
        assessment_row.addStretch(1)
        inspector_layout.addLayout(assessment_row)

        self.adv_heading_analyses = self._create_group_heading(tr("dock.group_analyses"))
        inspector_layout.addWidget(self.adv_heading_analyses)
        path_row = QHBoxLayout()
        path_row.setSpacing(6)
        path_row.addWidget(self.btn_new_task_workflow)
        path_row.addWidget(self.btn_new_task)
        path_row.addWidget(self.btn_path_edit)
        path_row.addWidget(self.btn_path_delete)
        path_row.addStretch(1)
        inspector_layout.addLayout(path_row)

        self.adv_heading_pipeline = self._create_group_heading(tr("dock.group_pipeline"))
        inspector_layout.addWidget(self.adv_heading_pipeline)
        workflow_row = QHBoxLayout()
        workflow_row.setSpacing(6)
        workflow_row.addWidget(self.btn_launch_workflow_editor)
        workflow_row.addStretch(1)
        inspector_layout.addLayout(workflow_row)

        self.adv_heading_results = self._create_group_heading(tr("dock.group_results"))
        inspector_layout.addWidget(self.adv_heading_results)
        results_row_1 = QHBoxLayout()
        results_row_1.setSpacing(6)
        results_row_1.addWidget(self.btn_open_results)
        results_row_1.addWidget(self.btn_engine_results)
        results_row_1.addWidget(self.btn_result_chart)
        results_row_1.addStretch(1)
        inspector_layout.addLayout(results_row_1)
        results_row_2 = QHBoxLayout()
        results_row_2.setSpacing(6)
        results_row_2.addWidget(self.btn_execution_history)
        results_row_2.addWidget(self.btn_show_map)
        results_row_2.addWidget(self.btn_toggle_visibility)
        results_row_2.addStretch(1)
        inspector_layout.addLayout(results_row_2)

        self.inspector_frame.setLayout(inspector_layout)
        inspector_scroll = QScrollArea()
        # Low minimum + resizable widget = the action panel scrolls when the
        # user drags the divider up, instead of pinning the handle in place.
        inspector_scroll.setMinimumHeight(100)
        inspector_scroll.setWidgetResizable(True)
        inspector_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        inspector_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        inspector_scroll.setWidget(self.inspector_frame)
        advanced_splitter.addWidget(inspector_scroll)
        advanced_splitter.setStretchFactor(0, 2)
        advanced_splitter.setStretchFactor(1, 1)
        advanced_splitter.setSizes([520, 340])
        advanced_splitter.handle(1).setToolTip(tr("dock.splitter_tooltip"))
        self._advanced_splitter_handle = advanced_splitter.handle(1)
        main_layout.addWidget(advanced_splitter, 1)
        widget.setLayout(main_layout)
        self.tabs.addTab(widget, tr("dock.tab_advanced"))
        dock_widget.setLayout(dock_layout)
        self.setWidget(dock_widget)
        self._refresh_selection_panel(None)
        self._refresh_guided_panel()

    def _create_guided_tab(self) -> QWidget:
        """Create the beginner-facing Guided Workbench command center."""
        widget = QWidget()
        outer_layout = QVBoxLayout()
        outer_layout.setContentsMargins(0, 0, 0, 0)
        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        content_widget = QWidget()
        layout = QVBoxLayout()
        layout.setContentsMargins(8, 8, 8, 8)
        layout.setSpacing(8)

        self.guided_context_label = QLabel(tr("guided.default_context_no_workspace"))
        self.guided_context_label.setWordWrap(True)
        self.guided_context_label.setStyleSheet("font-weight: bold;")
        layout.addWidget(self.guided_context_label)

        self.guided_status_badge = QLabel(tr("guided.default_status_start"))
        self.guided_status_badge.setWordWrap(True)
        layout.addWidget(self.guided_status_badge)

        self.guided_sequence_label = QLabel(tr("guided.sequence"))
        self.guided_sequence_label.setWordWrap(True)
        self.guided_sequence_label.setStyleSheet("font-style: italic;")
        layout.addWidget(self.guided_sequence_label)

        self.guided_help_panel = None
        self._guided_tutorial_links_label = None
        if tutorials_available():
            self.guided_help_panel = create_hint_panel(
                tr("guided.help_title"),
                tr("guided.help_body"),
                parent=content_widget,
            )
            help_panel = self.guided_help_panel
            self._guided_tutorial_links_label = create_external_links_label(
                [
                    "getting_started",
                    "create_workspace",
                    "create_study_area",
                    "first_task",
                    "review_results",
                ],
                prefix="tutorials.open_prefix",
                parent=help_panel,
            )
            help_panel.layout().addWidget(self._guided_tutorial_links_label)
            layout.addWidget(help_panel)

        panel = info_panel_colors()
        next_action_frame = QFrame()
        next_action_frame.setObjectName("guidedNextActionFrame")
        next_action_frame.setStyleSheet(
            "#guidedNextActionFrame {"
            f"background-color: {panel['background']};"
            f"border: 1px solid {panel['border']};"
            "border-radius: 6px;"
            "}"
        )
        next_action_layout = QVBoxLayout()
        next_action_layout.setContentsMargins(8, 8, 8, 8)
        next_action_layout.setSpacing(6)

        self.guided_next_label = QLabel(tr("guided.default_next_text"))
        self.guided_next_label.setWordWrap(True)
        # Explicit text color: the labels sit on the fixed light/dark card above,
        # so inheriting the palette text color would clash (white-on-light-blue
        # in dark mode). Keep the card readable in both themes.
        self.guided_next_label.setStyleSheet(
            f"font-weight: bold; color: {panel['text']}; background: transparent;"
        )
        self.guided_section_next = self._create_guided_section_heading(tr("guided.section_next"))
        self.guided_section_next.setStyleSheet(
            f"font-weight: bold; font-size: 12pt; color: {panel['text']}; background: transparent;"
        )
        next_action_layout.addWidget(self.guided_section_next)
        next_action_layout.addWidget(self.guided_next_label)

        next_row = QHBoxLayout()
        self.guided_primary_button = QPushButton(tr("guided.btn_create_workspace"))
        self.guided_primary_button.clicked.connect(self._create_guided_project)
        self.guided_secondary_button = QPushButton(tr("guided.btn_open_workspace"))
        self.guided_secondary_button.clicked.connect(self._open_workspace_dialog)
        next_row.addWidget(self.guided_primary_button)
        next_row.addWidget(self.guided_secondary_button)
        next_action_layout.addLayout(next_row)
        next_action_frame.setLayout(next_action_layout)
        layout.addWidget(next_action_frame)

        layout.addWidget(self._create_separator())
        self.guided_section_all_ops = self._create_guided_section_heading(tr("guided.section_all_ops"))
        layout.addWidget(self.guided_section_all_ops)

        self.guided_heading_workspace = self._create_group_heading(tr("guided.group_workspace"))
        layout.addWidget(self.guided_heading_workspace)
        workspace_row = QHBoxLayout()
        self.guided_create_workspace_button = QPushButton(tr("guided.btn_create_workspace"))
        self.guided_create_workspace_button.clicked.connect(self._create_guided_project)
        self.guided_open_workspace_button = QPushButton(tr("guided.btn_open_workspace"))
        self.guided_open_workspace_button.clicked.connect(self._open_workspace_dialog)
        workspace_row.addWidget(self.guided_create_workspace_button)
        workspace_row.addWidget(self.guided_open_workspace_button)
        layout.addLayout(workspace_row)

        self.guided_heading_study = self._create_group_heading(tr("guided.group_study_area"))
        layout.addWidget(self.guided_heading_study)
        study_row = QHBoxLayout()
        self.guided_create_study_button = QPushButton(tr("guided.btn_create_study"))
        self.guided_create_study_button.clicked.connect(self._create_assessment)
        self.guided_select_study_button = QPushButton(tr("guided.btn_select_study"))
        self.guided_select_study_button.clicked.connect(self._select_study_for_guided)
        study_row.addWidget(self.guided_create_study_button)
        study_row.addWidget(self.guided_select_study_button)
        layout.addLayout(study_row)
        self.guided_study_helper_label = self._create_guided_helper_label()
        layout.addWidget(self.guided_study_helper_label)

        self.guided_heading_analysis = self._create_group_heading(tr("guided.group_analysis"))
        layout.addWidget(self.guided_heading_analysis)
        analysis_row = QHBoxLayout()
        self.guided_create_analysis_button = QPushButton(tr("guided.btn_create_analysis"))
        self.guided_create_analysis_button.clicked.connect(self._create_provenance)
        self.guided_select_analysis_button = QPushButton(tr("guided.btn_select_analysis"))
        self.guided_select_analysis_button.clicked.connect(self._select_analysis_for_guided)
        analysis_row.addWidget(self.guided_create_analysis_button)
        analysis_row.addWidget(self.guided_select_analysis_button)
        layout.addLayout(analysis_row)
        self.guided_analysis_helper_label = self._create_guided_helper_label()
        layout.addWidget(self.guided_analysis_helper_label)
 
        self.guided_heading_tasks = self._create_group_heading(tr("guided.group_tasks"))
        layout.addWidget(self.guided_heading_tasks)
        task_row_1 = QHBoxLayout()
        self.guided_assess_conditions_button = QPushButton(tr("guided.btn_assess_conditions"))
        self.guided_assess_conditions_button.setToolTip(tr("guided.tip_assess_conditions"))
        self.guided_assess_conditions_button.clicked.connect(lambda: self._create_guided_task("netweaver"))
        self.guided_prioritize_button = QPushButton(tr("guided.btn_prioritize"))
        self.guided_prioritize_button.setToolTip(tr("guided.tip_prioritize"))
        self.guided_prioritize_button.clicked.connect(lambda: self._create_guided_task("prioritization"))
        task_row_1.addWidget(self.guided_assess_conditions_button)
        task_row_1.addWidget(self.guided_prioritize_button)
        layout.addLayout(task_row_1)
 
        task_row_2 = QHBoxLayout()
        self.guided_compare_button = QPushButton(tr("guided.btn_compare"))
        self.guided_compare_button.setToolTip(tr("guided.tip_compare"))
        self.guided_compare_button.clicked.connect(lambda: self._create_guided_task("mcda"))
        self.guided_portfolio_button = QPushButton(tr("guided.btn_portfolio"))
        self.guided_portfolio_button.setToolTip(tr("guided.tip_portfolio"))
        self.guided_portfolio_button.clicked.connect(lambda: self._create_guided_task("portfolio"))
        task_row_2.addWidget(self.guided_compare_button)
        task_row_2.addWidget(self.guided_portfolio_button)
        layout.addLayout(task_row_2)

        task_row_3 = QHBoxLayout()
        self.guided_select_task_button = QPushButton(tr("guided.btn_select_task"))
        self.guided_select_task_button.clicked.connect(self._select_task_for_guided)
        task_row_3.addWidget(self.guided_select_task_button)
        layout.addLayout(task_row_3)

        self.guided_tasks_helper_label = self._create_guided_helper_label()
        layout.addWidget(self.guided_tasks_helper_label)

        self.guided_heading_results = self._create_group_heading(tr("guided.group_results"))
        layout.addWidget(self.guided_heading_results)
        result_row = QHBoxLayout()
        self.guided_view_grid_button = QPushButton(tr("guided.btn_view_table"))
        self.guided_view_grid_button.clicked.connect(lambda: self._view_results())
        self.guided_view_chart_button = QPushButton(tr("guided.btn_view_chart"))
        self.guided_view_chart_button.clicked.connect(self._open_results_chart)
        self.guided_view_details_button = QPushButton(tr("guided.btn_view_hotlink"))
        self.guided_view_details_button.clicked.connect(self._open_engine_specific_results_viewer)
        result_row.addWidget(self.guided_view_grid_button)
        result_row.addWidget(self.guided_view_chart_button)
        result_row.addWidget(self.guided_view_details_button)
        layout.addLayout(result_row)
        self.guided_results_helper_label = self._create_guided_helper_label()
        layout.addWidget(self.guided_results_helper_label)

        layout.addWidget(self._create_separator())
        self.guided_heading_status = self._create_group_heading(tr("guided.section_status"))
        layout.addWidget(self.guided_heading_status)
        self.guided_activity_label = QLabel("")
        self.guided_activity_label.setWordWrap(True)
        layout.addWidget(self.guided_activity_label)
        layout.addStretch(1)

        content_widget.setLayout(layout)
        scroll_area.setWidget(content_widget)
        outer_layout.addWidget(scroll_area)
        widget.setLayout(outer_layout)
        return widget

    def connect_signals(self):
        """Connect internal signals"""
        self.item_selected.connect(self._on_item_selection_changed)
        self.executor.execution_started.connect(self._on_execution_started)
        self.executor.execution_completed.connect(self._on_execution_completed)
        self.executor.execution_failed.connect(self._on_execution_failed)

    # ------------------------------------------------------------------
    # i18n
    # ------------------------------------------------------------------

    def _apply_language(self, lang: str) -> None:
        """Picker handler: switch language at runtime."""
        if lang == get_language():
            self._update_lang_button_styles()
            return
        set_language(lang)
        # set_language() will call the listener registry, which fires
        # retranslateUi() on us and on any subscribed dialog.

    def _update_lang_button_styles(self) -> None:
        if not hasattr(self, "_lang_buttons"):
            return
        active = get_language()
        for code, btn in self._lang_buttons.items():
            btn.setChecked(code == active)
            if code == active:
                btn.setStyleSheet("font-weight: bold;")
            else:
                btn.setStyleSheet("")

    def retranslateUi(self) -> None:
        """Re-apply translated text to every translatable widget in the dock."""
        # Window + tabs
        self.setWindowTitle(tr("dock.window_title"))
        if hasattr(self, "language_label"):
            self.language_label.setText(tr("language.label"))
        if hasattr(self, "_lang_buttons"):
            for code, btn in self._lang_buttons.items():
                btn.setText(tr(f"language.{code}"))
            self._update_lang_button_styles()
        if hasattr(self, "tabs") and self.tabs.count() >= 2:
            self.tabs.setTabText(0, tr("dock.tab_guided"))
            self.tabs.setTabText(1, tr("dock.tab_advanced"))

        # Advanced tab chrome
        if hasattr(self, "summary_label"):
            self.summary_label.setText(tr("dock.summary"))
        if hasattr(self, "tree_widget"):
            self.tree_widget.setHeaderLabel(tr("dock.tree_header"))
        if hasattr(self, "action_hint_label"):
            self.action_hint_label.setText(tr("dock.action_hint"))
        if hasattr(self, "_advanced_splitter_handle"):
            self._advanced_splitter_handle.setToolTip(tr("dock.splitter_tooltip"))

        # Inspector default labels (only when nothing is selected)
        if self.selected_item is None:
            if hasattr(self, "inspector_name_label"):
                self.inspector_name_label.setText(tr("dock.inspector_name_default"))
            if hasattr(self, "inspector_type_label"):
                self.inspector_type_label.setText(tr("dock.inspector_type_default"))
            if hasattr(self, "inspector_help_label"):
                self.inspector_help_label.setText(tr("dock.inspector_help_default"))

        # Inspector group headings
        for attr, key in (
            ("adv_heading_workspaces", "dock.group_workspaces"),
            ("adv_heading_study_areas", "dock.group_study_areas"),
            ("adv_heading_analyses", "dock.group_analyses"),
            ("adv_heading_pipeline", "dock.group_pipeline"),
            ("adv_heading_results", "dock.group_results"),
        ):
            label = getattr(self, attr, None)
            if label is not None:
                label.setText(tr(key))

        # Inspector buttons + tooltips
        for attr, txt_key, tip_key in (
            ("btn_new_project", "dock.btn_new_project", "dock.tip_new_project"),
            ("btn_new_assessment", "dock.btn_new_assessment", "dock.tip_new_assessment"),
            ("btn_new_task_workflow", "dock.btn_new_task_workflow", "dock.tip_new_task_workflow"),
            ("btn_new_task", "dock.btn_new_task", "dock.tip_new_task"),
            ("btn_launch_workflow_editor", "dock.btn_launch_workflow_editor", "dock.tip_launch_workflow_editor"),
            ("btn_project_edit", "dock.btn_edit_selected", "dock.tip_project_edit"),
            ("btn_project_delete", "dock.btn_delete_selected", "dock.tip_project_delete"),
            ("btn_assessment_edit", "dock.btn_edit_selected", "dock.tip_assessment_edit"),
            ("btn_assessment_delete", "dock.btn_delete_selected", "dock.tip_assessment_delete"),
            ("btn_path_edit", "dock.btn_edit_selected", "dock.tip_path_edit"),
            ("btn_path_delete", "dock.btn_delete_selected", "dock.tip_path_delete"),
            ("btn_open_results", "dock.btn_open_results", "dock.tip_open_results"),
            ("btn_engine_results", "dock.btn_engine_results", "dock.tip_engine_results"),
            ("btn_result_chart", "dock.btn_result_chart", "dock.tip_result_chart"),
            ("btn_execution_history", "dock.btn_execution_history", "dock.tip_execution_history"),
            ("btn_show_map", "dock.btn_show_map", "dock.tip_show_map"),
            ("btn_toggle_visibility", "dock.btn_toggle_visibility", "dock.tip_toggle_visibility"),
        ):
            btn = getattr(self, attr, None)
            if btn is not None:
                btn.setText(tr(txt_key))
                btn.setToolTip(tr(tip_key))

        # Guided tab static labels (the dynamic state-driven labels updated
        # by _refresh_guided_panel will refresh on next selection change).
        if hasattr(self, "guided_sequence_label"):
            self.guided_sequence_label.setText(tr("guided.sequence"))
        help_panel = getattr(self, "guided_help_panel", None)
        if help_panel is not None:
            help_panel.title_label.setText(tr("guided.help_title"))
            help_panel.body_label.setText(tr("guided.help_body"))
            link_label = getattr(help_panel, "link_label", None)
            if link_label is not None and hasattr(link_label, "retranslate"):
                link_label.retranslate()
        tutorial_links_label = getattr(self, "_guided_tutorial_links_label", None)
        if tutorial_links_label is not None:
            retranslate = getattr(tutorial_links_label, "retranslate", None)
            if callable(retranslate):
                retranslate()
        if hasattr(self, "guided_section_next"):
            self.guided_section_next.setText(tr("guided.section_next"))
        if hasattr(self, "guided_section_all_ops"):
            self.guided_section_all_ops.setText(tr("guided.section_all_ops"))
        for attr, key in (
            ("guided_heading_workspace", "guided.group_workspace"),
            ("guided_heading_study", "guided.group_study_area"),
            ("guided_heading_analysis", "guided.group_analysis"),
            ("guided_heading_tasks", "guided.group_tasks"),
            ("guided_heading_results", "guided.group_results"),
            ("guided_heading_status", "guided.section_status"),
        ):
            label = getattr(self, attr, None)
            if label is not None:
                label.setText(tr(key))
        for attr, key in (
            ("guided_create_workspace_button", "guided.btn_create_workspace"),
            ("guided_open_workspace_button", "guided.btn_open_workspace"),
            ("guided_create_study_button", "guided.btn_create_study"),
            ("guided_select_study_button", "guided.btn_select_study"),
            ("guided_create_analysis_button", "guided.btn_create_analysis"),
            ("guided_select_analysis_button", "guided.btn_select_analysis"),
            ("guided_select_task_button", "guided.btn_select_task"),
            ("guided_view_grid_button", "guided.btn_view_table"),
            ("guided_view_chart_button", "guided.btn_view_chart"),
            ("guided_view_details_button", "guided.btn_view_hotlink"),
        ):
            btn = getattr(self, attr, None)
            if btn is not None:
                btn.setText(tr(key))
        for attr, txt_key, tip_key in (
            ("guided_assess_conditions_button", "guided.btn_assess_conditions", "guided.tip_assess_conditions"),
            ("guided_prioritize_button", "guided.btn_prioritize", "guided.tip_prioritize"),
            ("guided_compare_button", "guided.btn_compare", "guided.tip_compare"),
            ("guided_portfolio_button", "guided.btn_portfolio", "guided.tip_portfolio"),
        ):
            btn = getattr(self, attr, None)
            if btn is not None:
                btn.setText(tr(txt_key))
                btn.setToolTip(tr(tip_key))

        # Refresh state-driven panels so dynamic strings flip language too.
        if hasattr(self, "guided_context_label"):
            try:
                self._refresh_guided_panel()
            except Exception:
                pass
        if hasattr(self, "summary_label"):
            try:
                self._refresh_selection_panel(self.selected_path)
            except Exception:
                pass

    @staticmethod
    def _create_separator() -> QFrame:
        """Create a lightweight divider that respects the active QGIS palette."""
        divider = QFrame()
        divider.setFrameShape(QFrame.Shape.HLine)
        divider.setFrameShadow(QFrame.Shadow.Sunken)
        return divider

    def _all_context_buttons(self) -> List[QPushButton]:
        """Return the full set of dock actions managed by the inspector."""
        return [
            self.btn_new_project,
            self.btn_new_assessment,
            self.btn_new_task_workflow,
            self.btn_new_task,
            self.btn_launch_workflow_editor,
            self.btn_project_edit,
            self.btn_project_delete,
            self.btn_assessment_edit,
            self.btn_assessment_delete,
            self.btn_path_edit,
            self.btn_path_delete,
            self.btn_open_results,
            self.btn_engine_results,
            self.btn_result_chart,
            self.btn_execution_history,
            self.btn_show_map,
            self.btn_toggle_visibility,
        ]

    @staticmethod
    def _create_group_heading(text: str) -> QLabel:
        label = QLabel(text)
        label.setStyleSheet("font-weight: bold;")
        return label

    @staticmethod
    def _create_guided_section_heading(text: str) -> QLabel:
        label = QLabel(text)
        label.setStyleSheet("font-weight: bold; font-size: 12pt;")
        return label

    @staticmethod
    def _create_guided_helper_label() -> QLabel:
        label = QLabel("")
        label.setWordWrap(True)
        label.setStyleSheet(f"color: {muted_text_color()}; font-style: italic;")
        label.hide()
        return label

    def _refresh_guided_panel(self) -> None:
        """Refresh the Guided Workbench command center from the current selection."""
        if not hasattr(self, "guided_context_label"):
            return

        workspace_id = (self.selected_path or {}).get("workspace_id") or (self.selected_path or {}).get("project_id")
        study_id = (self.selected_path or {}).get("study_id") or (self.selected_path or {}).get("assessment_id")
        analysis_id = (self.selected_path or {}).get("analysis_id") or (self.selected_path or {}).get("provenance_id")
        task_id = (self.selected_path or {}).get("task_id") or (self.selected_path or {}).get("workflow_id")
        result_id = (self.selected_path or {}).get("result_id") or (self.selected_path or {}).get("results_id")

        workspace = self.project_manager.get_project(workspace_id) if workspace_id else None
        study = self.project_manager.get_assessment(workspace_id, study_id) if workspace_id and study_id else None
        analysis = self.project_manager.get_provenance(workspace_id, study_id, analysis_id) if workspace_id and study_id and analysis_id else None
        available_studies = list(workspace.assessments) if workspace and workspace.assessments else []

        none_text = tr("guided_state.name_none")
        workspace_name = workspace.name if workspace else none_text
        study_name = study.name if study else none_text
        analysis_name = analysis.name if analysis else none_text
        self.guided_context_label.setText(
            tr("guided_state.context_lines", workspace=workspace_name, study=study_name, analysis=analysis_name)
        )

        has_workspace = workspace is not None
        has_study = study is not None
        has_analysis = analysis is not None
        workflow_step = self._resolve_selected_workflow_step() if (task_id or result_id) else None
        has_result = bool(result_id)
        chart_supported = bool(
            workflow_step is not None
            and workflow_step.engine_type in {EngineType.NETWEAVER, EngineType.CDP}
        )

        if not has_workspace:
            status = tr("guided_state.status_start")
            next_text = tr("guided_state.next_create_workspace")
            primary_text, primary_handler = tr("guided_state.btn_create_workspace"), self._create_guided_project
            secondary_text, secondary_handler = tr("guided_state.btn_open_workspace"), self._open_workspace_dialog
        elif not has_study:
            status = tr("guided_state.status_workspace_ready")
            next_text = tr("guided_state.next_create_study")
            primary_text, primary_handler = tr("guided_state.btn_create_study"), self._create_assessment
            if available_studies:
                secondary_text, secondary_handler = tr("guided_state.btn_select_study"), self._select_study_for_guided
            else:
                secondary_text, secondary_handler = tr("guided_state.btn_open_workspace"), self._open_workspace_dialog
        elif not has_analysis:
            status = tr("guided_state.status_study_ready")
            next_text = tr("guided_state.next_create_analysis")
            primary_text, primary_handler = tr("guided_state.btn_create_analysis"), self._create_provenance
            secondary_text, secondary_handler = tr("guided_state.btn_create_study"), self._create_assessment
        elif has_result:
            status = tr("guided_state.status_result_selected")
            next_text = tr("guided_state.next_review_result")
            primary_text, primary_handler = tr("guided_state.btn_view_table"), self._view_results
            secondary_text, secondary_handler = tr("guided_state.btn_show_on_map"), self._show_selected_results_on_map
        elif task_id:
            status = tr("guided_state.status_task_selected")
            next_text = tr("guided_state.next_run_task")
            primary_text, primary_handler = tr("guided_state.btn_run_task"), self._run_selected_task
            secondary_text, secondary_handler = tr("guided_state.btn_view_hotlink"), self._open_engine_specific_results_viewer
        else:
            status = tr("guided_state.status_analysis_ready")
            next_text = tr("guided_state.next_add_task")
            primary_text, primary_handler = tr("guided_state.btn_prioritize"), lambda: self._create_guided_task("prioritization")
            secondary_text, secondary_handler = tr("guided_state.btn_assess_conditions"), lambda: self._create_guided_task("netweaver")

        self.guided_status_badge.setText(status)
        self.guided_next_label.setText(next_text)
        self.guided_primary_button.setText(primary_text)
        self._reconnect_button(self.guided_primary_button, primary_handler)
        self.guided_secondary_button.setText(secondary_text)
        self._reconnect_button(self.guided_secondary_button, secondary_handler)

        self._set_guided_helper_text(self.guided_study_helper_label, "" if has_workspace else tr("guided_state.helper_open_workspace_first"))
        self._set_guided_helper_text(self.guided_analysis_helper_label, "" if has_study else tr("guided_state.helper_create_study_first"))
        self._set_guided_helper_text(self.guided_tasks_helper_label, "" if has_analysis else tr("guided_state.helper_create_analysis_first"))
        self._set_guided_helper_text(self.guided_results_helper_label, "" if (task_id or result_id) else tr("guided_state.helper_run_task_first"))

        for button in (
            self.guided_create_study_button,
            self.guided_select_study_button,
        ):
            button.setEnabled(has_workspace)
        for button in (
            self.guided_create_analysis_button,
            self.guided_select_analysis_button,
        ):
            button.setEnabled(has_study)
        for button in (
            self.guided_assess_conditions_button,
            self.guided_prioritize_button,
            self.guided_compare_button,
            self.guided_portfolio_button,
            self.guided_select_task_button,
        ):
            button.setEnabled(has_analysis)
        for button in (
            self.guided_view_grid_button,
            self.guided_view_details_button,
        ):
            button.setEnabled(bool(task_id or result_id))
        self.guided_view_chart_button.setEnabled(chart_supported)

        self.guided_activity_label.setText(
            tr(
                "guided_state.activity_lines",
                workspace=workspace_name,
                study=study_name,
                analysis=analysis_name,
                next_text=next_text,
            )
        )

    @staticmethod
    def _set_guided_helper_text(label: QLabel, text: str) -> None:
        label.setText(text)
        label.setVisible(bool(text))

    @staticmethod
    def _reconnect_button(button: QPushButton, handler: Callable[[], None]) -> None:
        try:
            button.clicked.disconnect()
        except Exception:
            pass
        button.clicked.connect(lambda _checked=False: handler())

    def _select_study_for_guided(self) -> None:
        workspace_id = (self.selected_path or {}).get("project_id") or (self.selected_path or {}).get("workspace_id")
        workspace = self.project_manager.get_project(workspace_id) if workspace_id else None
        if not workspace or not workspace.assessments:
            QMessageBox.information(self, "Select Study Area", "No Study Areas are available in the current Workspace.")
            return

        dialog = QDialog(self)
        dialog.setWindowTitle("Select Study Area")
        dialog_layout = QVBoxLayout(dialog)
        dialog_layout.addWidget(QLabel("Choose the Study Area you want to make current for this Workspace."))

        list_widget = QListWidget(dialog)
        current_study_id = (self.selected_path or {}).get("study_id") or (self.selected_path or {}).get("assessment_id")
        current_row = 0
        for row, assessment in enumerate(workspace.assessments):
            list_widget.addItem(assessment.name)
            item = list_widget.item(row)
            item.setData(Qt.ItemDataRole.UserRole, assessment.id)
            if assessment.id == current_study_id:
                current_row = row
        list_widget.setCurrentRow(current_row)
        dialog_layout.addWidget(list_widget)

        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=dialog)
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        dialog_layout.addWidget(buttons)

        if exec_dialog(dialog) != QDialog.DialogCode.Accepted:
            return

        current_item = list_widget.currentItem()
        if current_item is None:
            QMessageBox.information(self, "Select Study Area", "Choose a Study Area to continue.")
            return

        study_id = current_item.data(Qt.ItemDataRole.UserRole)
        self._select_assessment_in_tree(study_id)
        self._refresh_guided_panel()

    def _select_analysis_for_guided(self) -> None:
        workspace_id = (self.selected_path or {}).get("project_id") or (self.selected_path or {}).get("workspace_id")
        study_id = (self.selected_path or {}).get("study_id") or (self.selected_path or {}).get("assessment_id")
        assessment = self.project_manager.get_assessment(workspace_id, study_id) if workspace_id and study_id else None
        if not assessment or not assessment.provenances:
            QMessageBox.information(self, "Select Analysis", "No Analyses are available in the current Study Area.")
            return

        dialog = QDialog(self)
        dialog.setWindowTitle("Select Analysis")
        dialog_layout = QVBoxLayout(dialog)
        dialog_layout.addWidget(QLabel("Choose the Analysis you want to make current for this Study Area."))

        list_widget = QListWidget(dialog)
        current_analysis_id = (self.selected_path or {}).get("provenance_id")
        current_row = 0
        for row, provenance in enumerate(assessment.provenances):
            list_widget.addItem(provenance.name)
            item = list_widget.item(row)
            item.setData(Qt.ItemDataRole.UserRole, provenance.id)
            if provenance.id == current_analysis_id:
                current_row = row
        list_widget.setCurrentRow(current_row)
        dialog_layout.addWidget(list_widget)

        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=dialog)
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        dialog_layout.addWidget(buttons)

        if exec_dialog(dialog) != QDialog.DialogCode.Accepted:
            return

        current_item = list_widget.currentItem()
        if current_item is None:
            QMessageBox.information(self, "Select Analysis", "Choose an Analysis to continue.")
            return

        analysis_id = current_item.data(Qt.ItemDataRole.UserRole)
        self._select_provenance_in_tree(analysis_id)
        self._refresh_guided_panel()

    def _select_task_for_guided(self) -> None:
        workspace_id = (self.selected_path or {}).get("project_id") or (self.selected_path or {}).get("workspace_id")
        study_id = (self.selected_path or {}).get("study_id") or (self.selected_path or {}).get("assessment_id")
        analysis_id = (self.selected_path or {}).get("analysis_id") or (self.selected_path or {}).get("provenance_id")

        provenance = self.project_manager.get_provenance(workspace_id, study_id, analysis_id) if workspace_id and study_id and analysis_id else None
        if not provenance or not provenance.workflow_steps:
            QMessageBox.information(self, "Select Task", "No Tasks are available in the current Analysis.")
            return

        dialog = QDialog(self)
        dialog.setWindowTitle("Select Task")
        dialog_layout = QVBoxLayout(dialog)
        dialog_layout.addWidget(QLabel("Choose the Task you want to select."))

        list_widget = QListWidget(dialog)
        current_task_id = (self.selected_path or {}).get("task_id") or (self.selected_path or {}).get("workflow_id")
        current_row = 0

        def collect_tasks(steps, depth=0):
            flat_list = []
            for step in steps:
                flat_list.append((step, depth))
                if step.children:
                    flat_list.extend(collect_tasks(step.children, depth + 1))
            return flat_list

        all_tasks = collect_tasks(provenance.workflow_steps)
        for row, (step, depth) in enumerate(all_tasks):
            indent = "    " * depth
            list_widget.addItem(f"{indent}{step.name}")
            item = list_widget.item(row)
            item.setData(Qt.ItemDataRole.UserRole, step.id)
            if step.id == current_task_id:
                current_row = row

        list_widget.setCurrentRow(current_row)
        dialog_layout.addWidget(list_widget)

        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=dialog)
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        dialog_layout.addWidget(buttons)

        if exec_dialog(dialog) != QDialog.DialogCode.Accepted:
            return

        current_item = list_widget.currentItem()
        if current_item is None:
            QMessageBox.information(self, "Select Task", "Choose a Task to continue.")
            return

        task_id = current_item.data(Qt.ItemDataRole.UserRole)
        self._select_workflow_step_in_tree(task_id)
        self._refresh_guided_panel()

    def _create_guided_task(self, task_kind: str) -> None:
        """Create a beginner-facing task without exposing workflow definitions."""
        if not self.selected_path or not self.selected_path.get("provenance_id"):
            QMessageBox.information(self, "Create Task", "Create or select an Analysis first.")
            return

        if task_kind == "netweaver":
            task = self._create_netweaver_workflow_step()
        elif task_kind == "portfolio":
            task = self._create_portfolio_workflow_step()
        else:
            task = self._create_cdp_workflow_step()

        if task is None:
            return

        self._insert_workflow_step(
            task,
            success_title="Task Created",
            success_template="Task '{name}' created.",
        )

    def _insert_workflow_step(
        self,
        workflow_step: WorkflowStep,
        *,
        success_title: str = "Success",
        success_template: str = "Task '{name}' created",
    ) -> bool:
        """Insert a task under the selected task/result, or under the selected Analysis."""
        if not self.selected_path or not self.selected_path.get("provenance_id"):
            QMessageBox.warning(self, "Selection Required", "Please select an Analysis or Task first")
            return False

        project = self.project_manager.get_project(self.selected_path["project_id"])
        assessment = self.project_manager.get_assessment(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"],
        )
        provenance = self.project_manager.get_provenance(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"],
            self.selected_path["provenance_id"],
        )
        if project is None or assessment is None or provenance is None:
            QMessageBox.warning(self, "Create Task", "Could not resolve the selected Workspace, Study Area, or Analysis.")
            return False

        parent_step = self._resolve_selected_workflow_step()
        parent_task_id = parent_step.id if parent_step is not None else ""
        if parent_step is not None:
            if not parent_step.add_child(workflow_step):
                QMessageBox.warning(self, "Error", "Maximum nesting depth (6) reached")
                return False
        else:
            provenance.add_workflow_step(workflow_step)

        if not self._persist_workflow_step_metadata(
            project,
            assessment,
            provenance,
            workflow_step,
            parent_task_id=parent_task_id,
        ):
            QMessageBox.warning(
                self,
                "Metadata Warning",
                "Task metadata could not be saved to the shared EMDS metadata database.",
            )

        # Link the task to a registry-imported model snapshot (provenance item 6).
        # When the create dialog imported a published model version, it stored the
        # embedded+referenced model_snapshots id; persist that linkage on the task.
        registry_snapshot_id = str(
            (workflow_step.parameters or {}).get("model_snapshot_id")
            or (getattr(workflow_step, "metadata", {}) or {}).get("model_snapshot_id")
            or ""
        ).strip()
        if registry_snapshot_id:
            try:
                self.metadata_manager.set_task_model_snapshot(workflow_step.id, registry_snapshot_id)
            except Exception as exc:
                debug_logger.warning("Could not link task to registry snapshot", {
                    "workflow_step_id": workflow_step.id,
                    "exception": str(exc),
                })

        self._populate_tree()
        if not self._select_workflow_step_in_tree(workflow_step.id):
            debug_logger.warning("Could not auto-select created task", {
                "workflow_step_id": workflow_step.id,
                "workflow_step_name": workflow_step.name,
            })
        self._persist_project_snapshots(project)
        self.workflow_step_created.emit(workflow_step)

        if workflow_step.parameters.get("run_immediately"):
            self._execute_workflow_step_immediately(workflow_step)
        else:
            QMessageBox.information(
                self,
                success_title,
                success_template.format(name=workflow_step.name),
            )
        return True

    def _set_button_visibility(self, button: QPushButton, visible: bool, enabled: Optional[bool] = None) -> None:
        button.setVisible(visible)
        button.setEnabled(visible if enabled is None else enabled)

    def _set_context_actions(self, node_type: Optional[str]) -> None:
        """Show grouped actions for the current selection while keeping the dock readable."""
        node_type = EMDSTreeModel.normalize_node_type(node_type)
        is_project = node_type == "workspace"
        is_assessment = node_type == "study"
        is_provenance = node_type == "analysis"
        is_workflow = node_type == "task"
        is_result = node_type == "result"
        workflow_step = self._resolve_selected_workflow_step() if (is_workflow or is_result) else None

        self._set_button_visibility(self.btn_new_project, True, True)
        self._set_button_visibility(self.btn_project_edit, is_project, is_project)
        self._set_button_visibility(self.btn_project_delete, is_project, is_project)

        self._set_button_visibility(self.btn_new_assessment, is_project, is_project)
        self._set_button_visibility(self.btn_assessment_edit, is_assessment, is_assessment)
        self._set_button_visibility(self.btn_assessment_delete, is_assessment, is_assessment)

        self._set_button_visibility(self.btn_new_task_workflow, is_assessment, is_assessment)
        self._set_button_visibility(self.btn_new_task, is_provenance or is_workflow, is_provenance or is_workflow)
        self._set_button_visibility(self.btn_path_edit, is_provenance or is_workflow, is_provenance or is_workflow)
        self._set_button_visibility(self.btn_path_delete, is_provenance or is_workflow, is_provenance or is_workflow)

        self._set_button_visibility(self.btn_launch_workflow_editor, is_assessment or is_provenance or is_workflow, is_assessment or is_provenance or is_workflow)
        has_task_context = workflow_step is not None
        self._set_button_visibility(self.btn_open_results, is_workflow or is_result, has_task_context)
        self._set_button_visibility(self.btn_engine_results, is_workflow or is_result, has_task_context)
        chart_supported = bool(
            workflow_step is not None
            and workflow_step.engine_type in {EngineType.NETWEAVER, EngineType.CDP}
        )
        self._set_button_visibility(self.btn_result_chart, is_workflow or is_result, chart_supported)
        self._set_button_visibility(self.btn_execution_history, is_workflow or is_result, has_task_context)
        self._set_button_visibility(self.btn_show_map, is_result, is_result)
        self._set_button_visibility(self.btn_toggle_visibility, is_result, is_result)

        if is_workflow:
            self.btn_new_task.setText(tr("selection.btn_add_subtask"))
            self.btn_new_task.setToolTip(tr("selection.tip_add_subtask"))
        else:
            self.btn_new_task.setText(tr("dock.btn_new_task"))
            self.btn_new_task.setToolTip(tr("dock.tip_new_task"))

        self.btn_new_assessment.setText(tr("dock.btn_new_assessment"))
        self.btn_new_task_workflow.setText(tr("dock.btn_new_task_workflow"))
        self.btn_launch_workflow_editor.setText(tr("dock.btn_launch_workflow_editor"))
        if workflow_step and workflow_step.engine_type == EngineType.NETWEAVER:
            self.btn_engine_results.setText(tr("selection.btn_engine_netweaver"))
        elif workflow_step and workflow_step.engine_type == EngineType.CDP:
            self.btn_engine_results.setText(tr("selection.btn_engine_cdp"))
        else:
            self.btn_engine_results.setText(tr("dock.btn_engine_results"))

    def _edit_selected_item(self) -> None:
        """Edit the currently selected node using the best available editor."""
        selected_item, data = self._resolve_selected_tree_item()
        if selected_item is None or data is None:
            QMessageBox.information(self, "Edit", "Select an item to edit.")
            return
        self._edit_item(data)

    @staticmethod
    def _selection_explanations() -> Dict[str, str]:
        return {
            "workspace": "Workspaces contain study areas.",
            "study": "Study Areas define the GIS scope for one or more analyses.",
            "analysis": "Analyses group runnable tasks.",
            "task": "Tasks run an engine and can produce results or child subtasks.",
            "result": "Results can be inspected, opened, or shown on the map.",
        }

    @staticmethod
    def _selection_hints() -> Dict[str, str]:
        return {
            "workspace": "Create a study area to define the GIS scope.",
            "study": "Create an analysis to organize tasks.",
            "analysis": "Add a task to run an EMDS engine.",
            "task": "Run this task or add a subtask that inherits its output.",
            "result": "Open this result or show it on the map.",
        }

    def _selection_display_name(self, data: Optional[TreeNodeData]) -> str:
        """Return a concise human-readable name for the current selection."""
        if data is None:
            return tr("selection.inspector_name_none")

        item = data.item
        node_type = EMDSTreeModel.normalize_node_type(data.node_type)
        if node_type == "result":
            return getattr(item, "layer_name", "Result")
        return getattr(item, "name", self._describe_node_type(data.node_type).title())

    def _refresh_selection_panel(self, path: Optional[Dict[str, Any]]) -> None:
        """Refresh header text, inspector details, and contextual actions."""
        item, data = self._resolve_selected_tree_item()
        node_type = data.node_type if data is not None else self._get_node_type_for_path(path)
        workflow_step = self._resolve_workflow_step_for_item(item, path)

        if node_type is None:
            self.summary_label.setText(tr("dock.summary"))
            self.inspector_name_label.setText(tr("dock.inspector_name_default"))
            self.inspector_type_label.setText(tr("dock.inspector_type_default"))
            self.inspector_help_label.setText(tr("dock.inspector_help_default"))
            self.btn_new_task.setText(tr("dock.btn_new_task"))
            self._set_context_actions(None)
            self._refresh_guided_panel()
            return

        display_name = self._selection_display_name(data)
        explanation = self._selection_explanations().get(node_type, "")
        hint = self._selection_hints().get(node_type, "")
        self.summary_label.setText(tr("selection.summary_selected"))

        self.inspector_name_label.setText(tr("selection.inspector_name_selected", name=display_name))
        node_label = EMDSTreeModel.NODE_LABELS.get(node_type, node_type.title())
        self.inspector_type_label.setText(tr("selection.inspector_type_selected", type=node_label, explanation=explanation))
        self.inspector_help_label.setText(tr("selection.inspector_help_selected", hint=hint))
        self._set_context_actions(node_type)
        self._refresh_guided_panel()

    def _get_available_layers(self):
        """Get the layers offered to the Create Study Area / workspace wizards.

        Scoped to the "Base Layers" group so the wizard lists only the original
        project inputs, not analysis/task outputs from prior runs. Before a
        "Base Layers" group exists (first workspace creation), this falls back
        to all currently loaded layers.
        """
        try:
            return self.project_handler.get_base_layer_names()
        except Exception:
            return []

    def _create_busy_dialog(self, *, title: str, label: str) -> QProgressDialog:
        """Create and immediately show an indeterminate busy dialog."""
        dialog = QProgressDialog(label, None, 0, 0, self)
        dialog.setWindowTitle(title)
        dialog.setWindowModality(Qt.WindowModality.WindowModal)
        dialog.setMinimumDuration(0)
        dialog.setAutoClose(False)
        dialog.setAutoReset(False)
        dialog.show()
        QApplication.processEvents()
        return dialog

    @staticmethod
    def _update_busy_dialog(dialog: Optional[QProgressDialog], label: str) -> None:
        """Refresh the text of an existing busy dialog and flush pending paint events."""
        if dialog is None:
            return
        dialog.setLabelText(label)
        QApplication.processEvents()

    @staticmethod
    def _engine_type_from_value(engine_value: str) -> EngineType:
        """Resolve persisted engine values back to the enum."""
        for engine_type in EngineType:
            if engine_type.value == engine_value:
                return engine_type
        return EngineType.NETWEAVER

    def _get_project_directory(self, project: Project) -> Optional[Path]:
        """Resolve the on-disk directory for a project."""
        metadata = getattr(project, "metadata", {}) or {}
        directory = metadata.get("directory")

        if not directory:
            registry_entry = self.metadata_manager.get_project(project.id)
            if registry_entry:
                directory = (registry_entry.metadata or {}).get("directory")
                if not directory:
                    directory = str(Path(registry_entry.project_file).parent)

        if not directory:
            return None

        return Path(directory)

    def _get_project_metadata_manager(self, project: Project) -> Optional[ProjectMetadataManager]:
        """Return the shared EMDS metadata manager."""
        return self.metadata_manager

    def _serialize_project_tree_snapshot(self, project: Project) -> str:
        """Serialize the EMDS hierarchy for one project into XML."""
        root = ET.Element("Project", attrib={"Id": project.id, "Name": project.name})
        if project.description:
            root.set("Description", project.description)

        for assessment in project.assessments:
            assessment_el = ET.SubElement(
                root,
                "Assessment",
                attrib={"Id": assessment.id, "Name": assessment.name},
            )
            if assessment.description:
                assessment_el.set("Description", assessment.description)

            for provenance in assessment.provenances:
                provenance_el = ET.SubElement(
                    assessment_el,
                    "Provenance",
                    attrib={"Id": provenance.id, "Name": provenance.name},
                )
                if provenance.description:
                    provenance_el.set("Description", provenance.description)

                for workflow_step in provenance.workflow_steps:
                    self._append_workflow_step_xml(provenance_el, workflow_step)

        return ET.tostring(root, encoding="unicode")

    def _append_workflow_step_xml(self, parent_element, workflow_step: WorkflowStep) -> None:
        """Append workflow/task nodes recursively to the EMDS tree snapshot."""
        task_el = ET.SubElement(
            parent_element,
            "Task",
            attrib={
                "Id": workflow_step.id,
                "Name": workflow_step.name,
                "EngineType": workflow_step.engine_type.value,
            },
        )
        if workflow_step.results:
            ET.SubElement(
                task_el,
                "Results",
                attrib={
                    "Id": workflow_step.results.id,
                    "LayerName": workflow_step.results.layer_name,
                    "FeatureCount": str(workflow_step.results.feature_count),
                    "LayerType": workflow_step.results.layer_type,
                },
            )
        for child_step in workflow_step.children:
            self._append_workflow_step_xml(task_el, child_step)

    def _serialize_map_table_of_contents(self) -> str:
        """Capture the current QGIS TOC as JSON for project reload/debug support."""
        try:
            from qgis.core import QgsProject

            def _node_to_dict(node):
                if hasattr(node, "layerId"):
                    return {
                        "type": "layer",
                        "name": node.name(),
                        "layer_id": node.layerId(),
                        "visible": bool(node.isVisible()),
                    }
                return {
                    "type": "group",
                    "name": node.name(),
                    "expanded": bool(node.isExpanded()),
                    "children": [_node_to_dict(child) for child in node.children()],
                }

            root = QgsProject.instance().layerTreeRoot()
            payload = {"children": [_node_to_dict(child) for child in root.children()]}
            return json.dumps(payload, ensure_ascii=True)
        except Exception as exc:
            debug_logger.warning("Could not serialize map table of contents", {"error": str(exc)})
            return ""

    def _persist_project_snapshots(self, project: Optional[Project]) -> None:
        """Update the shared Workspace row with current EMDS tree and map TOC snapshots."""
        if project is None:
            return
        metadata = dict(getattr(project, "metadata", {}) or {})
        metadata["xml_project_tree"] = self._serialize_project_tree_snapshot(project)
        metadata["map_table_of_content"] = self._serialize_map_table_of_contents()
        resolved_project_file = str(
            self._resolve_project_file_path(project)
            or metadata.get("qgis_project_file", "")
            or metadata.get("project_file", "")
        )
        metadata.setdefault("qgis_project_file", resolved_project_file)
        metadata.setdefault("project_file", resolved_project_file)
        metadata.setdefault("input_layers", metadata.get("input_layers", []))
        project.metadata = metadata
        if not self.metadata_manager.update_project(project.id, metadata=metadata):
            debug_logger.warning("Could not persist project snapshots", {"project_id": project.id})
        self._save_current_emds_project_if_dirty(project)

    def _save_current_emds_project_if_dirty(self, project: Optional[Project]) -> bool:
        """Save plugin-owned QGIS project changes so QGIS does not prompt on exit.

        EMDS updates the map/table-of-contents while creating workspaces, Study Areas,
        tasks, and results. Those are intentional plugin changes, so save the current
        EMDS-owned project after the metadata snapshot has been persisted. The guard
        avoids silently saving unrelated user projects.
        """
        if project is None:
            return False

        try:
            from qgis.core import QgsProject
        except ImportError:
            return False

        project_file = self._resolve_project_file_path(project)
        if project_file is None:
            return False

        qgis_project = QgsProject.instance()
        current_file = str(qgis_project.fileName() or "").strip()
        if not current_file:
            return False

        try:
            current_path = Path(current_file).resolve()
            expected_path = project_file.resolve()
        except Exception:
            current_path = Path(current_file)
            expected_path = project_file

        if str(current_path).lower() != str(expected_path).lower():
            debug_logger.debug("Skipping automatic project save; current project is not the EMDS workspace", {
                "project_id": project.id,
                "current_project_file": str(current_path),
                "emds_project_file": str(expected_path),
            })
            return False

        try:
            is_dirty = bool(qgis_project.isDirty()) if hasattr(qgis_project, "isDirty") else True
            if not is_dirty:
                return True

            saved = bool(qgis_project.write())
            QApplication.processEvents()
            if saved and hasattr(qgis_project, "setDirty"):
                qgis_project.setDirty(False)
                QApplication.processEvents()
            if not saved:
                debug_logger.warning("Could not automatically save EMDS QGIS project", {
                    "project_id": project.id,
                    "project_file": str(expected_path),
                })
                return False

            debug_logger.debug("Automatically saved EMDS QGIS project", {
                "project_id": project.id,
                "project_file": str(expected_path),
            })
            return True
        except Exception as exc:
            debug_logger.warning("Automatic EMDS QGIS project save failed", {
                "project_id": project.id,
                "project_file": str(expected_path),
                "exception": str(exc),
            })
            return False

    def _resolve_project_file_path(self, project: Project) -> Optional[Path]:
        """Resolve the saved QGIS project file path for a Workspace tree node."""
        metadata = getattr(project, "metadata", {}) or {}
        project_file = metadata.get("qgis_project_file") or metadata.get("project_file")

        if not project_file:
            registry_entry = self.metadata_manager.get_project(project.id)
            if registry_entry is not None:
                project_file = registry_entry.project_file

        if not project_file:
            return None

        resolved = Path(project_file)
        return resolved if resolved.exists() else None

    def _open_project_for_path(self, path: Optional[Dict[str, Optional[str]]] = None) -> bool:
        """Open the saved QGIS project associated with a tree selection path."""
        selection_path = path or self.selected_path
        if not selection_path or not selection_path.get("project_id"):
            QMessageBox.warning(self, "Open Workspace", "Please select an EMDS Workspace first.")
            return False

        project = self.project_manager.get_project(selection_path["project_id"])
        if project is None:
            QMessageBox.warning(self, "Open Workspace", "Could not resolve the selected Workspace.")
            return False

        project_file = self._resolve_project_file_path(project)
        if project_file is None:
            QMessageBox.warning(
                self,
                "Open Workspace",
                "No saved QGIS project file was found for the selected EMDS Workspace.",
            )
            return False

        if not self.project_handler.load_project(str(project_file)):
            QMessageBox.warning(
                self,
                "Open Workspace",
                f"Could not open the saved QGIS project:\n{project_file}",
            )
            return False

        project.metadata["project_file"] = str(project_file)
        if not self._select_tree_item_for_path(selection_path):
            self.selected_path = selection_path
            self._apply_layer_visibility_for_selection(selection_path)
        return True

    def _open_workspace_dialog(self) -> None:
        """Let the user select and open a saved workspace."""
        workspaces = self.metadata_manager.get_all_projects()
        if not workspaces:
            QMessageBox.information(self, "Open Workspace", "No saved EMDS Workspaces were found.")
            return

        dialog = QDialog(self)
        dialog.setWindowTitle("Open Workspace")
        layout = QVBoxLayout()
        list_widget = QListWidget(dialog)
        for workspace in workspaces:
            list_widget.addItem(workspace.name)
            item = list_widget.item(list_widget.count() - 1)
            item.setData(Qt.ItemDataRole.UserRole, workspace.workspace_id)
            item.setToolTip(workspace.qgis_project_file)
        list_widget.setCurrentRow(0)
        layout.addWidget(QLabel("Select an EMDS Workspace:"))
        layout.addWidget(list_widget)
        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        layout.addWidget(buttons)
        dialog.setLayout(layout)

        if exec_dialog(dialog) != QDialog.DialogCode.Accepted or list_widget.currentItem() is None:
            return

        workspace_id = list_widget.currentItem().data(Qt.ItemDataRole.UserRole)
        workspace = self.project_manager.get_project(workspace_id)
        if workspace is None:
            self._load_projects_from_database()
            workspace = self.project_manager.get_project(workspace_id)
        if workspace is None:
            selected_metadata = next(
                (candidate for candidate in workspaces if candidate.workspace_id == workspace_id),
                None,
            )
            if selected_metadata is not None:
                project_state = dict(selected_metadata.metadata or {})
                project_state.setdefault("qgis_project_file", selected_metadata.qgis_project_file)
                project_state.setdefault("project_file", selected_metadata.project_file)
                project_state.setdefault("input_layers", list(selected_metadata.input_layers))
                project_state.setdefault("gis_environment", "QGIS")
                workspace = Project(
                    id=selected_metadata.project_id,
                    name=selected_metadata.name,
                    description=selected_metadata.description,
                    metadata=project_state,
                )
                self._register_loaded_project(workspace)
        if workspace is None:
            QMessageBox.warning(self, "Open Workspace", "The selected Workspace could not be loaded.")
            return

        path = {"project_id": workspace.id, "workspace_id": workspace.id}
        self._select_tree_item_for_path(path)
        self._open_project_for_path(path)
        self._refresh_guided_panel()

    def _on_tree_item_activated(self, item, _column) -> None:
        """Open the selected EMDS project's QGIS map on tree activation."""
        if item is None:
            return

        path = EMDSTreeModel.get_node_path(self.tree_widget, item)
        if path.get("project_id"):
            self._open_project_for_path(path)

    def _build_workflow_step_from_metadata(self, task_metadata) -> WorkflowStep:
        """Rehydrate a workflow step from persisted metadata."""
        parameters = dict(task_metadata.metadata or {})
        parameters.setdefault("project_id", task_metadata.project_id)
        parameters.setdefault("assessment_id", task_metadata.spatial_reference_id)
        parameters.setdefault("provenance_id", task_metadata.provenance_id)
        if task_metadata.model_path:
            parameters.setdefault("model_file", task_metadata.model_path)

        metadata = {
            "project_id": task_metadata.project_id,
            "assessment_id": task_metadata.spatial_reference_id,
            "provenance_id": task_metadata.provenance_id,
            "parent_task_id": task_metadata.parent_task_id,
            "input_tables": task_metadata.input_tables,
            "result_tables": task_metadata.result_tables,
        }

        return WorkflowStep(
            id=task_metadata.task_id,
            name=task_metadata.name,
            engine_type=self._engine_type_from_value(task_metadata.engine_type),
            parameters=parameters,
            metadata=metadata,
            result=Results(
                id=str(uuid.uuid4()),
                layer_name=task_metadata.result_tables[0],
                feature_count=0,
                layer_type="Vector",
                metadata={
                    "table_name": task_metadata.result_tables[0],
                    "result_tables": list(task_metadata.result_tables),
                    "results_alias_tables": parameters.get("results_alias_tables", []),
                    "engine_type": task_metadata.engine_type,
                },
            ) if task_metadata.result_tables else None,
        )

    def _persist_provenance_metadata(
        self,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
    ) -> bool:
        """Persist an analysis-path record into the project's metadata DB."""
        project_metadata_manager = self._get_project_metadata_manager(project)
        if project_metadata_manager is None:
            return False

        metadata = dict(getattr(provenance, "metadata", {}) or {})
        metadata.update({
            "project_id": project.id,
            "assessment_id": assessment.id,
            "workflow_task_list": [step.id for step in provenance.workflow_steps],
        })
        provenance.metadata = metadata

        return project_metadata_manager.save_provenance(
            provenance_id=provenance.id,
            spatial_reference_id=assessment.id,
            project_id=project.id,
            name=provenance.name,
            description=provenance.description,
            metadata=metadata,
        )

    def _persist_workflow_step_metadata(
        self,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        parent_task_id: str = "",
    ) -> bool:
        """Persist a workflow task record into the project's metadata DB."""
        project_metadata_manager = self._get_project_metadata_manager(project)
        if project_metadata_manager is None:
            return False

        input_tables = []
        result_tables = []
        input_table_name = workflow_step.parameters.get("input_table_name")
        if input_table_name:
            input_tables.append(input_table_name)
        output_table_name = workflow_step.parameters.get("output_table_name")
        if output_table_name:
            result_tables.append(output_table_name)

        metadata = dict(getattr(workflow_step, "metadata", {}) or {})
        metadata.update(dict(workflow_step.parameters or {}))
        metadata.update({
            "project_id": project.id,
            "assessment_id": assessment.id,
            "provenance_id": provenance.id,
        })
        if parent_task_id:
            metadata["parent_task_id"] = parent_task_id
        workflow_step.metadata = metadata

        saved = project_metadata_manager.save_task_detail(
            task_id=workflow_step.id,
            provenance_id=provenance.id,
            project_id=project.id,
            spatial_reference_id=assessment.id,
            parent_task_id=parent_task_id,
            name=workflow_step.name,
            engine_type=workflow_step.engine_type.value,
            model_path=workflow_step.parameters.get("model_file", ""),
            input_tables=input_tables,
            result_tables=result_tables,
            metadata=metadata,
        )
        self._persist_provenance_metadata(project, assessment, provenance)
        if saved:
            self._sync_shadow_pipeline_for_provenance(project, assessment, provenance)
        return saved

    def _sync_shadow_pipeline_for_provenance(
        self,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
    ) -> None:
        """Level A: keep the analysis-linked pipeline aligned with the task tree."""
        try:
            from .workflow_definition import sync_pipeline_from_task_tree

            metadata = dict(getattr(provenance, "metadata", {}) or {})
            workflow_definition_id = str(metadata.get("workflow_definition_id") or "").strip()
            if workflow_definition_id:
                stored = self.metadata_manager.get_workflow_definition(
                    project.id, workflow_definition_id,
                )
                if not (stored and isinstance(stored.payload, dict)):
                    # Known pipeline id but its row could not be loaded — abort
                    # rather than overwrite/orphan the real (possibly
                    # control-flow-rich) pipeline on a transient read failure.
                    debug_logger.warning(
                        "Shadow pipeline sync aborted: linked definition could not be loaded", {
                            "analysis_id": provenance.id,
                            "workflow_definition_id": workflow_definition_id,
                        })
                    return

            existing = self._load_workflow_definition_for_provenance(project, provenance)
            if not str(existing.id or "").strip():
                existing.id = str(uuid.uuid4())
            synced = sync_pipeline_from_task_tree(provenance, existing)
            if not self._save_workflow_definition_for_provenance(
                project, assessment, provenance, synced,
            ):
                debug_logger.warning("Shadow pipeline sync could not persist", {
                    "analysis_id": provenance.id,
                })
        except Exception as exc:
            debug_logger.warning("Shadow pipeline sync failed", {
                "analysis_id": getattr(provenance, "id", ""),
                "exception": str(exc),
            })

    @staticmethod
    def _is_workflow_definition_task(workflow_step: Optional[WorkflowStep]) -> bool:
        """Return whether a workflow step is a composite workflow-definition task."""
        if workflow_step is None:
            return False
        if workflow_step.engine_type == EngineType.WORKFLOW:
            return True
        parameters = dict(getattr(workflow_step, "parameters", {}) or {})
        return str(parameters.get("task_type") or parameters.get("task_kind") or "").strip().lower() in {
            "workflow",
            "workflow_definition",
        }

    def _list_project_workflow_definition_choices(self, project: Project) -> List[Dict[str, Any]]:
        """Build workflow-definition rows for the add-task dialog."""
        definitions = self.metadata_manager.list_workflow_definitions_for_project(project.id)
        choices: List[Dict[str, Any]] = []
        for definition in definitions:
            payload = dict(definition.payload or {})
            choices.append({
                "id": definition.workflow_definition_id,
                "name": definition.name,
                "description": definition.description,
                "step_count": len(list(payload.get("steps", []) or [])),
            })
        return choices

    def _list_project_workflow_definitions_for_editor(self, project: Project) -> List[Dict[str, Any]]:
        """Return full workflow-definition payloads for the workflow editor."""
        definitions = self.metadata_manager.list_workflow_definitions_for_project(project.id)
        return [
            {
                "id": definition.workflow_definition_id,
                "name": definition.name,
                "description": definition.description,
                "payload": dict(definition.payload or {}),
            }
            for definition in definitions
        ]

    @staticmethod
    def _collect_available_tasks_for_workflow_editor(provenance: Provenance) -> List[Dict[str, str]]:
        """Flatten provenance tasks into editor-friendly selection rows."""

        collected: List[Dict[str, str]] = []

        def _walk(step: WorkflowStep, prefix: str = "") -> None:
            label = f"{prefix}{step.name}"
            collected.append({
                "id": step.id,
                "name": step.name,
                "label": label,
            })
            for child in list(step.children or []):
                _walk(child, prefix=f"{prefix}  ")

        for workflow_step in list(provenance.workflow_steps or []):
            _walk(workflow_step)
        return collected

    def _resolve_selection_context(
        self,
        path: Optional[Dict[str, Optional[str]]] = None,
    ) -> tuple[Optional[Project], Optional[Assessment], Optional[Provenance], Optional[WorkflowStep]]:
        """Resolve the current EMDS selection into concrete model objects."""
        selection_path = path or self.selected_path or {}
        project = self.project_manager.get_project(selection_path.get("project_id", ""))
        assessment = None
        provenance = None
        workflow_step = None

        if project is not None and selection_path.get("assessment_id"):
            assessment = self.project_manager.get_assessment(project.id, selection_path["assessment_id"])
        if project is not None and assessment is not None and selection_path.get("provenance_id"):
            provenance = self.project_manager.get_provenance(
                project.id,
                assessment.id,
                selection_path["provenance_id"],
            )
        if (
            project is not None
            and assessment is not None
            and provenance is not None
            and selection_path.get("workflow_id")
        ):
            workflow_step = self.project_manager.find_workflow_step(
                project.id,
                assessment.id,
                provenance.id,
                selection_path["workflow_id"],
            )
        return project, assessment, provenance, workflow_step

    def _load_workflow_definition_for_provenance(
        self,
        project: Project,
        provenance: Provenance,
    ) -> WorkflowDefinition:
        """Load the linked Workspace-scoped workflow definition for an Analysis."""
        metadata = dict(getattr(provenance, "metadata", {}) or {})
        workflow_definition_id = str(metadata.get("workflow_definition_id") or "").strip()
        if workflow_definition_id:
            stored_definition = self.metadata_manager.get_workflow_definition(
                project.id,
                workflow_definition_id,
            )
            if stored_definition and isinstance(stored_definition.payload, dict):
                definition = WorkflowDefinition.from_dict(stored_definition.payload)
                if not definition.name:
                    definition.name = stored_definition.name
                if not definition.description:
                    definition.description = stored_definition.description
                return definition

        legacy_payload = metadata.get("workflow_definition")
        if isinstance(legacy_payload, dict):
            definition = WorkflowDefinition.from_dict(legacy_payload)
        else:
            definition = build_default_workflow_definition(provenance)

        # If a pipeline id is already linked but its row could not be loaded
        # (e.g. a transient read miss), keep that SAME id on the fallback so a
        # later save reuses the slot rather than minting a new id and orphaning
        # the stored pipeline.
        if workflow_definition_id:
            definition.id = workflow_definition_id

        if not definition.name:
            definition.name = f"{provenance.name} Pipeline"
        return definition

    def _save_workflow_definition_for_provenance(
        self,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_definition: WorkflowDefinition,
    ) -> bool:
        """Persist a linked Workspace-scoped workflow definition for an Analysis."""
        metadata = dict(getattr(provenance, "metadata", {}) or {})
        metadata["workflow_definition_id"] = workflow_definition.id
        metadata.pop("workflow_definition", None)
        provenance.metadata = metadata

        saved_definition = self.metadata_manager.save_workflow_definition(
            project_id=project.id,
            workflow_definition_id=workflow_definition.id,
            name=workflow_definition.name or f"{provenance.name} Pipeline",
            description=workflow_definition.description,
            payload=workflow_definition.to_dict(),
        )
        if saved_definition:
            self._record_workflow_revision(workflow_definition.id, workflow_definition.to_dict())
        saved_path = self._persist_provenance_metadata(project, assessment, provenance)
        self._persist_project_snapshots(project)
        return saved_definition and saved_path

    @staticmethod
    def _resolve_runtime_value(value: Any, runtime_variables: Dict[str, Any]) -> Any:
        """Resolve `{{variable}}` tokens inside nested task parameter payloads."""
        if isinstance(value, str):
            def replace(match):
                token = str(match.group(1) or "").strip()
                resolved = runtime_variables.get(token, "")
                return "" if resolved is None else str(resolved)

            return re.sub(r"\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}", replace, value)
        if isinstance(value, list):
            return [EMDSDockWidget._resolve_runtime_value(item, runtime_variables) for item in value]
        if isinstance(value, dict):
            return {
                key: EMDSDockWidget._resolve_runtime_value(item, runtime_variables)
                for key, item in value.items()
            }
        return value

    def _clone_workflow_step_with_runtime_variables(
        self,
        workflow_step: WorkflowStep,
        runtime_variables: Dict[str, Any],
    ) -> WorkflowStep:
        """Create a shallow execution clone with token-resolved parameters."""
        cloned_step = copy.deepcopy(workflow_step)
        cloned_step.parameters = self._resolve_runtime_value(
            dict(cloned_step.parameters or {}),
            dict(runtime_variables or {}),
        )
        return cloned_step

    def _register_loaded_project(self, project: Project) -> None:
        """Register a rehydrated workspace, replacing stale in-memory state."""
        if hasattr(self.project_manager, "set_project"):
            self.project_manager.set_project(project)
            return
        if self.project_manager.get_project(project.id) is None:
            self.project_manager.add_project(project)

    def _load_projects_from_database(self):
        """Load saved Workspaces from the metadata database on startup.

        The shared emdsInfo.sqlite is used by both the QGIS plugin and the
        ArcGIS Pro add-in (per the cross-platform metadata design). Each
        host filters the workspaces table to just the ones it can open:
            * QGIS  → project_file ends with .qgz / .qgs
            * ArcGIS Pro → project_file ends with .aprx
        Filter is done in memory because the workspaces table is small
        (one row per project) and keeps the dockpane logic free of
        product-specific SQL.
        """
        try:
            saved_projects = self.metadata_manager.get_all_projects()

            # Platform filter: keep only QGIS-backed workspaces. A
            # workspace counts as QGIS if either project_file or the
            # legacy qgis_project_file column ends with .qgz or .qgs.
            def _is_qgis_workspace(p) -> bool:
                for candidate in (
                    getattr(p, "project_file", None),
                    getattr(p, "qgis_project_file", None),
                ):
                    if not candidate:
                        continue
                    lower = str(candidate).lower()
                    if lower.endswith(".qgz") or lower.endswith(".qgs"):
                        return True
                return False

            if saved_projects:
                saved_projects = [p for p in saved_projects if _is_qgis_workspace(p)]

            if saved_projects:
                print(f"Loading {len(saved_projects)} workspace(s) from metadata database")
                for project_metadata in saved_projects:
                    try:
                        project_state = dict(project_metadata.metadata or {})
                        project_state.setdefault("qgis_project_file", project_metadata.qgis_project_file)
                        project_state.setdefault("project_file", project_metadata.project_file)
                        project_state.setdefault("input_layers", list(project_metadata.input_layers))
                        project_state.setdefault("gis_environment", "QGIS")

                        project = Project(
                            id=project_metadata.project_id,
                            name=project_metadata.name,
                            description=project_metadata.description,
                            metadata=project_state,
                        )
                    except Exception as e:
                        print(f"  Error loading Workspace metadata: {e}")
                        continue

                    try:
                        saved_assessments = self.metadata_manager.get_spatial_references_for_project(project.id)
                        for spatial_ref in saved_assessments:
                            try:
                                assessment = Assessment(
                                    id=spatial_ref.spatial_reference_id,
                                    name=spatial_ref.name,
                                    description=spatial_ref.comments,
                                    input_layers=(spatial_ref.metadata or {}).get("input_layers", []),
                                    metadata={
                                        "output_layer_name": spatial_ref.output_layer_name,
                                        "reference_layer_name": spatial_ref.reference_layer_name,
                                        **(spatial_ref.metadata or {}),
                                    },
                                )
                                # Round-trip the raster-support layer-kind
                                # fields (kind, input_layer_refs, display_layers,
                                # raster_overlay). Legacy rows default to VECTOR.
                                assessment.apply_kind_metadata(spatial_ref.metadata or {})

                                saved_provenances = self.metadata_manager.get_provenances_for_spatial_reference(
                                    spatial_ref.spatial_reference_id
                                )
                                for saved_provenance in saved_provenances:
                                    try:
                                        provenance = Provenance(
                                            id=saved_provenance.provenance_id,
                                            name=saved_provenance.name,
                                            description=saved_provenance.description,
                                            metadata=dict(saved_provenance.metadata or {}),
                                        )

                                        saved_tasks = self.metadata_manager.get_task_details_for_provenance(
                                            saved_provenance.provenance_id
                                        )
                                        workflow_steps = {}
                                        for task_metadata in saved_tasks:
                                            try:
                                                workflow_steps[task_metadata.task_id] = (
                                                    self._build_workflow_step_from_metadata(task_metadata)
                                                )
                                            except Exception as e:
                                                print(f"    Error loading task '{task_metadata.name}': {e}")

                                        root_steps: List[WorkflowStep] = []
                                        for task_metadata in saved_tasks:
                                            workflow_step = workflow_steps.get(task_metadata.task_id)
                                            if workflow_step is None:
                                                continue
                                            parent_task_id = task_metadata.parent_task_id
                                            if parent_task_id and parent_task_id in workflow_steps:
                                                workflow_steps[parent_task_id].add_child(workflow_step)
                                            else:
                                                root_steps.append(workflow_step)

                                        for workflow_step in root_steps:
                                            provenance.add_workflow_step(workflow_step)

                                        assessment.add_provenance(provenance)
                                    except Exception as e:
                                        print(f"    Error loading Analysis '{saved_provenance.name}': {e}")

                                project.add_assessment(assessment)
                            except Exception as e:
                                print(f"    Error loading Study Area '{spatial_ref.name}': {e}")
                    except Exception as e:
                        print(f"  Error loading Workspace children for '{project.name}': {e}")

                    self._register_loaded_project(project)
                    print(f"  Loaded Workspace: {project.name}")

                self._populate_tree()
            else:
                print("No saved Workspaces found")

        except Exception as e:
            print(f"Error loading Workspaces from database: {e}")
            import traceback
            traceback.print_exc()

    def _on_item_selected(self):
        """Handle tree item selection"""
        selected_items = self.tree_widget.selectedItems()
        if selected_items:
            self.selected_item = selected_items[0]
            data = self._tree_item_data(self.selected_item)

            if data:
                path = EMDSTreeModel.get_node_path(self.tree_widget, self.selected_item)
                self.selected_path = path
                self.item_selected.emit(path)

                # Filter assessment-specific layers in the map tree based on current
                # selection context.
                self._apply_layer_visibility_for_selection(path)

                # Collapse all except active path
                EMDSTreeModel.collapse_all_except_path(self.tree_widget, self.selected_item)
        else:
            self.selected_item = None
            self.selected_path = None
            self._refresh_selection_panel(None)

    def _tree_item_data(self, item) -> Optional[TreeNodeData]:
        """Safely read tree node data, clearing stale widget references when needed."""
        if item is None:
            return None

        try:
            return item.data(0, Qt.ItemDataRole.UserRole)
        except RuntimeError as exc:
            if item is self.selected_item:
                self.selected_item = None
            debug_logger.debug("Ignoring deleted tree item reference", {"error": str(exc)})
            return None

    def _resolve_selected_tree_item(self):
        """Return the current live tree item and its metadata, if any."""
        candidates = []
        current_item = self.tree_widget.currentItem()
        if current_item is not None:
            candidates.append(current_item)
        candidates.extend(item for item in self.tree_widget.selectedItems() if item not in candidates)
        if self.selected_item is not None and self.selected_item not in candidates:
            candidates.append(self.selected_item)

        for item in candidates:
            data = self._tree_item_data(item)
            if data is not None:
                self.selected_item = item
                return item, data

        self.selected_item = None
        return None, None

    @staticmethod
    def _get_node_type_for_path(path: Optional[Dict[str, Any]]) -> Optional[str]:
        """Infer the selected node type from its stable EMDS path."""
        if not path:
            return None
        if path.get("result_id") or path.get("results_id"):
            return "result"
        if path.get("task_id") or path.get("workflow_id"):
            return "task"
        if path.get("analysis_id") or path.get("provenance_id"):
            return "analysis"
        if path.get("study_id") or path.get("assessment_id"):
            return "study"
        if path.get("workspace_id") or path.get("project_id"):
            return "workspace"
        return None

    def _on_item_selection_changed(self, path: dict):
        """Update button availability based on selected item type"""
        self._refresh_selection_panel(path)

    def _get_selected_node_type(self) -> Optional[str]:
        _, data = self._resolve_selected_tree_item()
        if data:
            return EMDSTreeModel.normalize_node_type(data.node_type)
        return self._get_node_type_for_path(self.selected_path)

    def _resolve_workflow_step_for_path(self, path: Optional[Dict[str, Any]]) -> Optional[WorkflowStep]:
        """Resolve the workflow step represented by a tree path, if any."""
        if not path:
            return None

        workflow_id = path.get("workflow_id")
        project_id = path.get("project_id")
        assessment_id = path.get("assessment_id")
        provenance_id = path.get("provenance_id")
        if not all([workflow_id, project_id, assessment_id, provenance_id]):
            return None

        return self.project_manager.find_workflow_step(
            project_id,
            assessment_id,
            provenance_id,
            workflow_id,
        )

    def _resolve_workflow_step_for_item(
        self,
        item=None,
        path: Optional[Dict[str, Any]] = None,
    ) -> Optional[WorkflowStep]:
        """Resolve the workflow step from the live tree item first, then fall back to path lookup."""
        if item is not None:
            data = self._tree_item_data(item)
            if data is not None:
                node_type = EMDSTreeModel.normalize_node_type(data.node_type)
                if node_type == "task" and isinstance(data.item, WorkflowStep):
                    return data.item
                if node_type == "result":
                    parent_item = item.parent()
                    parent_data = self._tree_item_data(parent_item) if parent_item is not None else None
                    parent_type = EMDSTreeModel.normalize_node_type(parent_data.node_type) if parent_data is not None else None
                    if parent_data is not None and parent_type == "task" and isinstance(parent_data.item, WorkflowStep):
                        return parent_data.item

        return self._resolve_workflow_step_for_path(path)

    def _resolve_selected_workflow_step(self) -> Optional[WorkflowStep]:
        """Resolve the workflow step for the currently selected workflow/results node."""
        item, _ = self._resolve_selected_tree_item()
        return self._resolve_workflow_step_for_item(item, self.selected_path)

    @staticmethod
    def _select_netweaver_dialog_topic(
        dialog: NetWeaverResultsDialog,
        selected_topics: Optional[List[str]],
    ) -> None:
        """Preselect the first requested NetWeaver topic when the dialog supports it."""
        if not selected_topics or not hasattr(dialog, "topic_tree"):
            return

        requested_topics = {str(topic).strip().lower() for topic in selected_topics if str(topic).strip()}
        if not requested_topics:
            return

        root = dialog.topic_tree.topLevelItem(0)
        if root is None:
            return

        for index in range(root.childCount()):
            item = root.child(index)
            if item.text(0).strip().lower() in requested_topics:
                dialog.topic_tree.setCurrentItem(item)
                break

    def _on_context_menu(self, position):
        """Show context menu on right-click"""
        item = self.tree_widget.itemAt(position)

        menu = QMenu()

        # If no item is clicked, show root context menu
        if not item:
            menu.addAction(tr("provenance.menu_create_workspace"), self._create_project)
            exec_menu(menu, QCursor.pos())
            return

        if self.tree_widget.currentItem() is not item:
            self.tree_widget.setCurrentItem(item)

        data: TreeNodeData = item.data(0, Qt.ItemDataRole.UserRole)
        if not data:
            return

        item_path = EMDSTreeModel.get_node_path(self.tree_widget, item)
        workflow_step = self._resolve_workflow_step_for_item(item, item_path)

        # Anchor selection state to the right-clicked node. A right-click does
        # not reliably move the current item under the tree's single-selection
        # mode, so without this every context action that resolves via
        # self.selected_path (Scenario Builder, Run Task, Open Results, Delete,
        # Edit, …) could act on the previously-selected node instead.
        self.selected_item = item
        self.selected_path = item_path

        node_type = EMDSTreeModel.normalize_node_type(data.node_type)

        if node_type == "workspace":
            menu.addAction(
                tr("provenance.menu_open_workspace"),
                lambda: self._open_project_for_path(EMDSTreeModel.get_node_path(self.tree_widget, item)),
            )
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_create_study_area"), self._create_assessment)
            menu.addAction(tr("provenance.menu_edit_workspace"), lambda: self._edit_item(data))
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_delete_workspace"), self._delete_selected)

        elif node_type == "study":
            menu.addAction(
                tr("provenance.menu_open_workspace"),
                lambda: self._open_project_for_path(EMDSTreeModel.get_node_path(self.tree_widget, item)),
            )
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_create_analysis"), self._create_provenance)
            menu.addAction(tr("provenance.menu_edit_study_area"), lambda: self._edit_item(data))
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_delete_study_area"), self._delete_selected)

        elif node_type == "analysis":
            menu.addAction(
                tr("provenance.menu_open_workspace"),
                lambda: self._open_project_for_path(EMDSTreeModel.get_node_path(self.tree_widget, item)),
            )
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_add_task"), self._create_workflow_step)
            menu.addAction(tr("provenance.btn_open_pipeline_builder"), lambda: self._edit_item(data))
            menu.addAction(tr("provenance.menu_workflow_history"), self._open_workflow_history_dialog)
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_duplicate_scenario"), self._duplicate_analysis_as_scenario)
            if self._analysis_is_scenario(data):
                menu.addAction(tr("provenance.menu_compare_baseline"), self._compare_scenario_to_baseline)
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_delete_analysis"), self._delete_selected)

        elif node_type == "task":
            menu.addAction(
                tr("provenance.menu_open_workspace"),
                lambda: self._open_project_for_path(EMDSTreeModel.get_node_path(self.tree_widget, item)),
            )
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_add_subtask"), self._create_workflow_step)
            menu.addAction(tr("provenance.menu_run_task"), self._run_selected_task)
            menu.addAction(tr("provenance.menu_scenario_builder"), self._open_scenario_builder)
            if workflow_step is not None and self._task_is_scenario_fork(workflow_step):
                menu.addAction(tr("provenance.menu_promote_scenario"), self._promote_scenario_task)
            menu.addAction(tr("provenance.btn_open_pipeline_builder"), self._edit_selected_workflow_definition)
            if workflow_step and workflow_step.engine_type == EngineType.NETWEAVER:
                menu.addAction(tr("provenance.menu_open_netweaver_viewer"), self._open_netweaver_hotlink)
            if workflow_step and workflow_step.engine_type == EngineType.CDP:
                menu.addAction(tr("provenance.menu_open_cdp_viewer"), self._open_cdp_results_viewer)
            menu.addAction(tr("provenance.menu_open_results"), self._view_results)
            if workflow_step is not None and workflow_step.result is not None:
                menu.addAction(tr("provenance.menu_compare_with"), self._open_compare_dialog)
            menu.addAction(tr("provenance.menu_execution_history"), self._open_execution_history)
            menu.addAction(tr("provenance.menu_view"), self._open_provenance_dialog)
            if workflow_step and workflow_step.engine_type in (EngineType.NETWEAVER, EngineType.CDP):
                menu.addAction(tr("provenance.menu_compare_versions"), self._open_model_version_compare)
            menu.addAction(tr("provenance.menu_edit_task"), lambda: self._edit_item(data))
            menu.addSeparator()
            menu.addAction(tr("provenance.menu_delete_task"), self._delete_selected)

        elif node_type == "result":
            if workflow_step and workflow_step.engine_type == EngineType.NETWEAVER:
                menu.addAction(tr("provenance.menu_open_netweaver_viewer"), self._open_netweaver_hotlink)
            if workflow_step and workflow_step.engine_type == EngineType.CDP:
                menu.addAction(tr("provenance.menu_open_cdp_viewer"), self._open_cdp_results_viewer)
            menu.addAction(tr("provenance.menu_open_results"), self._view_results)
            if workflow_step is not None:
                menu.addAction(tr("provenance.menu_compare_with"), self._open_compare_dialog)
            menu.addAction(tr("provenance.menu_execution_history"), self._open_execution_history)
            menu.addAction(tr("provenance.menu_view"), self._open_provenance_dialog)
            if workflow_step and workflow_step.engine_type in (EngineType.NETWEAVER, EngineType.CDP):
                menu.addAction(tr("provenance.menu_compare_versions"), self._open_model_version_compare)
            menu.addAction(tr("provenance.menu_show_on_map"), self._show_selected_results_on_map)
            menu.addAction(tr("provenance.menu_toggle_visibility"), self._toggle_results_visibility)

        exec_menu(menu, QCursor.pos())

    def _create_guided_project(self) -> None:
        """Create a Workspace and first Study Area with the Guided quick-start flow."""
        self._create_project(create_quickstart_study=True)

    def _create_project(self, *, create_quickstart_study: bool = False):
        """Create a new Workspace. Guided mode can also create the first Study Area."""
        from pathlib import Path

        debug_logger.section("_create_project started")

        # Refresh available layers before opening dialog
        debug_logger.debug("Step 0 - Get available layers")
        self.available_layers = self._get_available_layers()
        debug_logger.debug("Available layers", {"layers": self.available_layers})

        dialog_class = CreateGuidedProjectDialog if create_quickstart_study else CreateProjectDialog
        debug_logger.log(f"Opening {dialog_class.__name__}")
        dialog = dialog_class(self.available_layers, self)
        project = dialog.get_project()
        if not project:
            debug_logger.log("Dialog cancelled, returning")
            return

        debug_logger.debug("Project dialog returned", {"project_name": project.name})
        busy_dialog = self._create_busy_dialog(
            title="Creating Workspace",
            label=f"Creating workspace '{project.name}'...",
        )

        try:
            # Step 1: Create project directory
            self._update_busy_dialog(busy_dialog, "Creating the workspace folder...")
            debug_logger.debug("Step 1 - Create project directory", {
                "project_name": project.name
            })
            project_dir = self.project_handler.create_project_directory(project.name)
            debug_logger.debug("Project directory created", {
                "directory": str(project_dir),
                "exists": Path(project_dir).exists()
            })

            # Step 2: Create GeoPackage database for the project
            self._update_busy_dialog(busy_dialog, "Preparing the workspace database...")
            debug_logger.debug("Step 2 - Create GeoPackage database", {
                "database_will_be_at": str(Path(project_dir) / 'data.gpkg')
            })
            db_success, database_path = self.project_handler.create_geopackage_database(Path(project_dir))
            debug_logger.debug("Database creation result", {
                "success": db_success,
                "path_returned": str(database_path) if database_path else "None",
                "file_currently_exists": Path(database_path).exists() if database_path else "N/A"
            })
            if not db_success:
                # Database creation failed, but we can continue with basic SQLite
                # This is not a critical failure
                database_path = None
                debug_logger.log("Note: Spatialite database not available, will use basic layer storage")

            # Step 3: Create QGIS project file
            self._update_busy_dialog(busy_dialog, "Creating the QGIS project file...")
            debug_logger.debug("Step 3 - Create QGIS project file")
            project_file = self.project_handler.create_project_file(project_dir, project.name)
            debug_logger.debug("Project file created", {
                "project_file": str(project_file),
                "exists": Path(project_file).exists()
            })

            # Step 4: Create "Base Layers" group layer in new project
            base_group_name = "Base Layers"
            self._update_busy_dialog(busy_dialog, "Setting up QGIS layer groups...")
            debug_logger.debug("Step 4 - Create 'Base Layers' group layer")
            if not self.project_handler.create_group_layer(str(project_file), base_group_name):
                debug_logger.warning("Could not create 'Base Layers' group layer")
                QMessageBox.warning(self, "Warning", "Could not create 'Base Layers' group layer")
            else:
                debug_logger.success("'Base Layers' group layer created successfully")

            # Step 5: Copy selected input layers to GeoPackage database
            self._update_busy_dialog(busy_dialog, "Copying selected layers into the workspace database...")
            debug_logger.debug("Step 5 - Copy selected input layers to GeoPackage database")
            selected_layers = list(project.metadata.get('input_layers', []) or [])
            selected_layers = self.project_handler.order_layer_names_by_current_project(selected_layers)
            debug_logger.debug("Layer copy parameters", {
                "selected_layers": selected_layers,
                "number_of_selected_layers": len(selected_layers),
                "database_path_available": database_path is not None
            })
            table_mapping = {}

            if selected_layers and database_path:
                debug_logger.log("Calling copy_layers_to_geopackage...")
                table_mapping = self.project_handler.copy_layers_to_geopackage(
                    selected_layers,
                    Path(database_path)
                )
                debug_logger.debug("Table mapping result", {"mapping": str(table_mapping)})
                # Raster-support Phase 2: distinguish raster skips (a
                # known-and-expected outcome — the GeoPackage export
                # path is vector-only) from real copy failures so the
                # user gets an actionable message rather than a vague
                # "layer copy failed" lumped together.
                raster_skipped = list(
                    getattr(self.project_handler, "last_skipped_raster_layers", []) or []
                )
                raster_skipped_set = set(raster_skipped)
                failed = [
                    name
                    for name, table in table_mapping.items()
                    if not table and name not in raster_skipped_set
                ]
                debug_logger.debug("Copy results", {
                    "failed_layers": failed,
                    "failed_count": len(failed),
                    "raster_skipped": raster_skipped,
                    "raster_skipped_count": len(raster_skipped),
                })
                if raster_skipped:
                    debug_logger.warning(
                        f"{len(raster_skipped)} raster layer(s) skipped (GeoPackage export is vector-only)"
                    )
                    QMessageBox.information(
                        self,
                        "Raster Layers Not Copied",
                        (
                            f"{len(raster_skipped)} raster layer(s) were not copied into the "
                            f"workspace GeoPackage because GeoPackage export currently "
                            f"supports vector layers only:\n\n"
                            + "\n".join(f"  • {name}" for name in raster_skipped)
                            + "\n\nThe raster files remain in place and you can still display "
                            "them in the QGIS project. First-class raster support is on the "
                            "raster-support roadmap (Phase 3)."
                        ),
                    )
                if failed:
                    debug_logger.warning(f"{len(failed)} layer(s) failed to copy")
                    QMessageBox.warning(
                        self,
                        "Layer Copy Warning",
                        f"Could not copy {len(failed)} layer(s) to database:\n" + "\n".join(failed)
                    )
            else:
                debug_logger.log("Skipping layer copy (no selected layers or no database)")

            # Step 6 intentionally skipped:
            # We do not remap the currently open QGIS project's layers, because the goal is to
            # build the new QGIS project from copied database layers.
            debug_logger.debug("Step 6 - Skip remapping current QGIS project layers")

            # Step 7: Add layers from GeoPackage to new QGIS project
            self._update_busy_dialog(busy_dialog, "Loading workspace layers from the database...")
            debug_logger.debug("Step 7 - Load layers from GeoPackage database to new QGIS project")
            load_results = {}
            if selected_layers and database_path:
                debug_logger.log("Calling load_geopackage_layers_to_project...")
                load_results = self.project_handler.load_geopackage_layers_to_project(
                    selected_layers,
                    Path(database_path),
                    str(project_file),
                    base_group_name,
                    table_mapping
                )
                debug_logger.debug("Load results", {"results": str(load_results)})
                failed_load = [name for name, success in load_results.items() if not success]
                debug_logger.debug("Failed layer loads", {
                    "failed_layers": failed_load,
                    "count": len(failed_load)
                })
                if failed_load:
                    debug_logger.warning(f"{len(failed_load)} layer(s) failed to load from GeoPackage")
                    QMessageBox.warning(
                        self,
                        "Layer Load Warning",
                        f"Could not load {len(failed_load)} layer(s) from GeoPackage:\n" + "\n".join(failed_load)
                    )
                else:
                    debug_logger.success(f"All {len(selected_layers)} layer(s) loaded from GeoPackage successfully")
            else:
                debug_logger.log("Skipping layer load (no selected layers or no database)")

            # Step 8: Save Workspace metadata to database
            self._update_busy_dialog(busy_dialog, "Saving EMDS Workspace metadata...")
            debug_logger.debug("Step 8 - Save Workspace metadata to database")
            metadata = {
                'directory': str(project_dir),
                'database_path': str(database_path) if database_path else None,
                'table_mapping': table_mapping,
                'qgis_project_file': str(project_file),
                'project_file': str(project_file),
                'workspace_paths': [str(project_dir)],
                'plugin_version': self.PLUGIN_VERSION,
                'gis_environment': 'QGIS',
                'input_layers': list(selected_layers),
            }
            project.metadata.update(metadata)
            debug_logger.debug("Workspace metadata", {"metadata": str(metadata)})
            project_saved = self.metadata_manager.save_project(
                project_id=project.id,
                name=project.name,
                description=project.description,
                project_file=str(project_file),
                input_layers=selected_layers,
                # The MERGED metadata: dialog-provided keys (incl. the
                # emds_comments_html sidecar, Q6b) plus the runtime keys.
                metadata=project.metadata
            )
            debug_logger.debug("Metadata saved", {
                "project_saved": project_saved,
                "metadata_database": str(self.metadata_manager.db_path),
            })

            if not project_saved:
                underlying = getattr(self.metadata_manager, "last_save_error", None)
                detail = f"\n\nDetails: {underlying}" if underlying else ""
                debug_logger.warning(
                    f"Could not save Workspace metadata to database: {underlying or '(no detail)'}"
                )
                QMessageBox.warning(
                    self,
                    "Warning",
                    f"Could not save Workspace metadata to database.{detail}"
                    f"\n\nDatabase: {self.metadata_manager.db_path}",
                )

            # Step 9: Load the new project in QGIS
            self._update_busy_dialog(busy_dialog, "Opening the new QGIS project...")
            debug_logger.debug("Step 9 - Load new project in QGIS")
            load_success = self.project_handler.load_project(str(project_file))
            debug_logger.debug("Project load result", {"success": load_success})

            if load_success:
                # Step 10: Register the new Workspace before creating the first Study Area.
                self._update_busy_dialog(busy_dialog, "Registering the new Workspace...")
                if self.project_manager.get_project(project.id) is None:
                    self.project_manager.add_project(project)

                quickstart_study = None
                if create_quickstart_study:
                    # Step 11: Create the first Study Area overlay and metadata.
                    self._update_busy_dialog(busy_dialog, "Creating the first Study Area...")
                    quickstart_study = dialog.get_quickstart_study(str(database_path) if database_path else "")
                if create_quickstart_study and quickstart_study:
                    materialized_assessment = None
                    materialized_dialog = None
                    try:
                        materialized_assessment, materialized_dialog = self._materialize_quickstart_study(
                            project,
                            quickstart_study,
                            database_path,
                        )
                    except Exception as exc:
                        debug_logger.warning("Could not generate quick-start Study Area overlay", {
                            "exception": str(exc),
                            "study_area_name": quickstart_study.name,
                        })

                    if materialized_assessment is not None and materialized_dialog is not None:
                        quickstart_study = materialized_assessment
                        if not self._save_assessment_workflow(
                            project,
                            quickstart_study,
                            materialized_dialog,
                            show_message=False,
                        ):
                            if not self._save_quickstart_study(project, quickstart_study, database_path):
                                quickstart_study = None
                    else:
                        QMessageBox.warning(
                            self,
                            "Study Area Not Created",
                            "The Workspace was created, but the guided first Study Area overlay could not be generated. "
                            "Create the Study Area manually from the Guided Workbench or Advanced tab.",
                        )
                        quickstart_study = None

                self._update_busy_dialog(busy_dialog, "Updating the EMDS Workbench tree...")
                debug_logger.debug("Step 12 - Add project to tree view")
                self._populate_tree()
                debug_logger.log("Project added to tree")

                # Highlight/select the first Study Area when quick-start created one.
                debug_logger.debug("Select workspace/study area in tree")
                if quickstart_study:
                    self._select_assessment_in_tree(quickstart_study.id)
                else:
                    self._select_project_in_tree(project.id)
                debug_logger.log("Workspace selection synchronized")
                self._persist_project_snapshots(project)

                # Emit signal
                self.project_created.emit(project)
                debug_logger.log("Signal emitted")

                # Verify database file
                debug_logger.debug("Verifying final state")
                if database_path:
                    db_file = Path(database_path)
                    debug_logger.debug("Database file verification", {
                        "file_exists": db_file.exists(),
                        "file_size_bytes": db_file.stat().st_size if db_file.exists() else "N/A"
                    })

                debug_logger.section("SUCCESS: Project created completely")
                busy_dialog.close()

                self._refresh_guided_panel()
                creation_detail = (
                    f"First Study Area: {quickstart_study.name}\n"
                    if quickstart_study
                    else "Next step: create a Study Area for this Workspace.\n"
                )
                QMessageBox.information(
                    self,
                    "Success",
                    f"Workspace '{project.name}' created successfully\n\n"
                    f"{creation_detail}"
                    f"Location: {project_file}\n"
                    f"GeoPackage Database: {database_path if database_path else 'None'}\n"
                    f"Input Layers: {len(selected_layers)}"
                )
            else:
                busy_dialog.close()
                debug_logger.error("Could not load new project in QGIS")
                QMessageBox.warning(self, "Error", "Could not load the new QGIS project.")

        except Exception as e:
            busy_dialog.close()
            debug_logger.error("Exception in _create_project", {"exception": str(e)})
            import traceback
            debug_logger.log(traceback.format_exc())
            QMessageBox.critical(
                self,
                "Error Creating Workspace",
                f"An error occurred while creating the Workspace:\n{str(e)}"
            )

    def _save_quickstart_study(self, project: Project, assessment: Assessment, database_path: Optional[Path]) -> bool:
        """Persist the first Study Area collected by the Guided quick-start."""
        metadata = dict(getattr(assessment, "metadata", {}) or {})
        metadata.update({
            "project_id": project.id,
            "workspace_id": project.id,
            "database_path": str(database_path) if database_path else metadata.get("database_path", ""),
        })
        assessment.metadata = metadata
        # Embed the raster-support layer-kind fields so they round-trip
        # through the metadata JSON column.
        metadata = assessment.embed_kind_metadata(metadata)
        if not self.metadata_manager.save_spatial_reference(
            spatial_reference_id=assessment.id,
            project_id=project.id,
            name=assessment.name,
            comments=assessment.description,
            output_layer_name=metadata.get("output_layer_name", ""),
            reference_layer_name=metadata.get("reference_layer_name", ""),
            metadata=metadata,
        ):
            QMessageBox.warning(self, "Study Area Metadata", "The first Study Area metadata could not be saved.")
            return False
        project.add_assessment(assessment)
        return True

    def _materialize_quickstart_study(
        self,
        project: Project,
        assessment: Assessment,
        database_path: Optional[Path],
    ) -> tuple[Optional[Assessment], Optional[CreateAssessmentDialog]]:
        """Run the Study Area overlay builder headlessly for the quick-start flow."""
        if database_path is None:
            return None, None

        quickstart_layers = self._get_available_layers()
        dialog = CreateAssessmentDialog(
            quickstart_layers,
            self,
            database_path=str(database_path),
        )
        dialog.name_input.setText(assessment.name)
        dialog.description_input.setPlainText(assessment.description or "")

        metadata = dict(getattr(assessment, "metadata", {}) or {})
        target_layer_name = str(metadata.get("target_layer_name", "")).strip()
        included_layer_names = {
            str(name).strip()
            for name in (
                metadata.get("study_layer_names")
                or metadata.get("overlay_layer_names")
                or metadata.get("assessment_layer_names")
                or []
            )
            if str(name).strip()
        }
        target_geometry_type = None
        target_layer_object = dialog._get_layer_by_name(target_layer_name)
        if target_layer_object is not None:
            target_geometry_type = target_layer_object.geometryType()

        target_configured = False
        for row in range(dialog.tableWidget_layers.rowCount()):
            layer_name_item = dialog.tableWidget_layers.item(row, 0)
            status_combo = dialog.tableWidget_layers.cellWidget(row, 2)
            if layer_name_item is None or status_combo is None:
                continue

            layer_name = layer_name_item.text().strip()
            if layer_name == target_layer_name:
                status_combo.setCurrentText(dialog.STATUS_TARGET)
                target_configured = True
            elif layer_name in included_layer_names:
                row_layer = dialog._get_layer_by_name(layer_name)
                if (
                    row_layer is not None
                    and target_geometry_type is not None
                    and row_layer.geometryType() == target_geometry_type
                ):
                    status_combo.setCurrentText(dialog.STATUS_INCLUDE)
                else:
                    status_combo.setCurrentText(dialog.STATUS_DO_NOT_INCLUDE)
            else:
                status_combo.setCurrentText(dialog.STATUS_DO_NOT_INCLUDE)

        if not target_configured:
            QMessageBox.warning(
                self,
                "Create Workspace",
                "The selected target layer could not be found after the Workspace was created, so the first Study Area overlay was not generated.",
            )
            return None, dialog

        if not dialog._validate_page_1():
            return None, dialog

        dialog._setup_feature_page()
        if dialog.target_layer is None:
            QMessageBox.warning(
                self,
                "Create Workspace",
                "The first Study Area target layer could not be opened, so the overlay was not generated.",
            )
            return None, dialog

        dialog._select_all_features()
        dialog.accept()
        generated_assessment = dialog._assessment
        if generated_assessment is None:
            return None, dialog

        generated_assessment.id = assessment.id
        merged_metadata = dict(metadata)
        merged_metadata.update(getattr(generated_assessment, "metadata", {}) or {})
        if metadata.get("input_layers"):
            merged_metadata["input_layers"] = list(metadata.get("input_layers") or [])
        generated_assessment.metadata = merged_metadata
        generated_assessment.input_layers = list(
            dict.fromkeys(assessment.input_layers or generated_assessment.input_layers or [])
        )
        return generated_assessment, dialog

    def _create_assessment(self):
        """Launch the Vector or Raster Create-Study-Area dialog based on a chooser."""
        if not self.selected_path or not self.selected_path["project_id"]:
            QMessageBox.warning(self, "Selection Required", "Please select a Workspace first")
            return

        # Raster-support Phase 3: chooser routes between the vector
        # and raster Create-Study-Area dialogs. Both options enabled.
        kind_choice = self._prompt_study_area_kind()
        if kind_choice is None:
            return  # user cancelled

        project = self.project_manager.get_project(self.selected_path["project_id"])
        if project is None:
            QMessageBox.warning(self, "Selection Required", "Could not resolve the selected Workspace")
            return

        if kind_choice == "raster":
            self._create_raster_assessment(project)
            return

        # Vector path (default)
        self.available_layers = self._get_available_layers()
        database_path = self._get_project_database_path(project)
        dialog = CreateAssessmentDialog(
            self.available_layers,
            self,
            database_path=str(database_path) if database_path else "",
        )
        assessment = dialog.get_assessment()
        if assessment:
            self._save_assessment_workflow(project, assessment, dialog)

    def _create_raster_assessment(self, project) -> None:
        """Open the raster Create-Study-Area dialog and persist its output.

        Raster-support Phase 3 entry point; Phase 3.5f split the
        persistence path off from the vector workflow so we don't
        attempt to copy the raster into the workspace GeoPackage
        (canonical model writes the raster as a sibling file, not a
        GPKG table) or load it as a vector layer.
        """
        # Local import to avoid pulling Qt at top-of-file when it isn't needed.
        from .dialogs.create_raster_study_area_dialog import (
            CreateRasterStudyAreaDialog,
        )

        dialog = CreateRasterStudyAreaDialog(self)
        study = dialog.get_study()
        if study is None:
            return

        self._save_raster_assessment_workflow(
            project, study, dialog.overlay_result_path,
        )

    def _save_raster_assessment_workflow(
        self,
        project: Project,
        study: Assessment,
        overlay_raster_path: str,
        *,
        show_message: bool = True,
    ) -> bool:
        """Persist a raster study area and load its output raster into the map.

        Raster-support Phase 3.5f: parallel to _save_assessment_workflow
        but adapted to raster semantics:
          - No GeoPackage copy (the output is a .tif sibling file, not
            a GPKG table).
          - Records the output raster path under canonical `overlay_name`
            in metadata (matches ArcGIS Pro Study.OutputLayerName).
          - Adds the output to the QGIS project as QgsRasterLayer
            (mirrors ArcGIS Pro's AddRasterLayerToActiveMapAsync).

        The vector path (_save_assessment_workflow) is untouched.
        """
        overlay_name = Path(overlay_raster_path).stem if overlay_raster_path else ""

        # Merge raster-study details into the assessment metadata.
        # `overlay_name` is the canonical key; `output_layer_name` is
        # kept as an alias for the existing metadata-manager schema
        # (which has output_layer_name as a column).
        metadata = dict(getattr(study, "metadata", {}) or {})
        metadata.update({
            "project_id": project.id,
            "overlay_name": overlay_name,
            "output_layer_name": overlay_name,
            "overlay_raster_path": overlay_raster_path,
        })
        study.metadata = metadata
        # input_layers carries the canonical names list.
        if not study.input_layers:
            study.input_layers = [ref.name for ref in study.input_layer_refs]

        project_metadata_manager = self._get_project_metadata_manager(project)
        if project_metadata_manager is None:
            QMessageBox.warning(
                self,
                "Study Area Metadata",
                "The raster overlay was created, but the Workspace "
                "metadata database is unavailable.",
            )
            return False

        # Embed canonical ArcGIS Pro keys + qgis_extensions before
        # persisting. embed_kind_metadata handles all the role-split
        # and PascalCase work.
        metadata = study.embed_kind_metadata(metadata)
        saved = project_metadata_manager.save_spatial_reference(
            spatial_reference_id=study.id,
            project_id=project.id,
            name=study.name,
            comments=study.description,
            output_layer_name=overlay_name,
            reference_layer_name="",  # raster studies don't use the legacy single ref slot
            metadata=metadata,
        )
        if not saved:
            QMessageBox.warning(
                self,
                "Study Area Metadata",
                "The raster overlay was created, but the Study Area "
                "metadata could not be saved.",
            )
            return False

        # Update the in-memory project tree.
        existing = self.project_manager.get_assessment(project.id, study.id)
        if existing is None:
            project.add_assessment(study)
        else:
            existing.name = study.name
            existing.description = study.description
            existing.input_layers = list(study.input_layers)
            existing.metadata = dict(metadata)
            existing.kind = study.kind
            existing.input_layer_refs = list(study.input_layer_refs)
            existing.display_layers = list(study.display_layers)
            existing.raster_overlay = study.raster_overlay

        self._populate_tree()
        self._persist_project_snapshots(project)

        # Phase 3.5f: load the output raster as QgsRasterLayer (not
        # vector). This is the key behavioral difference from the
        # vector flow.
        self._add_raster_assessment_layer_to_map(
            study, overlay_raster_path,
        )
        self._select_assessment_in_tree(study.id)
        self._apply_layer_visibility_for_selection(
            self.selected_path or {"project_id": project.id, "assessment_id": study.id}
        )
        self.assessment_created.emit(study)

        if show_message:
            QMessageBox.information(
                self,
                "Raster Study Area Created",
                (
                    f"Raster Study Area '{study.name}' created successfully.\n\n"
                    f"Overlay raster: {overlay_raster_path or 'Unknown'}\n"
                    f"Unique combinations: see the raster's attribute table."
                ),
            )
        return True

    def _add_raster_assessment_layer_to_map(
        self,
        study: Assessment,
        raster_path: str,
    ) -> None:
        """Load the overlay raster into the QGIS project as QgsRasterLayer.

        Raster-support Phase 3.5f. Mirrors ArcGIS Pro's
        WizardLauncher.cs:335-340 `AddRasterLayerToActiveMapAsync`.
        Defensive: no-op if the path is empty or QGIS isn't importable.
        """
        if not raster_path:
            return
        try:
            from qgis.core import QgsProject, QgsRasterLayer
        except ImportError:
            return
        display_name = study.name or Path(raster_path).stem
        raster_layer = QgsRasterLayer(raster_path, display_name)
        if not raster_layer.isValid():
            debug_logger.warning(
                "Raster overlay output failed to load as QgsRasterLayer",
                {"raster_path": raster_path, "study_id": study.id},
            )
            QMessageBox.warning(
                self,
                "Raster Layer Load",
                f"The overlay raster was written to:\n{raster_path}\n\n"
                f"but QGIS could not load it as a raster layer. Add it "
                f"manually via Layer → Add Layer → Add Raster Layer.",
            )
            return
        # Mirror the vector flow: stamp the layer with its assessment id and
        # place it in the analysis's own top-level group (a sibling of "Base
        # Layers"), not at the tree root. This gives raster study areas the
        # same isolation and show-only-active-analysis visibility behavior.
        raster_layer.setCustomProperty("emds/assessment_id", study.id)
        raster_layer.setCustomProperty("emds/layer_role", "raster_overlay_result")
        assessment_group = self._ensure_assessment_group(study)
        QgsProject.instance().addMapLayer(raster_layer, False)
        assessment_group.insertLayer(0, raster_layer)

    def _prompt_study_area_kind(self) -> Optional[str]:
        """Return the study-area kind, prompting only when both kinds exist.

        Returns "vector", "raster", or None on cancel. If the loaded QGIS
        project contains only vector layers or only raster layers, the
        matching kind is selected automatically. Mixed projects still ask
        because either homogeneous study-area kind is valid.

        Phase 4c: peek at loaded layers via LayerKindResolver. If only
        one kind is present, skip the chooser. Mirrors ArcGIS Pro's
        WizardLauncher.cs:267-290:
          - Only raster layers loaded → default Raster
          - Only vector layers loaded → default Vector
          - Both kinds or empty project → default Vector
        """
        inferred_kind = self._infer_default_study_kind()
        if inferred_kind in ("vector", "raster"):
            return inferred_kind

        box = QMessageBox(self)
        box.setIcon(QMessageBox.Icon.Question)
        box.setWindowTitle("New Study Area")
        box.setText("What kind of study area do you want to create?")
        info = (
            "A study area is homogeneous: all of its analysis-input layers are "
            "either all vector or all raster. Layers of the opposite kind can "
            "still live in the project as display-only context."
        )
        box.setInformativeText(info)

        vector_button = box.addButton("Vector…", QMessageBox.ButtonRole.AcceptRole)
        raster_button = box.addButton("Raster…", QMessageBox.ButtonRole.AcceptRole)
        raster_button.setToolTip(
            "Raster study areas: pick raster inputs, choose bands and RAT "
            "columns to carry forward, then run GRASS r.cross to produce "
            "the unique-combination overlay raster."
        )
        cancel_button = box.addButton(QMessageBox.StandardButton.Cancel)
        # Phase 4c: pick the default button based on the inferred kind.
        box.setDefaultButton(
            raster_button if inferred_kind == "raster" else vector_button
        )
        box.exec()
        clicked = box.clickedButton()
        if clicked is cancel_button or clicked is None:
            return None
        if clicked is raster_button:
            return "raster"
        return "vector"

    @staticmethod
    def _infer_default_study_kind() -> str:
        """Look at the loaded QGIS project layers and recommend a default
        study-area kind.

        Returns "raster" / "vector" / "both" (or "vector" fallback when
        the project is empty or QGIS isn't importable). Pulled out as
        a static helper so Phase 4 tests can exercise it without going
        through the dialog.
        """
        try:
            from qgis.core import QgsProject  # type: ignore
        except ImportError:
            return "vector"
        try:
            layers = QgsProject.instance().mapLayers().values()
        except Exception:
            return "vector"
        has_raster = False
        has_vector = False
        for layer in layers:
            kind = LayerKindResolver.resolve(layer)
            if kind == LayerKind.RASTER:
                has_raster = True
            elif kind == LayerKind.VECTOR:
                has_vector = True
            if has_raster and has_vector:
                return "both"
        if has_raster and not has_vector:
            return "raster"
        if has_vector and not has_raster:
            return "vector"
        return "vector"  # empty project → safe default

    def _extract_assessment_details(self, dialog, assessment: Assessment) -> Dict[str, str]:
        """
        Extract assessment payload from current/future dialog versions.
        """
        def _to_text(value: Any) -> str:
            if value is None:
                return ""
            if isinstance(value, str):
                return value.strip()
            text_attr = getattr(value, "text", None)
            if callable(text_attr):
                try:
                    return str(text_attr()).strip()
                except Exception:
                    return ""
            plain_attr = getattr(value, "toPlainText", None)
            if callable(plain_attr):
                try:
                    return str(plain_attr()).strip()
                except Exception:
                    return ""
            return str(value).strip()

        assessment_name = assessment.name
        assessment_comments = assessment.description
        output_layer_name = ""
        reference_layer_name = ""
        assessment_metadata = getattr(assessment, "metadata", {}) or {}

        if hasattr(assessment, "metadata") and isinstance(assessment.metadata, dict):
            output_layer_name = assessment_metadata.get("output_layer_name", "")
            reference_layer_name = assessment_metadata.get("reference_layer_name", "")
            assessment_comments = assessment_metadata.get("assessment_comments", assessment_comments)

        output_candidates = [
            getattr(dialog, "output_layer_name", None),
            getattr(dialog, "output_table_name", None),
            getattr(dialog, "output_feature_class_name", None),
            getattr(dialog, "txtOutputTableName", None),
            getattr(dialog, "txtOutputFeatureClass", None),
        ]
        reference_candidates = [
            getattr(dialog, "reference_layer_name", None),
            getattr(dialog, "reference_layer", None),
            getattr(dialog, "txtReferenceLayerName", None),
        ]

        for candidate in output_candidates:
            text = _to_text(candidate)
            if text:
                output_layer_name = text
                break

        for candidate in reference_candidates:
            text = _to_text(candidate)
            if text:
                reference_layer_name = text
                break

        return {
            "assessment_name": assessment_name,
            "assessment_comments": assessment_comments or "",
            "input_layers": getattr(assessment, "input_layers", []),
            "target_layer_name": _to_text(getattr(dialog, "target_layer_name", None))
            or assessment_metadata.get("target_layer_name", ""),
            "target_table_name": _to_text(getattr(dialog, "target_table_name", None))
            or assessment_metadata.get("target_table_name", ""),
            "output_layer_name": output_layer_name or "",
            "output_table_name": _to_text(getattr(dialog, "output_table_name", None))
            or assessment_metadata.get("output_table_name", ""),
            "reference_layer_name": reference_layer_name or "",
            "reference_table_name": _to_text(getattr(dialog, "reference_table_name", None))
            or assessment_metadata.get("reference_table_name", ""),
            "database_path": _to_text(getattr(dialog, "database_path", None))
            or assessment_metadata.get("database_path", ""),
            "assessment_layer_names": assessment_metadata.get("assessment_layer_names", []),
            "spatial_marker_layer_names": assessment_metadata.get("spatial_marker_layer_names", []),
            "selected_feature_count": assessment_metadata.get("selected_feature_count", 0),
            "geometry_type": assessment_metadata.get("geometry_type", ""),
            "layer_configurations": assessment_metadata.get("layer_configurations", []),
        }

    def _save_assessment_workflow(self, project: Project, assessment: Assessment, dialog, *, show_message: bool = True) -> bool:
        """Persist a newly created assessment, refresh the tree, and add its output layer to the map."""
        details = self._extract_assessment_details(dialog, assessment)

        metadata = dict(getattr(assessment, "metadata", {}) or {})
        metadata.update(details)
        metadata["project_id"] = project.id
        assessment.metadata = metadata
        assessment.input_layers = list(details.get("input_layers", []) or assessment.input_layers or [])

        project_metadata_manager = self._get_project_metadata_manager(project)
        if project_metadata_manager is None:
            QMessageBox.warning(
                self,
                "Study Area Metadata",
                "The Study Area output was created, but the Workspace metadata database is unavailable.",
            )
            return False

        # Embed the raster-support layer-kind fields so they round-trip
        # through the metadata JSON column.
        metadata = assessment.embed_kind_metadata(metadata)
        saved = project_metadata_manager.save_spatial_reference(
            spatial_reference_id=assessment.id,
            project_id=project.id,
            name=assessment.name,
            comments=assessment.description,
            output_layer_name=details.get("output_layer_name", ""),
            reference_layer_name=details.get("reference_layer_name", ""),
            metadata=metadata,
        )
        if not saved:
            QMessageBox.warning(
                self,
                "Study Area Metadata",
                "The Study Area output was created, but the Study Area metadata could not be saved.",
            )
            return False

        existing = self.project_manager.get_assessment(project.id, assessment.id)
        if existing is None:
            project.add_assessment(assessment)
        else:
            existing.name = assessment.name
            existing.description = assessment.description
            existing.input_layers = list(assessment.input_layers)
            existing.metadata = dict(metadata)

        self._populate_tree()
        self._persist_project_snapshots(project)
        self._add_assessment_layers_to_map(
            project,
            assessment,
            details.get("output_table_name", "") or details.get("output_layer_name", ""),
            details.get("reference_table_name", "") or details.get("reference_layer_name", ""),
        )
        self._select_assessment_in_tree(assessment.id)
        self._apply_layer_visibility_for_selection(self.selected_path or {"project_id": project.id, "assessment_id": assessment.id})
        self.assessment_created.emit(assessment)

        output_table_name = details.get("output_table_name", "")
        database_path = details.get("database_path", "")
        if show_message:
            QMessageBox.information(
                self,
                "Study Area Created",
                (
                    f"Study Area '{assessment.name}' created successfully.\n\n"
                    f"Database: {database_path or 'Unknown'}\n"
                    f"Output Layer/Table: {output_table_name or 'Unknown'}"
                ),
            )
        return True

    def _get_project_database_path(self, project: Project) -> Optional[Path]:
        """Resolve project's GeoPackage path from in-memory or persisted metadata."""
        db_path = (project.metadata or {}).get("database_path")
        if not db_path:
            metadata = self.metadata_manager.get_project(project.id)
            if metadata and metadata.metadata:
                db_path = metadata.metadata.get("database_path")

        if not db_path:
            return None

        path = Path(db_path)
        return path if path.exists() else None

    def _resolve_table_name_in_gpkg(self, database_path: Path, requested_name: str) -> str:
        """Return best matching table name in GeoPackage for requested layer/table."""
        if not requested_name:
            return ""

        try:
            conn = sqlite3.connect(str(database_path))
            cur = conn.cursor()

            cur.execute(
                "SELECT table_name FROM gpkg_contents WHERE lower(table_name) = lower(?)",
                (requested_name,)
            )
            row = cur.fetchone()
            if row:
                conn.close()
                return row[0]

            cur.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND lower(name) = lower(?)",
                (requested_name,)
            )
            row = cur.fetchone()
            conn.close()
            return row[0] if row else requested_name
        except Exception:
            return requested_name

    def _ensure_base_layers_group(self):
        """Get or create the root Base Layers group in the current project."""
        from qgis.core import QgsProject
        root = QgsProject.instance().layerTreeRoot()
        base_group = root.findGroup("Base Layers")
        if base_group is None:
            base_group = root.insertGroup(0, "Base Layers")
        return base_group

    def _ensure_assessment_group(self, assessment: Assessment):
        """Get or create the TOP-LEVEL layer tree group for an assessment.

        Each analysis (study area) gets its own group at the ROOT of the layer
        tree, as a sibling of "Base Layers" -- never nested inside it. "Base
        Layers" holds only the original project inputs; analysis and task output
        layers belong in their own top-level analysis group so they (a) don't
        pollute "Base Layers" and (b) don't show up in the Create Study Area
        wizard, which offers only the Base Layers. Inserting at index 0 keeps
        the newest analysis above the base layers so results draw on top.
        """
        from qgis.core import QgsProject
        root = QgsProject.instance().layerTreeRoot()
        assessment_group = root.findGroup(assessment.name)
        if assessment_group is None:
            assessment_group = root.insertGroup(0, assessment.name)
        # Stamp the group with its assessment id so visibility logic can show
        # only the active analysis group and leave "Base Layers" (unstamped)
        # always on. Set every time so groups created before this property
        # existed get backfilled on next use.
        assessment_group.setCustomProperty("emds/assessment_id", assessment.id)
        return assessment_group

    def _resolve_task_run_meta(self, task_id: str) -> Dict[str, str]:
        """Latest run identity for a task (run_id / run_kind / scenario_id).

        Driven by the provenance tables (workflow_runs / workflow_step_runs), the
        same source ArcGIS Pro uses to label baseline vs scenario. By the time a
        result layer is added, the run has already been recorded (capture runs
        first in _finalize_completed_task_execution), so the latest run is this one.
        """
        try:
            runs = self.metadata_manager.get_step_runs_for_task(task_id)
        except Exception:
            runs = []
        if not runs:
            return {}
        latest = runs[0]
        run = None
        try:
            run = self.metadata_manager.get_workflow_run(latest.run_id)
        except Exception:
            run = None
        return {
            "run_id": latest.run_id or "",
            "run_kind": (run.run_kind if run else "") or "",
            "scenario_id": (run.scenario_id if run else "") or "",
        }

    def _ensure_results_group_hierarchy(
        self,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
    ):
        """Get or create the nested layer group hierarchy: Study Area -> Analysis -> [Scenarios ->] Task.

        Provenance organization (S1): a *task-level* scenario fork (a sibling task
        carrying ``scenario_baseline_task_id`` inside a non-scenario analysis) is
        collected under a "Scenarios" subgroup so the baseline pathway stays clean.
        Analysis-level scenarios already get their own "(scenario)" analysis group,
        so their tasks are left directly under it. The task group is stamped with
        run_kind / run_id / scenario_id so the map TOC reflects baseline vs scenario
        and visibility/cleanup tooling can key off it.

        Reruns overwrite (see _remove_existing_workflow_result_layers), so there is
        no Run sub-level — there is only ever the latest run's layers per task.
        """
        # 1. Study Area (Assessment) group (top-level)
        assessment_group = self._ensure_assessment_group(assessment)

        # 2. Analysis (Provenance) group (child of Study Area)
        analysis_group = assessment_group.findGroup(provenance.name)
        if analysis_group is None:
            analysis_group = assessment_group.insertGroup(0, provenance.name)
            analysis_group.setCustomProperty("emds/assessment_id", assessment.id)
            analysis_group.setCustomProperty("emds/analysis_id", provenance.id)

        # 2b. Route task-level scenario forks under a "Scenarios" subgroup.
        task_meta = getattr(workflow_step, "metadata", {}) or {}
        analysis_meta = getattr(provenance, "metadata", {}) or {}
        is_task_scenario = bool(task_meta.get("scenario_baseline_task_id")) and not analysis_meta.get("is_scenario")

        parent_group = analysis_group
        if is_task_scenario:
            scenarios_group = analysis_group.findGroup("Scenarios")
            if scenarios_group is None:
                scenarios_group = analysis_group.insertGroup(0, "Scenarios")
                scenarios_group.setCustomProperty("emds/assessment_id", assessment.id)
                scenarios_group.setCustomProperty("emds/analysis_id", provenance.id)
                scenarios_group.setCustomProperty("emds/group_role", "scenarios")
            parent_group = scenarios_group

        # 3. Task (WorkflowStep) group
        task_group = parent_group.findGroup(workflow_step.name)
        if task_group is None:
            task_group = parent_group.insertGroup(0, workflow_step.name)
            task_group.setCustomProperty("emds/assessment_id", assessment.id)
            task_group.setCustomProperty("emds/analysis_id", provenance.id)
            task_group.setCustomProperty("emds/workflow_step_id", workflow_step.id)

        # 4. Stamp run provenance (baseline vs scenario) from workflow_runs.
        run_meta = self._resolve_task_run_meta(workflow_step.id)
        task_group.setCustomProperty(
            "emds/run_kind",
            run_meta.get("run_kind") or ("scenario" if is_task_scenario else "baseline"),
        )
        if run_meta.get("run_id"):
            task_group.setCustomProperty("emds/run_id", run_meta["run_id"])
        scenario_id = run_meta.get("scenario_id") or analysis_meta.get("scenario_id") or task_meta.get("scenario_id")
        if scenario_id:
            task_group.setCustomProperty("emds/scenario_id", scenario_id)

        return task_group


    @staticmethod
    def _normalize_netweaver_name(name: Any) -> str:
        """Normalize NetWeaver topic/column names for matching."""
        return re.sub(r"[^a-z0-9]+", "_", str(name or "").strip().lower()).strip("_")

    def _get_netweaver_goal_field_map(
        self,
        workflow_step: WorkflowStep,
        results_df,
        requested_topics: Optional[List[str]] = None,
    ) -> Dict[str, str]:
        """Resolve selected NetWeaver goals to concrete output column names."""
        parameters = workflow_step.parameters or {}
        if requested_topics is None:
            requested_topics = [
                str(item).strip()
                for item in (parameters.get("map_topics") or parameters.get("selected_topics") or [])
                if str(item).strip()
            ]
        normalized_columns = {
            self._normalize_netweaver_name(column_name): str(column_name)
            for column_name in results_df.columns
        }

        goal_field_map: Dict[str, str] = {}
        for topic_name in requested_topics:
            normalized_topic = self._normalize_netweaver_name(topic_name)
            direct_match = normalized_columns.get(normalized_topic)
            if direct_match:
                goal_field_map[topic_name] = direct_match
                continue
            nw_match = normalized_columns.get(f"nw_{normalized_topic}")
            if nw_match:
                goal_field_map[topic_name] = nw_match

        if goal_field_map:
            return goal_field_map

        for column_name in results_df.columns:
            column_text = str(column_name)
            if column_text.lower().startswith("nw_"):
                label = column_text[3:].replace("_", " ").strip() or column_text
                goal_field_map[label] = column_text
        return goal_field_map

    def _resolve_vector_layer_for_table(
        self,
        *,
        input_layer_name: str,
        input_table_name: str,
        database_path: Optional[Path],
    ):
        """Resolve the spatial input layer for a workflow step from the map or project GeoPackage."""
        from qgis.core import QgsProject, QgsVectorLayer

        requested_names = {
            str(name).strip().lower()
            for name in [input_layer_name, input_table_name]
            if str(name).strip()
        }

        for layer in QgsProject.instance().mapLayers().values():
            if not isinstance(layer, QgsVectorLayer):
                continue
            layer_names = {
                layer.name().strip().lower(),
                str(layer.customProperty("emds/source_table_name", "")).strip().lower(),
            }
            if requested_names.intersection({name for name in layer_names if name}):
                return layer

        if database_path and input_table_name:
            resolved_table_name = self._resolve_table_name_in_gpkg(database_path, input_table_name)
            layer = QgsVectorLayer(
                f"{str(database_path)}|layername={resolved_table_name}",
                resolved_table_name,
                "ogr",
            )
            if layer.isValid():
                return layer

        return None

    def _remove_existing_workflow_result_layers(self, workflow_step_id: str) -> None:
        """Remove previously loaded result layers for this workflow step before re-adding them."""
        from qgis.core import QgsProject, QgsLayerTreeGroup

        project = QgsProject.instance()
        for layer in list(project.mapLayers().values()):
            if str(layer.customProperty("emds/workflow_step_id", "")) == workflow_step_id:
                project.removeMapLayer(layer.id())

        # Clean up empty task group node in the layer tree
        root = project.layerTreeRoot()
        if root is not None:
            def clean_groups(parent):
                for child in parent.children():
                    if isinstance(child, QgsLayerTreeGroup):
                        clean_groups(child)
                        if str(child.customProperty("emds/workflow_step_id", "")) == workflow_step_id:
                            parent.removeChildNode(child)
            clean_groups(root)


    def _load_netweaver_goal_layers(
        self,
        *,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        database_path: Path,
        output_table_name: str,
        goal_field_map: Dict[str, str],
    ) -> List[str]:
        """Load one styled map layer per selected NetWeaver goal from a single result table."""
        from qgis.core import QgsProject, QgsVectorLayer

        self._remove_existing_workflow_result_layers(workflow_step.id)

        task_group = self._ensure_results_group_hierarchy(assessment, provenance, workflow_step)
        created_layer_names: List[str] = []
        uri = f"{str(database_path)}|layername={output_table_name}"
        layer_creator = ResultsLayerCreator(QgsProject.instance())

        for topic_name, field_name in goal_field_map.items():
            display_name = f"{workflow_step.name} - {topic_name}"
            layer = QgsVectorLayer(uri, display_name, "ogr")
            if not layer.isValid():
                raise ValueError(
                    f"Could not load NetWeaver results table '{output_table_name}' from {database_path}"
                )

            layer.setCustomProperty("emds/assessment_id", assessment.id)
            layer.setCustomProperty("emds/layer_role", "netweaver_result")
            layer.setCustomProperty("emds/workflow_step_id", workflow_step.id)
            layer.setCustomProperty("emds/source_table_name", output_table_name)
            layer.setCustomProperty("emds/topic_name", topic_name)
            layer.setCustomProperty("emds/goal_field", field_name)
            layer_creator.apply_netweaver_goal_style(layer, field_name)

            QgsProject.instance().addMapLayer(layer, False)
            task_group.insertLayer(0, layer)
            created_layer_names.append(display_name)

        return created_layer_names


    def _persist_netweaver_results(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        execution_id: str,
        results_df,
        add_map_layers: bool = True,
    ) -> Dict[str, Any]:
        """Persist NetWeaver results into the project GeoPackage and add goal-specific map layers."""
        database_path = self._get_project_database_path(project)
        if database_path is None:
            raise ValueError("The selected project does not have a GeoPackage/SQLite database path")

        input_layer = self._resolve_vector_layer_for_table(
            input_layer_name=workflow_step.parameters.get("input_layer_name", ""),
            input_table_name=workflow_step.parameters.get("input_table_name", ""),
            database_path=database_path,
        )
        if input_layer is None:
            raise ValueError(
                "Could not resolve the source spatial layer/table for NetWeaver result persistence"
            )

        parameters = workflow_step.parameters or {}
        selected_topics = [
            str(item).strip()
            for item in (parameters.get("selected_topics") or [])
            if str(item).strip()
        ]
        map_topics = [
            str(item).strip()
            for item in (parameters.get("map_topics") or [])
            if str(item).strip()
        ]

        source_goal_field_map = self._get_netweaver_goal_field_map(
            workflow_step,
            results_df,
            requested_topics=selected_topics,
        )
        if not source_goal_field_map:
            raise ValueError("No NetWeaver goal output fields were found to add to the map")

        output_table_name = SpatialiteManager._sanitize_table_name(
            workflow_step.parameters.get("output_table_name")
            or f"netweaver_{workflow_step.name}_{workflow_step.id[:8]}"
        )

        spatialite_manager = SpatialiteManager()
        success, persisted_table_name = spatialite_manager.copy_vector_layer_to_database(
            output_table_name,
            input_layer,
            str(database_path),
        )
        if not success or not persisted_table_name:
            raise ValueError("Could not write the NetWeaver results layer into the project database")

        goal_field_map, debug_table_name = self._persist_netweaver_goal_values(
            database_path=database_path,
            persisted_table_name=persisted_table_name,
            workflow_step=workflow_step,
            source_goal_field_map=source_goal_field_map,
            results_df=results_df,
        )

        map_goal_field_map = {
            topic_name: field_name
            for topic_name, field_name in goal_field_map.items()
            if not map_topics or topic_name in map_topics
        }
        goal_layer_names = (
            self._load_netweaver_goal_layers(
                assessment=assessment,
                provenance=provenance,
                workflow_step=workflow_step,
                database_path=database_path,
                output_table_name=persisted_table_name,
                goal_field_map=map_goal_field_map,
            )
            if add_map_layers
            else []
        )

        workflow_step.parameters["output_table_name"] = persisted_table_name
        workflow_step.parameters["output_layer_name"] = persisted_table_name
        workflow_step.parameters["goal_fields"] = list(goal_field_map.values())
        workflow_step.parameters["goal_layers"] = goal_layer_names
        workflow_step.parameters["database_path"] = str(database_path)
        workflow_step.parameters["engine_debug_table_name"] = debug_table_name
        workflow_step.metadata = dict(workflow_step.metadata or {})
        workflow_step.metadata.update(
            {
                "execution_id": execution_id,
                "goal_field_map": goal_field_map,
                "map_goal_field_map": map_goal_field_map,
                "goal_layers": goal_layer_names,
                "result_tables": [persisted_table_name],
                "engine_debug_table_name": debug_table_name,
            }
        )
        workflow_step.results = Results(
            id=str(uuid.uuid4()),
            layer_name=persisted_table_name,
            feature_count=input_layer.featureCount(),
            layer_type="Vector",
            metadata={
                "table_name": persisted_table_name,
                "layer_name": persisted_table_name,
                "goal_field_map": goal_field_map,
                "map_goal_field_map": map_goal_field_map,
                "goal_layers": goal_layer_names,
                "execution_id": execution_id,
                "engine_debug_table_name": debug_table_name,
                "engine_type": workflow_step.engine_type.value,
            },
        )

        if not self._persist_workflow_step_metadata(project, assessment, provenance, workflow_step):
            debug_logger.warning("Could not persist NetWeaver task metadata after execution", {
                "workflow_step_id": workflow_step.id,
                "output_table_name": persisted_table_name,
            })

        self._populate_tree()
        item = self._find_tree_item_by_id("workflow", workflow_step.id)
        if item is not None:
            self._select_tree_item(item)
        self._persist_project_snapshots(project)

        return {
            "output_table_name": persisted_table_name,
            "goal_layer_names": goal_layer_names,
            "goal_field_map": goal_field_map,
            "engine_debug_table_name": debug_table_name,
        }

    def _persist_cdp_results(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        execution_id: str,
        results_df,
        add_map_layers: bool = True,
    ) -> Dict[str, Any]:
        """Persist CDP results into the project GeoPackage and add a styled priority layer to the map."""
        database_path = self._get_project_database_path(project)
        if database_path is None:
            raise ValueError("The selected project does not have a GeoPackage/SQLite database path")

        input_layer = self._resolve_vector_layer_for_table(
            input_layer_name=workflow_step.parameters.get("input_layer_name", ""),
            input_table_name=workflow_step.parameters.get("input_table_name", ""),
            database_path=database_path,
        )
        if input_layer is None:
            raise ValueError(
                "Could not resolve the source spatial layer/table for CDP result persistence"
            )

        output_table_name = SpatialiteManager._sanitize_table_name(
            workflow_step.parameters.get("output_table_name")
            or f"cdp_{workflow_step.name}_{workflow_step.id[:8]}"
        )

        spatialite_manager = SpatialiteManager()
        success, persisted_table_name = spatialite_manager.copy_vector_layer_to_database(
            output_table_name,
            input_layer,
            str(database_path),
        )
        if not success or not persisted_table_name:
            raise ValueError("Could not write the CDP results layer into the project database")

        persisted_columns = self._persist_cdp_result_values(
            database_path=database_path,
            persisted_table_name=persisted_table_name,
            results_df=results_df,
            input_layer=input_layer,
            display_field_name=str(workflow_step.parameters.get("display_field", "")).strip(),
        )

        if add_map_layers:
            layer_name = self._load_cdp_result_layer(
                assessment=assessment,
                provenance=provenance,
                workflow_step=workflow_step,
                database_path=database_path,
                output_table_name=persisted_table_name,
            )
        else:
            layer_name = f"{workflow_step.name} - PriorityScores"

        priority_score_field = self._find_cdp_priority_score_field_name(persisted_columns)
        workflow_step.parameters["output_table_name"] = persisted_table_name
        workflow_step.parameters["output_layer_name"] = persisted_table_name
        workflow_step.parameters["database_path"] = str(database_path)
        workflow_step.parameters["result_columns"] = persisted_columns
        workflow_step.parameters["priority_score_field"] = priority_score_field
        workflow_step.metadata = dict(workflow_step.metadata or {})
        workflow_step.metadata.update(
            {
                "execution_id": execution_id,
                "result_tables": [persisted_table_name],
                "result_columns": persisted_columns,
                "priority_score_field": priority_score_field,
                "layer_name": layer_name,
            }
        )
        workflow_step.results = Results(
            id=str(uuid.uuid4()),
            layer_name=persisted_table_name,
            feature_count=input_layer.featureCount(),
            layer_type="Vector",
            metadata={
                "table_name": persisted_table_name,
                "layer_name": layer_name,
                "result_columns": persisted_columns,
                "execution_id": execution_id,
                "priority_score_field": priority_score_field,
                "engine_type": workflow_step.engine_type.value,
            },
        )

        if not self._persist_workflow_step_metadata(project, assessment, provenance, workflow_step):
            debug_logger.warning("Could not persist CDP task metadata after execution", {
                "workflow_step_id": workflow_step.id,
                "output_table_name": persisted_table_name,
            })

        self._populate_tree()
        item = self._find_tree_item_by_id("workflow", workflow_step.id)
        if item is not None:
            self._select_tree_item(item)
        self._persist_project_snapshots(project)

        return {
            "output_table_name": persisted_table_name,
            "layer_name": layer_name,
            "result_columns": persisted_columns,
        }

    def _persist_portfolio_results(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        execution_id: str,
        results_df,
        add_map_layers: bool = True,
    ) -> Dict[str, Any]:
        """Persist Portfolio results into the project GeoPackage and add a styled 0/1 layer."""
        database_path = self._get_project_database_path(project)
        if database_path is None:
            raise ValueError("The selected project does not have a GeoPackage/SQLite database path")

        input_layer = self._resolve_vector_layer_for_table(
            input_layer_name=workflow_step.parameters.get("input_layer_name", ""),
            input_table_name=workflow_step.parameters.get("input_table_name", ""),
            database_path=database_path,
        )
        if input_layer is None:
            raise ValueError(
                "Could not resolve the source spatial layer/table for Portfolio result persistence"
            )

        output_table_name = SpatialiteManager._sanitize_table_name(
            workflow_step.parameters.get("output_table_name")
            or f"portfolio_{workflow_step.name}_{workflow_step.id[:8]}"
        )

        spatialite_manager = SpatialiteManager()
        success, persisted_table_name = spatialite_manager.copy_vector_layer_to_database(
            output_table_name,
            input_layer,
            str(database_path),
        )
        if not success or not persisted_table_name:
            raise ValueError("Could not write the Portfolio results layer into the project database")

        unique_field = str(workflow_step.parameters.get("unique_field", "") or "").strip()
        persisted_columns = self._persist_portfolio_result_values(
            database_path=database_path,
            persisted_table_name=persisted_table_name,
            results_df=results_df,
            input_layer=input_layer,
            unique_field=unique_field,
        )

        if add_map_layers:
            layer_name = self._load_portfolio_result_layer(
                assessment=assessment,
                provenance=provenance,
                workflow_step=workflow_step,
                database_path=database_path,
                output_table_name=persisted_table_name,
            )
        else:
            layer_name = f"{workflow_step.name} - Portfolio"

        workflow_step.parameters["output_table_name"] = persisted_table_name
        workflow_step.parameters["output_layer_name"] = persisted_table_name
        workflow_step.parameters["database_path"] = str(database_path)
        workflow_step.parameters["result_columns"] = persisted_columns

        portfolio_report = (workflow_step.metadata or {}).get("portfolio_report", {}) if workflow_step.metadata else {}
        workflow_step.metadata = dict(workflow_step.metadata or {})
        workflow_step.metadata.update(
            {
                "execution_id": execution_id,
                "result_tables": [persisted_table_name],
                "result_columns": persisted_columns,
                "layer_name": layer_name,
                "inportfolio_field": "InPortfolioSort",
                "portfolio_report": portfolio_report,
            }
        )
        workflow_step.results = Results(
            id=str(uuid.uuid4()),
            layer_name=persisted_table_name,
            feature_count=input_layer.featureCount(),
            layer_type="Vector",
            metadata={
                "table_name": persisted_table_name,
                "layer_name": layer_name,
                "result_columns": persisted_columns,
                "execution_id": execution_id,
                "engine_type": workflow_step.engine_type.value,
                "inportfolio_field": "InPortfolioSort",
                "portfolio_report": portfolio_report,
            },
        )

        if not self._persist_workflow_step_metadata(project, assessment, provenance, workflow_step):
            debug_logger.warning("Could not persist Portfolio task metadata after execution", {
                "workflow_step_id": workflow_step.id,
                "output_table_name": persisted_table_name,
            })

        self._populate_tree()
        item = self._find_tree_item_by_id("workflow", workflow_step.id)
        if item is not None:
            self._select_tree_item(item)
        self._persist_project_snapshots(project)

        return {
            "output_table_name": persisted_table_name,
            "layer_name": layer_name,
            "result_columns": persisted_columns,
            "portfolio_report": portfolio_report,
        }

    def _persist_portfolio_result_values(
        self,
        *,
        database_path: Path,
        persisted_table_name: str,
        results_df,
        input_layer,
        unique_field: str = "",
    ) -> List[str]:
        """
        Append Portfolio engine output columns onto a copied spatial table.

        Unlike NetWeaver/CDP where row order matches the input, Portfolio
        reorders rows (selected items first, then unselected). So we must
        join on the unique identifier field to put each engine row on the
        right feature.
        """
        from .qt_compat import QVariant
        from qgis.core import QgsField, QgsVectorLayer

        if results_df is None:
            raise ValueError("Portfolio results are not available for persistence")

        layer = QgsVectorLayer(
            f"{str(database_path)}|layername={persisted_table_name}",
            persisted_table_name,
            "ogr",
        )
        if not layer.isValid():
            raise ValueError(
                f"Could not open persisted Portfolio table '{persisted_table_name}' for attribute updates"
            )

        provider = layer.dataProvider()
        existing_fields = {field.name().lower() for field in layer.fields()}

        # Decide the persisted column set. We always expose the canonical
        # engine output columns; anything we don't recognise gets added as-is
        # so the user can inspect all fields without losing debug columns.
        canonical_output_columns = [
            "ItemId",
            "InPortfolio",
            "InPortfolioSort",
            "PortfolioSort",
            "CumCost",
            "CumBenefit",
            "ActSort",
            "BudgetCode",
        ]
        ordered_output_columns = [
            column_name
            for column_name in canonical_output_columns
            if column_name in results_df.columns
        ]
        for column_name in results_df.columns:
            if column_name not in ordered_output_columns:
                ordered_output_columns.append(str(column_name))

        source_field_names = {
            field.name().strip().lower(): field.name() for field in input_layer.fields()
        } if input_layer is not None else {}
        reserved_names = {"fid", "ogc_fid", "geom", "geometry", "rowid"}
        missing_fields = []
        column_name_map: List[tuple[str, str]] = []
        persisted_names: List[str] = []

        for column_name in ordered_output_columns:
            original_name = str(column_name)
            if original_name.strip().lower() in reserved_names:
                continue

            # Skip columns that would collide with existing source fields
            # (e.g. the user's own Cost/Benefit/ItemId). We will still update
            # the persisted table using the engine's values for result-only
            # columns like InPortfolio.
            if original_name.strip().lower() in source_field_names and original_name not in (
                "InPortfolio", "InPortfolioSort", "PortfolioSort",
                "CumCost", "CumBenefit", "ActSort", "BudgetCode",
            ):
                continue

            persisted_name = SpatialiteManager._sanitize_table_name(original_name)
            base_name = persisted_name
            suffix = 2
            while persisted_name.lower() in existing_fields:
                persisted_name = f"{base_name}_{suffix}"
                suffix += 1

            dtype_name = str(results_df[column_name].dtype).lower()
            if "int" in dtype_name:
                variant_type = QVariant.Int
            elif any(token in dtype_name for token in ("float", "double", "decimal")):
                variant_type = QVariant.Double
            elif "bool" in dtype_name:
                variant_type = QVariant.Bool
            else:
                variant_type = QVariant.String

            missing_fields.append(QgsField(persisted_name, variant_type))
            existing_fields.add(persisted_name.lower())
            persisted_names.append(persisted_name)
            column_name_map.append((original_name, persisted_name))

        if missing_fields and not provider.addAttributes(missing_fields):
            raise ValueError(
                f"Could not add Portfolio result columns to persisted table '{persisted_table_name}'"
            )
        if missing_fields:
            layer.updateFields()
            # Re-open to pick up the new columns through the provider cleanly
            layer = QgsVectorLayer(
                f"{str(database_path)}|layername={persisted_table_name}",
                persisted_table_name,
                "ogr",
            )
            if not layer.isValid():
                raise ValueError(
                    f"Could not reopen persisted Portfolio table '{persisted_table_name}' after adding result columns"
                )
            provider = layer.dataProvider()

        features = list(layer.getFeatures())
        if not features:
            raise ValueError(f"No rows were found in persisted Portfolio table '{persisted_table_name}'")

        # Build a lookup: ItemId -> engine output row, so we can join on the
        # unique field regardless of row ordering.
        itemid_column = "ItemId" if "ItemId" in results_df.columns else None
        engine_rows_by_id: Dict[str, Any] = {}
        if itemid_column is not None:
            for _, row in results_df.iterrows():
                key = str(row[itemid_column])
                engine_rows_by_id[key] = row

        field_indexes = {field.name().lower(): index for index, field in enumerate(layer.fields())}
        attr_map: Dict[int, Dict[int, Any]] = {}
        resolved_unique_field = source_field_names.get(unique_field.lower(), unique_field) if unique_field else ""

        for row_number, feature in enumerate(features):
            result_row = None
            if resolved_unique_field and engine_rows_by_id:
                try:
                    candidate_key = str(feature.attribute(resolved_unique_field))
                    result_row = engine_rows_by_id.get(candidate_key)
                except Exception:
                    result_row = None
            if result_row is None and row_number < len(results_df.index):
                # Fall back to positional alignment if the unique-field join fails.
                result_row = results_df.iloc[row_number]
            if result_row is None:
                continue

            feature_updates: Dict[int, Any] = {}
            for original_name, persisted_name in column_name_map:
                field_index = field_indexes.get(persisted_name.lower())
                if field_index is None:
                    continue
                try:
                    raw_value = result_row[original_name]
                except Exception:
                    continue
                feature_updates[field_index] = self._coerce_qgis_attribute_value(raw_value)
            if feature_updates:
                attr_map[feature.id()] = feature_updates

        if not attr_map:
            raise ValueError(
                f"No Portfolio result values were prepared for persisted table '{persisted_table_name}'"
            )
        if not provider.changeAttributeValues(attr_map):
            raise ValueError(
                f"Could not batch-update Portfolio result values in persisted table '{persisted_table_name}'"
            )
        layer.reload()

        return persisted_names

    def _load_portfolio_result_layer(
        self,
        *,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        database_path: Path,
        output_table_name: str,
    ) -> str:
        """Load the persisted Portfolio result layer and apply InPortfolioSort symbology."""
        from qgis.core import QgsProject, QgsVectorLayer

        self._remove_existing_workflow_result_layers(workflow_step.id)

        display_name = f"{workflow_step.name} - Portfolio Selection"
        uri = f"{str(database_path)}|layername={output_table_name}"
        layer = QgsVectorLayer(uri, display_name, "ogr")
        if not layer.isValid():
            raise ValueError(
                f"Could not load persisted Portfolio results table '{output_table_name}' from {database_path}"
            )

        layer.setCustomProperty("emds/assessment_id", assessment.id)
        layer.setCustomProperty("emds/layer_role", "portfolio_result")
        layer.setCustomProperty("emds/workflow_step_id", workflow_step.id)
        layer.setCustomProperty("emds/source_table_name", output_table_name)
        layer.setCustomProperty("emds/inportfolio_field", "InPortfolioSort")

        layer_creator = ResultsLayerCreator(QgsProject.instance())
        layer_creator.apply_portfolio_inportfolio_style(layer, "InPortfolioSort")

        task_group = self._ensure_results_group_hierarchy(assessment, provenance, workflow_step)
        QgsProject.instance().addMapLayer(layer, False)
        task_group.insertLayer(0, layer)
        return display_name


    @staticmethod
    def _sanitize_netweaver_field_name(level_index: int, topic_name: str) -> str:
        """Build the persisted EMDS NetWeaver field name."""
        safe_topic = re.sub(r"[^A-Za-z0-9_]+", "_", str(topic_name or "").strip()).strip("_")
        safe_topic = safe_topic or "goal"
        return f"nw{level_index}__{safe_topic}"

    @staticmethod
    def _preferred_netweaver_join_key(field_names: List[str], results_columns: List[str]) -> str:
        """Pick a stable identity column shared by persisted features and engine output."""
        layer_lookup = {str(name).strip().lower(): str(name) for name in field_names}
        result_lookup = {str(name).strip().lower(): str(name) for name in results_columns}
        for candidate in (
            "input_objectid",
            "objectid",
            "source_objectid",
            "emds_source_feature_id",
            "input_fid_attr",
            "ogc_fid",
            "fid",
        ):
            if candidate in layer_lookup and candidate in result_lookup:
                return layer_lookup[candidate]
        return ""

    @staticmethod
    def _stable_join_value(value: Any) -> str:
        """Normalize feature/result keys so QGIS variants and pandas scalars compare."""
        if value is None:
            return ""
        try:
            if hasattr(value, "item"):
                value = value.item()
        except Exception:
            pass
        text = str(value).strip()
        if not text:
            return ""
        try:
            numeric = float(text)
            if numeric.is_integer():
                return str(int(numeric))
        except Exception:
            pass
        return text

    def _persist_netweaver_debug_output_table(
        self,
        *,
        database_path: Path,
        debug_table_name: str,
        source_feature_ids: List[int],
        results_df,
    ) -> str:
        """Persist the raw NetWeaver engine output into a plain SQLite table for debugging."""
        debug_table_name = SpatialiteManager._sanitize_table_name(debug_table_name)
        debug_df = results_df.copy()
        debug_df.insert(0, "source_feature_id", source_feature_ids[: len(debug_df.index)])

        conn = sqlite3.connect(str(database_path))
        try:
            debug_df.to_sql(debug_table_name, conn, if_exists="replace", index=False)
            conn.commit()
        finally:
            conn.close()

        return debug_table_name

    def _persist_netweaver_goal_values(
        self,
        *,
        database_path: Path,
        persisted_table_name: str,
        workflow_step: WorkflowStep,
        source_goal_field_map: Dict[str, str],
        results_df,
    ) -> tuple[Dict[str, str], str]:
        """
        Persist NetWeaver outputs into a copied spatial layer/table.

        This mirrors the legacy ArcGIS flow more closely: copy the input spatial
        table first, add NW result fields, then populate those fields row-by-row.
        """
        level_index = max(1, int(getattr(workflow_step, "depth", 0)) + 1)
        persisted_goal_field_map = {
            topic_name: self._sanitize_netweaver_field_name(level_index, topic_name)
            for topic_name in source_goal_field_map
        }

        debug_logger.section("Persist NetWeaver goal values")
        debug_logger.debug("NetWeaver persistence mapping", {
            "workflow_step_id": workflow_step.id,
            "persisted_table_name": persisted_table_name,
            "level_index": level_index,
            "source_goal_field_map": source_goal_field_map,
            "persisted_goal_field_map": persisted_goal_field_map,
            "result_row_count": len(results_df.index),
        })

        from .qt_compat import QVariant
        from qgis.core import QgsField, QgsVectorLayer

        layer = QgsVectorLayer(
            f"{str(database_path)}|layername={persisted_table_name}",
            persisted_table_name,
            "ogr",
        )
        if not layer.isValid():
            raise ValueError(
                f"Could not open persisted NetWeaver table '{persisted_table_name}' for attribute updates"
            )

        provider = layer.dataProvider()
        existing_fields = {field.name().lower() for field in layer.fields()}
        missing_fields = [
            QgsField(persisted_field_name, QVariant.Double)
            for persisted_field_name in persisted_goal_field_map.values()
            if persisted_field_name.lower() not in existing_fields
        ]
        if missing_fields and not provider.addAttributes(missing_fields):
            raise ValueError(
                f"Could not add NetWeaver result columns to persisted table '{persisted_table_name}'"
            )
        if missing_fields:
            layer.updateFields()

        features = list(layer.getFeatures())
        if not features:
            raise ValueError(f"No rows were found in persisted NetWeaver table '{persisted_table_name}'")

        if len(features) != len(results_df.index):
            raise ValueError(
                "Persisted NetWeaver table row count does not match engine output row count "
                f"({len(features)} != {len(results_df.index)})"
            )

        join_key = self._preferred_netweaver_join_key(
            [field.name() for field in layer.fields()],
            [str(column) for column in results_df.columns],
        )
        result_rows_by_key: Dict[str, Any] = {}
        if join_key:
            result_key_column = next(
                (str(column) for column in results_df.columns if str(column).lower() == join_key.lower()),
                join_key,
            )
            for _, result_row in results_df.iterrows():
                key_value = self._stable_join_value(result_row.get(result_key_column))
                if key_value and key_value not in result_rows_by_key:
                    result_rows_by_key[key_value] = result_row
            if len(result_rows_by_key) != len(results_df.index):
                debug_logger.warning("NetWeaver stable-key join has duplicate or blank result keys", {
                    "workflow_step_id": workflow_step.id,
                    "join_key": join_key,
                    "unique_result_keys": len(result_rows_by_key),
                    "result_rows": len(results_df.index),
                })
                result_rows_by_key = {}

        source_feature_ids = [int(feature.id()) for feature in features]
        debug_table_name = self._persist_netweaver_debug_output_table(
            database_path=database_path,
            debug_table_name=f"{persisted_table_name}__engine_debug",
            source_feature_ids=source_feature_ids,
            results_df=results_df,
        )
        debug_logger.debug("Persisted raw NetWeaver engine output", {
            "workflow_step_id": workflow_step.id,
            "debug_table_name": debug_table_name,
            "engine_output_columns": list(results_df.columns),
            "engine_output_non_null_counts": {
                str(column_name): int(results_df[column_name].notna().sum())
                for column_name in results_df.columns
            },
            "join_key": join_key,
            "join_mode": "stable_key" if result_rows_by_key else "row_order",
        })

        field_indexes = {
            field.name(): index
            for index, field in enumerate(layer.fields())
        }

        # Build a batch attribute-update map: {feature_id: {field_index: value}}
        # This replaces the previous row-by-row changeAttributeValue loop and
        # lets the OGR provider handle all updates in a single operation.
        attr_map = {}
        for row_number, feature in enumerate(features):
            result_row = None
            if result_rows_by_key and join_key:
                feature_key = self._stable_join_value(feature[join_key])
                result_row = result_rows_by_key.get(feature_key)
                if result_row is None:
                    raise ValueError(
                        f"Could not find NetWeaver engine output row for {join_key}={feature_key!r}"
                    )
            else:
                result_row = results_df.iloc[row_number]
            fid = feature.id()
            fid_updates = {}
            for topic_name, source_field_name in source_goal_field_map.items():
                persisted_field_name = persisted_goal_field_map[topic_name]
                field_index = field_indexes.get(persisted_field_name)
                if field_index is None:
                    raise ValueError(
                        f"Persisted NetWeaver column '{persisted_field_name}' is missing from "
                        f"'{persisted_table_name}'"
                    )
                # D5: no-data markers persist as SQL NULL, never as the raw
                # -9999.888777 / double.MaxValue numbers — otherwise avg(),
                # symbology breaks and histogram bin edges all consume them as
                # real scores. The __engine_debug table deliberately keeps the
                # RAW values; it is written by _persist_netweaver_debug_output_table
                # via to_sql and never passes through here.
                fid_updates[field_index] = numeric_or_none(result_row[source_field_name])
            attr_map[fid] = fid_updates

        if not provider.changeAttributeValues(attr_map):
            raise ValueError(
                f"Could not batch-update NetWeaver values in persisted table '{persisted_table_name}'"
            )
        layer.updateFields()

        return persisted_goal_field_map, debug_table_name

    def _persist_cdp_result_values(
        self,
        *,
        database_path: Path,
        persisted_table_name: str,
        results_df,
        input_layer,
        display_field_name: str = "",
    ) -> List[str]:
        """Append CDP engine output columns onto a copied spatial table and populate them row-by-row."""
        from .qt_compat import QVariant
        from qgis.core import QgsField, QgsVectorLayer

        if results_df is None:
            raise ValueError("CDP results are not available for persistence")

        layer = QgsVectorLayer(
            f"{str(database_path)}|layername={persisted_table_name}",
            persisted_table_name,
            "ogr",
        )
        if not layer.isValid():
            raise ValueError(
                f"Could not open persisted CDP table '{persisted_table_name}' for attribute updates"
            )

        provider = layer.dataProvider()
        existing_fields = {field.name().lower() for field in layer.fields()}
        source_field_names = {field.name().lower() for field in input_layer.fields()} if input_layer is not None else set()
        reserved_names = {"fid", "ogc_fid", "geom", "geometry", "rowid"}
        persisted_names: List[str] = []
        missing_fields = []
        column_name_map: List[tuple[str, str]] = []

        for column_name in results_df.columns:
            original_name = str(column_name)
            persisted_name = SpatialiteManager._sanitize_table_name(original_name)

            if original_name.strip().lower() in reserved_names or persisted_name.lower() in reserved_names:
                continue

            if original_name.strip().lower() in source_field_names or persisted_name.lower() in existing_fields:
                continue

            base_name = persisted_name
            suffix = 2
            while persisted_name.lower() in existing_fields or any(persisted_name.lower() == name.lower() for _, name in column_name_map):
                persisted_name = f"{base_name}_{suffix}"
                suffix += 1

            if persisted_name.lower() not in existing_fields:
                dtype_name = str(results_df[column_name].dtype).lower()
                if "int" in dtype_name:
                    variant_type = QVariant.Int
                elif any(token in dtype_name for token in ("float", "double", "decimal")):
                    variant_type = QVariant.Double
                elif "bool" in dtype_name:
                    variant_type = QVariant.Bool
                else:
                    variant_type = QVariant.String
                missing_fields.append(QgsField(persisted_name, variant_type))
                existing_fields.add(persisted_name.lower())
            persisted_names.append(persisted_name)
            column_name_map.append((original_name, persisted_name))

        if missing_fields and not provider.addAttributes(missing_fields):
            raise ValueError(
                f"Could not add CDP result columns to persisted table '{persisted_table_name}'"
            )
        if missing_fields:
            layer.updateFields()

        if missing_fields:
            layer.reload()
            layer = QgsVectorLayer(
                f"{str(database_path)}|layername={persisted_table_name}",
                persisted_table_name,
                "ogr",
            )
            if not layer.isValid():
                raise ValueError(
                    f"Could not reopen persisted CDP table '{persisted_table_name}' after adding result columns"
                )
            provider = layer.dataProvider()

        features = list(layer.getFeatures())
        if not features:
            raise ValueError(f"No rows were found in persisted CDP table '{persisted_table_name}'")

        if len(features) != len(results_df.index):
            raise ValueError(
                "Persisted CDP table row count does not match engine output row count "
                f"({len(features)} != {len(results_df.index)})"
            )

        field_indexes = {field.name().lower(): index for index, field in enumerate(layer.fields())}
        attr_map: Dict[int, Dict[int, Any]] = {}
        resolved_display_field_name = ""
        if display_field_name:
            normalized_source_fields = {
                field.name().strip().lower(): field.name() for field in input_layer.fields()
            }
            resolved_display_field_name = normalized_source_fields.get(
                display_field_name.strip().lower(),
                display_field_name.strip(),
            )
            display_field_index = field_indexes.get("displayname")
            if display_field_index is None and "displayname" not in existing_fields:
                if not provider.addAttributes([QgsField("DisplayName", QVariant.String)]):
                    raise ValueError(
                        f"Could not add DisplayName column to persisted table '{persisted_table_name}'"
                    )
                layer.updateFields()
                layer.reload()
                field_indexes = {
                    field.name().lower(): index for index, field in enumerate(layer.fields())
                }
                display_field_index = field_indexes.get("displayname")
                if display_field_index is not None:
                    persisted_names.append("DisplayName")
                    existing_fields.add("displayname")
            elif display_field_index is not None and "DisplayName" not in persisted_names:
                persisted_names.append("DisplayName")

        for row_number, feature in enumerate(features):
            result_row = results_df.iloc[row_number]
            feature_updates: Dict[int, Any] = {}
            for original_name, persisted_name in column_name_map:
                field_index = field_indexes.get(persisted_name.lower())
                if field_index is None:
                    continue
                feature_updates[field_index] = self._coerce_qgis_attribute_value(
                    result_row[original_name]
                )
            if resolved_display_field_name:
                display_field_index = field_indexes.get("displayname")
                if display_field_index is not None:
                    feature_updates[display_field_index] = self._coerce_qgis_attribute_value(
                        feature.attribute(resolved_display_field_name)
                    )
            if feature_updates:
                attr_map[feature.id()] = feature_updates

        if not attr_map:
            raise ValueError(
                f"No CDP result values were prepared for persisted table '{persisted_table_name}'"
            )

        if not provider.changeAttributeValues(attr_map):
            raise ValueError(
                f"Could not batch-update CDP result values in persisted table '{persisted_table_name}'"
            )

        layer.reload()

        return persisted_names

    @staticmethod
    def _coerce_qgis_attribute_value(value: Any) -> Any:
        """Convert pandas/numpy values into QGIS-friendly scalar values."""
        if value is None:
            return None
        try:
            if value != value:
                return None
        except Exception:
            pass
        if hasattr(value, "item"):
            try:
                value = value.item()
            except Exception:
                pass
        if isinstance(value, (int, float, str, bool)):
            return value
        return str(value)

    @staticmethod
    def _find_cdp_priority_score_field_name(field_names: List[str]) -> str:
        """Resolve the persisted priority-score column name from available CDP output fields."""
        normalized = {str(name).strip().lower(): str(name) for name in field_names}
        for candidate in ("priorityscores", "priority_scores", "cdp_overall_score"):
            resolved = normalized.get(candidate.lower())
            if resolved:
                return resolved
        return ""

    def _load_cdp_result_layer(
        self,
        *,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        database_path: Path,
        output_table_name: str,
    ) -> str:
        """Load the persisted CDP result layer and apply PriorityScores symbology."""
        from qgis.core import QgsProject, QgsVectorLayer

        self._remove_existing_workflow_result_layers(workflow_step.id)

        display_name = f"{workflow_step.name} - PriorityScores"
        uri = f"{str(database_path)}|layername={output_table_name}"
        layer = QgsVectorLayer(uri, display_name, "ogr")
        if not layer.isValid():
            raise ValueError(
                f"Could not load persisted CDP results table '{output_table_name}' from {database_path}"
            )

        priority_field = self._find_cdp_priority_score_field_name(layer.fields().names())
        if not priority_field:
            raise ValueError(
                f"Could not find a PriorityScores field in persisted CDP table '{output_table_name}'"
            )

        layer.setCustomProperty("emds/assessment_id", assessment.id)
        layer.setCustomProperty("emds/layer_role", "cdp_result")
        layer.setCustomProperty("emds/workflow_step_id", workflow_step.id)
        layer.setCustomProperty("emds/source_table_name", output_table_name)
        layer.setCustomProperty("emds/priority_score_field", priority_field)

        layer_creator = ResultsLayerCreator(QgsProject.instance())
        layer_creator.apply_cdp_priority_style(layer, priority_field)

        task_group = self._ensure_results_group_hierarchy(assessment, provenance, workflow_step)
        QgsProject.instance().addMapLayer(layer, False)
        task_group.insertLayer(0, layer)
        return display_name


    def _load_assessment_layer(
        self,
        assessment: Assessment,
        display_name: str,
        layer_spec: str,
        database_path: Optional[Path],
        insert_index: int
    ) -> bool:
        """Load a single Study layer and insert it into the Study group."""
        if not layer_spec:
            return False

        from qgis.core import QgsProject, QgsVectorLayer

        assessment_group = self._ensure_assessment_group(assessment)

        candidates: List[str] = []
        if "|layername=" in layer_spec:
            candidates.append(layer_spec)
        if database_path:
            table_name = self._resolve_table_name_in_gpkg(database_path, layer_spec)
            candidates.append(f"{str(database_path)}|layername={table_name}")

        if not candidates:
            return False

        layer = None
        for uri in candidates:
            candidate_layer = QgsVectorLayer(uri, display_name, "ogr")
            if candidate_layer.isValid():
                layer = candidate_layer
                break

        if layer is None:
            debug_logger.warning("Could not load Study layer", {
                "assessment_id": assessment.id,
                "layer_spec": layer_spec,
                "candidate_uris": candidates
            })
            return False

        assessment_metadata = getattr(assessment, "metadata", {}) or {}
        output_crs_authid = str(assessment_metadata.get("output_crs_authid", "") or "").strip()
        if output_crs_authid and not layer.crs().isValid():
            from qgis.core import QgsCoordinateReferenceSystem
            fallback_crs = QgsCoordinateReferenceSystem(output_crs_authid)
            if fallback_crs.isValid():
                layer.setCrs(fallback_crs)

        layer.setCustomProperty("emds/assessment_id", assessment.id)
        layer.setCustomProperty("emds/layer_role", "assessment")
        QgsProject.instance().addMapLayer(layer, False)
        assessment_group.insertLayer(insert_index, layer)
        return True

    def _add_assessment_layers_to_map(
        self,
        project: Project,
        assessment: Assessment,
        output_layer_name: str,
        reference_layer_name: str = ""
    ) -> None:
        """
        Add the persisted assessment overlay layer to the map.

        The target/reference layer is still preserved in assessment metadata for
        downstream task creation, but the overlay is the artifact users expect to
        see immediately after finishing the wizard.
        """
        database_path = self._get_project_database_path(project)

        if output_layer_name:
            self._load_assessment_layer(
                assessment=assessment,
                display_name=output_layer_name,
                layer_spec=output_layer_name,
                database_path=database_path,
                insert_index=0,
            )

    def _apply_layer_visibility_for_selection(self, path: Dict[str, Optional[str]]) -> None:
        """
        Show only the active analysis group; keep "Base Layers" always visible.

        Each analysis is a top-level group stamped with "emds/assessment_id", so
        we toggle whole groups: the selected assessment's group is shown and the
        others hidden. "Base Layers" carries no stamp and is left untouched
        (always on). With no specific assessment selected (project-level), every
        analysis group is shown. A per-layer pass still covers any
        assessment-tagged layer NOT inside a stamped group (legacy projects).
        """
        try:
            from qgis.core import QgsProject, QgsLayerTreeLayer, QgsLayerTreeGroup

            selected_assessment_id = path.get("assessment_id")
            root = QgsProject.instance().layerTreeRoot()

            # Group-level toggle (primary): one checkbox per analysis group.
            for child in root.children():
                if not isinstance(child, QgsLayerTreeGroup):
                    continue
                group_assessment_id = child.customProperty("emds/assessment_id", "")
                if not group_assessment_id:
                    continue  # e.g. "Base Layers" — leave always visible
                if selected_assessment_id:
                    child.setItemVisibilityChecked(group_assessment_id == selected_assessment_id)
                else:
                    child.setItemVisibilityChecked(True)

            # Per-layer fallback for legacy/ungrouped assessment layers only.
            # Layers already inside a stamped analysis group are governed by the
            # group toggle above; skip them so we don't override the user's
            # manual per-layer visibility within the active group.
            for node in root.findLayers():
                if not isinstance(node, QgsLayerTreeLayer):
                    continue
                layer = node.layer()
                if layer is None:
                    continue
                layer_assessment_id = layer.customProperty("emds/assessment_id", "")
                if not layer_assessment_id:
                    continue
                if self._node_in_stamped_analysis_group(node):
                    continue
                if selected_assessment_id:
                    node.setItemVisibilityChecked(layer_assessment_id == selected_assessment_id)
                else:
                    node.setItemVisibilityChecked(True)
        except Exception as e:
            debug_logger.warning("Could not apply Study layer visibility", {"error": str(e)})

    @staticmethod
    def _node_in_stamped_analysis_group(node) -> bool:
        """Return True if any ancestor group carries an emds/assessment_id stamp."""
        try:
            from qgis.core import QgsLayerTreeGroup

            parent = node.parent()
            while parent is not None:
                if isinstance(parent, QgsLayerTreeGroup) and parent.customProperty(
                    "emds/assessment_id", ""
                ):
                    return True
                parent = parent.parent()
        except Exception:
            return False
        return False

    def _select_assessment_in_tree(self, assessment_id: str) -> None:
        """Find and select an assessment item in the tree by ID."""
        try:
            for i in range(self.tree_widget.topLevelItemCount()):
                project_item = self.tree_widget.topLevelItem(i)
                if project_item is None:
                    continue
                for j in range(project_item.childCount()):
                    item = project_item.child(j)
                    data: TreeNodeData = item.data(0, Qt.ItemDataRole.UserRole) if item else None
                    if data and EMDSTreeModel.normalize_node_type(data.node_type) == "study" and data.item_id == assessment_id:
                        self.tree_widget.setCurrentItem(item)
                        project_item.setExpanded(True)
                        item.setExpanded(True)
                        return
        except Exception as e:
            debug_logger.warning("Error selecting assessment in tree", {"error": str(e)})

    def _find_tree_item_by_id(self, node_type: str, item_id: str):
        """Find a tree item by node type and stable item ID."""
        if not item_id:
            return None
        node_type = EMDSTreeModel.normalize_node_type(node_type) or node_type

        def _walk(item):
            if item is None:
                return None

            data: TreeNodeData = item.data(0, Qt.ItemDataRole.UserRole)
            if data and EMDSTreeModel.normalize_node_type(data.node_type) == node_type and data.item_id == item_id:
                return item

            for index in range(item.childCount()):
                found = _walk(item.child(index))
                if found is not None:
                    return found
            return None

        for index in range(self.tree_widget.topLevelItemCount()):
            found = _walk(self.tree_widget.topLevelItem(index))
            if found is not None:
                return found

        return None

    def _select_tree_item_for_path(self, path: Optional[Dict[str, Optional[str]]]) -> bool:
        """Select the deepest existing tree node represented by a path payload."""
        if not path:
            return False

        for node_type, key in (
            ("task", "task_id"),
            ("task", "workflow_id"),
            ("analysis", "analysis_id"),
            ("analysis", "provenance_id"),
            ("study", "study_id"),
            ("study", "assessment_id"),
            ("workspace", "workspace_id"),
            ("workspace", "project_id"),
        ):
            item_id = path.get(key)
            if not item_id:
                continue

            item = self._find_tree_item_by_id(node_type, item_id)
            if item is not None:
                return self._select_tree_item(item)

        return False

    def _select_tree_item(self, item) -> bool:
        """Select a tree item and synchronize the dock selection state."""
        if item is None:
            return False

        current = item
        while current is not None:
            current.setExpanded(True)
            current = current.parent()

        self.tree_widget.setCurrentItem(item)
        self.tree_widget.scrollToItem(item)

        data: TreeNodeData = item.data(0, Qt.ItemDataRole.UserRole)
        if not data:
            return False

        self.selected_item = item
        self.selected_path = EMDSTreeModel.get_node_path(self.tree_widget, item)
        self.item_selected.emit(self.selected_path)
        return True

    def _select_provenance_in_tree(self, provenance_id: str) -> bool:
        """Find and select an analysis-path node by ID."""
        try:
            item = self._find_tree_item_by_id("analysis", provenance_id)
            if item is None:
                return False
            return self._select_tree_item(item)
        except Exception as exc:
            debug_logger.warning("Error selecting analysis in tree", {"error": str(exc)})
            return False

    def _select_workflow_step_in_tree(self, workflow_step_id: str) -> bool:
        """Find and select a task node by ID."""
        try:
            item = self._find_tree_item_by_id("task", workflow_step_id)
            if item is None:
                return False
            return self._select_tree_item(item)
        except Exception as exc:
            debug_logger.warning("Error selecting task in tree", {"error": str(exc)})
            return False

    def _create_provenance(self):
        """Create a new analysis and its first task."""
        if not self.selected_path or not self.selected_path["assessment_id"]:
            QMessageBox.warning(self, "Selection Required", "Please select a Study Area first")
            return

        project = self.project_manager.get_project(self.selected_path["project_id"])
        assessment = self.project_manager.get_assessment(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"]
        )
        if project is None or assessment is None:
            QMessageBox.warning(self, "Selection Required", "Could not resolve the selected Workspace or Study Area")
            return
        if not self._assessment_supports_current_engines(assessment):
            return

        dialog = CreateProvenanceDialog(self)
        analysis_request = dialog.get_analysis_request()
        if not analysis_request:
            return

        provenance = analysis_request["analysis"]
        engine_type = analysis_request["engine_type"]
        workflow_step = self._create_simple_workflow_step_for_engine(engine_type)
        if not workflow_step:
            return

        assessment.add_provenance(provenance)
        if not self._persist_provenance_metadata(project, assessment, provenance):
            QMessageBox.warning(
                self,
                "Metadata Warning",
                "Analysis metadata could not be saved to the shared EMDS metadata database."
            )
        self._populate_tree()
        if not self._select_provenance_in_tree(provenance.id):
            debug_logger.warning("Could not auto-select created Analysis", {
                "provenance_id": provenance.id,
                "provenance_name": provenance.name,
            })
            QMessageBox.warning(self, "Create Analysis", "The analysis was created, but could not be selected for task insertion.")
            return

        self._persist_project_snapshots(project)
        self.provenance_created.emit(provenance)
        self._refresh_guided_panel()
        self._insert_workflow_step(
            workflow_step,
            success_title="Analysis Created",
            success_template="First task '{name}' was added to the new analysis.",
        )

    def _create_workflow_step(self):
        """Create a new task."""
        if not self.selected_path or not self.selected_path["provenance_id"]:
            QMessageBox.warning(self, "Selection Required", "Please select an Analysis or Task first")
            return

        provenance = self.project_manager.get_provenance(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"],
            self.selected_path["provenance_id"]
        )
        project = self.project_manager.get_project(self.selected_path["project_id"])
        assessment = self.project_manager.get_assessment(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"]
        )
        if provenance is None:
            QMessageBox.warning(self, "Selection Required", "Could not resolve the selected Analysis")
            return
        if project is None or assessment is None:
            QMessageBox.warning(self, "Selection Required", "Could not resolve the selected Workspace or Study Area")
            return
        if not self._assessment_supports_current_engines(assessment):
            return

        task_dialog = CreateTaskDialog(
            workflow_definitions=self._list_project_workflow_definition_choices(project),
            parent=self,
        )
        task_request = task_dialog.get_task_request()
        if not task_request:
            return

        task_type = str(task_request.get("task_type") or "").strip().lower()
        engine_type = task_request.get("engine_type")
        if task_type == CreateTaskDialog.SIMPLE_TASK:
            workflow_step = self._create_simple_workflow_step_for_engine(engine_type)
        elif task_type == CreateTaskDialog.WORKFLOW_TASK:
            workflow_step = WorkflowStep(
                id=str(uuid.uuid4()),
                name=str(task_request.get("task_name") or "").strip(),
                engine_type=EngineType.WORKFLOW,
                parameters={
                    "task_type": "workflow_definition",
                    "workflow_definition_id": str(task_request.get("workflow_definition_id") or "").strip(),
                    "workflow_definition_name": str(task_request.get("workflow_definition_name") or "").strip(),
                    "workflow_bindings": dict(task_request.get("workflow_bindings", {}) or {}),
                    "run_immediately": bool(task_request.get("run_immediately", False)),
                },
            )
        else:
            QMessageBox.warning(
                self,
                "Unsupported Engine",
                f"The engine '{getattr(engine_type, 'value', engine_type)}' is not yet supported for this action.",
            )
            return

        if not workflow_step:
            return

        self._insert_workflow_step(
            workflow_step,
            success_title="Success",
            success_template="Task '{name}' created",
        )

    def _assessment_supports_current_engines(self, assessment) -> bool:
        """Kind-aware + capability-aware guard for Create-Analysis flows.

        For a VECTOR study area, all engines apply today (every engine
        currently supports vector). For a RASTER study area, we
        consult engine_capabilities.any_engine_supports_raster_study()
        — until at least one bridge flips its raster flag to True the
        flow is blocked with an actionable message that points at the
        roadmap. Once raster-aware engines arrive, this guard moves
        from "block all raster analyses" to "filter the engine picker
        to raster-capable engines"; the call sites below stay the same.
        """
        # Lazy local imports avoid a top-of-file circular with models.
        from .engine_capabilities import any_engine_supports_raster_study
        from .models import LayerKind

        kind = getattr(assessment, "kind", LayerKind.VECTOR)
        if kind == LayerKind.VECTOR:
            return True
        if kind == LayerKind.RASTER and any_engine_supports_raster_study():
            return True

        QMessageBox.information(
            self,
            "Raster Study Area",
            (
                "This study area is configured for raster inputs. The "
                "NetWeaver, CDP, and Portfolio engines bundled today "
                "consume vector attributes only.\n\n"
                "Raster-aware engines arrive when a bridge flips its "
                "supports_raster_study flag in engine_capabilities.py "
                "(roadmap Phase 3+). Until then, raster study areas "
                "stop at the overlay output and can still be "
                "displayed / inspected."
            ),
        )
        return False

    def _create_simple_workflow_step_for_engine(self, engine_type: EngineType) -> Optional[WorkflowStep]:
        """Launch the engine-specific wizard for a direct engine task."""
        if engine_type == EngineType.NETWEAVER:
            return self._create_netweaver_workflow_step()
        if engine_type == EngineType.CDP:
            return self._create_cdp_workflow_step()
        if engine_type == EngineType.TEMPLATE_2:
            return self._create_portfolio_workflow_step()

        QMessageBox.warning(
            self,
            "Unsupported Engine",
            f"The engine '{getattr(engine_type, 'value', engine_type)}' is not yet supported for this action.",
        )
        return None

    def _create_netweaver_workflow_step(self) -> Optional[WorkflowStep]:
        """Launch the dedicated NetWeaver wizard using assessment context."""
        project = self.project_manager.get_project(self.selected_path["project_id"])
        assessment = self.project_manager.get_assessment(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"]
        )
        if project is None or assessment is None:
            QMessageBox.warning(self, "Selection Required", "Could not resolve project or assessment")
            return None
        # Raster-support Phase 2 guard.
        if not self._assessment_supports_current_engines(assessment):
            return None

        context = self._get_assessment_execution_context(project, assessment)
        engine_path = self._resolve_netweaver_engine_path()
        dialog = CreateNetWeaverAnalysisDialog(
            project_id=project.id,
            assessment_id=assessment.id,
            input_layer_name=context["input_layer_name"],
            input_table_name=context["input_table_name"],
            input_fields=context["input_fields"],
            engine_path=engine_path,
            metadata_manager=self.metadata_manager,
            database_path=context["database_path"],
            parent=self,
        )
        workflow_step = dialog.get_workflow_step()
        if workflow_step:
            workflow_step.parameters["database_path"] = context["database_path"]
        return workflow_step

    def _create_portfolio_workflow_step(self) -> Optional[WorkflowStep]:
        """Launch the Portfolio wizard using assessment context."""
        project = self.project_manager.get_project(self.selected_path["project_id"])
        assessment = self.project_manager.get_assessment(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"]
        )
        if project is None or assessment is None:
            QMessageBox.warning(self, "Selection Required", "Could not resolve project or assessment")
            return None
        # Raster-support Phase 2 guard.
        if not self._assessment_supports_current_engines(assessment):
            return None

        context = self._get_assessment_execution_context(project, assessment)
        dialog = CreatePortfolioDialog(
            project_id=project.id,
            assessment_id=assessment.id,
            input_layer_name=context["input_layer_name"],
            input_table_name=context["input_table_name"],
            input_fields=context["input_fields"],
            metadata_manager=self.metadata_manager,
            parent=self,
        )
        workflow_step = dialog.get_workflow_step()
        if workflow_step:
            workflow_step.parameters["database_path"] = context["database_path"]
        return workflow_step

    def _create_cdp_workflow_step(self) -> Optional[WorkflowStep]:
        """Launch the CDP prioritization wizard using assessment context."""
        project = self.project_manager.get_project(self.selected_path["project_id"])
        assessment = self.project_manager.get_assessment(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"]
        )
        if project is None or assessment is None:
            QMessageBox.warning(self, "Selection Required", "Could not resolve project or assessment")
            return None
        # Raster-support Phase 2 guard.
        if not self._assessment_supports_current_engines(assessment):
            return None

        context = self._get_assessment_execution_context(project, assessment)
        engine_path = self._resolve_cdp_engine_path()
        dialog = CreatePrioritizationDialog(
            project_id=project.id,
            assessment_id=assessment.id,
            input_layer_name=context["input_layer_name"],
            input_table_name=context["input_table_name"],
            input_fields=context["input_fields"],
            engine_path=engine_path,
            input_field_options=context.get("input_field_options", []),
            metadata_manager=self.metadata_manager,
            parent=self,
        )
        workflow_step = dialog.get_workflow_step()
        if workflow_step:
            workflow_step.parameters["database_path"] = context["database_path"]
        return workflow_step

    def _get_assessment_execution_context(
        self,
        project: Project,
        assessment: Assessment,
        *,
        include_selected_parent: bool = True,
    ) -> Dict[str, Any]:
        """Resolve the layer/table context passed forward from assessment creation."""
        metadata = getattr(assessment, "metadata", {}) or {}
        input_layer_name = (
            metadata.get("output_layer_name")
            or metadata.get("output_table_name")
            or (assessment.input_layers[0] if assessment.input_layers else "")
        )
        input_table_name = metadata.get("output_table_name") or input_layer_name
        database_path = str(self._get_project_database_path(project) or "")

        parent_step = self._resolve_selected_workflow_step() if include_selected_parent else None
        if parent_step is not None:
            input_layer_name = (
                parent_step.parameters.get("output_layer_name")
                or (getattr(parent_step.results, "metadata", {}) or {}).get("layer_name")
                or parent_step.name
            )
            input_table_name = (
                parent_step.parameters.get("output_table_name")
                or (getattr(parent_step.results, "metadata", {}) or {}).get("table_name")
                or input_table_name
            )

        input_fields = self._get_input_fields(input_layer_name, input_table_name, database_path)
        input_field_options = self._get_input_field_options(
            input_layer_name,
            input_table_name,
            database_path,
            fields=input_fields,
            parent_step=parent_step,
        )

        return {
            "input_layer_name": input_layer_name,
            "input_table_name": input_table_name,
            "input_fields": input_fields,
            "input_field_options": input_field_options,
            "database_path": database_path,
        }

    def _extract_chained_execution_context(
        self,
        *,
        project: Project,
        assessment: Assessment,
        value: Any,
    ) -> Optional[Dict[str, Any]]:
        """Extract input-layer context from a previously executed task result payload."""
        if value is None:
            return None

        payload = value
        if isinstance(payload, dict) and isinstance(payload.get("finalize"), dict):
            payload = payload["finalize"]
        if isinstance(payload, dict) and isinstance(payload.get("persisted_result"), dict):
            payload = payload["persisted_result"]
        if not isinstance(payload, dict):
            return None

        database_path = str(
            payload.get("database_path")
            or self._get_project_database_path(project)
            or ""
        )
        input_table_name = str(
            payload.get("output_table_name")
            or payload.get("table_name")
            or payload.get("source_table_name")
            or ""
        ).strip()
        input_layer_name = str(
            payload.get("layer_name")
            or payload.get("output_layer_name")
            or input_table_name
            or ""
        ).strip()
        if not input_table_name:
            return None

        input_fields = self._get_input_fields(input_layer_name, input_table_name, database_path)
        input_field_options = self._get_input_field_options(
            input_layer_name,
            input_table_name,
            database_path,
            fields=input_fields,
            goal_metadata=payload,
        )
        return {
            "input_layer_name": input_layer_name or input_table_name,
            "input_table_name": input_table_name,
            "input_fields": input_fields,
            "input_field_options": input_field_options,
            "database_path": database_path,
        }

    def _get_input_fields(self, input_layer_name: str, input_table_name: str, database_path: str) -> List[str]:
        """Get available field names from the live QGIS layer or a GeoPackage table."""
        if database_path and input_table_name:
            try:
                conn = sqlite3.connect(database_path)
                cur = conn.cursor()
                cur.execute(f'PRAGMA table_info("{input_table_name}")')
                fields = [row[1] for row in cur.fetchall()]
                conn.close()
                if fields:
                    return fields
            except Exception as exc:
                debug_logger.warning("Could not read fields from GeoPackage table", {
                    "exception": str(exc),
                    "database_path": database_path,
                    "input_table_name": input_table_name,
                })

        try:
            from qgis.core import QgsProject

            for layer in QgsProject.instance().mapLayers().values():
                if layer.name().lower() == (input_layer_name or "").lower():
                    return [field.name() for field in layer.fields()]
        except Exception as exc:
            debug_logger.warning("Could not read fields from current QGIS layer", {"exception": str(exc)})

        return []

    def _get_input_field_options(
        self,
        input_layer_name: str,
        input_table_name: str,
        database_path: str,
        *,
        fields: Optional[List[str]] = None,
        parent_step: Optional[WorkflowStep] = None,
        goal_metadata: Optional[Dict[str, Any]] = None,
    ) -> List[Dict[str, Any]]:
        """Return field names plus labels/source hints for mapping dialogs."""
        field_types: Dict[str, str] = {}
        if database_path and input_table_name:
            try:
                safe_table_name = str(input_table_name).replace('"', '""')
                conn = sqlite3.connect(database_path)
                cur = conn.cursor()
                cur.execute(f'PRAGMA table_info("{safe_table_name}")')
                field_types = {
                    str(row[1]): str(row[2] or "")
                    for row in cur.fetchall()
                    if len(row) > 2 and row[1]
                }
                conn.close()
            except Exception as exc:
                debug_logger.warning("Could not read field metadata from GeoPackage table", {
                    "exception": str(exc),
                    "database_path": database_path,
                    "input_table_name": input_table_name,
                })

        if not field_types:
            try:
                from qgis.core import QgsProject

                for layer in QgsProject.instance().mapLayers().values():
                    if layer.name().lower() == (input_layer_name or "").lower():
                        field_types = {
                            field.name(): str(field.typeName() or "")
                            for field in layer.fields()
                        }
                        break
            except Exception as exc:
                debug_logger.warning("Could not read field metadata from current QGIS layer", {"exception": str(exc)})

        field_names = list(fields or [])
        if not field_names and field_types:
            field_names = list(field_types.keys())

        netweaver_labels = self._netweaver_field_labels(parent_step, goal_metadata)
        source_label = str(input_table_name or input_layer_name or "").strip()
        options: List[Dict[str, Any]] = []
        seen: set[str] = set()
        for field_name in field_names:
            name = str(field_name or "").strip()
            if not name or name.lower() in seen:
                continue
            seen.add(name.lower())
            nw_label = netweaver_labels.get(name.lower())
            if nw_label:
                options.append({
                    "name": name,
                    "label": name,
                    "data_type": field_types.get(name, ""),
                    "source_type": "netweaver",
                    "source_label": nw_label,
                })
            else:
                options.append({
                    "name": name,
                    "label": name,
                    "data_type": field_types.get(name, ""),
                    "source_type": "input",
                    "source_label": source_label,
                })
        return options

    def _netweaver_field_labels(
        self,
        parent_step: Optional[WorkflowStep],
        goal_metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, str]:
        """Infer which fields came from a parent NetWeaver task."""
        labels: Dict[str, str] = {}
        metadata_sources: List[Dict[str, Any]] = []
        if parent_step is not None:
            for source in (
                getattr(parent_step, "parameters", {}) or {},
                getattr(parent_step, "metadata", {}) or {},
                getattr(getattr(parent_step, "results", None), "metadata", {}) or {},
            ):
                if isinstance(source, dict):
                    metadata_sources.append(source)
        if isinstance(goal_metadata, dict):
            metadata_sources.append(goal_metadata)

        for source in metadata_sources:
            for key in ("goal_field_map", "map_goal_field_map", "goalFieldMap", "goal_fields_map"):
                goal_map = source.get(key)
                if isinstance(goal_map, dict):
                    for goal_name, field_name in goal_map.items():
                        name = str(field_name or "").strip()
                        if name:
                            labels[name.lower()] = str(goal_name or name)

            for key in ("goal_fields", "goalFields", "netweaver_fields", "netweaverFields"):
                goal_fields = source.get(key)
                if isinstance(goal_fields, dict):
                    for goal_name, field_name in goal_fields.items():
                        name = str(field_name or "").strip()
                        if name:
                            labels.setdefault(name.lower(), str(goal_name or name))
                elif isinstance(goal_fields, list):
                    for entry in goal_fields:
                        if isinstance(entry, dict):
                            name = str(entry.get("field") or entry.get("field_name") or entry.get("name") or "").strip()
                            label = str(entry.get("goal") or entry.get("label") or entry.get("name") or name)
                        else:
                            name = str(entry or "").strip()
                            label = name
                        if name:
                            labels.setdefault(name.lower(), label)

        return labels

    def _resolve_netweaver_engine_path(self) -> str:
        """Resolve the best available NetWeaver CLI path for local development."""
        packaged = self._resolve_packaged_engine_path("NetWeaverCLI")
        if packaged:
            return packaged
        plugin_root = Path(__file__).resolve().parent.parent
        cli_root = plugin_root / "netweaver-cli" / "NetWeaverCLI"
        preferred = cli_root / "bin" / "Debug" / "net10.0" / "NetWeaverCLI.dll"
        if preferred.exists():
            debug_logger.debug("Resolved preferred NetWeaver CLI engine path", {
                "chosen_path": str(preferred),
                "framework": "net10.0",
            })
            return str(preferred)
        return self._resolve_cli_output(cli_root, "NetWeaverCLI.dll")

    def _resolve_cdp_engine_path(self) -> str:
        """Resolve the best available CDP CLI path for local development."""
        packaged = self._resolve_packaged_engine_path("CDPCLI")
        if packaged:
            return packaged
        plugin_root = Path(__file__).resolve().parent.parent
        cli_root = plugin_root / "cdp-cli" / "CDPCLI"
        preferred = cli_root / "bin" / "Debug" / "net10.0" / "CDPCLI.dll"
        if preferred.exists():
            debug_logger.debug("Resolved preferred CDP CLI engine path", {
                "chosen_path": str(preferred),
                "framework": "net10.0",
            })
            return str(preferred)
        return self._resolve_cli_output(cli_root, "CDPCLI.dll")

    def _resolve_packaged_engine_path(self, engine_name: str) -> str:
        """Resolve plugin-local packaged engine outputs before falling back to repo siblings."""
        plugin_dir = Path(__file__).resolve().parent
        engines_root = plugin_dir / "engines"
        if not engines_root.exists():
            return ""

        for rid in self._preferred_engine_rids():
            engine_dir = engines_root / rid / engine_name
            if not engine_dir.exists():
                continue
            executable = engine_dir / f"{engine_name}.exe"
            if executable.exists():
                return str(executable)
            # macOS/Linux self-contained apphosts are extensionless native
            # binaries. Prefer the native apphost over the managed .dll so the
            # bridge launches it directly (no system .NET required); falling
            # through to the .dll would force `dotnet <dll>` and defeat the
            # self-contained build on a clean machine.
            native = engine_dir / engine_name
            if native.exists() and native.is_file():
                return str(native)
            dll = engine_dir / f"{engine_name}.dll"
            if dll.exists():
                return str(dll)

        return ""

    @staticmethod
    def _preferred_engine_rids() -> List[str]:
        system_name = platform.system().lower()
        machine = platform.machine().lower()

        if system_name == "windows":
            return ["win-x64"]
        if system_name == "darwin":
            if machine in {"arm64", "aarch64"}:
                return ["osx-arm64", "osx-x64"]
            return ["osx-x64", "osx-arm64"]
        if system_name == "linux":
            return ["linux-x64"]
        return []

    @staticmethod
    def _parse_framework_version(path: Path) -> tuple[int, int]:
        match = re.fullmatch(r"net(\d+)(?:\.(\d+))?", path.parent.name.lower())
        if not match:
            return (0, 0)
        major = int(match.group(1))
        minor = int(match.group(2) or 0)
        return (major, minor)

    def _resolve_cli_output(self, cli_root: Path, dll_name: str) -> str:
        """
        Resolve the best available CLI output by scanning actual build artifacts.

        Prefers higher target frameworks first, then newer files.
        """
        candidates = list((cli_root / "bin").rglob(dll_name))
        if candidates:
            sorted_candidates = sorted(
                candidates,
                key=lambda path: (
                    self._parse_framework_version(path),
                    path.stat().st_mtime,
                ),
                reverse=True,
            )
            chosen = sorted_candidates[0]
            debug_logger.debug("Resolved CLI engine path", {
                "dll_name": dll_name,
                "chosen_path": str(chosen),
                "candidate_count": len(sorted_candidates),
                "candidates": [str(path) for path in sorted_candidates[:10]],
            })
            return str(chosen)

        fallback = cli_root / "bin" / "Debug" / dll_name
        debug_logger.warning("No built CLI output found", {
            "dll_name": dll_name,
            "searched_root": str(cli_root / "bin"),
            "fallback_path": str(fallback),
        })
        return str(fallback)

    def _execute_workflow_step_immediately(self, workflow_step: WorkflowStep) -> None:
        """Kick off task execution immediately after the NetWeaver wizard finishes."""
        if self._is_workflow_definition_task(workflow_step):
            self._run_workflow_task(workflow_step)
            return

        debug_logger.section("Immediate workflow execution requested")
        debug_logger.debug("Workflow execution context", {
            "workflow_step_id": workflow_step.id,
            "workflow_step_name": workflow_step.name,
            "input_layer_name": workflow_step.parameters.get("input_layer_name"),
            "input_table_name": workflow_step.parameters.get("input_table_name"),
            "database_path": workflow_step.parameters.get("database_path", ""),
        })

        execution_id = self.executor.execute_workflow_step(workflow_step=workflow_step, input_layer_data=None)
        self.active_execution = execution_id
        self.active_execution_context = {
            "execution_id": execution_id,
            "project_id": (self.selected_path or {}).get("project_id", ""),
            "assessment_id": (self.selected_path or {}).get("assessment_id", ""),
            "provenance_id": (self.selected_path or {}).get("provenance_id", ""),
            "workflow_id": workflow_step.id,
        }
        self.progress_dialog = ExecutionProgressDialog(workflow_step, self)
        self.progress_dialog.set_execution_id(execution_id)

        progress_signals = self.executor.get_execution_progress_signals(execution_id)
        if progress_signals:
            progress_signals.progress_updated.connect(self.progress_dialog.update_progress)

        self.progress_dialog.show()

    def _on_execution_started(self, execution_id: str):
        """Handle execution start."""
        self.active_execution = execution_id
        if self.selected_path:
            self._on_item_selection_changed(self.selected_path)

    def _run_selected_task(self) -> None:
        """Run the currently selected task or linked workflow."""
        if self.active_execution:
            QMessageBox.information(self, "Task Running", "Another task is already running.")
            return
        node_type = self._get_selected_node_type()
        if node_type == "analysis":
            self._run_selected_workflow_definition()
            return
        workflow_step = self._get_selected_workflow_step()
        if workflow_step is None:
            QMessageBox.warning(self, "Selection Required", "Please select a task first")
            return
        if self._is_workflow_definition_task(workflow_step):
            self._run_workflow_task(workflow_step)
            return
        self._execute_workflow_step_immediately(workflow_step)

    def _build_workflow_task_initial_variables(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        parent_runtime_variables: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Build inherited and explicitly bound variables for a workflow task."""
        metadata = dict(getattr(workflow_step, "metadata", {}) or {})
        parameters = dict(getattr(workflow_step, "parameters", {}) or {})
        initial_variables: Dict[str, Any] = {
            "projectid": project.id,
            "spatialreferenceid": assessment.id,
            "provenanceid": provenance.id,
            "parentid": str(metadata.get("parent_task_id") or "0"),
            "project_id": project.id,
            "assessment_id": assessment.id,
            "provenance_id": provenance.id,
            "parent_id": str(metadata.get("parent_task_id") or "0"),
        }
        if parent_runtime_variables:
            initial_variables.update(dict(parent_runtime_variables))

        bindings = dict(parameters.get("workflow_bindings", {}) or {})
        for key, value in bindings.items():
            resolved_value = self._resolve_runtime_value(value, initial_variables)
            initial_variables[str(key)] = resolved_value
        return initial_variables

    @staticmethod
    def _build_analysis_path_workflow_initial_variables(
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
    ) -> Dict[str, Any]:
        """Build the default runtime variables for a top-level analysis-path workflow."""
        return {
            "projectid": project.id,
            "spatialreferenceid": assessment.id,
            "provenanceid": provenance.id,
            "parentid": "0",
            "project_id": project.id,
            "assessment_id": assessment.id,
            "provenance_id": provenance.id,
            "parent_id": "0",
        }

    def _load_workflow_step_mapping_file(self, mapping_file: str) -> List[Dict[str, str]]:
        """Parse a simple modelName,databaseName mapping CSV."""
        mapping_path = str(mapping_file or "").strip()
        if not mapping_path:
            return []

        resolved_path = Path(mapping_path)
        if not resolved_path.exists():
            raise ValueError(f"Mapping CSV was not found: {mapping_path}")

        field_mappings: List[Dict[str, str]] = []
        with resolved_path.open("r", encoding="utf-8-sig", newline="") as handle:
            reader = csv.reader(handle)
            for row_number, row in enumerate(reader, start=1):
                if not row or not any(str(value).strip() for value in row):
                    continue
                if len(row) < 2:
                    raise ValueError(
                        f"Mapping CSV line {row_number} must contain modelName,databaseName."
                    )
                target_variable = str(row[0]).strip()
                source_column = str(row[1]).strip()
                if not target_variable or not source_column:
                    raise ValueError(
                        f"Mapping CSV line {row_number} must contain both modelName and databaseName."
                    )
                field_mappings.append({
                    "target_variable": target_variable,
                    "source_column": source_column,
                })
        return field_mappings

    def _build_runtime_workflow_step_from_definition_step(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        definition_step: WorkflowDefinitionStep,
        runtime_variables: Optional[Dict[str, Any]] = None,
    ) -> WorkflowStep:
        """Convert a workflow-definition task step into a runnable engine task."""
        runtime_variables = dict(runtime_variables or {})
        resolved_config = self._resolve_runtime_value(
            dict(definition_step.config or {}),
            runtime_variables,
        )
        engine_value = str(resolved_config.get("engine_type", "") or "").strip()
        if not engine_value:
            raise ValueError(f"Pipeline step '{definition_step.name or definition_step.id}' is missing an engine type.")

        engine_type = self._engine_type_from_value(engine_value)
        if engine_type not in {EngineType.NETWEAVER, EngineType.CDP, EngineType.TEMPLATE_2}:
            raise ValueError(f"Pipeline step '{definition_step.name or definition_step.id}' uses unsupported engine '{engine_value}'.")

        # Fast path: if the step was configured via the wizard we already have a
        # complete parameters payload for the engine. Reconstitute the runtime
        # WorkflowStep from it directly, only re-wiring the assessment database
        # context and chained-input behavior.
        if bool(resolved_config.get("configured_via_wizard")) and isinstance(
            resolved_config.get("task_parameters"), dict
        ):
            return self._build_runtime_step_from_wizard_parameters(
                project=project,
                assessment=assessment,
                provenance=provenance,
                definition_step=definition_step,
                engine_type=engine_type,
                resolved_config=resolved_config,
                runtime_variables=runtime_variables,
            )

        context = self._get_assessment_execution_context(
            project,
            assessment,
            include_selected_parent=False,
        )
        input_source = str(resolved_config.get("input_source", "assessment") or "assessment").strip()
        if input_source == "previous_result":
            chained_context = self._extract_chained_execution_context(
                project=project,
                assessment=assessment,
                value=runtime_variables.get("last_task_result"),
            )
            if chained_context is None:
                raise ValueError(
                    f"Pipeline step '{definition_step.name or definition_step.id}' expected a previous task result, but none was available."
                )
            context = chained_context
        elif input_source == "named_result":
            variable_name = str(resolved_config.get("input_variable", "") or "").strip()
            if not variable_name:
                raise ValueError(
                    f"Pipeline step '{definition_step.name or definition_step.id}' uses a named result input but does not specify the variable name."
                )
            chained_context = self._extract_chained_execution_context(
                project=project,
                assessment=assessment,
                value=runtime_variables.get(variable_name),
            )
            if chained_context is None:
                raise ValueError(
                    f"Pipeline step '{definition_step.name or definition_step.id}' could not resolve input variable '{variable_name}' to a persisted task result."
                )
            context = chained_context

        mapping_file = str(resolved_config.get("mapping_file", "") or "").strip()
        field_mappings = self._load_workflow_step_mapping_file(mapping_file)
        input_fields = list(context.get("input_fields", []) or [])

        parameters: Dict[str, Any] = {
            "model_file": str(resolved_config.get("model_reference", "") or "").strip(),
            "field_mappings": field_mappings,
            "input_layer_name": context["input_layer_name"],
            "input_table_name": context["input_table_name"],
            "database_path": context["database_path"],
            "workflow_definition_step_id": definition_step.id,
            "workflow_definition_name": definition_step.name,
            "workflow_input_source": input_source,
        }
        if input_source == "named_result":
            parameters["workflow_input_variable"] = str(resolved_config.get("input_variable", "") or "").strip()
        if not parameters["model_file"]:
            raise ValueError(
                f"Pipeline step '{definition_step.name or definition_step.id}' is missing a model reference."
            )

        if engine_type == EngineType.CDP:
            preferred_primary_key = ""
            for candidate in ("fid", "ogc_fid", "FID", "OBJECTID"):
                if candidate in input_fields:
                    preferred_primary_key = candidate
                    break
            preferred_display_field = ""
            for mapping in field_mappings:
                candidate = str(mapping.get("source_column", "") or "").strip()
                if candidate and candidate in input_fields:
                    preferred_display_field = candidate
                    break
            parameters["primary_key"] = preferred_primary_key or (input_fields[0] if input_fields else "")
            parameters["display_field"] = preferred_display_field or parameters["primary_key"]

        return WorkflowStep(
            id=str(uuid.uuid4()),
            name=definition_step.name or f"{engine_type.value} Step",
            engine_type=engine_type,
            parameters=parameters,
            metadata={
                "project_id": project.id,
                "assessment_id": assessment.id,
                "provenance_id": provenance.id,
                "workflow_definition_step_id": definition_step.id,
            },
        )

    def _build_runtime_step_from_wizard_parameters(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        definition_step: WorkflowDefinitionStep,
        engine_type: EngineType,
        resolved_config: Dict[str, Any],
        runtime_variables: Dict[str, Any],
    ) -> WorkflowStep:
        """Turn a wizard-configured workflow-definition step into a runtime WorkflowStep.

        We keep the engine-specific parameters the wizard produced verbatim, but still
        honor the workflow's input-source chaining (previous_result / named_result) and
        refresh the database/layer context from the current assessment.
        """
        parameters = dict(resolved_config.get("task_parameters") or {})

        context = self._get_assessment_execution_context(
            project,
            assessment,
            include_selected_parent=False,
        )
        input_source = str(resolved_config.get("input_source", "assessment") or "assessment").strip()
        if input_source == "previous_result":
            chained_context = self._extract_chained_execution_context(
                project=project,
                assessment=assessment,
                value=runtime_variables.get("last_task_result"),
            )
            if chained_context is None:
                raise ValueError(
                    f"Pipeline step '{definition_step.name or definition_step.id}' expected a previous task result, but none was available."
                )
            context = chained_context
        elif input_source == "named_result":
            variable_name = str(resolved_config.get("input_variable", "") or "").strip()
            if not variable_name:
                raise ValueError(
                    f"Pipeline step '{definition_step.name or definition_step.id}' uses a named result input but does not specify the variable name."
                )
            chained_context = self._extract_chained_execution_context(
                project=project,
                assessment=assessment,
                value=runtime_variables.get(variable_name),
            )
            if chained_context is None:
                raise ValueError(
                    f"Pipeline step '{definition_step.name or definition_step.id}' could not resolve input variable '{variable_name}' to a persisted task result."
                )
            context = chained_context

        # Refresh the layer/table/database context so the wizard-configured task runs
        # against the current assessment state even if the project moves.
        parameters["input_layer_name"] = context.get("input_layer_name", parameters.get("input_layer_name", ""))
        parameters["input_table_name"] = context.get("input_table_name", parameters.get("input_table_name", ""))
        parameters["database_path"] = context.get("database_path", parameters.get("database_path", ""))
        parameters["workflow_definition_step_id"] = definition_step.id
        parameters["workflow_definition_name"] = definition_step.name
        parameters["workflow_input_source"] = input_source
        if input_source == "named_result":
            parameters["workflow_input_variable"] = str(resolved_config.get("input_variable", "") or "").strip()

        wizard_task_name = str(resolved_config.get("wizard_task_name", "") or "").strip()
        runtime_name = definition_step.name or wizard_task_name or f"{engine_type.value} Step"

        return WorkflowStep(
            id=str(uuid.uuid4()),
            name=runtime_name,
            engine_type=engine_type,
            parameters=parameters,
            metadata={
                "project_id": project.id,
                "assessment_id": assessment.id,
                "provenance_id": provenance.id,
                "workflow_definition_step_id": definition_step.id,
                "configured_via_wizard": True,
            },
        )

    def _execute_task_or_workflow_synchronously(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        runtime_variables: Optional[Dict[str, Any]] = None,
        progress_callback: Optional[Callable[[str, int], None]] = None,
    ) -> Dict[str, Any]:
        """Execute either an engine-backed task or a workflow-definition task."""
        if self._is_workflow_definition_task(workflow_step):
            workflow_definition_id = str(
                (workflow_step.parameters or {}).get("workflow_definition_id") or ""
            ).strip()
            if not workflow_definition_id:
                raise ValueError(
                    f"Workflow task '{workflow_step.name}' does not define a workflow_definition_id."
                )
            stored_definition = self.metadata_manager.get_workflow_definition(
                project.id,
                workflow_definition_id,
            )
            if stored_definition is None or not isinstance(stored_definition.payload, dict):
                raise ValueError(
                f"Workflow definition '{workflow_definition_id}' could not be found in this Workspace."
                )

            linked_definition = WorkflowDefinition.from_dict(stored_definition.payload)
            initial_variables = self._build_workflow_task_initial_variables(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_step=workflow_step,
                parent_runtime_variables=runtime_variables,
            )
            nested_result = self._execute_workflow_definition_synchronously(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_definition=linked_definition,
                initial_variables=initial_variables,
                log_callback=lambda message: progress_callback(str(message), 0)
                if callable(progress_callback) else None,
            )
            return {
                "workflow_definition_id": workflow_definition_id,
                "workflow_name": linked_definition.name,
                "logs": list(nested_result.logs),
                "variables": dict(nested_result.variables),
                "executed_task_ids": list(nested_result.executed_task_ids),
            }

        resolved_step = self._clone_workflow_step_with_runtime_variables(
            workflow_step,
            dict(runtime_variables or {}),
        )
        return self._execute_workflow_step_synchronously(
            project=project,
            assessment=assessment,
            provenance=provenance,
            workflow_step=resolved_step,
            progress_callback=progress_callback,
        )

    def _execute_workflow_definition_synchronously(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_definition: WorkflowDefinition,
        initial_variables: Optional[Dict[str, Any]] = None,
        log_callback: Optional[Callable[[str], None]] = None,
        test_mode: bool = False,
        row_limit: int = 200,
        results_db_path: Optional[str] = None,
        add_map_layers: bool = True,
    ):
        """Run one workflow definition synchronously and return its execution summary."""
        def task_runner(
            definition_step: WorkflowDefinitionStep,
            runtime_variables: Dict[str, Any],
        ) -> Dict[str, Any]:
            engine_value = str(
                (definition_step.config or {}).get("engine_type", "") or ""
            ).strip()
            if engine_value == EngineType.PYTHON_SCRIPT.value:
                from .python_script_runner import (
                    PythonScriptRunError,
                    run_python_script_from_step_config,
                )

                def _python_log(line: str) -> None:
                    if callable(log_callback):
                        prefix = definition_step.name or definition_step.id
                        log_callback(f"{prefix}: {line}")

                def _python_poll() -> None:
                    # The pipeline runs on the QGIS UI thread; pump the event
                    # loop while the script subprocess runs so the busy dialog
                    # keeps painting instead of showing "Not Responding".
                    try:
                        QApplication.processEvents()
                    except Exception:
                        pass

                try:
                    return run_python_script_from_step_config(
                        dict(definition_step.config or {}),
                        workflow_variables=dict(runtime_variables or {}),
                        log_callback=_python_log,
                        poll_callback=_python_poll,
                    )
                except PythonScriptRunError as exc:
                    raise ValueError(str(exc)) from exc

            runtime_step = self._build_runtime_workflow_step_from_definition_step(
                project=project,
                assessment=assessment,
                provenance=provenance,
                definition_step=definition_step,
                runtime_variables=runtime_variables,
            )

            def task_progress(message: str, _percentage: int) -> None:
                if callable(log_callback):
                    log_callback(f"{runtime_step.name}: {message}")

            input_layer_data = None
            if test_mode and str(
                (definition_step.config or {}).get("input_source", "assessment") or "assessment"
            ).strip() == "assessment":
                input_layer_data = self._load_capped_input_for_task(runtime_step, limit=row_limit)

            if self._is_workflow_definition_task(runtime_step):
                return self._execute_task_or_workflow_synchronously(
                    project=project,
                    assessment=assessment,
                    provenance=provenance,
                    workflow_step=runtime_step,
                    runtime_variables=runtime_variables,
                    progress_callback=task_progress,
                )

            return self._execute_workflow_step_synchronously(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_step=runtime_step,
                progress_callback=task_progress,
                input_layer_data=input_layer_data,
                add_map_layers=add_map_layers and not test_mode,
                results_db_path=results_db_path,
                run_notes="Pipeline Builder test run (staging only)." if test_mode else "",
                test_mode=test_mode,
            )

        executor = WorkflowDefinitionExecutor(
            task_runner=task_runner,
            log_callback=log_callback,
        )
        return executor.execute(
            workflow_definition,
            initial_variables=initial_variables,
        )

    def _run_workflow_task(self, workflow_step: WorkflowStep) -> None:
        """Run the selected workflow-definition task as a composite task."""
        project, assessment, provenance, _selected = self._resolve_selection_context()
        if project is None or assessment is None or provenance is None:
            QMessageBox.information(
                self,
                "Run Pipeline Task",
                "Select a Pipeline Task inside an Analysis first.",
            )
            return

        workflow_definition_id = str(
            (workflow_step.parameters or {}).get("workflow_definition_id") or ""
        ).strip()
        stored_definition = self.metadata_manager.get_workflow_definition(project.id, workflow_definition_id)
        if stored_definition is None:
            QMessageBox.warning(
                self,
                "Run Pipeline Task",
                "The linked workflow definition could not be found in this Workspace.",
            )
            return

        linked_definition = WorkflowDefinition.from_dict(stored_definition.payload)
        initial_variables = self._build_workflow_task_initial_variables(
            project=project,
            assessment=assessment,
            provenance=provenance,
            workflow_step=workflow_step,
        )
        busy_dialog = self._create_busy_dialog(
            title="Running Pipeline Task",
            label=f"Preparing workflow task '{workflow_step.name}'...",
        )

        def log_message(message: str) -> None:
            self._update_busy_dialog(busy_dialog, str(message))

        try:
            self.active_execution = "workflow_task"
            if self.selected_path:
                self._on_item_selection_changed(self.selected_path)

            result = self._execute_workflow_definition_synchronously(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_definition=linked_definition,
                initial_variables=initial_variables,
                log_callback=log_message,
            )
            self._persist_project_snapshots(project)
            self._populate_tree()
            self._select_workflow_step_in_tree(workflow_step.id)
            busy_dialog.close()
            QMessageBox.information(
                self,
                "Pipeline Task Complete",
                f"Workflow task '{workflow_step.name}' completed.\n\n"
                f"Linked workflow: {linked_definition.name or workflow_definition_id}\n"
                f"Executed tasks: {len(result.executed_task_ids)}\n"
                f"Variables: {', '.join(sorted(result.variables.keys())) if result.variables else '(none)'}",
            )
        except Exception as exc:
            busy_dialog.close()
            QMessageBox.critical(
                self,
                "Pipeline Task Failed",
                f"Workflow task '{workflow_step.name}' failed:\n\n{exc}",
            )
        finally:
            self.active_execution = None
            if self.selected_path:
                self._on_item_selection_changed(self.selected_path)

    def _run_pipeline_definition_from_editor(
        self,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_definition: WorkflowDefinition,
    ) -> None:
        """Full pipeline run launched from Pipeline Builder."""
        if not workflow_definition.steps:
            QMessageBox.information(
                self,
                tr("pipeline_builder.title"),
                tr("pipeline_builder.msg_no_steps"),
            )
            return
        busy_dialog = self._create_busy_dialog(
            title=tr("pipeline_builder.run_title"),
            label=tr("pipeline_builder.run_preparing", name=workflow_definition.name or provenance.name),
        )

        def log_message(message: str) -> None:
            self._update_busy_dialog(busy_dialog, str(message))

        try:
            result = self._execute_workflow_definition_synchronously(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_definition=workflow_definition,
                initial_variables=self._build_analysis_path_workflow_initial_variables(
                    project=project,
                    assessment=assessment,
                    provenance=provenance,
                ),
                log_callback=log_message,
            )
            self._persist_project_snapshots(project)
            self._populate_tree()
            self._select_provenance_in_tree(provenance.id)
            busy_dialog.close()
            QMessageBox.information(
                self,
                tr("pipeline_builder.run_title"),
                tr("pipeline_builder.run_complete", name=workflow_definition.name or provenance.name)
                + f"\n\n{len(result.executed_task_ids)} step(s) executed.",
            )
        except Exception as exc:
            busy_dialog.close()
            QMessageBox.critical(
                self,
                tr("pipeline_builder.run_title"),
                tr("pipeline_builder.run_failed", error=exc),
            )

    def _test_pipeline_definition_from_editor(
        self,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_definition: WorkflowDefinition,
    ) -> None:
        """Test pipeline (≤200 rows, staging DB only) from Pipeline Builder."""
        from .staging_results_store import StagingResultsStore

        if not workflow_definition.steps:
            QMessageBox.information(
                self,
                tr("pipeline_builder.title"),
                tr("pipeline_builder.msg_no_steps"),
            )
            return
        staging = StagingResultsStore()
        busy_dialog = self._create_busy_dialog(
            title=tr("pipeline_builder.test_title"),
            label=tr("pipeline_builder.test_preparing"),
        )

        def log_message(message: str) -> None:
            self._update_busy_dialog(busy_dialog, str(message))

        try:
            result = self._execute_workflow_definition_synchronously(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_definition=workflow_definition,
                initial_variables=self._build_analysis_path_workflow_initial_variables(
                    project=project,
                    assessment=assessment,
                    provenance=provenance,
                ),
                log_callback=log_message,
                test_mode=True,
                row_limit=200,
                results_db_path=str(staging.db_path),
                add_map_layers=False,
            )
            busy_dialog.close()
            QMessageBox.information(
                self,
                tr("pipeline_builder.test_title"),
                tr("pipeline_builder.test_complete", steps=len(result.executed_task_ids)),
            )
        except Exception as exc:
            busy_dialog.close()
            QMessageBox.critical(
                self,
                tr("pipeline_builder.test_title"),
                tr("pipeline_builder.test_failed", error=exc),
            )
        finally:
            staging.clear()

    def _build_workflow_editor_wizard_context(
        self,
        project: Optional[Project],
        assessment: Optional[Assessment],
        provenance: Optional[Provenance] = None,
    ) -> Dict[str, Any]:
        """Build the wizard_context dict the WorkflowEditorDialog uses for
        'Configure with Wizard...' on task steps. Returns an empty dict when no
        assessment is resolvable (the button will self-disable in that case)."""
        if project is None or assessment is None:
            return {}

        try:
            context = self._get_assessment_execution_context(project, assessment)
        except Exception:
            context = {}

        editor_context = {
            "project": project,
            "assessment": assessment,
            "provenance": provenance,
            "input_layer_name": context.get("input_layer_name", ""),
            "input_table_name": context.get("input_table_name", ""),
            "input_fields": list(context.get("input_fields", []) or []),
            "input_field_options": list(context.get("input_field_options", []) or []),
            "database_path": context.get("database_path", "") or str(self._get_project_database_path(project) or ""),
            "metadata_manager": self.metadata_manager,
            "netweaver_engine_path": self._resolve_netweaver_engine_path(),
            "cdp_engine_path": self._resolve_cdp_engine_path(),
        }
        if project is not None and assessment is not None and provenance is not None:
            editor_context["pipeline_run_callback"] = lambda definition: self._run_pipeline_definition_from_editor(
                project, assessment, provenance, definition,
            )
            editor_context["pipeline_test_callback"] = lambda definition: self._test_pipeline_definition_from_editor(
                project, assessment, provenance, definition,
            )
        return editor_context

    @staticmethod
    def _scenario_task_has_edits(working_task, source_task) -> bool:
        """Whether a Scenario Builder working task carries Step 1 edits.

        CDP weight edits and NetWeaver ignore-flag edits are written as a new
        temp model file, so any parameter change (including a swapped model)
        surfaces as a difference in the parameters payload.
        """
        if working_task is None:
            return False
        if source_task is None:
            return True
        working_params = dict(getattr(working_task, "parameters", {}) or {})
        source_params = dict(getattr(source_task, "parameters", {}) or {})
        return working_params != source_params

    def _open_pipeline_builder_for_provenance(
        self,
        project,
        assessment,
        provenance,
        *,
        sync_shadow: bool = False,
        scenario_task=None,
        scenario_source_task=None,
        scenario_name: str = "",
    ) -> None:
        """Open Pipeline Builder for an analysis (Level A shadow pipeline when sync_shadow=True).

        When escalating from the Scenario Builder, ``scenario_task`` is the
        in-progress working task. If it carries Step 1 edits (a changed model or
        parameters vs. the baseline ``scenario_source_task``), those edits are
        materialized as a configured, self-contained scenario TASK node in the
        linked pipeline so they survive the handoff instead of being dropped.
        """
        if project is None:
            QMessageBox.information(
                self,
                tr("dock.btn_launch_workflow_editor"),
                tr("provenance.msg_select_workspace_for_pipeline"),
            )
            return

        if sync_shadow and provenance is not None and assessment is not None:
            self._sync_shadow_pipeline_for_provenance(project, assessment, provenance)

        suggested_name = ""
        if provenance is not None and provenance.name.strip():
            suggested_name = f"{provenance.name} Pipeline"
        elif project.name.strip():
            suggested_name = f"{project.name} Pipeline"

        if provenance is not None:
            workflow_definition = self._load_workflow_definition_for_provenance(project, provenance)
            if not workflow_definition.steps and getattr(provenance, "workflow_steps", None):
                workflow_definition = build_default_workflow_definition(provenance)
            if not workflow_definition.name:
                workflow_definition.name = suggested_name
        else:
            workflow_definition = WorkflowDefinition(name=suggested_name)

        # Escalation from the Scenario Builder: materialize pending Step 1 edits
        # as a configured scenario node so they survive the handoff. The node is
        # only persisted if the user saves in the Pipeline Builder; sync keeps it
        # afterwards (scenario_variant) so later task edits never wipe it.
        if scenario_task is not None and self._scenario_task_has_edits(
            scenario_task, scenario_source_task,
        ):
            from .workflow_definition import build_scenario_task_step

            workflow_definition.steps.append(
                build_scenario_task_step(
                    scenario_task,
                    source_task_id=str(getattr(scenario_source_task, "id", "") or ""),
                    scenario_name=scenario_name,
                    is_root=True,
                )
            )

        wizard_context = self._build_workflow_editor_wizard_context(project, assessment, provenance)
        dialog = WorkflowEditorDialog(
            workflow_definition=workflow_definition,
            available_workflows=self._list_project_workflow_definitions_for_editor(project),
            wizard_context=wizard_context,
            parent=self,
        )
        updated_definition = dialog.get_workflow_definition()
        if updated_definition is None:
            return

        if provenance is not None:
            metadata = dict(getattr(provenance, "metadata", {}) or {})
            metadata["workflow_definition_id"] = updated_definition.id
            metadata.pop("workflow_definition", None)
            provenance.metadata = metadata
            if assessment is not None:
                self._persist_provenance_metadata(project, assessment, provenance)

        if not self.metadata_manager.save_workflow_definition(
            project_id=project.id,
            workflow_definition_id=updated_definition.id,
            name=updated_definition.name,
            description=updated_definition.description,
            payload=updated_definition.to_dict(),
        ):
            QMessageBox.warning(
                self,
                tr("dock.btn_launch_workflow_editor"),
                tr("provenance.msg_pipeline_save_failed"),
            )
            return

        self._record_workflow_revision(updated_definition.id, updated_definition.to_dict())
        self._populate_tree()
        if provenance is not None:
            self._select_provenance_in_tree(provenance.id)
        QMessageBox.information(
            self,
            tr("dock.btn_launch_workflow_editor"),
            tr("provenance.msg_pipeline_saved", name=updated_definition.name),
        )

    def _edit_selected_workflow_definition(self) -> None:
        """Launch the workflow editor for the current Workspace's reusable workflows."""
        project, assessment, provenance, workflow_step = self._resolve_selection_context()
        if provenance is None and workflow_step is not None and self.selected_path:
            project = self.project_manager.get_project(self.selected_path.get("project_id", ""))
            assessment = self.project_manager.get_assessment(
                self.selected_path.get("project_id", ""),
                self.selected_path.get("assessment_id", ""),
            )
            provenance = self.project_manager.get_provenance(
                self.selected_path.get("project_id", ""),
                self.selected_path.get("assessment_id", ""),
                self.selected_path.get("provenance_id", ""),
            )

        self._open_pipeline_builder_for_provenance(project, assessment, provenance)

    def _execute_workflow_step_synchronously(
        self,
        *,
        project: Project,
        assessment: Assessment,
        provenance: Provenance,
        workflow_step: WorkflowStep,
        progress_callback: Optional[Callable[[str, int], None]] = None,
        input_layer_data=None,
        add_map_layers: bool = True,
        run_label: str = "",
        run_notes: str = "",
        results_db_path: Optional[str] = None,
        test_mode: bool = False,
    ) -> Dict[str, Any]:
        """Run one existing EMDS task synchronously for the workflow engine."""
        execution_id, result, results_df = self.executor.execute_workflow_step_sync(
            workflow_step=workflow_step,
            input_layer_data=input_layer_data,
            progress_callback=progress_callback,
            results_db_path=results_db_path,
        )
        finalize = self._finalize_completed_task_execution(
            execution_id=execution_id,
            result=result,
            project=project,
            assessment=assessment,
            provenance=provenance,
            workflow_step=workflow_step,
            results_df=results_df,
            open_viewer=False,
            show_message=False,
            add_map_layers=add_map_layers,
            run_label=run_label,
            run_notes=run_notes,
            test_mode=test_mode,
        )
        return {
            "execution_id": execution_id,
            "result": result,
            "results_df": results_df,
            "finalize": finalize,
        }

    def _run_selected_workflow_definition(self) -> None:
        """Run the workflow definition linked to the selected Analysis."""
        project, assessment, provenance, _workflow_step = self._resolve_selection_context()
        if project is None or assessment is None or provenance is None:
            QMessageBox.information(
                self,
                "Run Pipeline",
                "Select an Analysis first.",
            )
            return

        workflow_definition = self._load_workflow_definition_for_provenance(project, provenance)
        if not workflow_definition.steps:
            QMessageBox.information(
                self,
                "Run Pipeline",
                "This Analysis does not contain any pipeline steps yet.",
            )
            return

        busy_dialog = self._create_busy_dialog(
            title="Running Pipeline",
            label=f"Preparing pipeline '{workflow_definition.name or provenance.name}'...",
        )

        def log_message(message: str) -> None:
            self._update_busy_dialog(busy_dialog, str(message))

        try:
            self.active_execution = "workflow_definition"
            if self.selected_path:
                self._on_item_selection_changed(self.selected_path)

            result = self._execute_workflow_definition_synchronously(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_definition=workflow_definition,
                initial_variables=self._build_analysis_path_workflow_initial_variables(
                    project=project,
                    assessment=assessment,
                    provenance=provenance,
                ),
                log_callback=log_message,
            )
            self._persist_project_snapshots(project)
            self._populate_tree()
            self._select_provenance_in_tree(provenance.id)
            busy_dialog.close()
            QMessageBox.information(
                self,
                "Pipeline Complete",
                f"Pipeline '{workflow_definition.name or provenance.name}' completed.\n\n"
                f"Executed tasks: {len(result.executed_task_ids)}\n"
                f"Variables: {', '.join(sorted(result.variables.keys())) if result.variables else '(none)'}\n\n"
                f"Last log:\n{result.logs[-1] if result.logs else 'No log messages.'}",
            )
        except Exception as exc:
            busy_dialog.close()
            QMessageBox.critical(
                self,
                "Pipeline Failed",
                f"Pipeline '{workflow_definition.name or provenance.name}' failed:\n\n{exc}",
            )
        finally:
            self.active_execution = None
            if self.selected_path:
                self._on_item_selection_changed(self.selected_path)

    def _get_selected_workflow_step(self) -> Optional[WorkflowStep]:
        """Resolve the selected task from the current tree selection."""
        if not self.selected_path or not self.selected_path.get("workflow_id"):
            return None
        return self.project_manager.find_workflow_step(
            self.selected_path["project_id"],
            self.selected_path["assessment_id"],
            self.selected_path["provenance_id"],
            self.selected_path["workflow_id"],
        )

    def _populate_tree(self) -> None:
        """Rebuild the tree, then apply provenance stale badges (Phase 4b)."""
        EMDSTreeModel.populate_tree(self.tree_widget, self.project_manager)
        self._apply_stale_badges()

    def _apply_stale_badges(self) -> None:
        """Badge task nodes whose results are stale vs their last recorded run."""
        try:
            from .provenance_capture import task_stale_reasons
            from .qt_compat import QColor

            root = self.tree_widget.invisibleRootItem()
            stack = [root.child(i) for i in range(root.childCount())]
            while stack:
                item = stack.pop()
                for i in range(item.childCount()):
                    stack.append(item.child(i))
                data = item.data(0, Qt.ItemDataRole.UserRole)
                if data is None or getattr(data, "node_type", "") != "task":
                    continue
                base = item.text(0)
                if base.endswith(" ⚠"):
                    base = base[:-2]
                reasons = task_stale_reasons(self.metadata_manager, data.item_id)
                if reasons:
                    item.setText(0, base + " ⚠")
                    item.setToolTip(0, "Stale (re-run to refresh): " + "; ".join(reasons))
                    item.setForeground(0, QColor(146, 64, 14))
                else:
                    item.setText(0, base)
        except Exception:
            pass

    def _record_workflow_revision(self, workflow_definition_id: str, payload: Dict[str, Any]) -> None:
        """Append an immutable workflow-definition revision (Phase 4a, deduped on hash)."""
        if not workflow_definition_id:
            return
        try:
            from .provenance_capture import current_user
            prior = self.metadata_manager.get_latest_workflow_definition_revision(workflow_definition_id)
            self.metadata_manager.add_workflow_definition_revision(
                workflow_definition_id,
                payload or {},
                change_summary=self._summarize_workflow_revision(prior, payload or {}),
                created_by=current_user(),
            )
        except Exception:
            pass

    @staticmethod
    def _summarize_workflow_revision(prior, payload) -> str:
        new_steps = payload.get("steps", []) if isinstance(payload, dict) else []
        if prior is None:
            return f"Initial revision ({len(new_steps)} step(s))"
        old_steps = (getattr(prior, "payload", {}) or {}).get("steps", [])
        if len(new_steps) != len(old_steps):
            return f"Steps {len(old_steps)} → {len(new_steps)}"
        return "Edited workflow configuration"

    def _open_workflow_history_dialog(self) -> None:
        """Show the revision history of the selected Analysis's workflow definition."""
        project, _assessment, provenance, _step = self._resolve_selection_context()
        if provenance is None:
            QMessageBox.information(self, "Workflow History", "Select an Analysis first.")
            return
        workflow_definition_id = str((getattr(provenance, "metadata", {}) or {}).get("workflow_definition_id") or "")
        if not workflow_definition_id:
            QMessageBox.information(
                self, "Workflow History", "This Analysis has no saved workflow definition yet."
            )
            return
        try:
            revisions = self.metadata_manager.list_workflow_definition_revisions(workflow_definition_id)
        except Exception:
            revisions = []
        if not revisions:
            QMessageBox.information(
                self,
                "Workflow History",
                "No revisions recorded yet. Edit and save the pipeline to start tracking history.",
            )
            return
        from .dialogs.workflow_history_dialog import WorkflowHistoryDialog
        dialog = WorkflowHistoryDialog(
            provenance.name,
            revisions,
            parent=self,
            restore_callback=lambda rev: self._restore_workflow_revision(
                project, provenance, workflow_definition_id, rev
            ),
        )
        exec_dialog(dialog)

    def _build_difference_report(self, task_a_id: str, task_b_id: str) -> str:
        """HTML difference report between two tasks' latest runs (S4)."""
        from .provenance_report import build_difference_report_html
        return build_difference_report_html(self.metadata_manager, task_a_id, task_b_id)

    def _restore_workflow_revision(self, project, provenance, workflow_definition_id, revision) -> bool:
        """Make a past revision the current workflow definition (additive, S4).

        Writes the revision's payload back as the current definition and records a
        new revision tagged as a restore. Never deletes history.
        """
        payload = getattr(revision, "payload", None) or {}
        if not workflow_definition_id or not payload:
            return False
        saved = self.metadata_manager.save_workflow_definition(
            project_id=project.id,
            workflow_definition_id=workflow_definition_id,
            name=f"{provenance.name} Pipeline",
            description="",
            payload=payload,
        )
        if not saved:
            return False
        try:
            from .provenance_capture import current_user
            self.metadata_manager.add_workflow_definition_revision(
                workflow_definition_id,
                payload,
                change_summary=f"Restored revision {getattr(revision, 'revision_number', '?')}",
                created_by=current_user(),
            )
        except Exception:
            pass
        try:
            self._persist_project_snapshots(project)
            self._populate_tree()
        except Exception:
            pass
        return True

    # ------------------------------------------------------------------
    # Phase 5 — scenarios / what-if
    # ------------------------------------------------------------------
    def _find_analysis_by_id(self, analysis_id: str):
        """Locate (workspace, study, analysis) for an analysis id in memory."""
        if not analysis_id:
            return None, None, None
        for workspace in getattr(self.project_manager, "workspaces", []):
            for study in getattr(workspace, "studies", []):
                for analysis in getattr(study, "analyses", []):
                    if analysis.id == analysis_id:
                        return workspace, study, analysis
        return None, None, None

    def _analysis_is_scenario(self, data) -> bool:
        try:
            _ws, _st, analysis = self._find_analysis_by_id(getattr(data, "item_id", ""))
            return bool(analysis and (analysis.metadata or {}).get("is_scenario"))
        except Exception:
            return False

    @staticmethod
    def _reid_cloned_tasks(tasks) -> None:
        """Recursively give cloned tasks fresh ids, clear results, remember baseline."""
        import uuid as _uuid
        for task in tasks or []:
            old_id = task.id
            task.id = str(_uuid.uuid4())
            task.result = None
            task.metadata = dict(task.metadata or {})
            task.metadata["scenario_baseline_task_id"] = old_id
            task.metadata.pop("model_snapshot_id", None)
            EMDSDockWidget._reid_cloned_tasks(getattr(task, "children", []) or [])

    def _persist_cloned_tasks(self, project, assessment, analysis, tasks, parent_id: str = "") -> None:
        for task in tasks or []:
            self._persist_workflow_step_metadata(project, assessment, analysis, task, parent_id)
            children = getattr(task, "children", []) or []
            if children:
                self._persist_cloned_tasks(project, assessment, analysis, children, task.id)

    def _duplicate_analysis_as_scenario(self) -> None:
        """Clone the selected Analysis (tasks + pipeline) as an independent scenario."""
        import copy
        import uuid

        project, assessment, provenance, _step = self._resolve_selection_context()
        if project is None or assessment is None or provenance is None:
            QMessageBox.information(self, "Duplicate as Scenario", "Select an Analysis first.")
            return
        try:
            scenario = copy.deepcopy(provenance)
            scenario.id = str(uuid.uuid4())
            scenario.name = f"{provenance.name} (scenario)"
            self._reid_cloned_tasks(getattr(scenario, "tasks", []) or [])

            scenario_id = str(uuid.uuid4())
            base_definition = self._load_workflow_definition_for_provenance(project, provenance)
            new_definition = copy.deepcopy(base_definition)
            new_definition.id = str(uuid.uuid4())
            new_definition.name = f"{base_definition.name or provenance.name} (scenario)"

            scenario.metadata = dict(provenance.metadata or {})
            scenario.metadata.update({
                "is_scenario": True,
                "scenario_of": provenance.id,
                "scenario_id": scenario_id,
                "workflow_definition_id": new_definition.id,
            })
            scenario.metadata.pop("workflow_definition", None)

            assessment.add_analysis(scenario)
            self._persist_provenance_metadata(project, assessment, scenario)
            self._persist_cloned_tasks(project, assessment, scenario, getattr(scenario, "tasks", []) or [])
            self._save_workflow_definition_for_provenance(project, assessment, scenario, new_definition)
            self._persist_project_snapshots(project)

            self._populate_tree()
            self._select_provenance_in_tree(scenario.id)
            QMessageBox.information(
                self,
                "Duplicate as Scenario",
                f"Created scenario '{scenario.name}'.\n\n"
                "Edit its model / mappings / parameters, run it (the baseline stays untouched), "
                "then use 'Compare to Baseline…' to see the effect.",
            )
        except Exception as exc:
            debug_logger.error("Duplicate as Scenario failed", {
                "analysis_id": provenance.id,
                "exception": str(exc),
            })
            QMessageBox.critical(self, "Duplicate as Scenario", f"Could not create the scenario:\n\n{exc}")

    def _compare_scenario_to_baseline(self) -> None:
        """Open the compare viewer for a scenario task result vs its baseline."""
        project, assessment, scenario, _step = self._resolve_selection_context()
        if scenario is None or not (scenario.metadata or {}).get("is_scenario"):
            QMessageBox.information(self, "Compare to Baseline", "Select a scenario Analysis.")
            return
        scenario_task = next(
            (t for t in self._iter_tasks_recursive(getattr(scenario, "tasks", []) or []) if t.result is not None),
            None,
        )
        if scenario_task is None:
            QMessageBox.information(
                self, "Compare to Baseline", "Run the scenario first to produce a result to compare."
            )
            return
        baseline_task_id = str((scenario_task.metadata or {}).get("scenario_baseline_task_id", "") or "")
        _ws, _st, baseline_analysis = self._find_analysis_by_id(str((scenario.metadata or {}).get("scenario_of", "") or ""))
        baseline_task = None
        if baseline_analysis is not None:
            baseline_task = next(
                (t for t in self._iter_tasks_recursive(getattr(baseline_analysis, "tasks", []) or [])
                 if t.id == baseline_task_id),
                None,
            )
        if baseline_task is None or baseline_task.result is None:
            QMessageBox.information(
                self, "Compare to Baseline", "The matching baseline task has no result to compare against."
            )
            return
        primary_df = self._load_dataframe_for_workflow_step(scenario_task)
        if primary_df is None or primary_df.empty:
            QMessageBox.information(self, "Compare to Baseline", "The scenario result has no data yet.")
            return
        engine_label = scenario_task.engine_type.value if hasattr(scenario_task.engine_type, "value") else str(scenario_task.engine_type)
        candidate = CompareCandidate(
            label=f"Baseline: {baseline_task.name}",
            workspace_name=project.name if project else "",
            study_name=assessment.name if assessment else "",
            analysis_name=baseline_analysis.name if baseline_analysis else "",
            engine=engine_label,
            workflow_step_id=baseline_task.id,
            load_dataframe=lambda step=baseline_task: self._load_dataframe_for_workflow_step(step),
        )
        dialog = CompareResultsDialog(
            primary_label=f"Scenario: {scenario_task.name}",
            primary_engine=engine_label,
            primary_dataframe=primary_df,
            candidates=[candidate],
            parent=self,
            provenance_provider=self._provenance_for_workflow_step,
            primary_workflow_step_id=scenario_task.id,
            difference_report_provider=self._build_difference_report,
        )
        self._show_engine_viewer_modeless(dialog)

    def _open_scenario_builder(self) -> None:
        """Guided what-if on the selected task (Scenario Builder, plan S2)."""
        project, assessment, provenance, source_task = self._resolve_selection_context()
        if project is None or assessment is None or provenance is None or source_task is None:
            QMessageBox.information(self, tr("provenance.scenario_builder_title"), tr("provenance.msg_select_task"))
            return
        from .staging_results_store import StagingResultsStore

        staging = StagingResultsStore()
        try:
            from .dialogs.scenario_builder_dialog import ScenarioBuilderDialog
            weights_provider, weights_applier = self._build_cdp_weights_io()
            nodes_provider, nodes_applier = self._build_nw_nodes_io()
            scenario_number = self._next_scenario_number(provenance, source_task.id)
            default_scenario_name = f"{source_task.name}_scenario{scenario_number}"
            dialog = ScenarioBuilderDialog(
                source_task,
                has_children=bool(getattr(source_task, "children", []) or []),
                preview_runner=lambda wt: self._scenario_test_runner(
                    project, assessment, provenance, wt, staging=staging,
                ),
                preview_run_runner=lambda wt, name: self._scenario_record_preview_run(
                    project, assessment, provenance, source_task, wt, scenario_name=name,
                ),
                weights_provider=weights_provider,
                weights_applier=weights_applier,
                nodes_provider=nodes_provider,
                nodes_applier=nodes_applier,
                default_scenario_name=default_scenario_name,
                preview_viewer=lambda eid, dframe: self._show_scenario_preview(eid, dframe),
                compare_viewer=lambda task, dframe, preview_task_id=None: self._show_scenario_test_compare(
                    task, dframe, preview_task_id=preview_task_id,
                ),
                pipeline_builder_opener=lambda working_task=None: self._open_pipeline_builder_for_provenance(
                    project, assessment, provenance, sync_shadow=True,
                    scenario_task=working_task, scenario_source_task=source_task,
                    scenario_name=default_scenario_name,
                ),
                parent=self,
            )
            exec_dialog(dialog)
            result = dialog.get_result()
            if not result:
                return
            self._apply_scenario_action(project, assessment, provenance, source_task, result)
        except Exception as exc:
            debug_logger.error("Scenario Builder failed", {
                "task_id": getattr(source_task, "id", ""),
                "exception": str(exc),
            })
            QMessageBox.critical(
                self,
                tr("provenance.scenario_builder_title"),
                tr("provenance.msg_scenario_failed", error=exc),
            )
        finally:
            staging.clear()

    def _load_capped_input_for_task(self, workflow_step, limit: int = 200):
        """Load a small input subset for a preview run (≤limit rows)."""
        import pandas as pd
        import sqlite3
        params = workflow_step.parameters or {}
        database_path = str(params.get("database_path", "") or "")
        table = str(params.get("input_table_name", "") or "")
        if database_path and table:
            try:
                conn = sqlite3.connect(database_path)
                df = pd.read_sql_query(f'SELECT * FROM "{table}" LIMIT {int(limit)}', conn)
                conn.close()
                return df
            except Exception:
                pass
        try:
            from qgis.core import QgsProject
            wanted = str(params.get("input_layer_name", "") or "").strip().lower()
            for layer in QgsProject.instance().mapLayers().values():
                if layer.name().strip().lower() == wanted and wanted:
                    field_names = [f.name() for f in layer.fields()]
                    rows = []
                    for index, feature in enumerate(layer.getFeatures()):
                        if index >= limit:
                            break
                        rows.append({fn: feature[fn] for fn in field_names})
                    return pd.DataFrame(rows)
        except Exception:
            pass
        return None

    def _scenario_test_runner(self, project, assessment, provenance, working_task, *, staging):
        """Run the working task on ≤200 rows (Test mode); return (ok, message, execution_id, df).

        Results persist only in the session staging database (~/.emds/sessions/).
        Nothing is written to the project tree, map, workflow_runs, or ~/.emds/results.db.
        Staging is cleared when the Scenario Builder dialog closes.
        """
        try:
            capped = self._load_capped_input_for_task(working_task, limit=200)
            if capped is None or capped.empty:
                return False, "Could not load input data for a test.", None, None
            execution_id, _result, df = self.executor.execute_workflow_step_sync(
                workflow_step=working_task,
                input_layer_data=capped,
                progress_callback=None,
                results_db_path=str(staging.db_path),
            )
            if df is None or getattr(df, "empty", True):
                return False, "Test produced no result rows.", None, None
            return (
                True,
                f"Test ran on {len(capped.index)} rows → {len(df.index)} result rows (staging only).",
                execution_id,
                df,
            )
        except Exception as exc:
            return False, f"Test failed: {exc}", None, None

    def _scenario_preview_runner(self, project, assessment, provenance, working_task, *, staging=None):
        """Backward-compatible alias for :meth:`_scenario_test_runner`."""
        if staging is None:
            from .staging_results_store import StagingResultsStore
            staging = StagingResultsStore()
            try:
                return self._scenario_test_runner(
                    project, assessment, provenance, working_task, staging=staging,
                )
            finally:
                staging.clear()
        return self._scenario_test_runner(project, assessment, provenance, working_task, staging=staging)

    def _scenario_record_preview_run(
        self, project, assessment, provenance, source_task, working_task, *, scenario_name: str,
    ):
        """Optional preview run: fork, execute ≤200 rows, record workflow_runs, no map layers."""
        from . import scenario_clone as scl

        try:
            base_name = (scenario_name or source_task.name or "scenario").strip()
            preview_name = base_name if base_name.lower().endswith(" preview") else f"{base_name} Preview"
            clone, _sid = scl.build_scenario_fork(
                source_task,
                scenario_name=preview_name,
                include_children=False,
                scenario_of_analysis_id=provenance.id,
            )
            clone.parameters = dict(clone.parameters or {})
            working_params = dict(getattr(working_task, "parameters", {}) or {})
            if working_params.get("model_file"):
                clone.parameters["model_file"] = working_params["model_file"]
            sibling_list, parent = scl.find_task_and_container(provenance, source_task.id)
            if sibling_list is None:
                return False, "Could not locate the task in the analysis.", None, None
            sibling_list.append(clone)
            parent_id = parent.id if parent is not provenance else ""
            self._persist_cloned_tasks(project, assessment, provenance, [clone], parent_id)

            capped = self._load_capped_input_for_task(clone, limit=200)
            if capped is None or capped.empty:
                return False, "Could not load input data for a preview run.", None, None

            run_payload = self._execute_workflow_step_synchronously(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_step=clone,
                input_layer_data=capped,
                add_map_layers=False,
                run_label=preview_name,
                run_notes="Preview run from Scenario Builder (layers not added to map).",
            )
            self._persist_project_snapshots(project)
            self._populate_tree()
            self._select_workflow_step_in_tree(clone.id)

            step_runs = self.metadata_manager.get_step_runs_for_task(clone.id)
            run_id = step_runs[0].run_id if step_runs else ""
            row_count = len((run_payload.get("results_df") or capped).index)
            message = (
                f"Preview run completed on {len(capped.index)} rows → {row_count} result rows."
                + (f" Run id: {run_id}." if run_id else "")
            )
            return True, message, clone.id, run_payload.get("results_df")
        except Exception as exc:
            return False, f"Preview run failed: {exc}", None, None

    @staticmethod
    def _task_is_scenario_fork(workflow_step: WorkflowStep) -> bool:
        metadata = dict(getattr(workflow_step, "metadata", {}) or {})
        return bool(metadata.get("is_scenario") or metadata.get("scenario_baseline_task_id"))

    def _promote_scenario_task(self) -> None:
        """Record a promoted-baseline audit marker for the latest scenario run (Pro parity)."""
        project, assessment, provenance, workflow_step = self._resolve_selection_context()
        if project is None or assessment is None or provenance is None or workflow_step is None:
            QMessageBox.information(self, tr("provenance.menu_promote_scenario"), tr("provenance.msg_select_task"))
            return
        if not self._task_is_scenario_fork(workflow_step):
            QMessageBox.information(
                self,
                tr("provenance.menu_promote_scenario"),
                tr("provenance.msg_promote_not_scenario"),
            )
            return
        step_runs = self.metadata_manager.get_step_runs_for_task(workflow_step.id)
        if not step_runs:
            QMessageBox.information(
                self,
                tr("provenance.menu_promote_scenario"),
                tr("provenance.msg_promote_no_run"),
            )
            return
        scenario_run = self.metadata_manager.get_workflow_run(step_runs[0].run_id)
        if scenario_run is None or (scenario_run.run_kind or "").lower() != "scenario":
            QMessageBox.information(
                self,
                tr("provenance.menu_promote_scenario"),
                tr("provenance.msg_promote_not_scenario_run"),
            )
            return
        confirm = QMessageBox.question(
            self,
            tr("provenance.menu_promote_scenario"),
            tr("provenance.msg_promote_confirm"),
            QMessageBox.Yes | QMessageBox.No,
            QMessageBox.No,
        )
        if confirm != QMessageBox.Yes:
            return
        from . import provenance_capture as pc

        promoted_id = self.metadata_manager.save_workflow_run(
            workspace_id=project.id,
            study_id=assessment.id,
            analysis_id=provenance.id,
            run_kind="baseline",
            parent_run_id=scenario_run.id,
            label=tr("provenance.promoted_scenario_label"),
            notes=tr("provenance.promoted_scenario_notes"),
            status="promoted",
            created_by=pc.current_user(),
            emds_version=self._emds_version_string(),
            gis_version=self._qgis_version_string(),
        )
        if not promoted_id:
            QMessageBox.warning(
                self,
                tr("provenance.menu_promote_scenario"),
                tr("provenance.msg_promote_failed"),
            )
            return
        QMessageBox.information(
            self,
            tr("provenance.menu_promote_scenario"),
            tr("provenance.msg_promote_success", run_id=promoted_id),
        )

    def _show_scenario_preview(self, execution_id, results_df) -> None:
        """Display the (not-saved) scenario test grid. Called after the busy
        state so the viewer opens with a normal cursor."""
        viewer = ResultsViewerDialog(execution_id, results_df, self)
        viewer.setWindowTitle("Scenario test (staging only)")
        exec_dialog(viewer)

    def _load_task_result_dataframe(self, workflow_step):
        """Load the latest persisted result grid for a task, if available."""
        try:
            if workflow_step.engine_type == EngineType.NETWEAVER:
                payload = self.netweaver_results_service.load_viewer_payload(workflow_step)
                return payload.get("results_df")
            if workflow_step.engine_type == EngineType.CDP:
                payload = self.cdp_results_service.load_viewer_payload(workflow_step)
                return payload.get("results_df")
        except Exception as exc:
            debug_logger.warning("Could not load baseline result for scenario test compare", {
                "task_id": getattr(workflow_step, "id", ""),
                "exception": str(exc),
            })
        return None

    def _show_scenario_test_compare(self, source_task, test_df, *, preview_task_id=None) -> None:
        """Compare baseline task output vs staging test or preview-run output (≤200 rows)."""
        if preview_task_id:
            preview_task = self._find_workflow_step_by_id(preview_task_id)
            if preview_task is not None:
                persisted = self._load_task_result_dataframe(preview_task)
                if persisted is not None and not getattr(persisted, "empty", True):
                    test_df = persisted
        baseline_df = self._load_task_result_dataframe(source_task)
        if baseline_df is None or getattr(baseline_df, "empty", True):
            QMessageBox.information(
                self,
                "Scenario Test",
                "No baseline result was found for this task. Showing the test grid only.",
            )
            self._show_scenario_preview("test", test_df)
            return
        try:
            from .dialogs.compare_results_dialog import CompareCandidate, CompareResultsDialog

            engine = (
                source_task.engine_type.value
                if hasattr(source_task.engine_type, "value")
                else str(source_task.engine_type)
            )
            cap = 200
            primary_df = baseline_df.head(cap)
            test_capped = test_df.head(cap) if test_df is not None else test_df
            compare_label = (
                f"Preview — {source_task.name}" if preview_task_id else f"Test — {source_task.name}"
            )
            dialog = CompareResultsDialog(
                primary_label=f"Baseline — {source_task.name}",
                primary_engine=engine,
                primary_dataframe=primary_df,
                candidates=[
                    CompareCandidate(
                        label=compare_label,
                        workspace_name="",
                        study_name="",
                        analysis_name="",
                        engine=engine,
                        workflow_step_id=source_task.id,
                        load_dataframe=lambda: test_capped,
                    )
                ],
                parent=self,
                primary_workflow_step_id=source_task.id,
            )
            exec_dialog(dialog)
        except Exception as exc:
            debug_logger.warning("Scenario test compare failed; falling back to grid", {
                "task_id": getattr(source_task, "id", ""),
                "exception": str(exc),
            })
            self._show_scenario_preview("test", test_df)

    def _apply_scenario_action(self, project, assessment, provenance, source_task, result) -> None:
        """Perform the chosen fork/replace and run it (plan S2)."""
        from . import scenario_clone as scl

        action = result["action"]
        name = result["scenario_name"]
        working = result["working_task"]
        new_model = (working.parameters or {}).get("model_file")

        if action in ("fork_only", "fork_children"):
            include_children = action == "fork_children"
            # "_scenarioN" suffix appended to cloned child task names so the
            # scenario subtree is visibly independent from the baseline.
            child_suffix = self._scenario_child_suffix(name, source_task.name) if include_children else ""
            clone, _sid = scl.build_scenario_fork(
                source_task,
                scenario_name=name,
                include_children=include_children,
                scenario_of_analysis_id=provenance.id,
                child_suffix=child_suffix,
            )
            if new_model:
                clone.parameters = dict(clone.parameters or {})
                clone.parameters["model_file"] = new_model
            sibling_list, parent = scl.find_task_and_container(provenance, source_task.id)
            if sibling_list is None:
                QMessageBox.warning(self, tr("provenance.scenario_builder_title"), tr("provenance.msg_task_not_found"))
                return
            sibling_list.append(clone)
            parent_id = parent.id if parent is not provenance else ""
            self._persist_cloned_tasks(project, assessment, provenance, [clone], parent_id)
            root_run = self._execute_workflow_step_synchronously(
                project=project, assessment=assessment, provenance=provenance, workflow_step=clone,
            )
            child_count, child_failures = 0, []
            if include_children and (clone.children or []):
                child_count, child_failures = self._run_scenario_subtree(
                    project, assessment, provenance, clone, root_run,
                )
            self._persist_project_snapshots(project)
            self._populate_tree()
            self._select_workflow_step_in_tree(clone.id)
            extra = ""
            if child_count:
                extra = (
                    f"\n\nRe-ran {child_count} child task(s) on this scenario's output "
                    "and added them to the map."
                )
            if child_failures:
                extra += "\n\nSome child tasks could not be auto-run (run them from the tree):\n" + "\n".join(
                    f"  • {nm}" for nm in child_failures
                )
            QMessageBox.information(
                self,
                tr("provenance.scenario_builder_title"),
                tr("provenance.msg_scenario_created", name=clone.name, extra=extra),
            )
            return

        if action == "replace":
            if new_model:
                source_task.parameters = dict(source_task.parameters or {})
                source_task.parameters["model_file"] = new_model
            for descendant in scl.iter_subtree(source_task)[1:]:
                self._remove_existing_workflow_result_layers(descendant.id)
            self._persist_workflow_step_metadata(project, assessment, provenance, source_task)
            self._execute_workflow_step_synchronously(
                project=project, assessment=assessment, provenance=provenance, workflow_step=source_task,
            )
            self._persist_project_snapshots(project)
            self._populate_tree()
            self._select_workflow_step_in_tree(source_task.id)
            QMessageBox.information(
                self,
                tr("provenance.scenario_builder_title"),
                tr("provenance.msg_scenario_replaced", name=source_task.name),
            )

    def _next_scenario_number(self, analysis, source_task_id: str) -> int:
        """Next sequential scenario number for a task = (existing forks of it) + 1."""
        from . import scenario_clone as scl
        count = 0
        for task in getattr(analysis, "tasks", []) or []:
            for node in scl.iter_subtree(task):
                md = getattr(node, "metadata", {}) or {}
                if md.get("is_scenario") and md.get("scenario_baseline_task_id") == source_task_id:
                    count += 1
        return count + 1

    @staticmethod
    def _scenario_child_suffix(scenario_name: str, source_name: str) -> str:
        """The "_scenarioN" tail to append to cloned child task names.

        When the scenario name follows the default "<task>_scenarioN" pattern,
        reuse its "_scenarioN" tail (so "child" -> "child_scenario1"); otherwise
        fall back to "_<scenario_name>".
        """
        prefix = f"{source_name}_"
        if scenario_name.startswith(prefix) and len(scenario_name) > len(prefix):
            return scenario_name[len(source_name):]  # e.g. "_scenario1"
        return f"_{scenario_name}" if scenario_name else "_scenario"

    def _run_scenario_subtree(self, project, assessment, provenance, parent_task, parent_run) -> tuple:
        """Auto-run a scenario clone's children top-down, re-pointing each child's
        input at its parent's freshly-produced output.

        Reuses ``_extract_chained_execution_context`` (the same parent->child input
        wiring the pipeline uses) so a child consumes the scenario output rather
        than the baseline's. Each run finalizes normally (adds its result to the
        map). Returns ``(count_run, [names_that_failed])``.
        """
        count = 0
        failures: List[str] = []
        for child in list(getattr(parent_task, "children", []) or []):
            try:
                ctx = self._extract_chained_execution_context(
                    project=project, assessment=assessment, value=parent_run,
                )
                if ctx:
                    child.parameters = dict(child.parameters or {})
                    if ctx.get("database_path"):
                        child.parameters["database_path"] = ctx["database_path"]
                    if ctx.get("input_table_name"):
                        child.parameters["input_table_name"] = ctx["input_table_name"]
                    if ctx.get("input_layer_name"):
                        child.parameters["input_layer_name"] = ctx["input_layer_name"]
                    self._persist_workflow_step_metadata(
                        project, assessment, provenance, child, parent_task.id,
                    )
                child_run = self._execute_workflow_step_synchronously(
                    project=project, assessment=assessment, provenance=provenance, workflow_step=child,
                )
                count += 1
                sub_count, sub_failures = self._run_scenario_subtree(
                    project, assessment, provenance, child, child_run,
                )
                count += sub_count
                failures.extend(sub_failures)
            except Exception as exc:
                debug_logger.warning("Scenario child auto-run failed", {
                    "child_id": getattr(child, "id", ""),
                    "child_name": getattr(child, "name", ""),
                    "exception": str(exc),
                })
                failures.append(getattr(child, "name", "(unnamed)"))
        return count, failures

    def _open_provenance_dialog(self) -> None:
        """Show the read-only provenance view for the selected task."""
        workflow_step = self._get_selected_workflow_step()
        if workflow_step is None:
            QMessageBox.information(self, "Provenance", "Select a task first.")
            return
        try:
            from .dialogs.provenance_dialog import ProvenanceDialog
            dialog = ProvenanceDialog(
                self.metadata_manager,
                workflow_step,
                parent=self,
                compare_callback=lambda step=workflow_step: self._open_model_version_compare(step),
            )
            exec_dialog(dialog)
        except Exception as exc:
            QMessageBox.warning(self, "Provenance", f"Could not open the provenance view:\n\n{exc}")

    def _provenance_for_workflow_step(self, step_id: str) -> Dict[str, Any]:
        """Provenance summary of a task's latest run (for compare dialogs)."""
        try:
            runs = self.metadata_manager.get_step_runs_for_task(step_id)
        except Exception:
            runs = []
        if not runs:
            return {}
        latest = runs[0]
        snapshot = None
        if latest.model_snapshot_id:
            snapshot = self.metadata_manager.get_model_snapshot(latest.model_snapshot_id)
        return {
            "model": snapshot.display_name if snapshot else "",
            "model_hash": snapshot.content_sha256 if snapshot else "",
            "engine_version": latest.engine_version or "",
            "when": latest.start_time or latest.created_at or "",
            "rows": latest.rows_processed or 0,
            "model_snapshot_id": latest.model_snapshot_id or "",
            "summary": snapshot.model_summary if snapshot else None,
        }

    def _open_model_version_compare(self, workflow_step=None) -> None:
        """Compare the model version last run against the current model file (3b)."""
        if workflow_step is None:
            workflow_step = self._get_selected_workflow_step() or self._resolve_selected_workflow_step()
        if workflow_step is None:
            QMessageBox.information(self, "Compare Model Versions", "Select a task first.")
            return
        params = workflow_step.parameters or {}
        model_path = str(params.get("model_file", "") or "")
        if not model_path:
            QMessageBox.information(
                self, "Compare Model Versions", "This task has no model file to compare."
            )
            return
        provenance = self._provenance_for_workflow_step(workflow_step.id)
        baseline_summary = provenance.get("summary") if provenance else None
        if not baseline_summary:
            QMessageBox.information(
                self,
                "Compare Model Versions",
                "No model snapshot has been captured yet. Run this task once to capture a "
                "baseline, then compare it against the current model file.",
            )
            return
        provider = self._build_model_summary_provider(workflow_step.engine_type)
        if provider is None:
            QMessageBox.warning(
                self,
                "Compare Model Versions",
                "No engine is configured to read the current model file.",
            )
            return
        try:
            from .dialogs.busy_progress import busy_progress
            from .translator import tr as _tr
            with busy_progress(self, _tr("provenance.busy_reading_model")):
                live = provider(model_path)
            live_summary = live.get("model_summary") if isinstance(live, dict) else None
        except Exception as exc:
            QMessageBox.warning(
                self, "Compare Model Versions", f"Could not read the current model file:\n\n{exc}"
            )
            return
        if not live_summary:
            QMessageBox.warning(
                self, "Compare Model Versions", "The current model file produced no summary."
            )
            return
        from .dialogs.model_version_compare_dialog import ModelVersionCompareDialog

        baseline_hash = str(provenance.get("model_hash", "") or "")
        file_name = model_path.replace("\\", "/").rsplit("/", 1)[-1]
        dialog = ModelVersionCompareDialog(
            label_old=f"Last run ({baseline_hash[:8]})" if baseline_hash else "Last run",
            summary_old=baseline_summary,
            label_new=f"Current file ({file_name})",
            summary_new=live_summary,
            field_mappings=params.get("field_mappings", []) or [],
            parent=self,
        )
        exec_dialog(dialog)

    def _resolve_model_blob_for_run(self, workflow_step):
        """Run-from-snapshot resolver (A1): snapshot the current model file and
        return its embedded DB bytes so the engine executes exactly those bytes.

        Snapshots the *current* file each run (so edits take effect) and returns
        the bytes read back from the DB. Returns (None, "") to fall back to the
        file path — provenance must never block a run.
        """
        import os
        try:
            if workflow_step is None or workflow_step.engine_type not in (EngineType.NETWEAVER, EngineType.CDP):
                return None, ""
            params = workflow_step.parameters or {}
            model_path = str(params.get("model_file", "") or "")
            if not model_path or not os.path.isfile(model_path):
                return None, ""
            from . import provenance_capture as pc
            snapshot_id = pc.ensure_model_snapshot(
                self.metadata_manager,
                model_kind=pc.model_kind_for_engine(workflow_step.engine_type.value),
                model_path=model_path,
                summary_provider=self._build_model_summary_provider(workflow_step.engine_type),
                created_by=pc.current_user(),
            )
            if not snapshot_id:
                return None, ""
            self.metadata_manager.set_task_model_snapshot(workflow_step.id, snapshot_id)
            blob = self.metadata_manager.get_model_snapshot_blob(snapshot_id)
            if not blob:
                return None, ""
            return blob, os.path.splitext(model_path)[1]
        except Exception:
            return None, ""

    def _build_model_summary_provider(self, engine_type):
        """Return a callable(model_path)->summary dict for the engine, or None.

        Used by snapshot capture to fetch the deterministic model summary +
        engine version. Best-effort: returns None when no engine path is
        configured so capture degrades to bytes + hash only.
        """
        try:
            from .bridge_loader import NetWeaverBridge, CDPBridge
        except Exception:
            return None
        engine_paths = getattr(self.executor, "engine_paths", {}) or {}
        try:
            if engine_type == EngineType.NETWEAVER and engine_paths.get("netweaver"):
                bridge = NetWeaverBridge(engine_paths["netweaver"])
            elif engine_type == EngineType.CDP and engine_paths.get("cdp"):
                bridge = CDPBridge(engine_paths["cdp"])
            else:
                return None
        except Exception:
            return None
        return lambda path: bridge.get_model_summary(path)

    def _build_cdp_weights_io(self):
        """Return (provider, applier) for in-EMDS CDP weight editing, or (None, None).

        Path B (CdpEditor backend). ``provider(model_path)`` returns the editable
        weight edges (modeling-space ids); ``applier(model_path, edits)`` applies
        the edits, writes the edited engine-loadable .cdpx to a temp file, and
        returns its path. The Scenario Builder then points the working task at that file,
        so preview / fork / snapshot all use the edited model with no extra wiring.
        Returns (None, None) when no CDP engine is configured.
        """
        try:
            from .bridge_loader import CDPBridge
        except Exception:
            return None, None
        engine_paths = getattr(self.executor, "engine_paths", {}) or {}
        cdp_path = engine_paths.get("cdp")
        if not cdp_path:
            return None, None
        try:
            bridge = CDPBridge(cdp_path)
        except Exception:
            return None, None

        def provider(model_path):
            return bridge.get_editable_weights(model_path)

        def applier(model_path, edits):
            import tempfile
            out = tempfile.NamedTemporaryFile(suffix=".cdpx", delete=False)
            out.close()
            res = bridge.set_weights(model_path, edits, output_path=out.name)
            return res.get("output_path") or out.name

        return provider, applier

    def _build_nw_nodes_io(self):
        """Return (provider, applier) for in-EMDS NetWeaver ignore-flag editing, or (None, None).

        Path B (fixed emdsViewers engine). ``provider(model_path)`` returns the
        toggleable nodes + current persistent-ignore; ``applier(model_path,
        ignore_nodes, clear_nodes)`` sets PersistentIgnore, writes the edited
        engine-loadable .nw2 to a temp file, and returns its path. The Scenario Builder
        points the working task at that file, so preview / fork / snapshot all use
        the edited model. Returns (None, None) when no NetWeaver engine is configured.
        """
        try:
            from .bridge_loader import NetWeaverBridge
        except Exception:
            return None, None
        engine_paths = getattr(self.executor, "engine_paths", {}) or {}
        nw_path = engine_paths.get("netweaver")
        if not nw_path:
            return None, None
        try:
            bridge = NetWeaverBridge(nw_path)
        except Exception:
            return None, None

        def provider(model_path):
            return bridge.get_editable_nodes(model_path)

        def applier(model_path, ignore_nodes, clear_nodes):
            import tempfile
            out = tempfile.NamedTemporaryFile(suffix=".nw2", delete=False)
            out.close()
            res = bridge.set_node_ignores(
                model_path,
                ignore_nodes=ignore_nodes,
                clear_nodes=clear_nodes,
                output_path=out.name,
            )
            return res.get("output_path") or out.name

        return provider, applier

    @staticmethod
    def _emds_version_string() -> str:
        """Best-effort EMDS plugin version from metadata.txt."""
        try:
            from pathlib import Path as _Path
            meta = _Path(__file__).with_name("metadata.txt")
            for line in meta.read_text(encoding="utf-8").splitlines():
                if line.strip().startswith("version="):
                    return line.split("=", 1)[1].strip()
        except Exception:
            pass
        return ""

    @staticmethod
    def _qgis_version_string() -> str:
        try:
            from qgis.core import Qgis
            return str(Qgis.QGIS_VERSION)
        except Exception:
            return ""

    def _record_task_provenance(
        self,
        *,
        project,
        assessment,
        provenance,
        workflow_step,
        execution_id,
        result,
        run_label: str = "",
        run_notes: str = "",
    ) -> None:
        """Capture model snapshot + run manifest for a completed task (provenance).

        Hooked into the shared completion path so both single-task runs and
        workflow runs are covered. Never raises — provenance must not break a run.
        """
        if not (project and assessment and provenance and workflow_step):
            return
        try:
            from . import provenance_capture as pc

            params = workflow_step.parameters or {}
            engine_value = workflow_step.engine_type.value
            engine_version = str((result or {}).get("engine_version", "") or "")
            model_path = str(params.get("model_file", "") or "")

            snapshot_id = ""
            if model_path:
                snapshot_id = pc.ensure_model_snapshot(
                    self.metadata_manager,
                    model_kind=pc.model_kind_for_engine(engine_value),
                    model_path=model_path,
                    summary_provider=self._build_model_summary_provider(workflow_step.engine_type),
                    engine_version=engine_version,
                    created_by=pc.current_user(),
                )
                if snapshot_id:
                    self.metadata_manager.set_task_model_snapshot(workflow_step.id, snapshot_id)

            input_refs = [
                ref for ref in (params.get("input_layer_name"), params.get("input_table_name")) if ref
            ]
            safe_params = {k: v for k, v in params.items() if k != "field_mappings"}

            # Scenario tagging (Phase 5): scenario analyses and task-level forks
            # are recorded as run_kind='scenario' and linked back to the baseline.
            analysis_meta = getattr(provenance, "metadata", {}) or {}
            task_meta = dict(getattr(workflow_step, "metadata", {}) or {})
            is_analysis_scenario = bool(analysis_meta.get("is_scenario"))
            is_task_scenario = bool(
                task_meta.get("is_scenario") or task_meta.get("scenario_baseline_task_id")
            )
            is_scenario = is_analysis_scenario or is_task_scenario
            run_kind = "scenario" if is_scenario else "baseline"
            scenario_id = str(
                task_meta.get("scenario_id")
                or (analysis_meta.get("scenario_id") if is_analysis_scenario else "")
                or ""
            )
            parent_run_id = ""
            derived_from = ""
            baseline_task_id = str(task_meta.get("scenario_baseline_task_id", "") or "")
            if is_scenario and baseline_task_id:
                try:
                    base_runs = self.metadata_manager.get_step_runs_for_task(baseline_task_id)
                except Exception:
                    base_runs = []
                if base_runs:
                    parent_run_id = base_runs[0].run_id
                    derived_from = base_runs[0].result_id

            pc.record_task_run(
                self.metadata_manager,
                workspace_id=project.id,
                study_id=assessment.id,
                analysis_id=provenance.id,
                task_id=workflow_step.id,
                task_name=workflow_step.name,
                engine_type=engine_value,
                execution_id=execution_id,
                engine_version=engine_version,
                model_snapshot_id=snapshot_id,
                parameters=safe_params,
                field_mappings=params.get("field_mappings", []) or [],
                input_refs=input_refs,
                run_kind=run_kind,
                scenario_id=scenario_id,
                parent_run_id=parent_run_id,
                run_label=run_label,
                run_notes=run_notes,
                derived_from_result_id=derived_from,
                status=str((result or {}).get("status", "completed") or "completed"),
                start_time=str((result or {}).get("start_time", "") or ""),
                end_time=str((result or {}).get("end_time", "") or ""),
                duration_ms=int((result or {}).get("duration_ms", 0) or 0),
                rows_processed=int((result or {}).get("rows_processed", 0) or 0),
                emds_version=self._emds_version_string(),
                gis_version=self._qgis_version_string(),
                created_by=pc.current_user(),
            )
        except Exception as exc:
            try:
                debug_logger.warning("Provenance capture failed (non-fatal)", {
                    "execution_id": execution_id,
                    "exception": str(exc),
                })
            except Exception:
                pass

    def _finalize_completed_task_execution(
        self,
        *,
        execution_id: str,
        result: dict,
        project: Optional[Project],
        assessment: Optional[Assessment],
        provenance: Optional[Provenance],
        workflow_step: Optional[WorkflowStep],
        results_df,
        open_viewer: bool = True,
        show_message: bool = True,
        add_map_layers: bool = True,
        run_label: str = "",
        run_notes: str = "",
        test_mode: bool = False,
    ) -> Dict[str, Any]:
        """Persist post-engine results and optionally show viewers/messages."""
        if test_mode:
            # Staging/Test run: the engine output already went to the session
            # staging DB. Do NOT record canonical provenance or write output
            # tables into the project DB — a Test must never pollute the
            # baseline. (Mirror the Scenario Builder test runner, which bypasses
            # finalize entirely.)
            return {"message": "", "persisted_result": None, "test_mode": True}
        self._record_task_provenance(
            project=project,
            assessment=assessment,
            provenance=provenance,
            workflow_step=workflow_step,
            execution_id=execution_id,
            result=result,
            run_label=run_label,
            run_notes=run_notes,
        )
        if workflow_step and workflow_step.engine_type == EngineType.NETWEAVER:
            persisted_result = self._persist_netweaver_results(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_step=workflow_step,
                execution_id=execution_id,
                results_df=results_df,
                add_map_layers=add_map_layers,
            )
            message = (
                f"NetWeaver task execution completed successfully.\n"
                f"Execution ID: {execution_id}\n"
                f"Rows processed: {result.get('rows_processed', 0)}\n"
                f"Output table: {persisted_result['output_table_name']}\n"
                f"Goal layers added: {len(persisted_result['goal_layer_names'])}"
            )
            if show_message:
                QMessageBox.information(self, "Task Complete", message)
            return {"message": message, "persisted_result": persisted_result}

        if workflow_step and workflow_step.engine_type == EngineType.CDP:
            persisted_result = self._persist_cdp_results(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_step=workflow_step,
                execution_id=execution_id,
                results_df=results_df,
                add_map_layers=add_map_layers,
            )
            if open_viewer:
                viewer = ResultsViewerDialog(
                    execution_id,
                    results_df,
                    self,
                    database_path=str(self._get_project_database_path(project) or ""),
                    table_name=str(persisted_result.get("output_table_name") or ""),
                )
                exec_dialog(viewer)
            message = (
                f"CDP task execution completed successfully.\n"
                f"Execution ID: {execution_id}\n"
                f"Rows processed: {result.get('rows_processed', 0)}\n"
                f"Output table: {persisted_result['output_table_name']}\n"
                f"Map layer: {persisted_result['layer_name']}"
            )
            if show_message:
                QMessageBox.information(self, "Task Complete", message)
            return {"message": message, "persisted_result": persisted_result}

        if workflow_step and workflow_step.engine_type == EngineType.TEMPLATE_2:
            persisted_result = self._persist_portfolio_results(
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_step=workflow_step,
                execution_id=execution_id,
                results_df=results_df,
                add_map_layers=add_map_layers,
            )
            if open_viewer:
                viewer = ResultsViewerDialog(
                    execution_id,
                    results_df,
                    self,
                    database_path=str(self._get_project_database_path(project) or ""),
                    table_name=str(persisted_result.get("output_table_name") or ""),
                )
                exec_dialog(viewer)
            report = persisted_result.get("portfolio_report") or {}
            selected_count = int(report.get("selected_count", 0) or 0)
            total_spent = float(report.get("total_spent", 0.0) or 0.0)
            total_budget = float(report.get("total_budget", 0.0) or 0.0)
            efficacy = float(report.get("efficacy", 0.0) or 0.0)
            message = (
                f"Portfolio task execution completed successfully.\n"
                f"Execution ID: {execution_id}\n"
                f"Rows processed: {result.get('rows_processed', 0)}\n"
                f"Output table: {persisted_result['output_table_name']}\n"
                f"Map layer: {persisted_result['layer_name']}\n"
                f"Selected items: {selected_count}\n"
                f"Total spent: ${total_spent:,.2f} of ${total_budget:,.2f} "
                f"(efficacy: {efficacy:,.2f})"
            )
            if show_message:
                QMessageBox.information(self, "Task Complete", message)
            return {"message": message, "persisted_result": persisted_result}

        if open_viewer:
            viewer = ResultsViewerDialog(execution_id, results_df, self)
            exec_dialog(viewer)

        message = (
            f"Task execution completed successfully.\n"
            f"Execution ID: {execution_id}\n"
            f"Rows processed: {result.get('rows_processed', 0)}"
        )
        if show_message:
            QMessageBox.information(self, "Task Complete", message)
        return {"message": message, "persisted_result": {}}

    def _on_execution_completed(self, execution_id: str, result: dict):
        """Handle execution completion."""
        self.active_execution = None
        execution_context = self.active_execution_context if (
            self.active_execution_context and self.active_execution_context.get("execution_id") == execution_id
        ) else None
        self.active_execution_context = None
        if self.selected_path:
            self._on_item_selection_changed(self.selected_path)
        if self.progress_dialog:
            self.progress_dialog.accept()
            self.progress_dialog = None

        results_df = self.executor.get_results(execution_id)
        workflow_step = None
        project = None
        assessment = None
        provenance = None
        if execution_context:
            project, assessment, provenance, workflow_step = self._resolve_selection_context(execution_context)

        try:
            self._finalize_completed_task_execution(
                execution_id=execution_id,
                result=result,
                project=project,
                assessment=assessment,
                provenance=provenance,
                workflow_step=workflow_step,
                results_df=results_df,
                open_viewer=True,
                show_message=True,
            )
            return
        except Exception as exc:
            if workflow_step and workflow_step.engine_type == EngineType.NETWEAVER:
                debug_logger.error("NetWeaver post-processing failed", {
                    "execution_id": execution_id,
                    "workflow_step_id": workflow_step.id,
                    "exception": str(exc),
                })
                QMessageBox.critical(
                    self,
                    "NetWeaver Result Persistence Failed",
                    f"NetWeaver processing finished, but the results could not be persisted to the project database:\n\n{exc}",
                )
                return

            if workflow_step and workflow_step.engine_type == EngineType.CDP:
                debug_logger.error("CDP post-processing failed", {
                    "execution_id": execution_id,
                    "workflow_step_id": workflow_step.id,
                    "exception": str(exc),
                })
                QMessageBox.critical(
                    self,
                    "CDP Result Persistence Failed",
                    f"CDP processing finished, but the results could not be persisted to the project database:\n\n{exc}",
                )
                return

            if workflow_step and workflow_step.engine_type == EngineType.TEMPLATE_2:
                debug_logger.error("Portfolio post-processing failed", {
                    "execution_id": execution_id,
                    "workflow_step_id": workflow_step.id,
                    "exception": str(exc),
                })
                QMessageBox.critical(
                    self,
                    "Portfolio Result Persistence Failed",
                    f"Portfolio processing finished, but the results could not be persisted to the project database:\n\n{exc}",
                )
                return

            viewer = ResultsViewerDialog(execution_id, results_df, self)
            exec_dialog(viewer)
            QMessageBox.information(
                self,
                "Task Complete",
                f"Task execution completed successfully.\n"
                f"Execution ID: {execution_id}\n"
                f"Rows processed: {result.get('rows_processed', 0)}"
            )
            return

    def _on_execution_failed(self, execution_id: str, error_message: str):
        """Handle execution failure."""
        self.active_execution = None
        if self.active_execution_context and self.active_execution_context.get("execution_id") == execution_id:
            self.active_execution_context = None
        if self.selected_path:
            self._on_item_selection_changed(self.selected_path)
        if self.progress_dialog:
            self.progress_dialog.reject()
            self.progress_dialog = None

        QMessageBox.critical(
            self,
            "Task Failed",
            f"Task execution failed:\n\n{error_message}"
        )

    def _collect_task_subtree_ids(self, task) -> list:
        """Return the ids of a task and all of its nested sub-tasks."""
        ids = [task.id]
        for child in getattr(task, "children", None) or []:
            ids.extend(self._collect_task_subtree_ids(child))
        return ids

    def _remove_task_recursive(self, tasks: list, target_id: str) -> list:
        """Return ``tasks`` with the task whose id == target_id removed at any depth."""
        pruned = []
        for task in tasks:
            if task.id == target_id:
                continue
            task.children = self._remove_task_recursive(
                list(getattr(task, "children", None) or []), target_id
            )
            pruned.append(task)
        return pruned

    def _delete_selected(self):
        """Delete the selected item"""
        selected_item, data = self._resolve_selected_tree_item()
        if not selected_item or not data or not self.selected_path:
            return

        reply = QMessageBox.question(
            self,
            "Confirm Delete",
            f"Delete this {self._describe_node_type(data.node_type)}?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        )

        if reply == QMessageBox.StandardButton.Yes:
            node_type = EMDSTreeModel.normalize_node_type(data.node_type)
            if node_type == "workspace":
                self.project_manager.delete_project(data.item_id)
                self.metadata_manager.delete_project(data.item_id)
            elif node_type == "study":
                project = self.project_manager.get_project(self.selected_path["project_id"])
                if project:
                    project.assessments = [a for a in project.assessments if a.id != data.item_id]
                self.metadata_manager.delete_spatial_reference(data.item_id)
            elif node_type == "analysis":
                assessment = self.project_manager.get_assessment(
                    self.selected_path["project_id"],
                    self.selected_path["assessment_id"]
                )
                if assessment:
                    assessment.provenances = [p for p in assessment.provenances if p.id != data.item_id]
                self.metadata_manager.delete_provenance(data.item_id)
            elif node_type == "task":
                # Remove the task at any depth (top-level or nested sub-task),
                # together with its whole sub-tree, from the in-memory model AND
                # the metadata DB. The old code gated in-memory removal on
                # workflow_id, which is set for every task, so the node was only
                # deleted from the DB and reappeared on the next tree refresh.
                provenance = self.project_manager.get_provenance(
                    self.selected_path["project_id"],
                    self.selected_path["assessment_id"],
                    self.selected_path["provenance_id"]
                )
                removed_ids = [data.item_id]
                if provenance is not None:
                    target = self.project_manager.find_workflow_step(
                        self.selected_path["project_id"],
                        self.selected_path["assessment_id"],
                        self.selected_path["provenance_id"],
                        data.item_id,
                    )
                    if target is not None:
                        removed_ids = self._collect_task_subtree_ids(target)
                    provenance.workflow_steps = self._remove_task_recursive(
                        list(provenance.workflow_steps or []), data.item_id
                    )
                for task_id in removed_ids:
                    self.metadata_manager.delete_task_detail(task_id)

            self._populate_tree()
            self.item_deleted.emit(node_type or data.node_type, data.item_id)
            QMessageBox.information(
                self,
                "Success",
                f"{self._describe_node_type(data.node_type).capitalize()} deleted"
            )

    def _open_netweaver_hotlink(self) -> None:
        """Open the NetWeaver-specific viewer for the selected workflow/results node."""
        self._view_results(preferred_viewer="netweaver_hotlink")

    def _open_cdp_results_viewer(self) -> None:
        """Open the CDP-specific viewer for the selected workflow/results node."""
        self._view_results(preferred_viewer="cdp_viewer")

    def _open_lpa_results_viewer(self) -> None:
        """Open the LPA-specific viewer for the selected workflow/results node."""
        self._view_results(preferred_viewer="lpa_viewer")

    def _open_portfolio_results_viewer(self) -> None:
        """Open the Portfolio-specific viewer for the selected workflow/results node."""
        self._view_results(preferred_viewer="portfolio_viewer")

    def _open_engine_specific_results_viewer(self) -> None:
        """Open the best engine-specific hotlink/results viewer for the selected task."""
        workflow_step = self._resolve_selected_workflow_step()
        if workflow_step is None:
            QMessageBox.information(
                self,
                "Open Results",
                "Select a completed task or result to open its viewer.",
            )
            return
        if workflow_step.engine_type == EngineType.NETWEAVER:
            self._view_results(preferred_viewer="netweaver_hotlink")
        elif workflow_step.engine_type == EngineType.CDP:
            self._view_results(preferred_viewer="cdp_viewer")
        elif workflow_step.engine_type == EngineType.TEMPLATE_1:
            self._view_results(preferred_viewer="lpa_viewer")
        elif workflow_step.engine_type == EngineType.TEMPLATE_2:
            self._view_results(preferred_viewer="portfolio_viewer")
        else:
            self._view_results()

    def _open_results_chart(self) -> None:
        """Open a histogram/chart for the selected task results when tabular data exists."""
        workflow_step = self._resolve_selected_workflow_step()
        if workflow_step is None:
            QMessageBox.information(
                self,
                "View Chart",
                "Select a completed task or result to chart.",
            )
            return

        try:
            if workflow_step.engine_type == EngineType.NETWEAVER:
                payload = self.netweaver_results_service.load_viewer_payload(workflow_step)
                dataframe = payload["results_df"]
                title = f"NetWeaver Chart - {workflow_step.name}"
                subtitle = f"Execution Results: {payload['execution_id']}"
            elif workflow_step.engine_type == EngineType.CDP:
                payload = self.cdp_results_service.load_viewer_payload(workflow_step)
                dataframe = payload["results_df"]
                title = f"CDP Chart - {payload.get('workflow_name', workflow_step.name)}"
                subtitle = f"Execution Results: {payload['execution_id']}"
            else:
                self._view_results()
                return

            dialog = ResultsHistogramDialog(
                dataframe,
                title=title,
                subtitle=subtitle,
                parent=self,
            )
            exec_dialog(dialog)
        except Exception as exc:
            debug_logger.warning("Could not open result chart", {
                "workflow_step_id": workflow_step.id,
                "exception": str(exc),
            })
            QMessageBox.warning(self, "View Chart", f"Could not open the chart viewer:\n{exc}")

    def _open_execution_history(self) -> None:
        """Open execution history for the selected workflow step."""
        workflow_step = self._resolve_selected_workflow_step()
        if workflow_step is None:
            QMessageBox.information(
                self,
                "Execution History",
                "Select a task or its results to review execution history.",
            )
            return

        dialog = ExecutionHistoryDialog(
            workflow_step.id,
            self.executor.results_manager,
            self,
        )
        exec_dialog(dialog)

    def _open_generic_results_viewer(
        self,
        *,
        execution_id: str,
        results_df,
        workflow_name: str = "",
    ) -> None:
        """Open the shared grid viewer using a lightweight results payload."""
        title = f"Results: {execution_id}"
        subtitle = f"Execution Results: {execution_id}"
        if workflow_name:
            subtitle = f"{workflow_name}\n{subtitle}"
        viewer = ResultsViewerDialog.from_payload(
            execution_id=execution_id,
            results_df=results_df,
            title=title,
            subtitle=subtitle,
            parent=self,
        )
        exec_dialog(viewer)

    def _show_engine_viewer_modeless(self, dialog) -> None:
        """Show an engine viewer (NetWeaver/CDP) non-modally and keep it alive.

        Returning ArcGIS users expect to keep the hotlink viewer open while
        clicking features on the QGIS canvas. exec_dialog() blocks the main
        UI; show() does not.
        """
        try:
            dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
        except Exception:
            pass
        self._open_viewers.append(dialog)
        try:
            dialog.destroyed.connect(lambda _obj=None, ref=dialog: self._open_viewers.remove(ref) if ref in self._open_viewers else None)
        except Exception:
            pass
        dialog.show()
        try:
            dialog.raise_()
            dialog.activateWindow()
        except Exception:
            pass

    def _open_cdp_viewer_dialog(self, payload: Dict[str, Any]) -> None:
        """Open the dedicated CDP viewer from its service payload contract."""
        dialog = CDPResultsDialog.from_payload(payload, parent=self)
        self._show_engine_viewer_modeless(dialog)

    def _open_lpa_viewer_dialog(self, payload: Dict[str, Any]) -> None:
        """Open the LPA viewer with whatever payload fields we have available."""
        dialog = LPAResultsDialog.from_payload(payload, parent=self)
        self._show_engine_viewer_modeless(dialog)

    def _open_portfolio_viewer_dialog(self, payload: Dict[str, Any]) -> None:
        """Open the Portfolio viewer with the result + portfolio_report metadata."""
        dialog = PortfolioResultsDialog.from_payload(payload, parent=self)
        self._show_engine_viewer_modeless(dialog)

    def _build_portfolio_viewer_payload(
        self,
        workflow_step: WorkflowStep,
        project: Optional[Project],
    ) -> Dict[str, Any]:
        """Assemble a Portfolio viewer payload from the task's parameters + metadata.

        The Portfolio engine already stashes its run report on the
        workflow_step's metadata under ``portfolio_report`` (see
        executor.py:558-569). We pair that with the latest completed
        execution's DataFrame and the persistence-layer's GeoPackage info.
        """
        parameters = dict(workflow_step.parameters or {})
        metadata = dict(workflow_step.metadata or {})

        database_path = str(parameters.get("database_path", "") or "").strip()
        if not database_path and project is not None:
            database_path = str(self._get_project_database_path(project) or "")
        output_table_name = str(parameters.get("output_table_name", "") or "").strip()

        results_df = None
        execution_id = str(metadata.get("execution_id", "") or "")
        try:
            executions = self.executor.results_manager.get_workflow_executions(workflow_step.id)
        except Exception:
            executions = []
        for execution in executions:
            try:
                df = self.executor.results_manager.get_execution_results_dataframe(
                    execution.execution_id
                )
            except Exception:
                continue
            if df is not None and not df.empty:
                results_df = df
                if not execution_id:
                    execution_id = execution.execution_id
                break

        return {
            "workflow_name": workflow_step.name or "Portfolio",
            "execution_id": execution_id,
            "results_df": results_df,
            "portfolio_report": dict(metadata.get("portfolio_report") or {}),
            "strategy": str(parameters.get("strategy", "") or ""),
            "cost_field": str(parameters.get("cost_field", "") or ""),
            "benefit_field": str(parameters.get("benefit_field", "") or ""),
            "unique_field": str(parameters.get("unique_field", "") or ""),
            "inportfolio_field": str(metadata.get("inportfolio_field", "") or "InPortfolioSort"),
            "database_path": database_path,
            "output_table_name": output_table_name,
        }

    def _build_lpa_viewer_payload(
        self,
        workflow_step: WorkflowStep,
        project: Optional[Project],
    ) -> Dict[str, Any]:
        """Assemble an LPA viewer payload from the task's parameters + persisted result.

        LPA execution isn't fully built out yet, so this gathers what
        information is reliably available: the GeoPackage path, the
        output table name (if the engine wrote one), and the most recent
        completed-execution DataFrame.
        """
        parameters = dict(workflow_step.parameters or {})
        database_path = str(parameters.get("database_path", "") or "").strip()
        if not database_path and project is not None:
            database_path = str(self._get_project_database_path(project) or "")
        output_table_name = str(parameters.get("output_table_name", "") or "").strip()

        results_df = None
        execution_id = ""
        try:
            executions = self.executor.results_manager.get_workflow_executions(workflow_step.id)
        except Exception:
            executions = []
        for execution in executions:
            try:
                df = self.executor.results_manager.get_execution_results_dataframe(
                    execution.execution_id
                )
            except Exception:
                continue
            if df is not None and not df.empty:
                results_df = df
                execution_id = execution.execution_id
                break

        return {
            "workflow_name": workflow_step.name or "LPA",
            "execution_id": execution_id,
            "results_df": results_df,
            "summary": dict(parameters.get("lpa_summary", {}) or {}),
            "database_path": database_path,
            "output_table_name": output_table_name,
            "row_key_field": str(parameters.get("primary_key", "") or "ogc_fid"),
            "display_field": str(parameters.get("display_field", "") or ""),
        }

    # ------------------------------------------------------------------
    # Compare-analyses dialog
    # ------------------------------------------------------------------
    def _load_dataframe_for_workflow_step(self, workflow_step: WorkflowStep):
        """Best-effort DataFrame load for any task with a result."""
        if workflow_step is None:
            return None
        try:
            if workflow_step.engine_type == EngineType.NETWEAVER:
                payload = self.netweaver_results_service.load_viewer_payload(workflow_step)
                return payload.get("results_df")
            if workflow_step.engine_type == EngineType.CDP:
                payload = self.cdp_results_service.load_viewer_payload(workflow_step)
                return payload.get("results_df")
        except Exception as exc:
            debug_logger.debug("compare: failed to load result frame", {
                "workflow_step_id": workflow_step.id,
                "exception": str(exc),
            })
        # Fallback: latest completed execution from the results manager directly.
        try:
            executions = self.executor.results_manager.get_workflow_executions(workflow_step.id)
        except Exception:
            executions = []
        for execution in executions:
            try:
                df = self.executor.results_manager.get_execution_results_dataframe(execution.execution_id)
            except Exception:
                continue
            if df is not None and not df.empty:
                return df
        return None

    def _gather_compare_candidates(self, primary_step: WorkflowStep) -> List[CompareCandidate]:
        """Collect every other completed result in the manager as a comparison candidate."""
        candidates: List[CompareCandidate] = []
        try:
            workspaces = self.project_manager.workspaces
        except Exception:
            workspaces = []

        for workspace in workspaces:
            for study in getattr(workspace, "studies", []):
                for analysis in getattr(study, "analyses", []):
                    for task in self._iter_tasks_recursive(getattr(analysis, "tasks", [])):
                        if task.id == primary_step.id:
                            continue
                        if task.result is None:
                            continue
                        engine_label = task.engine_type.value if hasattr(task.engine_type, "value") else str(task.engine_type)
                        candidates.append(
                            CompareCandidate(
                                label=task.name or task.id,
                                workspace_name=workspace.name,
                                study_name=study.name,
                                analysis_name=analysis.name,
                                engine=engine_label,
                                workflow_step_id=task.id,
                                load_dataframe=lambda step=task: self._load_dataframe_for_workflow_step(step),
                            )
                        )
        return candidates

    def _find_workflow_step_by_id(self, task_id: str) -> Optional[WorkflowStep]:
        """Locate a task anywhere in the loaded workspace tree."""
        if not task_id:
            return None
        for workspace in getattr(self.project_manager, "workspaces", []) or []:
            for study in getattr(workspace, "assessments", []) or []:
                for analysis in getattr(study, "analyses", []) or []:
                    for task in self._iter_tasks_recursive(getattr(analysis, "tasks", []) or []):
                        if task.id == task_id:
                            return task
        return None

    @staticmethod
    def _iter_tasks_recursive(tasks):
        for task in tasks:
            yield task
            yield from EMDSDockWidget._iter_tasks_recursive(getattr(task, "children", []) or [])

    def _open_compare_dialog(self) -> None:
        """Open the Compare Analyses dialog seeded with the currently selected result."""
        workflow_step = self._resolve_selected_workflow_step()
        if workflow_step is None:
            QMessageBox.information(
                self,
                "Compare Analyses",
                "Select a result (or a task with a completed result) before comparing.",
            )
            return

        primary_df = self._load_dataframe_for_workflow_step(workflow_step)
        if primary_df is None or primary_df.empty:
            QMessageBox.information(
                self,
                "Compare Analyses",
                f"'{workflow_step.name}' has no loadable result data yet.",
            )
            return

        candidates = self._gather_compare_candidates(workflow_step)
        engine_label = workflow_step.engine_type.value if hasattr(workflow_step.engine_type, "value") else str(workflow_step.engine_type)
        dialog = CompareResultsDialog(
            primary_label=workflow_step.name or workflow_step.id,
            primary_engine=engine_label,
            primary_dataframe=primary_df,
            candidates=candidates,
            parent=self,
            provenance_provider=self._provenance_for_workflow_step,
            primary_workflow_step_id=workflow_step.id,
            difference_report_provider=self._build_difference_report,
        )
        self._show_engine_viewer_modeless(dialog)

    def _view_results(self, preferred_viewer: str = "default"):
        """Open the most appropriate viewer for the selected workflow/results node."""
        _, data = self._resolve_selected_tree_item()
        if not data:
            return

        workflow_step = self._resolve_selected_workflow_step()

        if workflow_step and workflow_step.engine_type == EngineType.NETWEAVER:
            try:
                payload = self.netweaver_results_service.load_viewer_payload(workflow_step)
                if preferred_viewer == "netweaver_hotlink":
                    dialog = NetWeaverResultsDialog(
                        workflow_name=workflow_step.name,
                        execution_id=payload["execution_id"],
                        results_df=payload["results_df"],
                        topic_column_map=payload["topic_column_map"],
                        model_file=payload.get("model_file", ""),
                        engine_path=payload.get("engine_path", ""),
                        field_mappings=payload.get("field_mappings", {}),
                        topic_tree=payload.get("topic_tree", []),
                        workflow_step_id=payload.get("workflow_step_id", workflow_step.id),
                        output_table_name=payload.get("output_table_name", ""),
                        database_path=payload.get("database_path", ""),
                        row_key_field=payload.get("row_key_field", "ogc_fid"),
                        topic_layer_map=payload.get("topic_layer_map", {}),
                        topic_action_payloads=payload.get("topic_action_payloads", {}),
                        selected_topics=payload.get("selected_topics", []),
                        map_topics=payload.get("map_topics", []),
                        parent=self,
                    )
                    dialog.setWindowTitle(f"NetWeaver Hotlink – {workflow_step.name}")
                    self._select_netweaver_dialog_topic(dialog, payload.get("selected_topics"))
                    self._show_engine_viewer_modeless(dialog)
                else:
                    self._open_generic_results_viewer(
                        execution_id=payload["execution_id"],
                        results_df=payload["results_df"],
                        workflow_name=workflow_step.name,
                    )
                return
            except Exception as exc:
                debug_logger.warning("Could not open NetWeaver results viewer", {
                    "workflow_step_id": workflow_step.id,
                    "preferred_viewer": preferred_viewer,
                    "exception": str(exc),
                })
                QMessageBox.warning(
                    self,
                    "NetWeaver Hotlink" if preferred_viewer == "netweaver_hotlink" else "NetWeaver Results",
                    f"Could not open the NetWeaver results viewer:\n{exc}"
                )
                return

        if workflow_step and workflow_step.engine_type == EngineType.TEMPLATE_2:
            try:
                project, _assessment, _provenance, _selected = self._resolve_selection_context()
                payload = self._build_portfolio_viewer_payload(workflow_step, project)
                if preferred_viewer in ("portfolio_viewer", "default", "engine"):
                    self._open_portfolio_viewer_dialog(payload)
                else:
                    results_df = payload.get("results_df")
                    if results_df is None or results_df.empty:
                        QMessageBox.information(
                            self,
                            "Open Results",
                            f"'{workflow_step.name}' has no completed Portfolio results yet.",
                        )
                        return
                    self._open_generic_results_viewer(
                        execution_id=str(payload.get("execution_id") or ""),
                        results_df=results_df,
                        workflow_name=str(payload.get("workflow_name") or workflow_step.name),
                    )
                return
            except Exception as exc:
                debug_logger.warning("Could not open Portfolio results viewer", {
                    "workflow_step_id": workflow_step.id,
                    "exception": str(exc),
                })
                QMessageBox.warning(
                    self,
                    "Portfolio Results",
                    f"Could not open the Portfolio results viewer:\n{exc}",
                )
                return

        if workflow_step and workflow_step.engine_type == EngineType.TEMPLATE_1:
            try:
                project, _assessment, _provenance, _selected = self._resolve_selection_context()
                payload = self._build_lpa_viewer_payload(workflow_step, project)
                if preferred_viewer in ("lpa_viewer", "default", "engine"):
                    self._open_lpa_viewer_dialog(payload)
                else:
                    results_df = payload.get("results_df")
                    if results_df is None or results_df.empty:
                        QMessageBox.information(
                            self,
                            "Open Results",
                            f"'{workflow_step.name}' has no completed LPA results yet.",
                        )
                        return
                    self._open_generic_results_viewer(
                        execution_id=str(payload.get("execution_id") or ""),
                        results_df=results_df,
                        workflow_name=str(payload.get("workflow_name") or workflow_step.name),
                    )
                return
            except Exception as exc:
                debug_logger.warning("Could not open LPA results viewer", {
                    "workflow_step_id": workflow_step.id,
                    "exception": str(exc),
                })
                QMessageBox.warning(
                    self,
                    "LPA Results",
                    f"Could not open the LPA results viewer:\n{exc}",
                )
                return

        if workflow_step and workflow_step.engine_type == EngineType.CDP:
            try:
                payload = self.cdp_results_service.load_viewer_payload(workflow_step)
                if preferred_viewer == "cdp_viewer":
                    self._open_cdp_viewer_dialog(payload)
                else:
                    self._open_generic_results_viewer(
                        execution_id=payload["execution_id"],
                        results_df=payload["results_df"],
                        workflow_name=payload.get("workflow_name", workflow_step.name),
                    )
                return
            except Exception as exc:
                debug_logger.warning("Could not open CDP results viewer", {
                    "workflow_step_id": workflow_step.id,
                    "exception": str(exc),
                })
                QMessageBox.warning(
                    self,
                    "CDP Results",
                    f"Could not open the CDP results viewer:\n{exc}"
                )
                return

        if EMDSTreeModel.normalize_node_type(data.node_type) == "result":
            results: Results = data.item
            QMessageBox.information(
                self,
                "Open Results",
                f"Would open external viewer for: {results.layer_name}\n"
                f"Features: {results.feature_count}\n"
                f"Type: {results.layer_type}"
            )
            return

        QMessageBox.information(
            self,
            "Open Results",
            "No completed results are available for the selected task yet."
        )

    def _toggle_results_visibility(self):
        """Toggle visibility of results layer"""
        selected_item, data = self._resolve_selected_tree_item()
        if not selected_item or not data:
            return
        if not data or EMDSTreeModel.normalize_node_type(data.node_type) != "result":
            return

        results: Results = data.item
        results.visible = not results.visible

        # Update tree display
        selected_item.setText(0, EMDSTreeModel.format_results_label(results))

        self.results_visibility_toggled.emit(results.layer_name, results.visible)
        self._refresh_selection_panel(self.selected_path)
        QMessageBox.information(
            self,
            "Visibility Updated",
            f"Layer {results.layer_name} is now {'visible' if results.visible else 'hidden'}"
        )

    def _show_selected_results_on_map(self) -> None:
        """Reveal the selected results layer in the current map and zoom to it."""
        selected_item, data = self._resolve_selected_tree_item()
        if not selected_item or not data or EMDSTreeModel.normalize_node_type(data.node_type) != "result":
            QMessageBox.information(
                self,
                "Show On Map",
                "Select a results item to reveal it on the map.",
            )
            return

        results: Results = data.item
        layer = self._find_map_layer_for_results(results)
        if layer is None:
            QMessageBox.information(
                self,
                "Show On Map",
                f"No map layer was found for '{results.layer_name}' in the current QGIS project.",
            )
            return

        try:
            from qgis.core import QgsProject

            root = QgsProject.instance().layerTreeRoot()
            layer_node = root.findLayer(layer.id())
            if layer_node is not None:
                layer_node.setItemVisibilityChecked(True)

            self.iface.setActiveLayer(layer)
            if layer.extent().isEmpty():
                results.visible = True
                selected_item.setText(0, EMDSTreeModel.format_results_label(results))
                self.results_visibility_toggled.emit(results.layer_name, True)
                self._refresh_selection_panel(self.selected_path)
                return

            self.canvas.setExtent(layer.extent())
            self.canvas.refresh()
            results.visible = True
            selected_item.setText(0, EMDSTreeModel.format_results_label(results))
            self.results_visibility_toggled.emit(results.layer_name, True)
            self._refresh_selection_panel(self.selected_path)
        except Exception as exc:
            QMessageBox.warning(
                self,
                "Show On Map",
                f"Could not activate the results layer on the map:\n{exc}",
            )

    def _find_map_layer_for_results(self, results: Results):
        """Locate the persisted QGIS layer that corresponds to a results node."""
        try:
            from qgis.core import QgsProject

            metadata = dict(getattr(results, "metadata", {}) or {})
            candidate_names = {
                str(value).strip().lower()
                for value in (
                    results.layer_name,
                    metadata.get("table_name"),
                    metadata.get("output_table_name"),
                    metadata.get("result_table_name"),
                )
                if str(value).strip()
            }
            workflow_step_id = str(metadata.get("workflow_step_id") or "")

            for layer in QgsProject.instance().mapLayers().values():
                if workflow_step_id and str(layer.customProperty("emds/workflow_step_id", "")) == workflow_step_id:
                    return layer

                layer_names = {
                    str(layer.name()).strip().lower(),
                    str(layer.customProperty("emds/source_table_name", "")).strip().lower(),
                    str(layer.customProperty("emds/output_table_name", "")).strip().lower(),
                    str(layer.customProperty("emds/result_table_name", "")).strip().lower(),
                }
                if candidate_names.intersection(name for name in layer_names if name):
                    return layer
        except Exception as exc:
            debug_logger.warning("Could not resolve results layer on map", {"exception": str(exc)})

        return None

    def _describe_node_type(self, node_type: str) -> str:
        node_type = EMDSTreeModel.normalize_node_type(node_type) or node_type
        labels = {
            "workspace": "workspace",
            "study": "study",
            "analysis": "analysis",
            "task": "task",
            "result": "result",
        }
        return labels.get(node_type, node_type)

    def _edit_item(self, data: TreeNodeData):
        """Edit the selected tree node.

        Analysis/Task open the Pipeline Builder; Workspace/Study Area open a
        lightweight name/description/comments editor.
        """
        node_type = EMDSTreeModel.normalize_node_type(data.node_type)
        if node_type in {"analysis", "task"}:
            self._edit_selected_workflow_definition()
            return
        if node_type == "workspace":
            self._edit_workspace_details(data)
            return
        if node_type == "study":
            self._edit_study_details(data)
            return

        QMessageBox.information(
            self,
            "Edit",
            f"Edit functionality for this {self._describe_node_type(data.node_type)} is coming soon"
        )

    def _edit_workspace_details(self, data: TreeNodeData) -> None:
        """Edit a workspace's name, description, and comments."""
        project = self.project_manager.get_project(data.item_id)
        if project is None and self.selected_path:
            project = self.project_manager.get_project(self.selected_path.get("project_id", ""))
        if project is None:
            QMessageBox.information(self, tr("edit_node.title_workspace"), tr("edit_node.err_workspace_not_found"))
            return

        workspace_meta = project.metadata or {}
        result = EditNodeDialog.edit(
            tr("edit_node.title_workspace"),
            name=project.name,
            description=project.description,
            description_html=str(workspace_meta.get(SIDECAR_HTML_KEY, "")),
            comments=str(workspace_meta.get("comments", "")),
            comments_html=str(workspace_meta.get("comments_html", "")),
            parent=self,
        )
        if result is None:
            return
        name = result["name"]
        description = result["description"]
        comments = result["comments"]
        if not name:
            QMessageBox.warning(self, tr("edit_node.title_workspace"), tr("edit_node.err_name_required"))
            return

        # Canonical columns stay plain; rich bodies ride in the sidecars
        # (update_workspace merges this patch into existing metadata). The
        # QGIS edit supersedes any ArcGIS-authored RTF sidecar — merge
        # semantics can't delete keys, so blank/refresh them explicitly.
        metadata_patch = {
            "comments": comments,
            "comments_html": result["comments_html"],
            SIDECAR_HTML_KEY: result["description_html"],
            ARCGIS_RTF_KEY: "",
            ARCGIS_PLAIN_KEY: description,
        }
        project.name = name
        project.description = description
        project.metadata = {**(project.metadata or {}), **metadata_patch}
        if not self.metadata_manager.update_workspace(
            project.id, name=name, description=description, metadata=metadata_patch
        ):
            QMessageBox.warning(self, tr("edit_node.title_workspace"), tr("edit_node.err_workspace_save_failed"))
            return

        self._populate_tree()
        self._select_project_in_tree(project.id)

    def _edit_study_details(self, data: TreeNodeData) -> None:
        """Edit a study area's name, description, and comments."""
        project_id = (self.selected_path or {}).get("project_id", "")
        project = self.project_manager.get_project(project_id)
        assessment = self.project_manager.get_assessment(project_id, data.item_id)
        if project is None or assessment is None:
            QMessageBox.information(self, tr("edit_node.title_study"), tr("edit_node.err_study_not_found"))
            return

        study_meta = assessment.metadata or {}
        result = EditNodeDialog.edit(
            tr("edit_node.title_study"),
            name=assessment.name,
            description=assessment.description,
            description_html=str(study_meta.get(SIDECAR_HTML_KEY, "")),
            comments=str(study_meta.get("comments", "")),
            comments_html=str(study_meta.get("comments_html", "")),
            parent=self,
        )
        if result is None:
            return
        name = result["name"]
        description = result["description"]
        comments = result["comments"]
        if not name:
            QMessageBox.warning(self, tr("edit_node.title_study"), tr("edit_node.err_name_required"))
            return

        # The study DB row stores the model's description in its `comments`
        # column; the separate user "comments" field lives in metadata. Preserve
        # all other study metadata (kind, layer refs, output names, …).
        existing = self.metadata_manager.get_spatial_reference(assessment.id)
        merged_meta = dict(existing.metadata if existing and existing.metadata else (assessment.metadata or {}))
        merged_meta["comments"] = comments
        merged_meta["comments_html"] = result["comments_html"]
        merged_meta[SIDECAR_HTML_KEY] = result["description_html"]
        # Supersede any ArcGIS-authored rich sidecar on this study row.
        if ARCGIS_RTF_KEY in merged_meta:
            merged_meta[ARCGIS_RTF_KEY] = ""
        if ARCGIS_PLAIN_KEY in merged_meta:
            merged_meta[ARCGIS_PLAIN_KEY] = description
        output_layer_name = existing.output_layer_name if existing else str((assessment.metadata or {}).get("output_layer_name", ""))
        reference_layer_name = existing.reference_layer_name if existing else str((assessment.metadata or {}).get("reference_layer_name", ""))

        assessment.name = name
        assessment.description = description
        assessment.metadata = merged_meta
        if not self.metadata_manager.save_spatial_reference(
            spatial_reference_id=assessment.id,
            project_id=project.id,
            name=name,
            comments=description,
            output_layer_name=output_layer_name,
            reference_layer_name=reference_layer_name,
            metadata=merged_meta,
        ):
            QMessageBox.warning(self, tr("edit_node.title_study"), tr("edit_node.err_study_save_failed"))
            return

        self._populate_tree()

    def _select_project_in_tree(self, project_id: str):
        """
        Find and select a Workspace item in the tree by its ID.

        Args:
            project_id: ID of the Workspace to select
        """
        try:
            # Iterate through all top-level items (Workspaces)
            for i in range(self.tree_widget.topLevelItemCount()):
                item = self.tree_widget.topLevelItem(i)
                if item:
                    data: TreeNodeData = item.data(0, Qt.ItemDataRole.UserRole)
                    if data and EMDSTreeModel.normalize_node_type(data.node_type) == "workspace" and data.item_id == project_id:
                        # Found the Workspace, select it.
                        self.tree_widget.setCurrentItem(item)
                        item.setExpanded(True)
                        return
        except Exception as e:
            print(f"Error selecting project in tree: {e}")

    def add_demo_data(self):
        """Add demo data for testing"""
        # Create a demo project
        project = Project(
            id=str(uuid.uuid4()),
            name="Demo Project",
            description="A demonstration project"
        )

        # Add assessment
        assessment = Assessment(
            id=str(uuid.uuid4()),
            name="Forest Assessment",
            input_layers=self.available_layers[:2] if self.available_layers else [],
        )
        project.add_assessment(assessment)

        # Add provenance
        provenance = Provenance(
            id=str(uuid.uuid4()),
            name="Processing Pipeline 1",
        )
        assessment.add_provenance(provenance)

        # Add workflow step
        workflow_step = WorkflowStep(
            id=str(uuid.uuid4()),
            name="Calculate Intersection",
            engine_type=EngineType.NETWEAVER,
        )

        # Add results
        results = Results(
            id=str(uuid.uuid4()),
            layer_name="output_intersection.shp",
            feature_count=500,
            layer_type="Polygon",
        )
        workflow_step.results = results

        provenance.add_workflow_step(workflow_step)
        self.project_manager.add_project(project)

        self._populate_tree()
