import math
from typing import Dict, List

import pyqtgraph as pg
from pyqtgraph import InfiniteLine
from pyqtgraph.graphicsItems.LabelItem import LabelItem
from pyqtgraph.graphicsItems.ViewBox.ViewBoxMenu import ViewBoxMenu
from qgis.PyQt import QtCore, QtGui, QtWidgets
from qgis.PyQt.QtCore import Qt, pyqtSignal

from openlog.core.pint_utilities import unit_conversion
from openlog.datamodel.assay.generic_assay import AssayDomainType
from openlog.gui.assay_visualization.assay_inspector_line import AssayInspectorLine
from openlog.gui.assay_visualization.config.assay_column_visualization_config import (
    AssayColumnVisualizationConfig,
)
from openlog.gui.assay_visualization.config.assay_visualization_config import (
    AssayVisualizationConfig,
)
from openlog.gui.assay_visualization.item_tags import (
    CategoricalLegendTag,
    LegendTag,
    SimpleLegendTag,
)
from openlog.gui.assay_visualization.minimap import DepthMiniMap, TimeMiniMap
from openlog.gui.assay_visualization.stacked.stacked_config import StackedConfiguration


class DynamicLabelItem(LabelItem):
    """
    Custom LabelItem to avoid PlotWidget's width being constrained by title.
    Text is truncated with '...' leaving at least the first letter.
    """

    def __init__(self, text=" ", parent=None, angle=0, **args):
        self.initialize_parameters()
        self.default_height = 45  # 3 lines
        super().__init__(text, parent, angle, **args)

    def initialize_parameters(self):
        self.full_text = ""
        self.full_text_width = 0
        # list storing each rows cumulated lengths
        self.lengths = []
        self.dots = "..."
        self.dots_length = 0
        self.minimal_width = 0

    def setFullText(self, text: str):
        """
        Compute text properties.
        """
        self.initialize_parameters()
        self.full_text = text
        self.setText(text)
        if text == "":
            return
        # set tooltip
        self.item.setToolTip(self.full_text.replace("<br>", "\n"))
        self.full_text_width = self.itemRect().width()
        # get lengths
        full_html = self.item.toHtml()
        full_html = full_html.replace("<br />", "<br>")
        rows = self.full_text.split("<br>")
        for row in rows:
            result = []
            for i in range(len(row)):
                substr = row[: i + 1]
                html = QtWidgets.QGraphicsTextItem()
                html.setHtml(full_html.replace(self.full_text, substr))
                result.append(html.boundingRect().width())
            self.lengths.append(result)

        # get dot length with current html parameters
        html = QtWidgets.QGraphicsTextItem()
        html.setHtml(full_html.replace(self.full_text, self.dots))
        self.dots_length = html.boundingRect().width()

        # set minimal width
        self.minimal_width = self.getMinimalWidth()

    def updateMin(self):
        """
        Override of updateMin setting minimal width.
        """
        bounds = self.itemRect()
        width = self.minimal_width if self.minimal_width > 0 else self.full_text_width
        self.setMinimumWidth(width)
        self.setMinimumHeight(self.default_height)
        self.setMaximumHeight(self.default_height)

        self._sizeHint = {
            QtCore.Qt.SizeHint.MinimumSize: (width, self.default_height),
            QtCore.Qt.SizeHint.PreferredSize: (width, self.default_height),
            QtCore.Qt.SizeHint.MaximumSize: (-1, self.default_height),
            QtCore.Qt.SizeHint.MinimumDescent: (0, 0),
        }
        self.updateGeometry()

    def getMinimalWidth(self) -> float:
        """
        Mimimal width is equal to first character + "..." width
        """
        try:
            first_char_width = max([length[0] for length in self.lengths])
        except IndexError:
            first_char_width = 0

        return first_char_width + self.dots_length

    def truncateText(self) -> str:
        """
        Given visible width, return truncated text.
        """

        if len(self.lengths) == 0:
            return self.full_text

        # current width
        visible_width = self.rect().width()
        new_rows = []
        rows = self.full_text.split("<br>")
        for row, length in zip(rows, self.lengths):
            # enough space for entire row
            if length[-1] < visible_width:
                new_rows.append(row)
                continue
            else:
                for i in reversed(range(len(row))):
                    # we should keep at least first character of each row
                    if length[i] + self.dots_length < visible_width or i == 0:
                        new_rows.append(row[: i + 1] + self.dots)
                        break

        return "<br>".join(new_rows)

    def resizeEvent(self, ev):
        """
        Override of resizeEvent to dynamically truncate text.
        """
        self.item.setHtml(self.truncateText())

        return super().resizeEvent(ev)


class CustomItemSample(pg.ItemSample):
    def __init__(self, item):
        """
        custom pg.ItemSample used to override mouseClickEvent method over legend items.
        This class is used when instantiating a pg.LegendItem class.
        When a legend item is clicked, it check or uncheck visibility parameter of AssayColumnVisualizationConfig.
        """
        super().__init__(item)

    def _synchronize_scatterplot_visibility(self):
        """
        Since each brush is unique by category, we scan each pg.SpotItem and update visibility according to its brush.
        """
        is_visible = self.item.isVisible()
        brush = self.item.opts.get("brush")
        plot_item = self.topLevelWidget().items[0]
        spots = plot_item.points()
        for spot in spots:
            if spot.brush() == brush:
                spot.setVisible(is_visible)

    def mouseClickEvent(self, event):
        """
        If categoricalScatterPlotItem, we have to manually show/hide concerned spots.
        Else, it synchronize visibility with config
        """
        super().mouseClickEvent(event)
        if isinstance(self.item, pg.ScatterPlotItem):

            self._synchronize_scatterplot_visibility()

        else:
            is_visible = self.item.config.visibility_param.value()
            self.item.config.visibility_param.setValue(not is_visible)


class CustomVbMenu(ViewBoxMenu):
    """
    Subclass of ViewBoxMenu which is the contextual menu (right click).
    Override of autoRange method to autoRange properly with or without minimap (View all option).
    """

    def autoRange(self):
        plotitem = self.view().parentWidget()
        super().autoRange()
        if plotitem.minimap:
            plotitem.display_minimap(plotitem.minimap.isVisible())


class EmptyGraphicsItem(QtWidgets.QGraphicsWidget):
    pass


class AssayPlotItem(pg.PlotItem):
    # signal to be emitted when axes must be aligned in plot grid
    alignAxes = pyqtSignal(bool)

    def __init__(
        self,
        parent=None,
        name=None,
        labels=None,
        title=None,
        viewBox=None,
        axisItems=None,
        enableMenu=True,
        collar_stack=False,
        **kargs,
    ):
        """
        pg.PlotItem override for automatic legend of plot graph

        Args:
            parent : see pg documentation
            name : see pg documentation
            labels : see pg documentation
            title : see pg documentation
            viewBox : see pg documentation
            axisItems : see pg documentation
            enableMenu : see pg documentation
            collar_stack (bool) : True if plotItem is for collar stack (multiple collar available), default False
            **kargs,
        """
        super().__init__(
            parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs
        )
        self.collar_stack = collar_stack
        self.minimap = None

        # No legend if no item inserted
        self.legend = None

        self.set_menu()

        self.geometryChanged.connect(self._adjust_y_axis_font)

        self.layout.removeItem(self.titleLabel)
        self.titleLabel = DynamicLabelItem("", size="11pt", parent=self)
        self.layout.addItem(self.titleLabel, 0, 1)
        self.empty_item = EmptyGraphicsItem()

    def _insert_empty_space(self, domain: AssayDomainType) -> None:
        """
        Since heterogenous plots supports, we have to add an empty widget in layout to align axes.
        """

        if domain == AssayDomainType.DEPTH:
            self._insert_depth_empty_space()
        else:
            self._insert_time_empty_space()

    def _insert_time_empty_space(self):
        """
        Add an empty column to layout.
        Empty column is used to align multiplots axes.
        """
        self.empty_item.setSizePolicy(
            QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred
        )
        self.empty_item.setPreferredWidth(0)
        self.empty_item.setMinimumWidth(0)

        default_layout_items = []
        for r in range(self.layout.rowCount()):
            for c in range(self.layout.columnCount()):
                item = self.layout.itemAt(r, c)
                if item is None:
                    continue
                default_layout_items.append((item, r, c, 1, 1))

        while self.layout.count() > 0:
            self.layout.removeAt(0)

        self.layout.addItem(self.empty_item, 0, 0, 4, 1)

        for item, r, c, rs, cs in default_layout_items:
            c += 1
            self.layout.addItem(item, r, c, rs, cs)

        self.layout.setColumnMinimumWidth(0, 0)

    def _insert_depth_empty_space(self):
        """
        Add an empty row to layout.
        Empty row is used to align multiplots axes.
        """
        self.empty_item.setSizePolicy(
            QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed
        )
        self.empty_item.setPreferredHeight(0)
        self.empty_item.setMinimumHeight(0)

        default_layout_items = []
        for r in range(self.layout.rowCount()):
            for c in range(self.layout.columnCount()):
                item = self.layout.itemAt(r, c)
                if item is None:
                    continue
                default_layout_items.append((item, r, c, 1, 1))

        while self.layout.count() > 0:
            self.layout.removeAt(0)

        self.layout.addItem(self.empty_item, 0, 0, 1, 3)

        for item, r, c, rs, cs in default_layout_items:
            r += 1
            self.layout.addItem(item, r, c, rs, cs)

        self.layout.setRowMinimumHeight(0, 0)

    def set_menu(self):
        vb = self.getViewBox()
        vb.menu = CustomVbMenu(vb)

    def display_grids(self, x: bool, y: bool) -> None:
        """
        Enable/disable plot grids.
        Args:
            - x: enable x grid
            - y: enable y grid
        """
        self.ctrl.xGridCheck.setChecked(x)
        self.ctrl.yGridCheck.setChecked(y)

    def transform_to_log(self, x: bool, y: bool) -> None:
        """
        Transform axes to logarithm scale.
        """
        self.ctrl.logXCheck.setChecked(x)
        self.ctrl.logYCheck.setChecked(y)

    def _adjust_y_axis_font(self):
        """
        Method used to adjust y axis title font size, depending ViewBox geometry.
        """

        # y axis
        axis = self.getAxis("left")
        label = axis.label
        label_size = label.boundingRect().width()
        # get available height
        height = self.getViewBox().height()
        font_style = axis.labelStyle

        if not font_style.get("font-size"):
            return

        font_size = int(font_style["font-size"].replace("px", ""))
        if font_size == 0:
            font_size = 1

        # minimum font size : 9, max : 15
        new_font_size = math.ceil(font_size * height / label_size)
        new_font_size = max(9, new_font_size)
        new_font_size = min(15, new_font_size)

        font_style["font-size"] = str(new_font_size) + "px"
        blocker = QtCore.QSignalBlocker(axis.label)
        axis.labelStyle = font_style
        axis._updateLabel()
        blocker.unblock()

    def set_minimap(self, depth: bool = True):

        if depth:
            self.minimap = DepthMiniMap(parent=self)
            self.sigYRangeChanged.connect(self.minimap.onMove)
        else:
            self.minimap = TimeMiniMap(parent=self)
            self.sigXRangeChanged.connect(self.minimap.onMove)

        self.minimap.setVisible(False)

    def display_minimap(self, display: bool):

        # logarithm
        if isinstance(self.minimap, DepthMiniMap):
            x_log = self.ctrl.logXCheck.isChecked()
            self.minimap.ctrl.logXCheck.setChecked(x_log)
        else:
            y_log = self.ctrl.logYCheck.isChecked()
            self.minimap.ctrl.logYCheck.setChecked(y_log)

        # create a lag to display minimap without overlaping plot
        xmin, xmax = self.viewRange()[0]
        ymin, ymax = self.viewRange()[1]
        self.autoRange()
        new_xmin, new_xmax = self.viewRange()[0]
        new_ymin, new_ymax = self.viewRange()[1]
        if isinstance(self.minimap, DepthMiniMap):
            if display:
                self.setXRange(new_xmin - (new_xmax - new_xmin) * 0.05, new_xmax)
            self.setYRange(ymin, ymax)
        else:
            if display:
                self.setYRange(new_ymin - (new_ymax - new_ymin) * 0.05, new_ymax)
            self.setXRange(xmin, xmax)

        # adjust anchor again for eventual zero-length ticklength
        self.minimap.setOffset()
        self.minimap._setGeom()
        self.minimap.setVisible(display)

        # trick to refresh displaying without focus with mouse
        self.minimap.autoRange(padding=0)
        r = self.minimap.viewRange()
        if isinstance(self.minimap, DepthMiniMap):
            self.minimap.setXRange(*r[0])
            self.minimap.setYRange(*r[1], padding=0)
        else:
            self.minimap.setXRange(*r[0], padding=0)
            self.minimap.setYRange(*r[1])

        # set current limits to minimap
        self.minimap._setRange()

    def onMinimapMove(self):
        """
        Method (slot) triggered when minimap is dragged by the user.
        """
        blocker = QtCore.QSignalBlocker(self)
        region_bounds = self.minimap.lr.getRegion()
        if isinstance(self.minimap, DepthMiniMap):
            self.setYRange(*region_bounds)
        else:
            self.setXRange(*region_bounds)

        blocker.unblock()

    def _mapColor(self) -> None:
        """
        Assign color to an item via configuration.
        """
        items = [item for item in self.items if isinstance(item, LegendTag)]
        for index, item in enumerate(items):
            current_pen = item.config.pen_params.get_pen()
            current_pen.setColor(pg.intColor(index))
            item.config.pen_params.init_from_pen(current_pen)
            item.config._update_plot_item_color_ramp()

    def _add_legend_item(self, item):
        """
        Add an item to legend.
        """
        if isinstance(item, LegendTag):

            # Add legend if not available
            if self.legend is None:
                self.legend = pg.LegendItem(
                    offset=(-1, 50),
                    brush="white",
                    pen="black",
                    sampleType=CustomItemSample,
                )

            if isinstance(item, CategoricalLegendTag):
                self.legend.setParentItem(self)
                for legend_item, name in item.legend_items:
                    self.legend.addItem(legend_item, name)

            else:
                # check number of items
                n_item = len([it for it in self.items if isinstance(it, LegendTag)])

                # legend is displayed if there are multiple items
                if n_item > 1:
                    self._mapColor()
                    self.legend.setParentItem(self)

                collar_display_name, column_name = (
                    item.config.hole_display_name,
                    item.config.column.name,
                )
                # for stacked plot, label is "collar_id - column name"
                if self.collar_stack:
                    full_name = f"{collar_display_name} - {column_name}"
                else:
                    full_name = column_name

                # add legend idem
                self.legend.addItem(item, full_name)

    def addItem(self, item, *args, **kargs):
        """Override pg.PlotItem addItem for automatic legend definition"""
        super().addItem(item, *args, **kargs)
        self.minimap.addItem(item)
        self._add_legend_item(item)

    def removeItem(self, item):
        """Override pg.PlotItem removeItem for automatic item deletion from legend"""
        super().removeItem(item)

        # only "legendable" items are considered

        if self.legend is not None and isinstance(item, SimpleLegendTag):
            self.legend.removeItem(item)


class AssayPlotWidget(pg.PlotWidget):
    visibilityChanged = pyqtSignal(bool)

    def __init__(
        self,
        domain: AssayDomainType,
        parent=None,
        background="default",
        plotItem=None,
        collar_stack=False,
        **kargs,
    ):
        """
        pg.PlotWidget override to store domain and extra functions to define limits and display AssayInspectorLine

        Args:
            domain: AssayDomainType
            parent:
            background:
            plotItem:
            collar_stack (bool) : True if PlotWidget is for collar stack (multiple collar available), default False
            **kargs:
        """
        if plotItem is None:
            plotItem = AssayPlotItem(collar_stack=collar_stack, **kargs)
            plotItem.set_minimap(domain == AssayDomainType.DEPTH)

        super().__init__(parent, background, plotItem, **kargs)
        self.domain = domain
        self.collar_stack = collar_stack
        self.setBackground("white")
        # horizontal line to mark planned geometry limit
        self.red_line = InfiniteLine(
            pos=0, angle=0, pen=pg.mkPen("red", width=1, style=QtCore.Qt.DashLine)
        )

        # Define axis from domain
        if self.domain == AssayDomainType.TIME:
            axis = pg.DateAxisItem()
            self.setAxisItems({"bottom": axis})
            self.getViewBox().setMouseEnabled(y=False)
        else:
            styles = {"color": "black", "font-size": "15px"}
            self.setLabel("left", "Depth", "m", **styles)
            self.showAxis("top")

            self.getViewBox().invertY(True)  # Y inversion for depth assay
            self.getViewBox().setMouseEnabled(x=False)

        # Insert empty item
        plotItem._insert_empty_space(domain)

        # Add an inspector line (not attached by default)
        self.inspector = AssayInspectorLine(self, domain=self.domain)
        self._sync_inspector = None
        self._connection_to_sync_inspector = None
        self._connection_from_sync_inspector = None

        self.assay_config = None
        self.assay_column_config = None
        self.stacked_config = None

        # Modify default contextual menu
        self.customize_contextual_menu()

        # Store displayed unit
        self._displayed_unit = ""

        self.setAcceptDrops(True)

        # attributes storing dimension in pixel of an axis (top if depth domain, else left)
        self.axis_without_label_dim = 0
        self.axis_with_label_dim = 0
        self._compute_default_axis_dim()

    def _compute_default_axis_dim(self) -> None:
        """
        Compute default axis dimensions.
        """
        if self.domain == AssayDomainType.DEPTH:
            a = pg.AxisItem("top")
            a.label.setVisible(False)
            self.axis_without_label_dim = a.height()
            a.setLabel("ok")
            a.label.setVisible(True)
            self.axis_with_label_dim = a.height()
        else:
            a = pg.AxisItem("left")
            a.label.setVisible(False)
            self.axis_without_label_dim = a.width()
            a.setLabel("ok")
            a.label.setVisible(True)
            self.axis_with_label_dim = a.width()

    def get_axis_position(self) -> float:
        """
        Get main axis position (px).
        If time assay it's the left axis furthest to the right.
        If depth assay, it's the top axis furthest to the bottom.
        """

        orientation = "top" if self.domain == AssayDomainType.DEPTH else "left"
        # get all scene's axes
        all_axis = [
            self.plotItem.layout.itemAt(i)
            for i in range(self.plotItem.layout.count())
            if isinstance(self.plotItem.layout.itemAt(i), pg.AxisItem)
        ]
        all_axis = [
            axis
            for axis in all_axis
            if axis.orientation == orientation and axis.isVisible()
        ]
        axis_height = sum(
            [
                self.axis_with_label_dim
                if axis.label.isVisible()
                else self.axis_without_label_dim
                for axis in all_axis
            ]
        )

        # title geometry only for depth assays
        title_height = (
            self.plotItem.titleLabel.height()
            if self.domain == AssayDomainType.DEPTH
            else 0.0
        )
        return axis_height + title_height

    def showEvent(self, e):
        self.visibilityChanged.emit(True)
        super().showEvent(e)

    def hideEvent(self, e):
        self.visibilityChanged.emit(False)
        super().hideEvent(e)

    def setVisible(self, visible):
        self.visibilityChanged.emit(visible)
        super().setVisible(visible)

    def deleteLater(self):
        """
        Need to remove PlotWidgetContainer as well.
        """
        super().deleteLater()
        if self.parent():
            self.parent().deleteLater()

    def mouseReleaseEvent(self, e):
        """
        mouseReleaseEvent override to remove first right click in drag and drop operation.
        If not overriden, contextual menu appear in the wrong location.
        """
        ce = self.scene().clickEvents
        pos = e.pos()
        to_remove = [
            x for x in ce if x.button() == Qt.RightButton and x.scenePos() != pos
        ]
        ce = [x for x in ce if x not in to_remove]
        self.scene().clickEvents = ce
        pg.PlotWidget.mouseReleaseEvent(self, e)

    def dropEvent(self, e):
        pos = e.pos()
        widget = e.source()
        self.parent().parent().parent().swap_widgets(self, widget)

    def dragEnterEvent(self, e):

        e.accept()

    def dragMoveEvent(self, e):

        e.accept()

    def mouseMoveEvent(self, e):

        if e.buttons() == Qt.RightButton:
            drag = QtGui.QDrag(self)
            mime = QtCore.QMimeData()
            drag.setMimeData(mime)
            dropAction = drag.exec(Qt.MoveAction)
        else:
            pg.PlotWidget.mouseMoveEvent(self, e)

    def get_container(self):
        """
        Get PlotWidgetContainer.
        """

        parent = self.parent()
        return parent

    def is_inspector_attached(self) -> bool:
        """
        Return True if inspector line is displayed.
        """
        activated_inspector = (
            len(
                [
                    item
                    for item in self.plotItem.items
                    if isinstance(item, AssayInspectorLine)
                ]
            )
            > 0
        )

        return activated_inspector

    def _switch_items(self, altitude: str) -> None:
        """
        Switch items in altitude/length.
        """
        if self.domain == AssayDomainType.TIME:
            return

        items = self.plotItem.items
        activated_inspector = self.is_inspector_attached()
        self.inspector.dettach()
        red_line_pos = []
        for item in items:
            if item == self.red_line or not hasattr(item, "switcher"):
                continue
            # get items planned_eoh limit only if it is < to data limit
            value = None
            if (
                not item.switcher.is_empty
                and item.switcher.min_planned_alt
                and item.switcher.planned_eoh
            ):
                if item.switcher.min_planned_alt < item.switcher.planned_eoh:
                    value = item.switcher.planned_eoh
            red_line_pos.append(value)
            item.switcher.switch(altitude)
            self.plotItem.minimap.addItem(item)

        self.automatic_limits()
        self.inspector.switcher.switch(altitude)

        # display planned geometry limit if it intersect data
        if altitude == "planned":
            red_line_pos = [value for value in red_line_pos if value is not None]

            if len(red_line_pos) > 0:
                pos = red_line_pos[0]
                self.red_line.setPos(pos)
                self.plotItem.addItem(self.red_line)
        else:
            self.plotItem.removeItem(self.red_line)

        self.enable_inspector_line(activated_inspector)
        self.getViewBox().invertY(altitude == "length")
        styles = {"color": "black", "font-size": "15px"}
        label = "Depth" if altitude == "length" else "Altitude"
        self.setLabel("left", label, "m", **styles)

        self.plotItem.minimap.invertY(altitude == "length")

        # refresh minimap display
        if self.assay_column_config:
            self.plotItem.display_minimap(
                self.assay_column_config.minimap_param.value()
            )

        # refresh stacked minimap. trigger Plot options minimap
        if self.stacked_config:
            self.stacked_config.display_stacked_minimap()

    def get_data_bounds(self) -> tuple[list]:
        """
        Get real data bounds.
        """

        x = []
        y = []
        for item in self.plotItem.items:
            if not hasattr(item, "dataBounds"):
                continue
            bounds_x = item.dataBounds(0)
            bounds_y = item.dataBounds(1)
            if bounds_x is not None and (
                bounds_x[0] != bounds_x[1] or self.domain == AssayDomainType.DEPTH
            ):
                x += bounds_x
            if bounds_y is not None and (
                bounds_y[0] != bounds_y[1] or self.domain == AssayDomainType.TIME
            ):
                y += bounds_y

        try:
            res = [min(x), max(x)], [min(y), max(y)]
        except:
            res = [None, None]

        return res

    def automatic_limits(self):
        y_range = None
        bounds = self.get_data_bounds()
        plot_y_range = bounds[1]
        if plot_y_range is not None:
            if y_range is None:
                y_range = plot_y_range
            else:
                y_range[0] = min(y_range[0], plot_y_range[0])
                y_range[1] = max(y_range[1], plot_y_range[1])
        if y_range:
            self.set_limits(*y_range)

    def customize_contextual_menu(self):
        menu = self.plotItem.getMenu()
        menu.menuAction().setVisible(False)
        # hide Mouse Mode options in contextual menu
        vbm_menu = self.getViewBox().menu
        vbm_actions = vbm_menu.actions()
        for a in vbm_actions:
            if a.text() == "Mouse Mode":
                a.setVisible(False)

        # add action for configuration selection
        select_action = QtWidgets.QAction("Display configuration", vbm_menu)
        select_action.triggered.connect(self._emit_config_signal)
        vbm_menu.addAction(select_action)

    def _emit_config_signal(self):
        """
        Emit AssayColumnVisualizationConfig.plot_selected_signal signal for selection in tree model.
        """

        if self.assay_column_config is not None:
            self.assay_column_config.plot_selected_signal.signal.emit(
                self.assay_column_config
            )

    def set_assay_config(self, assay_config: AssayVisualizationConfig) -> None:
        self.assay_config = assay_config
        self._displayed_unit = self.assay_config.unit_parameter.value()

        self._update_title()

    def set_assay_column_config(
        self, assay_column_config: AssayColumnVisualizationConfig
    ) -> None:
        self.assay_column_config = assay_column_config
        self._displayed_unit = self.assay_column_config.unit_parameter.value()
        self._update_title()

        # set trace coordinates to inspector line
        if self.domain == AssayDomainType.DEPTH:
            if assay_column_config.assay.geo_extractor is not None:
                self.inspector.set_trace_vertices(
                    assay_column_config.assay.geo_extractor.get_trace_vertices(
                        planned=False
                    ),
                    planned=False,
                )

                self.inspector.set_trace_vertices(
                    assay_column_config.assay.geo_extractor.get_trace_vertices(
                        planned=True
                    ),
                    planned=True,
                )

    def set_stacked_config(self, stacked_config: StackedConfiguration) -> None:
        self.stacked_config = stacked_config
        self._displayed_unit = self.stacked_config.unit_parameter.value()
        self._update_title()

    def update_displayed_unit(self, new_unit: str) -> None:
        if self._displayed_unit != new_unit:
            ranges = self.getViewBox().viewRange()

            if self.domain == AssayDomainType.TIME:
                update_range = ranges[1]
                new_range = unit_conversion(
                    from_unit=self._displayed_unit, to_unit=new_unit, array=update_range
                )
                self.getViewBox().setRange(yRange=new_range)

            else:
                update_range = ranges[0]
                new_range = unit_conversion(
                    from_unit=self._displayed_unit, to_unit=new_unit, array=update_range
                )
                self.getViewBox().setRange(xRange=new_range)
            self._displayed_unit = new_unit
        self._update_title()

    @property
    def _unit_str_suffix(self) -> str:
        if self._displayed_unit:
            return f" ({self._displayed_unit})"
        else:
            return ""

    @property
    def _time_assay_title(self) -> str:
        if self.assay_config:
            return f"{self.assay_config.assay_display_name}{self._unit_str_suffix}"
        elif self.assay_column_config:
            return (
                f"{self.assay_column_config.assay_display_name}{self._unit_str_suffix}"
            )
        elif self.stacked_config:
            return f"{self.stacked_config.name}{self._unit_str_suffix}"
        else:
            return ""

    @property
    def _depth_assay_title(self) -> str:
        if self.assay_config:
            return f"{self.assay_config.assay_display_name}{self._unit_str_suffix}"
        elif self.assay_column_config:
            return self._assay_column_title
        elif self.stacked_config:
            return f"{self.stacked_config.name}{self._unit_str_suffix}"
        else:
            return ""

    @property
    def _plot_title(self) -> str:
        if self.domain == AssayDomainType.TIME:
            return self._time_plot_title
        else:
            return self._depth_plot_title

    @property
    def _assay_column_title(self) -> str:
        # For column config, column name is indicated only if different from assay name
        column_name = self.assay_column_config.column.display_name
        assay_name = self.assay_column_config.assay_display_name
        display_assay = self.assay_column_config.title_assay_param.value()
        display_column = self.assay_column_config.title_column_param.value()
        if display_assay and display_column:
            if column_name != assay_name:
                return f"{assay_name}<br>{column_name}{self._unit_str_suffix}"
            else:
                return f"{assay_name}{self._unit_str_suffix}"
        elif display_assay and not display_column:
            return f"{assay_name}{self._unit_str_suffix}"
        elif not display_assay and display_column:
            return f"{column_name}{self._unit_str_suffix}"
        else:
            return ""

    @property
    def _depth_plot_title(self) -> str:
        # For depth plot we need indicate collar, assay name and unit
        if self.assay_config:
            return f"{self.assay_config.hole_display_name}<br>{self._depth_assay_title}"
        elif self.assay_column_config:
            display_collar = self.assay_column_config.title_collar_param.value()
            if display_collar and self._depth_assay_title != "":
                return f"{self.assay_column_config.hole_display_name}<br>{self._depth_assay_title}"
            elif display_collar and self._depth_assay_title == "":
                return self.assay_column_config.hole_display_name
            else:
                return f"{self._depth_assay_title}"

        elif self.stacked_config:
            return f"{self.stacked_config.name}{self._unit_str_suffix}"
        else:
            return ""

    @property
    def _time_plot_title(self) -> str:
        # For time plot we need only indicate collar
        if self.assay_config:
            return self.assay_config.hole_display_name
        elif self.assay_column_config:
            display_collar = self.assay_column_config.title_collar_param.value()
            if display_collar and self._assay_column_title != "":
                return f"{self.assay_column_config.hole_display_name}<br>{self._assay_column_title}"
            elif display_collar and self._assay_column_title == "":
                return self.assay_column_config.hole_display_name
            else:
                return f"{self._assay_column_title}"
        elif self.stacked_config:
            return self.stacked_config.name
        else:
            return ""

    def _update_title(self) -> None:
        self.setTitle(self._plot_title, color="black", size="15px")
        self.plotItem.titleLabel.setFullText(self._plot_title)
        if self.domain == AssayDomainType.TIME:
            styles = {"color": "black", "font-size": "15px"}
            self.setLabel("left", self._time_assay_title, "", **styles)

        self.plotItem.alignAxes.emit(True)

    def set_limits(self, min_domain_axis: float, max_domain_axis: float) -> None:
        """
        Define limit depending on current domain

        Args:
            min_domain_axis: float
            max_domain_axis: float
        """

        tol = abs(max_domain_axis - min_domain_axis) / 20

        if self.domain == AssayDomainType.TIME:
            self.getViewBox().setLimits(
                xMin=min_domain_axis - tol, xMax=max_domain_axis + tol
            )
        else:
            self.getViewBox().setLimits(
                yMin=min_domain_axis - tol, yMax=max_domain_axis + tol
            )

        # update minimap range
        self.plotItem.minimap._setRange(
            range=[min_domain_axis - tol, max_domain_axis + tol]
        )

        if not self.inspector.valueDefined:
            self.inspector.setPos(
                min_domain_axis + (max_domain_axis - min_domain_axis) / 2.0
            )
            self.inspector.valueDefined = True

    def enable_inspector_line(self, enable: bool) -> None:
        """
        Enable or disable inspector line for this plot

        Args:
            enable: (bool)
        """
        if enable:
            self.inspector.attachToPlotItem(self.getPlotItem())
        else:
            self.inspector.dettach()

    def enable_inspector_sync(
        self, sync_inspector: AssayInspectorLine, enable: bool
    ) -> None:
        """
        Enable inspector line synchronisation

        Args:
            sync_inspector: (AssayInspectorLine) inspector line to be synchronized
            enable: (bool) enable or disable sync
        """
        # Disable current sync
        if self._sync_inspector and self._connection_to_sync_inspector:
            self._sync_inspector.disconnect(self._connection_to_sync_inspector)
            self._sync_inspector = None
            self._connection_to_sync_inspector = None

        if self._connection_from_sync_inspector:
            self.inspector.sigPositionChanged.disconnect(
                self._connection_from_sync_inspector
            )
            self._connection_from_sync_inspector = None

        # Enable if asked
        if enable and sync_inspector != self.inspector:
            self._sync_inspector = sync_inspector
            self._connection_to_sync_inspector = (
                self._sync_inspector.sigPositionChanged.connect(
                    lambda inspector: self.inspector.setPos(inspector.getPos())
                )
            )
            self._connection_from_sync_inspector = (
                self.inspector.sigPositionChanged.connect(
                    lambda inspector: self._sync_inspector.setPos(inspector.getPos())
                )
            )

    def minimumSizeHint(self):
        """
        Return minimum size hint with width calculation from displayed title and axes

        Returns: (QSize) minimum size hint

        """
        res = super().minimumSizeHint()
        if self.plotItem.titleLabel:
            size_title_label = self.plotItem.titleLabel.sizeHint(
                QtCore.Qt.SizeHint.MinimumSize, 0
            )
            res.setWidth(int(size_title_label.width()))
        if self.getAxis("left"):
            size_axis = self.getAxis("left").minimumSize()
            res.setWidth(res.width() + int(size_axis.width()))
            if self.getAxis("left").label:
                res.setHeight(
                    int(self.getAxis("left").label.boundingRect().width() * 0.8)
                )  ## bounding rect is usually an overestimate
        if self.getAxis("right"):
            size_axis = self.getAxis("right").minimumSize()
            res.setWidth(res.width() + int(size_axis.width()))
        if self.getAxis("top"):
            size_axis = self.getAxis("top").minimumSize()
            res.setHeight(res.height() + int(size_axis.height()))
        if self.getAxis("bottom"):
            size_axis = self.getAxis("bottom").minimumSize()
            res.setHeight(res.height() + int(size_axis.height()))
        return res
