"""NetWeaver node-NETWORK viewer (parity plan Q2).

Engine-fed graph of one topic's full dependency network — operators, functions,
fuzzy curves, variables, K-nodes — colored for the feature row selected in the
hotlink. Mirrors the revamped ArcGIS ``EmdsNwViewer``:

* shapes by node type (stadium pills for operators/functions, rounded
  rectangles for variables/curves/K-nodes, ellipses for goal holders);
* fills from the canonical ``netweaver_scheme`` ramp, text color by luminance;
* dashed grey/slate connectors for conditional/persistent-ignore edges;
* single-click inspects (detail panel), double-click drills (breadcrumb bar,
  Back button, Esc), zoom in/out/fit (+ Ctrl+wheel, Ctrl+0);
* when the hotlink's selected row changes, one ``evaluate_row`` call restyles
  the scene in place (no re-layout).

Topology comes from the ``get_node_network`` bridge verb (cached per topic);
values come from ``evaluate_row``. When the engine or model is unavailable the
dialog degrades to the legacy topic-hierarchy graph so nothing regresses.
"""

from __future__ import annotations

import html as _html
from typing import Any, Dict, List, Optional, Set, Tuple

from .netweaver_graph_layout import edge_endpoints, layout_tree, subtree_ids
from .netweaver_scheme import (
    NETWEAVER_CLASS_BREAKS,
    NO_DATA_LABEL,
    classify_netweaver_value,
    color_for_label,
    text_color_for_fill,
)
from .qt_compat import (
    QApplication,
    QBrush,
    QCheckBox,
    QColor,
    QDialog,
    QFont,
    QFrame,
    QGraphicsItem,
    QGraphicsLineItem,
    QGraphicsPathItem,
    QGraphicsPolygonItem,
    QGraphicsRectItem,
    QGraphicsScene,
    QGraphicsSimpleTextItem,
    QGraphicsTextItem,
    QGraphicsView,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QPainter,
    QPainterPath,
    QPen,
    QPointF,
    QPolygonF,
    QPushButton,
    QSettings,
    QSplitter,
    QTableWidget,
    QTableWidgetItem,
    QTextBrowser,
    QTimer,
    QTreeWidget,
    QTreeWidgetItem,
    QVBoxLayout,
    QWidget,
    Qt,
    pyqtSignal,
    exec_dialog,
)
from .translator import tr

# Item-data roles for the outline / nodes trees.
_ROLE_TOPIC = Qt.ItemDataRole.UserRole          # topic name a tree item stands for
_ROLE_OCCURRENCE = Qt.ItemDataRole.UserRole + 1  # occurrence id within that topic
_ROLE_LOADED = Qt.ItemDataRole.UserRole + 2      # lazy-expansion marker

_OUTLINE_DEPTH_CAP = 24     # matches the ArcGIS outline depth cap
_OUTLINE_NODE_BUDGET = 2000  # occurrences shown per topic before truncating
# Max evaluate_row subprocesses one outline refresh may spawn (each blocks the
# GUI ~0.3 s); stale topics re-evaluate lazily when the user expands them.
_OUTLINE_EVAL_BUDGET = 3

_GEOMETRY_SETTINGS_KEY = "EMDS/netweaver_graph_dialog/geometry"


# ---------------------------------------------------------------------------
# Legacy topic-hierarchy graph (fallback mode when the engine is unavailable)
# ---------------------------------------------------------------------------

_NODE_WIDTH = 210.0
_NODE_HEIGHT = 58.0
_LEVEL_GAP_X = 46.0
_NODE_GAP_Y = 18.0


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


class _TopicGraphNodeItem(QGraphicsRectItem):
    """A selectable topic node (legacy topic-hierarchy mode)."""

    def __init__(
        self,
        *,
        topic_key: str,
        topic_name: str,
        column_name: str,
        value: Any,
        queued: bool,
        on_map: bool,
        view: "NetWeaverNodeGraphView",
    ):
        super().__init__(0.0, 0.0, _NODE_WIDTH, _NODE_HEIGHT)
        self.topic_key = topic_key
        self.topic_name = topic_name
        self.column_name = column_name
        self._view = view
        self.setAcceptHoverEvents(True)
        self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True)

        class_label = classify_netweaver_value(value)
        fill_hex = color_for_label(class_label) or ("#ffffff" if column_name else "#eef2f7")

        self.setBrush(QBrush(QColor(fill_hex)))
        if queued:
            pen = QPen(QColor("#2563eb"), 3.0)
        elif on_map:
            pen = QPen(QColor("#16a34a"), 2.0)
        else:
            pen = QPen(QColor("#475569"), 1.0)
        self.setPen(pen)

        text_hex = text_color_for_fill(fill_hex)
        title = QGraphicsSimpleTextItem(self._truncate(topic_name, 28), self)
        title_font = title.font()
        title_font.setBold(True)
        title.setFont(title_font)
        title.setBrush(QBrush(QColor(text_hex)))
        title.setPos(10.0, 7.0)

        parts: List[str] = []
        if column_name:
            parts.append(column_name)
        if value not in (None, ""):
            parts.append(str(value))
        if queued:
            parts.append(tr("netweaver_graph.badge_queued"))
        elif on_map:
            parts.append(tr("netweaver_graph.badge_on_map"))
        subtitle = QGraphicsSimpleTextItem(self._truncate(" | ".join(parts), 34), self)
        subtitle.setBrush(QBrush(QColor(text_hex)))
        subtitle.setPos(10.0, 31.0)

        tooltip_lines = [topic_name]
        if column_name:
            tooltip_lines.append(tr("netweaver_graph.tip_column", column=column_name))
        if value not in (None, ""):
            tooltip_lines.append(tr("netweaver_graph.tip_value", value=value))
        if class_label:
            tooltip_lines.append(tr("netweaver_graph.tip_class", label=class_label))
        if queued:
            tooltip_lines.append(tr("netweaver_graph.tip_queued"))
        elif on_map:
            tooltip_lines.append(tr("netweaver_graph.tip_on_map"))
        self.setToolTip("\n".join(tooltip_lines))

    def mousePressEvent(self, event) -> None:  # type: ignore[override]
        if event.button() == Qt.MouseButton.LeftButton and self.topic_key:
            self._view.topicClicked.emit(self.topic_key)
        super().mousePressEvent(event)

    def mouseDoubleClickEvent(self, event) -> None:  # type: ignore[override]
        if event.button() == Qt.MouseButton.LeftButton and self.topic_key:
            self._view.topicDoubleClicked.emit(self.topic_key)
        super().mouseDoubleClickEvent(event)

    @staticmethod
    def _truncate(text: str, limit: int) -> str:
        text = str(text)
        if len(text) <= limit:
            return text
        return text[: max(limit - 1, 1)] + "..."


class NetWeaverNodeGraphView(QGraphicsView):
    """Left-to-right topic hierarchy graph (legacy fallback mode)."""

    topicClicked = pyqtSignal(str)
    topicDoubleClicked = pyqtSignal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self._scene = QGraphicsScene(self)
        self.setScene(self._scene)
        self.setRenderHint(QPainter.RenderHint.Antialiasing, True)
        self.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
        self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
        self.setBackgroundBrush(QBrush(QColor("#f8fafc")))

    def set_topics(
        self,
        *,
        topic_tree: List[Dict[str, Any]],
        topic_column_map: Dict[str, str],
        row_values: Dict[str, Any],
        queued_topic_keys: Set[str],
        map_topic_keys: Set[str],
    ) -> None:
        self._scene.clear()
        nodes = self._build_nodes(topic_tree, topic_column_map)
        if not nodes:
            placeholder = QGraphicsSimpleTextItem(tr("netweaver_graph.placeholder_no_topics"))
            placeholder.setBrush(QBrush(QColor("#64748b")))
            placeholder.setPos(20.0, 20.0)
            self._scene.addItem(placeholder)
            self._scene.setSceneRect(0.0, 0.0, 520.0, 100.0)
            return

        y_cursor = 0.0
        placements: Dict[str, Tuple[float, float]] = {}
        for node in nodes:
            if node["parent_key"]:
                continue
            y_cursor = self._place_node(
                node,
                nodes,
                row_values,
                queued_topic_keys,
                map_topic_keys,
                placements,
                x=0.0,
                y=y_cursor,
            ) + _NODE_GAP_Y

        for node in nodes:
            parent_key = node["parent_key"]
            topic_key = node["topic_key"]
            if not parent_key or parent_key not in placements or topic_key not in placements:
                continue
            px, py = placements[parent_key]
            cx, cy = placements[topic_key]
            line = QGraphicsLineItem(
                px + _NODE_WIDTH,
                py + (_NODE_HEIGHT / 2.0),
                cx,
                cy + (_NODE_HEIGHT / 2.0),
            )
            line.setPen(QPen(QColor("#94a3b8"), 1.0))
            line.setZValue(-1.0)
            self._scene.addItem(line)

        scene_rect = self._scene.itemsBoundingRect()
        scene_rect.adjust(-20.0, -20.0, 20.0, 20.0)
        self._scene.setSceneRect(scene_rect)

    def _place_node(
        self,
        node: Dict[str, str],
        nodes: List[Dict[str, str]],
        row_values: Dict[str, Any],
        queued_topic_keys: Set[str],
        map_topic_keys: Set[str],
        placements: Dict[str, Tuple[float, float]],
        *,
        x: float,
        y: float,
    ) -> float:
        topic_key = node["topic_key"]
        item = _TopicGraphNodeItem(
            topic_key=topic_key,
            topic_name=node["topic_name"],
            column_name=node["column_name"],
            value=row_values.get(node["column_name"]),
            queued=topic_key in queued_topic_keys,
            on_map=topic_key in map_topic_keys,
            view=self,
        )
        item.setPos(x, y)
        self._scene.addItem(item)
        placements[topic_key] = (x, y)

        y_cursor = y + _NODE_HEIGHT + _NODE_GAP_Y
        children = [child for child in nodes if child["parent_key"] == topic_key]
        for child in children:
            y_cursor = self._place_node(
                child,
                nodes,
                row_values,
                queued_topic_keys,
                map_topic_keys,
                placements,
                x=x + _NODE_WIDTH + _LEVEL_GAP_X,
                y=y_cursor,
            ) + _NODE_GAP_Y
        return max(y + _NODE_HEIGHT, y_cursor - _NODE_GAP_Y)

    def _build_nodes(
        self,
        topic_tree: List[Dict[str, Any]],
        topic_column_map: Dict[str, str],
    ) -> List[Dict[str, str]]:
        if topic_tree:
            names_by_id: Dict[int, str] = {}
            raw_nodes: List[Dict[str, Any]] = []
            for raw in topic_tree:
                name = str(raw.get("Name", "")).strip()
                if not name:
                    continue
                node_id = int(raw.get("ID", 0) or 0)
                names_by_id[node_id] = name
                raw_nodes.append(raw)

            nodes: List[Dict[str, str]] = []
            for raw in raw_nodes:
                name = str(raw.get("Name", "")).strip()
                parent_id = int(raw.get("ParentID", 0) or 0)
                parent_name = names_by_id.get(parent_id, "")
                column_name = self._match_column(name, topic_column_map)
                nodes.append({
                    "topic_key": _normalize_name(name),
                    "topic_name": name,
                    "column_name": column_name,
                    "parent_key": _normalize_name(parent_name) if parent_name else "",
                })
            return nodes

        return [
            {
                "topic_key": _normalize_name(topic_name),
                "topic_name": str(topic_name),
                "column_name": str(column_name),
                "parent_key": "",
            }
            for topic_name, column_name in topic_column_map.items()
        ]

    @staticmethod
    def _match_column(name: str, topic_column_map: Dict[str, str]) -> str:
        if name in topic_column_map:
            return str(topic_column_map[name])
        normalized = _normalize_name(name)
        for topic_name, column_name in topic_column_map.items():
            if _normalize_name(topic_name) == normalized:
                return str(column_name)
        return ""


# ---------------------------------------------------------------------------
# Network mode — engine-fed node network (Q2)
# ---------------------------------------------------------------------------

# Shape vocabulary ported from the ArcGIS NodeViewer.GetShapeKey.
_ELLIPSE_TYPES = {"DGoalHolderNode"}
_RECT_TYPES = {
    "DSwitchNode",
    "DVarNode",
    "DVarHolderNode",
    "DFuzzyCurveNode",
    "DUndeterminedNode",
    "DKNode",
}

_MAX_TEXT_WIDTH = 170.0
_UNCOLORED_FILL = "#ffffff"
_KNODE_FILL = "#483d8b"  # dark slate blue — constants keep their legacy look

_EDGE_STYLES = {
    # connection type -> (color, dash pattern or None)
    "normal": ("#334155", None),
    "conditional": ("#9ca3af", [2.0, 6.0]),
    "persistent": ("#708090", [6.0, 3.0]),
}


def _format_value(value: Any) -> str:
    if value is None:
        return tr("netweaver_graph.value_no_data")
    if isinstance(value, float):
        return f"{value:.3f}"
    return str(value)


def _render_documentation_html(
    title: str,
    hint: str,
    hyperlink: str,
    docs: List[Dict[str, Any]],
    extra: str = "",
) -> str:
    """Reading-pane HTML for one documentation entry — everything escaped
    (doc bodies are author text, never markup)."""
    parts = [f"<h3>{_html.escape(title)}</h3>"]
    if extra:
        parts.append(f"<p><i>{_html.escape(extra)}</i></p>")
    if hint:
        parts.append(
            f"<p><b>{_html.escape(tr('netweaver_graph.docs_hint_label'))}</b> "
            f"{_html.escape(hint)}</p>"
        )
    if hyperlink:
        safe_link = _html.escape(hyperlink, quote=True)
        parts.append(
            f"<p><b>{_html.escape(tr('netweaver_graph.docs_link_label'))}</b> "
            f'<a href="{safe_link}">{safe_link}</a></p>'
        )
    for doc in docs:
        doc_title = str(doc.get("title") or doc.get("key") or "")
        body = str(doc.get("text") or "")
        if doc_title:
            parts.append(f"<h4>{_html.escape(doc_title)}</h4>")
        parts.append("<p>" + _html.escape(body).replace("\n", "<br/>") + "</p>")
    if len(parts) == 1:
        parts.append(
            f"<p><i>{_html.escape(tr('netweaver_graph.docs_no_content'))}</i></p>"
        )
    return "".join(parts)


class _NetworkNodeItem(QGraphicsPathItem):
    """One occurrence of a KB node: shape by type, fill by truth value."""

    def __init__(self, occurrence: Dict[str, Any], view: "NetWeaverNetworkView"):
        super().__init__()
        self.occurrence_id = int(occurrence.get("id", 0))
        self.node_name = str(occurrence.get("name") or "")
        self.node_type = str(occurrence.get("type") or "")
        self.node_label = str(occurrence.get("label") or "") or self.node_name
        self.node_hint = str(occurrence.get("hint") or "")
        self.db_field = str(occurrence.get("db_field") or "")
        self.connection = str(occurrence.get("connection") or "normal")
        self.has_children = bool(occurrence.get("children"))
        self._view = view
        self._selected = False
        self._fill_hex = _UNCOLORED_FILL
        self.setAcceptHoverEvents(True)

        display_text = self.node_label or self.node_name
        self._compact = (
            0 < len(display_text) <= 6
            and "\n" not in display_text
            and "\r" not in display_text
        )

        if self._compact:
            text_item = QGraphicsSimpleTextItem(display_text, self)
            font = QFont(text_item.font())
            font.setPointSizeF(max(font.pointSizeF(), 9.0))
            font.setBold(True)
            text_item.setFont(font)
            rect = text_item.boundingRect()
            self._width = max(38.0, rect.width() + 26.0)
            self._height = max(30.0, rect.height() + 14.0)
            text_item.setPos(
                (self._width - rect.width()) / 2.0,
                (self._height - rect.height()) / 2.0,
            )
            self._text_item = text_item
        else:
            text_item = QGraphicsTextItem(self)
            text_item.setPlainText(display_text)
            natural = text_item.boundingRect()
            text_width = min(natural.width(), _MAX_TEXT_WIDTH)
            text_item.setTextWidth(text_width)
            option = text_item.document().defaultTextOption()
            option.setAlignment(Qt.AlignmentFlag.AlignHCenter)
            text_item.document().setDefaultTextOption(option)
            rect = text_item.boundingRect()
            is_ellipse = self.node_type in _ELLIPSE_TYPES
            pad_x = 40.0 if is_ellipse else 20.0
            pad_y = 22.0 if is_ellipse else 14.0
            self._width = rect.width() + pad_x
            self._height = rect.height() + pad_y
            text_item.setPos(
                (self._width - rect.width()) / 2.0,
                (self._height - rect.height()) / 2.0,
            )
            self._text_item = text_item

        self.setPath(self._build_path())
        self.setPen(QPen(QColor("#475569"), 1.2))
        self.apply_value(None, has_row=False)

    # -- geometry -----------------------------------------------------------

    def size(self) -> Tuple[float, float]:
        return self._width, self._height

    def _build_path(self) -> QPainterPath:
        path = QPainterPath()
        if self.node_type in _ELLIPSE_TYPES:
            path.addEllipse(0.0, 0.0, self._width, self._height)
        elif self.node_type in _RECT_TYPES:
            path.addRoundedRect(0.0, 0.0, self._width, self._height, 8.0, 8.0)
        else:
            # Stadium: rounded rect whose radius is half the height.
            radius = self._height / 2.0
            path.addRoundedRect(0.0, 0.0, self._width, self._height, radius, radius)
        return path

    # -- styling --------------------------------------------------------------

    def apply_value(self, entry: Optional[Dict[str, Any]], *, has_row: bool) -> None:
        """Restyle for a value entry ({value, no_data, ignored}) in place."""
        self.value_entry = entry
        value = entry.get("value") if entry else None
        no_data = bool(entry.get("no_data")) if entry else False
        ignored = bool(entry.get("ignored")) if entry else False

        if self.node_type == "DKNode":
            fill_hex = _KNODE_FILL
            self.class_label = None
        elif not has_row or entry is None:
            fill_hex = _UNCOLORED_FILL
            self.class_label = None
        elif ignored or no_data or value is None:
            fill_hex = color_for_label(NO_DATA_LABEL) or "#AAAAAA"
            self.class_label = NO_DATA_LABEL
        else:
            self.class_label = classify_netweaver_value(value)
            fill_hex = color_for_label(self.class_label) or _UNCOLORED_FILL

        self._fill_hex = fill_hex
        self.setBrush(QBrush(QColor(fill_hex)))
        text_hex = text_color_for_fill(fill_hex)
        if isinstance(self._text_item, QGraphicsSimpleTextItem):
            self._text_item.setBrush(QBrush(QColor(text_hex)))
        else:
            self._text_item.setDefaultTextColor(QColor(text_hex))

        tooltip = [self.node_name or self.node_label]
        tooltip.append(tr("netweaver_graph.tip_type", type=self.node_type))
        if has_row and entry is not None:
            tooltip.append(tr("netweaver_graph.tip_value", value=_format_value(value)))
            if self.class_label:
                tooltip.append(tr("netweaver_graph.tip_class", label=self.class_label))
            if ignored:
                tooltip.append(tr("netweaver_graph.tip_ignored"))
        else:
            tooltip.append(tr("netweaver_graph.tip_no_feature"))
        if self.node_hint:
            tooltip.append(self.node_hint)
        if self.db_field:
            tooltip.append(tr("netweaver_graph.tip_db_field", column=self.db_field))
        self.setToolTip("\n".join(tooltip))
        self._apply_pen()

    def set_selected_style(self, selected: bool) -> None:
        self._selected = selected
        self._apply_pen()

    def _apply_pen(self) -> None:
        if self._selected:
            self.setPen(QPen(QColor("#2563eb"), 2.6))
        else:
            self.setPen(QPen(QColor("#475569"), 1.2))

    # -- interaction ----------------------------------------------------------

    def mousePressEvent(self, event) -> None:  # type: ignore[override]
        if event.button() == Qt.MouseButton.LeftButton:
            self._view.nodeClicked.emit(self.occurrence_id)
        super().mousePressEvent(event)

    def mouseDoubleClickEvent(self, event) -> None:  # type: ignore[override]
        if event.button() == Qt.MouseButton.LeftButton:
            self._view.nodeDoubleClicked.emit(self.occurrence_id)
        super().mouseDoubleClickEvent(event)


class NetWeaverNetworkView(QGraphicsView):
    """Scene host for the node network: drag-pan plus Ctrl+wheel zoom."""

    nodeClicked = pyqtSignal(int)
    nodeDoubleClicked = pyqtSignal(int)
    userZoomed = pyqtSignal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self._scene = QGraphicsScene(self)
        self.setScene(self._scene)
        self.setRenderHint(QPainter.RenderHint.Antialiasing, True)
        self.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
        self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
        self.setBackgroundBrush(QBrush(QColor("#f8fafc")))
        self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)

    def graph_scene(self) -> QGraphicsScene:
        return self._scene

    def zoom(self, factor: float) -> None:
        self.scale(factor, factor)
        self.userZoomed.emit()

    def fit_all(self) -> None:
        rect = self._scene.sceneRect()
        if rect.isValid() and not rect.isEmpty():
            self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)

    def wheelEvent(self, event) -> None:  # type: ignore[override]
        if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
            delta = event.angleDelta().y()
            if delta:
                self.zoom(1.15 if delta > 0 else 1.0 / 1.15)
            event.accept()
            return
        super().wheelEvent(event)


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

class NetWeaverNodeGraphDialog(QDialog):
    """Modeless NetWeaver network viewer (falls back to the topic graph)."""

    topicSelected = pyqtSignal(str)
    topicDoubleClicked = pyqtSignal(str)

    def __init__(
        self,
        *,
        workflow_name: str,
        topic_tree: List[Dict[str, Any]],
        topic_column_map: Dict[str, str],
        row_values: Dict[str, Any],
        queued_topic_keys: Set[str],
        map_topic_keys: Set[str],
        model_file: str = "",
        engine_path: str = "",
        field_mappings: Optional[Dict[str, str]] = None,
        initial_topic: str = "",
        feature_label: str = "",
        parent=None,
    ):
        super().__init__(parent)
        self.workflow_name = workflow_name
        self.topic_tree = topic_tree or []
        self.topic_column_map = dict(topic_column_map or {})
        self.queued_topic_keys = set(queued_topic_keys or set())
        self.map_topic_keys = set(map_topic_keys or set())
        self._model_file = model_file or ""
        self._engine_path = engine_path or ""
        self._field_mappings = dict(field_mappings or {})
        self._row: Dict[str, Any] = dict(row_values or {})
        self._feature_label = str(feature_label or "")

        self._bridge = None
        self._mode = "topics"
        self._needs_initial_fit = False
        self._topology_cache: Dict[str, List[Dict[str, Any]]] = {}
        # Names that failed to resolve as networks — never re-probe them
        # (each miss costs a blocking ~0.3 s engine subprocess).
        self._topology_failed: Set[str] = set()
        self._truncated_topics: Set[str] = set()
        # Topics whose evaluate_row failed for the CURRENT row/overrides —
        # don't retry until the caches are invalidated.
        self._values_failed: Set[str] = set()
        self._values_cache: Dict[str, Dict[int, Dict[str, Any]]] = {}
        self._node_values: Dict[str, Any] = {}
        self._ignore_overrides: Dict[str, Dict[str, bool]] = {}
        self._crumbs: List[Dict[str, Any]] = []
        self._items_by_id: Dict[int, _NetworkNodeItem] = {}
        self._documents_payload: Optional[Dict[str, Any]] = None
        self._selected_id: Optional[int] = None
        self._user_zoomed = False
        self._pending_row: Optional[Tuple[Dict[str, Any], str]] = None
        self._row_timer = QTimer(self)
        self._row_timer.setSingleShot(True)
        self._row_timer.setInterval(150)
        self._row_timer.timeout.connect(self._apply_pending_row)

        self._editable_nodes: Optional[List[Dict[str, Any]]] = None
        self._resolved_inputs: Dict[str, str] = {}
        self._unresolved_variables: List[str] = []

        self.setWindowTitle(tr("netweaver_graph.window_title", name=workflow_name))
        self.resize(1080, 700)
        self._restore_geometry()

        self._build_ui()
        self._enter_best_mode(initial_topic)

    # -----------------------------------------------------------------------
    # UI skeleton
    # -----------------------------------------------------------------------

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

        self.context_label = QLabel("")
        self.context_label.setWordWrap(True)
        font = QFont(self.context_label.font())
        font.setBold(True)
        self.context_label.setFont(font)
        layout.addWidget(self.context_label)

        # Initialize outline_tree
        self.outline_tree = QTreeWidget()
        self.outline_tree.setColumnCount(2)
        self.outline_tree.setHeaderLabels([
            tr("netweaver_graph.outline_col_node"),
            tr("netweaver_graph.outline_col_value"),
        ])
        self.outline_tree.header().setStretchLastSection(False)
        self.outline_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        self.outline_tree.header().setSectionResizeMode(
            1, QHeaderView.ResizeMode.ResizeToContents
        )
        self.outline_tree.itemExpanded.connect(self._on_outline_expanded)
        self.outline_tree.itemDoubleClicked.connect(self._on_outline_double_clicked)
        self._outline_built = False

        # Initialize nodes_tree (Goals & Calculations)
        self.nodes_tree = QTreeWidget()
        self.nodes_tree.setColumnCount(2)
        self.nodes_tree.setHeaderLabels([
            tr("netweaver_graph.outline_col_node"),
            tr("netweaver_graph.outline_col_value"),
        ])
        self.nodes_tree.header().setStretchLastSection(False)
        self.nodes_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        self.nodes_tree.header().setSectionResizeMode(
            1, QHeaderView.ResizeMode.ResizeToContents
        )
        self.nodes_tree.itemDoubleClicked.connect(self._on_nodes_double_clicked)

        # Center widget for network graph
        center_widget = QWidget(self)
        center_layout = QVBoxLayout()
        center_layout.setContentsMargins(0, 0, 0, 0)
        center_widget.setLayout(center_layout)

        # Breadcrumbs + toolbar on one row.
        nav_row = QHBoxLayout()
        center_layout.addLayout(nav_row)

        self.back_button = QPushButton(tr("netweaver_graph.btn_back"))
        self.back_button.clicked.connect(self._go_back)
        nav_row.addWidget(self.back_button)

        self.breadcrumb_row = QHBoxLayout()
        self.breadcrumb_row.setSpacing(2)
        nav_row.addLayout(self.breadcrumb_row)
        nav_row.addStretch()

        self.zoom_out_button = QPushButton("−")
        self.zoom_out_button.setFixedWidth(34)
        self.zoom_out_button.setToolTip(tr("netweaver_graph.btn_zoom_out"))
        self.zoom_out_button.clicked.connect(lambda: self._zoom(1.0 / 1.25))
        nav_row.addWidget(self.zoom_out_button)

        self.zoom_in_button = QPushButton("+")
        self.zoom_in_button.setFixedWidth(34)
        self.zoom_in_button.setToolTip(tr("netweaver_graph.btn_zoom_in"))
        self.zoom_in_button.clicked.connect(lambda: self._zoom(1.25))
        nav_row.addWidget(self.zoom_in_button)

        self.fit_button = QPushButton(tr("netweaver_graph.btn_fit"))
        self.fit_button.setToolTip(tr("netweaver_graph.btn_fit_tip"))
        self.fit_button.clicked.connect(self._fit_view)
        nav_row.addWidget(self.fit_button)

        self.details_button = QPushButton(tr("netweaver_graph.btn_details"))
        self.details_button.setCheckable(True)
        self.details_button.setChecked(True)
        self.details_button.toggled.connect(self._toggle_details)
        nav_row.addWidget(self.details_button)

        drill_hint = QLabel(tr("netweaver_graph.hint_drill"))
        drill_hint.setStyleSheet("QLabel { color: #64748b; padding-left: 10px; }")
        nav_row.addWidget(drill_hint)

        self.network_view = NetWeaverNetworkView(self)
        self.network_view.nodeClicked.connect(self._on_node_clicked)
        self.network_view.nodeDoubleClicked.connect(self._on_node_double_clicked)
        self.network_view.userZoomed.connect(self._on_user_zoomed)
        center_layout.addWidget(self.network_view, 1)

        self.detail_panel = self._build_detail_panel()

        # Horizontal Splitter between Left, Center, Right
        self.main_splitter = QSplitter(Qt.Orientation.Horizontal, self)
        self.main_splitter.addWidget(self.outline_tree)
        self.main_splitter.addWidget(center_widget)
        self.main_splitter.addWidget(self.detail_panel)
        self.main_splitter.setStretchFactor(0, 0)
        self.main_splitter.setStretchFactor(1, 1)
        self.main_splitter.setStretchFactor(2, 0)
        self.main_splitter.setSizes([200, 600, 280])

        # Bottom container for Goals & Calculations
        bottom_container = QWidget(self)
        bottom_layout = QVBoxLayout()
        bottom_layout.setContentsMargins(0, 4, 0, 0)
        bottom_container.setLayout(bottom_layout)
        
        lbl_nodes = QLabel(tr("netweaver_graph.tab_nodes"))
        lbl_nodes.setStyleSheet("font-weight: bold;")
        bottom_layout.addWidget(lbl_nodes)
        bottom_layout.addWidget(self.nodes_tree, 1)

        # Vertical Splitter between Top main_splitter and Bottom bottom_container
        self.vertical_splitter = QSplitter(Qt.Orientation.Vertical, self)
        self.vertical_splitter.addWidget(self.main_splitter)
        self.vertical_splitter.addWidget(bottom_container)
        self.vertical_splitter.setStretchFactor(0, 4)
        self.vertical_splitter.setStretchFactor(1, 1)
        self.vertical_splitter.setSizes([560, 140])

        # Add vertical_splitter to dialog's main layout
        layout.addWidget(self.vertical_splitter, 1)

        # Legacy fallback view (hidden unless we degrade to topic mode).
        self.legacy_view = NetWeaverNodeGraphView(self)
        self.legacy_view.topicClicked.connect(self.topicSelected.emit)
        self.legacy_view.topicDoubleClicked.connect(self.topicDoubleClicked.emit)
        self.legacy_view.hide()
        layout.addWidget(self.legacy_view, 1)

        # Legend strip.
        legend_row = QHBoxLayout()
        legend_row.setSpacing(12)
        legend_row.addStretch()
        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: {color_hex}; border: 1px solid #64748b;"
            )
            cell.addWidget(swatch)
            cell.addWidget(QLabel(label))
            legend_row.addLayout(cell)
        legend_row.addStretch()
        layout.addLayout(legend_row)

        bottom_row = QHBoxLayout()
        layout.addLayout(bottom_row)
        self.status_label = QLabel("")
        self.status_label.setWordWrap(True)
        bottom_row.addWidget(self.status_label, 1)
        # Q3-4 — stateless what-ifs live client-side; one click clears them.
        self.reset_whatifs_button = QPushButton(tr("netweaver_graph.btn_reset_whatifs"))
        self.reset_whatifs_button.setToolTip(tr("netweaver_graph.btn_reset_whatifs_tip"))
        self.reset_whatifs_button.clicked.connect(self._reset_whatifs)
        self.reset_whatifs_button.setEnabled(False)
        bottom_row.addWidget(self.reset_whatifs_button)
        close_button = QPushButton(tr("netweaver_graph.btn_close"))
        close_button.clicked.connect(self.close)
        bottom_row.addWidget(close_button)

    def _build_detail_panel(self) -> QWidget:
        panel = QWidget(self)
        panel_layout = QVBoxLayout()
        panel_layout.setContentsMargins(8, 4, 4, 4)
        panel.setLayout(panel_layout)

        self.detail_title = QLabel(tr("netweaver_graph.detail_no_selection"))
        self.detail_title.setWordWrap(True)
        title_font = QFont(self.detail_title.font())
        title_font.setBold(True)
        title_font.setPointSizeF(title_font.pointSizeF() + 1.0)
        self.detail_title.setFont(title_font)
        panel_layout.addWidget(self.detail_title)

        value_row = QHBoxLayout()
        self.detail_swatch = QFrame()
        self.detail_swatch.setFixedSize(16, 16)
        self.detail_swatch.setFrameShape(QFrame.Shape.StyledPanel)
        value_row.addWidget(self.detail_swatch)
        self.detail_value = QLabel("")
        value_row.addWidget(self.detail_value, 1)
        panel_layout.addLayout(value_row)

        self.detail_meta = QLabel("")
        self.detail_meta.setWordWrap(True)
        self.detail_meta.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByMouse
        )
        panel_layout.addWidget(self.detail_meta)

        # Q3-4 — per-node ignore what-ifs (stateless: applied per evaluate_row
        # call, never written to the model; Reset restores the persisted state).
        self.whatif_conditional = QCheckBox(tr("netweaver_graph.whatif_conditional"))
        self.whatif_conditional.setToolTip(tr("netweaver_graph.whatif_tip"))
        self.whatif_conditional.toggled.connect(
            lambda checked: self._on_whatif_toggled("conditional", checked)
        )
        panel_layout.addWidget(self.whatif_conditional)
        self.whatif_persistent = QCheckBox(tr("netweaver_graph.whatif_persistent"))
        self.whatif_persistent.setToolTip(tr("netweaver_graph.whatif_tip"))
        self.whatif_persistent.toggled.connect(
            lambda checked: self._on_whatif_toggled("persistent", checked)
        )
        panel_layout.addWidget(self.whatif_persistent)
        self._set_whatif_controls(None)

        self.btn_documents = QPushButton("View Documents...")
        self.btn_documents.clicked.connect(self._show_documents_dialog)
        panel_layout.addWidget(self.btn_documents)
        panel_layout.addStretch()

        return panel

    # -----------------------------------------------------------------------
    # Q3 tabs — Outline / Nodes / Data assignments
    # -----------------------------------------------------------------------

    def _show_documents_dialog(self) -> None:
        if self._bridge is None:
            return
        if self._documents_payload is None:
            try:
                self._documents_payload = self._bridge.get_documents(self._model_file)
            except Exception as exc:
                self._set_status(tr("netweaver_graph.status_engine_error", error=exc))
                return

        dlg = QDialog(self)
        dlg.setWindowTitle("Model Documentation")
        dlg.resize(900, 600)
        dlg_layout = QVBoxLayout(dlg)

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

        tree = QTreeWidget(dlg)
        tree.setHeaderHidden(True)
        splitter.addWidget(tree)

        pane = QTextBrowser(dlg)
        pane.setOpenExternalLinks(True)
        pane.setPlaceholderText(tr("netweaver_graph.docs_select_prompt"))
        splitter.addWidget(pane)
        splitter.setStretchFactor(0, 0)
        splitter.setStretchFactor(1, 1)
        splitter.setSizes([300, 600])

        # Close button
        btn_close = QPushButton("Close", dlg)
        btn_close.clicked.connect(dlg.accept)
        btn_close_layout = QHBoxLayout()
        btn_close_layout.addStretch()
        btn_close_layout.addWidget(btn_close)
        dlg_layout.addLayout(btn_close_layout)

        kb = self._documents_payload.get("kb") or {}
        kb_item = QTreeWidgetItem([
            tr("netweaver_graph.docs_kb_header", name=str(kb.get("name") or "?"))
        ])
        bold = QFont(kb_item.font(0))
        bold.setBold(True)
        kb_item.setFont(0, bold)
        kb_item.setData(0, _ROLE_OCCURRENCE, -1)
        tree.addTopLevelItem(kb_item)

        nodes = self._documents_payload.get("nodes") or []
        target_item = kb_item
        selected_node_name = self._selected_node_name()

        for index, node in enumerate(nodes):
            name = str(node.get("name") or "?")
            item = QTreeWidgetItem([name])
            item.setToolTip(0, str(node.get("type") or ""))
            item.setData(0, _ROLE_OCCURRENCE, index)
            tree.addTopLevelItem(item)
            if selected_node_name and name.lower() == selected_node_name.lower():
                target_item = item

        def on_selection_changed():
            items = tree.selectedItems()
            if not items:
                return
            idx = items[0].data(0, _ROLE_OCCURRENCE)
            if idx is None:
                return
            idx = int(idx)
            if idx < 0:
                kb_data = self._documents_payload.get("kb") or {}
                title = str(kb_data.get("name") or "?")
                hint = ""
                hyperlink = str(kb_data.get("hyperlink") or "")
                docs = kb_data.get("docs") or []
                extra = str(kb_data.get("file") or "")
            else:
                nodes_data = self._documents_payload.get("nodes") or []
                if idx >= len(nodes_data):
                    return
                node_data = nodes_data[idx]
                title = str(node_data.get("name") or "?")
                hint = str(node_data.get("hint") or "")
                hyperlink = str(node_data.get("hyperlink") or "")
                docs = node_data.get("docs") or []
                extra = ""
            pane.setHtml(
                _render_documentation_html(title, hint, hyperlink, docs, extra)
            )

        tree.itemSelectionChanged.connect(on_selection_changed)
        tree.setCurrentItem(target_item)
        on_selection_changed()
        exec_dialog(dlg)

    # -- outline ------------------------------------------------------------

    def _refresh_outline_tab(self) -> None:
        if not self._outline_built:
            self._build_outline_roots()
            self._outline_built = True
        self._refresh_outline_values()

    def _build_outline_roots(self) -> None:
        """Top level mirrors the topic hierarchy; each topic lazily expands
        into its full node network (ArcGIS full-viewer Outline equivalent)."""
        self.outline_tree.clear()
        entries = [
            raw for raw in self.topic_tree
            if str(raw.get("Name", "")).strip()
        ]
        items_by_id: Dict[int, QTreeWidgetItem] = {}
        if entries:
            for raw in entries:
                name = str(raw.get("Name", "")).strip()
                item = QTreeWidgetItem([name, ""])
                item.setData(0, _ROLE_TOPIC, name)
                item.setData(0, _ROLE_LOADED, False)
                bold = QFont(item.font(0))
                bold.setBold(True)
                item.setFont(0, bold)
                item.addChild(QTreeWidgetItem([tr("netweaver_graph.outline_loading"), ""]))
                parent_id = int(raw.get("ParentID", 0) or 0)
                parent_item = items_by_id.get(parent_id)
                if parent_item is not None:
                    if not parent_item.data(0, _ROLE_LOADED):
                        # A topic with subtopics lists them directly; only
                        # LEAF topics lazy-expand into their node network.
                        parent_item.takeChildren()
                        parent_item.setData(0, _ROLE_LOADED, True)
                    parent_item.addChild(item)
                else:
                    self.outline_tree.addTopLevelItem(item)
                items_by_id[int(raw.get("ID", 0) or 0)] = item
        else:
            for topic_name in self.topic_column_map:
                item = QTreeWidgetItem([str(topic_name), ""])
                item.setData(0, _ROLE_TOPIC, str(topic_name))
                item.setData(0, _ROLE_LOADED, False)
                item.addChild(QTreeWidgetItem([tr("netweaver_graph.outline_loading"), ""]))
                self.outline_tree.addTopLevelItem(item)

    def _on_outline_expanded(self, item: QTreeWidgetItem) -> None:
        topic = item.data(0, _ROLE_TOPIC)
        if not topic:
            return
        if item.data(0, _ROLE_LOADED):
            # Re-expanding a topic whose values were skipped by the refresh
            # budget (or invalidated) re-evaluates it on demand.
            if (
                item.data(0, _ROLE_OCCURRENCE) is not None
                and self._values_cache.get(str(topic)) is None
            ):
                self._apply_outline_values(item, self._ensure_values(str(topic)))
            return
        item.setData(0, _ROLE_LOADED, True)
        item.takeChildren()
        nodes = self._fetch_topology(str(topic))
        if not nodes:
            item.addChild(QTreeWidgetItem([tr("netweaver_graph.outline_no_network"), ""]))
            return
        budget = [_OUTLINE_NODE_BUDGET]

        def add_occurrences(parent_item: QTreeWidgetItem, occurrence_id: int, depth: int) -> None:
            if depth > _OUTLINE_DEPTH_CAP or budget[0] <= 0:
                return
            for child_id in nodes[occurrence_id].get("children") or []:
                if budget[0] <= 0:
                    parent_item.addChild(
                        QTreeWidgetItem([tr("netweaver_graph.outline_truncated"), ""])
                    )
                    return
                budget[0] -= 1
                child = nodes[child_id]
                child_name = str(child.get("name") or child.get("label") or "?")
                child_item = QTreeWidgetItem([child_name, ""])
                child_item.setData(0, _ROLE_TOPIC, str(topic))
                child_item.setData(0, _ROLE_OCCURRENCE, int(child_id))
                child_item.setData(0, _ROLE_LOADED, True)
                parent_item.addChild(child_item)
                add_occurrences(child_item, int(child_id), depth + 1)

        item.setData(0, _ROLE_OCCURRENCE, 0)
        add_occurrences(item, 0, 0)
        self._apply_outline_values(item, self._ensure_values(str(topic)))

    def _refresh_outline_values(self) -> None:
        """Re-swatch expanded topics for the current row/what-ifs. At most
        ``_OUTLINE_EVAL_BUDGET`` topics are (re-)evaluated per pass — each is
        a blocking ~0.3 s engine subprocess — so scrubbing rows with many
        expanded topics never freezes the UI; skipped topics blank their value
        cells and re-evaluate lazily when the user expands them again."""
        eval_budget = [_OUTLINE_EVAL_BUDGET]
        for index in range(self.outline_tree.topLevelItemCount()):
            self._refresh_outline_item(
                self.outline_tree.topLevelItem(index), eval_budget
            )

    def _refresh_outline_item(
        self, item: QTreeWidgetItem, eval_budget: List[int]
    ) -> None:
        topic = item.data(0, _ROLE_TOPIC)
        if topic and item.data(0, _ROLE_LOADED) and item.data(0, _ROLE_OCCURRENCE) is not None:
            values = None
            if item.isExpanded():
                values = self._values_cache.get(str(topic))
                if values is None and eval_budget[0] > 0:
                    eval_budget[0] -= 1
                    values = self._ensure_values(str(topic))
            self._apply_outline_values(item, values)
            return  # descendants share this topic; _apply walks them
        for index in range(item.childCount()):
            self._refresh_outline_item(item.child(index), eval_budget)

    def _apply_outline_values(
        self,
        topic_item: QTreeWidgetItem,
        values: Optional[Dict[int, Dict[str, Any]]],
    ) -> None:
        def apply(item: QTreeWidgetItem) -> None:
            occurrence_id = item.data(0, _ROLE_OCCURRENCE)
            if occurrence_id is not None:
                entry = values.get(int(occurrence_id)) if values else None
                self._style_value_cell(item, entry)
            for index in range(item.childCount()):
                apply(item.child(index))

        apply(topic_item)

    def _style_value_cell(self, item: QTreeWidgetItem, entry: Optional[Dict[str, Any]]) -> None:
        if entry is None:
            item.setText(1, "")
            item.setBackground(1, QBrush())
            item.setForeground(1, QBrush())
            return
        value = None if entry.get("no_data") else entry.get("value")
        item.setText(1, _format_value(value))
        class_label = (
            NO_DATA_LABEL
            if entry.get("no_data") or entry.get("ignored") or value is None
            else classify_netweaver_value(value)
        )
        fill = color_for_label(class_label)
        if fill:
            item.setBackground(1, QBrush(QColor(fill)))
            item.setForeground(1, QBrush(QColor(text_color_for_fill(fill))))
        else:
            item.setBackground(1, QBrush())
            item.setForeground(1, QBrush())

    def _on_outline_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None:
        topic = item.data(0, _ROLE_TOPIC)
        if not topic:
            return
        occurrence_id = item.data(0, _ROLE_OCCURRENCE)
        self._navigate_to(str(topic), int(occurrence_id or 0))

    # -- goals / calculated nodes (Q3-2) --------------------------------------

    def _ensure_editable_nodes(self) -> List[Dict[str, Any]]:
        if self._editable_nodes is not None:
            return self._editable_nodes
        if self._bridge is None:
            return []
        try:
            result = self._bridge.get_editable_nodes(self._model_file)
        except Exception as exc:
            # Leave the cache unset so the next visit retries — a transient
            # failure must not permanently empty the Goals & Calcs tab.
            self._set_status(tr("netweaver_graph.status_engine_error", error=exc))
            return []
        self._editable_nodes = list(result.get("nodes") or [])
        return self._editable_nodes

    def _persisted_ignore_flags(self, node_name: str) -> Optional[Dict[str, bool]]:
        """The model's saved ignore flags for a named goal/calc/var, or None
        when the engine cannot resolve that name for what-if overrides."""
        for node in self._ensure_editable_nodes():
            if str(node.get("name") or "") == node_name:
                return {
                    "conditional": bool(node.get("conditional_ignore")),
                    "persistent": bool(node.get("persistent_ignore")),
                }
        return None

    def _refresh_nodes_tab(self) -> None:
        # Ensure node_values exist for the current row (any topic evaluation
        # returns the whole KB's named values).
        if self._crumbs:
            self._ensure_values(self._crumbs[-1]["topic"])
        self.nodes_tree.clear()
        groups = (
            ("goal", tr("netweaver_graph.nodes_goals")),
            ("calc", tr("netweaver_graph.nodes_calcs")),
        )
        editable = self._ensure_editable_nodes()
        for node_type, group_label in groups:
            group_item = QTreeWidgetItem([group_label, ""])
            bold = QFont(group_item.font(0))
            bold.setBold(True)
            group_item.setFont(0, bold)
            self.nodes_tree.addTopLevelItem(group_item)
            for node in editable:
                if str(node.get("type")) != node_type:
                    continue
                name = str(node.get("name") or "")
                if not name:
                    continue
                value = self._node_values.get(name)
                child = QTreeWidgetItem([name, _format_value(value)])
                child.setData(0, _ROLE_TOPIC, name)
                entry = {"value": value, "no_data": value is None, "ignored": False}
                self._style_value_cell(child, entry if self._node_values else None)
                group_item.addChild(child)
            group_item.setExpanded(True)

    def _on_nodes_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None:
        name = item.data(0, _ROLE_TOPIC)
        if name:
            self._navigate_to(str(name), 0)

    # -- shared navigation -----------------------------------------------------

    def _navigate_to(self, topic: str, occurrence_id: int) -> None:
        """Open *topic* (optionally re-rooted at an occurrence) in the graph."""
        if self._fetch_topology(topic) is None:
            self._set_status(tr("netweaver_graph.status_drill_failed", name=topic))
            return
        self._crumbs = [{"label": topic, "topic": topic, "root_id": 0}]
        if occurrence_id:
            nodes = self._topology_cache.get(topic) or []
            if occurrence_id < len(nodes):
                node = nodes[occurrence_id]
                label = str(node.get("name") or node.get("label") or "?")
                self._crumbs.append(
                    {"label": label, "topic": topic, "root_id": occurrence_id}
                )
        self._render_current()

    # -- what-ifs (Q3-4) --------------------------------------------------------

    def _selected_node_name(self) -> str:
        if self._selected_id is None or not self._crumbs:
            return ""
        nodes = self._topology_cache.get(self._crumbs[-1]["topic"]) or []
        if self._selected_id >= len(nodes):
            return ""
        return str(nodes[self._selected_id].get("name") or "").strip()

    def _set_whatif_controls(self, node_name: Optional[str]) -> None:
        """Sync the detail-panel checkboxes to the node's EFFECTIVE ignore
        state: the client-side what-if override when present, otherwise the
        model's persisted flag. Disabled for nodes the engine cannot resolve
        (anonymous operators, K-node constants), so a checkbox can never
        show an override that would silently do nothing."""
        persisted = (
            self._persisted_ignore_flags(node_name)
            if node_name and self._mode == "network" and self._bridge is not None
            else None
        )
        enabled = persisted is not None
        override = self._ignore_overrides.get(node_name or "", {})
        for box, key in (
            (self.whatif_conditional, "conditional"),
            (self.whatif_persistent, "persistent"),
        ):
            box.blockSignals(True)
            box.setEnabled(enabled)
            effective = override.get(key, persisted.get(key) if persisted else False)
            box.setChecked(bool(effective))
            box.blockSignals(False)

    def _on_whatif_toggled(self, key: str, checked: bool) -> None:
        name = self._selected_node_name()
        if not name:
            return
        persisted = self._persisted_ignore_flags(name) or {}
        override = dict(self._ignore_overrides.get(name, {}))
        if checked == bool(persisted.get(key)):
            # Back to the model's saved state — no override needed. Explicit
            # False overrides matter: un-ignoring a persistently-ignored node
            # is a legitimate what-if the C# side applies per call.
            override.pop(key, None)
        else:
            override[key] = checked
        if override:
            self._ignore_overrides[name] = override
        else:
            self._ignore_overrides.pop(name, None)
        self._after_overrides_changed()

    def _reset_whatifs(self) -> None:
        if not self._ignore_overrides:
            return
        self._ignore_overrides.clear()
        self._after_overrides_changed()

    def _after_overrides_changed(self) -> None:
        count = len(self._ignore_overrides)
        self.reset_whatifs_button.setEnabled(count > 0)
        self.reset_whatifs_button.setText(
            tr("netweaver_graph.btn_reset_whatifs_count", count=count)
            if count
            else tr("netweaver_graph.btn_reset_whatifs")
        )
        # Every cached evaluation is stale once the overrides change.
        self._invalidate_values()
        self._restyle_current()

    def _refresh_visible_tab(self) -> None:
        if self._mode != "network":
            return
        if self._outline_built:
            self._refresh_outline_values()
        self._refresh_nodes_tab()

    # -----------------------------------------------------------------------
    # Geometry persistence (Q4-2)
    # -----------------------------------------------------------------------

    def _restore_geometry(self) -> None:
        try:
            saved = QSettings().value(_GEOMETRY_SETTINGS_KEY)
            if saved is not None:
                self.restoreGeometry(saved)
        except Exception:
            pass

    def showEvent(self, event) -> None:  # type: ignore[override]
        super().showEvent(event)
        if getattr(self, "_needs_initial_fit", False):
            self._needs_initial_fit = False
            # After the show-time layout pass, so the viewport has real size.
            QTimer.singleShot(0, self._fit_view)

    def hideEvent(self, event) -> None:  # type: ignore[override]
        # Covers both exits: close() -> closeEvent -> hide, AND Esc ->
        # QDialog.reject() -> hide (which never reaches closeEvent).
        try:
            QSettings().setValue(_GEOMETRY_SETTINGS_KEY, self.saveGeometry())
        except Exception:
            pass
        super().hideEvent(event)

    # -----------------------------------------------------------------------
    # Mode selection
    # -----------------------------------------------------------------------

    def _enter_best_mode(self, initial_topic: str) -> None:
        try:
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            if self._init_bridge():
                topic = self._resolve_initial_topic(initial_topic)
                if topic is not None:
                    self._mode = "network"
                    self._crumbs = [{"label": topic, "topic": topic, "root_id": 0}]
                    self._render_current()
                    return
            self._enter_topics_mode()
        finally:
            QApplication.restoreOverrideCursor()

    def _init_bridge(self) -> bool:
        if not self._model_file or not self._engine_path:
            return False
        try:
            from .bridge_loader import NetWeaverBridge

            self._bridge = NetWeaverBridge(self._engine_path)
            return True
        except Exception as exc:  # missing binary, missing python-bridge, …
            self._set_status(tr("netweaver_graph.status_engine_error", error=exc))
            self._bridge = None
            return False

    def _enter_topics_mode(self) -> None:
        self._mode = "topics"
        self.vertical_splitter.hide()
        self.reset_whatifs_button.hide()
        self.legacy_view.show()
        self.legacy_view.set_topics(
            topic_tree=self.topic_tree,
            topic_column_map=self.topic_column_map,
            row_values=self._row,
            queued_topic_keys=self.queued_topic_keys,
            map_topic_keys=self.map_topic_keys,
        )
        self.context_label.setText(
            tr("netweaver_graph.context_topics_mode", name=self.workflow_name)
        )
        if self._model_file and self._engine_path:
            # Bridge construction succeeded but topology fetches failed.
            return
        self._set_status(tr("netweaver_graph.status_topics_fallback"))

    def _resolve_initial_topic(self, initial_topic: str) -> Optional[str]:
        candidates: List[str] = []
        seen: Set[str] = set()

        def add(name: Any) -> None:
            text = str(name).strip()
            key = _normalize_name(text)
            if text and key not in seen:
                seen.add(key)
                candidates.append(text)

        add(initial_topic)
        for raw in self.topic_tree:
            if raw.get("IsRoot"):
                add(raw.get("Name", ""))
        for name in self.topic_column_map:
            add(name)
        # Each failed probe blocks the UI for one ~0.3 s engine subprocess, so
        # cap the constructor to a handful; if none of these resolve, the
        # engine/model is broken and the topics fallback is correct anyway.
        for candidate in candidates[:4]:
            if self._fetch_topology(candidate) is not None:
                return candidate
        return None

    # -----------------------------------------------------------------------
    # Bridge data
    # -----------------------------------------------------------------------

    def _fetch_topology(self, topic: str) -> Optional[List[Dict[str, Any]]]:
        cached = self._topology_cache.get(topic)
        if cached is not None:
            return cached
        if self._bridge is None or topic in self._topology_failed:
            return None
        try:
            result = self._bridge.get_node_network(self._model_file, topic)
        except Exception as exc:
            self._topology_failed.add(topic)
            self._set_status(tr("netweaver_graph.status_engine_error", error=exc))
            return None
        nodes = result.get("nodes") or []
        if not nodes:
            self._topology_failed.add(topic)
            return None
        self._topology_cache[topic] = nodes
        if result.get("truncated"):
            self._truncated_topics.add(topic)
        return nodes

    def _invalidate_values(self) -> None:
        """Row or overrides changed — every cached evaluation is stale."""
        self._values_cache.clear()
        self._values_failed.clear()
        self._node_values = {}

    def _ensure_values(self, topic: str) -> Optional[Dict[int, Dict[str, Any]]]:
        """Per-occurrence values for *topic* under the current row (cached)."""
        if not self._row or self._bridge is None or topic in self._values_failed:
            return None
        cached = self._values_cache.get(topic)
        if cached is not None:
            return cached
        try:
            result = self._bridge.evaluate_row(
                self._model_file,
                self._row,
                field_mappings=self._field_mappings or None,
                ignore_overrides=self._ignore_overrides or None,
                topic_name=topic,
            )
        except Exception as exc:
            self._values_failed.add(topic)
            self._set_status(tr("netweaver_graph.status_engine_error", error=exc))
            return None
        missing_overrides = result.get("missing_override_nodes") or []
        if missing_overrides:
            self._set_status(tr(
                "netweaver_graph.status_missing_overrides",
                names=", ".join(str(n) for n in missing_overrides[:5]),
            ))
        values_by_id = {
            int(entry.get("id", -1)): entry for entry in (result.get("topic_nodes") or [])
        }
        self._values_cache[topic] = values_by_id
        self._node_values = result.get("node_values") or {}
        self._resolved_inputs = dict(result.get("resolved_inputs") or {})
        self._unresolved_variables = list(result.get("unresolved_variables") or [])
        return values_by_id

    # -----------------------------------------------------------------------
    # Rendering
    # -----------------------------------------------------------------------

    def _render_current(self, *, keep_view: bool = False) -> None:
        crumb = self._crumbs[-1]
        topic = crumb["topic"]
        root_id = int(crumb.get("root_id", 0))
        nodes = self._fetch_topology(topic)
        if nodes is None:
            self._enter_topics_mode()
            return
        self.vertical_splitter.show()
        self.legacy_view.hide()
        values = self._ensure_values(topic)

        scene = self.network_view.graph_scene()
        scene.clear()
        self._items_by_id = {}
        self._selected_id = None

        ids = subtree_ids(nodes, root_id)
        items: Dict[int, _NetworkNodeItem] = {}
        sizes: Dict[int, Tuple[float, float]] = {}
        for node_id in ids:
            item = _NetworkNodeItem(nodes[node_id], self.network_view)
            entry = values.get(node_id) if values else None
            item.apply_value(entry, has_row=bool(values))
            items[node_id] = item
            sizes[node_id] = item.size()

        positions = layout_tree(nodes, sizes, root_id=root_id)

        # Edges beneath the shapes.
        for node_id in ids:
            for child_id in nodes[node_id].get("children") or []:
                self._add_edge(scene, nodes, positions, sizes, node_id, child_id)

        for node_id in ids:
            item = items[node_id]
            x, y = positions[node_id]
            item.setPos(x, y)
            scene.addItem(item)

        self._items_by_id = items
        rect = scene.itemsBoundingRect()
        rect.adjust(-30.0, -30.0, 30.0, 30.0)
        scene.setSceneRect(rect)

        self._rebuild_breadcrumbs()
        self._update_context_label(topic)
        self._clear_detail()
        if not self._outline_built:
            self._build_outline_roots()
            self._outline_built = True
        self._refresh_outline_values()
        self._refresh_nodes_tab()
        if values is None and self._row:
            pass  # engine error already reported via status
        else:
            self._set_status("  ".join(self._status_parts(topic)))

        if not keep_view:
            self._user_zoomed = False
            self.network_view.resetTransform()
            if self.isVisible():
                self.network_view.fit_all()
            else:
                # fitInView against the never-laid-out default viewport would
                # compute a garbage transform; fit on first show instead.
                self._needs_initial_fit = True

    def _add_edge(
        self,
        scene: QGraphicsScene,
        nodes: List[Dict[str, Any]],
        positions: Dict[int, Tuple[float, float]],
        sizes: Dict[int, Tuple[float, float]],
        parent_id: int,
        child_id: int,
    ) -> None:
        x1, y1, x2, y2 = edge_endpoints(positions, sizes, parent_id, child_id)
        connection = str(nodes[child_id].get("connection") or "normal")
        color_hex, dashes = _EDGE_STYLES.get(connection, _EDGE_STYLES["normal"])
        pen = QPen(QColor(color_hex), 1.6)
        if dashes:
            pen.setDashPattern(dashes)
        line = QGraphicsLineItem(x1, y1, x2, y2)
        line.setPen(pen)
        line.setZValue(-1.0)
        scene.addItem(line)

        # Arrowhead pointing at the child (top-down flow).
        arrow = QPolygonF([
            QPointF(x2, y2),
            QPointF(x2 - 5.0, y2 - 9.0),
            QPointF(x2 + 5.0, y2 - 9.0),
        ])
        head = QGraphicsPolygonItem(arrow)
        head.setBrush(QBrush(QColor(color_hex)))
        head.setPen(QPen(QColor(color_hex), 1.0))
        head.setZValue(-1.0)
        scene.addItem(head)

    def _rebuild_breadcrumbs(self) -> None:
        while self.breadcrumb_row.count():
            item = self.breadcrumb_row.takeAt(0)
            widget = item.widget()
            if widget is not None:
                widget.deleteLater()
        for index, crumb in enumerate(self._crumbs):
            if index:
                separator = QLabel("▸")
                self.breadcrumb_row.addWidget(separator)
            button = QPushButton(str(crumb["label"]))
            button.setFlat(True)
            button.setStyleSheet("QPushButton { padding: 2px 6px; }")
            if index == len(self._crumbs) - 1:
                crumb_font = QFont(button.font())
                crumb_font.setBold(True)
                button.setFont(crumb_font)
            button.clicked.connect(lambda _checked=False, i=index: self._pop_to(i))
            self.breadcrumb_row.addWidget(button)
        self.back_button.setEnabled(len(self._crumbs) > 1)

    def _update_context_label(self, topic: str) -> None:
        if self._feature_label:
            text = tr(
                "netweaver_graph.context",
                topic=topic,
                feature=self._feature_label,
            )
        else:
            text = tr("netweaver_graph.context_no_feature", topic=topic)
        self.context_label.setText(text)

    def _set_status(self, text: str) -> None:
        self.status_label.setText(str(text))

    # -----------------------------------------------------------------------
    # Navigation
    # -----------------------------------------------------------------------

    def _status_parts(self, topic: str) -> List[str]:
        """Q7 — surface the diagnostics evaluate_row already returns."""
        parts: List[str] = []
        if not self._row:
            parts.append(tr("netweaver_graph.status_no_row"))
        if topic in self._truncated_topics:
            parts.append(tr("netweaver_graph.status_truncated"))
        if self._row and self._unresolved_variables:
            parts.append(tr(
                "netweaver_graph.status_unresolved",
                count=len(self._unresolved_variables),
            ))
        if self._row and self._node_values:
            no_data_count = sum(
                1 for value in self._node_values.values() if value is None
            )
            if no_data_count:
                parts.append(tr(
                    "netweaver_graph.status_no_data_count", count=no_data_count
                ))
        return parts

    def _pop_to(self, index: int) -> None:
        if 0 <= index < len(self._crumbs) - 1:
            self._crumbs = self._crumbs[: index + 1]
            self._render_current()

    def _go_back(self) -> None:
        if len(self._crumbs) > 1:
            self._crumbs.pop()
            self._render_current()

    def _on_node_clicked(self, occurrence_id: int) -> None:
        previous = self._items_by_id.get(self._selected_id) if self._selected_id is not None else None
        if previous is not None:
            previous.set_selected_style(False)
        self._selected_id = occurrence_id
        item = self._items_by_id.get(occurrence_id)
        if item is not None:
            item.set_selected_style(True)
        self._populate_detail(occurrence_id)

    def _on_node_double_clicked(self, occurrence_id: int) -> None:
        # This slot runs synchronously inside the clicked item's
        # mouseDoubleClickEvent (direct signal connection). Re-rendering here
        # would scene.clear() the item while it is still on the call stack and
        # crash on return ("wrapped C/C++ object has been deleted") — defer
        # the drill until the event dispatch has fully unwound.
        QTimer.singleShot(0, lambda: self._drill_into(occurrence_id))

    def _drill_into(self, occurrence_id: int) -> None:
        try:
            self._drill_into_unguarded(occurrence_id)
        except RuntimeError:
            pass  # dialog deleted between scheduling and firing

    def _drill_into_unguarded(self, occurrence_id: int) -> None:
        crumb = self._crumbs[-1]
        nodes = self._topology_cache.get(crumb["topic"]) or []
        if occurrence_id >= len(nodes):
            return
        node = nodes[occurrence_id]
        label = str(node.get("name") or node.get("label") or "?")

        if node.get("children") and occurrence_id != int(crumb.get("root_id", 0)):
            # Local re-root: the subtree is already in the payload.
            self._crumbs.append(
                {"label": label, "topic": crumb["topic"], "root_id": occurrence_id}
            )
            self._render_current()
            return

        name = str(node.get("name") or "").strip()
        if not name or node.get("children"):
            return  # anonymous operator root, or already the root
        if self._fetch_topology(name) is None:
            self._set_status(tr("netweaver_graph.status_drill_failed", name=label))
            return
        self._crumbs.append({"label": name, "topic": name, "root_id": 0})
        self._render_current()
        # Keep the hotlink's topic tree in sync when the drill target is a
        # scored topic (mirrors the ArcGIS NodeInspected forwarding).
        topic_key = _normalize_name(name)
        for topic_name in self.topic_column_map:
            if _normalize_name(topic_name) == topic_key:
                self.topicSelected.emit(topic_key)
                break

    # -----------------------------------------------------------------------
    # Detail panel
    # -----------------------------------------------------------------------

    def _clear_detail(self) -> None:
        self.detail_title.setText(tr("netweaver_graph.detail_no_selection"))
        self.detail_value.setText("")
        self.detail_meta.setText("")
        self.detail_swatch.setStyleSheet("background-color: transparent;")
        self._set_whatif_controls(None)

    def _populate_detail(self, occurrence_id: int) -> None:
        crumb = self._crumbs[-1]
        topic = crumb["topic"]
        nodes = self._topology_cache.get(topic) or []
        if occurrence_id >= len(nodes):
            return
        node = nodes[occurrence_id]
        values = self._values_cache.get(topic) or {}
        entry = values.get(occurrence_id)

        name = str(node.get("name") or node.get("label") or "?")
        self.detail_title.setText(name)

        # Same rule as the node fill (apply_value): ignored / no-data wins
        # over the raw value, so the swatch never contradicts the scene.
        value, class_label = self._entry_display(entry)
        fill = color_for_label(class_label)
        if fill:
            self.detail_swatch.setStyleSheet(
                f"background-color: {fill}; border: 1px solid #64748b;"
            )
        else:
            self.detail_swatch.setStyleSheet(
                "background-color: transparent; border: 1px solid #cbd5e1;"
            )
        if entry is not None:
            value_text = _format_value(value)
            if class_label:
                value_text = f"{value_text}  ({class_label})"
            if entry.get("ignored"):
                value_text += f"  — {tr('netweaver_graph.tip_ignored')}"
        else:
            value_text = tr("netweaver_graph.tip_no_feature")
        self.detail_value.setText(value_text)

        meta_lines = [tr("netweaver_graph.detail_type", type=str(node.get("type") or ""))]
        hint = str(node.get("hint") or "")
        if hint:
            meta_lines.append(tr("netweaver_graph.detail_hint", hint=hint))
        db_field = str(node.get("db_field") or "")
        if db_field:
            meta_lines.append(tr("netweaver_graph.detail_db_field", column=db_field))
        connection = str(node.get("connection") or "normal")
        if connection != "normal":
            meta_lines.append(
                tr("netweaver_graph.detail_connection", connection=connection)
            )
        result_column = self._result_column_for(name)
        if result_column:
            meta_lines.append(
                tr("netweaver_graph.detail_result_column", column=result_column)
            )
        self.detail_meta.setText("\n".join(meta_lines))

        # Q3-4 — what-if toggles apply to NAMED nodes only.
        self._set_whatif_controls(name if str(node.get("name") or "").strip() else None)

    @staticmethod
    def _entry_display(entry: Optional[Dict[str, Any]]) -> Tuple[Any, Optional[str]]:
        """(display value, class label) for a per-occurrence value entry,
        honoring ignored/no-data BEFORE the ramp — the node-fill rule."""
        if entry is None:
            return None, None
        if entry.get("no_data") or entry.get("ignored") or entry.get("value") is None:
            return None, NO_DATA_LABEL
        value = entry.get("value")
        return value, classify_netweaver_value(value)

    def _result_column_for(self, node_name: str) -> str:
        normalized = _normalize_name(node_name)
        for topic_name, column_name in self.topic_column_map.items():
            if _normalize_name(topic_name) == normalized:
                return str(column_name)
        return ""

    # -----------------------------------------------------------------------
    # View helpers
    # -----------------------------------------------------------------------

    def _zoom(self, factor: float) -> None:
        if self._mode == "network":
            self.network_view.zoom(factor)

    def _fit_view(self) -> None:
        if self._mode == "network":
            self._user_zoomed = False
            self.network_view.fit_all()

    def _on_user_zoomed(self) -> None:
        self._user_zoomed = True

    def _toggle_details(self, checked: bool) -> None:
        self.detail_panel.setVisible(checked)

    def keyPressEvent(self, event) -> None:  # type: ignore[override]
        if event.key() == Qt.Key.Key_Escape and len(self._crumbs) > 1:
            self._go_back()
            event.accept()
            return
        if (
            event.key() == Qt.Key.Key_0
            and event.modifiers() & Qt.KeyboardModifier.ControlModifier
        ):
            self._fit_view()
            event.accept()
            return
        super().keyPressEvent(event)

    # -----------------------------------------------------------------------
    # Live updates from the hotlink (Q2 feature-change flow)
    # -----------------------------------------------------------------------

    def update_row(self, row_values: Dict[str, Any], feature_label: str = "") -> None:
        """Called by the hotlink when its selected row changes. Debounced;
        one evaluate_row call restyles the scene in place (no re-layout)."""
        self._pending_row = (dict(row_values or {}), str(feature_label or ""))
        self._row_timer.start()

    def _apply_pending_row(self) -> None:
        if self._pending_row is None:
            return
        row_values, feature_label = self._pending_row
        self._pending_row = None
        self._row = row_values
        self._feature_label = feature_label
        self._invalidate_values()

        if self._mode != "network":
            self.legacy_view.set_topics(
                topic_tree=self.topic_tree,
                topic_column_map=self.topic_column_map,
                row_values=self._row,
                queued_topic_keys=self.queued_topic_keys,
                map_topic_keys=self.map_topic_keys,
            )
            return

        self._restyle_current()

    def _restyle_current(self) -> None:
        """Re-evaluate the current topic and restyle items without re-layout."""
        crumb = self._crumbs[-1]
        topic = crumb["topic"]
        values = self._ensure_values(topic)
        has_row = bool(values)
        for occurrence_id, item in self._items_by_id.items():
            entry = values.get(occurrence_id) if values else None
            item.apply_value(entry, has_row=has_row)
        self._update_context_label(topic)
        if self._selected_id is not None:
            self._populate_detail(self._selected_id)
        if self._row and values is None:
            pass  # engine error already reported via status
        else:
            self._set_status("  ".join(self._status_parts(topic)))
        self._refresh_visible_tab()
