import json
import os
import time
from pathlib import Path
from typing import List, Union

import pyqtgraph as pg
from qgis.core import QgsApplication, QgsProject, QgsVectorLayer
from qgis.PyQt import uic
from qgis.PyQt.QtCore import QCoreApplication, Qt, QTimer, pyqtSignal
from qgis.PyQt.QtGui import QIcon, QMouseEvent
from qgis.PyQt.QtWidgets import (
    QAbstractItemView,
    QAction,
    QActionGroup,
    QComboBox,
    QDoubleSpinBox,
    QFileDialog,
    QInputDialog,
    QMenu,
    QMessageBox,
    QProgressDialog,
    QPushButton,
    QToolBar,
    QToolButton,
    QWidget,
)
from qgis.utils import iface

from openlog.__about__ import DIR_PLUGIN_ROOT
from openlog.core.barchart_vector_layer import BarChartVectorLayer
from openlog.core.trace_symbology import TraceSymbology
from openlog.datamodel.assay.assay_factory import AssayFactory
from openlog.datamodel.assay.generic_assay import (
    AssayDataExtent,
    AssayDefinition,
    AssayDomainType,
)
from openlog.datamodel.connection.openlog_connection import OpenLogConnection
from openlog.gui.assay_visualization import config_loader
from openlog.gui.assay_visualization.assay_plot_widget import AssayPlotWidget
from openlog.gui.assay_visualization.assay_sort import AssaySort
from openlog.gui.assay_visualization.config.assay_column_visualization_config import (
    AssayColumnVisualizationConfig,
)
from openlog.gui.assay_visualization.config.assay_visualization_config import (
    AssayVisualizationConfig,
    AssayVisualizationConfigList,
)
from openlog.gui.assay_visualization.config.assay_visualization_config_tree_model import (
    AssayVisualizationConfigProxyModel,
    AssayVisualizationConfigTreeModel,
)
from openlog.gui.assay_visualization.config_factory import (
    AssayColumnVisualizationConfigFactory,
)
from openlog.gui.assay_visualization.plot_grid import PlotGrid
from openlog.gui.assay_visualization.plot_item_factory import AssayPlotItemFactory
from openlog.gui.assay_visualization.stacked.stacked_config import (
    StackedConfiguration,
    StackedConfigurationList,
)
from openlog.plugins.extensions.crossplots.classes import (
    CrossPlotConfig,
    CrossPlotConfigList,
)
from openlog.plugins.manager import get_plugin_manager
from openlog.toolbelt import PlgLogger
from openlog.toolbelt.checkable_combobox_input_dialog import (
    CheckableComboBoxInputDialog,
)
from openlog.toolbelt.preferences import PlgOptionsManager


class OpenedMenu(QMenu):
    """
    Subclass of QMenu to keep it open while clicking on items.
    """

    def mouseReleaseEvent(self, a0: QMouseEvent) -> None:
        action = self.actionAt(a0.pos())
        if action:
            action.trigger()


class AssayWidget(QWidget):
    # custom signal emitted when logviewer is opened
    visibility_enabled = pyqtSignal(bool)

    def __init__(
        self,
        domain: AssayDomainType,
        parent: QWidget = None,
        openlog_connection: OpenLogConnection = None,
    ):
        """
        Widget to display assay from an OpenLogConnection

        Args:
            parent: parent QWidget
            domain: AssayDomainType assay domain type
            openlog_connection: OpenLogConnection used to get assays
        """
        super().__init__(parent)
        self.domain = domain
        self._openlog_connection = openlog_connection
        self.log = PlgLogger().log
        uic.loadUi(os.path.join(os.path.dirname(__file__), "assay_widget.ui"), self)

        self._visualization_config = AssayVisualizationConfigList()
        self._stacked_config = StackedConfigurationList()
        self._crossplot_config = CrossPlotConfigList()

        self._settings_default_configs = AssayVisualizationConfigList()
        self._settings_default_config_file = None

        # Load/Save configuration actions
        self.load_conf_action = QAction(
            QIcon(":images/themes/default/mActionFileOpen.svg"),
            "Load...",
            self,
        )
        self.load_conf_action.triggered.connect(self._load_configuration)

        self.save_conf_action = QAction(
            QIcon(":images/themes/default/mActionFileSaveAs.svg"),
            "Save As...",
            self,
        )
        self.save_conf_action.triggered.connect(self._save_current_configuration)

        # Add/Remove assay actions
        self.add_assay_action = QAction(
            QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "add_assay.svg")),
            "Add",
            self,
        )
        self.add_assay_action.triggered.connect(self._add_new_assay)

        self.remove_assay_action = QAction(
            QIcon(QgsApplication.iconPath("mActionRemove.svg")),
            "Remove",
            self,
        )
        self.remove_assay_action.triggered.connect(self._remove_selected_rows)

        self.remove_assay_action.setEnabled(False)

        # trace projection
        self.split_traces = QToolButton(self)
        self.split_traces.setPopupMode(QToolButton.MenuButtonPopup)
        self.split_traces.setToolTip("Project to canvas")
        self.split_menu = OpenedMenu(self)
        self.split_menu_planned = QAction("Planned")
        self.split_menu_planned.setCheckable(True)
        self.split_menu_effective = QAction("Effective")
        self.split_menu_effective.setCheckable(True)
        self.split_menu_effective.setChecked(True)
        self.split_menu.addAction(self.split_menu_planned)
        self.split_menu.addAction(self.split_menu_effective)
        self.split_traces.setMenu(self.split_menu)
        self.def_action = QAction(
            QIcon(
                str(DIR_PLUGIN_ROOT / "resources" / "images" / "trace_symbology_2.svg")
            ),
            "Project to canvas",
            self,
        )
        self.split_traces.setDefaultAction(self.def_action)
        self.split_traces.defaultAction().triggered.connect(self._display_splitted_slot)
        self.split_traces.defaultAction().setEnabled(False)

        # bar projection
        self.split_traces_bar = QToolButton(self)
        self.split_traces_bar.setPopupMode(QToolButton.MenuButtonPopup)
        self.split_bar_menu = OpenedMenu(self)
        self.split_bar_menu_planned = QAction("Planned")
        self.split_bar_menu_planned.setCheckable(True)
        self.split_bar_menu_effective = QAction("Effective")
        self.split_bar_menu_effective.setCheckable(True)
        self.split_bar_menu_effective.setChecked(True)
        self.split_bar_menu.addAction(self.split_bar_menu_planned)
        self.split_bar_menu.addAction(self.split_bar_menu_effective)
        self.split_traces_bar.setMenu(self.split_bar_menu)
        btn_txt = (
            "Project barchart to canvas"
            if get_plugin_manager().get_barchart_projection_plugin().enable
            else "Project barchart to canvas (Premium only)"
        )
        self.def_bar_action = QAction(
            QIcon(
                str(DIR_PLUGIN_ROOT / "resources" / "images" / "barchart_symbology.svg")
            ),
            btn_txt,
            self,
        )
        self.split_traces_bar.setDefaultAction(self.def_bar_action)
        self.split_traces_bar.defaultAction().triggered.connect(
            self._display_splitted_bar_slot
        )
        self.split_traces_bar.defaultAction().setEnabled(False)

        # connect map extent signal
        self.barchart_refresh_timer = QTimer()
        self.barchart_refresh_timer.setSingleShot(True)
        self.barchart_refresh_timer.timeout.connect(self._update_barcharts_layers)
        if iface is not None:
            iface.mapCanvas().extentsChanged.connect(
                lambda: self.barchart_refresh_timer.start(200)
            )

        # transpose button
        self.flat_grid_checkbox = QToolButton(self)
        self.flat_grid_checkbox.setCheckable(True)
        self.flat_grid_action = QAction(
            QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "flat_grid.svg")),
            "Transpose",
            self,
        )
        self.flat_grid_action.setCheckable(True)
        self.flat_grid_checkbox.setDefaultAction(self.flat_grid_action)
        self.flat_grid_checkbox.defaultAction().triggered.connect(
            lambda checked: self._plot_grid.enable_flat_grid(checked)
        )

        # altitude button
        self.switch_altitude_checkbox = QToolButton(self)
        self.switch_altitude_checkbox.setCheckable(True)
        self.switch_altitude_checkbox.setPopupMode(QToolButton.MenuButtonPopup)
        self.switch_altitude_action = QAction("Altitude", self)
        self.switch_altitude_action.setCheckable(True)
        self.switch_altitude_action.setIcon(
            QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "icon_moutain.svg"))
        )
        self.switch_altitude_checkbox.setDefaultAction(self.switch_altitude_action)

        self.switch_altitude_menu = QMenu(self)
        self.switch_effective = QAction("Effective")
        self.switch_effective.setCheckable(True)
        self.switch_effective.setChecked(True)
        self.switch_planned = QAction("Planned")
        self.switch_planned.setCheckable(True)

        self.switch_action_group = QActionGroup(self)
        self.switch_action_group.setExclusive(True)
        self.switch_action_group.addAction(self.switch_effective)
        self.switch_action_group.addAction(self.switch_planned)

        self.switch_altitude_menu.addAction(self.switch_planned)
        self.switch_altitude_menu.addAction(self.switch_effective)

        self.switch_altitude_checkbox.setMenu(self.switch_altitude_menu)
        self.switch_altitude_checkbox.defaultAction().triggered.connect(
            self._switch_altitude
        )
        self.switch_planned.triggered.connect(self._switch_altitude)
        self.switch_effective.triggered.connect(self._switch_altitude)

        if self.domain == AssayDomainType.TIME or not self._can_split_geometries():
            self.switch_altitude_checkbox.setDisabled(True)

        # Depth ticks
        self.depth_ticks_checkbox = QToolButton(self)
        self.depth_ticks_checkbox.setCheckable(True)
        # self.depth_ticks_checkbox.setPopupMode(QToolButton.MenuButtonPopup)
        self.depth_ticks_action = QAction("Ticks", self)
        self.depth_ticks_action.setCheckable(True)
        self.depth_ticks_action.setToolTip("Display depth ticks on canvas")
        self.depth_ticks_action.setIcon(
            QIcon(
                str(DIR_PLUGIN_ROOT / "resources" / "images" / "depth_ticks_icon.svg")
            )
        )
        self.depth_ticks_checkbox.setDefaultAction(self.depth_ticks_action)

        # interval spinbox
        self.depth_ticks_spinbox = QDoubleSpinBox(self)
        self.depth_ticks_spinbox.setToolTip("Ticks interval in meters")
        self.depth_ticks_spinbox.setDecimals(0)
        self.depth_ticks_spinbox.setMinimum(0)
        self.depth_ticks_spinbox.setSingleStep(10)
        self.depth_ticks_spinbox.setValue(10)
        self.depth_ticks_spinbox.valueChanged.connect(self._set_ticks_interval)

        self.depth_ticks_checkbox.defaultAction().triggered.connect(self._depth_ticks)
        self.depth_ticks_handler = (
            get_plugin_manager().get_depth_ticks_plugin().handler(self)
        )
        # Plot tree widget
        self.plot_tree_widget.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.plot_tree_widget.setSortingEnabled(True)
        self.plot_tree_widget.setSelectionMode(QAbstractItemView.ExtendedSelection)

        self._plot_grid = PlotGrid(
            domain,
            self._visualization_config,
            self._stacked_config,
            self._crossplot_config,
            self._openlog_connection,
            self,
        )

        self.azimuth_button_action = QAction(self)
        self.azimuth_button_action.setIcon(
            QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "icon_azimuth.svg"))
        )
        self.azimuth_button_action.setToolTip("Sorting by azimuth")
        self.azimuth_button_action.triggered.connect(self._on_azimuth_sorting_action)

        self.azimuth_spinbox = QDoubleSpinBox(self)
        self.azimuth_spinbox.setToolTip(
            "Orientation in degrees relative to north (clockwise)"
        )
        self.azimuth_spinbox.setDecimals(0)
        self.azimuth_spinbox.setMinimum(0)
        self.azimuth_spinbox.setMaximum(360)
        self.azimuth_spinbox.setSingleStep(5)
        self.azimuth_spinbox.editingFinished.connect(self._set_azimuth_sorting)

        self.azimuth_annoter = (
            get_plugin_manager().get_azimuth_sorting_plugin().annoter(self)
        )

        self.sort_menu = QComboBox(self)
        self.sort_menu.setToolTip("Sorting policy")
        self.sort_menu.addItem("Sort by collar")
        self.sort_menu.setItemIcon(
            0, QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "sort_collar.svg"))
        )
        self.sort_menu.addItem("Sort by source")
        self.sort_menu.setItemIcon(
            1, QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "sort_assay.svg"))
        )
        self.sort_menu.addItem("Sort by X")
        self.sort_menu.setItemIcon(
            2, QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "icon_x_major.svg"))
        )
        self.sort_menu.addItem("Sort by Y")
        self.sort_menu.setItemIcon(
            3, QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "icon_y_major.svg"))
        )
        self.sort_menu.addItem("Unordered")
        self.sort_menu.view().setRowHidden(4, True)

        self.sort_menu.addItem("Sort by azimuth")
        self.sort_menu.setItemIcon(
            5, QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "icon_azimuth.svg"))
        )
        self.sort_menu.view().setRowHidden(5, True)

        width = self.sort_menu.minimumSizeHint().width()
        self.sort_menu.view().setMinimumWidth(width)
        self.sort_menu.currentIndexChanged.connect(self._assay_sort_updated)

        self.toolbar = QToolBar(self)
        self.toolbar.addAction(self.add_assay_action)
        self.toolbar.addAction(self.remove_assay_action)
        self.toolbar.addWidget(self.split_traces)
        self.toolbar.addWidget(self.split_traces_bar)
        self.toolbar.addSeparator()
        self.toolbar.addWidget(self.sort_menu)
        self.toolbar.addAction(self.azimuth_button_action)
        self.azimuth_spinbox_action = self.toolbar.addWidget(self.azimuth_spinbox)
        self.toolbar.addWidget(self.flat_grid_checkbox)
        self.toolbar.addWidget(self.switch_altitude_checkbox)
        self.depth_ticks_btn_action = self.toolbar.addWidget(self.depth_ticks_checkbox)
        self.depth_ticks_spinbox_action = self.toolbar.addWidget(
            self.depth_ticks_spinbox
        )

        self.toolbar.addSeparator()
        self._plot_grid.add_action_to_toolbar(self.toolbar)

        # Add menu for symbology load/save

        self.toolbar.addSeparator()
        symbology_menu = QMenu(self)
        symbology_menu.setIcon(
            QIcon(
                str(
                    DIR_PLUGIN_ROOT / "resources" / "images" / "icon_save_symbology.svg"
                )
            )
        )
        symbology_menu.setTitle("Viewer state")
        symbology_menu.menuAction().setToolTip("Viewer state")
        symbology_menu.addAction(self.load_conf_action)
        symbology_menu.addAction(self.save_conf_action)

        self.toolbar.addAction(symbology_menu.menuAction())

        self.toolbar_hboxlayout.addWidget(self.toolbar)

        self.plot_grid_scrollarea.setWidget(self._plot_grid)

        # Visualization configuration
        self._visualization_config_model = AssayVisualizationConfigTreeModel(
            self._visualization_config,
            self._stacked_config,
            self._crossplot_config,
            self._openlog_connection,
            self,
        )
        self._proxy_mdl = AssayVisualizationConfigProxyModel(self)
        self._proxy_mdl.setSourceModel(self._visualization_config_model)
        self.plot_tree_widget.setModel(self._proxy_mdl)
        self.plot_tree_widget.selectionModel().selectionChanged.connect(
            self._tree_selection_changed
        )
        self.plot_tree_widget.header().sortIndicatorChanged.connect(
            self._sort_order_updated
        )

        self._visualization_config_model.set_view(self.plot_tree_widget)

        self._check_state_connect = None
        self._connect_check_state_signal()

        self.plot_selection_splitter.setStretchFactor(0, 0)
        self.plot_selection_splitter.setStretchFactor(1, 1)

        self.assay_config_widget.apply_to_all.connect(self._apply_to_all)
        self.assay_config_widget.apply_to_visible.connect(self._apply_to_visible)
        self.assay_config_widget.apply_to_selected.connect(self._apply_to_selected)

        self.stacked_config_creation_widget.add_stacked_config.connect(
            self._add_current_stacked_config
        )
        self.stacked_config_creation_widget.create_cross_plot.connect(
            self._add_crossplot_config
        )

        self._collar_layer_selection_connect = None
        self.set_openlog_connection(self._openlog_connection)

        self.visibility_enabled.connect(self._collar_feature_selected)

        # set timer for refreshing InspectorHandler without loosing logviewer performances
        self.inspector_refresh_timer = QTimer()
        self.inspector_refresh_timer.setSingleShot(True)
        self.inspector_refresh_timer.timeout.connect(
            self._visualization_config.refresh_inspector_handler
        )

        self.azimuth_spinbox_action.setVisible(False)
        self.azimuth_button_action.setVisible(True)
        # hide/show depending installed plugins
        self.azimuth_button_action.setEnabled(
            get_plugin_manager().get_azimuth_sorting_plugin().enable
        )
        if not get_plugin_manager().get_azimuth_sorting_plugin().enable:
            self.azimuth_button_action.setToolTip("Sorting by azimuth (Premium only)")

        self.depth_ticks_spinbox_action.setVisible(False)
        self.depth_ticks_btn_action.setVisible(True)
        self.depth_ticks_btn_action.setEnabled(
            self.domain == AssayDomainType.DEPTH
            and get_plugin_manager().get_depth_ticks_plugin().enable
        )
        if not get_plugin_manager().get_depth_ticks_plugin().enable:
            self.depth_ticks_checkbox.setToolTip(
                "Display depth ticks on canvas (Premium only)"
            )

    def _set_ticks_interval(self):

        self.depth_ticks_handler.on_ticks_interval_change()

    def _depth_ticks(self):

        self.depth_ticks_spinbox_action.setVisible(self.depth_ticks_action.isChecked())
        self.depth_ticks_handler.on_depth_ticks_click()

    def _on_azimuth_sorting_action(self):
        """
        Display spinbox to define azimuth.
        """
        self.azimuth_spinbox_action.setVisible(
            not self.azimuth_spinbox_action.isVisible()
        )
        self.azimuth_annoter.on_azimuth_click()

    def _set_azimuth_sorting(self):
        """
        Azimuth definition. Triggered when enter is pressed.
        """
        self.azimuth_spinbox_action.setVisible(False)
        self.azimuth_annoter.on_azimuth_click()
        azimuth = self.azimuth_spinbox.value()
        current_index = self.sort_menu.currentIndex()
        self.sort_menu.setCurrentIndex(5)
        self.sort_menu.setItemText(5, f"Sort by : {str(round(azimuth))}°")
        self.sort_menu.view().setRowHidden(5, False)
        if current_index == 5:
            self._assay_sort_updated()

    def showEvent(self, ev):
        """
        Override for emitting signal when logviewer is opened.
        """
        super().showEvent(ev)
        self.visibility_enabled.emit(True)

    def _switch_altitude(self):
        if not self.switch_altitude_checkbox.isChecked():
            altitude = "length"
        elif self.switch_planned.isChecked():
            altitude = "planned"
        else:
            altitude = "effective"

        widgets = self.findChildren(AssayPlotWidget)
        for widget in widgets:
            widget._switch_items(altitude)
            self._plot_grid.automatic_limits()

        self._plot_grid.update_sync_plot()
        self._plot_grid.set_full_range()

        # trigger depth ticks
        self._depth_ticks()

    def _can_split_geometries(self) -> bool:
        """
        Trace geometries could be available in :
        - XplordbConnection
        - SpatialiteConnection

        """
        result = False
        if self._openlog_connection:
            result = (
                self._openlog_connection.get_layers_iface().is_trace_geometry_exist()
                or self._openlog_connection.get_layers_iface().is_planned_trace_geometry_exist()
            )

        return result

    def set_openlog_connection(self, openlog_connection: OpenLogConnection):
        """
        Define used OpenLogConnection

        Args:
            openlog_connection: OpenLogConnection
        """
        self._clear_config()
        self._openlog_connection = openlog_connection
        if openlog_connection is not None:
            # all collars
            try:
                collars = self._openlog_connection.get_read_iface().get_all_collars()
                self._visualization_config.set_inspector_handler_eohs(collars)
                geometry_fields = self._openlog_connection.get_geometry_fields()
                self._visualization_config.set_inspector_handler_geometry_fields(
                    geometry_fields
                )
                # trace layers
                traces = (
                    self._openlog_connection.get_layers_iface().get_collar_trace_layer()
                )
                planned_traces = (
                    self._openlog_connection.get_layers_iface().get_planned_trace_layer()
                )
                self._visualization_config.refresh_inspector_handler(
                    [traces, planned_traces]
                )
            except:
                pass

        self.depth_ticks_handler.reinitialize()
        self.depth_ticks_handler.on_depth_ticks_click()
        self._visualization_config_model.openlog_connection = openlog_connection
        self.add_assay_action.setEnabled(self._openlog_connection is not None)
        self._plot_grid.set_openlog_connection(openlog_connection)

        self.assay_config_widget.setVisible(False)
        self.stacked_config_creation_widget.setVisible(False)
        self.stacked_config_widget.setVisible(False)

        self.load_conf_action.setEnabled(self._openlog_connection is not None)
        self.save_conf_action.setEnabled(self._openlog_connection is not None)

        if self.domain == AssayDomainType.DEPTH and self._can_split_geometries():
            self.switch_altitude_checkbox.setEnabled(True)

        self._connect_collar_selection_signal()

    def _connect_check_state_signal(self):
        """
        Connect check state signal for automatic limit and sync plot definition

        """
        self._check_state_connect = (
            self._visualization_config_model.itemCheckStateChanged.connect(
                self._check_state_changed
            )
        )

    def _disconnect_check_state_signal(self):
        """
        Disconnect check state signal for automatic limit and sync plot definition

        """
        if self._check_state_connect:
            self._visualization_config_model.itemCheckStateChanged.disconnect(
                self._check_state_connect
            )
            self._check_state_connect = None

    def _get_collar_layer(self) -> Union[QgsVectorLayer, None]:
        """
        Get collar QgsVectorLayer from current connection

        Returns: Union[QgsVectorLayer, None] collar layer

        """
        if self._openlog_connection is not None:
            return self._openlog_connection.get_layers_iface().get_collar_layer()
        else:
            return None

    def _connect_collar_selection_signal(self) -> None:
        """
        Connect collar selection signal for feature synchronization

        """
        # For now collar selection is done by collar feature synchronization
        collar_layer = self._get_collar_layer()
        if collar_layer is not None:
            self._collar_layer_selection_connect = (
                collar_layer.selectionChanged.connect(self._collar_feature_selected)
            )

    def _disconnect_collar_selection_signal(self) -> None:
        """
        Disconnect collar selection signal for feature synchronization

        """
        # For now collar selection is done by collar feature synchronization
        collar_layer = self._get_collar_layer()
        if self._collar_layer_selection_connect and collar_layer is not None:
            collar_layer.selectionChanged.disconnect(
                self._collar_layer_selection_connect
            )
            self._collar_layer_selection_connect = None

    def _clear_config(self) -> None:
        """
        Clear current visualization configuration.
        If plot is associated to configuration, plot is also deleted

        """
        self._visualization_config.clear()
        self._visualization_config_model.refresh()

    def _add_new_assay(self) -> None:
        """
        Ask user for assay selection

        """
        assay_definitions = (
            self._openlog_connection.get_assay_iface().get_all_available_assay_definitions()
        )

        # Display only not displayed assay
        selected_hole_id = self._get_selected_hole_id()

        assay_list = [
            assay
            for assay in assay_definitions
            if not self._visualization_config.check_if_all_assay_column_are_available(
                assay
            )
            and assay.domain == self.domain
            and self._openlog_connection.get_assay_iface().is_assay_available_for_collars(
                selected_hole_id, assay.variable
            )
        ]

        assay_name_list = [assay.variable for assay in assay_list]
        assay_display_name_list = [assay.display_name for assay in assay_list]

        dialog = CheckableComboBoxInputDialog(
            assay_name_list,
            assay_display_name_list,
            self,
            "Downhole data selection",
            "Select downhole data",
        )
        dialog.clear_selection()
        if dialog.exec():

            # Disconnect check state signal to avoid multiple automatic limits calculation
            self._disconnect_check_state_signal()

            assay_list = dialog.get_selected_values()
            self._add_selected_assay_and_collar(
                assay_list, selected_hole_id, assay_definitions
            )

            # Restore check state signal connection for automatic limits calculation
            self._connect_check_state_signal()
            self._check_state_changed()

    def _add_selected_assay_and_collar(
        self,
        assay_list: List[str],
        selected_hole_id: List[str],
        assay_definitions: List[AssayDefinition],
    ) -> None:
        """
        Add selected assay and collar to current visualization widget :
        - create default configuration for each assay
        - add configuration to

        Args:
            assay_list: (List[str]) selected assay names
            selected_hole_id: (List[str]) selected hole id
            assay_definitions: (List[AssayDefinition]) available assay definitions
        """
        if self.domain == AssayDomainType.DEPTH and self._can_split_geometries():
            self.switch_altitude_checkbox.setEnabled(True)

        # Define imported column for each assay

        assay_column_dict = {}
        for assay_name in assay_list:
            assay_def = [
                definition
                for definition in assay_definitions
                if definition.variable == assay_name
            ][0]

            plotable_columns = list(assay_def.get_plottable_columns().keys())
            # get display name
            display_name_columns = [
                assay_def.get_plottable_columns().get(col).display_name
                for col in plotable_columns
            ]

            # Remove already added columns
            added_column = self._visualization_config.get_assay_added_columns(
                assay_name
            )
            columns = [col for col in plotable_columns if col not in added_column]
            display_name_columns = [
                display_col
                for display_col, col in zip(display_name_columns, plotable_columns)
                if col not in added_column
            ]

            if len(plotable_columns) > 1:
                dialog = CheckableComboBoxInputDialog(
                    columns,
                    display_name_columns,
                    self,
                    "Select colum for {0}".format(assay_def.display_name),
                    assay_def.display_name,
                )
                dialog.clear_selection()
                if not dialog.exec():
                    return
                else:
                    assay_column_dict[assay_name] = dialog.get_selected_values()
            else:
                assay_column_dict[assay_name] = columns

        # Add configuration for each selected collar
        hole_len = len(selected_hole_id)
        assay_len = len(assay_list)
        if assay_len:
            if not hole_len:
                hole_len = 1

            progress_dialog = QProgressDialog(
                "Importing collar data",
                "Cancel",
                0,
                hole_len * assay_len,
                self,
            )
            progress_dialog.setWindowModality(Qt.WindowModal)
            progress_dialog.show()

            i = 0
            for i, assay_name in enumerate(assay_list):
                assay_def = [
                    definition
                    for definition in assay_definitions
                    if definition.variable == assay_name
                ][0]
                display_name = assay_def.display_name
                title = (
                    "Time domain downhole data : {0}".format(display_name)
                    if self.domain == AssayDomainType.TIME
                    else "Depth domain downhole data : {0}".format(display_name)
                )
                progress_dialog.setWindowTitle(title)
                progress_dialog.setValue(i * hole_len)
                QCoreApplication.processEvents()

                # Get selected columns
                selected_columns = assay_column_dict[assay_name]

                # Update default configuration
                progress_dialog.setLabelText("Creating default configuration")
                QCoreApplication.processEvents()

                self._update_default_assay_visualization_config(
                    assay_name, selected_columns
                )
                for j, hole_id in enumerate(selected_hole_id):
                    progress_dialog.setValue(i * hole_len + j)
                    progress_dialog.setLabelText(
                        "Importing {0} downhole data".format(
                            self._openlog_connection.get_read_iface().get_collar_display_name(
                                hole_id
                            )
                        )
                    )
                    self._add_visualization_config(
                        hole_id, assay_name, selected_columns
                    )

                    if progress_dialog.wasCanceled():
                        break
                    QCoreApplication.processEvents()

                if progress_dialog.wasCanceled():
                    break
                QCoreApplication.processEvents()

            progress_dialog.hide()
            progress_dialog.deleteLater()

            # Refresh displayed visualization
            self._visualization_config_model.refresh()

            # Refresh grid to respect order
            self._plot_grid.refresh_grid()

            # Simulate new selection
            self._tree_selection_changed()

    def _update_default_assay_visualization_config(
        self, assay_name: str, selected_columns: List[str]
    ) -> None:

        try:
            assay_visualization_config = (
                self._settings_default_configs.get_default_configuration(assay_name)
            )
            self._visualization_config.append_to_list(assay_visualization_config)
        except AssayVisualizationConfigList.UnavailableDefaultConfig:
            pass

        # Get assay from connection
        assay = AssayFactory().create_assay(self._openlog_connection, "", assay_name)
        new_assay = False
        try:
            assay_visualization_config = (
                self._visualization_config.get_default_configuration(assay_name)
            )
        except AssayVisualizationConfigList.UnavailableDefaultConfig:
            # Create assay visualization configuration
            assay_visualization_config = AssayVisualizationConfig(
                hole_id="", assay=assay_name
            )
            assay_visualization_config.is_visible = True
            assay_visualization_config.assay_name = assay_name
            assay_visualization_config.assay_display_name = (
                assay.assay_definition.display_name
            )
            self._visualization_config.append_to_list(assay_visualization_config)
            new_assay = True

        # Create configuration for each assay column not already available
        for col in selected_columns:
            if col not in assay_visualization_config.column_config:
                config = AssayColumnVisualizationConfigFactory().create_config(
                    assay.assay_definition,
                    col,
                    self._openlog_connection.get_assay_iface(),
                )
                config.assay = assay
                self._connect_switch_config_slot(config, self._switch_numerical_config)
                self._connect_switch_config_slot(config, self._update_button_states)
                self._connect_cross_symbology_variable_slot(
                    config, self._update_button_states
                )

                # For categorical assay, default configuration doesn't have category
                if config.is_categorical and not config.is_discrete:
                    # special case for Geotic (symbology stored in database)
                    if self._openlog_connection.symbology_db_stored:
                        config.bar_symbology.init_from_database(
                            session=self._openlog_connection.session, assay=assay
                        )
                    else:
                        config.bar_symbology.init_from_assay(assay)

                elif config.is_categorical and config.is_discrete:
                    config.point_symbology.init_from_assay(assay)

                assay_visualization_config.add_column_config(config)
        # if new assay, load default symbology
        if new_assay:
            assay_visualization_config.load_default_symbology()

        self._visualization_config.update_min_max()

        self.inspector_refresh_timer.start(10)

    def _connect_switch_config_slot(
        self, config: AssayColumnVisualizationConfig, slot: callable
    ) -> None:
        """
        Connect slot to config's signal.
        Check if slot is already connected.
        """
        try:
            while True:
                config.switch_config.config_signal.disconnect(slot)
        except Exception:
            config.switch_config.config_signal.connect(slot)

    def _connect_cross_symbology_variable_slot(
        self, config: AssayColumnVisualizationConfig, slot: callable
    ) -> None:
        """
        Connect slot to config's signal.
        Check if slot is already connected.
        """
        if not config.is_minmax_registered:
            return

        try:
            while True:
                config.color_ramp_handler.param.sigValueChanged.disconnect(slot)
        except Exception:
            config.color_ramp_handler.param.sigValueChanged.connect(slot)

        if config.is_discrete and config.extended_config:
            try:
                while True:
                    config.extended_config.color_ramp_handler.param.sigValueChanged.disconnect(
                        slot
                    )
            except Exception:
                config.extended_config.color_ramp_handler.param.sigValueChanged.connect(
                    slot
                )

        elif not config.is_discrete and config.discrete_configuration:
            try:
                while True:
                    config.discrete_configuration.color_ramp_handler.param.sigValueChanged.disconnect(
                        slot
                    )
            except Exception:
                config.discrete_configuration.color_ramp_handler.param.sigValueChanged.connect(
                    slot
                )

    def _switch_numerical_config(self, config, new_config):
        """
        Switch a config with another in AssayVisualizationConfigList and Tree model.
        Used for extended/discrete switch.
        """
        # replace config in : AssayVisualizationConfigList, tree model
        self._visualization_config.replace_config(config, new_config)
        self._visualization_config_model.replace_config(config, new_config)
        self._update_assay_config_widget_from_selection(collapse=False)
        self._update_stacked_config_widget_from_selection(collapse=False)
        self._plot_grid.update_sync_plot()

    def _switch_stacked_numerical_config(self, config, new_config):
        """
        Switch a config with another in stacked list.
        Used for extended/discrete switch inside stacked config.
        """
        self._stacked_config.replace_config(config, new_config)
        self._update_assay_config_widget_from_selection(collapse=False)
        self._update_stacked_config_widget_from_selection(collapse=False)
        self._plot_grid.update_sync_plot()

    def _display_splitted_bar_slot(self):

        if self.split_bar_menu_effective.isChecked():
            self._display_splitted(planned=False, bar_chart=True)

        if self.split_bar_menu_planned.isChecked():
            self._display_splitted(planned=True, bar_chart=True)

    def _display_splitted_slot(self):

        if self.split_menu_effective.isChecked():
            self._display_splitted(planned=False)

        if self.split_menu_planned.isChecked():
            self._display_splitted(planned=True)

    def _display_splitted(self, planned: bool = False, bar_chart: bool = False):

        # choose right projector
        projector_cls = TraceSymbology
        if bar_chart:
            projector_cls = (
                get_plugin_manager().get_barchart_projection_plugin().projector
            )

        geom_col = "proj_planned_geom" if planned else "proj_effective_geom"
        indexes = self.plot_tree_widget.selectionModel().selectedIndexes()
        is_column = False
        for proxy_index in indexes:
            index = self._proxy_mdl.mapToSource(proxy_index)

            if self._visualization_config_model.is_assay(index):
                config = self._visualization_config_model.data(
                    index, self._visualization_config_model.CONFIG_ROLE
                )
                column_configs = list(config.column_config.values())
                is_discrete = (
                    column_configs[0].assay.assay_definition.data_extent
                    == AssayDataExtent.DISCRETE
                )

            elif self._visualization_config_model.is_assay_column(index):
                config = self._visualization_config_model.data(
                    index, self._visualization_config_model.CONFIG_ROLE
                )
                column_configs = [config]
                is_discrete = (
                    config.assay.assay_definition.data_extent
                    == AssayDataExtent.DISCRETE
                )
                is_column = True
            else:
                continue

            # display layers to QGIS canvas

            for column_config in column_configs:

                layer = self._openlog_connection.get_layers_iface().get_splitted_trace_layer(
                    column_config, planned, bar_chart
                )

                # if doesnt exist because database was created before Openlog update, create it
                if not layer.isValid():
                    assay_column = column_config.column
                    self._openlog_connection.get_assay_iface().split_traces(
                        table_name=column_config.assay_name,
                        assay_column=assay_column,
                        discrete=is_discrete,
                        geom=geom_col,
                    )
                    layer = self._openlog_connection.get_layers_iface().get_splitted_trace_layer(
                        column_config, planned, bar_chart
                    )

                if layer.isValid():
                    assay_def = self._openlog_connection.get_assay_iface().get_assay_table_definition(
                        column_config.assay_name
                    )
                    # translate symbology used in logviewer to QGIS layer
                    layer = projector_cls(column_config, assay_def).set_symbology(layer)

                    # remove layer if exists
                    for lyr in QgsProject.instance().mapLayers().values():
                        if lyr.name() == layer.name():
                            QgsProject.instance().removeMapLayers([lyr.id()])

                    QgsProject.instance().addMapLayer(layer, False)
                    # add to corresponding group
                    root = QgsProject.instance().layerTreeRoot()
                    if planned:
                        group = root.findGroup("Planned")
                    else:
                        group = root.findGroup("Effective")
                    group.addLayer(layer)

    def _update_barcharts_layers(self) -> None:
        """
        Update bar scale according to visible holes.
        """
        if self._openlog_connection:
            projector_cls = (
                get_plugin_manager().get_barchart_projection_plugin().projector
            )
            layer_iface = self._openlog_connection.get_layers_iface()
            splitted_layers = layer_iface.splitted_trace_layer
            barchart_layers = [
                lyr
                for lyr in splitted_layers
                if isinstance(lyr, BarChartVectorLayer)
                and layer_iface._is_layer_exist(lyr)
            ]
            for lyr in barchart_layers:
                projector_cls.refresh_from_extent(lyr)

    def _remove_selected_rows(self) -> None:
        """
        Remove selected assay or/and collars of tree model.
        Removed collars are unselected in QGIS canvas

        """

        removed_config = []
        removed_column_config = []
        removed_stacked_config = []
        removed_crossplots_config = []
        removed_collar_ids = []
        indexes = self.plot_tree_widget.selectionModel().selectedIndexes()
        for proxy_index in indexes:
            index = self._proxy_mdl.mapToSource(proxy_index)
            if self._visualization_config_model.is_assay(index):
                config = self._visualization_config_model.data(
                    index, self._visualization_config_model.CONFIG_ROLE
                )
                removed_config.append(config)

            if self._visualization_config_model.is_assay_column(index):
                config = self._visualization_config_model.data(
                    index, self._visualization_config_model.CONFIG_ROLE
                )
                removed_column_config.append(config)

            if self._visualization_config_model.is_stacked(index):
                config = self._visualization_config_model.data(
                    index, self._visualization_config_model.CONFIG_ROLE
                )
                removed_stacked_config.append(config)

            if self._visualization_config_model.is_crossplot(index):
                config = self._visualization_config_model.data(
                    index, self._visualization_config_model.CONFIG_ROLE
                )
                removed_crossplots_config.append(config)

            if self._visualization_config_model.is_collar(index):
                collar_name = self._visualization_config_model.data(index)
                removed_collar_ids.append(collar_name)

        removed_collar_ids = list(set(removed_collar_ids))

        for config in removed_config:
            # Remove from configuration
            self._visualization_config.remove_assay(config.assay_name)

        for config in removed_column_config:
            # Remove from configuration
            self._visualization_config.remove_column(config.column.name)

        for config in removed_stacked_config:
            # Remove from configuration
            self._stacked_config.remove_config(config)
            # Remove from tree model
            self._visualization_config_model.refresh()

        for config in removed_crossplots_config:
            # Remove from configuration
            self._crossplot_config.remove_config(config)
            # Remove from tree model
            self._visualization_config_model.refresh()
        # Refresh displayed visualization
        self._visualization_config_model.refresh()

        self._check_state_changed()

        # unselect collars from layer
        self._openlog_connection.get_layers_iface().unselect_collar_by_id(
            removed_collar_ids
        )

    def _collar_feature_selected(self) -> None:
        """
        Update configuration with selected collar in map

        """
        if self.isVisible() and self._openlog_connection is not None:
            selected_hole = self._get_selected_hole_id()
            self._remove_unavailable_collar(selected_hole)
            self._add_or_update_selected_collar(selected_hole)
            self._visualization_config.update_min_max()
            self.inspector_refresh_timer.start(10)
            self._update_crossplot_config_holes(selected_hole)

    def _remove_unavailable_collar(self, selected_hole: List[str]) -> None:
        """
        Remove unavailable collar in tree model and visualization configuration

        Args:
            selected_hole: [str] selected collar ids
        """
        unavailable_collar = [
            hole_id
            for hole_id in self._visualization_config.collar_list()
            if hole_id not in selected_hole
        ]
        for hole_id in unavailable_collar:
            self._visualization_config.remove_collar(hole_id)

    def _add_or_update_selected_collar(self, selected_hole: List[str]) -> None:
        """
        Add or update selected collar for visualization config add if needed

        Args:
            selected_hole: [str]  selected collar ids
        """
        hole_len = len(selected_hole)

        # Disconnect check state signal to avoid multiple automatic limits calculation
        self._disconnect_check_state_signal()

        if hole_len:

            progress_dialog = QProgressDialog(
                "Importing collar assays", "Cancel", 0, hole_len, self
            )
            title = (
                "Time assays" if self.domain == AssayDomainType.TIME else "Depth assays"
            )
            progress_dialog.setWindowTitle(title)
            progress_dialog.setWindowModality(Qt.WindowModal)
            progress_dialog.show()
            i = 0
            for hole_id in selected_hole:
                progress_dialog.setValue(i)
                progress_dialog.setLabelText(
                    "Importing {0} assays".format(
                        self._openlog_connection.get_read_iface().get_collar_display_name(
                            hole_id
                        )
                    )
                )
                if self._visualization_config.has_collar(hole_id):
                    self._update_collar(hole_id)
                else:
                    self._add_collar(hole_id)
                if progress_dialog.wasCanceled():
                    break
                i = i + 1
                QCoreApplication.processEvents()

            progress_dialog.hide()
            progress_dialog.deleteLater()

        self._visualization_config_model.refresh()

        # Restore check state signal connection for automatic limits calculation
        self._connect_check_state_signal()
        self._check_state_changed()

    def _update_collar(self, hole_id: str) -> None:
        """
        Update collar visualization configuration with added assays

        Args:
            hole_id: (str) collar hole id
        """
        # Add only assay not already available
        added_assay = [
            assay
            for assay in self._visualization_config.assay_list()
            if not self._visualization_config.has_collar_assay(hole_id, assay)
        ]
        for assay in added_assay:
            # Get selected column from config
            columns = self._visualization_config.get_assay_added_columns(assay)
            self._add_visualization_config(hole_id, assay, columns)

    def _add_collar(self, hole_id: str) -> None:
        """
        Add collar visualization configuration

        Args:
            hole_id: (str) collar hole id
        """
        # Add all available assays
        for assay_name in self._visualization_config.assay_list():
            # Get selected column from config
            columns = self._visualization_config.get_assay_added_columns(assay_name)
            self._add_visualization_config(hole_id, assay_name, columns)

    def _add_visualization_config(
        self, hole_id: str, assay_name: str, columns: List[str]
    ) -> None:
        """
        Add a new visualization configuration for collar and assay

        Args:
            hole_id: (str) collar hole id
            assay_name: (str) assay name
            columns: List[str] assay collumns to add
        """

        # Create or update visualization config
        assay_visualization_config = self._create_or_update_visualization_config(
            hole_id, assay_name, columns
        )

        # Create plot from created configuration
        self._create_plot_from_config(assay_visualization_config, columns)

    def _create_or_update_visualization_config(
        self, hole_id: str, assay_name: str, columns: List[str]
    ) -> AssayVisualizationConfig:
        """
        Create or update a configuration for a collar and assay for defined columns

        Args:
            hole_id: (str) collar hole id
            assay_name: (str) assay name
            columns: List[str] assay collumns to add

        Returns: AssayVisualizationConfig created configuration

        """
        # Get assay from connection
        assay = AssayFactory().create_assay(
            self._openlog_connection, hole_id, assay_name
        )

        default_config = None

        # Get default configuration if available
        try:
            default_config = self._visualization_config.get_default_configuration(
                assay_name
            )
        except AssayVisualizationConfigList.UnavailableDefaultConfig:
            self.log(
                message="No default configuration available for assay {}".format(
                    assay_name
                )
            )

        # Create or get assay visualization configuration
        try:
            assay_visualization_config = (
                self._visualization_config.get_assay_hole_configuration(
                    assay_name, hole_id
                )
            )
        except AssayVisualizationConfigList.UnavailableConfig:
            assay_visualization_config = AssayVisualizationConfig(
                hole_id=hole_id, assay=assay_name
            )

            # Init from default config
            if default_config:
                assay_visualization_config.copy_from_config(default_config)

            self._visualization_config.append_to_list(assay_visualization_config)

        assay_visualization_config.hole_display_name = (
            self._openlog_connection.get_read_iface().get_collar_display_name(hole_id)
        )
        assay_visualization_config.assay_display_name = (
            assay.assay_definition.display_name
        )
        # Default visibility
        visible_assays = self._visualization_config.visible_assay_list()
        is_visible = assay_name in visible_assays
        assay_visualization_config.is_visible = is_visible
        # Create configuration for each assay column
        for col in columns:
            config = AssayColumnVisualizationConfigFactory().create_config(
                assay.assay_definition, col, self._openlog_connection.get_assay_iface()
            )

            config.hole_display_name = (
                self._openlog_connection.get_read_iface().get_collar_display_name(
                    assay.hole_id
                )
            )
            config.set_hole_id(assay.hole_id)
            self._connect_switch_config_slot(config, self._switch_numerical_config)
            self._connect_switch_config_slot(config, self._update_button_states)
            self._connect_cross_symbology_variable_slot(
                config, self._update_button_states
            )

            # Copy column config from default config
            if default_config and col in default_config.column_config:
                # for switched config, default could be of different type
                config = config.get_copiable_config(default_config.column_config[col])
                config.copy_from_config(default_config.column_config[col])

            assay_visualization_config.add_column_config(config)

        self.inspector_refresh_timer.start(10)

        return assay_visualization_config

    def _create_plot_from_config(
        self,
        assay_visualization_config: AssayVisualizationConfig,
        columns: Union[List[str], None] = None,
    ) -> None:
        """
        Create plot from configuration

        Args:
            assay_visualization_config: (AssayVisualizationConfig) configuration
            columns: List[str], Optional assay collumns to add
        """
        hole_id = assay_visualization_config.hole_id
        assay_name = assay_visualization_config.assay_name

        # Get assay from connection
        assay = AssayFactory().create_assay(
            self._openlog_connection, hole_id, assay_name
        )

        if not hole_id or not self._openlog_connection.get_assay_iface().is_assay_available_for_collar(
            hole_id, assay_name
        ):
            return None

        assay_visualization_config.hole_display_name = (
            self._openlog_connection.get_read_iface().get_collar_display_name(hole_id)
        )
        assay_visualization_config.assay_display_name = (
            assay.assay_definition.display_name
        )

        # Default visibility
        is_visible = assay_visualization_config.is_visible

        # Create configuration for each assay column
        for col, config in assay_visualization_config.column_config.items():

            # If columns defined, add only columns in list
            if columns and col not in columns:
                continue

            # Create plot widget for assay and collar
            plot = self._plot_grid.add_plot_widget(assay)
            plot.setVisible(is_visible)

            config.set_hole_id(assay.hole_id)
            config.hole_display_name = (
                self._openlog_connection.get_read_iface().get_collar_display_name(
                    assay.hole_id
                )
            )
            config.assay_name = assay.assay_definition.variable
            config.assay_display_name = assay.assay_definition.display_name
            config.set_plot_widget(plot)
            used_plot = plot
            AssayPlotItemFactory().create_plot_items(used_plot, assay, col, config)

            used_plot.getViewBox().autoRange()
            plot.set_assay_column_config(config)
            # execute post item creation
            config.after_plot_item_creation()

        self._visualization_config.update_min_max()

        self.inspector_refresh_timer.start(10)

        self._switch_altitude()

        # sometimes minimap geometry is not correct, this trick do the job
        QgsApplication.processEvents()
        self._update_minimaps()
        self._plot_grid.refresh_grid()

    def _update_minimaps(self):
        """
        Update minimaps displaying after adding new widgets.
        """
        for config in self._visualization_config.list:
            for col in config.column_config.values():
                col._display_minimap()

    def _check_state_changed(self) -> None:
        """
        Update sync plot and define automatic limits when selection is changed

        """
        self._plot_grid.update_sync_plot()
        self._plot_grid.automatic_limits()
        self._plot_grid.update_empty_cells_visibility()
        self._plot_grid._resize_widgets()
        self.inspector_refresh_timer.start(10)

    def _assay_sort_updated(self) -> None:
        """
        Update plot grid sort

        """
        if self.sort_menu.currentIndex() == 1:
            sort = AssaySort.ASSAY_NAME
        elif self.sort_menu.currentIndex() == 0:
            sort = AssaySort.HOLE_ID
        elif self.sort_menu.currentIndex() == 2:
            sort = AssaySort.X
        elif self.sort_menu.currentIndex() == 3:
            sort = AssaySort.Y
        elif self.sort_menu.currentIndex() == 4:
            sort = AssaySort.UNDEFINED
        elif self.sort_menu.currentIndex() == 5:
            sort = AssaySort.set_azimuth(self.azimuth_spinbox.value()).AZIMUTH

        self._plot_grid.set_assay_sort(sort)

    def _sort_order_updated(self) -> None:
        """
        Update plot grid row/col sort

        """
        sort_order = self.plot_tree_widget.header().sortIndicatorOrder()
        self._plot_grid.set_sort_order(sort_order)

    def _get_selected_hole_id(self) -> List[str]:
        """
        Get selected collar from OpenLogConnection collar layer

        Returns: [str] list of selected collar hole id

        """
        return (
            self._openlog_connection.get_layers_iface().get_selected_collar_from_layer()
        )

    def _tree_selection_changed(self) -> None:
        """
        Defined remove action enable from current tree model selection
        """

        self._update_button_states()
        self._update_config_widgets()

    def _update_button_states(self) -> None:
        """
        Update button states depending selection.
        """
        # enable split button require all selected index are splittable
        splittable = True
        # enable orthogonal projection button
        orthogonally_splittable = True
        # can remove assay if parent is root, collar if parent is root, stacked
        removable = True
        indexes = self.plot_tree_widget.selectionModel().selectedIndexes()
        for proxy_index in indexes:
            index = self._proxy_mdl.mapToSource(proxy_index)
            if self._visualization_config_model.is_assay(index):
                removable = removable and True

            elif self._visualization_config_model.is_assay_column(index):
                # not if there is only one column
                assay_config = self._visualization_config_model.data(
                    index.parent(), self._visualization_config_model.CONFIG_ROLE
                )
                if len(assay_config.column_config) > 1:
                    removable = removable and True
                else:
                    removable = removable and False

            elif self._visualization_config_model.is_stacked(index):
                removable = removable and True

            elif self._visualization_config_model.is_crossplot(index):
                removable = removable and True

            elif self._visualization_config_model.is_collar(
                index
            ) and not self._visualization_config_model.is_assay(index.parent()):
                removable = removable and True

            else:
                removable = removable and False

            if self._visualization_config_model.is_splittable(index):
                splittable = splittable and True
            else:
                splittable = splittable and False

            if self._visualization_config_model.is_orthogonally_splittable(index):
                orthogonally_splittable = orthogonally_splittable and True
            else:
                orthogonally_splittable = orthogonally_splittable and False

        self.remove_assay_action.setEnabled(removable and len(indexes) > 0)

        # enable/disable split trace button
        self.split_traces.defaultAction().setEnabled(
            splittable
            and self._openlog_connection.get_layers_iface().splitted_trace_layer_available()
            and self._can_split_geometries()
            and len(indexes) > 0
        )

        # enable/disable orthogonal projection button
        self.split_traces_bar.defaultAction().setEnabled(
            orthogonally_splittable
            and self._openlog_connection.get_layers_iface().splitted_trace_layer_available()
            and self._can_split_geometries()
            and len(indexes) > 0
            and get_plugin_manager().get_barchart_projection_plugin().enable
        )

    def _update_config_widgets(self) -> None:
        """
        Update config widget, stacked config widget and stacked creation widgets.
        """
        self._update_assay_config_widget_from_selection(collapse=True)
        self._update_stacked_config_widget_from_selection(collapse=True)
        self._update_stacked_config_creation_widget_from_selection()

    def _update_assay_config_widget_from_selection(
        self, collapse: bool = False
    ) -> None:
        """
        Update current assay config widget from assay selection

        """
        self.assay_config_widget.setVisible(False)
        indexes = self.plot_tree_widget.selectionModel().selectedIndexes()
        if len(indexes) == 1:
            config = indexes[0].data(self._visualization_config_model.CONFIG_ROLE)
            if isinstance(config, AssayVisualizationConfig):
                self.assay_config_widget.set_assay_config(config)
                self.assay_config_widget.setVisible(True)
            elif isinstance(config, AssayColumnVisualizationConfig):
                self.assay_config_widget.set_assay_column_config(config)
                self.assay_config_widget.setVisible(True)
            if collapse:
                self.assay_config_widget.collapse(True)

    def _apply_to_all(self) -> None:
        """
        Apply selected assay visualization configuration to all collar and define configuration as default

        """
        selected_config = self.assay_config_widget.get_assay_config()
        if not selected_config:
            return
        for config in self._visualization_config.list:
            # Update configuration if same assay
            if config.assay_name == selected_config.assay_name:
                config.copy_from_config(selected_config)

    def _apply_to_selected(self) -> None:
        """
        Apply selected assay visualization configuration to selected collar

        """
        selected_config = self.assay_config_widget.get_assay_config()
        if not selected_config:
            return
        for config in self._visualization_config.list:
            # Update configuration if same assay but not if default configuration
            if config.assay_name == selected_config.assay_name and config.hole_id:
                config.copy_from_config(selected_config)

    def _apply_to_visible(self) -> None:
        """
        Apply selected assay visualization configuration to visible collar

        """
        selected_config = self.assay_config_widget.get_assay_config()
        if not selected_config:
            return
        for config in self._visualization_config.list:
            # Update configuration if same assay but not if default configuration or collar not visible
            if (
                config.assay_name == selected_config.assay_name
                and config.hole_id
                and config.is_visible
            ):
                config.copy_from_config(selected_config)

    def _update_crossplot_config_holes(self, holes: list) -> None:
        """
        Update default holes in crossplot configs.
        """
        for crossplot_config in self._crossplot_config.list:
            if len(holes) > 0:
                crossplot_config.update_default_collars(holes)

    def _update_stacked_config_widget_from_selection(
        self, collapse: bool = False
    ) -> None:
        """
        Update current stacked config widget from assay selection

        """
        self.stacked_config_widget.setVisible(False)
        indexes = self.plot_tree_widget.selectionModel().selectedIndexes()
        if len(indexes) == 1:
            config = indexes[0].data(self._visualization_config_model.CONFIG_ROLE)
            if isinstance(config, StackedConfiguration):
                self.stacked_config_widget.set_stacked_config(config)
                self.stacked_config_widget.setVisible(True)
            if isinstance(config, CrossPlotConfig):
                self.stacked_config_widget.set_crossplot_config(config)
                self.stacked_config_widget.setVisible(True)

            if collapse:
                self.stacked_config_widget.collapse()

    def _update_stacked_config_creation_widget_from_selection(self):
        """
        Update current stacked config creation widget from assay column selection

        """
        self.stacked_config_creation_widget.setVisible(False)
        stacked_config = self._stacked_config_from_selected_indexes()

        if len(stacked_config.config_list) > 1:
            self.stacked_config_creation_widget.set_stacked_configuration(
                stacked_config
            )
            self.stacked_config_creation_widget.setVisible(True)

    def _stacked_config_from_selected_indexes(self) -> StackedConfiguration:
        """
        Create StackedConfiguration from treeview selected indexes

        Returns: StackedConfiguration

        """
        indexes = self.plot_tree_widget.selectionModel().selectedIndexes()
        stacked_config = StackedConfiguration()
        for proxy_index in indexes:
            index = self._proxy_mdl.mapToSource(proxy_index)
            self._extract_column_config_from_index(index, stacked_config)
        return stacked_config

    def _extract_column_config_from_index(
        self, index, stacked_config: StackedConfiguration
    ) -> None:
        """

        Args:
            index:
            stacked_config: StackedConfiguration
        """
        config = index.data(self._visualization_config_model.CONFIG_ROLE)
        if isinstance(config, AssayVisualizationConfig):
            # Add only configuration with defined hole_id
            if config.hole_id:
                for colum in config.column_config.values():
                    stacked_config.add_column_config(colum)
        elif isinstance(config, AssayColumnVisualizationConfig):
            if config.hole_id:
                stacked_config.add_column_config(config)

        # Extract configuration from children
        for i in range(0, self._visualization_config_model.rowCount(index)):
            child = self._visualization_config_model.index(i, 0, index)
            self._extract_column_config_from_index(child, stacked_config)

    def _add_crossplot_config(self) -> None:
        """
        Add crossplot configuration from current config creation widget. (premium)
        """
        stacked_config = self.stacked_config_creation_widget.get_stacked_configuration()
        crossplot_config = (
            get_plugin_manager()
            .get_crossplots_plugin()
            .config_factory(stacked_config.config_list, self._openlog_connection)
        )
        ok = True
        while ok:
            name, ok = QInputDialog.getText(self, "New crossplot", "Crossplot name")
            if name:
                used_crossplot_config_names = [
                    c.name for c in self._crossplot_config.list
                ]
                if name not in used_crossplot_config_names:
                    # construct plot
                    crossplot_config.set_plot()
                    crossplot_config.set_name(name)
                    self._add_crossplot_config_plot(crossplot_config)
                    break
                else:
                    QMessageBox.warning(
                        self,
                        "Invalid crossplot name",
                        "Crossplot name already used.",
                    )

    def _add_current_stacked_config(self) -> None:
        """
        Add stacked configuration from current config creation widget

        """
        stacked_config = self.stacked_config_creation_widget.get_stacked_configuration()
        ok = True
        while ok:
            name, ok = QInputDialog.getText(self, "New multiplot", "Multiplot name")
            if name:
                used_stacked_config_names = [c.name for c in self._stacked_config.list]
                if name not in used_stacked_config_names:
                    stacked_config.name = name
                    self._add_stacked_config_plot(stacked_config)
                    break
                else:
                    QMessageBox.warning(
                        self,
                        "Invalid Multiplot name",
                        "Multiplot name already used.",
                    )

    def _add_crossplot_config_plot(self, crossplot_config) -> None:
        """
        Add crossplot configuration plot and save CrossPlotConfig to model

        Args:
            crossplot_config: CrossPlotConfig
        """

        plot = crossplot_config.plot
        plot = self._plot_grid.add_crossplot_widget(plot)
        plot.getViewBox().autoRange()

        self._crossplot_config.list.append(crossplot_config)
        # Refresh view
        self._visualization_config_model.refresh()

    def _add_stacked_config_plot(self, stacked_config: StackedConfiguration) -> None:
        """
        Add stacked configuration plot and save StackedConfiguration to model

        Args:
            stacked_config: StackedConfiguration
        """
        # Create plot widget
        plot = stacked_config.create_plot_widget(self.domain)

        plot = self._plot_grid.add_stacked_plot_widget(plot)
        stacked_config.set_plot(plot)

        copy_config = []
        # Create configuration for each assay column
        for config in stacked_config.config_list:
            # Get assay from connection
            assay = AssayFactory().create_assay(
                self._openlog_connection, config.hole_id, config.assay_name
            )
            # Create new configuration
            new_config = AssayColumnVisualizationConfigFactory().create_config(
                assay.assay_definition,
                config.column.name,
                self._openlog_connection.get_assay_iface(),
            )
            # Copy style
            new_config = new_config.get_copiable_config(config)
            new_config.copy_from_config(config)
            # Define hole id and assay
            new_config.set_hole_id(config.hole_id)
            new_config.hole_display_name = config.hole_display_name
            self._connect_switch_config_slot(
                new_config, self._switch_stacked_numerical_config
            )
            self._connect_switch_config_slot(new_config, self._update_button_states)
            self._connect_cross_symbology_variable_slot(
                new_config, self._update_button_states
            )
            # Update visibility param to display hole id
            new_config.update_name_for_stacked_config()
            # Add plot items
            AssayPlotItemFactory().create_plot_items(
                plot, assay, new_config.column.name, new_config
            )
            new_config.plot_stacked = plot
            copy_config.append(new_config)

        plot.getViewBox().autoRange()

        # Use created config list and update model
        stacked_config.config_list = copy_config
        plot.set_stacked_config(stacked_config)
        self._stacked_config.list.append(stacked_config)
        # refresh unit parameters
        stacked_config.refresh_unit_param()
        # Refresh view
        self._visualization_config_model.refresh()

    def _save_current_configuration(self) -> None:

        d = {}
        # global parameters
        d["connection"] = self._openlog_connection.get_database_name()
        d["sorting_policy"] = self.sort_menu.currentIndex()
        d["transpose"] = self.flat_grid_action.isChecked()
        d["planned_altitude"] = self.switch_planned.isChecked()
        d["effective_altitude"] = self.switch_effective.isChecked()
        d["altitude_checkbox"] = self.switch_altitude_action.isChecked()
        d[
            "inspector_enabled"
        ] = self._plot_grid.enable_inspector_line_action.isChecked()
        d[
            "inspector_sync"
        ] = self._plot_grid.synchronize_inspector_line_action.isChecked()

        filename, _ = QFileDialog.getSaveFileName(
            self,
            "Select file",
            "openlog_state.json",
            "JSON file(*.json)",
        )
        if filename:
            with open(filename, "w") as f:
                j = self._visualization_config.to_json()
                # append global parameters
                d |= json.loads(j)
                f.write(json.dumps(d))

    def _load_configuration(self) -> None:
        filename, _ = QFileDialog.getOpenFileName(
            self, "Select file", "", "JSON file(*.json)"
        )
        if filename:
            with open(filename, "r") as f:
                data = json.load(f)
                # check if database is the same
                if self._openlog_connection.get_database_name() != data.get(
                    "connection"
                ):
                    QMessageBox.warning(
                        None,
                        "Invalid file",
                        "JSON file was created from another database.",
                    )
                    return

                # database symbology ?
                m = QMessageBox(
                    QMessageBox.Warning,
                    "Symbology",
                    "Do you want to load symbology from this file ?",
                    buttons=(
                        QMessageBox.StandardButton.Yes
                        | QMessageBox.StandardButton.No
                        | QMessageBox.StandardButton.Cancel
                    ),
                )
                load_symbo = m.exec()
                if load_symbo == QMessageBox.StandardButton.Cancel:
                    return

                configs, errors = config_loader.from_json(
                    data, self._openlog_connection, self.domain
                )
                if len(errors):
                    error_msg = "There was some errors during import, check details for more information."
                    buttons = QMessageBox.StandardButton.NoButton
                    if len(configs.list):
                        error_msg += "\n Do you want to import {} valid configuration(s) ?".format(
                            len(configs.list)
                        )
                        buttons = (
                            QMessageBox.StandardButton.Yes
                            | QMessageBox.StandardButton.No
                        )
                    msg_box = QMessageBox(
                        QMessageBox.Warning,
                        "Errors in imported file.",
                        error_msg,
                        buttons=buttons,
                    )
                    msg_box.setDetailedText("\n".join(errors))
                    result = msg_box.exec()

                    if len(configs.list) and result != QMessageBox.StandardButton.Yes:
                        return

                self._visualization_config.clear()

                self._disconnect_collar_selection_signal()
                self._openlog_connection.get_layers_iface().select_collar_by_id(
                    configs.collar_list()
                )
                self._connect_collar_selection_signal()

                for config in configs.list:
                    self._visualization_config.append_to_list(config)
                    self._create_plot_from_config(config)
                    if load_symbo == QMessageBox.StandardButton.No:
                        config.load_default_symbology()

                # global parameters
                self.sort_menu.setCurrentIndex(data.get("sorting_policy"))
                self.flat_grid_checkbox.defaultAction().setChecked(
                    data.get("transpose")
                )
                self._plot_grid.enable_flat_grid(data.get("transpose"))
                self.switch_effective.setChecked(data.get("effective_altitude"))
                self.switch_planned.setChecked(data.get("planned_altitude"))
                self.switch_altitude_action.setChecked(data.get("altitude_checkbox"))
                self._switch_altitude()
                self._visualization_config.set_inspector_position()
                # inspector lines
                self._plot_grid.enable_inspector_line_action.setChecked(
                    data.get("inspector_enabled")
                )
                self._plot_grid._enable_inspector_line(data.get("inspector_enabled"))
                self._plot_grid.synchronize_inspector_line_action.setChecked(
                    data.get("inspector_sync")
                )
                self._plot_grid._sync_inspector_line(data.get("inspector_sync"))
                # plot visibility
                self._visualization_config_model.refresh()
                # # Refresh displayed visualization
                self._visualization_config.refresh_visibility(data)
                # # Refresh displayed visualization
                self._plot_grid.refresh_grid()
