import logging
import sys
from datetime import timedelta
from typing import Dict, Optional

from PyQt6 import QtCore, uic
from PyQt6.QtCore import QItemSelectionModel, QMimeData, QPoint, Qt, pyqtSlot
from PyQt6.QtGui import QDesktopServices, QKeySequence, QShortcut
from PyQt6.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QHeaderView,
    QLayout,
    QMenu,
    QMessageBox,
    QStyledItemDelegate,
    QTableView,
    QTableWidgetItem,
    QWidget,
)

from vorta.borg.check import BorgCheckJob
from vorta.borg.compact import BorgCompactJob
from vorta.borg.delete import BorgDeleteJob
from vorta.borg.diff import BorgDiffJob
from vorta.borg.info_archive import BorgInfoArchiveJob
from vorta.borg.list_repo import BorgListRepoJob
from vorta.borg.prune import BorgPruneJob
from vorta.borg.rename import BorgRenameJob
from vorta.i18n import trans_late, translate
from vorta.i18n.richtext import escape, format_richtext, link
from vorta.store.models import ArchiveModel, SettingsModel
from vorta.utils import (
    borg_compat,
    find_best_unit_for_sizes,
    format_archive_name,
    get_asset,
    get_mount_points,
    pretty_bytes,
)
from vorta.views.archive.archive_extract import ArchiveExtract
from vorta.views.archive.archive_mount import ArchiveMount
from vorta.views.base_tab import BaseTab
from vorta.views.dialogs.archive import diff_result
from vorta.views.dialogs.archive.diff_result import DiffResultDialog, DiffTree
from vorta.views.source_tab import SizeItem
from vorta.views.utils import get_colored_icon

uifile = get_asset('UI/archive_tab.ui')
ArchiveTabUI, ArchiveTabBase = uic.loadUiType(uifile)

logger = logging.getLogger(__name__)


#: The number of decimal digits to show in the size column
SIZE_DECIMAL_DIGITS = 1


# from https://stackoverflow.com/questions/63177587/pyqt-tableview-align-icons-to-center
class IconDelegate(QStyledItemDelegate):
    def initStyleOption(self, option, index):
        super().initStyleOption(option, index)
        option.decorationSize = option.rect.size() - QtCore.QSize(0, 10)


class ArchiveTab(BaseTab, ArchiveTabBase, ArchiveTabUI):
    prune_intervals = ['hour', 'day', 'week', 'month', 'year']

    def __init__(self, parent=None, app=None, profile_provider=None):
        """Init."""
        super().__init__(parent=parent, profile_provider=profile_provider)
        self.setupUi(parent)
        self.is_editing = False  # track if the cell edit was completed or canceled
        self.mount_points = {}  # mapping of archive name to mount point
        self.repo_mount_point: Optional[str] = None  # mount point of whole repo
        self.menu = None
        self.app = app or self.app
        self.toolBox.setCurrentIndex(0)
        self.repoactions_enabled = True
        self.renamed_archive_original_name = None
        self.remaining_refresh_archives = (
            0  # number of archives that are left to refresh before action buttons are enabled again
        )

        self.archive_mount = ArchiveMount(self)
        self.archive_extract = ArchiveExtract(self)

        #: Tooltip dict to save the tooltips set in the designer
        self.tooltip_dict: Dict[QWidget, str] = {}
        self.tooltip_dict[self.bDiff] = self.bDiff.toolTip()
        self.tooltip_dict[self.bDelete] = self.bDelete.toolTip()
        self.tooltip_dict[self.bRefreshArchive] = self.bRefreshArchive.toolTip()
        self.tooltip_dict[self.compactButton] = self.compactButton.toolTip()

        header = self.archiveTable.horizontalHeader()
        header.setVisible(True)
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive)
        header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)

        delegate = IconDelegate(self.archiveTable)
        self.archiveTable.setItemDelegateForColumn(5, delegate)

        self.mount_help_text = trans_late('Form', 'To mount archives, first install "FUSE for macOS" from %1.')
        self.mount_help_link_text = trans_late('Form', 'here')
        self.pruning_help_text = trans_late(
            'Form',
            'Pruning removes older archives. You can choose the number of hourly, daily, etc. archives to preserve. '
            'Usually you will keep more newer and fewer old archives. Read %1.',
        )
        self.pruning_help_link_text = trans_late('Form', 'more')
        self._init_help_texts()

        if sys.platform != 'darwin':
            self._set_status('')  # Set platform-specific hints.

        self.archiveTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.archiveTable.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.archiveTable.setWordWrap(False)
        self.archiveTable.setTextElideMode(QtCore.Qt.TextElideMode.ElideLeft)
        self.archiveTable.setAlternatingRowColors(True)
        self.archiveTable.cellDoubleClicked.connect(self.cell_double_clicked)
        self.archiveTable.setSortingEnabled(True)
        self.archiveTable.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.archiveTable.customContextMenuRequested.connect(self.archiveitem_contextmenu)
        edit_delegate = self.archiveTable.itemDelegate()
        edit_delegate.closeEditor.connect(self.on_editing_finished)

        # shortcuts
        shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.archiveTable)
        shortcut_copy.activated.connect(self.archive_copy)

        # single and double selection feature
        self.archiveTable.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
        self.archiveTable.selectionModel().selectionChanged.connect(self.on_selection_change)

        # connect archive actions
        self.bMountArchive.clicked.connect(self.archive_mount.bmountarchive_clicked)
        self.bRefreshArchive.clicked.connect(self.refresh_archive_info)
        self.bRename.clicked.connect(self.cell_double_clicked)
        self.bDelete.clicked.connect(self.delete_action)
        self.bExtract.clicked.connect(self.archive_extract.extract_action)
        self.compactButton.clicked.connect(self.compact_action)

        # other signals
        self.bList.clicked.connect(self.refresh_archive_list)
        self.bPrune.clicked.connect(self.prune_action)
        self.bCheck.clicked.connect(self.check_action)
        self.bDiff.clicked.connect(self.diff_action)
        self.bMountRepo.clicked.connect(self.archive_mount.bmountrepo_clicked)

        self.archiveNameTemplate.textChanged.connect(
            lambda tpl, key='new_archive_name': self.save_archive_template(tpl, key)
        )
        self.prunePrefixTemplate.textChanged.connect(
            lambda tpl, key='prune_prefix': self.save_archive_template(tpl, key)
        )

        # Connect prune setting signals (only once in init, not in populate_from_profile)
        for i in self.prune_intervals:
            getattr(self, f'prune_{i}').valueChanged.connect(self.save_prune_setting)
        self.prune_keep_within.editingFinished.connect(self.save_prune_setting)

        self.track_profile_change(call_now=True)
        self.set_icons()

        # Connect to events
        self.track_palette_change()
        self.track_palette_change(self.populate_from_profile)
        self.track_backup_finished()
        self.track_profile_change(self.toggle_compact_button_visibility)
        self.track_signal(self.app.backup_cancelled_event, self.cancel_action)

    def set_icons(self):
        """Used when changing between light- and dark mode"""
        self.bCheck.setIcon(get_colored_icon('check-circle'))
        self.bDiff.setIcon(get_colored_icon('stream-solid'))
        self.bPrune.setIcon(get_colored_icon('cut'))
        self.bList.setIcon(get_colored_icon('refresh'))
        self.compactButton.setIcon(get_colored_icon('broom-solid'))
        self.toolBox.setItemIcon(0, get_colored_icon('tasks'))
        self.toolBox.setItemIcon(1, get_colored_icon('cut'))
        self.bRefreshArchive.setIcon(get_colored_icon('refresh'))
        self.bRename.setIcon(get_colored_icon('edit'))
        self.bDelete.setIcon(get_colored_icon('trash'))
        self.bExtract.setIcon(get_colored_icon('cloud-download'))

        self.archive_mount.bmountarchive_refresh(icon_only=True)
        self.archive_mount.bmountrepo_refresh()

    @pyqtSlot(QPoint)
    def archiveitem_contextmenu(self, pos: QPoint):
        # index under cursor
        index = self.archiveTable.indexAt(pos)
        if not index.isValid():
            return  # popup only for items

        selected_rows = self.archiveTable.selectionModel().selectedRows(index.column())

        if selected_rows and index not in selected_rows:
            return  # popup only for selected items

        menu = QMenu(self.archiveTable)
        menu.addAction(get_colored_icon('copy'), self.tr("Copy"), lambda: self.archive_copy(index=index))
        menu.addSeparator()

        # archive actions
        button_connection_pairs = [
            (self.bRefreshArchive, self.refresh_archive_info),
            (self.bDiff, self.diff_action),
            (self.bMountArchive, self.archive_mount.bmountarchive_clicked),
            (self.bExtract, self.archive_extract.extract_action),
            (self.bRename, self.cell_double_clicked),
            (self.bDelete, self.delete_action),
        ]

        for button, connection in button_connection_pairs:
            action = menu.addAction(button.icon(), button.text(), connection)
            action.setEnabled(button.isEnabled())

        menu.popup(self.archiveTable.viewport().mapToGlobal(pos))

    def cancel_action(self):
        self._set_status(self.tr("Action cancelled."))
        self._toggle_all_buttons(True)

    def _set_status(self, text):
        self.mountErrors.setText(text)
        self.mountErrors.repaint()

    def _init_help_texts(self):
        mount_template = self.mountErrors.text()
        mount_sentence = format_richtext(
            escape(translate('Form', self.mount_help_text)),
            link('https://macfuse.github.io/', translate('Form', self.mount_help_link_text)),
        )
        self.mountErrors.setText(format_richtext(mount_template, mount_sentence))

        prune_template = self.pruningHelpLabel.text()
        prune_sentence = format_richtext(
            escape(translate('Form', self.pruning_help_text)),
            link(
                'https://borgbackup.readthedocs.io/en/stable/usage/prune.html',
                translate('Form', self.pruning_help_link_text),
                color="#FF4500",
            ),
        )
        self.pruningHelpLabel.setText(format_richtext(prune_template, prune_sentence))

    def _toggle_all_buttons(self, enabled=True):
        """
        Set all the buttons in the archive panel to the given state.

        Parameters
        ----------
        enabled : bool, optional
            The enabled state, by default True
        """
        self.repoactions_enabled = enabled

        for button in [
            self.bCheck,
            self.bList,
            self.bPrune,
            self.bDiff,
            self.bMountRepo,
            self.bDelete,
            self.compactButton,
            self.fArchiveActions,
        ]:
            button.setEnabled(enabled)
            button.repaint()

        # Restore states
        self.on_selection_change()

    def populate_from_profile(self):
        """Populate archive list and prune settings from profile."""
        profile = self.profile()
        if profile.repo is not None:
            # get mount points
            self.mount_points, repo_mount_points = get_mount_points(profile.repo.url)
            if repo_mount_points:
                self.repo_mount_point = repo_mount_points[0]

            if profile.repo.name:
                repo_name = f"{profile.repo.name} ({profile.repo.url})"
            else:
                repo_name = profile.repo.url
            self.toolBox.setItemText(0, self.tr('Archives for {}').format(repo_name))

            archives = [s for s in profile.repo.archives.select().order_by(ArchiveModel.time.desc())]

            # if no archive's name can be found in self.mount_points, then hide the mount point column
            if not any(a.name in self.mount_points for a in archives):
                self.archiveTable.hideColumn(3)
            else:
                self.archiveTable.showColumn(3)

            sorting = self.archiveTable.isSortingEnabled()
            self.archiveTable.setSortingEnabled(False)
            best_unit = find_best_unit_for_sizes((a.size for a in archives), precision=SIZE_DECIMAL_DIGITS)
            for row, archive in enumerate(archives):
                self.archiveTable.insertRow(row)

                formatted_time = archive.time.strftime('%Y-%m-%d %H:%M')
                self.archiveTable.setItem(row, 0, QTableWidgetItem(formatted_time))

                # format units based on user settings for 'dynamic' or 'fixed' units
                fixed_unit = best_unit if SettingsModel.get(key='enable_fixed_units').value else None
                size = pretty_bytes(archive.size, fixed_unit=fixed_unit, precision=SIZE_DECIMAL_DIGITS)
                self.archiveTable.setItem(row, 1, SizeItem(size))

                if archive.duration is not None:
                    formatted_duration = str(timedelta(seconds=round(archive.duration)))
                else:
                    formatted_duration = ''

                self.archiveTable.setItem(row, 2, QTableWidgetItem(formatted_duration))

                mount_point = self.mount_points.get(archive.name)
                if mount_point is not None:
                    item = QTableWidgetItem(mount_point)
                    self.archiveTable.setItem(row, 3, item)

                self.archiveTable.setItem(row, 4, QTableWidgetItem(archive.name))

                if archive.trigger == 'scheduled':
                    item = QTableWidgetItem(get_colored_icon('clock-o'), '')
                    item.setToolTip(self.tr('Scheduled'))
                    self.archiveTable.setItem(row, 5, item)
                elif archive.trigger == 'user':
                    item = QTableWidgetItem(get_colored_icon('user'), '')
                    item.setToolTip(self.tr('User initiated'))
                    item.setTextAlignment(Qt.AlignmentFlag.AlignRight)
                    self.archiveTable.setItem(row, 5, item)

            self.archiveTable.setRowCount(len(archives))
            self.archiveTable.setSortingEnabled(sorting)
            item = self.archiveTable.item(0, 0)
            self.archiveTable.scrollToItem(item)

            self.archiveTable.selectionModel().clearSelection()
            if self.remaining_refresh_archives == 0:
                self._toggle_all_buttons(enabled=True)
        else:
            self.mount_points = {}
            self.archiveTable.setRowCount(0)
            self.toolBox.setItemText(0, self.tr('Archives'))
            self._toggle_all_buttons(enabled=False)

        self.archiveNameTemplate.setText(profile.new_archive_name)
        self.prunePrefixTemplate.setText(profile.prune_prefix)

        # Block signals so loading from the DB doesn't re-fire save_prune_setting
        # and overwrite prune_keep_within with stale QLineEdit text (#2493).
        self.prune_keep_within.blockSignals(True)
        try:
            self.prune_keep_within.setText(profile.prune_keep_within)
            for i in self.prune_intervals:
                widget = getattr(self, f'prune_{i}')
                widget.blockSignals(True)
                try:
                    widget.setValue(getattr(profile, f'prune_{i}'))
                finally:
                    widget.blockSignals(False)
        finally:
            self.prune_keep_within.blockSignals(False)

    def on_selection_change(self, selected=None, deselected=None):
        """
        React to a change of the selection of the archiveTableView.

        Enables or disables archive actions and the diff button.
        Makes sure at maximum 2 rows are selected.

        Parameters
        ----------
        selected : QItemSelection, optional
            The new selection.
        deselected : QItemSelection, optional
            The previous selection.
        """
        # handle selection of more than 2 rows
        selectionModel: QItemSelectionModel = self.archiveTable.selectionModel()
        indexes = selectionModel.selectedRows()
        # actions that are enabled only when a single archive is selected
        single_archive_action_buttons = [self.bMountArchive, self.bExtract, self.bRename]
        # actions that are enabled when at least one archive is selected
        multi_archive_action_buttons = [self.bDelete, self.bRefreshArchive]

        # Toggle archive actions frame
        layout: QLayout = self.fArchiveActions.layout()

        # task in progress -> disable all
        reason = ""
        if not self.repoactions_enabled:
            reason = self.tr("(borg already running)")

        # Disable the delete and refresh buttons if no archive is selected
        if self.repoactions_enabled and len(indexes) > 0:
            for button in multi_archive_action_buttons:
                button.setEnabled(True)
                button.setToolTip(self.tooltip_dict.get(button, ""))
        else:
            for button in multi_archive_action_buttons:
                button.setEnabled(False)
                button.setToolTip(self.tooltip_dict.get(button, "") + " " + self.tr("(Select minimum one archive)"))

        # Toggle diff button
        if self.repoactions_enabled and len(indexes) == 2:
            # Enable diff button
            self.bDiff.setEnabled(True)
            self.bDiff.setToolTip(self.tooltip_dict.get(self.bDiff, ""))
        else:
            # disable diff button
            self.bDiff.setEnabled(False)

            tooltip = self.tooltip_dict[self.bDiff]
            self.bDiff.setToolTip(tooltip + " " + reason or self.tr("(Select two archives)"))

        if self.repoactions_enabled and len(indexes) == 1:
            # Enable archive actions
            for widget in single_archive_action_buttons:
                widget.setEnabled(True)

            for index in range(layout.count()):
                widget = layout.itemAt(index).widget()
                if widget is not None:
                    widget.setToolTip(self.tooltip_dict.get(widget, ""))

            # refresh bMountArchive for the selected archive
            self.archive_mount.bmountarchive_refresh()
        else:
            reason = reason or self.tr("(Select exactly one archive)")

            # too few or too many selected.
            for widget in single_archive_action_buttons:
                tooltip = widget.toolTip()
                tooltip = self.tooltip_dict.setdefault(widget, tooltip)
                widget.setToolTip(tooltip + " " + reason)
                widget.setEnabled(False)

            # special treatment for dynamic mount/unmount button.
            self.archive_mount.bmountarchive_refresh()
            tooltip = self.bMountArchive.toolTip()
            self.bMountArchive.setToolTip(tooltip + " " + reason)

    def archive_copy(self, index=None):
        """
        Copy an archive name to the clipboard.

        Copies the first selected archive if no index is specified.
        """
        if index is None:
            indexes = self.archiveTable.selectionModel().selectedRows()

            if not indexes:
                return

            index = indexes[0]

        archive_name = self.archiveTable.item(index.row(), 4).text()

        data = QMimeData()
        data.setText(archive_name)

        QApplication.clipboard().setMimeData(data)

    def save_archive_template(self, tpl, key):
        profile = self.profile()
        try:
            preview = self.tr('Preview: %s') % format_archive_name(profile, tpl)
            setattr(profile, key, tpl)
            profile.save()
        except Exception:
            preview = self.tr('Error in archive name template.')

        if key == 'new_archive_name':
            self.archiveNamePreview.setText(preview)
        else:
            self.prunePrefixPreview.setText(preview)

    def check_action(self):
        params = BorgCheckJob.prepare(self.profile())
        if not params['ok']:
            self._set_status(params['message'])
            return

        # Conditions are met (borg binary available, etc)
        row_selected = self.archiveTable.selectionModel().selectedRows()
        if row_selected:
            archive_cell = self.archiveTable.item(row_selected[0].row(), 4)
            if archive_cell:
                archive_name = archive_cell.text()
                params['cmd'][-1] += f'::{archive_name}'

        job = BorgCheckJob(params['cmd'], params, self.profile().repo.id)
        job.updated.connect(self._set_status)
        job.result.connect(self.check_result)
        self._toggle_all_buttons(False)
        self.app.jobs_manager.add_job(job)

    def check_result(self, result):
        if result['returncode'] == 0:
            self._toggle_all_buttons(True)

    def compact_action(self):
        params = BorgCompactJob.prepare(self.profile())
        if params['ok']:
            job = BorgCompactJob(params['cmd'], params, self.profile().repo.id)
            job.updated.connect(self._set_status)
            job.result.connect(self.compact_result)
            self._toggle_all_buttons(False)
            self.app.jobs_manager.add_job(job)
        else:
            self._set_status(params['message'])

    def compact_result(self, result):
        self._toggle_all_buttons(True)

    def prune_action(self):
        params = BorgPruneJob.prepare(self.profile())
        if params['ok']:
            job = BorgPruneJob(params['cmd'], params, self.profile().repo.id)
            job.updated.connect(self._set_status)
            job.result.connect(self.prune_result)
            self._toggle_all_buttons(False)
            self.app.jobs_manager.add_job(job)
        else:
            self._set_status(params['message'])

    def prune_result(self, result):
        if result['returncode'] == 0:
            self._set_status(self.tr('Pruning finished.'))
            self.refresh_archive_list()
        else:
            self._toggle_all_buttons(True)

    def refresh_archive_list(self):
        params = BorgListRepoJob.prepare(self.profile())
        if params['ok']:
            job = BorgListRepoJob(params['cmd'], params, self.profile().repo.id)
            job.updated.connect(self._set_status)
            job.result.connect(self.list_result)
            self._toggle_all_buttons(False)
            self.app.jobs_manager.add_job(job)
        else:
            self._set_status(params['message'])

    def list_result(self, result):
        self._toggle_all_buttons(True)
        if result['returncode'] == 0:
            self._set_status(self.tr('Refreshed archives.'))
            self.populate_from_profile()

    def refresh_archive_info(self):
        selected_archives = self.archiveTable.selectionModel().selectedRows()

        archive_names = []
        for index in selected_archives:
            archive_names.append(self.archiveTable.item(index.row(), 4).text())

        self.remaining_refresh_archives = len(archive_names)  # number of archives to refresh
        self._toggle_all_buttons(False)
        for archive_name in archive_names:
            if archive_name is not None:
                params = BorgInfoArchiveJob.prepare(self.profile(), archive_name)
                if params['ok']:
                    job = BorgInfoArchiveJob(params['cmd'], params, self.profile().repo.id)
                    job.updated.connect(self._set_status)
                    job.result.connect(self.info_result)
                    self.app.jobs_manager.add_job(job)
                else:
                    self._set_status(params['message'])
                    return

    def info_result(self, result):
        self.remaining_refresh_archives -= 1
        if result['returncode'] == 0 and self.remaining_refresh_archives == 0:
            self._toggle_all_buttons(True)
            self._set_status(self.tr('Refreshed archives.'))
            self.populate_from_profile()

    def selected_archive_name(self):
        row_selected = self.archiveTable.selectionModel().selectedRows()
        if row_selected:
            archive_cell = self.archiveTable.item(row_selected[0].row(), 4)
            if archive_cell:
                return archive_cell.text()
        return None

    def save_prune_setting(self, new_value=None):
        profile = self.profile()
        for i in self.prune_intervals:
            setattr(profile, f'prune_{i}', getattr(self, f'prune_{i}').value())
        profile.prune_keep_within = self.prune_keep_within.text()
        profile.save()

    def cell_double_clicked(self, row=None, column=None):
        if not self.bRename.isEnabled():
            return

        if not row or not column:
            row = self.archiveTable.currentRow()
            column = self.archiveTable.currentColumn()

        if column == 3:
            archive_name = self.selected_archive_name()
            if not archive_name:
                return

            mount_point = self.mount_points.get(archive_name)

            if mount_point is not None:
                QDesktopServices.openUrl(QtCore.QUrl(f'file:///{mount_point}'))

        if column == 4:
            item = self.archiveTable.item(row, column)
            self.renamed_archive_original_name = item.text()
            self.is_editing = True
            item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsEditable)
            self.archiveTable.editItem(item)

    def on_editing_finished(self, editor, hint):
        """Called when the user finishes editing a cell (By pressing Enter or clicking away)."""
        if not self.is_editing:
            return

        row = self.archiveTable.currentRow()
        column = self.archiveTable.currentColumn()

        self.cell_changed(row, column)

    def cell_changed(self, row, column):
        # return if this is not a name change
        if column != 4 or not self.is_editing:
            return

        item = self.archiveTable.item(row, column)
        new_name = item.text()
        profile = self.profile()

        # if the name hasn't changed or if this slot is called when first repopulating the table, do nothing.
        if new_name == self.renamed_archive_original_name:
            self.is_editing = False
            return

        # if new name is blank, revert to the original name
        if not new_name:
            item.setText(self.renamed_archive_original_name)
            self._set_status(self.tr('Archive name cannot be blank.'))
            self.is_editing = False
            return

        # check if the new name already exists
        new_name_exists = ArchiveModel.get_or_none(name=new_name, repo=profile.repo)
        if new_name_exists is not None:
            self._set_status(self.tr('An archive with this name already exists.'))
            item.setText(self.renamed_archive_original_name)
            self.is_editing = False
            return

        params = BorgRenameJob.prepare(profile, self.renamed_archive_original_name, new_name)
        if not params['ok']:
            self._set_status(params['message'])
            self.is_editing = False
            return

        self._set_status(self.tr('Renaming archive...'))
        job = BorgRenameJob(params['cmd'], params, self.profile().repo.id)
        job.updated.connect(self._set_status)
        job.result.connect(self.rename_result)
        self._toggle_all_buttons(False)
        self.app.jobs_manager.add_job(job)
        self.is_editing = False

    def row_of_archive(self, archive_name):
        items = self.archiveTable.findItems(archive_name, QtCore.Qt.MatchFlag.MatchExactly)
        rows = [item.row() for item in items if item.column() == 4]
        return rows[0] if rows else None

    def confirm_dialog(self, title, text):
        msg = QMessageBox()
        msg.setIcon(QMessageBox.Icon.Information)
        msg.setText(text)
        msg.setWindowTitle(title)
        msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
        msg.button(QMessageBox.StandardButton.Yes).setText(self.tr("Yes"))
        msg.button(QMessageBox.StandardButton.Cancel).setText(self.tr("Cancel"))
        return msg.exec() == QMessageBox.StandardButton.Yes

    def delete_action(self):
        # Since this function modify the UI, we can't put the whole function in a JobQueue.

        # determine selected archives
        archives = []
        for index in self.archiveTable.selectionModel().selectedRows():
            archive_cell = self.archiveTable.item(index.row(), 4)
            if archive_cell:
                archives.append(archive_cell.text())

        if not archives:
            self._set_status(self.tr("No archive selected"))
            return

        params = BorgDeleteJob.prepare(self.profile(), archives)
        if not params['ok']:
            self._set_status(params['message'])
            return

        if len(archives) > 1:
            body = self.tr("Are you sure you want to delete all the selected archives?")
        else:
            body = self.tr("Are you sure you want to delete the selected archive?")
        if not self.confirm_dialog(self.tr("Confirm deletion"), body):
            return

        job = BorgDeleteJob(params['cmd'], params, self.profile().repo.id)
        job.updated.connect(self._set_status)
        job.result.connect(self.delete_result)
        self._toggle_all_buttons(False)
        self.app.jobs_manager.add_job(job)

    def delete_result(self, result):
        archives = result['params']['archives']
        if result['returncode'] == 0:
            if len(archives) > 1:
                status = self.tr('Archives deleted.')
            else:
                status = self.tr('Archive deleted.')
            self._set_status(status)

            # remove rows from list and database
            for archive in archives:
                for entry in self.archiveTable.findItems(archive, QtCore.Qt.MatchFlag.MatchExactly):
                    self.archiveTable.removeRow(entry.row())
                archive_obj = ArchiveModel.get_or_none(name=archive)
                if archive_obj:
                    archive_obj.delete_instance()

        self._toggle_all_buttons(True)

    def diff_action(self):
        """
        Handle the diff button being clicked.

        Exactly two archives must be selected in `archiveTable`. This is
        usually enforced by `on_selection_change`.
        """
        selected_archives = self.archiveTable.selectionModel().selectedRows()
        profile = self.profile()

        name1 = self.archiveTable.item(selected_archives[0].row(), 4).text()
        name2 = self.archiveTable.item(selected_archives[1].row(), 4).text()

        archive1, archive2 = (
            profile.repo.archives.select()
            .where((ArchiveModel.name == name1) | (ArchiveModel.name == name2))
            .order_by(ArchiveModel.time.desc())
        )

        archive_name_newer = archive1.name
        archive_name_older = archive2.name

        # Start diff job
        params = BorgDiffJob.prepare(profile, archive_name_older, archive_name_newer)

        if params['ok']:
            self._toggle_all_buttons(False)
            job = BorgDiffJob(params['cmd'], params, self.profile().repo.id)
            job.updated.connect(self.mountErrors.setText)
            job.result.connect(self.list_diff_result)
            self.app.jobs_manager.add_job(job)
        else:
            self._set_status(params['message'])

    def list_diff_result(self, result):
        """
        Process the result of the `BorgDiffJob`.

        The `BorgDiffJob` was initiated by `diff_action`.

        Parameters
        ----------
        result : dict
            The BorgJob result.
        """
        self._set_status('')
        if result['returncode'] == 0:
            archive_newer = ArchiveModel.get(name=result['params']['archive_name_newer'])
            archive_older = ArchiveModel.get(name=result['params']['archive_name_older'])
            self._set_status(self.tr("Processing diff results."))

            model = DiffTree()

            self._t = diff_result.ParseThread(result['data'], result['params']['json_lines'], model)
            self._t.finished.connect(lambda: self.show_diff_result(archive_newer, archive_older, model))
            self._t.start()

    def show_diff_result(self, archive_newer, archive_older, model):
        self._t = None

        # show dialog
        self._toggle_all_buttons(True)
        self._set_status('')
        window = DiffResultDialog(archive_newer, archive_older, model)
        window.setParent(self)
        window.setWindowFlags(Qt.WindowType.Window)
        window.setWindowModality(Qt.WindowModality.NonModal)
        self._resultwindow = window  # for testing
        window.show()

    def rename_result(self, result):
        if result['returncode'] == 0:
            self.refresh_archive_info()
            self._set_status(self.tr('Archive renamed.'))
            self.renamed_archive_original_name = None
            self.populate_from_profile()
        else:
            self._toggle_all_buttons(True)

    def toggle_compact_button_visibility(self):
        """
        Enable or disable the compact button depending on the Borg version.
        This function runs once on startup, and every time the profile is changed.
        """
        if borg_compat.check("COMPACT_SUBCOMMAND"):
            self.compactButton.setEnabled(True)
            self.compactButton.setToolTip(self.tooltip_dict[self.compactButton])
        else:
            self.compactButton.setEnabled(False)
            tooltip = self.tooltip_dict[self.compactButton]
            self.compactButton.setToolTip(tooltip + " " + self.tr("(This feature needs Borg 1.2.0 or higher)"))
