"""
NetWeaver results viewer for the QGIS plugin.

Supports:
  A1 — Hierarchical topic tree via get_topic_tree bridge call.
  A2 — Qt signals for topic/row selection (non-modal safe).
  A3 — QTableView + DataFrameTableModel + ResultsFilterProxyModel.
  B1 — Single-layer renderer swap (find or create result layer; apply
        graduated NetWeaver symbology per selected topic column).
  B2 — row_key_field (ogc_fid) for map↔table feature linking.
  B3 — QgsHighlight flash when a row is selected in the table.
  B4 — Per-row tree-leaf recoloring using NETWEAVER_CLASS_BREAKS.
  C1 — Hotlink topic queue for adding multiple styled topic layers to QGIS.
  C2 — Map/table mode tabs plus CSV/XLSX/table and PNG/print map exports.
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional, Set, Tuple

import html as _py_html
import re

import pandas as pd
from .qt_compat import (
    QAbstractItemView,
    QColor,
    QComboBox,
    QDialog,
    QFileDialog,
    QFrame,
    QFont,
    QGroupBox,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QMenu,
    QMessageBox,
    QPushButton,
    QSortFilterProxyModel,  # noqa: F401 (re-exported via results_grid_dialog)
    QSplitter,
    QTableView,
    QTableWidget,
    QTableWidgetItem,
    QTabWidget,
    QTextEdit,
    QTimer,
    QToolButton,
    QTreeWidget,
    QTreeWidgetItem,
    QVBoxLayout,
    QWidget,
    Qt,
    pyqtSignal,
)

from . import debug_logger
from .histogram_widget import get_numeric_columns
from .netweaver_node_inspector_dialog import NetWeaverNodeInspectorDialog
from .netweaver_scheme import (
    NETWEAVER_CLASS_BREAKS,
    classify_netweaver_value,
    color_for_label,
    text_color_for_fill,
)
from .netweaver_node_graph_dialog import NetWeaverNodeGraphDialog
from .results_grid_dialog import ResultsFilterProxyModel
from .results_grid_model import DataFrameTableModel
from .results_histogram_dialog import ResultsHistogramDialog
from .qt_compat import exec_dialog, exec_menu
from .translator import tr

try:
    from qgis.core import (
        QgsFeatureRequest,
        QgsGraduatedSymbolRenderer,
        QgsMapLayer,
        QgsProject,
        QgsRendererRange,
        QgsSymbol,
        QgsVectorLayer,
    )
    from qgis.gui import (
        QgsHighlight,
        QgsMapCanvas,
        QgsMapToolIdentifyFeature,
        QgsMapToolPan,
    )
except Exception:
    QgsFeatureRequest = None
    QgsGraduatedSymbolRenderer = None
    QgsHighlight = None
    QgsMapCanvas = None
    QgsMapLayer = None
    QgsMapToolIdentifyFeature = None
    QgsMapToolPan = None
    QgsProject = None
    QgsRendererRange = None
    QgsSymbol = None
    QgsVectorLayer = None


# ---------------------------------------------------------------------------
# Module-level renderer helper (B1) — extracted from LayerCreator so the
# dialog can apply it without importing the full layer_creator module.
# ---------------------------------------------------------------------------

_NW_RENDERER_RANGES = NETWEAVER_CLASS_BREAKS


def _build_netweaver_renderer(layer, goal_field: str):
    """Return a QgsGraduatedSymbolRenderer for *goal_field* on *layer*, or None."""
    if (
        QgsGraduatedSymbolRenderer is None
        or QgsRendererRange is None
        or QgsSymbol is None
        or layer is None
    ):
        return None
    if goal_field not in layer.fields().names():
        return None

    renderer_ranges = []
    for lower, upper, label, color_hex in _NW_RENDERER_RANGES:
        symbol = QgsSymbol.defaultSymbol(layer.geometryType())
        if symbol is None:
            continue
        symbol.setColor(QColor(color_hex))
        renderer_ranges.append(QgsRendererRange(lower, upper, symbol, label))

    renderer = QgsGraduatedSymbolRenderer(goal_field, renderer_ranges)
    renderer.setMode(QgsGraduatedSymbolRenderer.Custom)
    return renderer


# ---------------------------------------------------------------------------
# Dialog
# ---------------------------------------------------------------------------

class NetWeaverResultsDialog(QDialog):
    """NetWeaver results browser with embedded map and renderer-swap hotlink."""

    # A2 — signals
    topic_selected = pyqtSignal(str, str)   # topic_name, column_name
    row_selected   = pyqtSignal(object)     # row Series

    def __init__(
        self,
        workflow_name: str,
        execution_id: str,
        results_df: pd.DataFrame,
        topic_column_map: Dict[str, str],
        model_file: str = "",
        # Q2 — node-network viewer
        engine_path: str = "",
        field_mappings: Optional[Dict[str, str]] = None,
        # A1
        topic_tree: Optional[List[Dict[str, Any]]] = None,
        # B1 / B2
        workflow_step_id: str = "",
        output_table_name: str = "",
        database_path: str = "",
        row_key_field: str = "ogc_fid",
        topic_layer_map: Optional[Dict[str, str]] = None,
        topic_action_payloads: Optional[Dict[str, Dict[str, Any]]] = None,
        selected_topics: Optional[List[str]] = None,
        map_topics: Optional[List[str]] = None,
        parent=None,
    ):
        super().__init__(parent)
        self.workflow_name    = workflow_name
        self.execution_id     = execution_id
        self.raw_results_df   = results_df.copy()
        self.topic_column_map = topic_column_map
        self.model_file       = model_file
        # Q2 — node-network viewer inputs + the open graph dialog (if any)
        self.engine_path      = engine_path
        self.field_mappings   = dict(field_mappings or {})
        self._graph_dialog: Optional[NetWeaverNodeGraphDialog] = None

        # A1
        self.topic_hierarchy: List[Dict[str, Any]] = topic_tree or []

        # B1 / B2
        self.workflow_step_id  = workflow_step_id
        self.output_table_name = output_table_name
        self.database_path     = database_path
        self.row_key_field     = row_key_field
        self.topic_layer_map   = topic_layer_map or {}
        self.topic_action_payloads = topic_action_payloads or {}
        # The task's selected topics — these populate the preview dropdown so the
        # embedded map can be driven by topic (not by which project layers happen
        # to be visible).
        self.selected_topics = [
            str(item).strip()
            for item in (selected_topics or [])
            if str(item).strip()
        ]
        self._result_layer     = None          # cached single result layer
        self._layer_sel_conn   = None          # connection to selectionChanged
        self._identify_tool    = None
        self._select_tool      = None
        # Q5 — embedded-map interaction mode ("select" default)
        self._map_mode         = "select"
        self._pan_tool         = None
        self._identify_layer   = None
        self._identify_dialog: Optional[QDialog] = None

        # UI state
        self.selected_topic:  Optional[str] = None
        self.selected_column: Optional[str] = None
        self.topic_items: List[QTreeWidgetItem] = []
        initial_map_topics = map_topics or []
        self.map_topic_keys: Set[str] = {
            self._topic_key_for_name(topic_name)
            for topic_name in initial_map_topics
            if str(topic_name).strip()
        }
        self.queued_topic_keys: Set[str] = set()
        self._selected_row_highlight = None

        # Map canvas references (populated in _build_map_panel)
        self.embedded_map_canvas:      Optional["QgsMapCanvas"] = None
        self.embedded_map_status:      Optional[QLabel]         = None
        self.embedded_map_placeholder: Optional[QLabel]         = None
        self.embedded_map_layers:      List["QgsMapLayer"]      = []
        self.preview_layer_combo:      Optional[QComboBox]      = None
        self._updating_preview_layers = False

        # A3 — table model
        display_df = results_df.fillna("")
        self._df_model    = DataFrameTableModel(display_df, self)
        self._proxy_model = ResultsFilterProxyModel(self)
        self._proxy_model.setSourceModel(self._df_model)

        self.setWindowTitle(tr("netweaver_results.window_title", name=workflow_name))
        self.resize(1200, 780)

        self._init_ui()
        self._populate_topics()

        # Default to the first selected topic so the dropdown and the embedded
        # map agree on open (instead of showing whatever project layers exist).
        self._refresh_preview_layer_options()
        if (
            self.preview_layer_combo is not None
            and self.preview_layer_combo.count() > 0
            and not self.selected_topic
        ):
            first_topic = self.preview_layer_combo.itemData(0)
            if first_topic:
                self._select_topic_by_name(str(first_topic))

    # -----------------------------------------------------------------------
    # UI construction
    # -----------------------------------------------------------------------

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

        # Header
        header = QLabel(tr(
            "netweaver_results.header",
            name=self.workflow_name,
            exec_id=self.execution_id,
            rows=len(self.raw_results_df.index),
        ))
        header.setWordWrap(True)
        main_layout.addWidget(header)

        if self.model_file:
            model_label = QLabel(tr("netweaver_results.model_label", path=self.model_file))
            model_label.setWordWrap(True)
            main_layout.addWidget(model_label)

        action_row = QHBoxLayout()
        main_layout.addLayout(action_row)

        self.btn_add_topics_to_map = QPushButton(tr("netweaver_results.btn_add_to_map"))
        self.btn_add_topics_to_map.clicked.connect(self._add_included_topics_to_map)
        action_row.addWidget(self.btn_add_topics_to_map)

        # Single Export menu mirroring the old WPF "Actions" ribbon group.
        self.export_button = QToolButton()
        self.export_button.setText(tr("netweaver_results.btn_export"))
        self.export_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        export_menu = QMenu(self.export_button)
        export_menu.addAction(tr("netweaver_results.menu_print_map"), self._print_map)
        export_menu.addAction(tr("netweaver_results.menu_print_table"), self._print_table)
        export_menu.addAction(tr("netweaver_results.menu_export_png"), self._export_map_png)
        export_menu.addSeparator()
        export_menu.addAction(tr("netweaver_results.menu_export_csv"), self._export_table_csv)
        export_menu.addAction(tr("netweaver_results.menu_export_xlsx"), self._export_table_xlsx)
        self.export_button.setMenu(export_menu)
        action_row.addWidget(self.export_button)

        action_row.addStretch()

        # H-splitter: left = topics+detail | right = map (top) + table (bottom)
        splitter = QSplitter(Qt.Orientation.Horizontal)
        main_layout.addWidget(splitter, 1)

        # ---- Left panel ----
        left_host   = QWidget(self)
        left_panel  = QVBoxLayout()
        left_host.setLayout(left_panel)
        splitter.addWidget(left_host)

        left_panel.addWidget(QLabel(tr("netweaver_results.lbl_topics")))
        topic_search_row = QHBoxLayout()
        self.topic_search = QLineEdit()
        self.topic_search.setPlaceholderText(tr("netweaver_results.topic_search_placeholder"))
        self.topic_search.textChanged.connect(self._filter_topics)
        topic_search_row.addWidget(self.topic_search, 1)

        self.btn_prev_topic = QPushButton(tr("netweaver_results.btn_prev"))
        self.btn_prev_topic.clicked.connect(lambda: self._select_relative_topic(-1))
        topic_search_row.addWidget(self.btn_prev_topic)

        self.btn_next_topic = QPushButton(tr("netweaver_results.btn_next"))
        self.btn_next_topic.clicked.connect(lambda: self._select_relative_topic(1))
        topic_search_row.addWidget(self.btn_next_topic)
        left_panel.addLayout(topic_search_row)

        self.topic_tree = QTreeWidget()
        self.topic_tree.setColumnCount(2)
        self.topic_tree.setHeaderLabels([
            tr("netweaver_results.col_topic"),
            tr("netweaver_results.col_value"),
        ])
        self.topic_tree.header().setStretchLastSection(False)
        self.topic_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        self.topic_tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        self.topic_tree.itemSelectionChanged.connect(self._on_topic_changed)
        self.topic_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.topic_tree.customContextMenuRequested.connect(self._show_topic_context_menu)
        self.topic_tree.itemDoubleClicked.connect(lambda *_: self._open_node_inspector())
        left_panel.addWidget(self.topic_tree, 1)

        self.topic_details = QTextEdit()
        self.topic_details.setReadOnly(True)
        self.topic_details.setMinimumHeight(100)
        left_panel.addWidget(self.topic_details)

        topic_action_row = QHBoxLayout()
        self.btn_show_on_map = QPushButton(tr("netweaver_results.btn_show_on_map"))
        self.btn_show_on_map.clicked.connect(self._show_selected_topic_on_map)
        topic_action_row.addWidget(self.btn_show_on_map)

        self.btn_node_inspector = QPushButton(tr("netweaver_results.btn_node_inspector"))
        self.btn_node_inspector.clicked.connect(self._open_node_inspector)
        topic_action_row.addWidget(self.btn_node_inspector)

        self.btn_model_graph = QPushButton(tr("netweaver_results.btn_model_graph"))
        self.btn_model_graph.clicked.connect(self._open_model_graph)
        topic_action_row.addWidget(self.btn_model_graph)
        left_panel.addLayout(topic_action_row)

        # ---- Right panel (V-splitter: map on top, table+row-details below) ----
        right_host   = QWidget(self)
        right_layout = QVBoxLayout()
        right_layout.setContentsMargins(0, 0, 0, 0)
        right_host.setLayout(right_layout)
        splitter.addWidget(right_host)

        right_splitter = QSplitter(Qt.Orientation.Vertical)
        right_layout.addWidget(right_splitter, 1)

        # Map pane
        map_host = QWidget(self)
        map_tab_layout = QVBoxLayout()
        map_tab_layout.setContentsMargins(0, 0, 0, 0)
        map_host.setLayout(map_tab_layout)

        map_tab_layout.addWidget(QLabel(tr("netweaver_results.lbl_map_preview")))
        self.map_panel = self._build_map_panel()
        map_tab_layout.addWidget(self.map_panel, 1)

        self.status_label = QLabel(tr("netweaver_results.status_select_topic"))
        self.status_label.setWordWrap(True)
        map_tab_layout.addWidget(self.status_label)
        right_splitter.addWidget(map_host)

        # Table pane (table + row inspection)
        table_host = QWidget(self)
        table_tab_layout = QVBoxLayout()
        table_tab_layout.setContentsMargins(0, 0, 0, 0)
        table_host.setLayout(table_tab_layout)

        # A3 — QTableView
        self.results_table = QTableView()
        self.results_table.setModel(self._proxy_model)
        self.results_table.setSortingEnabled(True)
        self.results_table.setAlternatingRowColors(True)
        self.results_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.results_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.results_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.results_table.selectionModel().selectionChanged.connect(self._on_row_changed)
        hdr = self.results_table.horizontalHeader()
        hdr.setStretchLastSection(True)
        hdr.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
        table_tab_layout.addWidget(self.results_table, 1)

        row_action_row = QHBoxLayout()
        self.btn_show_all_rows = QPushButton(tr("netweaver_results.btn_show_all_rows"))
        self.btn_show_all_rows.clicked.connect(self._clear_map_row_filter)
        row_action_row.addWidget(self.btn_show_all_rows)
        row_action_row.addStretch()
        table_tab_layout.addLayout(row_action_row)

        table_tab_layout.addWidget(QLabel(tr("netweaver_results.lbl_selected_row")))
        self.row_details = QTextEdit()
        self.row_details.setReadOnly(True)
        self.row_details.setMinimumHeight(120)
        table_tab_layout.addWidget(self.row_details)

        right_splitter.addWidget(table_host)
        right_splitter.setStretchFactor(0, 1)
        right_splitter.setStretchFactor(1, 1)
        right_splitter.setSizes([360, 360])

        # Match the old WPF 300-pixel fixed tree — pin a sensible left width
        # and prevent the topic panel from collapsing entirely.
        splitter.setStretchFactor(0, 0)
        splitter.setStretchFactor(1, 1)
        splitter.setCollapsible(0, False)
        splitter.setSizes([320, 880])

        # Always-visible legend (matches the old LegendDisplayPanel docked at
        # the bottom of the WPF window).
        legend_group = QGroupBox(tr("netweaver_results.group_legend"))
        legend_outer = QHBoxLayout()
        legend_outer.setContentsMargins(8, 4, 8, 4)
        legend_outer.addLayout(self._build_legend_layout())
        legend_outer.addStretch()
        legend_group.setLayout(legend_outer)
        main_layout.addWidget(legend_group)

        # Button bar
        button_row = QHBoxLayout()
        main_layout.addLayout(button_row)
        button_row.addStretch()

        self.btn_histogram = QPushButton(tr("netweaver_results.btn_histogram"))
        self.btn_histogram.clicked.connect(self._open_histogram)
        button_row.addWidget(self.btn_histogram)

        btn_close = QPushButton(tr("netweaver_results.btn_close"))
        btn_close.clicked.connect(self.close)
             # Initial map sync (no topic selected yet → shows project context)
        self._refresh_embedded_map()

    def _apply_button_icon(self, button: QPushButton, icon_name: str, fallback_text: str) -> None:
        try:
            from qgis.core import QgsApplication
            if QgsApplication is not None:
                icon = QgsApplication.getThemeIcon(icon_name)
                if icon and not icon.isNull():
                    button.setIcon(icon)
                    button.setText("")
                    button.setFixedWidth(34)
                    return
        except Exception:
            pass
        button.setText(fallback_text)

    def _build_map_panel(self) -> QWidget:
        """Create the embedded map area (canvas or placeholder)."""
        host = QFrame(self)
        host.setFrameShape(QFrame.Shape.StyledPanel)
        layout = QVBoxLayout()
        host.setLayout(layout)

        action_row = QHBoxLayout()
        action_row.addWidget(QLabel(tr("netweaver_results.lbl_preview_topic")))
        self.preview_layer_combo = QComboBox()
        self.preview_layer_combo.currentIndexChanged.connect(self._on_preview_layer_changed)
        action_row.addWidget(self.preview_layer_combo, 1)

        self.btn_map_refresh = QPushButton(tr("netweaver_results.btn_refresh_preview"))
        self.btn_map_refresh.clicked.connect(self._refresh_embedded_map)
        action_row.addWidget(self.btn_map_refresh)

        self.btn_map_focus = QPushButton(tr("netweaver_results.btn_fit_preview"))
        self.btn_map_focus.clicked.connect(self._zoom_embedded_map_to_topic)
        action_row.addWidget(self.btn_map_focus)
        action_row.addStretch()
        layout.addLayout(action_row)

        # Q5 — map interaction toolbar: exclusive Select/Identify/Pan mode toggles
        # + icon-styled map controls
        tools_row = QHBoxLayout()
        self.btn_map_select = QPushButton(tr("netweaver_results.btn_map_select"))
        self.btn_map_select.setCheckable(True)
        self.btn_map_select.setAutoExclusive(True)
        self.btn_map_select.setChecked(True)
        self.btn_map_select.setToolTip(tr("netweaver_results.tip_map_select"))
        self.btn_map_select.toggled.connect(
            lambda checked: self._set_map_mode("select") if checked else None
        )
        tools_row.addWidget(self.btn_map_select)

        self.btn_map_identify = QPushButton(tr("netweaver_results.btn_map_identify"))
        self.btn_map_identify.setCheckable(True)
        self.btn_map_identify.setAutoExclusive(True)
        self.btn_map_identify.setToolTip(tr("netweaver_results.tip_map_identify"))
        self.btn_map_identify.toggled.connect(
            lambda checked: self._set_map_mode("identify") if checked else None
        )
        tools_row.addWidget(self.btn_map_identify)

        tools_row.addSpacing(16)

        self.btn_map_zoom_in = QPushButton("+")
        self.btn_map_zoom_in.setFixedWidth(34)
        self.btn_map_zoom_in.setToolTip(tr("netweaver_results.tip_map_zoom_in"))
        self.btn_map_zoom_in.clicked.connect(self._zoom_embedded_map_in)
        tools_row.addWidget(self.btn_map_zoom_in)

        self.btn_map_zoom_out = QPushButton("−")
        self.btn_map_zoom_out.setFixedWidth(34)
        self.btn_map_zoom_out.setToolTip(tr("netweaver_results.tip_map_zoom_out"))
        self.btn_map_zoom_out.clicked.connect(self._zoom_embedded_map_out)
        tools_row.addWidget(self.btn_map_zoom_out)

        self.btn_map_zoom_selection = QPushButton()
        self._apply_button_icon(
            self.btn_map_zoom_selection,
            "mActionZoomToSelected.svg",
            tr("netweaver_results.btn_map_zoom_selection"),
        )
        self.btn_map_zoom_selection.setToolTip(
            tr("netweaver_results.tip_map_zoom_selection")
        )
        self.btn_map_zoom_selection.clicked.connect(self._zoom_embedded_map_to_selection)
        tools_row.addWidget(self.btn_map_zoom_selection)

        self.btn_map_pan = QPushButton()
        self.btn_map_pan.setCheckable(True)
        self.btn_map_pan.setAutoExclusive(True)
        self._apply_button_icon(
            self.btn_map_pan,
            "mActionPan.svg",
            tr("netweaver_results.btn_map_pan"),
        )
        self.btn_map_pan.setToolTip(tr("netweaver_results.tip_map_pan"))
        self.btn_map_pan.toggled.connect(
            lambda checked: self._set_map_mode("pan") if checked else None
        )
        tools_row.addWidget(self.btn_map_pan)

        self.btn_map_full_extent = QPushButton()
        self._apply_button_icon(
            self.btn_map_full_extent,
            "mActionZoomFullExtent.svg",
            tr("netweaver_results.btn_map_full_extent"),
        )
        self.btn_map_full_extent.setToolTip(tr("netweaver_results.tip_map_full_extent"))
        self.btn_map_full_extent.clicked.connect(self._zoom_embedded_map_full_extent)
        tools_row.addWidget(self.btn_map_full_extent)
        tools_row.addStretch()
        layout.addLayout(tools_row)

        self.embedded_map_status = QLabel(tr("netweaver_results.map_initializing"))
        self.embedded_map_status.setWordWrap(True)
        layout.addWidget(self.embedded_map_status)

        if QgsMapCanvas is None:
            self.embedded_map_placeholder = QLabel(tr("netweaver_results.map_unavailable"))
            self.embedded_map_placeholder.setWordWrap(True)
            self.embedded_map_placeholder.setMinimumHeight(220)
            layout.addWidget(self.embedded_map_placeholder)
            return host

        self.embedded_map_canvas = QgsMapCanvas(host)
        self.embedded_map_canvas.setCanvasColor(Qt.GlobalColor.white)
        self.embedded_map_canvas.setMinimumHeight(240)
        layout.addWidget(self.embedded_map_canvas)
        return host

    def _build_legend_layout(self):
        """Horizontal class-break legend strip (always visible at the dialog bottom)."""
        legend_layout = QHBoxLayout()
        legend_layout.setSpacing(12)
        for lower, upper, label, color_hex in NETWEAVER_CLASS_BREAKS:
            cell = QHBoxLayout()
            cell.setSpacing(4)
            swatch = QFrame()
            swatch.setFrameShape(QFrame.Shape.StyledPanel)
            swatch.setFixedSize(14, 14)
            swatch.setStyleSheet(
                f"background-color: {QColor(color_hex).name()}; border: 1px solid #666;"
            )
            cell.addWidget(swatch)
            bracket = "]" if upper == 1.0 else ")"
            text = f"{label} [{lower:g}, {upper:g}{bracket}"
            cell.addWidget(QLabel(text))
            legend_layout.addLayout(cell)
        return legend_layout

    # -----------------------------------------------------------------------
    # A1 — hierarchical topic tree population
    # -----------------------------------------------------------------------

    def _populate_topics(self) -> None:
        """Populate the topic tree widget from hierarchy or flat map."""
        self.topic_tree.clear()
        self.topic_items.clear()

        if self.topic_hierarchy:
            self._populate_topics_hierarchical()
        else:
            self._populate_topics_flat()

        self._refresh_map_topic_keys_from_project()
        self.topic_tree.expandAll()
        self._refresh_hotlink_action_state()

        if self.topic_items:
            self.topic_tree.setCurrentItem(self.topic_items[0])

    def _populate_topics_flat(self) -> None:
        """Flat (legacy) topic list under a single 'Topics (N)' root."""
        root = QTreeWidgetItem([f"Topics ({len(self.topic_column_map)})", ""])
        self.topic_tree.addTopLevelItem(root)
        for topic_name, column_name in self.topic_column_map.items():
            item = QTreeWidgetItem([topic_name, ""])
            item.setData(0, Qt.ItemDataRole.UserRole, column_name)
            item.setData(0, Qt.ItemDataRole.UserRole + 1, topic_name.lower())
            item.setData(0, Qt.ItemDataRole.UserRole + 2, topic_name)
            self._refresh_topic_inclusion_style(item)
            root.addChild(item)
            self.topic_items.append(item)
        root.setExpanded(True)

    def _populate_topics_hierarchical(self) -> None:
        """Walk the ID/ParentID hierarchy returned by get_topic_tree."""
        items_by_id: Dict[int, QTreeWidgetItem] = {}
        pending_children: Dict[int, List[QTreeWidgetItem]] = {}

        for node in self.topic_hierarchy:
            name = str(node.get("Name", "")).strip()
            if not name:
                continue

            node_id   = int(node.get("ID", 0) or 0)
            parent_id = int(node.get("ParentID", 0) or 0)
            is_root   = bool(node.get("IsRoot", False))

            column_name = self._match_node_to_column(name)

            item = QTreeWidgetItem([name, ""])
            item.setData(0, Qt.ItemDataRole.UserRole,     column_name)
            item.setData(0, Qt.ItemDataRole.UserRole + 1, name.lower())
            item.setData(0, Qt.ItemDataRole.UserRole + 2, name)
            self._refresh_topic_inclusion_style(item)
            items_by_id[node_id] = item

            if column_name:
                self.topic_items.append(item)

            parent_item = items_by_id.get(parent_id)
            if parent_id and parent_item is not None:
                parent_item.addChild(item)
            elif is_root or not parent_id:
                self.topic_tree.addTopLevelItem(item)
                item.setExpanded(True)
            else:
                pending_children.setdefault(parent_id, []).append(item)

            for waiting_child in pending_children.pop(node_id, []):
                item.addChild(waiting_child)

        # Attach any orphans (parent arrived after child — shouldn't happen normally)
        for orphan_list in pending_children.values():
            for orphan in orphan_list:
                self.topic_tree.addTopLevelItem(orphan)

    def _match_node_to_column(self, name: str) -> str:
        """Fuzzy-match a hierarchy node name to a topic_column_map entry."""
        if name in self.topic_column_map:
            return self.topic_column_map[name]
        norm = self._normalize_name(name)
        for topic_name, col in self.topic_column_map.items():
            if self._normalize_name(topic_name) == norm:
                return col
        return ""

    @staticmethod
    def _normalize_name(name: str) -> str:
        n = str(name).strip().lower().replace(" ", "_").replace("-", "_")
        while "__" in n:
            n = n.replace("__", "_")
        return n

    def _topic_key_for_name(self, name: str) -> str:
        return self._normalize_name(name)

    # -----------------------------------------------------------------------
    # Topic filtering / navigation
    # -----------------------------------------------------------------------

    def _visible_topic_items(self) -> List[QTreeWidgetItem]:
        return [item for item in self.topic_items if not item.isHidden()]

    def _filter_topics(self, text: str) -> None:
        filter_text = text.strip().lower()
        visible_count = 0
        for item in self.topic_items:
            is_visible = not filter_text or filter_text in item.text(0).lower()
            item.setHidden(not is_visible)
            if is_visible:
                visible_count += 1

        # Update root label if in flat mode
        root = self.topic_tree.topLevelItem(0)
        if root is not None and root.data(0, Qt.ItemDataRole.UserRole) is None:
            root.setText(0, f"Topics ({visible_count}/{len(self.topic_items)})")
            root.setExpanded(True)

        visible_items = self._visible_topic_items()
        current = self.topic_tree.currentItem()
        if visible_items and (current is None or current.isHidden()):
            self.topic_tree.setCurrentItem(visible_items[0])

    def _select_relative_topic(self, offset: int) -> None:
        visible_items = self._visible_topic_items()
        if not visible_items:
            return
        current = self.topic_tree.currentItem()
        if current not in visible_items:
            self.topic_tree.setCurrentItem(visible_items[0])
            return
        idx  = visible_items.index(current)
        next_idx = (idx + offset) % len(visible_items)
        self.topic_tree.setCurrentItem(visible_items[next_idx])

    def _topic_key_for_item(self, item: Optional[QTreeWidgetItem]) -> str:
        if item is None:
            return ""
        topic_name = item.data(0, Qt.ItemDataRole.UserRole + 2) or item.text(0)
        return self._topic_key_for_name(str(topic_name))

    def _select_topic_by_key(self, topic_key: str) -> bool:
        for item in self.topic_items:
            if self._topic_key_for_item(item) == topic_key:
                self.topic_tree.setCurrentItem(item)
                self.topic_tree.scrollToItem(item)
                return True
        return False

    def _refresh_topic_inclusion_style(self, item: QTreeWidgetItem) -> None:
        """Mark topics already on-map or queued without changing their names."""
        font = QFont(item.font(0))
        topic_key = self._topic_key_for_item(item)
        queued = topic_key in self.queued_topic_keys
        on_map = topic_key in self.map_topic_keys
        font.setBold(queued or on_map)
        font.setItalic(on_map and not queued)
        item.setFont(0, font)
        if queued:
            item.setToolTip(0, f"{item.text(0)}\nQueued to add to the main map.")
        elif on_map:
            item.setToolTip(0, f"{item.text(0)}\nAlready on the main map.")
        else:
            item.setToolTip(0, item.text(0))

    def _toggle_current_topic_inclusion(self) -> None:
        item = self.topic_tree.currentItem()
        if item is None or not item.data(0, Qt.ItemDataRole.UserRole):
            return
        topic_key = self._topic_key_for_item(item)
        if topic_key in self.queued_topic_keys:
            self.queued_topic_keys.remove(topic_key)
        else:
            self.queued_topic_keys.add(topic_key)
        self._refresh_topic_inclusion_style(item)
        self._recolor_topic_tree_for_row(self._current_source_row())
        self._refresh_topic_action_state()

    def _refresh_hotlink_action_state(self) -> None:
        self._refresh_topic_action_state()

    def _refresh_topic_action_state(self) -> None:
        count = len(self.queued_topic_keys)
        if count:
            self.btn_add_topics_to_map.setText(f"Add Queued Topics to Map ({count})")
        else:
            self.btn_add_topics_to_map.setText(tr("netweaver_results.btn_add_to_map"))

    def _queued_topic_keys_in_tree_order(self) -> List[str]:
        ordered = []
        for item in self.topic_items:
            topic_key = self._topic_key_for_item(item)
            if topic_key in self.queued_topic_keys:
                ordered.append(topic_key)
        return ordered

    # -----------------------------------------------------------------------
    # Row helpers (A3 — proxy-aware)
    # -----------------------------------------------------------------------

    def _current_source_row(self) -> int:
        """Return the DataFrame row index for the currently selected proxy row."""
        proxy_idx = self.results_table.currentIndex()
        if not proxy_idx.isValid():
            return -1
        src_idx = self._proxy_model.mapToSource(proxy_idx)
        return src_idx.row() if src_idx.isValid() else -1

    def _selected_row_info(self) -> Tuple[Optional[int], Optional[object], object]:
        src_row = self._current_source_row()
        if src_row < 0 or not self.selected_column:
            return None, None, None
        row_index  = self.raw_results_df.index[src_row]
        row_series = self.raw_results_df.iloc[src_row]
        return src_row, row_index, row_series.get(self.selected_column, None)

    # -----------------------------------------------------------------------
    # Topic changed handler
    # -----------------------------------------------------------------------

    def _on_topic_changed(self) -> None:
        items = self.topic_tree.selectedItems()
        if not items:
            return

        item = items[0]
        column_name = item.data(0, Qt.ItemDataRole.UserRole)
        if not column_name:
            self.selected_topic = item.text(0)
            self.selected_column = None
            self.topic_details.setPlainText("Select a topic with a result column to inspect values.")
            self.status_label.setText(tr("netweaver_results.status_select_topic_result"))
            return

        self.selected_topic  = item.text(0)
        self.selected_column = str(column_name)

        self.topic_details.setPlainText("\n".join(self._build_topic_summary_lines()))
        self.status_label.setText(
            f"Inspecting topic '{self.selected_topic}' via column '{self.selected_column}'."
        )

        debug_logger.debug("NetWeaver topic selected", {
            "execution_id":   self.execution_id,
            "topic_name":     self.selected_topic,
            "column_name":    self.selected_column,
        })

        # A2 signal
        self.topic_selected.emit(self.selected_topic, self.selected_column)

        # B1 — apply renderer to single result layer
        self._apply_topic_to_result_layer()

        # Refresh embedded map to show the restylyed layer
        self._refresh_embedded_map()
        self._refresh_row_details()
        # Keep the preview dropdown in sync when the topic tree drives selection.
        self._sync_preview_combo_to_selected_topic()

    # -----------------------------------------------------------------------
    # Row changed handler
    # -----------------------------------------------------------------------

    def _on_row_changed(self, *_args) -> None:
        self._refresh_row_details()
        self._sync_embedded_map_to_row()
        # B4 — recolor tree leaves for this row
        self._recolor_topic_tree_for_row(self._current_source_row())
        # Q2 — restyle the open node-network viewer for the new feature
        if self._graph_dialog is not None:
            try:
                self._graph_dialog.update_row(
                    self._row_values_for_graph(), self._feature_label_for_row()
                )
            except RuntimeError:
                self._graph_dialog = None  # dialog already deleted by Qt
        # A2 signal
        src_row = self._current_source_row()
        if src_row >= 0:
            self.row_selected.emit(self.raw_results_df.iloc[src_row])

    def _refresh_row_details(self) -> None:
        src_row = self._current_source_row()
        if src_row < 0:
            self.row_details.clear()
            return

        row_series = self.raw_results_df.iloc[src_row]
        lines = [f"Row Index: {self.raw_results_df.index[src_row]}"]

        ordered_columns: List[str] = []
        for candidate in (self.row_key_field, "ogc_fid", "fid", "FID"):
            if candidate and candidate in self.raw_results_df.columns and candidate not in ordered_columns:
                ordered_columns.append(candidate)

        netweaver_columns = [
            str(column_name)
            for column_name in self.raw_results_df.columns
            if str(column_name).lower().startswith("nw")
        ]
        for column_name in netweaver_columns:
            if column_name not in ordered_columns:
                ordered_columns.append(column_name)

        for column_name in self.raw_results_df.columns:
            if column_name not in ordered_columns:
                ordered_columns.append(str(column_name))

        preview_columns = ordered_columns[:20]
        for col in preview_columns:
            v = row_series.get(col, "")
            lines.append(f"{col}: {'' if pd.isna(v) else v}")

        remaining = len(ordered_columns) - len(preview_columns)
        if remaining > 0:
            lines.extend(["", f"… {remaining} more columns not shown"])

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

    def _build_topic_summary_lines(self) -> List[str]:
        lines = [
            f"Topic: {self.selected_topic}",
            f"Result Column: {self.selected_column}",
        ]
        if not self.selected_column or self.selected_column not in self.raw_results_df.columns:
            return lines

        numeric_series = pd.to_numeric(self.raw_results_df[self.selected_column], errors="coerce")
        valid_values = numeric_series.dropna()
        lines.append(f"Rows with values: {int(valid_values.shape[0])}")

        if not valid_values.empty:
            lines.append(f"Minimum: {valid_values.min():.4f}")
            lines.append(f"Maximum: {valid_values.max():.4f}")
            lines.append("")
            lines.append("Class counts:")
            for lower, upper, label, _color in NETWEAVER_CLASS_BREAKS:
                if upper == 1.0:
                    in_range = (valid_values >= lower) & (valid_values <= upper)
                else:
                    in_range = (valid_values >= lower) & (valid_values < upper)
                lines.append(f"  {label}: {int(in_range.sum())}")

        src_row, row_index, row_value = self._selected_row_info()
        if src_row is not None:
            lines.append("")
            lines.append(f"Selected row: {row_index}")
            if row_value not in (None, ""):
                class_label = classify_netweaver_value(row_value) or "Unclassified"
                lines.append(f"Selected row value: {row_value} ({class_label})")

        return lines

    # -----------------------------------------------------------------------
    # B4 — per-row tree leaf recoloring
    # -----------------------------------------------------------------------

    def _recolor_topic_tree_for_row(self, src_row: int) -> None:
        """Color tree leaf items based on their value in the selected row."""
        if src_row < 0:
            for item in self.topic_items:
                self._reset_topic_item_style(item)
            return
        row_series = self.raw_results_df.iloc[src_row]
        for item in self.topic_items:
            column_name = item.data(0, Qt.ItemDataRole.UserRole)
            if not column_name:
                continue
            value    = row_series.get(column_name, None)
            class_label = classify_netweaver_value(value) or ""
            hex_color = color_for_label(class_label)
            if hex_color:
                item.setBackground(0, QColor(hex_color))
                item.setBackground(1, QColor(hex_color))
                topic_key = self._topic_key_for_item(item)
                if topic_key in self.queued_topic_keys:
                    suffix = "\nQueued to add to the main map."
                elif topic_key in self.map_topic_keys:
                    suffix = "\nAlready on the main map."
                else:
                    suffix = ""
                item.setToolTip(0, f"{item.text(0)}\nValue: {value}\nClass: {class_label}{suffix}")
                # Text color follows the fill's luminance — black on the pale
                # bins, white on the saturated blue/red ends of the ramp.
                text_hex = text_color_for_fill(hex_color)
                item.setForeground(0, QColor(text_hex))
                item.setForeground(1, QColor(text_hex))
                try:
                    fnum = float(value)
                    item.setText(1, f"{fnum:.3f}")
                except (TypeError, ValueError):
                    item.setText(1, "" if value is None else str(value))
            else:
                self._reset_topic_item_style(item)

    def _reset_topic_item_style(self, item: QTreeWidgetItem) -> None:
        item.setBackground(0, QColor("#f3f4f6"))
        item.setBackground(1, QColor("#f3f4f6"))
        item.setForeground(0, QColor("#333333"))
        item.setForeground(1, QColor("#333333"))
        item.setText(1, "")
        item.setToolTip(0, item.text(0))
        self._refresh_topic_inclusion_style(item)

    # -----------------------------------------------------------------------
    # B1 — single result layer + renderer swap
    # -----------------------------------------------------------------------

    def _find_result_layer(self):
        """
        Return the single NetWeaver result vector layer for this workflow step.

        Search order:
          1. Cached reference (_result_layer).
          2. Project layers tagged with emds/layer_role='netweaver_result'
             and emds/workflow_step_id matching self.workflow_step_id.
          3. Create from GeoPackage if database_path and output_table_name
             are available.
        """
        if QgsProject is None or QgsVectorLayer is None:
            return None

        # 1. Cache
        if self._result_layer is not None:
            try:
                if self._result_layer.isValid():
                    return self._result_layer
            except Exception:
                pass
            self._result_layer = None

        # 2. Search project
        exact_table_matches = []
        step_matches = []
        for layer in QgsProject.instance().mapLayers().values():
            if not isinstance(layer, QgsVectorLayer):
                continue
            role    = str(layer.customProperty("emds/layer_role", ""))
            step_id = str(layer.customProperty("emds/workflow_step_id", ""))
            source_table_name = str(layer.customProperty("emds/source_table_name", ""))
            if role != "netweaver_result":
                continue
            if self.output_table_name and source_table_name == self.output_table_name:
                exact_table_matches.append(layer)
                continue
            if not self.workflow_step_id or step_id == self.workflow_step_id:
                step_matches.append(layer)

        for match_list in (exact_table_matches, step_matches):
            if match_list:
                self._result_layer = match_list[0]
                self._connect_layer_selection(self._result_layer)
                return self._result_layer

        # 3. Create from GeoPackage
        if self.database_path and self.output_table_name:
            try:
                uri   = f"{self.database_path}|layername={self.output_table_name}"
                layer = QgsVectorLayer(uri, self.workflow_name, "ogr")
                if layer.isValid():
                    layer.setCustomProperty("emds/layer_role",         "netweaver_result")
                    layer.setCustomProperty("emds/workflow_step_id",   self.workflow_step_id)
                    layer.setCustomProperty("emds/source_table_name",  self.output_table_name)
                    QgsProject.instance().addMapLayer(layer, True)
                    self._result_layer = layer
                    self._connect_layer_selection(layer)
                    debug_logger.debug("Created NetWeaver result layer from GeoPackage", {
                        "table": self.output_table_name,
                        "database": self.database_path,
                    })
                    return layer
            except Exception as exc:
                debug_logger.warning("Could not create NetWeaver result layer", {"exception": str(exc)})

        return None

    def _apply_topic_to_result_layer(self):
        """
        Apply the selected topic's NetWeaver renderer to the result layer.
        Returns the layer on success, None otherwise.
        """
        if not self.selected_column:
            return None

        layer = self._find_result_layer()
        if layer is None:
            return None

        renderer = _build_netweaver_renderer(layer, self.selected_column)
        if renderer is not None:
            layer.setRenderer(renderer)
            layer.setCustomProperty("emds/goal_field", self.selected_column)
            layer.triggerRepaint()
            iface = self._resolve_iface()
            if iface is not None:
                try:
                    iface.layerTreeView().refreshLayerSymbology(layer.id())
                except Exception:
                    pass

        return layer

    # -----------------------------------------------------------------------
    # B2 — map → table feature linking
    # -----------------------------------------------------------------------

    def _connect_layer_selection(self, layer) -> None:
        """Wire the result layer's selectionChanged to scroll the table."""
        try:
            if self._layer_sel_conn is not None and self._layer_sel_conn != layer:
                try:
                    self._layer_sel_conn.selectionChanged.disconnect(self._on_map_selection_changed)
                except Exception:
                    pass
            layer.selectionChanged.connect(self._on_map_selection_changed)
            self._layer_sel_conn = layer
        except Exception:
            pass

    def _on_map_selection_changed(self, selected_ids, _deselected_ids, _clear) -> None:
        """Scroll and filter the results table to the feature selected on the map."""
        if not selected_ids:
            self._clear_map_row_filter(clear_layer_selection=False)
            return
        src_row = self._find_source_row_for_selected_feature(selected_ids)
        if src_row is None or src_row < 0:
            return

        self._proxy_model.set_source_rows_filter({src_row})
        src_idx   = self._df_model.index(src_row, 0)
        proxy_idx = self._proxy_model.mapFromSource(src_idx)
        if proxy_idx.isValid():
            self.results_table.setCurrentIndex(proxy_idx)
            self.results_table.scrollTo(proxy_idx)
        self.status_label.setText(
            f"Inspecting topic '{self.selected_topic}' for the selected map feature."
        )

    def _find_source_row_for_selected_feature(self, selected_ids) -> Optional[int]:
        layer = self._find_result_layer()
        if layer is None:
            return None

        fid = next(iter(selected_ids))
        feature = None
        try:
            request = QgsFeatureRequest().setFilterFid(fid) if QgsFeatureRequest is not None else None
            if request is not None:
                feature = next(layer.getFeatures(request), None)
        except Exception:
            feature = None

        key_value = fid
        if feature is not None and self.row_key_field and self.row_key_field in layer.fields().names():
            try:
                key_value = feature.attribute(self.row_key_field)
            except Exception:
                key_value = fid

        if self.row_key_field and self.row_key_field in self.raw_results_df.columns:
            series = self.raw_results_df[self.row_key_field]
            matches = self.raw_results_df.index[
                series.astype(str) == str(key_value)
            ]
            if len(matches) > 0:
                return self.raw_results_df.index.get_loc(matches[0])

        try:
            return int(fid)
        except Exception:
            return None

    def _clear_map_row_filter(self, clear_layer_selection: bool = True) -> None:
        self._proxy_model.clear_source_rows_filter()
        if clear_layer_selection:
            layer = self._find_result_layer()
            if layer is not None:
                try:
                    layer.removeSelection()
                except Exception:
                    pass
        self.status_label.setText(
            f"Inspecting topic '{self.selected_topic}' via column '{self.selected_column}'."
            if self.selected_topic and self.selected_column
            else "Select a topic to inspect its output column."
        )

    # -----------------------------------------------------------------------
    # Map canvas sync
    # -----------------------------------------------------------------------

    def _resolve_iface(self):
        """Best-effort access to the live QGIS interface."""
        iface = getattr(self.parent(), "iface", None)
        if iface is not None:
            return iface
        try:
            from qgis.utils import iface as global_iface
            return global_iface
        except Exception:
            return None

    def _get_default_embedded_layers(self):
        """Return a reasonable fallback layer stack for the embedded map."""
        iface = self._resolve_iface()
        if iface is not None:
            try:
                canvas_layers = [l for l in iface.mapCanvas().layers() if l is not None]
                if canvas_layers:
                    return canvas_layers
            except Exception:
                pass
        if QgsProject is None:
            return []
        return [l for l in QgsProject.instance().mapLayers().values() if l is not None][:8]

    @staticmethod
    def _layer_identity(layer) -> str:
        if layer is None:
            return ""
        try:
            return str(layer.id())
        except Exception:
            return str(id(layer))

    def _refresh_preview_layer_options(self) -> None:
        """Populate the preview dropdown with the task's selected topics.

        The dropdown drives the embedded map by TOPIC: choosing a topic
        symbolizes the result layer for that topic. (It is NOT a picker of
        whichever project layers happen to be visible.) Only topics that resolve
        to a result column are listed, since those are the renderable ones.
        """
        if self.preview_layer_combo is None:
            return

        topics = [t for t in self.selected_topics if t in self.topic_column_map]
        if not topics:
            topics = list(self.topic_column_map.keys())

        current = self.preview_layer_combo.currentData()
        self._updating_preview_layers = True
        try:
            self.preview_layer_combo.clear()
            for topic_name in topics:
                self.preview_layer_combo.addItem(topic_name, topic_name)
            self.preview_layer_combo.setEnabled(self.preview_layer_combo.count() > 0)
            target = self.selected_topic or current
            if target:
                idx = self.preview_layer_combo.findData(target)
                if idx >= 0:
                    self.preview_layer_combo.setCurrentIndex(idx)
        finally:
            self._updating_preview_layers = False

    def _sync_preview_combo_to_selected_topic(self) -> None:
        """Mirror the topic-tree selection into the preview dropdown."""
        if self.preview_layer_combo is None or not self.selected_topic:
            return
        idx = self.preview_layer_combo.findData(self.selected_topic)
        if idx < 0:
            return
        self._updating_preview_layers = True
        try:
            self.preview_layer_combo.setCurrentIndex(idx)
        finally:
            self._updating_preview_layers = False

    def _select_topic_by_name(self, topic_name: str) -> None:
        """Drive topic selection from the preview dropdown (mirrors the tree)."""
        key = self._topic_key_for_name(topic_name)
        target = None
        for item in self.topic_items:
            if self._topic_key_for_item(item) != key:
                continue
            if item.data(0, Qt.ItemDataRole.UserRole):  # leaf carrying a result column
                target = item
                break
            if target is None:
                target = item
        if target is not None:
            self.topic_tree.setCurrentItem(target)  # fires _on_topic_changed
            return
        # Fallback: no matching tree item — apply the topic directly.
        self.selected_topic = topic_name
        self.selected_column = self.topic_column_map.get(topic_name)
        self._apply_topic_to_result_layer()
        self._refresh_embedded_map()

    def _selected_preview_layer(self):
        """The layer shown in the embedded preview = the symbolized result layer."""
        if self._result_layer is not None:
            return self._result_layer
        return self.embedded_map_layers[0] if self.embedded_map_layers else None

    def _on_preview_layer_changed(self, *_args) -> None:
        if self._updating_preview_layers:
            return
        if self.preview_layer_combo is None:
            return
        topic_name = self.preview_layer_combo.currentData()
        if topic_name:
            self._select_topic_by_name(str(topic_name))

    def _refresh_embedded_map(self) -> None:
        """
        B1: Sync the embedded canvas to the selected topic.
        Finds the single result layer, swaps its renderer, and displays it.
        Falls back to project context layers when no result layer exists yet.
        """
        if self.embedded_map_status is None:
            return

        # Try to apply renderer + get layer
        result_layer = self._apply_topic_to_result_layer()

        if result_layer is not None:
            layers = [result_layer] + self._get_default_embedded_layers()
            # Deduplicate while preserving order
            seen = set()
            layers = [l for l in layers if not (id(l) in seen or seen.add(id(l)))]
        else:
            layers = self._get_default_embedded_layers()

        self.embedded_map_layers = list(layers)
        self._refresh_preview_layer_options()

        if self.embedded_map_canvas is None:
            topic_text = f"topic '{self.selected_topic}'" if self.selected_topic else "selected topic"
            if result_layer:
                self.embedded_map_status.setText(
                    f"Map preview ready for {topic_text} — canvas unavailable in this environment."
                )
            else:
                self.embedded_map_status.setText(
                    "Map preview hook is ready, but no embedded canvas is available."
                )
            return

        if not layers:
            self.embedded_map_canvas.setLayers([])
            self.embedded_map_status.setText(tr("netweaver_results.map_no_project_layers"))
            self.embedded_map_canvas.refresh()
            return

        preview_layer = result_layer or self._selected_preview_layer() or layers[0]
        self.embedded_map_canvas.setLayers([preview_layer])
        # Q5 — re-install the ACTIVE tool after every layer/renderer swap:
        # the identify tool binds to a specific layer object, so a swap
        # would otherwise leave it identifying against a stale layer.
        self._install_embedded_map_tool(preview_layer)

        focus_layer = preview_layer
        try:
            self.embedded_map_canvas.setDestinationCrs(focus_layer.crs())
        except Exception:
            pass

        extent = focus_layer.extent()
        if extent and not extent.isNull() and not extent.isEmpty():
            extent.scale(1.05)
            self.embedded_map_canvas.setExtent(extent)

        if self.selected_topic and result_layer and preview_layer == result_layer:
            self.embedded_map_status.setText(
                f"Symbolized by topic '{self.selected_topic}' "
                f"(column '{self.selected_column}')."
            )
        elif layers:
            try:
                layer_name = preview_layer.name()
            except Exception:
                layer_name = "selected layer"
            self.embedded_map_status.setText(
                f"Previewing '{layer_name}'. Select a topic to apply NetWeaver symbology."
            )

        self.embedded_map_canvas.refresh()
        self._sync_embedded_map_to_row()

    # -----------------------------------------------------------------------
    # Q5 — embedded-map interaction modes + zoom actions
    # -----------------------------------------------------------------------

    def _set_map_mode(self, mode: str) -> None:
        if mode == self._map_mode:
            return
        self._map_mode = mode
        self._install_embedded_map_tool()

    def _sync_map_mode_buttons(self) -> None:
        """Make the toggles reflect the mode that is ACTUALLY active."""
        for button, mode in (
            (self.btn_map_select, "select"),
            (self.btn_map_identify, "identify"),
            (self.btn_map_pan, "pan"),
        ):
            button.blockSignals(True)
            button.setChecked(self._map_mode == mode)
            button.blockSignals(False)

    @staticmethod
    def _is_identifiable_layer(layer) -> bool:
        """QgsMapToolIdentifyFeature needs a VECTOR layer."""
        if layer is None:
            return False
        if QgsVectorLayer is not None:
            return isinstance(layer, QgsVectorLayer)
        return hasattr(layer, "getFeatures")

    def _install_embedded_map_tool(self, layer=None) -> None:
        """(Re)install the map tool for the current mode. Called on mode
        change AND after every layer/renderer swap (`layer` is the fresh
        preview layer then; otherwise the last-known one is reused). When
        identify mode is requested but impossible (no vector layer), the
        mode falls back to pan and the toggles are re-synced so the toolbar
        never disagrees with the active tool."""
        canvas = self.embedded_map_canvas
        if canvas is None:
            return
        if layer is not None:
            self._identify_layer = layer
        if self._map_mode == "select":
            try:
                if (
                    QgsMapToolIdentifyFeature is not None
                    and self._is_identifiable_layer(self._identify_layer)
                ):
                    if self._select_tool is not None:
                        try:
                            self._select_tool.featureIdentified.disconnect(
                                self._on_embedded_feature_selected
                            )
                        except Exception:
                            pass
                    self._select_tool = QgsMapToolIdentifyFeature(
                        canvas, self._identify_layer
                    )
                    self._select_tool.featureIdentified.connect(
                        self._on_embedded_feature_selected
                    )
                    canvas.setMapTool(self._select_tool)
                    self._sync_map_mode_buttons()
                    return
            except Exception as exc:
                debug_logger.debug(
                    "Could not install embedded select tool",
                    {"exception": str(exc)},
                )
            self._map_mode = "pan"
        if self._map_mode == "identify":
            try:
                if (
                    QgsMapToolIdentifyFeature is not None
                    and self._is_identifiable_layer(self._identify_layer)
                ):
                    if self._identify_tool is not None:
                        try:
                            self._identify_tool.featureIdentified.disconnect(
                                self._on_embedded_feature_identified
                            )
                        except Exception:
                            pass
                    self._identify_tool = QgsMapToolIdentifyFeature(
                        canvas, self._identify_layer
                    )
                    self._identify_tool.featureIdentified.connect(
                        self._on_embedded_feature_identified
                    )
                    canvas.setMapTool(self._identify_tool)
                    self._sync_map_mode_buttons()
                    return
            except Exception as exc:
                debug_logger.debug(
                    "Could not install embedded identify tool",
                    {"exception": str(exc)},
                )
            # Identify unavailable — fall back to pan, visibly.
            self._map_mode = "pan"
        try:
            if QgsMapToolPan is not None:
                if self._pan_tool is None:
                    self._pan_tool = QgsMapToolPan(canvas)
                canvas.setMapTool(self._pan_tool)
        except Exception as exc:
            debug_logger.debug(
                "Could not install embedded pan tool", {"exception": str(exc)}
            )
        self._sync_map_mode_buttons()

    def _zoom_embedded_map_in(self) -> None:
        if self.embedded_map_canvas is not None:
            self.embedded_map_canvas.zoomIn()

    def _zoom_embedded_map_out(self) -> None:
        if self.embedded_map_canvas is not None:
            self.embedded_map_canvas.zoomOut()

    def _zoom_embedded_map_to_selection(self) -> None:
        if self.embedded_map_canvas is None:
            return
        layer = self._find_result_layer() or self._selected_preview_layer()
        # Non-vector preview layers have no selection — same message as none.
        if not self._is_identifiable_layer(layer) or layer.selectedFeatureCount() == 0:
            QMessageBox.information(
                self,
                tr("netweaver_results.msg_map_preview_title"),
                tr("netweaver_results.msg_no_selection_to_zoom"),
            )
            return
        try:
            self.embedded_map_canvas.zoomToSelected(layer)
            self.embedded_map_canvas.refresh()
        except Exception as exc:
            debug_logger.debug("Zoom to selection failed", {"exception": str(exc)})

    def _zoom_embedded_map_full_extent(self) -> None:
        if self.embedded_map_canvas is None:
            return
        try:
            self.embedded_map_canvas.zoomToFullExtent()
            self.embedded_map_canvas.refresh()
        except Exception as exc:
            debug_logger.debug("Zoom to full extent failed", {"exception": str(exc)})

    def _on_embedded_feature_selected(self, feature) -> None:
        # Feature ids and row keys only mean anything on the RESULT layer;
        # selecting on some other preview layer must not select/match rows
        # in the results table by a coincidentally-equal id.
        result_layer = self._find_result_layer()
        from_result_layer = (
            result_layer is not None and self._identify_layer is result_layer
        )
        if from_result_layer and feature is not None:
            try:
                result_layer.selectByIds([feature.id()])
            except Exception as exc:
                debug_logger.debug(
                    "Embedded select selection failed", {"exception": str(exc)}
                )

    def _on_embedded_feature_identified(self, feature) -> None:
        # Feature ids and row keys only mean anything on the RESULT layer;
        # identifying on some other preview layer must not select/match rows
        # in the results table by a coincidentally-equal id.
        result_layer = self._find_result_layer()
        from_result_layer = (
            result_layer is not None and self._identify_layer is result_layer
        )
        if from_result_layer and feature is not None:
            try:
                result_layer.selectByIds([feature.id()])
            except Exception as exc:
                debug_logger.debug(
                    "Embedded identify selection failed", {"exception": str(exc)}
                )
        # Q5 — one-row Field/Value pivot (ArcGIS DisplayOneRow parity).
        self._show_identify_dialog(feature, match_results_row=from_result_layer)

    # Geometry payloads never belong in the identify pivot.
    _GEOMETRY_COLUMNS = {"wkt", "geometry", "geom", "wkb_geometry", "the_geom"}
    # A real WKT literal is the keyword followed by an optional Z/M suffix and
    # then '(' or EMPTY — a bare keyword prefix is NOT enough ("Point Reyes
    # Station" is a legitimate attribute value, not a geometry).
    _WKT_VALUE_RE = re.compile(
        r"^\s*(?:MULTI)?(?:POINT|LINESTRING|POLYGON|CURVE|SURFACE)"
        r"|^\s*GEOMETRYCOLLECTION",
        re.IGNORECASE,
    )
    _WKT_PAYLOAD_RE = re.compile(
        r"^\s*[A-Z]+\s*(?:Z|M|ZM)?\s*(?:\(|EMPTY\b)", re.IGNORECASE
    )

    @classmethod
    def _is_geometry_field(cls, name: str, value: Any) -> bool:
        if str(name).strip().lower() in cls._GEOMETRY_COLUMNS:
            return True
        if isinstance(value, str):
            head = value[:64]
            if cls._WKT_VALUE_RE.match(head) and cls._WKT_PAYLOAD_RE.match(head):
                return True
        return False

    def _identify_fields_for_feature(
        self, feature, match_results_row: bool
    ) -> Tuple[str, List[Tuple[str, Any]]]:
        """(feature label, [(field, value), ...]) for the identify pivot.
        When the feature came from the RESULT layer, prefer the results
        DataFrame row (it carries the nw_ topic scores); otherwise show the
        raw feature attributes — matching another layer's ids against the
        results table would pair the wrong row."""
        if (
            match_results_row
            and self.row_key_field
            and self.row_key_field in self.raw_results_df.columns
        ):
            try:
                key_value = feature.attribute(self.row_key_field)
            except Exception:
                key_value = None
            if key_value is not None:
                key_column = self.raw_results_df[self.row_key_field]
                matches = self.raw_results_df[key_column == key_value]
                if matches.empty:
                    # Dtype-tolerant retry (int64 column vs str attribute…).
                    matches = self.raw_results_df[
                        key_column.astype(str) == str(key_value)
                    ]
                if not matches.empty:
                    row = matches.iloc[0]
                    fields = [
                        (str(column), row.get(column))
                        for column in self.raw_results_df.columns
                        if not self._is_geometry_field(column, row.get(column))
                    ]
                    return str(key_value), fields
        try:
            fields = [
                (str(name), value)
                for name, value in zip(feature.fields().names(), feature.attributes())
                if not self._is_geometry_field(name, value)
            ]
            return str(feature.id()), fields
        except Exception:
            return "", []

    def _show_identify_dialog(self, feature, match_results_row: bool = True) -> None:
        if feature is None:
            return
        label, fields = self._identify_fields_for_feature(feature, match_results_row)
        if not fields:
            return
        # One identify window at a time — a new click replaces the old one.
        if self._identify_dialog is not None:
            try:
                self._identify_dialog.close()
            except RuntimeError:
                pass
            self._identify_dialog = None

        dialog = QDialog(self)
        dialog.setWindowTitle(tr("netweaver_results.identify_title", fid=label))
        dialog.resize(420, 480)
        dialog_layout = QVBoxLayout()
        dialog.setLayout(dialog_layout)

        table = QTableWidget(len(fields), 2)
        table.setHorizontalHeaderLabels([
            tr("netweaver_results.identify_col_field"),
            tr("netweaver_results.identify_col_value"),
        ])
        table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        table.verticalHeader().setVisible(False)
        header = table.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        for row_index, (name, value) in enumerate(fields):
            table.setItem(row_index, 0, QTableWidgetItem(str(name)))
            if value is None or (isinstance(value, float) and value != value):
                text = ""  # None and NaN render blank, not "nan"
            else:
                text = str(value)
            table.setItem(row_index, 1, QTableWidgetItem(text))
        dialog_layout.addWidget(table, 1)

        button_row = QHBoxLayout()
        button_row.addStretch()
        close_button = QPushButton(tr("netweaver_results.identify_btn_close"))
        close_button.clicked.connect(dialog.close)
        button_row.addWidget(close_button)
        dialog_layout.addLayout(button_row)

        try:
            dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
        except Exception:
            pass
        self._identify_dialog = dialog
        # Identity-guarded: the OLD dialog's deferred destroyed signal fires
        # AFTER the new dialog is stored here — it must not null the new ref.
        dialog.destroyed.connect(
            lambda *_, d=dialog: self._on_identify_dialog_destroyed(d)
        )
        dialog.show()
        dialog.raise_()
        dialog.activateWindow()

    def _on_identify_dialog_destroyed(self, dialog) -> None:
        if self._identify_dialog is dialog:
            self._identify_dialog = None

    def _zoom_embedded_map_to_topic(self) -> None:
        if self.embedded_map_canvas is None:
            return
        layer = self._selected_preview_layer()
        if layer is None:
            QMessageBox.information(self, tr("netweaver_results.msg_map_preview_title"), tr("netweaver_results.msg_no_layers_to_zoom"))
            return
        extent = layer.extent()
        if extent is None or extent.isNull() or extent.isEmpty():
            QMessageBox.information(self, tr("netweaver_results.msg_map_preview_title"), tr("netweaver_results.msg_no_extent"))
            return
        extent.scale(1.05)
        self.embedded_map_canvas.setExtent(extent)
        self.embedded_map_canvas.refresh()

    def _sync_embedded_map_to_row(self) -> None:
        """B3: Flash the map feature and update status when a row is selected."""
        if self.embedded_map_status is None:
            return
        src_row = self._current_source_row()
        if src_row < 0:
            return
        row_index = self.raw_results_df.index[src_row]
        prefix = (
            f"Topic '{self.selected_topic}' map" if self.selected_topic else "Map"
        )
        self.embedded_map_status.setText(f"{prefix} → row {row_index}.")

        # B3 — QgsHighlight flash
        self._flash_row_feature(src_row)

    def _flash_row_feature(self, src_row: int) -> None:
        """Flash the map feature corresponding to src_row in the embedded canvas."""
        if (
            QgsHighlight is None
            or QgsFeatureRequest is None
            or self.embedded_map_canvas is None
            or not self.row_key_field
        ):
            return
        if self.row_key_field not in self.raw_results_df.columns:
            return

        layer = self._find_result_layer()
        if layer is None:
            return

        try:
            key_value = self.raw_results_df.iloc[src_row][self.row_key_field]
            if isinstance(key_value, str):
                escaped_value = key_value.replace("'", "''")
                expression = f'"{self.row_key_field}" = \'{escaped_value}\''
            else:
                expression = f'"{self.row_key_field}" = {key_value}'
            request   = QgsFeatureRequest().setFilterExpression(
                expression
            )
            features = list(layer.getFeatures(request))
            if not features:
                return

            if self._selected_row_highlight is not None:
                try:
                    self._selected_row_highlight.hide()
                    self._selected_row_highlight.deleteLater()
                except Exception:
                    pass

            highlight = QgsHighlight(self.embedded_map_canvas, features[0], layer)
            highlight.setColor(QColor("#ffff00"))
            highlight.setWidth(3)
            highlight.show()
            self._selected_row_highlight = highlight

            # Auto-dismiss after 800 ms
            QTimer.singleShot(800, highlight.hide)
            QTimer.singleShot(1200, highlight.deleteLater)
        except Exception as exc:
            debug_logger.debug("QgsHighlight flash failed", {"exception": str(exc)})

    # -----------------------------------------------------------------------
    # B1 — "Show On Map" button: apply renderer + focus main QGIS canvas
    # -----------------------------------------------------------------------

    def _show_selected_topic_on_map(self) -> None:
        """
        Apply the selected topic's renderer to the result layer and bring it
        into focus on the main QGIS map canvas.
        """
        if not self.selected_topic or not self.selected_column:
            QMessageBox.information(self, tr("netweaver_results.msg_show_on_map_title"), tr("netweaver_results.msg_select_topic_first"))
            return

        layer = self._apply_topic_to_result_layer()

        if layer is None:
            QMessageBox.information(
                self,
                "Show On Map",
                f"No result layer found for topic '{self.selected_topic}'.\n\n"
                "Save the workflow results to the QGIS project first, or ensure "
                "the GeoPackage path is set on the workflow step.",
            )
            return

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

            iface = self._resolve_iface()
            if iface is not None:
                iface.setActiveLayer(layer)
                extent = layer.extent()
                if extent and not extent.isNull() and not extent.isEmpty():
                    iface.mapCanvas().setExtent(extent)
                iface.mapCanvas().refresh()
        except Exception as exc:
            QMessageBox.warning(
                self,
                "Show On Map",
                f"Layer found but could not focus in QGIS:\n{exc}",
            )

    def _topic_name_for_key(self, topic_key: str) -> str:
        for item in self.topic_items:
            if self._topic_key_for_item(item) == topic_key:
                return str(item.data(0, Qt.ItemDataRole.UserRole + 2) or item.text(0))
        return topic_key

    def _topic_column_for_key(self, topic_key: str) -> str:
        for item in self.topic_items:
            if self._topic_key_for_item(item) == topic_key:
                return str(item.data(0, Qt.ItemDataRole.UserRole) or "")
        for topic_name, column_name in self.topic_column_map.items():
            if self._normalize_name(topic_name) == topic_key:
                return column_name
        return ""

    def _find_existing_topic_layer(self, topic_name: str, column_name: str):
        if QgsProject is None or QgsVectorLayer is None:
            return None

        preferred_name = (
            self.topic_layer_map.get(topic_name)
            or f"{self.workflow_name} - {topic_name}"
        )
        normalized_topic = self._normalize_name(topic_name)
        for layer in QgsProject.instance().mapLayers().values():
            if not isinstance(layer, QgsVectorLayer):
                continue
            role = str(layer.customProperty("emds/layer_role", ""))
            layer_topic = str(layer.customProperty("emds/topic_name", ""))
            layer_field = str(layer.customProperty("emds/goal_field", ""))
            layer_step = str(layer.customProperty("emds/workflow_step_id", ""))
            if layer.name() == preferred_name:
                return layer
            if (
                role in {"netweaver_topic_layer", "netweaver_result"}
                and self._normalize_name(layer_topic) == normalized_topic
                and (not column_name or layer_field == column_name)
                and (not self.workflow_step_id or layer_step == self.workflow_step_id)
            ):
                return layer
        return None

    def _refresh_map_topic_keys_from_project(self) -> None:
        if QgsProject is None or QgsVectorLayer is None:
            return
        for topic_name, column_name in self.topic_column_map.items():
            if self._find_existing_topic_layer(topic_name, column_name) is not None:
                self.map_topic_keys.add(self._topic_key_for_name(topic_name))
        for item in self.topic_items:
            self._refresh_topic_inclusion_style(item)

    def _create_or_find_topic_layer(self, topic_name: str, column_name: str):
        layer = self._find_existing_topic_layer(topic_name, column_name)
        if layer is not None:
            renderer = _build_netweaver_renderer(layer, column_name)
            if renderer is not None:
                layer.setRenderer(renderer)
                layer.triggerRepaint()
            return layer

        if QgsProject is None or QgsVectorLayer is None:
            return None

        source_uri = ""
        provider = "ogr"
        if self.database_path and self.output_table_name:
            source_uri = f"{self.database_path}|layername={self.output_table_name}"
        else:
            result_layer = self._find_result_layer()
            if result_layer is not None:
                try:
                    source_uri = result_layer.source()
                    provider = result_layer.providerType()
                except Exception:
                    source_uri = ""

        if not source_uri:
            return None

        layer_name = self.topic_layer_map.get(topic_name) or f"{self.workflow_name} - {topic_name}"
        try:
            layer = QgsVectorLayer(source_uri, layer_name, provider)
            if not layer.isValid():
                return None

            layer.setCustomProperty("emds/layer_role", "netweaver_topic_layer")
            layer.setCustomProperty("emds/workflow_step_id", self.workflow_step_id)
            layer.setCustomProperty("emds/source_table_name", self.output_table_name)
            layer.setCustomProperty("emds/topic_name", topic_name)
            layer.setCustomProperty("emds/goal_field", column_name)

            renderer = _build_netweaver_renderer(layer, column_name)
            if renderer is not None:
                layer.setRenderer(renderer)

            QgsProject.instance().addMapLayer(layer, True)
            return layer
        except Exception as exc:
            debug_logger.warning("Could not create NetWeaver topic layer", {
                "topic": topic_name,
                "column": column_name,
                "exception": str(exc),
            })
            return None

    def _add_included_topics_to_map(self) -> None:
        topic_keys = self._queued_topic_keys_in_tree_order()
        if not topic_keys and self.selected_topic:
            topic_keys = [self._topic_key_for_name(self.selected_topic)]

        if not topic_keys:
            QMessageBox.information(self, tr("netweaver_results.msg_add_topics_title"), tr("netweaver_results.msg_queue_topics_first"))
            return

        added_layers = []
        missing_topics = []
        added_keys = []
        for topic_key in topic_keys:
            topic_name = self._topic_name_for_key(topic_key)
            column_name = self._topic_column_for_key(topic_key)
            if not column_name:
                missing_topics.append(topic_name)
                continue
            layer = self._create_or_find_topic_layer(topic_name, column_name)
            if layer is None:
                missing_topics.append(topic_name)
                continue

            try:
                root = QgsProject.instance().layerTreeRoot()
                tree_layer = root.findLayer(layer.id())
                if tree_layer is not None:
                    tree_layer.setItemVisibilityChecked(True)
            except Exception:
                pass

            added_layers.append(layer)
            added_keys.append(topic_key)

        iface = self._resolve_iface()
        if iface is not None and added_layers:
            try:
                iface.setActiveLayer(added_layers[0])
                extent = added_layers[0].extent()
                if extent and not extent.isNull() and not extent.isEmpty():
                    iface.mapCanvas().setExtent(extent)
                iface.mapCanvas().refresh()
            except Exception:
                pass

        message = tr("netweaver_results.msg_add_topics_summary", count=len(added_layers))
        if missing_topics:
            message += tr("netweaver_results.msg_could_not_add", topics=", ".join(missing_topics))
        QMessageBox.information(self, tr("netweaver_results.msg_add_topics_title"), message)

        for topic_key in added_keys:
            self.map_topic_keys.add(topic_key)
            self.queued_topic_keys.discard(topic_key)
        for item in self.topic_items:
            self._refresh_topic_inclusion_style(item)
        self._refresh_topic_action_state()
        self._refresh_preview_layer_options()

    def _export_table_csv(self) -> None:
        if self.raw_results_df.empty:
            QMessageBox.information(self, tr("netweaver_results.msg_export_csv_title"), tr("netweaver_results.msg_no_data"))
            return
        file_path, _ = QFileDialog.getSaveFileName(
            self,
            tr("netweaver_results.file_dialog_csv_title"),
            "",
            tr("netweaver_results.file_dialog_csv_filter"),
        )
        if not file_path:
            return
        if not file_path.lower().endswith(".csv"):
            file_path += ".csv"
        try:
            self.raw_results_df.to_csv(file_path, index=False)
            QMessageBox.information(
                self,
                tr("netweaver_results.msg_export_csv_title"),
                tr("netweaver_results.msg_export_csv_success", path=file_path),
            )
        except Exception as exc:
            QMessageBox.critical(
                self,
                tr("netweaver_results.msg_export_csv_title"),
                tr("netweaver_results.msg_export_csv_failed", error=exc),
            )

    def _export_table_xlsx(self) -> None:
        if self.raw_results_df.empty:
            QMessageBox.information(self, tr("netweaver_results.msg_export_excel_title"), tr("netweaver_results.msg_no_data"))
            return
        file_path, _ = QFileDialog.getSaveFileName(
            self,
            tr("netweaver_results.file_dialog_xlsx_title"),
            "",
            tr("netweaver_results.file_dialog_xlsx_filter"),
        )
        if not file_path:
            return
        if not file_path.lower().endswith(".xlsx"):
            file_path += ".xlsx"
        try:
            self.raw_results_df.to_excel(file_path, index=False)
            QMessageBox.information(
                self,
                tr("netweaver_results.msg_export_excel_title"),
                tr("netweaver_results.msg_export_excel_success", path=file_path),
            )
        except Exception as exc:
            QMessageBox.critical(
                self,
                tr("netweaver_results.msg_export_excel_title"),
                tr("netweaver_results.msg_export_excel_failed", error=exc),
            )

    def _export_map_png(self) -> None:
        canvas = self.embedded_map_canvas
        if canvas is None:
            iface = self._resolve_iface()
            canvas = iface.mapCanvas() if iface is not None else None
        if canvas is None:
            QMessageBox.information(self, tr("netweaver_results.msg_export_map_title"), tr("netweaver_results.msg_no_map_canvas_export"))
            return

        file_path, _ = QFileDialog.getSaveFileName(
            self,
            tr("netweaver_results.file_dialog_png_title"),
            "",
            tr("netweaver_results.file_dialog_png_filter"),
        )
        if not file_path:
            return
        if not file_path.lower().endswith(".png"):
            file_path += ".png"

        try:
            if hasattr(canvas, "saveAsImage"):
                canvas.saveAsImage(file_path)
            else:
                canvas.grab().save(file_path, "PNG")
            QMessageBox.information(
                self,
                tr("netweaver_results.msg_export_map_title"),
                tr("netweaver_results.msg_export_map_success", path=file_path),
            )
        except Exception as exc:
            QMessageBox.critical(
                self,
                tr("netweaver_results.msg_export_map_title"),
                tr("netweaver_results.msg_export_map_failed", error=exc),
            )

    def _print_map(self) -> None:
        canvas = self.embedded_map_canvas
        if canvas is None:
            iface = self._resolve_iface()
            canvas = iface.mapCanvas() if iface is not None else None
        if canvas is None:
            QMessageBox.information(self, tr("netweaver_results.msg_print_map_title"), tr("netweaver_results.msg_no_map_canvas_print"))
            return

        try:
            try:
                from qgis.PyQt.QtGui import QPainter
                from qgis.PyQt.QtPrintSupport import QPrintDialog, QPrinter
            except ImportError:
                from PyQt6.QtGui import QPainter
                from PyQt6.QtPrintSupport import QPrintDialog, QPrinter

            printer = QPrinter(QPrinter.PrinterMode.HighResolution)

            dialog = QPrintDialog(printer, self)
            result = dialog.exec()
            if result != QDialog.DialogCode.Accepted:
                return

            pixmap = canvas.grab()
            painter = QPainter(printer)
            try:
                painter.drawPixmap(printer.pageRect(), pixmap, pixmap.rect())
            finally:
                painter.end()
        except Exception as exc:
            QMessageBox.warning(
                self,
                tr("netweaver_results.msg_print_map_title"),
                tr("netweaver_results.msg_print_failed", error=exc),
            )

    def _print_table(self) -> None:
        """Q7 — print the results TABLE (filtered/sorted view), matching the
        ArcGIS hotlink's print-the-active-tab behavior."""
        model = self._proxy_model
        if model is None or model.rowCount() == 0:
            QMessageBox.information(
                self,
                tr("netweaver_results.msg_print_table_title"),
                tr("netweaver_results.msg_no_rows_print"),
            )
            return
        try:
            try:
                from qgis.PyQt.QtGui import QTextDocument
                from qgis.PyQt.QtPrintSupport import QPrintDialog, QPrinter
            except ImportError:
                from PyQt6.QtGui import QTextDocument
                from PyQt6.QtPrintSupport import QPrintDialog, QPrinter

            printer = QPrinter(QPrinter.PrinterMode.HighResolution)
            dialog = QPrintDialog(printer, self)
            if dialog.exec() != QDialog.DialogCode.Accepted:
                return

            columns = model.columnCount()
            parts = [
                "<h3>", _py_html.escape(self.workflow_name), "</h3>",
                "<table border='1' cellspacing='0' cellpadding='3'><tr>",
            ]
            for column in range(columns):
                header = model.headerData(
                    column, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole
                )
                parts.append(f"<th>{_py_html.escape(str(header or ''))}</th>")
            parts.append("</tr>")
            for row in range(model.rowCount()):
                parts.append("<tr>")
                for column in range(columns):
                    value = model.data(
                        model.index(row, column), Qt.ItemDataRole.DisplayRole
                    )
                    parts.append(f"<td>{_py_html.escape(str(value or ''))}</td>")
                parts.append("</tr>")
            parts.append("</table>")

            document = QTextDocument()
            document.setHtml("".join(parts))
            document.print(printer)
        except Exception as exc:
            QMessageBox.warning(
                self,
                tr("netweaver_results.msg_print_table_title"),
                tr("netweaver_results.msg_print_failed", error=exc),
            )

    # -----------------------------------------------------------------------
    # Context menu
    # -----------------------------------------------------------------------

    def _show_topic_context_menu(self, position) -> None:
        item = self.topic_tree.itemAt(position)
        if item is None or not item.data(0, Qt.ItemDataRole.UserRole):
            return
        self.topic_tree.setCurrentItem(item)
        menu = QMenu(self)
        menu.addAction(tr("netweaver_results.ctx_show_on_map"),       self._show_selected_topic_on_map)
        menu.addAction(tr("netweaver_results.ctx_open_node_inspector"), self._open_node_inspector)
        menu.addAction(tr("netweaver_results.ctx_open_model_graph"), self._open_model_graph)
        topic_key = self._topic_key_for_item(item)
        include_label = (
            tr("netweaver_results.ctx_remove_from_queue")
            if topic_key in self.queued_topic_keys
            else tr("netweaver_results.ctx_queue_for_map")
        )
        menu.addAction(include_label, self._toggle_current_topic_inclusion)
        menu.addAction(tr("netweaver_results.ctx_add_queued"), self._add_included_topics_to_map)
        exec_menu(menu, self.topic_tree.viewport().mapToGlobal(position))

    # -----------------------------------------------------------------------
    # Node inspector
    # -----------------------------------------------------------------------

    def _open_node_inspector(self) -> None:
        if not self.selected_topic or not self.selected_column:
            QMessageBox.information(self, tr("netweaver_results.msg_node_inspector_title"), tr("netweaver_results.msg_select_topic_first"))
            return
        _src_row, row_index, row_value = self._selected_row_info()
        dialog = NetWeaverNodeInspectorDialog(
            workflow_name=self.workflow_name,
            topic_name=self.selected_topic,
            column_name=self.selected_column,
            column_series=self.raw_results_df[self.selected_column],
            selected_row_index=row_index,
            selected_row_value=row_value,
            parent=self,
        )
        try:
            dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
        except Exception:
            pass
        dialog.show()
        dialog.raise_()
        dialog.activateWindow()

    def _row_values_for_graph(self) -> Dict[str, Any]:
        src_row = self._current_source_row()
        if src_row < 0:
            return {}
        row_series = self.raw_results_df.iloc[src_row]
        return {
            str(column_name): row_series.get(column_name, None)
            for column_name in self.raw_results_df.columns
        }

    def _feature_label_for_row(self) -> str:
        """Short identity of the selected feature for the graph header."""
        src_row = self._current_source_row()
        if src_row < 0:
            return ""
        row_series = self.raw_results_df.iloc[src_row]
        for candidate in (self.row_key_field, "ogc_fid", "fid", "FID", "OBJECTID"):
            if candidate and candidate in self.raw_results_df.columns:
                key_value = row_series.get(candidate)
                if key_value not in (None, ""):
                    return f"{candidate} {key_value}"
        return str(self.raw_results_df.index[src_row])

    def _open_model_graph(self) -> None:
        if not self.topic_column_map and not self.topic_hierarchy:
            QMessageBox.information(
                self,
                tr("netweaver_results.msg_model_graph_title"),
                tr("netweaver_results.msg_no_topics_to_graph"),
            )
            return
        # Single instance: a second request re-focuses the open viewer,
        # refreshed to the CURRENT row and map/queue state.
        if self._graph_dialog is not None:
            try:
                self._graph_dialog.queued_topic_keys = set(self.queued_topic_keys)
                self._graph_dialog.map_topic_keys = set(self.map_topic_keys)
                self._graph_dialog.update_row(
                    self._row_values_for_graph(), self._feature_label_for_row()
                )
                self._graph_dialog.show()
                self._graph_dialog.raise_()
                self._graph_dialog.activateWindow()
                return
            except RuntimeError:
                self._graph_dialog = None  # already deleted by Qt
        dialog = NetWeaverNodeGraphDialog(
            workflow_name=self.workflow_name,
            topic_tree=self.topic_hierarchy,
            topic_column_map=self.topic_column_map,
            row_values=self._row_values_for_graph(),
            queued_topic_keys=set(self.queued_topic_keys),
            map_topic_keys=set(self.map_topic_keys),
            model_file=self.model_file,
            engine_path=self.engine_path,
            field_mappings=self.field_mappings,
            initial_topic=self.selected_topic or "",
            feature_label=self._feature_label_for_row(),
            parent=self,
        )
        dialog.topicSelected.connect(self._select_topic_by_key)
        dialog.topicDoubleClicked.connect(self._open_node_inspector_for_key)
        try:
            dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
        except Exception:
            pass
        self._graph_dialog = dialog
        dialog.destroyed.connect(self._on_graph_dialog_destroyed)
        dialog.show()
        dialog.raise_()
        dialog.activateWindow()

    def _on_graph_dialog_destroyed(self, *_args) -> None:
        self._graph_dialog = None

    def _open_node_inspector_for_key(self, topic_key: str) -> None:
        if self._select_topic_by_key(topic_key):
            self._open_node_inspector()

    # -----------------------------------------------------------------------
    # Histogram
    # -----------------------------------------------------------------------

    def _open_histogram(self) -> None:
        numeric_columns = get_numeric_columns(self.raw_results_df)
        if not numeric_columns:
            QMessageBox.information(
                self, "Histogram",
                "There are no numeric result columns available for histogram display.",
            )
            return

        initial_columns = []
        if self.selected_column and self.selected_column in numeric_columns:
            initial_columns = [self.selected_column]
        elif self.topic_column_map:
            for col in self.topic_column_map.values():
                if col in numeric_columns:
                    initial_columns = [col]
                    break
        if not initial_columns:
            initial_columns = [numeric_columns[0]]

        dialog = ResultsHistogramDialog(
            self.raw_results_df,
            initial_columns=initial_columns,
            title=f"NetWeaver Histogram – {self.workflow_name}",
            subtitle=f"Execution: {self.execution_id}",
            parent=self,
        )
        exec_dialog(dialog)
