"""
Compare two or more analysis results side-by-side.

Mirrors the legacy ArcGIS RunCriticalDifferences + DisplayHistogram + DisplayGrid
flow: pick a primary result and 1-4 additional results from anywhere in the
workspace, verify column compatibility, then show a side-by-side merged grid
plus an overlay histogram of the chosen columns.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional

import pandas as pd
from ..qt_compat import (
    QAbstractItemView,
    QCheckBox,
    QColor,
    QDialog,
    QFileDialog,
    QGroupBox,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QListWidget,
    QListWidgetItem,
    QMessageBox,
    QPushButton,
    QSplitter,
    QTableView,
    QTableWidget,
    QTableWidgetItem,
    QTabWidget,
    QTreeWidget,
    QTreeWidgetItem,
    QVBoxLayout,
    QWidget,
    Qt,
)
from ..histogram_widget import HistogramWidget, drop_no_data, get_numeric_columns
from ..results_grid_model import DataFrameTableModel
from ..translator import tr, add_listener, remove_listener


MAX_ADDITIONAL_RESULTS = 4  # 5 total including the primary, matches RunCriticalDifferences


@dataclass
class CompareCandidate:
    """One result that can be compared against the primary result."""

    label: str
    workspace_name: str
    study_name: str
    analysis_name: str
    engine: str
    workflow_step_id: str
    load_dataframe: Callable[[], Optional[pd.DataFrame]]


class CompareResultsDialog(QDialog):
    """Side-by-side comparison of 2-5 analysis results."""

    def __init__(
        self,
        *,
        primary_label: str,
        primary_engine: str,
        primary_dataframe: pd.DataFrame,
        candidates: List[CompareCandidate],
        parent=None,
        provenance_provider: Optional[Callable[[str], Dict[str, Any]]] = None,
        primary_workflow_step_id: str = "",
        difference_report_provider: Optional[Callable[[str, str], str]] = None,
    ):
        super().__init__(parent)
        # S4: optional builder(primary_task_id, other_task_id) -> HTML difference report.
        self._difference_report_provider = difference_report_provider
        self.primary_label = primary_label
        self.primary_engine = primary_engine
        self.primary_dataframe = primary_dataframe.copy() if primary_dataframe is not None else pd.DataFrame()
        self.candidates = candidates
        self._loaded_frames: Dict[str, pd.DataFrame] = {primary_label: self.primary_dataframe}
        self._comparison_set: List[str] = []  # ordered list of selected candidate labels
        # Provenance (3a): optional provider(workflow_step_id) -> info dict, used
        # to show *why* results differ (model snapshot/hash, engine version, run time).
        self._provenance_provider = provenance_provider
        self._label_to_step_id: Dict[str, str] = {}
        if primary_workflow_step_id:
            self._label_to_step_id[primary_label] = primary_workflow_step_id
        for candidate in candidates:
            step_id = getattr(candidate, "workflow_step_id", "")
            if step_id:
                self._label_to_step_id[candidate.label] = step_id

        self.setWindowTitle(tr("compare_results.window_title"))
        self.resize(1200, 760)
        self._init_ui()
        self.retranslateUi()
        add_listener(self.retranslateUi)

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

        self.header_label = QLabel(self._format_header_text())
        self.header_label.setTextFormat(Qt.TextFormat.RichText)
        self.header_label.setWordWrap(True)
        layout.addWidget(self.header_label)

        # Candidate picker (top) + comparison output (bottom) in a vertical splitter
        v_splitter = QSplitter(Qt.Orientation.Vertical)
        layout.addWidget(v_splitter, 1)

        # --- Picker pane ---
        picker_host = QWidget(self)
        picker_layout = QVBoxLayout()
        picker_layout.setContentsMargins(0, 0, 0, 0)
        picker_host.setLayout(picker_layout)

        self.picker_prompt_label = QLabel(tr("compare_results.picker_prompt", max=MAX_ADDITIONAL_RESULTS))
        self.picker_prompt_label.setWordWrap(True)
        picker_layout.addWidget(self.picker_prompt_label)

        self.candidate_tree = QTreeWidget()
        self.candidate_tree.setColumnCount(4)
        self.candidate_tree.setHeaderLabels(self._tree_header_labels())
        self.candidate_tree.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.candidate_tree.itemChanged.connect(self._on_candidate_check_changed)
        h = self.candidate_tree.header()
        h.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        h.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
        h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
        picker_layout.addWidget(self.candidate_tree, 1)

        action_row = QHBoxLayout()
        self.btn_run_compare = QPushButton(tr("compare_results.btn_compare"))
        self.btn_run_compare.clicked.connect(self._run_comparison)
        self.btn_run_compare.setEnabled(False)
        action_row.addWidget(self.btn_run_compare)

        self.btn_clear_selection = QPushButton(tr("compare_results.btn_clear"))
        self.btn_clear_selection.clicked.connect(self._clear_selection)
        action_row.addWidget(self.btn_clear_selection)
        action_row.addStretch()

        self.selection_status = QLabel(tr("compare_results.status_initial"))
        action_row.addWidget(self.selection_status)
        picker_layout.addLayout(action_row)

        v_splitter.addWidget(picker_host)

        # --- Output pane ---
        output_host = QWidget(self)
        output_layout = QVBoxLayout()
        output_layout.setContentsMargins(0, 0, 0, 0)
        output_host.setLayout(output_layout)

        self.warning_label = QLabel("")
        self.warning_label.setWordWrap(True)
        self.warning_label.setStyleSheet("color: #92400e; background-color: #fef3c7; padding: 6px;")
        self.warning_label.setVisible(False)
        output_layout.addWidget(self.warning_label)

        self.column_options_group = QGroupBox(tr("compare_results.group_column_alignment"))
        column_options_layout = QHBoxLayout()
        self.skip_unmatched_check = QCheckBox(tr("compare_results.skip_unmatched"))
        self.skip_unmatched_check.setChecked(True)
        column_options_layout.addWidget(self.skip_unmatched_check)
        column_options_layout.addStretch()
        self.column_options_group.setLayout(column_options_layout)
        output_layout.addWidget(self.column_options_group)

        self.output_tabs = QTabWidget()
        output_layout.addWidget(self.output_tabs, 1)

        # Side-by-side table tab
        table_tab = QWidget(self)
        table_tab_layout = QVBoxLayout()
        table_tab.setLayout(table_tab_layout)
        self._table_model = DataFrameTableModel(pd.DataFrame(), self)
        self.merged_table = QTableView()
        self.merged_table.setModel(self._table_model)
        self.merged_table.setAlternatingRowColors(True)
        self.merged_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.merged_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        hh = self.merged_table.horizontalHeader()
        hh.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
        hh.setStretchLastSection(True)
        table_tab_layout.addWidget(self.merged_table, 1)

        export_row = QHBoxLayout()
        export_row.addStretch()
        self.btn_export_csv = QPushButton(tr("compare_results.btn_export_csv"))
        self.btn_export_csv.clicked.connect(self._export_comparison_csv)
        self.btn_export_csv.setEnabled(False)
        export_row.addWidget(self.btn_export_csv)
        table_tab_layout.addLayout(export_row)

        self.output_tabs.addTab(table_tab, tr("compare_results.tab_table"))

        # Histogram overlay tab
        self.histogram_widget = HistogramWidget(
            pd.DataFrame(),
            initial_columns=[],
            title=tr("compare_results.histogram_title"),
            subtitle=tr("compare_results.histogram_subtitle"),
            parent=self,
        )
        self.output_tabs.addTab(self.histogram_widget, tr("compare_results.tab_histogram"))

        # Provenance tab (3a): only when a provider is supplied. Shows the
        # model snapshot / engine version / run time behind each result so the
        # comparison answers *why* they differ, not just *that* they differ.
        if self._provenance_provider is not None:
            self.provenance_table = QTableWidget()
            self.provenance_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
            self.provenance_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
            self.provenance_table.setAlternatingRowColors(True)
            self.output_tabs.addTab(self.provenance_table, "Provenance")

        v_splitter.addWidget(output_host)
        v_splitter.setStretchFactor(0, 1)
        v_splitter.setStretchFactor(1, 2)
        v_splitter.setSizes([260, 460])

        # Bottom buttons
        button_row = QHBoxLayout()
        if self._difference_report_provider is not None and self._label_to_step_id:
            self.btn_difference_report = QPushButton(tr("provenance.btn_difference_report"))
            self.btn_difference_report.setToolTip(tr("provenance.tip_difference_report"))
            self.btn_difference_report.clicked.connect(self._export_difference_report)
            button_row.addWidget(self.btn_difference_report)
        button_row.addStretch()
        self.close_button = QPushButton(tr("compare_results.btn_close"))
        self.close_button.clicked.connect(self.close)
        button_row.addWidget(self.close_button)
        layout.addLayout(button_row)

        self._populate_candidate_tree()

    def _format_header_text(self) -> str:
        return tr(
            "compare_results.header_primary",
            label=self.primary_label,
            engine=self.primary_engine,
            rows=len(self.primary_dataframe.index),
            columns=len(self.primary_dataframe.columns),
        )

    def _tree_header_labels(self) -> List[str]:
        return [
            tr("compare_results.col_result"),
            tr("compare_results.col_engine"),
            tr("compare_results.col_analysis"),
            tr("compare_results.col_study_workspace"),
        ]

    def retranslateUi(self) -> None:
        self.setWindowTitle(tr("compare_results.window_title"))
        if hasattr(self, "header_label"):
            self.header_label.setText(self._format_header_text())
        if hasattr(self, "picker_prompt_label"):
            self.picker_prompt_label.setText(tr("compare_results.picker_prompt", max=MAX_ADDITIONAL_RESULTS))
        if hasattr(self, "candidate_tree"):
            self.candidate_tree.setHeaderLabels(self._tree_header_labels())
        if hasattr(self, "btn_run_compare"):
            self.btn_run_compare.setText(tr("compare_results.btn_compare"))
        if hasattr(self, "btn_clear_selection"):
            self.btn_clear_selection.setText(tr("compare_results.btn_clear"))
        if hasattr(self, "btn_export_csv"):
            self.btn_export_csv.setText(tr("compare_results.btn_export_csv"))
        if hasattr(self, "close_button"):
            self.close_button.setText(tr("compare_results.btn_close"))
        if hasattr(self, "column_options_group"):
            self.column_options_group.setTitle(tr("compare_results.group_column_alignment"))
        if hasattr(self, "skip_unmatched_check"):
            self.skip_unmatched_check.setText(tr("compare_results.skip_unmatched"))
        if hasattr(self, "output_tabs") and self.output_tabs.count() >= 2:
            self.output_tabs.setTabText(0, tr("compare_results.tab_table"))
            self.output_tabs.setTabText(1, tr("compare_results.tab_histogram"))
        self._refresh_selection_status()
        # The "no other results" placeholder row, if shown, also needs refreshing.
        if not self.candidates and hasattr(self, "candidate_tree") and self.candidate_tree.topLevelItemCount() == 1:
            placeholder = self.candidate_tree.topLevelItem(0)
            if placeholder is not None and not (placeholder.flags() & Qt.ItemFlag.ItemIsUserCheckable):
                placeholder.setText(0, tr("compare_results.no_other_results"))

    def closeEvent(self, event):
        remove_listener(self.retranslateUi)
        super().closeEvent(event)

    # ------------------------------------------------------------------
    # Candidate tree
    # ------------------------------------------------------------------
    def _populate_candidate_tree(self) -> None:
        self.candidate_tree.blockSignals(True)
        self.candidate_tree.clear()

        groups: Dict[str, QTreeWidgetItem] = {}
        for candidate in self.candidates:
            group_key = f"{candidate.workspace_name} / {candidate.study_name}"
            group = groups.get(group_key)
            if group is None:
                group = QTreeWidgetItem(["", "", "", group_key])
                group.setFirstColumnSpanned(False)
                font = group.font(3)
                font.setBold(True)
                group.setFont(3, font)
                group.setFlags(group.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
                self.candidate_tree.addTopLevelItem(group)
                groups[group_key] = group
                group.setExpanded(True)

            item = QTreeWidgetItem([
                candidate.label,
                candidate.engine,
                candidate.analysis_name,
                "",
            ])
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            item.setCheckState(0, Qt.CheckState.Unchecked)
            item.setData(0, Qt.ItemDataRole.UserRole, candidate.label)
            group.addChild(item)

        if not self.candidates:
            placeholder = QTreeWidgetItem([tr("compare_results.no_other_results"), "", "", ""])
            placeholder.setFlags(placeholder.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
            self.candidate_tree.addTopLevelItem(placeholder)

        self.candidate_tree.blockSignals(False)
        self._refresh_selection_status()

    def _on_candidate_check_changed(self, item: QTreeWidgetItem, column: int) -> None:
        if column != 0 or item.flags() & Qt.ItemFlag.ItemIsUserCheckable == 0:
            return
        label = item.data(0, Qt.ItemDataRole.UserRole)
        if not label:
            return
        if item.checkState(0) == Qt.CheckState.Checked:
            if label not in self._comparison_set:
                if len(self._comparison_set) >= MAX_ADDITIONAL_RESULTS:
                    item.setCheckState(0, Qt.CheckState.Unchecked)
                    QMessageBox.information(
                        self,
                        tr("compare_results.selection_limit_title"),
                        tr("compare_results.selection_limit_body", max=MAX_ADDITIONAL_RESULTS),
                    )
                    return
                self._comparison_set.append(label)
        else:
            if label in self._comparison_set:
                self._comparison_set.remove(label)
        self._refresh_selection_status()

    def _clear_selection(self) -> None:
        self._comparison_set.clear()
        self.candidate_tree.blockSignals(True)
        for top_index in range(self.candidate_tree.topLevelItemCount()):
            top = self.candidate_tree.topLevelItem(top_index)
            for child_index in range(top.childCount()):
                child = top.child(child_index)
                if child.flags() & Qt.ItemFlag.ItemIsUserCheckable:
                    child.setCheckState(0, Qt.CheckState.Unchecked)
        self.candidate_tree.blockSignals(False)
        self._refresh_selection_status()

    def _refresh_selection_status(self) -> None:
        if not hasattr(self, "selection_status"):
            return
        count = len(self._comparison_set)
        self.selection_status.setText(
            tr("compare_results.status_template", count=count, max=MAX_ADDITIONAL_RESULTS)
        )
        if hasattr(self, "btn_run_compare"):
            self.btn_run_compare.setEnabled(count >= 1)

    # ------------------------------------------------------------------
    # Comparison
    # ------------------------------------------------------------------
    def _candidate_by_label(self, label: str) -> Optional[CompareCandidate]:
        for candidate in self.candidates:
            if candidate.label == label:
                return candidate
        return None

    def _ensure_dataframe(self, label: str) -> Optional[pd.DataFrame]:
        if label in self._loaded_frames:
            return self._loaded_frames[label]
        candidate = self._candidate_by_label(label)
        if candidate is None:
            return None
        try:
            df = candidate.load_dataframe()
        except Exception as exc:
            QMessageBox.critical(
                self,
                tr("compare_results.window_title"),
                tr("compare_results.err_load_result", label=label, error=exc),
            )
            return None
        if df is None:
            return None
        self._loaded_frames[label] = df.copy()
        return self._loaded_frames[label]

    def _verify_column_compatibility(
        self,
        labels_in_order: List[str],
    ) -> Dict[str, Any]:
        column_sets: Dict[str, set] = {}
        for label in labels_in_order:
            df = self._loaded_frames.get(label)
            if df is None:
                column_sets[label] = set()
            else:
                column_sets[label] = {str(c) for c in df.columns}

        if not column_sets:
            return {"common": set(), "missing_by_result": {}}

        all_columns: set = set()
        for cols in column_sets.values():
            all_columns.update(cols)
        common = set.intersection(*column_sets.values()) if column_sets else set()
        missing_by_result: Dict[str, List[str]] = {}
        for label, cols in column_sets.items():
            missing = sorted(all_columns - cols)
            if missing:
                missing_by_result[label] = missing
        return {"common": sorted(common), "missing_by_result": missing_by_result}

    def _run_comparison(self) -> None:
        if not self._comparison_set:
            return

        # Lazy-load any selected DataFrames
        labels_in_order = [self.primary_label] + list(self._comparison_set)
        for label in labels_in_order:
            if self._ensure_dataframe(label) is None:
                QMessageBox.warning(
                    self,
                    tr("compare_results.window_title"),
                    tr("compare_results.err_no_data", label=label),
                )
                return

        compatibility = self._verify_column_compatibility(labels_in_order)
        common = list(compatibility["common"])
        missing = compatibility["missing_by_result"]

        if missing:
            lines = []
            for label, cols in missing.items():
                preview = ", ".join(cols[:6]) + ("…" if len(cols) > 6 else "")
                lines.append(tr("compare_results.warning_missing_line", label=label, columns=preview))
            self.warning_label.setText(
                tr("compare_results.warning_columns_differ", details="\n".join(lines))
            )
            self.warning_label.setVisible(True)
        else:
            self.warning_label.setVisible(False)

        # Build merged DataFrame
        merged = self._build_merged_dataframe(labels_in_order, common)
        self._table_model.set_dataframe(merged)
        self.btn_export_csv.setEnabled(not merged.empty)

        # Drive histogram with all numeric columns, pre-select first per result
        numeric_cols = get_numeric_columns(merged)
        initial_cols = numeric_cols[: min(len(numeric_cols), 4)]
        self.histogram_widget.set_dataframe(merged, initial_cols)
        self.histogram_widget.title_text = tr(
            "compare_results.histogram_title_dynamic",
            count=len(labels_in_order),
            labels=", ".join(labels_in_order),
        )

        # Cache merged DataFrame for export
        self._merged_dataframe = merged

        # Provenance tab (3a)
        if self._provenance_provider is not None:
            self._populate_provenance(labels_in_order)

    def _populate_provenance(self, labels_in_order: List[str]) -> None:
        table = getattr(self, "provenance_table", None)
        if table is None:
            return
        columns = ["Result", "Model", "Model hash", "Engine ver.", "When", "Rows"]
        table.clear()
        table.setColumnCount(len(columns))
        table.setHorizontalHeaderLabels(columns)
        table.setRowCount(len(labels_in_order))
        primary_hash: Optional[str] = None
        for row_idx, label in enumerate(labels_in_order):
            step_id = self._label_to_step_id.get(label, "")
            info: Dict[str, Any] = {}
            if step_id:
                try:
                    info = self._provenance_provider(step_id) or {}
                except Exception:
                    info = {}
            model_hash = str(info.get("model_hash", "") or "")
            if row_idx == 0:
                primary_hash = model_hash
            short_hash = (model_hash[:12] + "…") if len(model_hash) > 12 else model_hash
            values = [
                label,
                str(info.get("model", "") or ""),
                short_hash,
                str(info.get("engine_version", "") or ""),
                str(info.get("when", "") or ""),
                str(info.get("rows", "") or ""),
            ]
            differs = bool(primary_hash and model_hash and model_hash != primary_hash)
            for col_idx, value in enumerate(values):
                item = QTableWidgetItem(value)
                if differs and col_idx in (1, 2):
                    item.setBackground(QColor(254, 243, 199))
                table.setItem(row_idx, col_idx, item)
        header = table.horizontalHeader()
        header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
        header.setStretchLastSection(True)

    def _build_merged_dataframe(
        self,
        labels_in_order: List[str],
        common_columns: List[str],
    ) -> pd.DataFrame:
        skip_unmatched = self.skip_unmatched_check.isChecked()

        # Identify a join key. Prefer ogc_fid / fid / OBJECTID / id if present
        # in *every* selected DataFrame.
        candidate_keys = ["ogc_fid", "fid", "FID", "OBJECTID", "id", "ID", "RowID"]
        join_key = None
        for key in candidate_keys:
            if all(key in self._loaded_frames[label].columns for label in labels_in_order):
                join_key = key
                break

        # Build a per-result frame keyed by the join key (or row index when missing)
        merged: Optional[pd.DataFrame] = None
        for label in labels_in_order:
            df = self._loaded_frames[label]
            if skip_unmatched:
                use_cols = [c for c in common_columns if c in df.columns]
            else:
                use_cols = list(df.columns)

            sub = df[use_cols].copy() if use_cols else df.copy()

            # Suffix columns (except the join key)
            rename_map = {}
            for col in sub.columns:
                if col == join_key:
                    continue
                rename_map[col] = f"{col} [{label}]"
            sub = sub.rename(columns=rename_map)

            if join_key:
                sub = sub.set_index(join_key, drop=True)
            else:
                sub.index.name = "row"

            if merged is None:
                merged = sub
            else:
                merged = merged.join(sub, how="outer")

        merged = merged if merged is not None else pd.DataFrame()
        merged = merged.reset_index()

        # When exactly two results are compared and the primary has a numeric
        # column shared with the secondary, append a Difference column for
        # each shared numeric column. Mirrors the legacy GenerateDifferenceTable
        # behaviour from RunCriticalDifferences.xaml.cs.
        if len(labels_in_order) == 2 and skip_unmatched:
            primary, secondary = labels_in_order
            for col in common_columns:
                pcol = f"{col} [{primary}]"
                scol = f"{col} [{secondary}]"
                if pcol in merged.columns and scol in merged.columns:
                    try:
                        # D5: a sentinel on either side makes the delta
                        # meaningless (1.8e308 - 0.67), so drop it to NaN and
                        # let the notna() guard below skip the row.
                        diff = (
                            drop_no_data(merged[scol]) - drop_no_data(merged[pcol])
                        ).reindex(merged.index)
                        if diff.notna().any():
                            merged[f"{col} Δ ({secondary} − {primary})"] = diff
                    except Exception:
                        continue

        return merged

    def _export_difference_report(self) -> None:
        """Build + save an HTML difference report between the primary and a comparison (S4).

        The comparison side is the first *selected* candidate (else the first
        available); both side labels are surfaced so the user knows which pair the
        report covers.
        """
        primary_id = self._label_to_step_id.get(self.primary_label, "")
        other_id = ""
        other_label = ""
        for label in (self._comparison_set or []):
            if label != self.primary_label and self._label_to_step_id.get(label):
                other_id = self._label_to_step_id[label]
                other_label = label
                break
        if not other_id:
            for label, step_id in self._label_to_step_id.items():
                if label != self.primary_label and step_id:
                    other_id = step_id
                    other_label = label
                    break
        if not primary_id or not other_id:
            QMessageBox.information(
                self, tr("provenance.mbox_difference_report"), tr("provenance.difference_need_two")
            )
            return
        try:
            html_text = self._difference_report_provider(primary_id, other_id)
        except Exception as exc:
            QMessageBox.warning(self, tr("provenance.mbox_difference_report"),
                                tr("provenance.report_build_failed", error=exc))
            return
        safe = "".join(c if c.isalnum() else "_" for c in f"{self.primary_label}_vs_{other_label}")[:80]
        path, _ = QFileDialog.getSaveFileName(
            self, tr("provenance.difference_save_title"), f"difference_{safe}.html", "HTML (*.html)"
        )
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as handle:
                handle.write(html_text)
        except OSError as exc:
            QMessageBox.warning(self, tr("provenance.mbox_difference_report"),
                                tr("provenance.report_write_failed", error=exc))
            return
        QMessageBox.information(
            self, tr("provenance.mbox_difference_report"),
            tr("provenance.difference_saved", a=self.primary_label, b=other_label, path=path),
        )

    def _export_comparison_csv(self) -> None:
        merged = getattr(self, "_merged_dataframe", None)
        if merged is None or merged.empty:
            return
        path, _ = QFileDialog.getSaveFileName(
            self,
            tr("compare_results.export_dialog_title"),
            "",
            tr("compare_results.csv_filter"),
        )
        if not path:
            return
        if not path.lower().endswith(".csv"):
            path += ".csv"
        try:
            merged.to_csv(path, index=False)
            QMessageBox.information(
                self,
                tr("compare_results.export_dialog_title"),
                tr("compare_results.export_success", path=path),
            )
        except Exception as exc:
            QMessageBox.critical(
                self,
                tr("compare_results.export_dialog_title"),
                tr("compare_results.export_failed", error=exc),
            )
