# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
"""
Provides a modal dialog box for modifying the AWS Deadline Cloud
local workstation configuration.

Example code:
    from deadline.client.ui.dialogs import DeadlineConfigDialog
    DeadlineConfigDialog.configure_settings(parent=self)
"""

__all__ = ["DeadlineConfigDialog"]

from configparser import ConfigParser
from logging import getLogger, root
from typing import Callable, Dict, List, Optional

import boto3  # type: ignore[import]
from botocore.exceptions import ProfileNotFound  # type: ignore[import]
from deadline.job_attachments.models import FileConflictResolution, JobAttachmentsFileSystem
from qtpy.QtCore import QSize, Qt, Signal
from qtpy.QtWidgets import (  # pylint: disable=import-error; type: ignore
    QApplication,
    QCheckBox,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QFileDialog,
    QFormLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QListWidget,
    QMessageBox,
    QPushButton,
    QSizePolicy,
    QSpacerItem,
    QStyle,
    QVBoxLayout,
    QWidget,
    QScrollArea,
)

import os

from ... import api
from ..deadline_authentication_status import DeadlineAuthenticationStatus
from ...config import config_file, get_setting_default, str2bool
from .._utils import block_signals, tr
from ..widgets import DirectoryPickerWidget
from ..widgets.deadline_authentication_status_widget import DeadlineAuthenticationStatusWidget
from ..widgets import (
    DeadlineFarmListComboBoxController,
    DeadlineQueueListComboBoxController,
    DeadlineStorageProfileListComboBoxController,
)
from .deadline_login_dialog import DeadlineLoginDialog

logger = getLogger(__name__)

NOT_VALID_MARKER = "[NOT VALID]"


class DeadlineConfigDialog(QDialog):
    """
    A modal dialog box for modifying the AWS Deadline Cloud local workstation
    configuration.

    Example code:
        DeadlineConfigDialog.configure_settings(parent=self)
    """

    @staticmethod
    def configure_settings(
        parent: Optional[QWidget] = None, set_profile_focus: bool = False
    ) -> bool:
        """
        Static method that runs the Deadline Config Dialog.

        Args:
            parent: Parent widget
            set_profile_focus: Optional boolean to set the initial focus to the profile selector

        Returns True if any changes were applied, False otherwise.
        """
        deadline_config = DeadlineConfigDialog(parent=parent)

        if set_profile_focus:
            deadline_config.config_box.aws_profiles_box.setFocus()

        deadline_config.exec_()
        return deadline_config.changes_were_applied

    def __init__(self, parent: Optional[QWidget] = None) -> None:
        super().__init__(
            parent=parent, f=Qt.WindowSystemMenuHint | Qt.WindowTitleHint | Qt.WindowCloseButtonHint
        )

        self.setWindowTitle(tr("AWS Deadline Cloud workstation configuration"))
        self.deadline_authentication_status = DeadlineAuthenticationStatus.getInstance()
        self._build_ui()

    def sizeHint(self):
        # Get available screen space for adaptive sizing
        screen = QApplication.primaryScreen().availableGeometry()
        available_height = screen.height()

        # Calculate optimal dialog height based on screen size
        content_height = 850  # Height needed to show all content without scroll bars
        max_dialog_height = int(available_height * 0.9)  # 90% of screen height

        # Use smaller of content height or max screen percentage
        optimal_height = min(content_height, max_dialog_height)

        return QSize(650, optimal_height)

    def _build_ui(self):
        self.layout = QVBoxLayout(self)

        self.config_box = DeadlineWorkstationConfigWidget(parent=self)

        self.scrollArea = DeadlineScrollArea(self)
        self.scrollArea.setWidget(self.config_box)
        # Enable widget resizing within scroll area
        self.scrollArea.setWidgetResizable(True)
        self.scrollArea.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
        # Hide the scroll area border for a cleaner appearance
        self.scrollArea.setStyleSheet("QScrollArea { border: none; }")

        self.layout.addWidget(self.scrollArea)

        self.config_box.refreshed.connect(self.on_refresh)

        self.auth_status_box = DeadlineAuthenticationStatusWidget(
            parent=self, show_profile_switch=False
        )
        self.layout.addWidget(self.auth_status_box)
        self.deadline_authentication_status.deadline_config_changed.connect(self.config_box.refresh)
        self.deadline_authentication_status.api_availability_changed.connect(
            self.on_auth_status_update
        )

        # We only use a Close button, not OK/Cancel, because we live update the settings.
        self.button_box = QDialogButtonBox(
            QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Apply, Qt.Horizontal
        )
        self.button_box.button(QDialogButtonBox.Ok).setText(tr("Ok"))
        self.button_box.button(QDialogButtonBox.Cancel).setText(tr("Cancel"))
        self.button_box.button(QDialogButtonBox.Apply).setText(tr("Apply"))
        self.button_box.accepted.connect(self.accept)
        self.button_box.rejected.connect(self.reject)
        self.button_box.clicked.connect(self.on_button_box_clicked)
        self.auth_status_box.logout_clicked.connect(self.on_logout)
        self.auth_status_box.login_clicked.connect(self.on_login)
        self.layout.addWidget(self.button_box)

        # Refresh the lists so queue/farm show the description instead of the ID
        self.config_box.refresh_lists()

    @property
    def changes_were_applied(self) -> bool:
        return self.config_box.changes_were_applied

    def accept(self):
        if self.config_box.apply():
            super().accept()

    def reject(self):
        self.deadline_authentication_status.set_config(config_file.read_config())
        super().reject()

    def on_login(self):
        DeadlineLoginDialog.login(parent=self, config=self.config_box.config)
        self.deadline_authentication_status.refresh_status()
        self.config_box.refresh()

    def on_logout(self):
        api.logout(config=self.config_box.config)
        self.deadline_authentication_status.refresh_status()
        self.config_box.refresh()

    def on_button_box_clicked(self, button):
        if self.button_box.standardButton(button) == QDialogButtonBox.Apply:
            self.config_box.apply()

    def on_refresh(self):
        # Enable the "Apply" button only if there are changes
        self.button_box.button(QDialogButtonBox.Apply).setEnabled(bool(self.config_box.changes))
        # Update the auth status with the refreshed config
        self.deadline_authentication_status.set_config(self.config_box.config)

    def on_auth_status_update(self):
        # If the AWS Deadline Cloud API is authorized successfully for the AWS profile
        # in the config dialog, refresh the farm/queue lists
        if self.deadline_authentication_status.api_availability and config_file.get_setting(
            "defaults.aws_profile_name", self.deadline_authentication_status.config
        ) == config_file.get_setting("defaults.aws_profile_name", self.config_box.config):
            self.config_box.refresh_lists()


class DeadlineScrollArea(QScrollArea):
    def __init__(self, parent: Optional[QWidget] = None):
        super().__init__(parent)

    def sizeHint(self):
        return QSize(500, 400)


class DeadlineWorkstationConfigWidget(QWidget):
    """
    A widget that displays and edits the AWS Deadline Cloud local workstation.
    """

    # Signal for when the GUI is refreshed
    refreshed = Signal()

    # Emitted when an async refresh_queues_list thread completes,
    # provides (aws_profile_name, farm_id, [(queue_id, queue_name), ...])
    _queue_list_update = Signal(str, str, list)
    # Emitted when an async refresh_storage_profiles_name_list thread completes,
    # provides (aws_profile_name, farm_id, queue_id, [storage_profile_id, ...])
    _storage_profile_list_update = Signal(str, str, list)
    # This signal is sent when any background refresh thread catches an exception,
    # provides (operation_name, BaseException)
    _background_exception = Signal(str, BaseException)

    def __init__(self, parent: Optional[QWidget] = None):
        super().__init__(parent)

        self.changes: dict = {}
        self.config: Optional[ConfigParser] = None
        self.changes_were_applied = False

        # Flags to track when we're waiting for cascading list refreshes
        # These prevent the list_updated handlers from interfering with manual user selections
        self._awaiting_farms_for_cascade = False
        self._awaiting_queues_for_cascade = False

        self._build_ui()
        self._fill_aws_profiles_box()
        self.refresh()

    def minimumSizeHint(self):
        return QSize(500, 700)

    def _build_ui(self):
        # Ensure the widget expands horizontally
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)

        self.v_layout = QVBoxLayout(self)
        self.setLayout(self.v_layout)

        # Set consistent spacing between group boxes and margins
        self.v_layout.setSpacing(20)  # 20px spacing between group boxes
        self.v_layout.setContentsMargins(10, 10, 10, 10)  # 10px margins around content

        # Simplified stylesheet - focus only on title positioning, use layout for spacing
        GROUP_BOX_STYLE_SHEET = """
        QGroupBox::title {
            subcontrol-origin: margin;
            padding-left: 5px;
            top: 0px;
        }
        QGroupBox {
            margin-top: 20px;
        }
        """

        self.labels = {}
        self._refresh_callbacks: List[Callable] = []

        # Global settings
        self.global_settings_group = QGroupBox(parent=self, title=tr("Global settings"))
        self.global_settings_group.setStyleSheet(GROUP_BOX_STYLE_SHEET)
        self.global_settings_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        self.v_layout.addWidget(self.global_settings_group)
        global_settings_layout = QFormLayout(self.global_settings_group)
        self._build_global_settings_ui(self.global_settings_group, global_settings_layout)

        # AWS Profile-specific settings
        self.profile_settings_group = QGroupBox(parent=self, title=tr("Profile settings"))
        self.profile_settings_group.setStyleSheet(GROUP_BOX_STYLE_SHEET)
        self.profile_settings_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        self.v_layout.addWidget(self.profile_settings_group)
        profile_settings_layout = QFormLayout(self.profile_settings_group)
        self._build_profile_settings_ui(self.profile_settings_group, profile_settings_layout)

        # Farm-specific settings
        self.farm_settings_group = QGroupBox(parent=self, title=tr("Farm settings"))
        self.farm_settings_group.setStyleSheet(GROUP_BOX_STYLE_SHEET)
        self.farm_settings_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        self.v_layout.addWidget(self.farm_settings_group)
        farm_settings_layout = QFormLayout(self.farm_settings_group)
        self._build_farm_settings_ui(self.farm_settings_group, farm_settings_layout)

        # General settings
        self.general_settings_group = QGroupBox(parent=self, title=tr("General settings"))
        self.general_settings_group.setStyleSheet(GROUP_BOX_STYLE_SHEET)
        self.general_settings_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        self.v_layout.addWidget(self.general_settings_group)
        general_settings_layout = QFormLayout(self.general_settings_group)
        self._build_general_settings_ui(self.general_settings_group, general_settings_layout)

        # Add vertical spacer to push content to top and prevent group boxes from expanding
        self.v_layout.addItem(QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding))

        self._background_exception.connect(self.handle_background_exception)

    def _build_global_settings_ui(self, group, layout):
        layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)

        self.aws_profiles_box = QComboBox(parent=group)
        aws_profile_label = self.labels["defaults.aws_profile_name"] = QLabel(tr("AWS profile"))
        layout.addRow(aws_profile_label, self.aws_profiles_box)
        self.aws_profiles_box.currentTextChanged.connect(self.aws_profile_changed)

    def _build_profile_settings_ui(self, group, layout):
        layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)

        self.job_history_dir_edit = DirectoryPickerWidget(
            initial_directory="",
            directory_label=tr("Job history directory"),
            parent=group,
            collapse_user_dir=True,
        )
        job_history_dir_label = self.labels["settings.job_history_dir"] = QLabel(
            tr("Job history directory")
        )
        layout.addRow(job_history_dir_label, self.job_history_dir_edit)
        self.job_history_dir_edit.path_changed.connect(self.job_history_dir_changed)

        self.default_farm_box = DeadlineFarmListComboBoxController(parent=group)
        default_farm_box_label = self.labels["defaults.farm_id"] = QLabel(tr("Default farm"))
        self.default_farm_box.box.currentIndexChanged.connect(self.default_farm_changed)
        self.default_farm_box.background_exception.connect(self.handle_background_exception)
        layout.addRow(default_farm_box_label, self.default_farm_box)

    def _build_farm_settings_ui(self, group, layout):
        layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)

        self.default_queue_box = DeadlineQueueListComboBoxController(parent=group)
        default_queue_box_label = self.labels["defaults.queue_id"] = QLabel(tr("Default queue"))
        self.default_queue_box.box.currentIndexChanged.connect(self.default_queue_changed)
        self.default_queue_box.background_exception.connect(self.handle_background_exception)
        layout.addRow(default_queue_box_label, self.default_queue_box)

        # Connect to controller signals to handle cascading updates when lists are populated
        # (e.g., after profile or farm change)
        from ..controllers import DeadlineUIController

        self._controller = DeadlineUIController.getInstance()
        self._controller.farms_updated.connect(self._on_farms_list_updated, Qt.QueuedConnection)
        self._controller.queues_updated.connect(self._on_queues_list_updated, Qt.QueuedConnection)

        self.default_storage_profile_box = DeadlineStorageProfileListComboBoxController(
            parent=group
        )
        default_storage_profile_box_label = self.labels["settings.storage_profile_id"] = QLabel(
            tr("Default storage profile")
        )
        self.default_storage_profile_box.box.currentIndexChanged.connect(
            self.default_storage_profile_name_changed
        )
        self.default_storage_profile_box.background_exception.connect(
            self.handle_background_exception
        )
        layout.addRow(default_storage_profile_box_label, self.default_storage_profile_box)

        item_name_copied = JobAttachmentsFileSystem.COPIED.value
        item_name_virtual = JobAttachmentsFileSystem.VIRTUAL.value
        job_attachments_file_system_tooltip = (
            "This setting determines how job attachments are loaded on the worker instance. "
            f"'{item_name_copied}' may be faster if every task needs all attachments, while "
            f"'{item_name_virtual}' may perform better if tasks only require a subset of attachments."
        )
        values_with_tooltips = {
            item_name_copied: "When selected, the worker downloads all job attachments to disk before rendering begins.",
            item_name_virtual: "When selected, the worker downloads attachments only when needed by each task.",
        }
        self.job_attachments_file_system_box = self._init_combobox_setting_with_tooltips(
            group=group,
            layout=layout,
            setting_name="defaults.job_attachments_file_system",
            label_text=tr("Job attachments filesystem options"),
            label_tooltip=job_attachments_file_system_tooltip,
            values_with_tooltips=values_with_tooltips,
        )

    def _build_general_settings_ui(self, group, layout):
        layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)

        self.auto_accept = self._init_checkbox_setting(
            group, layout, "settings.auto_accept", tr("Auto accept prompt defaults")
        )
        self.telemetry_opt_out = self._init_checkbox_setting(
            group, layout, "telemetry.opt_out", tr("Telemetry opt out")
        )
        self.force_s3_check = self._init_checkbox_setting(
            group, layout, "settings.force_s3_check", tr("Always check S3 job attachments")
        )

        self._conflict_resolution_options = [option.name for option in FileConflictResolution]
        self.conflict_resolution_box = self._init_combobox_setting(
            group,
            layout,
            "settings.conflict_resolution",
            tr("Conflict resolution option"),
            self._conflict_resolution_options,
        )

        self._log_levels = ["ERROR", "WARNING", "INFO", "DEBUG"]
        self.log_level_box = self._init_combobox_setting(
            group,
            layout,
            "settings.log_level",
            tr("Current logging level"),
            self._log_levels,
        )

        # Locale selector
        self._locales = [
            ("", "System Default"),
            ("de_DE", "Deutsch (Deutschland)"),
            ("en_US", "English (United States)"),
            ("es_ES", "Español (España)"),
            ("fr_FR", "Français (France)"),
            ("id_ID", "Bahasa Indonesia (Indonesia)"),
            ("it_IT", "Italiano (Italia)"),
            ("ja_JP", "日本語 (日本)"),
            ("ko_KR", "한국어 (대한민국)"),
            ("pt_BR", "Português (Brasil)"),
            ("tr_TR", "Türkçe (Türkiye)"),
            ("zh_CN", "中文 (简体)"),
            ("zh_TW", "中文 (繁體)"),
        ]
        self.locale_box = self._init_combobox_setting_with_display_names(
            group,
            layout,
            "settings.locale",
            tr("Language"),
            self._locales,
        )

        # Add message label for language change notification
        self.locale_change_message = QLabel()
        self.locale_change_message.setStyleSheet("QLabel { font-style: italic; }")
        self.locale_change_message.hide()
        layout.addRow("", self.locale_change_message)

        # Add refresh callback for locale message
        def refresh_locale_message():
            if "settings.locale" in self.changes:
                self.locale_change_message.setText(
                    tr("Language will change next time the submitter is opened")
                )
                self.locale_change_message.show()
            else:
                self.locale_change_message.hide()

        self._refresh_callbacks.append(refresh_locale_message)

        # Known asset paths section
        known_paths_label = QLabel(tr("Known asset paths"))
        known_paths_label.setToolTip(
            "Paths that should not generate warnings when outside storage profile locations"
        )
        self.labels["settings.known_asset_paths"] = known_paths_label

        known_paths_widget = QWidget(parent=group)
        known_paths_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
        known_paths_layout = QVBoxLayout(known_paths_widget)
        known_paths_layout.setContentsMargins(0, 0, 0, 0)

        # Add buttons and status label
        button_layout = QHBoxLayout()
        self.add_known_path_button = QPushButton(tr("Add..."))
        self.add_known_path_button.clicked.connect(self._on_add_known_path)
        self.edit_known_path_button = QPushButton(tr("Edit..."))
        self.edit_known_path_button.clicked.connect(self._on_edit_known_path)
        self.remove_known_path_button = QPushButton(tr("Remove Selected"))
        self.remove_known_path_button.clicked.connect(self._on_remove_known_path)
        self.known_paths_status = QLabel()
        button_layout.addWidget(self.add_known_path_button)
        button_layout.addWidget(self.edit_known_path_button)
        button_layout.addWidget(self.remove_known_path_button)
        button_layout.addWidget(self.known_paths_status)
        known_paths_layout.addLayout(button_layout)

        # Add path list
        self.known_paths_list = QListWidget(parent=known_paths_widget)
        self.known_paths_list.setAlternatingRowColors(True)
        known_paths_layout.addWidget(self.known_paths_list)

        layout.addRow(known_paths_label, known_paths_widget)

        # Add refresh callback for known paths
        def refresh_known_paths():
            with block_signals(self.known_paths_list):
                self.known_paths_list.clear()
                paths_str = config_file.get_setting(
                    "settings.known_asset_paths", config=self.config
                )
                if paths_str:
                    paths = paths_str.split(os.pathsep)
                    self.known_paths_list.addItems(paths)

            # Update status label
            count = self.known_paths_list.count()
            self.known_paths_status.setText(f"{count} paths")

            self._update_known_paths_buttons()

        self._refresh_callbacks.append(refresh_known_paths)
        self.known_paths_list.itemSelectionChanged.connect(self._update_known_paths_buttons)

    def _init_checkbox_setting(
        self, group: QWidget, layout: QFormLayout, setting_name: str, label_text: str
    ) -> QCheckBox:
        """
        Creates a checkbox setting and adds it to the specified group and layout. This function also connects state
        change logic, refresh logic, and logic to save the setting in the config file.

        Args:
            group (QWidget): The parent of the checkbox
            layout (QFormLayout): The layout to add a row to for the checkbox
            setting_name (str): The setting name as provided to the config. E.g. "settings.foo_bar"
            label_text (str): The displayed description. E.g. "Foo Bar"

        Returns:
            QCheckBox: The created checkbox.
        """
        checkbox = QCheckBox(parent=group)
        label = self.labels[setting_name] = QLabel(label_text)
        layout.addRow(label, checkbox)

        def refresh_checkbox():
            """Function that refreshes the state of the checkbox based on the setting name"""
            with block_signals(checkbox):
                try:
                    state = str2bool(config_file.get_setting(setting_name, config=self.config))
                except ValueError as e:
                    logger.warning(f"{e} for '{setting_name}'")
                    state = False
                checkbox.setChecked(state)

        self._refresh_callbacks.append(refresh_checkbox)

        def checkbox_changed(new_state: int):
            """Callback for if the state of a given checkbox has changed"""
            value = str(checkbox.isChecked())
            self.changes[setting_name] = value
            self.refresh()

        checkbox.stateChanged.connect(checkbox_changed)

        return checkbox

    def _init_combobox_setting(
        self,
        group: QWidget,
        layout: QFormLayout,
        setting_name: str,
        label_text: str,
        values: List[str],
    ):
        """
        Creates a combobox setting and adds it to the specified group and layout. This function also connects state
        change logic, refresh logic, and logic to save the setting in the config file.

        Args:
            group (QWidget): The parent of the combobox
            layout (QFormLayout): The layout to add a row to for the combobox
            setting_name (str): The setting name as provided to the config. E.g. "settings.foo_bar"
            label_text (str): The displayed description. E.g. "Foo Bar"
            values (List[str]): The list of values that can be selected. E.g. ["Option A", "Option B"]

        Returns:
            QComboBox: The created combobox.
        """
        label = QLabel(label_text)
        combo_box = QComboBox(parent=group)
        layout.addRow(label, combo_box)
        combo_box.addItems(values)

        def refresh_combo_box():
            """Function that refreshes the state of the combo box based on the setting name"""
            with block_signals(combo_box):
                value = config_file.get_setting(setting_name, config=self.config)
                if value not in values:
                    default = get_setting_default(setting_name, config=self.config)
                    logger.warning(f"'{value}' is not one of {values}. Defaulting to '{default}'.")
                    value = default
                combo_box.setCurrentText(value)

        def combo_box_changed(new_value):
            """Callback for if the state of a given checkbox has changed"""
            self.changes[setting_name] = new_value
            self.refresh()

        combo_box.currentTextChanged.connect(combo_box_changed)

        self._refresh_callbacks.append(refresh_combo_box)

    def _init_combobox_setting_with_tooltips(
        self,
        group: QWidget,
        layout: QFormLayout,
        setting_name: str,
        label_text: str,
        label_tooltip: str,
        values_with_tooltips: Dict[str, str],
    ):
        """
        Creates and adds a combo box setting to the given group and layout, similar to `_init_combobox_setting`
        method. This method differentiates itself by adding tooltips for label and combo box items. Also,
        appends an (PySide6's built-in) Information icon at the label end to indicate tooltip availability.

        Args:
            group (QWidget): The parent of the combobox
            layout (QFormLayout): The layout to add a row to for the combobox
            setting_name (str): The setting name as provided to the config. E.g. "settings.foo_bar"
            label_text (str): The displayed description. E.g. "Foo Bar"
            label_tooltip (str): The tooltip for the label.
            values_with_tooltips (Dict[str, str]): The list of values that can be selected, along with their
                tooltips. E.g. {"Option A": "If A is selected, ...", "Option B": "If B is selected, ..."}
        """
        label = QLabel(label_text)
        icon_label = QLabel()
        icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
        icon_label.setPixmap(icon.pixmap(16, 16))
        icon_label.setToolTip(label_tooltip)

        combo_box = QComboBox(parent=group)

        row_layout = QHBoxLayout()
        row_layout.addWidget(label)
        row_layout.addWidget(icon_label)
        row_layout.addWidget(combo_box)
        row_layout.setStretchFactor(combo_box, 1)
        row_layout.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)

        layout.addRow(row_layout)

        for index, (value, tooltip) in enumerate(values_with_tooltips.items()):
            combo_box.addItem(value)
            combo_box.setItemData(index, tooltip, Qt.ToolTipRole)

        def refresh_combo_box():
            """Function that refreshes the state of the combo box based on the setting name"""
            with block_signals(combo_box):
                value = config_file.get_setting(setting_name, config=self.config)
                values = list(values_with_tooltips.keys())
                if value not in values:
                    default = get_setting_default(setting_name, config=self.config)
                    logger.warning(f"'{value}' is not one of {values}. Defaulting to '{default}'.")
                    value = default
                combo_box.setCurrentText(value)
                combo_box.setToolTip(values_with_tooltips[value])

        def combo_box_changed(new_value):
            """Callback for if the state of a given checkbox has changed"""
            self.changes[setting_name] = new_value
            self.refresh()

        combo_box.currentTextChanged.connect(combo_box_changed)

        self._refresh_callbacks.append(refresh_combo_box)

    def _init_combobox_setting_with_display_names(
        self,
        group: QWidget,
        layout: QFormLayout,
        setting_name: str,
        label_text: str,
        values_with_display_names: List[tuple],
    ):
        """
        Creates a combobox setting with separate values and display names.

        Args:
            group (QWidget): The parent of the combobox
            layout (QFormLayout): The layout to add a row to for the combobox
            setting_name (str): The setting name as provided to the config
            label_text (str): The displayed description
            values_with_display_names (List[tuple]): List of (value, display_name) tuples
        """
        label = QLabel(label_text)
        combo_box = QComboBox(parent=group)
        layout.addRow(label, combo_box)

        for value, display_name in values_with_display_names:
            combo_box.addItem(display_name, value)

        def refresh_combo_box():
            with block_signals(combo_box):
                value = config_file.get_setting(setting_name, config=self.config)
                index = combo_box.findData(value)
                if index >= 0:
                    combo_box.setCurrentIndex(index)
                else:
                    default = get_setting_default(setting_name, config=self.config)
                    index = combo_box.findData(default)
                    if index >= 0:
                        combo_box.setCurrentIndex(index)

        def combo_box_changed(index):
            new_value = combo_box.itemData(index)
            self.changes[setting_name] = new_value
            self.refresh()

        combo_box.currentIndexChanged.connect(combo_box_changed)
        self._refresh_callbacks.append(refresh_combo_box)

    def handle_background_exception(self, title, e):
        QMessageBox.warning(self, title, f"Encountered an error:\n{e}")  # type: ignore[call-arg]

    def _fill_aws_profiles_box(self):
        # Use boto3 directly with no profile, so we don't get an error
        # if the configured profile does not exist.
        try:
            session = boto3.Session()
            aws_profile_names = [
                "(default)",
                *(
                    name
                    for name in session._session.full_config["profiles"].keys()
                    if name != "default"
                ),
            ]
        except ProfileNotFound:
            logger.exception("Failed to create boto3.Session for AWS profile list")
            aws_profile_names = [f"{NOT_VALID_MARKER} <failed to retrieve AWS profile names>"]

        self.aws_profile_names = aws_profile_names
        with block_signals(self.aws_profiles_box):
            self.aws_profiles_box.addItems(list(self.aws_profile_names))

    def refresh_lists(self):
        # Use the cached api_availability from DeadlineAuthenticationStatus
        # instead of making a blocking API call on the main thread.
        # The status is refreshed asynchronously and on_auth_status_update
        # will call this method again when api_availability_changed is emitted.
        auth_status = DeadlineAuthenticationStatus.getInstance()
        if auth_status.api_availability:
            self.default_farm_box.refresh_list()
            self.default_queue_box.refresh_list()
            self.default_storage_profile_box.refresh_list()

    def refresh(self):
        """
        Refreshes all the configuration UI elements from the current config.
        """
        # Make self.config be a deep copy of the config, with changes applied
        self.config = ConfigParser()
        self.config.read_dict(config_file.read_config())
        for setting_name, value in self.changes.items():
            config_file.set_setting(setting_name, value, self.config)
        self.default_farm_box.set_config(self.config)
        self.default_queue_box.set_config(self.config)
        self.default_storage_profile_box.set_config(self.config)

        with block_signals(self.aws_profiles_box):
            aws_profile_name = config_file.get_setting(
                "defaults.aws_profile_name", config=self.config
            )
            # Change the values representing the default to the UI value representing the default
            if aws_profile_name in ("(default)", "default", ""):
                aws_profile_name = "(default)"
            elif aws_profile_name not in self.aws_profile_names:
                aws_profile_name = f"{NOT_VALID_MARKER} {aws_profile_name}"
            index = self.aws_profiles_box.findText(aws_profile_name, Qt.MatchFixedString)
            if index >= 0:
                self.aws_profiles_box.setCurrentIndex(index)
            else:
                self.aws_profiles_box.insertItem(0, aws_profile_name)
                self.aws_profiles_box.setCurrentIndex(0)

        with block_signals(self.job_history_dir_edit):
            job_history_dir = config_file.get_setting(
                "settings.job_history_dir", config=self.config
            )
            self.job_history_dir_edit.setText(job_history_dir)

        self.default_farm_box.refresh_selected_id()

        for refresh_callback in self._refresh_callbacks:
            refresh_callback()

        self.default_queue_box.refresh_selected_id()
        self.default_storage_profile_box.refresh_selected_id()

        # Put an orange box around the labels for any settings that are changed
        for setting_name, label in self.labels.items():
            if setting_name in self.changes:
                label.setStyleSheet("border: 1px solid orange;")
            else:
                label.setStyleSheet("")

        self.refreshed.emit()

    def apply(self) -> bool:
        """
        Apply all the settings that the user has changed into the config file.

        Returns True if the settings were applied, False otherwise.
        """

        # We need to retrieve here as changing Queue's won't update.
        self.changes["settings.storage_profile_id"] = (
            self.default_storage_profile_box.box.currentData()
        )

        for setting_name, value in self.changes.items():
            if value.startswith(NOT_VALID_MARKER):
                QMessageBox.warning(  # type: ignore[call-arg]
                    self,
                    "Apply changes",
                    f"Cannot apply changes, {value} is not valid for setting {setting_name}",
                )
                return False

        self.config = config_file.read_config()

        for setting_name, value in self.changes.items():
            config_file.set_setting(setting_name, value, self.config)
        root.setLevel(config_file.get_setting("settings.log_level"))
        api.get_deadline_cloud_library_telemetry_client().set_opt_out(config=self.config)

        # Only update self.changes_were_applied if false. We don't want to invalidate that a change has
        # occurred if the user repeatedly hits "Apply" or hits "Apply" and then "Save".
        if not self.changes_were_applied:
            self.changes_were_applied = len(self.changes) > 0

        self.changes.clear()

        config_file.write_config(self.config)

        # Refresh the GUI (writing the config file should cause this, but do this redundantly to make sure)
        self.refresh()

        return True

    def aws_profile_changed(self, value):
        self.changes["defaults.aws_profile_name"] = value
        # Clear the farm_id and queue_id since they don't exist in the new profile
        self.changes["defaults.farm_id"] = ""
        self.changes["defaults.queue_id"] = ""
        self.default_farm_box.clear_list()
        self.default_queue_box.clear_list()
        self.default_storage_profile_box.clear_list()
        self.refresh()
        # Trigger cascading refresh - farms will load, then queues, then storage profiles
        self._awaiting_farms_for_cascade = True
        self.default_farm_box.refresh_list()

    def job_history_dir_changed(self):
        job_history_dir = self.job_history_dir_edit.text()
        # Only apply the change if the text was actually edited
        if job_history_dir != config_file.get_setting(
            "settings.job_history_dir", config=self.config
        ):
            self.changes["settings.job_history_dir"] = job_history_dir
        self.refresh()

    def default_farm_changed(self, index):
        if index < 0:
            return
        farm_id = self.default_farm_box.box.itemData(index)
        if farm_id is None:
            return
        self.changes["defaults.farm_id"] = farm_id
        # Clear the queue_id since the old queue doesn't exist in the new farm
        self.changes["defaults.queue_id"] = ""
        # Clear storage profile lists - they depend on queue which we're about to refresh
        self.default_storage_profile_box.clear_list()
        self.refresh()
        # Trigger cascading refresh - queues will load, then storage profiles
        self._awaiting_queues_for_cascade = True
        self.default_queue_box.refresh_list()

    def default_queue_changed(self, index):
        if index < 0:
            return
        queue_id = self.default_queue_box.box.currentData()
        if queue_id is None:
            return
        self.changes["defaults.queue_id"] = queue_id
        self.refresh()
        self.default_storage_profile_box.refresh_list()

    def default_storage_profile_name_changed(self, index):
        if index < 0:
            return
        storage_profile_id = self.default_storage_profile_box.box.itemData(index)
        if storage_profile_id is None:
            return
        self.changes["settings.storage_profile_id"] = storage_profile_id
        self.refresh()

    def _on_farms_list_updated(self, farms_list):
        """
        Handle when the farm list is updated from the controller.

        When farms are loaded as part of a cascade (e.g., after a profile change),
        we need to continue the cascade by refreshing queues.
        """
        if not self._awaiting_farms_for_cascade:
            return

        self._awaiting_farms_for_cascade = False

        # Get the currently selected farm from the combo box
        current_farm_id = self.default_farm_box.box.currentData()
        if current_farm_id:
            # Update the changes dict with the selected farm
            self.changes["defaults.farm_id"] = current_farm_id
            self.refresh()
            # Continue cascade - refresh queues with the valid farm_id
            self._awaiting_queues_for_cascade = True
            self.default_queue_box.refresh_list()

    def _on_queues_list_updated(self, queues_list):
        """
        Handle when the queue list is updated from the controller.

        When queues are loaded as part of a cascade (e.g., after a farm change),
        we need to continue the cascade by refreshing storage profiles.
        """
        if not self._awaiting_queues_for_cascade:
            return

        self._awaiting_queues_for_cascade = False

        # Get the currently selected queue from the combo box
        current_queue_id = self.default_queue_box.box.currentData()
        if current_queue_id:
            # Update the changes dict with the selected queue
            self.changes["defaults.queue_id"] = current_queue_id
            self.refresh()
            # Continue cascade - refresh storage profiles with the valid queue_id
            self.default_storage_profile_box.refresh_list()

    def _on_add_known_path(self):
        """Handle adding a new known path"""
        path = QFileDialog.getExistingDirectory(
            self, "Select a directory to add to known asset paths", os.path.expanduser("~")
        )
        if path:
            # Normalize path
            path = os.path.normpath(path)

            # Get current paths
            current_paths = []
            for i in range(self.known_paths_list.count()):
                current_paths.append(self.known_paths_list.item(i).text())

            # Only add if not already in list
            if path not in current_paths:
                self.known_paths_list.addItem(path)

                # Update config
                current_paths.append(path)
                self.changes["settings.known_asset_paths"] = os.pathsep.join(current_paths)
                self.refresh()

    def _on_remove_known_path(self):
        """Handle removing the selected known path"""
        current_row = self.known_paths_list.currentRow()
        if current_row >= 0:
            self.known_paths_list.takeItem(current_row)

            # Update config
            current_paths = []
            for i in range(self.known_paths_list.count()):
                current_paths.append(self.known_paths_list.item(i).text())
            self.changes["settings.known_asset_paths"] = os.pathsep.join(current_paths)
            self.refresh()

    def _update_known_paths_buttons(self):
        """Enable/disable edit and remove buttons based on selection"""
        has_selection = self.known_paths_list.currentRow() >= 0
        self.edit_known_path_button.setEnabled(has_selection)
        self.remove_known_path_button.setEnabled(has_selection)

    def _on_edit_known_path(self):
        """Handle editing the selected known path"""
        current_row = self.known_paths_list.currentRow()
        if current_row >= 0:
            current_path = self.known_paths_list.item(current_row).text()
            path = QFileDialog.getExistingDirectory(
                self,
                "Select a directory to replace the selected path",
                current_path if os.path.exists(current_path) else os.path.expanduser("~"),
            )
            if path:
                # Normalize path
                path = os.path.normpath(path)

                # Only update if path changed
                if path != current_path:
                    # Get current paths
                    current_paths = []
                    for i in range(self.known_paths_list.count()):
                        if i != current_row:  # Skip the path being edited
                            current_paths.append(self.known_paths_list.item(i).text())

                    # Only add if not already in list
                    if path not in current_paths:
                        self.known_paths_list.item(current_row).setText(path)
                        current_paths.insert(current_row, path)

                    self.changes["settings.known_asset_paths"] = os.pathsep.join(current_paths)
                    self.refresh()
