"""Scenario Builder — guided what-if on one task (plan S2).

A bounded, linear flow (distinct from the structural Pipeline Builder):
  1. Change   — swap the model, or edit weights (CDP) / ignore flags (NetWeaver).
  2. Test     — run the change on a small subset (≤200 rows); staging DB only.
  3. Apply    — Fork + re-run children / Fork (this node only) / Replace.

The dialog mutates a throwaway working copy of the task; the dock performs the
structural fork/replace and execution. It never touches the baseline directly.

The model read/apply calls and the test run shell out to the engine
synchronously; each is wrapped in ``busy_progress`` so the window shows clear
"working" feedback and disables its button instead of silently freezing.
"""

from __future__ import annotations

import copy
import inspect
import os
import uuid
from typing import Any, Callable, Optional

from ..qt_compat import (
    QAbstractItemView,
    QButtonGroup,
    QDialog,
    QDialogButtonBox,
    QFileDialog,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMessageBox,
    QPushButton,
    QRadioButton,
    QTableWidget,
    QTableWidgetItem,
    QVBoxLayout,
    QHeaderView,
    Qt,
)
from ..translator import tr
from .busy_progress import busy_progress

# Engines whose model can be swapped to a different file.
_MODEL_ENGINES = ("NetWeaver", "CDP")


class ScenarioBuilderDialog(QDialog):
    """Guided 3-step what-if on a single task."""

    def __init__(
        self,
        source_task,
        *,
        has_children: bool,
        preview_runner: Callable[[Any], tuple],
        preview_run_runner: Optional[Callable[[Any, str], tuple]] = None,
        weights_provider: Optional[Callable[[str], dict]] = None,
        weights_applier: Optional[Callable[[str, list], str]] = None,
        nodes_provider: Optional[Callable[[str], dict]] = None,
        nodes_applier: Optional[Callable[[str, list, list], str]] = None,
        default_scenario_name: str = "",
        preview_viewer: Optional[Callable[[Any, Any], None]] = None,
        compare_viewer: Optional[Callable[..., None]] = None,
        pipeline_builder_opener: Optional[Callable[[Any], None]] = None,
        parent=None,
    ):
        super().__init__(parent)
        self.source_task = source_task
        self.has_children = has_children
        self._preview_runner = preview_runner
        self._preview_run_runner = preview_run_runner
        self._weights_provider = weights_provider
        self._weights_applier = weights_applier
        self._nodes_provider = nodes_provider
        self._nodes_applier = nodes_applier
        self._default_scenario_name = default_scenario_name
        self._preview_viewer = preview_viewer
        self._compare_viewer = compare_viewer
        self._pipeline_builder_opener = pipeline_builder_opener
        # Working copy with a throwaway id so preview never pollutes the baseline.
        self.working_task = copy.deepcopy(source_task)
        self.working_task.id = str(uuid.uuid4())
        self._engine_value = (
            source_task.engine_type.value if hasattr(source_task.engine_type, "value") else str(source_task.engine_type)
        )
        self._action: Optional[str] = None
        self._wizard_step = 0
        self.setWindowTitle(tr("provenance.scenario_builder_window_title", name=source_task.name))
        self.resize(640, 560)
        self._build()

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

        self.step_indicator = QLabel("")
        self.step_indicator.setStyleSheet("font-weight: 600; color: #374151;")
        layout.addWidget(self.step_indicator)

        intro = QLabel(tr("provenance.scenario_builder_intro"))
        intro.setWordWrap(True)
        layout.addWidget(intro)

        self.change_group = self._build_change_group()
        self.preview_group = self._build_preview_group()
        self.apply_group = self._build_apply_group()
        layout.addWidget(self.change_group)
        layout.addWidget(self.preview_group)
        layout.addWidget(self.apply_group)
        layout.addStretch()

        nav = QHBoxLayout()
        self.btn_pipeline_escape = QPushButton(tr("provenance.btn_open_pipeline_builder"))
        self.btn_pipeline_escape.setEnabled(self._pipeline_builder_opener is not None)
        self.btn_pipeline_escape.clicked.connect(self._on_open_pipeline_builder)
        nav.addWidget(self.btn_pipeline_escape)
        nav.addStretch()
        self.btn_back = QPushButton(tr("provenance.wizard_back"))
        self.btn_back.clicked.connect(self._on_wizard_back)
        nav.addWidget(self.btn_back)
        self.btn_next = QPushButton(tr("provenance.wizard_next"))
        self.btn_next.clicked.connect(self._on_wizard_next)
        nav.addWidget(self.btn_next)
        cancel = QPushButton(tr("provenance.btn_cancel"))
        cancel.clicked.connect(self.reject)
        nav.addWidget(cancel)
        self.btn_commit = QPushButton(tr("provenance.btn_apply"))
        self.btn_commit.clicked.connect(self._on_commit)
        nav.addWidget(self.btn_commit)
        layout.addLayout(nav)
        self._update_wizard_step()

    def _update_wizard_step(self) -> None:
        self.change_group.setVisible(self._wizard_step == 0)
        self.preview_group.setVisible(self._wizard_step == 1)
        self.apply_group.setVisible(self._wizard_step == 2)
        self.btn_back.setEnabled(self._wizard_step > 0)
        self.btn_next.setVisible(self._wizard_step < 2)
        self.btn_commit.setVisible(self._wizard_step == 2)
        self.step_indicator.setText(tr("provenance.wizard_step_indicator", step=self._wizard_step + 1))

    def _on_wizard_back(self) -> None:
        if self._wizard_step <= 0:
            return
        self._wizard_step -= 1
        self._update_wizard_step()

    def _on_wizard_next(self) -> None:
        if self._wizard_step >= 2:
            return
        self._wizard_step += 1
        self._update_wizard_step()

    def _build_change_group(self) -> QGroupBox:
        group = QGroupBox(tr("provenance.scenario_builder_change_group"))
        v = QVBoxLayout()
        group.setLayout(v)

        current = str((self.working_task.parameters or {}).get("model_file", "") or "(none)")
        self.model_label = QLabel(tr("provenance.scenario_builder_model_prefix", model=current))
        self.model_label.setWordWrap(True)
        v.addWidget(self.model_label)

        row = QHBoxLayout()
        self.btn_swap = QPushButton(tr("provenance.btn_swap_model"))
        self.btn_swap.setEnabled(self._engine_value in _MODEL_ENGINES)
        self.btn_swap.clicked.connect(self._on_swap_model)
        row.addWidget(self.btn_swap)

        # CDP weight editing and NetWeaver ignore-flag editing are supported in-EMDS
        # (Path B). Other engines route to the dedicated editor (deferred).
        cdp_weights_ready = (
            self._engine_value == "CDP"
            and self._weights_provider is not None
            and self._weights_applier is not None
        )
        nw_nodes_ready = (
            self._engine_value == "NetWeaver"
            and self._nodes_provider is not None
            and self._nodes_applier is not None
        )
        self.btn_edit_weights = None
        self.btn_edit_nodes = None
        if cdp_weights_ready:
            self.btn_edit_weights = QPushButton(tr("provenance.btn_edit_weights"))
            self.btn_edit_weights.setToolTip(tr("provenance.tip_edit_weights"))
            self.btn_edit_weights.clicked.connect(self._on_edit_weights)
            row.addWidget(self.btn_edit_weights)
        elif nw_nodes_ready:
            self.btn_edit_nodes = QPushButton(tr("provenance.btn_edit_ignore"))
            self.btn_edit_nodes.setToolTip(tr("provenance.tip_edit_ignore"))
            self.btn_edit_nodes.clicked.connect(self._on_edit_nodes)
            row.addWidget(self.btn_edit_nodes)
        else:
            self.btn_edit_model = QPushButton(tr("provenance.btn_edit_in_editor"))
            self.btn_edit_model.setEnabled(False)
            self.btn_edit_model.setToolTip(tr("provenance.tip_edit_in_editor"))
            row.addWidget(self.btn_edit_model)
        row.addStretch()
        v.addLayout(row)

        self.edit_status = QLabel("")
        self.edit_status.setStyleSheet("color: #2e7d32;")
        self.edit_status.setWordWrap(True)
        v.addWidget(self.edit_status)

        if self._engine_value not in _MODEL_ENGINES:
            note = QLabel(tr("provenance.no_swap_note"))
            note.setStyleSheet("color: #666;")
            note.setWordWrap(True)
            v.addWidget(note)
        return group

    def _build_preview_group(self) -> QGroupBox:
        group = QGroupBox(tr("provenance.scenario_builder_preview_group"))
        v = QVBoxLayout()
        group.setLayout(v)
        row = QHBoxLayout()
        self.btn_preview = QPushButton(tr("provenance.btn_run_test"))
        self.btn_preview.clicked.connect(self._on_preview)
        row.addWidget(self.btn_preview)
        self.btn_preview_run = QPushButton(tr("provenance.btn_record_preview_run"))
        self.btn_preview_run.setEnabled(self._preview_run_runner is not None)
        self.btn_preview_run.clicked.connect(self._on_preview_run)
        row.addWidget(self.btn_preview_run)
        row.addStretch()
        v.addLayout(row)
        self.preview_status = QLabel("")
        self.preview_status.setWordWrap(True)
        v.addWidget(self.preview_status)
        return group

    def _build_apply_group(self) -> QGroupBox:
        group = QGroupBox(tr("provenance.scenario_builder_apply_group"))
        v = QVBoxLayout()
        group.setLayout(v)

        name_row = QHBoxLayout()
        name_row.addWidget(QLabel(tr("provenance.scenario_name_label")))
        self.name_edit = QLineEdit(
            self._default_scenario_name
            or tr("provenance.scenario_name_default", name=self.source_task.name)
        )
        name_row.addWidget(self.name_edit)
        v.addLayout(name_row)

        self.action_group = QButtonGroup(self)
        self.rb_fork_children = QRadioButton(tr("provenance.rb_fork_children"))
        self.rb_fork_only = QRadioButton(tr("provenance.rb_fork_only"))
        self.rb_replace = QRadioButton(tr("provenance.rb_replace"))
        for rb in (self.rb_fork_children, self.rb_fork_only, self.rb_replace):
            self.action_group.addButton(rb)
            v.addWidget(rb)
        if self.has_children:
            self.rb_fork_children.setChecked(True)
        else:
            self.rb_fork_children.setEnabled(False)
            self.rb_fork_children.setToolTip(tr("provenance.tip_no_children"))
            self.rb_fork_only.setChecked(True)
        return group

    # ------------------------------------------------------------------
    def _on_swap_model(self) -> None:
        filt = (
            tr("provenance.swap_filter_netweaver")
            if self._engine_value == "NetWeaver"
            else tr("provenance.swap_filter_cdp")
        )
        path, _ = QFileDialog.getOpenFileName(self, tr("provenance.swap_dialog_title"), "", filt)
        if not path:
            return
        self.working_task.parameters = dict(self.working_task.parameters or {})
        self.working_task.parameters["model_file"] = path
        self.model_label.setText(tr("provenance.scenario_builder_model_prefix", model=path))
        self.preview_status.setText("")
        self.edit_status.setText("")

    def _on_edit_weights(self) -> None:
        """Open the CDP weight grid; on accept, apply edits to a new model file."""
        model_path = str((self.working_task.parameters or {}).get("model_file", "") or "")
        if not model_path or not os.path.isfile(model_path):
            QMessageBox.information(self, tr("provenance.mbox_edit_weights"), tr("provenance.mbox_no_cdp_model"))
            return
        try:
            with busy_progress(self, tr("provenance.busy_reading_weights"), disable=[self.btn_edit_weights]):
                data = self._weights_provider(model_path)
        except Exception as exc:
            QMessageBox.warning(self, tr("provenance.mbox_edit_weights"),
                                tr("provenance.mbox_read_weights_failed", error=exc))
            return
        editor = _CdpWeightEditorDialog(data, parent=self)
        if not editor.exec():
            return
        edits = editor.get_edits()
        if not edits:
            self.edit_status.setText(tr("provenance.status_no_weight_changes"))
            return
        try:
            with busy_progress(self, tr("provenance.busy_applying_weights"), disable=[self.btn_edit_weights]):
                edited_path = self._weights_applier(model_path, edits)
        except Exception as exc:
            QMessageBox.warning(self, tr("provenance.mbox_edit_weights"),
                                tr("provenance.mbox_apply_weights_failed", error=exc))
            return
        self.working_task.parameters = dict(self.working_task.parameters or {})
        self.working_task.parameters["model_file"] = edited_path
        self.model_label.setText(tr("provenance.scenario_builder_model_prefix", model=edited_path))
        self.preview_status.setText("")
        self.edit_status.setText(tr("provenance.status_weights_applied", count=len(edits)))

    def _on_edit_nodes(self) -> None:
        """Open the NetWeaver ignore-flag tree; on accept, apply to a new model file."""
        model_path = str((self.working_task.parameters or {}).get("model_file", "") or "")
        if not model_path or not os.path.isfile(model_path):
            QMessageBox.information(self, tr("provenance.mbox_edit_ignore"), tr("provenance.mbox_no_nw_model"))
            return
        try:
            with busy_progress(self, tr("provenance.busy_reading_nodes"), disable=[self.btn_edit_nodes]):
                data = self._nodes_provider(model_path)
        except Exception as exc:
            QMessageBox.warning(self, tr("provenance.mbox_edit_ignore"),
                                tr("provenance.mbox_read_nodes_failed", error=exc))
            return
        editor = _NetWeaverIgnoreEditorDialog(data, parent=self)
        if not editor.exec():
            return
        to_ignore, to_clear = editor.get_changes()
        if not to_ignore and not to_clear:
            self.edit_status.setText(tr("provenance.status_no_ignore_changes"))
            return
        try:
            with busy_progress(self, tr("provenance.busy_applying_ignore"), disable=[self.btn_edit_nodes]):
                edited_path = self._nodes_applier(model_path, to_ignore, to_clear)
        except Exception as exc:
            QMessageBox.warning(self, tr("provenance.mbox_edit_ignore"),
                                tr("provenance.mbox_apply_ignore_failed", error=exc))
            return
        self.working_task.parameters = dict(self.working_task.parameters or {})
        self.working_task.parameters["model_file"] = edited_path
        self.model_label.setText(tr("provenance.scenario_builder_model_prefix", model=edited_path))
        self.preview_status.setText("")
        self.edit_status.setText(
            tr("provenance.status_ignore_applied", count=len(to_ignore) + len(to_clear))
        )

    def _on_preview(self) -> None:
        if self._preview_runner is None:
            return
        self.preview_status.setText(tr("provenance.busy_running_test"))
        ok, message = False, ""
        execution_id, preview_df = None, None
        try:
            with busy_progress(
                self,
                tr("provenance.busy_running_test"),
                disable=[self.btn_preview, self.btn_preview_run],
            ):
                ok, message, execution_id, preview_df = self._preview_runner(self.working_task)
        except Exception as exc:
            ok, message = False, str(exc)
        self.preview_status.setText(("✓ " if ok else "⚠ ") + str(message))
        # Show the interactive results grid AFTER the busy state ends so it opens
        # with a normal cursor (not the wait/hourglass cursor) and isn't blocked
        # behind the modal busy dialog.
        if ok and preview_df is not None:
            if self._compare_viewer is not None:
                try:
                    self._compare_viewer(self.source_task, preview_df)
                except Exception:
                    if self._preview_viewer is not None:
                        self._preview_viewer(execution_id, preview_df)
            elif self._preview_viewer is not None:
                try:
                    self._preview_viewer(execution_id, preview_df)
                except Exception:
                    pass

    def _on_preview_run(self) -> None:
        if self._preview_run_runner is None:
            return
        scenario_name = self.name_edit.text().strip() or self._default_scenario_name
        self.preview_status.setText(tr("provenance.busy_running_preview_run"))
        ok, message = False, ""
        try:
            with busy_progress(
                self,
                tr("provenance.busy_running_preview_run"),
                disable=[self.btn_preview, self.btn_preview_run],
            ):
                result = self._preview_run_runner(self.working_task, scenario_name)
                if len(result) >= 2:
                    ok, message = bool(result[0]), str(result[1])
                if ok and len(result) >= 4 and result[3] is not None and self._compare_viewer is not None:
                    preview_task_id = result[2] if len(result) > 2 else None
                    # Decide by signature whether the viewer accepts the optional
                    # preview_task_id kwarg, rather than catching TypeError — a
                    # broad except would mask a genuine TypeError raised *inside*
                    # the viewer as a harmless signature mismatch.
                    if self._callable_accepts_kwarg(self._compare_viewer, "preview_task_id"):
                        self._compare_viewer(self.source_task, result[3], preview_task_id=preview_task_id)
                    else:
                        self._compare_viewer(self.source_task, result[3])
        except Exception as exc:
            ok, message = False, str(exc)
        self.preview_status.setText(("✓ " if ok else "⚠ ") + str(message))

    @staticmethod
    def _callable_accepts_kwarg(func, name: str) -> bool:
        """Whether ``func`` accepts a keyword argument ``name`` (or **kwargs)."""
        try:
            params = inspect.signature(func).parameters
        except (TypeError, ValueError):
            return False
        return name in params or any(
            p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
        )

    def _on_open_pipeline_builder(self) -> None:
        if self._pipeline_builder_opener is None:
            return
        try:
            # Hand the in-progress working task to the opener so any Step 1
            # edits (swapped model / edited weights / ignore flags) are carried
            # into the Pipeline Builder as a configured scenario node rather than
            # being silently dropped at the handoff.
            self._pipeline_builder_opener(self.working_task)
        except Exception as exc:
            QMessageBox.warning(
                self,
                tr("provenance.scenario_builder_title"),
                tr("provenance.msg_open_pipeline_failed", error=exc),
            )
            return
        # Escalation is a one-way handoff: the working task's edits are now
        # materialized in the Pipeline Builder. Close the Scenario Builder so the
        # user cannot also Fork/Replace here and create a duplicate scenario —
        # the two paths are mutually exclusive.
        self.reject()

    def _on_commit(self) -> None:
        if self.rb_fork_children.isChecked():
            self._action = "fork_children"
        elif self.rb_fork_only.isChecked():
            self._action = "fork_only"
        elif self.rb_replace.isChecked():
            self._action = "replace"
        else:
            QMessageBox.information(self, self.windowTitle(), tr("provenance.msg_choose_action"))
            return
        if self._action in ("fork_children", "fork_only") and not self.name_edit.text().strip():
            QMessageBox.information(self, self.windowTitle(), tr("provenance.msg_enter_name"))
            return
        self.accept()

    def get_result(self) -> Optional[dict]:
        if self._action is None:
            return None
        return {
            "action": self._action,
            "scenario_name": self.name_edit.text().strip(),
            "working_task": self.working_task,
        }


class _CdpWeightEditorDialog(QDialog):
    """Editable grid of a CDP model's criteria weights (plan S3, Path B).

    One row per parent→child weight edge (modeling-space ids). The Weight column
    is editable (0..1); the engine renormalizes a parent's children to sum to 1
    when the edits are applied, so the entered numbers are *targets*, not the
    guaranteed final values when several siblings are edited at once.
    """

    _WEIGHT_COL = 2

    def __init__(self, data: dict, parent=None):
        super().__init__(parent)
        self.setWindowTitle(tr("provenance.edit_weights_title", name=data.get("name", "CDP model")))
        self.resize(560, 480)
        self._data = data
        self._edges = list(data.get("weights", []) or [])
        self._originals: dict = {}
        self._build()

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

        intro = QLabel(tr("provenance.edit_weights_intro"))
        intro.setWordWrap(True)
        layout.addWidget(intro)

        # Fidelity guard: warn when the model uses features the engine-dialect export
        # does not preserve, so an edited+saved copy could score differently.
        if self._data.get("has_custom_value_functions") or self._data.get("has_uncertainty"):
            lost = []
            if self._data.get("has_custom_value_functions"):
                lost.append(tr("provenance.feature_value_functions"))
            if self._data.get("has_uncertainty"):
                lost.append(tr("provenance.feature_uncertainty"))
            features = tr("provenance.list_and").join(lost)
            warn = QLabel(tr("provenance.edit_weights_warn", features=features))
            warn.setWordWrap(True)
            warn.setStyleSheet(
                "color: #92400e; background-color: #fef3c7; padding: 6px; border-radius: 4px;"
            )
            layout.addWidget(warn)

        self.table = QTableWidget(len(self._edges), 3)
        self.table.setHorizontalHeaderLabels([
            tr("provenance.col_criterion"), tr("provenance.col_parent"), tr("provenance.col_weight"),
        ])
        self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.table.verticalHeader().setVisible(False)
        header = self.table.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)

        for row, edge in enumerate(self._edges):
            child = QTableWidgetItem(str(edge.get("child_name", "")))
            child.setFlags(child.flags() & ~Qt.ItemFlag.ItemIsEditable)
            parent = QTableWidgetItem(str(edge.get("parent_name", "")))
            parent.setFlags(parent.flags() & ~Qt.ItemFlag.ItemIsEditable)
            weight_value = float(edge.get("pvector", 0.0) or 0.0)
            self._originals[row] = weight_value
            weight = QTableWidgetItem(f"{weight_value:.4f}")
            weight.setFlags(weight.flags() | Qt.ItemFlag.ItemIsEditable)
            self.table.setItem(row, 0, child)
            self.table.setItem(row, 1, parent)
            self.table.setItem(row, self._WEIGHT_COL, weight)
        layout.addWidget(self.table)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.accepted.connect(self._on_accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def _invalid_rows(self) -> list:
        """Names of rows whose Weight cell isn't a parseable number (non-empty)."""
        bad = []
        for row, edge in enumerate(self._edges):
            item = self.table.item(row, self._WEIGHT_COL)
            text = (item.text() if item is not None else "").strip()
            if not text:
                continue
            try:
                float(text)
            except (TypeError, ValueError):
                bad.append(str(edge.get("child_name", f"row {row + 1}")))
        return bad

    def _on_accept(self) -> None:
        # Validate before accepting so a typo is reported, never silently dropped.
        bad = self._invalid_rows()
        if bad:
            QMessageBox.warning(
                self,
                tr("provenance.mbox_edit_weights"),
                tr("provenance.mbox_invalid_weights", rows="\n".join(f"  • {b}" for b in bad)),
            )
            return
        self.accept()

    def get_edits(self) -> list:
        """Changed edges as [{parent_id, child_id, weight}], clamped to 0..1.

        Cells are validated in _on_accept, so by here every non-empty Weight cell
        parses; unparseable/empty cells are skipped defensively.
        """
        edits = []
        for row, edge in enumerate(self._edges):
            item = self.table.item(row, self._WEIGHT_COL)
            if item is None:
                continue
            try:
                new_value = float(item.text())
            except (TypeError, ValueError):
                continue
            new_value = max(0.0, min(1.0, new_value))
            if abs(new_value - self._originals.get(row, new_value)) < 1e-9:
                continue
            edits.append({
                "parent_id": int(edge.get("parent_id")),
                "child_id": int(edge.get("child_id")),
                "weight": new_value,
            })
        return edits


class _NetWeaverIgnoreEditorDialog(QDialog):
    """Checkbox grid of a NetWeaver model's nodes and their persistent-ignore state.

    One row per toggleable node (goal/calc/var), grouped by type. Checking the
    Ignore box sets ``PersistentIgnore`` (the serialized flag); the engine
    excludes ignored nodes when the model runs. Identity is by node name.
    """

    _IGNORE_COL = 2

    def __init__(self, data: dict, parent=None):
        super().__init__(parent)
        self.setWindowTitle(tr("provenance.edit_ignore_title", name=data.get("name", "NetWeaver model")))
        self.resize(560, 520)
        self._nodes = list(data.get("nodes", []) or [])
        self._originals: dict = {}
        self._build()

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

        intro = QLabel(tr("provenance.edit_ignore_intro"))
        intro.setWordWrap(True)
        layout.addWidget(intro)

        self.table = QTableWidget(len(self._nodes), 3)
        self.table.setHorizontalHeaderLabels([
            tr("provenance.col_node"), tr("provenance.col_type"), tr("provenance.col_ignore"),
        ])
        self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.table.verticalHeader().setVisible(False)
        header = self.table.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)

        for row, node in enumerate(self._nodes):
            name = QTableWidgetItem(str(node.get("name", "")))
            name.setFlags(name.flags() & ~Qt.ItemFlag.ItemIsEditable)
            kind = QTableWidgetItem(str(node.get("type", "")))
            kind.setFlags(kind.flags() & ~Qt.ItemFlag.ItemIsEditable)
            ignored = bool(node.get("persistent_ignore", False))
            self._originals[row] = ignored
            check = QTableWidgetItem()
            check.setFlags((check.flags() | Qt.ItemFlag.ItemIsUserCheckable) & ~Qt.ItemFlag.ItemIsEditable)
            check.setCheckState(Qt.CheckState.Checked if ignored else Qt.CheckState.Unchecked)
            self.table.setItem(row, 0, name)
            self.table.setItem(row, 1, kind)
            self.table.setItem(row, self._IGNORE_COL, check)
        layout.addWidget(self.table)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def get_changes(self) -> tuple:
        """Return (to_ignore, to_clear): node names whose checkbox changed."""
        to_ignore, to_clear = [], []
        for row, node in enumerate(self._nodes):
            item = self.table.item(row, self._IGNORE_COL)
            if item is None:
                continue
            now = item.checkState() == Qt.CheckState.Checked
            if now == self._originals.get(row, now):
                continue
            name = str(node.get("name", ""))
            (to_ignore if now else to_clear).append(name)
        return to_ignore, to_clear


# Backward-compatible alias for transitional imports.
ScenarioSandboxDialog = ScenarioBuilderDialog
