"""
CDP results hotlink viewer for the QGIS plugin.
"""

from __future__ import annotations

from typing import Any, Callable, Dict, List, Optional, Sequence

import pandas as pd

from . import debug_logger
from .bridge_loader import CDPBridge
from .qt_compat import (
    QAbstractItemView,
    QBrush,
    QColor,
    QComboBox,
    QDialog,
    QGraphicsRectItem,
    QGraphicsScene,
    QGraphicsSimpleTextItem,
    QGraphicsView,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QMessageBox,
    QPainter,
    QPainterPath,
    QPen,
    QPushButton,
    QSplitter,
    QTabWidget,
    QTableView,
    QTextEdit,
    QTreeWidget,
    QTreeWidgetItem,
    QVBoxLayout,
    QWidget,
    Qt,
    exec_dialog,
)
from .result_export_helpers import (
    TabExportSpec,
    export_dataframe_to_csv,
    export_dataframes_to_xlsx,
    export_widget_as_png,
    print_table_view,
)
from .results_grid_dialog import ResultsFilterProxyModel
from .results_grid_model import DataFrameTableModel
from .translator import tr


class CDPModelNodeItem(QGraphicsRectItem):
    """Clickable graphics item representing a single CDP model node."""

    def __init__(
        self,
        x: float,
        y: float,
        width: float,
        height: float,
        *,
        node_id: str,
        label: str,
        subtitle: str,
        on_selected: Callable[[str], None],
        lowest_criterion: bool = False,
        fill_color: str = "",
        selected_fill_color: str = "",
        border_color: str = "#9ca3af",
        selected_border_color: str = "#2563eb",
        parent=None,
    ):
        super().__init__(0.0, 0.0, width, height, parent)
        self.node_id = node_id
        self._on_selected = on_selected
        base_fill = fill_color or ("#fef3c7" if lowest_criterion else "#eff6ff")
        base_selected_fill = selected_fill_color or ("#fde68a" if lowest_criterion else "#bfdbfe")
        self._default_brush = QBrush(QColor(base_fill))
        self._selected_brush = QBrush(QColor(base_selected_fill))
        self._default_pen = QPen(QColor(border_color))
        self._selected_pen = QPen(QColor(selected_border_color))
        self._selected_pen.setWidth(2)

        self.setPos(x, y)
        self.setBrush(self._default_brush)
        self.setPen(self._default_pen)

        self.label_item = QGraphicsSimpleTextItem(label, self)
        self.label_item.setBrush(QBrush(QColor("#111827")))
        self.label_item.setPos(8, 6)

        self.subtitle_item = QGraphicsSimpleTextItem(subtitle, self)
        self.subtitle_item.setBrush(QBrush(QColor("#6b7280")))
        self.subtitle_item.setPos(8, max(20, int(height - 18)))

    def set_selected_state(self, is_selected: bool) -> None:
        self.setBrush(self._selected_brush if is_selected else self._default_brush)
        self.setPen(self._selected_pen if is_selected else self._default_pen)

    def mousePressEvent(self, event) -> None:  # noqa: N802
        if callable(self._on_selected):
            self._on_selected(self.node_id)
        super().mousePressEvent(event)


class HorizontalBarChartWidget(QWidget):
    """Lightweight horizontal bar chart for viewer tabs."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self._labels: List[str] = []
        self._values: List[float] = []
        self._title = ""
        self._value_label = ""
        self._empty_message = "No chart data available."
        self.setMinimumHeight(260)

    def set_chart_data(
        self,
        labels: Sequence[str],
        values: Sequence[float],
        *,
        title: str = "",
        value_label: str = "",
        empty_message: str = "No chart data available.",
    ) -> None:
        paired = []
        for label, value in zip(labels, values):
            try:
                numeric_value = float(value)
            except Exception:
                continue
            paired.append((str(label), numeric_value))

        self._labels = [label for label, _ in paired]
        self._values = [value for _, value in paired]
        self._title = title
        self._value_label = value_label
        self._empty_message = empty_message
        self.update()

    def paintEvent(self, _event) -> None:  # noqa: N802
        painter = QPainter(self)
        render_hint = getattr(QPainter, "Antialiasing", None)
        if render_hint is None and hasattr(QPainter, "RenderHint"):
            render_hint = getattr(QPainter.RenderHint, "Antialiasing", None)
        if render_hint is not None:
            painter.setRenderHint(render_hint)
        rect = self.rect().adjusted(12, 12, -12, -12)

        painter.fillRect(rect, QBrush(QColor("#fbfbfc")))
        painter.setPen(QPen(QColor("#d1d5db")))
        painter.drawRect(rect)

        title_height = 26
        if self._title:
            painter.setPen(QPen(QColor("#111827")))
            painter.drawText(rect.adjusted(10, 8, -10, 0), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop, self._title)

        if not self._labels or not self._values:
            painter.setPen(QPen(QColor("#6b7280")))
            painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self._empty_message)
            return

        chart_rect = rect.adjusted(12, title_height + 8, -12, -12)
        row_count = len(self._labels)
        if row_count <= 0 or chart_rect.height() <= 20:
            return

        max_label_chars = max(len(label) for label in self._labels)
        label_width = min(max(110, max_label_chars * 7), 220)
        value_width = 72
        bar_left = chart_rect.left() + label_width + 12
        bar_right = chart_rect.right() - value_width - 8
        if bar_right <= bar_left:
            return

        max_value = max(abs(value) for value in self._values) or 1.0
        row_height = max(22, chart_rect.height() // max(row_count, 1))
        bar_height = max(10, row_height - 10)

        for idx, (label, value) in enumerate(zip(self._labels, self._values)):
            y = chart_rect.top() + idx * row_height
            if y + bar_height > chart_rect.bottom():
                break

            painter.setPen(QPen(QColor("#374151")))
            painter.drawText(
                chart_rect.left(),
                y,
                label_width,
                bar_height,
                Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
                label,
            )

            painter.setPen(QPen(QColor("#e5e7eb")))
            painter.drawLine(bar_left, y + bar_height, bar_right, y + bar_height)

            bar_width = int((abs(value) / max_value) * max(1, bar_right - bar_left))
            bar_color = QColor("#2563eb" if value >= 0 else "#b91c1c")
            painter.fillRect(bar_left, y + 2, bar_width, max(6, bar_height - 4), QBrush(bar_color))

            painter.setPen(QPen(QColor("#111827")))
            painter.drawText(
                bar_right + 4,
                y,
                value_width,
                bar_height,
                Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
                f"{value:.4f}",
            )

        if self._value_label:
            painter.setPen(QPen(QColor("#6b7280")))
            painter.drawText(
                rect.adjusted(10, 0, -10, -6),
                Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom,
                self._value_label,
            )


class CDPResultsDialog(QDialog):
    """Dedicated viewer for CDP workflow results."""

    @classmethod
    def from_payload(cls, payload: Dict[str, Any], parent=None) -> "CDPResultsDialog":
        return cls(
            workflow_name=str(payload.get("workflow_name", "")).strip(),
            execution_id=str(payload.get("execution_id", "")).strip(),
            results_df=payload.get("results_df"),
            summary=payload.get("summary") or {},
            model_info=payload.get("model_info") or {},
            model_path=str(payload.get("model_path", "")).strip(),
            display_field=str(
                (payload.get("resolved_display_field") or {}).get("resolved_name")
                or payload.get("display_field", "")
            ).strip(),
            primary_key=str(
                (payload.get("resolved_primary_key") or {}).get("resolved_name")
                or payload.get("primary_key", "")
            ).strip(),
            field_mappings=list(payload.get("field_mappings", []) or []),
            payload=payload,
            parent=parent,
        )

    def __init__(
        self,
        workflow_name: str,
        execution_id: str,
        results_df: Optional[pd.DataFrame],
        summary: Dict[str, Any],
        model_info: Dict[str, Any],
        model_path: str = "",
        display_field: str = "",
        primary_key: str = "",
        field_mappings: Optional[List[Dict[str, Any]]] = None,
        payload: Optional[Dict[str, Any]] = None,
        parent=None,
    ):
        super().__init__(parent)
        self.workflow_name = workflow_name
        self.execution_id = execution_id
        self.payload = payload or {}
        self.summary = summary or {}
        self.model_info = model_info or {}
        self.model_path = model_path
        self.field_mappings = field_mappings or []

        self.results_df = self._coerce_dataframe(results_df)
        self.calculations_df = self._build_calculations_frame(self.results_df)
        self.display_field = display_field or self._resolve_first_column(["DisplayName", "display_name", "name", "Name"])
        self.primary_key = primary_key or self._resolve_first_column(["ogc_fid", "fid", "FID", "RowID", "OBJECTID", "id", "ID"])
        self.row_key_field = str(self.payload.get("row_key_field", self.primary_key)).strip()
        self.score_column = self._resolve_first_column(["PriorityScores", "priorityscores", "cdp_overall_score", "overall_score", "score"])
        self.rank_column = self._resolve_first_column(["cdp_rank", "rank", "Rank", "overall_rank"])
        self.cdp_engine_path = str(self.payload.get("cdp_engine_path", "")).strip()
        self.database_path = str(self.payload.get("database_path", "")).strip()
        self.output_table_name = str(self.payload.get("output_table_name", "")).strip()
        self.input_table_name = str(self.payload.get("input_table_name", "")).strip()
        self.model_graph = dict(self.payload.get("model_graph", {}) or {})
        self.model_diagram = dict(self.payload.get("model_diagram", {}) or {})
        self.mapping_rows = list(self.payload.get("mapping_rows", []) or [])
        self.validation_settings = dict(self.payload.get("validation_settings", {}) or {})
        self.lowest_criteria_rows = list(self.payload.get("lowest_criteria_rows", []) or [])
        self.contributions_df = self._coerce_dataframe(self.payload.get("contributions_table"))
        self.sensitivity_df = self._coerce_dataframe(self.payload.get("sensitivity_table"))
        self.tradeoffs_df = self._coerce_dataframe(self.payload.get("tradeoffs_table"))
        self.source_level_options = list(self.payload.get("source_level_options", []) or [])
        self.target_level_options = list(self.payload.get("target_level_options", []) or [])
        self.sensitivity_criterion_options = list(self.payload.get("sensitivity_criterion_options", []) or [])
        self._updating_contribution_selectors = False
        self._model_graph_tree_items: Dict[str, Any] = {}
        self._model_graph_node_items: Dict[str, CDPModelNodeItem] = {}
        self._model_graph_nodes_by_id: Dict[str, Dict[str, Any]] = {}
        self._model_graph_edge_items: List[Any] = []
        self._model_graph_syncing_selection = False
        # Per-tab export registry. Each entry binds a tab to the
        # DataFrame(s) and chart widget that "Print Tab", "Export Tab",
        # and "Export Chart PNG" should target. Populated after the tab
        # widgets are built in _init_ui.
        self._tab_export_specs: List[Dict[str, Any]] = []

        self.bridge = None
        if self.cdp_engine_path and self.model_path:
            try:
                self.bridge = CDPBridge(self.cdp_engine_path)
            except Exception as exc:
                debug_logger.warning("Could not initialize CDP viewer bridge", {"exception": str(exc)})

        self.setWindowTitle(tr("cdp_results.window_title", name=workflow_name))
        self.resize(1380, 860)

        self._init_ui()
        self._populate_selector_options()
        self._refresh_workbench_details()
        self._refresh_prioritization_view()
        self._refresh_contributions_view()
        self._refresh_sensitivity_view()
        self._refresh_tradeoffs_view()

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

        header = QLabel(
            f"Pipeline: {self.workflow_name}    "
            f"Execution: {self.execution_id}    "
            f"Rows: {len(self.results_df.index)}    "
            f"Columns: {len(self.results_df.columns)}"
        )
        header.setWordWrap(True)
        layout.addWidget(header)

        self.tabs = QTabWidget()
        layout.addWidget(self.tabs, 1)

        # Tab order: power surfaces first (Prioritization is the legacy WPF
        # landing tab equivalent), reference/debug surfaces last.
        self._build_prioritization_tab()
        self._build_calculations_tab()
        self._build_contributions_tab()
        self._build_sensitivity_tab()
        self._build_tradeoffs_tab()
        self._build_model_graph_tab()
        self._build_criteria_levels_tab()
        self._build_workbench_tab()
        self._build_model_context_tab()

        # Land on Prioritization by default (matches old PaIndividualDisplay).
        self.tabs.setCurrentIndex(0)

        # Snapshot which DataFrame/chart belongs to each tab so the
        # bottom-row toolbar can hand them off to the export helpers.
        self._register_tab_export_specs()

        button_row = QHBoxLayout()

        self.btn_print_tab = QPushButton(tr("cdp_results.btn_print"))
        self.btn_print_tab.setToolTip(tr("cdp_results.tip_print"))
        self.btn_print_tab.clicked.connect(self._on_print_current_tab)
        button_row.addWidget(self.btn_print_tab)

        self.btn_export_tab_csv = QPushButton(tr("cdp_results.btn_export_csv"))
        self.btn_export_tab_csv.setToolTip(tr("cdp_results.tip_export_csv"))
        self.btn_export_tab_csv.clicked.connect(self._on_export_current_tab_csv)
        button_row.addWidget(self.btn_export_tab_csv)

        self.btn_export_chart_png = QPushButton(tr("cdp_results.btn_export_chart"))
        self.btn_export_chart_png.setToolTip(tr("cdp_results.tip_export_chart"))
        self.btn_export_chart_png.clicked.connect(self._on_export_current_chart_png)
        button_row.addWidget(self.btn_export_chart_png)

        self.btn_export_all_xlsx = QPushButton(tr("cdp_results.btn_export_xlsx"))
        self.btn_export_all_xlsx.setToolTip(tr("lpa_results.tip_export_xlsx"))
        self.btn_export_all_xlsx.clicked.connect(self._on_export_all_tabs_xlsx)
        button_row.addWidget(self.btn_export_all_xlsx)

        self.btn_add_calc_field = QPushButton(tr("cdp_results.btn_add_calc"))
        self.btn_add_calc_field.setToolTip(tr("lpa_results.tip_calc_enabled"))
        self.btn_add_calc_field.clicked.connect(self._on_add_calculated_field)
        button_row.addWidget(self.btn_add_calc_field)

        button_row.addStretch()
        close_button = QPushButton(tr("cdp_results.btn_close"))
        close_button.clicked.connect(self.close)
        button_row.addWidget(close_button)
        layout.addLayout(button_row)

        # Refresh button availability whenever the user switches tabs.
        self.tabs.currentChanged.connect(self._refresh_export_buttons_for_current_tab)
        self._refresh_export_buttons_for_current_tab()

    # ------------------------------------------------------------------
    # Per-tab print / export
    # ------------------------------------------------------------------
    def _register_tab_export_specs(self) -> None:
        """Pair each tab with the DataFrame(s) and chart widget it owns."""
        specs: List[Dict[str, Any]] = []

        def _register(name: str, *, bundle=None, dataframe_attr=None, chart=None) -> None:
            spec: Dict[str, Any] = {"name": name}
            if bundle is not None:
                spec["bundle"] = bundle
            if dataframe_attr is not None:
                spec["dataframe_attr"] = dataframe_attr
            if chart is not None:
                spec["chart"] = chart
            specs.append(spec)

        # Match the order used in _init_ui so list indices align with tab indices.
        _register("Prioritization", bundle=getattr(self, "prioritization_bundle", None),
                  chart=getattr(self, "prioritization_chart", None))
        _register("Calculations", bundle=getattr(self, "calculations_bundle", None),
                  chart=getattr(self, "calculations_chart", None))
        _register("Contributions", bundle=getattr(self, "contributions_bundle", None),
                  chart=getattr(self, "contributions_chart", None))
        _register("Sensitivity", bundle=getattr(self, "sensitivity_bundle", None))
        _register("Tradeoffs", bundle=getattr(self, "tradeoffs_bundle", None))
        _register("Model Graph")  # tree/graph view, no DataFrame to export directly
        _register("Criteria-Levels", bundle=getattr(self, "criteria_bundle", None))
        _register("Workbench", bundle=getattr(self, "workbench_bundle", None))
        _register("Model Context")  # text-only tab

        self._tab_export_specs = specs

    def _current_tab_spec(self) -> Optional[Dict[str, Any]]:
        index = self.tabs.currentIndex()
        if 0 <= index < len(self._tab_export_specs):
            return self._tab_export_specs[index]
        return None

    def _spec_dataframe(self, spec: Dict[str, Any]) -> Optional[pd.DataFrame]:
        """Resolve the DataFrame currently shown in *spec*'s table."""
        if spec is None:
            return None
        bundle = spec.get("bundle")
        if bundle is not None and "model" in bundle:
            try:
                return bundle["model"].dataframe()
            except Exception:
                return None
        attr = spec.get("dataframe_attr")
        if attr:
            df = getattr(self, attr, None)
            if isinstance(df, pd.DataFrame):
                return df
        return None

    def _refresh_export_buttons_for_current_tab(self, *_args) -> None:
        spec = self._current_tab_spec()
        has_table = bool(spec and spec.get("bundle"))
        has_chart = bool(spec and spec.get("chart"))
        if hasattr(self, "btn_print_tab"):
            self.btn_print_tab.setEnabled(has_table)
        if hasattr(self, "btn_export_tab_csv"):
            self.btn_export_tab_csv.setEnabled(has_table)
        if hasattr(self, "btn_export_chart_png"):
            self.btn_export_chart_png.setEnabled(has_chart)
        if hasattr(self, "btn_add_calc_field"):
            can_add = bool(self.database_path and self.output_table_name)
            self.btn_add_calc_field.setEnabled(can_add)
            if can_add:
                self.btn_add_calc_field.setToolTip(
                    "Add a calculated column to the persisted CDP GeoPackage table."
                )
            else:
                self.btn_add_calc_field.setToolTip(
                    "Add Calculated Field requires a GeoPackage-backed CDP result."
                )

    def _default_export_basename(self) -> str:
        name_part = self.workflow_name.strip() or "cdp"
        execution_part = (self.execution_id or "").strip()[:8]
        if execution_part:
            return f"{name_part}-{execution_part}".replace(" ", "_")
        return name_part.replace(" ", "_")

    def _on_print_current_tab(self) -> None:
        spec = self._current_tab_spec()
        if spec is None or not spec.get("bundle"):
            QMessageBox.information(self, tr("cdp_results.msg_print_tab_title"), tr("cdp_results.msg_no_print_table"))
            return
        view = spec["bundle"].get("view")
        title = f"{self.workflow_name} — {spec.get('name', 'Tab')}"
        print_table_view(view, title=title, parent=self)

    def _on_export_current_tab_csv(self) -> None:
        spec = self._current_tab_spec()
        if spec is None or not spec.get("bundle"):
            QMessageBox.information(self, tr("cdp_results.msg_export_tab_csv_title"), tr("cdp_results.msg_no_export_table"))
            return
        df = self._spec_dataframe(spec)
        default_name = f"{self._default_export_basename()}-{spec.get('name', 'tab').lower()}.csv"
        export_dataframe_to_csv(df if df is not None else pd.DataFrame(), default_name=default_name, parent=self)

    def _on_export_current_chart_png(self) -> None:
        spec = self._current_tab_spec()
        chart = spec.get("chart") if spec else None
        if chart is None:
            QMessageBox.information(self, tr("cdp_results.msg_export_chart_png_title"), tr("cdp_results.msg_no_chart"))
            return
        default_name = f"{self._default_export_basename()}-{spec.get('name', 'chart').lower()}.png"
        export_widget_as_png(chart, default_name=default_name, parent=self)

    def _on_export_all_tabs_xlsx(self) -> None:
        sheets: List[TabExportSpec] = []
        for spec in self._tab_export_specs:
            df = self._spec_dataframe(spec)
            if df is None or df.empty:
                continue
            sheets.append(TabExportSpec(name=spec.get("name", "Tab"), dataframe=df))
        default_name = f"{self._default_export_basename()}-all-tabs.xlsx"
        export_dataframes_to_xlsx(sheets, default_name=default_name, parent=self)

    def _on_add_calculated_field(self) -> None:
        """Materialize a user-defined QGIS expression into the CDP result table."""
        if not (self.database_path and self.output_table_name):
            QMessageBox.information(
                self,
                "Add Calculated Field",
                "This CDP view is not backed by a GeoPackage result table, so "
                "calculated fields cannot be saved.",
            )
            return

        try:
            from qgis.core import QgsVectorLayer
        except Exception:
            QMessageBox.critical(self, tr("cdp_results.calc_title"), tr("cdp_results.calc_no_qgis"))
            return

        uri = f"{self.database_path}|layername={self.output_table_name}"
        layer = QgsVectorLayer(uri, self.output_table_name, "ogr")
        if not layer.isValid():
            QMessageBox.critical(
                self,
                "Add Calculated Field",
                f"Could not open the result table at:\n{self.database_path}\n"
                f"layer: {self.output_table_name}",
            )
            return

        from .calculated_fields import CalculatedFieldError, materialize_calculated_field
        from .dialogs.calculated_field_dialog import CalculatedFieldDialog

        dialog = CalculatedFieldDialog(
            layer=layer,
            existing_field_names=list(self.results_df.columns),
            parent=self,
        )
        if exec_dialog(dialog) != QDialog.DialogCode.Accepted:
            return

        spec = dialog.spec()
        try:
            outcome = materialize_calculated_field(
                layer=layer,
                field_name=spec.name,
                qvariant_type=spec.qvariant_type,
                expression_text=spec.expression,
            )
        except CalculatedFieldError as exc:
            QMessageBox.critical(self, tr("cdp_results.calc_title"), str(exc))
            return
        except Exception as exc:  # noqa: BLE001
            QMessageBox.critical(
                self,
                "Add Calculated Field",
                f"An unexpected error occurred while adding the column:\n{exc}",
            )
            return

        self.results_df = outcome.dataframe.fillna("")
        self.calculations_df = self._build_calculations_frame(self.results_df)
        self.display_field = self.display_field or self._resolve_first_column(
            ["DisplayName", "display_name", "name", "Name"]
        )
        self.primary_key = self.primary_key or self._resolve_first_column(
            ["ogc_fid", "fid", "FID", "RowID", "OBJECTID", "id", "ID"]
        )
        self.score_column = self._resolve_first_column(
            ["PriorityScores", "priorityscores", "cdp_overall_score", "overall_score", "score"]
        )
        self.rank_column = self._resolve_first_column(["cdp_rank", "rank", "Rank", "overall_rank"])
        self._refresh_after_calculated_field()

        message = f"Added column '{outcome.field_name}' to {self.output_table_name}."
        if outcome.skipped_rows > 0:
            message += (
                f"\n\nNote: {outcome.skipped_rows} row(s) had expression evaluation "
                "errors and received NULL values."
            )
        QMessageBox.information(self, tr("cdp_results.calc_title"), message)

    def _build_workbench_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

        summary_text = QTextEdit()
        summary_text.setReadOnly(True)
        summary_text.setMaximumHeight(190)
        summary_text.setPlainText(self._format_execution_context())
        layout.addWidget(summary_text)

        search_row = QHBoxLayout()
        search_row.addWidget(QLabel(tr("cdp_results.lbl_search")))
        self.workbench_search = QLineEdit()
        self.workbench_search.setPlaceholderText(tr("cdp_results.search_placeholder"))
        search_row.addWidget(self.workbench_search, 1)
        btn_clear = QPushButton(tr("cdp_results.btn_clear"))
        btn_clear.clicked.connect(self.workbench_search.clear)
        search_row.addWidget(btn_clear)
        layout.addLayout(search_row)

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

        self.workbench_bundle = self._create_table_bundle(self.results_df, on_selection_changed=self._refresh_workbench_details)
        self.workbench_search.textChanged.connect(self.workbench_bundle["proxy"].set_filter_text)
        splitter.addWidget(self.workbench_bundle["view"])

        right_host = QWidget(self)
        right_layout = QVBoxLayout()
        right_host.setLayout(right_layout)
        splitter.addWidget(right_host)

        self.workbench_status = QLabel(tr("cdp_results.workbench_status_default"))
        self.workbench_status.setWordWrap(True)
        right_layout.addWidget(self.workbench_status)

        self.workbench_details = QTextEdit()
        self.workbench_details.setReadOnly(True)
        right_layout.addWidget(self.workbench_details, 1)
        splitter.setStretchFactor(0, 2)
        splitter.setStretchFactor(1, 1)

        self.tabs.addTab(tab, tr("cdp_results.tab_workbench"))
        self._select_first_row(self.workbench_bundle)

    def _build_model_graph_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

        self.model_graph_summary = QLabel(self._format_model_graph_summary())
        self.model_graph_summary.setWordWrap(True)
        layout.addWidget(self.model_graph_summary)

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

        self.model_graph_tree = QTreeWidget()
        self.model_graph_tree.setColumnCount(3)
        self.model_graph_tree.setHeaderLabels(["Node", "Level", "HBlockID"])
        self.model_graph_tree.currentItemChanged.connect(self._on_model_graph_selection_changed)
        splitter.addWidget(self.model_graph_tree)

        right_host = QWidget(self)
        right_layout = QVBoxLayout()
        right_host.setLayout(right_layout)
        splitter.addWidget(right_host)

        self.model_graph_status = QLabel(
            "Click a node in the tree or on the graph to inspect it. "
            "The diagram preserves CDP level bands and vertical ordering when available."
        )
        self.model_graph_status.setWordWrap(True)
        right_layout.addWidget(self.model_graph_status)

        self.model_graph_scene = QGraphicsScene(self)
        self.model_graph_view = QGraphicsView(self)
        self.model_graph_view.setScene(self.model_graph_scene)
        right_layout.addWidget(self.model_graph_view, 2)

        self.model_graph_details = QTextEdit()
        self.model_graph_details.setReadOnly(True)
        right_layout.addWidget(self.model_graph_details, 1)

        splitter.setStretchFactor(0, 1)
        splitter.setStretchFactor(1, 1)

        self.tabs.addTab(tab, tr("cdp_results.tab_cdp_model_view"))
        self._populate_model_graph_tree()
        self._populate_model_graph_scene()

    def _build_calculations_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

        summary_text = QTextEdit()
        summary_text.setReadOnly(True)
        summary_text.setMaximumHeight(140)
        summary_text.setPlainText(self._format_calculations_summary())
        layout.addWidget(summary_text)

        # User-adjustable splitter between calculation rows, numeric summary,
        # and the priorities bar chart (matches the legacy WPF GridSplitter).
        v_splitter = QSplitter(Qt.Orientation.Vertical)
        layout.addWidget(v_splitter, 1)

        rows_host = QWidget(self)
        rows_layout = QVBoxLayout()
        rows_layout.setContentsMargins(0, 0, 0, 0)
        rows_host.setLayout(rows_layout)
        rows_layout.addWidget(QLabel(tr("cdp_results.lbl_calculation_rows")))
        self.calculations_bundle = self._create_table_bundle(self.calculations_df)
        rows_layout.addWidget(self.calculations_bundle["view"], 1)
        v_splitter.addWidget(rows_host)

        stats_host = QWidget(self)
        stats_layout = QVBoxLayout()
        stats_layout.setContentsMargins(0, 0, 0, 0)
        stats_host.setLayout(stats_layout)
        stats_layout.addWidget(QLabel(tr("cdp_results.lbl_numeric_summary")))
        stats_df = self._build_numeric_summary_frame(self.calculations_df)
        self.calculations_stats_bundle = self._create_table_bundle(stats_df)
        stats_layout.addWidget(self.calculations_stats_bundle["view"], 1)
        v_splitter.addWidget(stats_host)

        chart_host = QWidget(self)
        chart_layout = QVBoxLayout()
        chart_layout.setContentsMargins(0, 0, 0, 0)
        chart_host.setLayout(chart_layout)
        chart_layout.addWidget(QLabel(tr("cdp_results.lbl_priority_chart")))
        self.calculations_chart = HorizontalBarChartWidget(self)
        self.calculations_chart.setMinimumHeight(200)
        chart_layout.addWidget(self.calculations_chart)
        v_splitter.addWidget(chart_host)

        v_splitter.setStretchFactor(0, 2)
        v_splitter.setStretchFactor(1, 1)
        v_splitter.setStretchFactor(2, 1)
        v_splitter.setSizes([320, 180, 200])

        self._refresh_calculations_chart()
        self.tabs.addTab(tab, tr("cdp_results.tab_calculations"))

    def _refresh_calculations_chart(self) -> None:
        """Render a horizontal bar chart of priority scores across calculation rows."""
        chart = getattr(self, "calculations_chart", None)
        if chart is None:
            return
        df = self.calculations_df
        if df is None or df.empty:
            chart.set_chart_data([], [], title="Priority Scores", empty_message="No calculation data.")
            return

        score_col = self.score_column
        if not score_col or score_col not in df.columns:
            for candidate in ("PriorityScores", "priorityscores", "cdp_overall_score", "overall_score", "score"):
                if candidate in df.columns:
                    score_col = candidate
                    break
        if not score_col or score_col not in df.columns:
            chart.set_chart_data([], [], title="Priority Scores", empty_message="No priority score column found.")
            return

        label_col = self.display_field if self.display_field in df.columns else None
        if label_col is None:
            for candidate in ("DisplayName", "display_name", "name", "Name", self.primary_key):
                if candidate and candidate in df.columns:
                    label_col = candidate
                    break

        try:
            scored = df.copy()
            scored["_score"] = pd.to_numeric(scored[score_col], errors="coerce")
            scored = scored.dropna(subset=["_score"]).sort_values("_score", ascending=False).head(20)
        except Exception:
            chart.set_chart_data([], [], title="Priority Scores", empty_message="Could not chart scores.")
            return

        labels: List[str] = []
        for _, row in scored.iterrows():
            if label_col and label_col in scored.columns:
                labels.append(str(row.get(label_col, "")))
            else:
                labels.append(str(row.name))
        values = [float(v) for v in scored["_score"].tolist()]
        chart.set_chart_data(
            labels,
            values,
            title="Top 20 priority scores",
            value_label=score_col,
            empty_message="No priority score data.",
        )

    def _build_prioritization_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

        top_row = QHBoxLayout()
        top_row.addWidget(QLabel(tr("cdp_results.lbl_chart_scope")))
        self.prioritization_scope = QComboBox()
        self.prioritization_scope.addItems(["Top 20", "All Rows"])
        self.prioritization_scope.currentIndexChanged.connect(self._refresh_prioritization_chart)
        top_row.addWidget(self.prioritization_scope)
        top_row.addStretch()
        layout.addLayout(top_row)

        search_row = QHBoxLayout()
        search_row.addWidget(QLabel(tr("cdp_results.lbl_grid_filter")))
        self.prioritization_search = QLineEdit()
        self.prioritization_search.setPlaceholderText(tr("cdp_results.prio_search_placeholder"))
        search_row.addWidget(self.prioritization_search, 1)
        btn_clear = QPushButton(tr("cdp_results.btn_clear"))
        btn_clear.clicked.connect(self.prioritization_search.clear)
        search_row.addWidget(btn_clear)
        layout.addLayout(search_row)

        self.prioritization_info = QLabel("")
        self.prioritization_info.setWordWrap(True)
        layout.addWidget(self.prioritization_info)

        self.prioritization_chart = HorizontalBarChartWidget(self)
        layout.addWidget(self.prioritization_chart)

        self.prioritization_bundle = self._create_table_bundle(pd.DataFrame())
        layout.addWidget(self.prioritization_bundle["view"], 1)
        self._connect_chart_bundle_sync(
            self.prioritization_bundle,
            refresh_callback=self._refresh_prioritization_chart,
            search_box=self.prioritization_search,
        )

        self.tabs.addTab(tab, tr("cdp_results.tab_prioritization"))

    def _build_contributions_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

        # Match the old WPF PaIndividualDisplay layout: grid dominates, chart
        # sits at the bottom in a user-resizable vertical split.
        v_splitter = QSplitter(Qt.Orientation.Vertical)
        layout.addWidget(v_splitter, 1)

        # --- Top pane: selectors + filter + grid ---
        top_host = QWidget(self)
        top_layout = QVBoxLayout()
        top_layout.setContentsMargins(0, 0, 0, 0)
        top_host.setLayout(top_layout)

        selector_row = QHBoxLayout()
        source_label = QLabel(tr("cdp_results.lbl_source_level"))
        source_label.setToolTip(
            "A CDP level is a group of criteria at the same hierarchical depth "
            "in the model. The Source Level filters which originating criteria "
            "appear as rows in the contribution flow below."
        )
        selector_row.addWidget(source_label)
        self.source_level_combo = QComboBox()
        self.source_level_combo.currentIndexChanged.connect(self._on_source_level_changed)
        selector_row.addWidget(self.source_level_combo)

        target_label = QLabel(tr("cdp_results.lbl_target_level"))
        target_label.setToolTip(
            "The Target Level controls which receiving criteria the contribution "
            "flow is computed against. Together, source and target levels define "
            "the criteria-to-feature contribution to display."
        )
        selector_row.addWidget(target_label)
        self.target_level_combo = QComboBox()
        self.target_level_combo.currentIndexChanged.connect(self._on_target_level_changed)
        selector_row.addWidget(self.target_level_combo)
        selector_row.addStretch()
        top_layout.addLayout(selector_row)

        search_row = QHBoxLayout()
        search_row.addWidget(QLabel(tr("cdp_results.lbl_grid_filter")))
        self.contributions_search = QLineEdit()
        self.contributions_search.setPlaceholderText(tr("cdp_results.contrib_search_placeholder"))
        search_row.addWidget(self.contributions_search, 1)
        btn_clear = QPushButton(tr("cdp_results.btn_clear"))
        btn_clear.clicked.connect(self.contributions_search.clear)
        search_row.addWidget(btn_clear)
        top_layout.addLayout(search_row)

        self.contributions_info = QLabel("")
        self.contributions_info.setWordWrap(True)
        top_layout.addWidget(self.contributions_info)

        self.contributions_bundle = self._create_table_bundle(self.contributions_df)
        top_layout.addWidget(self.contributions_bundle["view"], 1)
        self._connect_chart_bundle_sync(
            self.contributions_bundle,
            refresh_callback=self._refresh_contributions_chart,
            search_box=self.contributions_search,
        )

        v_splitter.addWidget(top_host)

        # --- Bottom pane: chart ---
        chart_host = QWidget(self)
        chart_layout = QVBoxLayout()
        chart_layout.setContentsMargins(0, 0, 0, 0)
        chart_host.setLayout(chart_layout)
        self.contributions_chart = HorizontalBarChartWidget(self)
        self.contributions_chart.setMinimumHeight(200)
        chart_layout.addWidget(self.contributions_chart)
        v_splitter.addWidget(chart_host)

        v_splitter.setStretchFactor(0, 3)
        v_splitter.setStretchFactor(1, 1)
        v_splitter.setSizes([520, 220])

        self.tabs.addTab(tab, tr("cdp_results.tab_contributions"))

    def _build_sensitivity_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

        selector_row = QHBoxLayout()
        selector_row.addWidget(QLabel(tr("cdp_results.lbl_criterion")))
        self.sensitivity_combo = QComboBox()
        self.sensitivity_combo.currentIndexChanged.connect(self._reload_sensitivity)
        selector_row.addWidget(self.sensitivity_combo)
        selector_row.addStretch()
        layout.addLayout(selector_row)

        self.sensitivity_info = QLabel("")
        self.sensitivity_info.setWordWrap(True)
        layout.addWidget(self.sensitivity_info)

        self.sensitivity_bundle = self._create_table_bundle(self.sensitivity_df)
        layout.addWidget(self.sensitivity_bundle["view"], 1)

        self.tabs.addTab(tab, tr("cdp_results.tab_sensitivity"))

    def _build_tradeoffs_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

        selector_row = QHBoxLayout()
        selector_row.addWidget(QLabel(tr("cdp_results.lbl_lowest_criteria")))
        self.tradeoffs_combo = QComboBox()
        self.tradeoffs_combo.currentIndexChanged.connect(self._reload_tradeoffs)
        selector_row.addWidget(self.tradeoffs_combo)
        selector_row.addStretch()
        layout.addLayout(selector_row)

        self.tradeoffs_info = QLabel("")
        self.tradeoffs_info.setWordWrap(True)
        layout.addWidget(self.tradeoffs_info)

        self.tradeoffs_bundle = self._create_table_bundle(self.tradeoffs_df)
        layout.addWidget(self.tradeoffs_bundle["view"], 1)

        self.tabs.addTab(tab, tr("cdp_results.tab_tradeoffs"))

    def _build_model_context_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

        text = QTextEdit()
        text.setReadOnly(True)
        text.setPlainText(self._format_model_context())
        layout.addWidget(text, 1)

        self.tabs.addTab(tab, tr("cdp_results.tab_model_context"))

    def _build_criteria_levels_tab(self) -> None:
        tab = QWidget(self)
        layout = QVBoxLayout()
        tab.setLayout(layout)

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

        criteria_host = QWidget(self)
        criteria_layout = QVBoxLayout()
        criteria_host.setLayout(criteria_layout)
        criteria_layout.addWidget(QLabel(tr("cdp_results.lbl_criteria")))
        criteria_df = self._coerce_dataframe(self.model_info.get("Criteria"))
        self.criteria_bundle = self._create_table_bundle(criteria_df)
        criteria_layout.addWidget(self.criteria_bundle["view"], 1)
        splitter.addWidget(criteria_host)

        levels_host = QWidget(self)
        levels_layout = QVBoxLayout()
        levels_host.setLayout(levels_layout)
        levels_layout.addWidget(QLabel(tr("cdp_results.lbl_levels")))
        levels_df = self._coerce_dataframe(self.model_info.get("Levels"))
        self.levels_bundle = self._create_table_bundle(levels_df)
        levels_layout.addWidget(self.levels_bundle["view"], 1)
        splitter.addWidget(levels_host)

        splitter.setStretchFactor(0, 1)
        splitter.setStretchFactor(1, 1)
        self.tabs.addTab(tab, tr("cdp_results.tab_criteria_levels"))

    def _format_model_graph_summary(self) -> str:
        graph_payload = self._model_diagram_payload()
        node_count = len(list(graph_payload.get("Nodes", []) or []))
        edge_count = len(list(graph_payload.get("Edges", []) or []))
        level_count = len(list(graph_payload.get("Levels", []) or []))
        lowest_count = len(list(graph_payload.get("LowestCriteriaRows", []) or []))
        alternative_count = len(
            [
                node
                for node in list(graph_payload.get("Nodes", []) or [])
                if self._coerce_bool(node.get("IsAlternative"))
            ]
        )
        model_name = str(
            graph_payload.get("Name")
            or self.model_info.get("Name")
            or self.workflow_name
        ).strip()
        source = str(graph_payload.get("Source", "")).strip() or "graph"
        return (
            f"Model graph for {model_name}: "
            f"{node_count} nodes, {edge_count} edges, {level_count} levels, "
            f"{lowest_count} lowest-criteria nodes, {alternative_count} alternatives. "
            f"Layout source: {source}."
        )

    def _build_model_graph_context(self) -> Dict[str, Any]:
        graph_payload = self._model_diagram_payload()
        nodes = list(graph_payload.get("Nodes", []) or [])
        edges = list(graph_payload.get("Edges", []) or [])
        lowest_rows = list(graph_payload.get("LowestCriteriaRows", []) or [])
        levels = list(graph_payload.get("Levels", []) or [])

        node_lookup: Dict[str, Dict[str, Any]] = {}
        children_by_parent: Dict[str, List[str]] = {}
        child_ids: set[str] = set()
        lowest_ids = {
            str(row.get("HBlockID")).strip()
            for row in lowest_rows
            if row.get("HBlockID") is not None
        }

        for node in nodes:
            node_id = str(node.get("HBlockID", "")).strip()
            if not node_id:
                continue
            node_lookup[node_id] = node

        for edge in edges:
            parent_id = str(edge.get("CriterionID", "")).strip()
            child_id = str(edge.get("SubCriterionID", "")).strip()
            if not parent_id or not child_id:
                continue
            children_by_parent.setdefault(parent_id, []).append(child_id)
            child_ids.add(child_id)

        root_ids = [
            str(node_id).strip()
            for node_id in list(self.model_graph.get("RootNodeIds", []) or [])
            if str(node_id).strip()
        ]
        if not root_ids:
            root_ids = [
                node_id
                for node_id in node_lookup.keys()
                if node_id not in child_ids
            ]

        if not root_ids:
            root_ids = list(node_lookup.keys())

        level_values: List[int] = []
        for node in node_lookup.values():
            try:
                level_values.append(self._resolve_node_level(node))
            except Exception:
                continue
        ordered_levels = sorted(set(level_values))
        if not ordered_levels:
            ordered_levels = [0]

        return {
            "graph_payload": graph_payload,
            "nodes": node_lookup,
            "edges": edges,
            "levels": levels,
            "children_by_parent": children_by_parent,
            "lowest_ids": lowest_ids,
            "root_ids": root_ids,
            "ordered_levels": ordered_levels,
        }

    def _populate_model_graph_tree(self) -> None:
        self.model_graph_tree.clear()
        self._model_graph_tree_items = {}
        context = self._build_model_graph_context()
        node_lookup = context["nodes"]
        children_by_parent = context["children_by_parent"]
        lowest_ids = context["lowest_ids"]
        root_ids = context["root_ids"]

        if not node_lookup:
            placeholder = QTreeWidgetItem(["No model graph metadata available", "", ""])
            self.model_graph_tree.addTopLevelItem(placeholder)
            self.model_graph_details.setPlainText(
                "No graph metadata was returned for this CDP model."
            )
            return

        def sort_key(node_id: str) -> tuple[Any, ...]:
            node = node_lookup.get(node_id, {})
            return (
                self._resolve_node_level(node, 9999),
                self._resolve_node_vertical_position(node, 9999),
                str(node.get("Name", "")).lower(),
                node_id,
            )

        def build_item(node_id: str, path_ids: Optional[set[str]] = None) -> Optional[QTreeWidgetItem]:
            node = node_lookup.get(node_id)
            if not node:
                return None

            current_path = set(path_ids or set())
            if node_id in current_path:
                return None
            current_path.add(node_id)

            name = str(node.get("Name", "")).strip() or f"Node {node_id}"
            if self._coerce_bool(node.get("IsAlternative")):
                name = f"{name} [ALT]"
            elif node_id in lowest_ids:
                name = f"{name} [LC]"
            item = QTreeWidgetItem([
                name,
                str(self._resolve_node_level(node, "")),
                node_id,
            ])
            item.setData(0, Qt.ItemDataRole.UserRole, node)
            self._model_graph_tree_items[node_id] = item

            for child_id in sorted(children_by_parent.get(node_id, []), key=sort_key):
                child_item = build_item(child_id, current_path)
                if child_item is not None:
                    item.addChild(child_item)

            return item

        for root_id in sorted(root_ids, key=sort_key):
            root_item = build_item(root_id)
            if root_item is not None:
                self.model_graph_tree.addTopLevelItem(root_item)

        self.model_graph_tree.expandAll()
        if self.model_graph_tree.topLevelItemCount() > 0:
            self.model_graph_tree.setCurrentItem(self.model_graph_tree.topLevelItem(0))

    def _populate_model_graph_scene(self) -> None:
        self.model_graph_scene.clear()
        self._model_graph_node_items = {}
        self._model_graph_edge_items = []

        context = self._build_model_graph_context()
        node_lookup = context["nodes"]
        edges = context["edges"]
        lowest_ids = context["lowest_ids"]
        ordered_levels = context["ordered_levels"]
        level_rows = context["levels"]

        if not node_lookup:
            return

        self._model_graph_nodes_by_id = node_lookup
        level_lookup = {
            self._resolve_level_index(level, fallback_index=index): level
            for index, level in enumerate(level_rows)
        }
        vertical_values = sorted(
            {
                self._resolve_node_vertical_position(node)
                for node in node_lookup.values()
            }
        )
        if not vertical_values:
            vertical_values = list(range(len(node_lookup)))
        vertical_to_slot = {
            vertical_value: slot
            for slot, vertical_value in enumerate(vertical_values)
        }

        left_values = [
            self._safe_float(level.get("DiagramLeft"))
            for level in level_lookup.values()
            if level.get("DiagramLeft") is not None
        ]
        right_values = [
            self._safe_float(level.get("DiagramRight"))
            for level in level_lookup.values()
            if level.get("DiagramRight") is not None
        ]
        min_left = min(left_values) if left_values else 0.0
        max_right = max(right_values) if right_values else float(len(ordered_levels) * 220)
        diagram_width = max(1.0, max_right - min_left)
        x_scale = 1.12
        left_margin = 28.0
        top_margin = 54.0
        band_header_height = 26.0
        row_height = 34.0
        band_padding = 8.0

        level_boxes: Dict[int, Dict[str, float]] = {}
        content_bottom = top_margin + band_header_height + (len(vertical_values) * row_height) + 24.0

        for fallback_index, level_value in enumerate(ordered_levels):
            level_row = level_lookup.get(level_value, {})
            level_left = self._safe_float(
                level_row.get("DiagramLeft"),
                fallback_index * 240.0,
            )
            level_right = self._safe_float(
                level_row.get("DiagramRight"),
                level_left + 220.0,
            )
            band_left = left_margin + ((level_left - min_left) * x_scale)
            band_right = left_margin + ((level_right - min_left) * x_scale)
            if band_right <= band_left:
                band_right = band_left + 180.0

            level_boxes[level_value] = {
                "left": band_left,
                "right": band_right,
                "top": top_margin,
                "content_top": top_margin + band_header_height,
                "bottom": content_bottom,
            }

            header_pen = QPen(QColor("#111827"))
            header_pen.setWidth(1)
            header_brush = QBrush(QColor("#f8fafc"))
            body_brush = QBrush(QColor("#ffffff"))
            self.model_graph_scene.addRect(
                band_left,
                top_margin,
                band_right - band_left,
                band_header_height,
                header_pen,
                header_brush,
            )
            self.model_graph_scene.addRect(
                band_left,
                top_margin + band_header_height,
                band_right - band_left,
                content_bottom - (top_margin + band_header_height),
                QPen(QColor("#d1d5db")),
                body_brush,
            )

            label_text = str(level_row.get("Name", "")).strip() or f"Level {level_value}"
            label_item = self.model_graph_scene.addText(label_text)
            label_item.setDefaultTextColor(QColor("#111827"))
            label_item.setPos(band_left + 6.0, top_margin + 3.0)

        node_render_data: Dict[str, Dict[str, float]] = {}
        for node_id, node in node_lookup.items():
            level_value = self._resolve_node_level(node)
            level_box = level_boxes.get(level_value)
            if level_box is None:
                continue

            vertical_value = self._resolve_node_vertical_position(node)
            row_slot = vertical_to_slot.get(vertical_value, 0)
            is_alternative = self._coerce_bool(node.get("IsAlternative"))
            node_width = max(92.0, (level_box["right"] - level_box["left"]) - (band_padding * 2.0))
            node_height = 26.0 if is_alternative else 34.0
            x = level_box["left"] + band_padding
            y = level_box["content_top"] + (row_slot * row_height) + ((row_height - node_height) / 2.0)
            node_render_data[node_id] = {
                "x": x,
                "y": y,
                "width": node_width,
                "height": node_height,
            }

        edge_pen = QPen(QColor("#111827"))
        edge_pen.setWidth(1)
        for edge in edges:
            parent_id = str(edge.get("CriterionID", "")).strip()
            child_id = str(edge.get("SubCriterionID", "")).strip()
            if parent_id not in node_render_data or child_id not in node_render_data:
                continue
            parent_rect = node_render_data[parent_id]
            child_rect = node_render_data[child_id]
            start_x = parent_rect["x"] + parent_rect["width"]
            start_y = parent_rect["y"] + (parent_rect["height"] / 2.0)
            end_x = child_rect["x"]
            end_y = child_rect["y"] + (child_rect["height"] / 2.0)
            elbow_x = start_x + max(18.0, (end_x - start_x) * 0.42)

            path = QPainterPath()
            path.moveTo(start_x, start_y)
            path.lineTo(elbow_x, start_y)
            path.lineTo(elbow_x, end_y)
            path.lineTo(end_x, end_y)
            path_item = self.model_graph_scene.addPath(path, edge_pen)
            self._model_graph_edge_items.append(path_item)

        for node_id, node in node_lookup.items():
            render_data = node_render_data.get(node_id)
            if render_data is None:
                continue
            name = str(node.get("Name", "")).strip() or f"Node {node_id}"
            is_alternative = self._coerce_bool(node.get("IsAlternative"))
            level_value = self._resolve_node_level(node)
            level_box = level_boxes.get(level_value, {})
            band_width = level_box.get("right", 220.0) - level_box.get("left", 0.0)
            max_chars = max(10, int((band_width - 24.0) / 7.0))

            if is_alternative:
                fill_color = "#ffffff"
                selected_fill = "#fffbeb"
            elif node_id in lowest_ids:
                fill_color = "#fef08a"
                selected_fill = "#fde68a"
            elif level_value <= min(ordered_levels):
                fill_color = "#e0f2fe"
                selected_fill = "#bfdbfe"
            else:
                fill_color = "#ffffff"
                selected_fill = "#dbeafe"

            subtitle = "Alternative" if is_alternative else ""
            item = CDPModelNodeItem(
                render_data["x"],
                render_data["y"],
                render_data["width"],
                render_data["height"],
                node_id=node_id,
                label=self._truncate_label(name, max_chars),
                subtitle=subtitle,
                on_selected=self._select_model_graph_node_by_id,
                lowest_criterion=node_id in lowest_ids,
                fill_color=fill_color,
                selected_fill_color=selected_fill,
                border_color="#111827",
                selected_border_color="#d97706",
            )
            self.model_graph_scene.addItem(item)
            self._model_graph_node_items[node_id] = item

        bounds = self.model_graph_scene.itemsBoundingRect()
        self.model_graph_scene.setSceneRect(bounds.adjusted(-20, -20, 20, 20))
        self.model_graph_view.centerOn(bounds.center())

    def _on_model_graph_selection_changed(self, current, _previous) -> None:
        if current is None:
            self.model_graph_details.clear()
            return

        node = current.data(0, Qt.ItemDataRole.UserRole) or {}
        if not isinstance(node, dict):
            self.model_graph_details.setPlainText(str(node))
            return

        node_id = str(node.get("HBlockID", "")).strip()
        if node_id:
            self._highlight_model_graph_node(node_id)

        preferred_keys = [
            "Name",
            "HBlockID",
            "LevelName",
            "Level",
            "DiagramLevel",
            "VerticalPosition",
            "DiagramVerticalPos",
            "IsAlternative",
            "DiagramMatched",
            "AccumulatedWeights",
            "RatingType",
            "PrimaryScaleType",
            "PrimaryScaleName",
            "ScaleDetails",
            "Note",
        ]
        lines = ["Selected CDP Model Node", ""]

        seen_keys = set()
        for key in preferred_keys:
            if key in node:
                value = node.get(key)
                lines.append(f"{key}: {'' if pd.isna(value) else value}")
                seen_keys.add(key)

        for key in sorted(node.keys()):
            if key in seen_keys:
                continue
            value = node.get(key)
            lines.append(f"{key}: {'' if pd.isna(value) else value}")

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

    def _select_model_graph_node_by_id(self, node_id: str) -> None:
        if not node_id:
            return
        item = self._model_graph_tree_items.get(node_id)
        if item is None:
            self._highlight_model_graph_node(node_id)
            return

        self._model_graph_syncing_selection = True
        try:
            self.model_graph_tree.setCurrentItem(item)
        finally:
            self._model_graph_syncing_selection = False

        self._highlight_model_graph_node(node_id)

    def _highlight_model_graph_node(self, node_id: str) -> None:
        for candidate_id, item in self._model_graph_node_items.items():
            item.set_selected_state(candidate_id == node_id)

        selected_item = self._model_graph_node_items.get(node_id)
        if selected_item is not None:
            self.model_graph_view.centerOn(selected_item)

    def _model_diagram_payload(self) -> Dict[str, Any]:
        if isinstance(self.model_diagram, dict) and self.model_diagram:
            return self.model_diagram
        return self.model_graph

    def _resolve_node_level(self, node: Dict[str, Any], default_value: Any = 0) -> int:
        return self._safe_int(
            node.get("DiagramLevel"),
            self._safe_int(node.get("Level"), default_value if isinstance(default_value, int) else 0),
        )

    def _resolve_node_vertical_position(self, node: Dict[str, Any], default_value: int = 0) -> int:
        return self._safe_int(
            node.get("DiagramVerticalPos"),
            self._safe_int(node.get("VerticalPosition"), self._safe_int(node.get("VerticalPos"), default_value)),
        )

    def _resolve_level_index(self, level: Dict[str, Any], fallback_index: int = 0) -> int:
        for key in ("DiagramLevelIndex", "LevelID", "HLevelsCounter", "Level"):
            if key in level:
                return self._safe_int(level.get(key), fallback_index)
        return fallback_index

    @staticmethod
    def _coerce_bool(value: Any) -> bool:
        if isinstance(value, bool):
            return value
        text = str(value or "").strip().lower()
        return text in {"1", "true", "yes", "y"}

    @staticmethod
    def _safe_int(value: Any, default_value: int = 0) -> int:
        try:
            return int(value)
        except Exception:
            return default_value

    @staticmethod
    def _safe_float(value: Any, default_value: float = 0.0) -> float:
        try:
            return float(value)
        except Exception:
            return default_value

    @staticmethod
    def _truncate_label(value: str, max_length: int) -> str:
        text = str(value or "").strip()
        if len(text) <= max_length:
            return text
        return f"{text[: max_length - 1].rstrip()}..."

    def _create_table_bundle(self, dataframe: Optional[pd.DataFrame], on_selection_changed=None) -> Dict[str, Any]:
        model = DataFrameTableModel(self._coerce_dataframe(dataframe), self)
        proxy = ResultsFilterProxyModel(self)
        proxy.setSourceModel(model)

        view = QTableView()
        view.setModel(proxy)
        view.setSortingEnabled(True)
        view.setAlternatingRowColors(True)
        view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)

        header = view.horizontalHeader()
        header.setStretchLastSection(True)
        header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)

        if on_selection_changed is not None:
            view.selectionModel().selectionChanged.connect(on_selection_changed)

        return {
            "model": model,
            "proxy": proxy,
            "view": view,
        }

    def _connect_chart_bundle_sync(self, bundle: Dict[str, Any], refresh_callback, search_box: Optional[QLineEdit] = None) -> None:
        proxy = bundle["proxy"]
        view = bundle["view"]

        if search_box is not None:
            search_box.textChanged.connect(proxy.set_filter_text)
            search_box.textChanged.connect(lambda *_args: refresh_callback())

        if hasattr(proxy, "layoutChanged"):
            proxy.layoutChanged.connect(refresh_callback)
        if hasattr(proxy, "modelReset"):
            proxy.modelReset.connect(refresh_callback)
        if hasattr(proxy, "rowsInserted"):
            proxy.rowsInserted.connect(lambda *_args: refresh_callback())
        if hasattr(proxy, "rowsRemoved"):
            proxy.rowsRemoved.connect(lambda *_args: refresh_callback())

        header = view.horizontalHeader()
        if hasattr(header, "sortIndicatorChanged"):
            header.sortIndicatorChanged.connect(lambda *_args: refresh_callback())

    def _bundle_visible_dataframe(self, bundle: Dict[str, Any]) -> pd.DataFrame:
        proxy = bundle["proxy"]
        model = bundle["model"]
        source_df = model.dataframe()
        if source_df.empty:
            return source_df

        source_rows: List[int] = []
        for proxy_row in range(proxy.rowCount()):
            proxy_index = proxy.index(proxy_row, 0)
            source_index = proxy.mapToSource(proxy_index)
            if source_index.isValid():
                source_rows.append(source_index.row())

        if not source_rows:
            return source_df.iloc[0:0].copy()
        return source_df.iloc[source_rows].copy()

    def _set_table_dataframe(self, bundle: Dict[str, Any], dataframe: Optional[pd.DataFrame]) -> None:
        bundle["model"].set_dataframe(self._coerce_dataframe(dataframe))
        bundle["view"].resizeColumnsToContents()

    def _refresh_after_calculated_field(self) -> None:
        """Refresh CDP viewer surfaces after the persisted result table changes."""
        if hasattr(self, "workbench_bundle"):
            self._set_table_dataframe(self.workbench_bundle, self.results_df)
        if hasattr(self, "calculations_bundle"):
            self._set_table_dataframe(self.calculations_bundle, self.calculations_df)
        if hasattr(self, "calculations_stats_bundle"):
            self._set_table_dataframe(
                self.calculations_stats_bundle,
                self._build_numeric_summary_frame(self.calculations_df),
            )

        self._refresh_prioritization_view()
        self._refresh_prioritization_chart()
        self._refresh_calculations_chart()
        self._refresh_workbench_details()
        self._register_tab_export_specs()
        self._refresh_export_buttons_for_current_tab()

    def _select_first_row(self, bundle: Dict[str, Any]) -> None:
        if bundle["proxy"].rowCount() <= 0:
            return
        bundle["view"].selectRow(0)

    def _current_row_series(self, bundle: Dict[str, Any]) -> Optional[pd.Series]:
        index = bundle["view"].currentIndex()
        if not index.isValid():
            return None
        source_index = bundle["proxy"].mapToSource(index)
        if not source_index.isValid():
            return None
        dataframe = bundle["model"].dataframe()
        if dataframe.empty or source_index.row() >= len(dataframe.index):
            return None
        return dataframe.iloc[source_index.row()]

    def _refresh_workbench_details(self, *_args) -> None:
        row_series = self._current_row_series(self.workbench_bundle)
        if row_series is None:
            self.workbench_details.clear()
            return

        display_value = row_series.get(self.display_field, "") if self.display_field else ""
        key_value = row_series.get(self.primary_key, "") if self.primary_key else ""
        score_value = row_series.get(self.score_column, "") if self.score_column else ""

        lines = [
            "Selected Result",
            f"Display value: {display_value}",
            f"Primary key: {key_value}",
            f"Score: {score_value}",
            "",
        ]

        for column_name in self.results_df.columns[:20]:
            value = row_series.get(column_name, "")
            lines.append(f"{column_name}: {'' if pd.isna(value) else value}")

        remaining = len(self.results_df.columns) - min(20, len(self.results_df.columns))
        if remaining > 0:
            lines.extend(["", f"... {remaining} more columns not shown"])

        self.workbench_status.setText(
            f"Inspecting {display_value or key_value or 'the selected result row'}."
        )
        self.workbench_details.setPlainText("\n".join(lines))

    def _refresh_prioritization_view(self, *_args) -> None:
        prioritized = self.calculations_df.copy()
        if self.score_column and self.score_column in prioritized.columns:
            prioritized[self.score_column] = pd.to_numeric(prioritized[self.score_column], errors="coerce")
            prioritized = prioritized.sort_values(by=self.score_column, ascending=False, kind="mergesort")

        self._set_table_dataframe(self.prioritization_bundle, prioritized)
        self._refresh_prioritization_chart()
        self._select_first_row(self.prioritization_bundle)

    def _refresh_prioritization_chart(self, *_args) -> None:
        visible_frame = self._bundle_visible_dataframe(self.prioritization_bundle)
        scope = self.prioritization_scope.currentText() if hasattr(self, "prioritization_scope") else "Top 20"
        chart_frame = visible_frame.head(20) if scope == "Top 20" else visible_frame
        labels = self._resolve_chart_labels(chart_frame)
        values = self._resolve_numeric_series(chart_frame, self.score_column)
        self.prioritization_chart.set_chart_data(
            labels,
            values,
            title="PriorityScores by visible grid rows",
            value_label=self.score_column or "PriorityScores",
            empty_message="PriorityScores were not found for charting.",
        )
        self.prioritization_info.setText(
            f"Grid rows: {len(visible_frame.index)}. "
            f"Chart scope: {scope}."
        )

    def _refresh_contributions_view(self) -> None:
        self._set_table_dataframe(self.contributions_bundle, self.contributions_df)
        self._refresh_contributions_chart()

    def _refresh_contributions_chart(self, *_args) -> None:
        visible_frame = self._bundle_visible_dataframe(self.contributions_bundle)
        metric_column = self._resolve_contribution_metric_column(visible_frame)
        labels = self._resolve_chart_labels(visible_frame.head(20))
        values = self._resolve_numeric_series(visible_frame.head(20), metric_column)
        self.contributions_chart.set_chart_data(
            labels,
            values,
            title="Contribution chart from visible grid rows",
            value_label=metric_column,
            empty_message="No numeric contribution columns were available for charting.",
        )
        self.contributions_info.setText(
            f"Grid rows: {len(visible_frame.index)}. "
            f"Chart metric: {metric_column or '(none found)'}."
        )

    def _refresh_sensitivity_view(self) -> None:
        self._set_table_dataframe(self.sensitivity_bundle, self.sensitivity_df)
        selected = self.sensitivity_combo.currentText() if hasattr(self, "sensitivity_combo") else ""
        self.sensitivity_info.setText(
            f"Rows: {len(self.sensitivity_df.index)}. "
            f"Criterion: {selected or '(all)'}."
        )

    def _refresh_tradeoffs_view(self) -> None:
        self._set_table_dataframe(self.tradeoffs_bundle, self.tradeoffs_df)
        selected = self.tradeoffs_combo.currentText() if hasattr(self, "tradeoffs_combo") else ""
        self.tradeoffs_info.setText(
            f"Rows: {len(self.tradeoffs_df.index)}. "
            f"Lowest criteria: {selected or '(default)'}."
        )

    def _populate_selector_options(self) -> None:
        self._set_combo_options(self.source_level_combo, self.source_level_options, self._format_source_level_option)
        self._set_combo_options(self.target_level_combo, self.target_level_options, self._format_target_level_option)
        self._apply_contribution_selector_rules()
        self._set_combo_options(self.sensitivity_combo, self.sensitivity_criterion_options, lambda value: str(value))
        self._set_combo_options(self.tradeoffs_combo, self.lowest_criteria_rows, self._format_tradeoff_option)

    def _set_combo_options(self, combo: QComboBox, options: Sequence[Any], formatter) -> None:
        combo.blockSignals(True)
        combo.clear()
        for option in options:
            combo.addItem(formatter(option), option)
        combo.blockSignals(False)
        if combo.count() > 0:
            combo.setCurrentIndex(0)

    def _on_target_level_changed(self, *_args) -> None:
        if self._updating_contribution_selectors:
            return
        self._apply_contribution_selector_rules()
        self._reload_contributions()

    def _on_source_level_changed(self, *_args) -> None:
        if self._updating_contribution_selectors:
            return
        self._apply_contribution_selector_rules()
        self._reload_contributions()

    def _apply_contribution_selector_rules(self) -> None:
        current_source_text = self.source_level_combo.currentText().strip()
        current_target_hblock_id = self._resolve_target_hblock_id()
        filtered_target_options = self._filtered_target_level_options()
        self._updating_contribution_selectors = True
        try:
            self.target_level_combo.blockSignals(True)
            self.target_level_combo.clear()
            for option in filtered_target_options:
                self.target_level_combo.addItem(self._format_target_level_option(option), option)
            self.target_level_combo.blockSignals(False)

            if self.target_level_combo.count() > 0:
                self._restore_combo_by_data(self.target_level_combo, current_target_hblock_id, "HBlockID")
                if self.target_level_combo.currentIndex() < 0:
                    self.target_level_combo.setCurrentIndex(0)

            filtered_source_options = self._filtered_source_level_options()
            self.source_level_combo.blockSignals(True)
            self.source_level_combo.clear()
            for option in filtered_source_options:
                self.source_level_combo.addItem(self._format_source_level_option(option), option)
            self.source_level_combo.blockSignals(False)

            if self.source_level_combo.count() == 0:
                return

            if current_source_text:
                self._restore_combo_text(self.source_level_combo, current_source_text)
            if self.source_level_combo.currentIndex() < 0:
                self.source_level_combo.setCurrentIndex(0)

            if self.target_level_combo.count() > 0:
                filtered_target_options = self._filtered_target_level_options()
                self.target_level_combo.blockSignals(True)
                self.target_level_combo.clear()
                for option in filtered_target_options:
                    self.target_level_combo.addItem(self._format_target_level_option(option), option)
                self.target_level_combo.blockSignals(False)
                self._restore_combo_by_data(self.target_level_combo, current_target_hblock_id, "HBlockID")
                if self.target_level_combo.currentIndex() < 0:
                    self.target_level_combo.setCurrentIndex(0)
        finally:
            self._updating_contribution_selectors = False

    def _filtered_source_level_options(self) -> List[Any]:
        available_target_levels = self._available_target_levels()
        if not available_target_levels:
            return []

        target_option = self.target_level_combo.currentData()
        selected_target_level = self._extract_option_int(
            target_option,
            ("LevelIndex", "Level", "LevelID", "HLevelsCounter", "DiagramLevelIndex", "ID", "Value"),
            None,
        )
        max_source_level = max(available_target_levels) - 1

        filtered = []
        for option in self.source_level_options:
            source_level = self._extract_option_int(
                option,
                ("LevelIndex", "Level", "LevelID", "HLevelsCounter", "DiagramLevelIndex", "ID", "Value"),
                None,
            )
            if source_level is None:
                continue
            if self._is_alternative_level(source_level):
                continue
            if source_level > max_source_level:
                continue
            if selected_target_level is not None and source_level >= selected_target_level:
                continue
            if not any(target_level > source_level for target_level in available_target_levels):
                continue
            filtered.append(option)
        return filtered

    def _filtered_target_level_options(self) -> List[Any]:
        source_level = self._resolve_source_level_index()
        filtered = []
        for option in self.target_level_options:
            target_level = self._extract_option_int(
                option,
                ("LevelIndex", "Level", "LevelID", "HLevelsCounter", "DiagramLevelIndex", "ID", "Value"),
                None,
            )
            if target_level is None:
                continue
            if self._is_alternative_level(target_level):
                continue
            if target_level <= source_level:
                continue
            filtered.append(option)
        return filtered

    def _reload_contributions(self, *_args) -> None:
        if self.bridge is None or self.results_df.empty:
            self._refresh_contributions_view()
            return

        source_level_index = self._resolve_source_level_index()
        target_hblock_id = self._resolve_target_hblock_id()
        try:
            response = self.bridge.get_contributions(
                self.results_df,
                self.model_path,
                field_mappings=self.field_mappings,
                mapping_rows=self.mapping_rows,
                display_field=self.display_field,
                primary_key=self.primary_key,
                input_table_name=self.input_table_name,
                validation_settings=self.validation_settings,
                source_level_index=source_level_index,
                target_hblock_id=target_hblock_id,
            )
            self.contributions_df = self._prepare_contributions_frame(response.get("table"))
            info = response.get("model_info", {}) or {}
            self.source_level_options = list(info.get("SourceLevelOptions", self.source_level_options) or [])
            self.target_level_options = list(info.get("TargetLevelOptions", self.target_level_options) or [])
            self._apply_contribution_selector_rules()
        except Exception as exc:
            debug_logger.warning("Could not reload CDP contributions", {"exception": str(exc)})
            QMessageBox.warning(self, tr("cdp_results.cdp_viewer_title"), tr("cdp_results.err_reload_contributions", error=exc))
        self._refresh_contributions_view()

    def _reload_sensitivity(self, *_args) -> None:
        if self.bridge is None or self.results_df.empty:
            self._refresh_sensitivity_view()
            return

        criterion_name = self.sensitivity_combo.currentText().strip()
        try:
            response = self.bridge.get_sensitivity(
                self.results_df,
                self.model_path,
                field_mappings=self.field_mappings,
                mapping_rows=self.mapping_rows,
                display_field=self.display_field,
                primary_key=self.primary_key,
                input_table_name=self.input_table_name,
                validation_settings=self.validation_settings,
                criterion_name=criterion_name,
            )
            self.sensitivity_df = self._coerce_dataframe(response.get("table"))
            info = response.get("model_info", {}) or {}
            updated_options = list(info.get("CriterionOptions", self.sensitivity_criterion_options) or [])
            if updated_options and updated_options != self.sensitivity_criterion_options:
                self.sensitivity_criterion_options = updated_options
                self._set_combo_options(self.sensitivity_combo, self.sensitivity_criterion_options, lambda value: str(value))
                if criterion_name:
                    self._restore_combo_text(self.sensitivity_combo, criterion_name)
        except Exception as exc:
            debug_logger.warning("Could not reload CDP sensitivity", {"exception": str(exc)})
            QMessageBox.warning(self, tr("cdp_results.cdp_viewer_title"), tr("cdp_results.err_reload_sensitivity", error=exc))
        self._refresh_sensitivity_view()

    def _reload_tradeoffs(self, *_args) -> None:
        if self.bridge is None or self.results_df.empty:
            self._refresh_tradeoffs_view()
            return

        reference_hblock_id = self._resolve_tradeoff_hblock_id()
        try:
            response = self.bridge.get_tradeoffs(
                self.results_df,
                self.model_path,
                field_mappings=self.field_mappings,
                mapping_rows=self.mapping_rows,
                display_field=self.display_field,
                primary_key=self.primary_key,
                input_table_name=self.input_table_name,
                validation_settings=self.validation_settings,
                reference_hblock_id=reference_hblock_id,
            )
            self.tradeoffs_df = self._coerce_dataframe(response.get("table"))
            info = response.get("model_info", {}) or {}
            updated_rows = list(info.get("LowestCriteriaRows", self.lowest_criteria_rows) or [])
            if updated_rows and updated_rows != self.lowest_criteria_rows:
                self.lowest_criteria_rows = updated_rows
                self._set_combo_options(self.tradeoffs_combo, self.lowest_criteria_rows, self._format_tradeoff_option)
                self._restore_combo_by_data(self.tradeoffs_combo, reference_hblock_id, "HBlockID")
        except Exception as exc:
            debug_logger.warning("Could not reload CDP tradeoffs", {"exception": str(exc)})
            QMessageBox.warning(self, tr("cdp_results.cdp_viewer_title"), tr("cdp_results.err_reload_tradeoffs", error=exc))
        self._refresh_tradeoffs_view()

    def _format_execution_context(self) -> str:
        lines = [
            "Results Summary",
            f"Pipeline: {self.workflow_name or '(unnamed pipeline)'}",
            f"Execution ID: {self.execution_id or '(none)'}",
            f"Rows: {self.summary.get('row_count', len(self.results_df.index))}",
            f"Columns: {self.summary.get('column_count', len(self.results_df.columns))}",
            f"Display field: {self.display_field or '(not supplied)'}",
            f"Primary key: {self.primary_key or '(not supplied)'}",
            f"Row key field: {self.row_key_field or '(not supplied)'}",
            f"Priority score column: {self.score_column or '(not found)'}",
            f"Rank column: {self.rank_column or '(not found)'}",
        ]

        if self.database_path:
            lines.append(f"Database: {self.database_path}")
        if self.output_table_name:
            lines.append(f"Output table: {self.output_table_name}")
        if self.model_path:
            lines.append(f"Model: {self.model_path}")

        if self.summary.get("score_min") is not None:
            lines.extend(
                [
                    "",
                    "Priority Score Statistics",
                    f"Minimum: {self.summary['score_min']:.4f}",
                    f"Mean: {self.summary['score_mean']:.4f}",
                    f"Maximum: {self.summary['score_max']:.4f}",
                ]
            )

        return "\n".join(lines)

    def _format_calculations_summary(self) -> str:
        lines = [
            "Calculations tab uses the persisted output table written for this CDP run.",
            "Columns are ordered with the object id first, then HBlockID, DisplayName, and PriorityScores.",
        ]
        if self.score_column:
            lines.append(f"Resolved score column: {self.score_column}")
        if self.display_field:
            lines.append(f"Display labels: {self.display_field}")
        return "\n".join(lines)

    def _format_model_context(self) -> str:
        if not self.model_info:
            return "No CDP model metadata was available."

        lines = [
            f"Model: {self.model_info.get('Name', '(unknown)')}",
            f"Description: {self.model_info.get('Description', '(none)')}",
            f"Engine: {self.model_info.get('Engine', 'CDP')}",
            f"Levels: {self.model_info.get('LevelCount', 0)}",
            f"Criteria: {self.model_info.get('CriteriaCount', 0)}",
            f"Alternatives: {self.model_info.get('AlternativeCount', 0)}",
        ]

        scalar_preview = self.payload.get("model_summary", {}).get("scalar_preview", {})
        if scalar_preview:
            lines.extend(["", "Scalar Metadata"])
            for key, value in scalar_preview.items():
                lines.append(f"{key}: {value}")

        return "\n".join(lines)

    def _build_calculations_frame(self, results_df: pd.DataFrame) -> pd.DataFrame:
        if results_df.empty:
            return results_df.copy()

        ordered_columns: List[str] = []
        for candidate in ("ogc_fid", "fid", "FID"):
            if candidate in results_df.columns:
                ordered_columns.append(candidate)
                break

        for candidate in ("HBlockID", "DisplayName", "PriorityScores"):
            if candidate in results_df.columns and candidate not in ordered_columns:
                ordered_columns.append(candidate)

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

        return results_df.loc[:, ordered_columns].copy()

    def _build_numeric_summary_frame(self, dataframe: pd.DataFrame) -> pd.DataFrame:
        rows: List[Dict[str, Any]] = []
        for column_name in dataframe.columns:
            series = pd.to_numeric(dataframe[column_name], errors="coerce")
            if series.notna().sum() == 0:
                continue
            rows.append(
                {
                    "Column": column_name,
                    "Count": int(series.notna().sum()),
                    "Minimum": float(series.min()),
                    "Mean": float(series.mean()),
                    "Maximum": float(series.max()),
                }
            )
        return pd.DataFrame(rows)

    def _resolve_chart_labels(self, dataframe: pd.DataFrame) -> List[str]:
        if dataframe.empty:
            return []

        labels: List[str] = []
        for index, row in dataframe.iterrows():
            label = ""
            for candidate in (self.display_field, "DisplayName", self.primary_key):
                if candidate and candidate in dataframe.columns:
                    value = row.get(candidate, "")
                    if value not in (None, "") and not pd.isna(value):
                        label = str(value)
                        break
            labels.append(label or str(index))
        return labels

    @staticmethod
    def _resolve_numeric_series(dataframe: pd.DataFrame, column_name: str) -> List[float]:
        if not column_name or column_name not in dataframe.columns:
            return []
        series = pd.to_numeric(dataframe[column_name], errors="coerce").fillna(0)
        return [float(value) for value in series.tolist()]

    def _resolve_contribution_metric_column(self, dataframe: pd.DataFrame) -> str:
        if dataframe.empty:
            return ""
        excluded_names = {
            "ogc_fid",
            "fid",
            "rowid",
            "objectid",
            "hblockid",
            "priorityscores",
        }
        preferred_fragments = ("decisionscore", "contribution", "score", "priority")

        numeric_candidates: List[str] = []
        for column_name in dataframe.columns:
            normalized = str(column_name).strip().lower()
            if normalized in excluded_names or normalized.startswith("cdp__"):
                continue
            if pd.to_numeric(dataframe[column_name], errors="coerce").notna().sum() == 0:
                continue
            numeric_candidates.append(str(column_name))

        for fragment in preferred_fragments:
            for column_name in numeric_candidates:
                if fragment in column_name.lower():
                    return column_name
        return numeric_candidates[0] if numeric_candidates else ""

    def _resolve_source_level_index(self) -> int:
        option = self.source_level_combo.currentData()
        value = self._extract_option_int(
            option,
            ("LevelIndex", "Level", "LevelID", "HLevelsCounter", "DiagramLevelIndex", "ID", "Value"),
            None,
        )
        if value is not None:
            return value
        return max(1, self.source_level_combo.currentIndex())

    def _resolve_target_hblock_id(self) -> int:
        option = self.target_level_combo.currentData()
        value = self._extract_option_int(option, ("HBlockID", "ID", "Value"), None)
        if value is not None:
            return value
        return 0

    def _resolve_tradeoff_hblock_id(self) -> int:
        option = self.tradeoffs_combo.currentData()
        value = self._extract_option_int(option, ("HBlockID",), None)
        if value is not None:
            return value
        return 0

    def _available_target_levels(self) -> List[int]:
        levels = {
            level
            for option in self.target_level_options
            for level in [
                self._extract_option_int(
                    option,
                    ("LevelIndex", "Level", "LevelID", "HLevelsCounter", "DiagramLevelIndex", "ID", "Value"),
                    None,
                )
            ]
            if level is not None and not self._is_alternative_level(level)
        }
        return sorted(levels)

    def _alternative_level_numbers(self) -> set[int]:
        alternative_levels: set[int] = set()
        level_rows = list(self.model_info.get("Levels", []) or [])
        for level_row in level_rows:
            level_number = self._extract_option_int(level_row, ("Level", "LevelID", "HLevelsCounter", "ID", "Value"), None)
            level_name = str(level_row.get("Name", "")).strip().lower() if isinstance(level_row, dict) else ""
            if level_number is None:
                continue
            if "candidate" in level_name or "alternative" in level_name:
                alternative_levels.add(level_number)

        if alternative_levels:
            return alternative_levels

        if self.model_info.get("AlternativeCount") and self.target_level_options:
            all_levels = [
                self._extract_option_int(
                    option,
                    ("LevelIndex", "Level", "LevelID", "HLevelsCounter", "DiagramLevelIndex", "ID", "Value"),
                    None,
                )
                for option in self.target_level_options
            ]
            concrete_levels = [level for level in all_levels if level is not None]
            if concrete_levels:
                alternative_levels.add(max(concrete_levels))
        return alternative_levels

    def _is_alternative_level(self, level_value: Optional[int]) -> bool:
        if level_value is None:
            return False
        return int(level_value) in self._alternative_level_numbers()

    @staticmethod
    def _format_source_level_option(option: Any) -> str:
        if isinstance(option, dict):
            level = (
                option.get("LevelIndex")
                if option.get("LevelIndex") not in ("", None)
                else option.get("Level", option.get("LevelID", option.get("HLevelsCounter", option.get("DiagramLevelIndex", ""))))
            )
            name = option.get("Name", option.get("LevelName", ""))
            if name:
                return f"L{level}: {name}" if level != "" else str(name)
            if level != "":
                return f"Level {level}"
        return str(option)

    @staticmethod
    def _format_target_level_option(option: Any) -> str:
        if isinstance(option, dict):
            level = (
                option.get("LevelIndex")
                if option.get("LevelIndex") not in ("", None)
                else option.get("Level", option.get("LevelID", option.get("HLevelsCounter", option.get("DiagramLevelIndex", ""))))
            )
            name = option.get("Name", option.get("CriteriaName", ""))
            hblock = option.get("HBlockID", "")
            prefix = f"L{level}: " if level not in ("", None) else ""
            suffix = f" (HBlockID {hblock})" if hblock not in ("", None) else ""
            if name:
                return f"{prefix}{name}{suffix}"
        return str(option)

    @staticmethod
    def _format_tradeoff_option(option: Any) -> str:
        if isinstance(option, dict):
            name = option.get("Name", "")
            hblock = option.get("HBlockID", "")
            if name:
                return f"{name} (HBlockID {hblock})"
        return str(option)

    @staticmethod
    def _extract_option_int(option: Any, keys: Sequence[str], default: Optional[int]) -> Optional[int]:
        if isinstance(option, dict):
            for key in keys:
                if key in option:
                    try:
                        return int(option[key])
                    except Exception:
                        continue
        return default

    @staticmethod
    def _restore_combo_text(combo: QComboBox, text: str) -> None:
        for index in range(combo.count()):
            if combo.itemText(index).strip().lower() == text.strip().lower():
                combo.setCurrentIndex(index)
                return

    @staticmethod
    def _restore_combo_by_data(combo: QComboBox, value: int, data_key: str) -> None:
        for index in range(combo.count()):
            data = combo.itemData(index)
            if isinstance(data, dict):
                try:
                    if int(data.get(data_key, -1)) == value:
                        combo.setCurrentIndex(index)
                        return
                except Exception:
                    continue

    @staticmethod
    def _coerce_dataframe(value: Any) -> pd.DataFrame:
        if isinstance(value, pd.DataFrame):
            records = value.to_dict("records")
            dataframe = pd.DataFrame.from_records(records)
            if len(value.index) == len(dataframe.index):
                dataframe.index = value.index.copy()
            return dataframe
        if isinstance(value, list):
            return pd.DataFrame(value)
        if isinstance(value, dict):
            return pd.DataFrame([value])
        return pd.DataFrame()

    def _prepare_contributions_frame(self, value: Any) -> pd.DataFrame:
        dataframe = self._coerce_dataframe(value)
        if dataframe.empty:
            return dataframe

        display_column = "DisplayName"
        has_display_values = (
            display_column in dataframe.columns
            and dataframe[display_column].fillna("").astype(str).str.strip().ne("").any()
        )
        if has_display_values:
            return dataframe

        key_candidates = [
            self.primary_key,
            self.row_key_field,
            "ogc_fid",
            "fid",
            "FID",
            "RowID",
        ]
        source_df = self.results_df.copy()
        if display_column not in source_df.columns:
            return dataframe

        for key_name in key_candidates:
            if not key_name or key_name not in dataframe.columns or key_name not in source_df.columns:
                continue
            lookup = (
                source_df[[key_name, display_column]]
                .drop_duplicates(subset=[key_name])
                .set_index(key_name)[display_column]
            )
            mapped = dataframe[key_name].map(lookup)
            if mapped.notna().any():
                dataframe[display_column] = mapped.fillna("")
                return dataframe

        if len(dataframe.index) == len(source_df.index):
            dataframe[display_column] = source_df[display_column].tolist()

        return dataframe

    def _resolve_first_column(self, candidates: Sequence[str]) -> str:
        normalized = {str(column).strip().lower(): str(column) for column in self.results_df.columns}
        for candidate in candidates:
            match = normalized.get(str(candidate).strip().lower())
            if match:
                return match
        return ""
