import bisect
import re
import sys
import threading
from time import perf_counter_ns, sleep

import pyqtgraph as pg
from PySide6 import QtWidgets
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
from PySide6.QtGui import QCloseEvent
from serial.tools import list_ports  # type: ignore

from use_the_force._logging import Logging
from use_the_force.forceSensor import ForceSensor
from use_the_force.gui.error_ui import Ui_errorWindow
from use_the_force.gui.main_ui import Ui_MainWindow

__all__ = [
    "UserInterface",
    "mainLogWorker",
    "saveToLog",
    "ForceSensorGUI",
    "ErrorInterface",
    "start",
]


class UserInterface(QtWidgets.QMainWindow):
    def __init__(self) -> None:
        super().__init__()

        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)
        # disable MDM until switched
        self.ui.MDM.setVisible(False)
        self.ui.MDM.setEnabled(False)

        self.error_ui = ErrorInterface()

        ###########
        # BUTTONS #
        ###########
        # Button event mapping
        self.button_handlers = {
            self.ui.butConnect: self.butConnect,
            self.ui.butFile: self.butFile,
            self.ui.butTare: self.butTare,
            self.ui.butRecord: self.butRecord,
            self.ui.butClear: self.butClear,
            self.ui.butSave: self.butSave,
            self.ui.butSingleRead: self.butSingleRead,
            self.ui.butSwitchManual: self.butSwitchMDM,
            self.ui.butFileMDM: self.butFileMDM,
            self.ui.butReadForceMDM: self.readForceMDM,
            self.ui.butSwitchDirectionMDM: self.switchDirectionMDM,
            self.ui.butDeletePreviousMDM: self.butDeletePreviousMDM,
            self.ui.butMove: self.butMove,
            self.ui.butUpdateVelocity: self.butUpdateVelocity,
            self.ui.butHome: self.butHome,
            self.ui.butForceStop: self.butForceStop,
            self.ui.butTareDisplay: self.butDisplayTare,
            self.ui.butSwapPositions: self.swapPositions,
        }

        # Connect all buttons
        for button, handler in self.button_handlers.items():
            button.pressed.connect(handler)

        # Text boxes and value boxes
        self.ui.setNewtonPerCount.valueChanged.connect(self.setLoadPerCount)
        self.ui.setPlotTimerInterval.valueChanged.connect(self.updatePlotTimerInterval)
        self.ui.setLineReads.valueChanged.connect(self.singleReadLinesForcesUpdate)
        self.ui.setLineSkips.valueChanged.connect(self.singleReadSkipsUpdate)
        self.ui.setStepSizeMDM.valueChanged.connect(self.singleReadStepUpdate)
        self.ui.title_2.textChanged.connect(self.updatePlotMDMTitle)
        self.ui.setUnitDisplay.editingFinished.connect(self.updateUnitDisplay)

        ###############
        # Check Ports #
        ###############
        ports: list[str] = [port.device for port in list_ports.comports()]
        if len(ports) > 0:
            self.ui.setPortName.setText(ports[0])
        else:
            self.ui.setPortName.setText("No ports found")
        del ports

        ###################
        # INITIALIZE VARS #
        ###################
        self.sensorConnected: bool = False
        self.threadReachedEnd: bool = False
        self.recording: bool = False
        self.fileGraphOpen: bool = False
        self.fileOpen: bool = False
        self.fileMDMOpen: bool = False
        self.readForceMDMToggle: bool = False
        self.switchDirectionMDMToggle: bool = False
        self.MDMActive: bool = False
        self.singleReadToggle: bool = False
        self.homed: bool = False
        self.singleReadForce: float = float()
        self.singleReadForces: int = self.ui.setLineReads.value()
        self.singleReadSkips: int = self.ui.setLineSkips.value()
        self.stepSizeMDM: float = self.ui.setStepSizeMDM.value()
        self.plotIndexX: int = 1
        self.plotIndexY: int = 2
        self.velocity: int = self.ui.setVelocity.value()
        self.txtLogMDM: str = str()
        self.reMDMMatch: re.Pattern[str] = re.compile(r"\[[A-Za-z0-9]+\]")
        self.data: list[list[float]] = [[], [], []]
        setattr(self.ui, "errorMessage", [])

        ###################
        # INITIALIZE PLOT #
        ###################
        self.plot(clrBg="default")
        self.plotMDM()

        # Plot timer interval in ms
        self.plotTimerInterval: int = self.ui.setPlotTimerInterval.value()

        ##################
        # MULTITHREADING #
        ##################
        self.sensor = ForceSensorGUI(caller=self)
        # self.cmds = Commands(self.sensor.ser)
        self.sensor.errorSignal.connect(self.error)

        self.plotTimer = QTimer()
        self.plotTimer.timeout.connect(self.updatePlot)

        self.mainLogWorker = mainLogWorker(self)
        self.mainLogWorker.startSignal.connect(self.startPlotTimer)
        self.mainLogWorker.endSignal.connect(self.stopPlotTimer)
        self.mainLogWorker.switchXAxisSignal.connect(self.switchToTime)
        # self.mainLogWorker.singleReadStartSignal.connect()
        self.mainLogWorker.singleReadEndSignal.connect(self.singleReadEnd)

        self.saveToLog = saveToLog(self)
        self.saveToLog.startSignal.connect(self.saveStart)
        self.saveToLog.endSignal.connect(self.saveEnd)

        self.thread_pool = QThreadPool.globalInstance()

        ############################
        # CHANGE IN NEXT UI UPDATE #
        ############################
        # TODO: add screen for movement options and movement cycles.
        # ^ Might never update this one ^

    def enableElement(self, *elements: QtWidgets.QWidget) -> None:
        """
        Enables the specified GUI elements.
        """
        [element.setEnabled(True) for element in elements]

    def disableElement(self, *elements: QtWidgets.QWidget) -> None:
        """
        Disables the specified GUI elements.
        """
        [element.setEnabled(False) for element in elements]

    def resetConnectUI(self) -> None:
        """
        Resets the UI state for the connect button and related elements.
        """
        self.ui.butConnect.setText("Connect")
        self.ui.butConnect.setChecked(False)
        self.sensorConnected = False
        self.homed = False
        self.enableElement(self.ui.butConnect, self.ui.setPortName)
        self.ui.setForceApplied.editingFinished.disconnect(self.butDisplayForce)
        self.disableElement(
            self.ui.setNewtonPerCount,
            self.ui.setGaugeValue,
            self.ui.butRecord,
            self.ui.butTare,
            self.ui.butSingleRead,
            self.ui.setUnitDisplay,
            self.ui.butTareDisplay,
            self.ui.setForceApplied,
            self.ui.butHome,
        )
        self.ui.toolBox.setItemText(
            self.ui.toolBox.indexOf(self.ui.sensorOptions), "Sensor"
        )

    def setConnectedUI(self) -> None:
        """
        Updates the UI state for a successful connection.
        """
        self.ui.butConnect.setText("Connected")
        self.ui.butConnect.setChecked(True)
        self.enableElement(
            self.ui.butTare,
            self.ui.butSingleRead,
            self.ui.setNewtonPerCount,
            self.ui.setGaugeValue,
            self.ui.butHome,
            self.ui.butForceStop,
            self.ui.butUpdateVelocity,
            self.ui.butConnect,
            self.ui.setUnitDisplay,
            self.ui.butTareDisplay,
            self.ui.setForceApplied,
        )
        self.disableElement(self.ui.setPortName)
        self.ui.setForceApplied.editingFinished.connect(self.butDisplayForce)
        if not self.MDMActive:
            if not self.fileOpen:
                self.butClear()
            self.enableElement(self.ui.butFile)
        elif self.fileMDMOpen:
            self.enableElement(self.ui.butReadForceMDM)

    def closeEvent(self, event: QCloseEvent) -> None:
        """
        Safely closes the program and ends certain threads

        Some threads can be set to run infinitly, but will now be closed
        """
        if self.recording:
            self.recording = False
        if self.sensorConnected:
            self.butConnect()
        if not (self.fileOpen or self.MDMActive) and len(self.data[0]) > 0:
            self.ui.errorMessage = [
                "Unsaved Data",
                "Unsaved Data!",
                "You have unsaved data, quit anyways?",
            ]
            if not self.error():  # Cancel
                event.ignore()
                self.butSave()

    def plot(self, **kwargs) -> None:
        """
        Plots the data on the central plot.

        :param data: list containing both x-axes and y-axes as `[...,x,y]`. `...` is ignored
        :type data: list[..., list, list]

        :param label1loc: location of first label, default: `"left"`
        :type label1loc: str
        :param label1txt: text of first label
        :type label1txt: str
        :param label2loc: location of second label, default: `"bottom"`
        :type label2loc: str
        :param label2txt: text of second label
        :type label2txt: str

        :param color: line color, default: `"r"`
        :type color: str
        :param linewidth: linewidth, default: `5`
        :type linewidth: int

        :param clrBg: color of the background, default: `"w"`
        :type clrBg: str
        :param clrFg: color of the foreground, default: `"k"`
        :type clrFg: str
        """

        pg.setConfigOption("foreground", kwargs.pop("clrFg", "k"))
        pg.setConfigOption("background", kwargs.pop("clrBg", "w"))
        # self.ui.graphMDM.setBackground(background=kwargs.pop("clrBg", "w"))
        self.ui.graph1.plot(
            *self.data[-2:],
            symbol=kwargs.pop("symbol", None),
            pen={
                "color": kwargs.pop("color", "r"),
                "width": kwargs.pop("linewidth", 5),
            },
        )

        self.updatePlotLabel(
            graph=self.ui.graph1,
            labelLoc=kwargs.pop("label1loc", "left"),
            labelTxt=kwargs.pop("label1txt", self.ui.yLabel.text()),
        )

        self.updatePlotLabel(
            graph=self.ui.graph1,
            labelLoc=kwargs.pop("label2loc", "bottom"),
            labelTxt=kwargs.pop("label2txt", "Displacement [mm]"),
        )

        self.ui.yLabel.textChanged.connect(self.updatePlotYLabel)

        self.ui.graph1.setTitle(self.ui.title.text(), color=(255, 255, 255))
        self.ui.title.textChanged.connect(self.updatePlotTitle)

    def updatePlot(self) -> None:
        """
        Updates the plot
        """
        self.ui.graph1.plot(self.data[self.plotIndexX], self.data[self.plotIndexY])

        if len(self.data[self.plotIndexX]) > 0:
            try:
                self.xLim = int(self.ui.xLimSet.value())
                if abs(self.xLim) < self.data[self.plotIndexX][-1] and (self.xLim != 0):
                    self.ui.graph1.setXRange(
                        self.data[self.plotIndexX][-1] + self.xLim,
                        self.data[self.plotIndexX][-1],
                    )
                    i = bisect.bisect_left(
                        self.data[self.plotIndexX],
                        self.data[self.plotIndexX][-1] + self.xLim,
                    )
                    self.ui.graph1.setYRange(
                        min(self.data[self.plotIndexY][i:]),
                        max(self.data[self.plotIndexY][i:]),
                    )

                elif self.xLim == 0:
                    self.ui.graph1.setXRange(0, self.data[self.plotIndexX][-1])
                    self.ui.graph1.setYRange(
                        min(self.data[self.plotIndexY]), max(self.data[self.plotIndexY])
                    )

            except:
                self.ui.graph1.setXRange(0, self.data[self.plotIndexX][-1])
                self.ui.graph1.setYRange(
                    min(self.data[self.plotIndexY]), max(self.data[self.plotIndexY])
                )

    def switchPlotIndexX(self, index: int) -> None:
        self.plotIndexX = index
        if index == 0:
            self.updatePlotLabel(
                graph=self.ui.graph1, labelLoc="bottom", labelTxt="Time [s]"
            )
        elif index == 1:
            self.updatePlotLabel(
                graph=self.ui.graph1, labelLoc="bottom", labelTxt="Displacement [mm]"
            )
        self.ui.graph1.clear()
        self.updatePlot()

    def switchPlotIndexY(self, index: int) -> None:
        self.plotIndexY = index
        self.ui.graph1.clear()
        self.updatePlot()

    def switchToTime(self) -> None:
        self.switchPlotIndexX(0)

    def updatePlotLabel(self, graph, labelLoc: str, labelTxt: str) -> None:
        """
        Updates the label

        :param graph: what graph to update
        :type PlotWidget:
        :param labelLoc: label location
        :type labelLoc: str
        :param labelTxt: label text
        :type labelTxt: str
        """
        graph.setLabel(labelLoc, labelTxt)

    def updatePlotYLabel(self) -> None:
        self.updatePlotLabel(
            graph=self.ui.graph1, labelLoc="left", labelTxt=self.ui.yLabel.text()
        )

    def updatePlotTimerInterval(self) -> None:
        tmp = self.ui.setPlotTimerInterval.value()
        try:
            tmp = int(tmp)
            if tmp > 0:
                self.plotTimerInterval = tmp
                if hasattr(self, "plotTimer"):
                    self.plotTimer.setInterval(self.plotTimerInterval)

        except:
            pass

        del tmp

    def updatePlotTitle(self) -> None:
        self.ui.graph1.setTitle(self.ui.title.text(), color=(255, 255, 255))

    def startPlotTimer(self) -> None:
        """
        Start the QTimer in the main thread when the signal is emitted.
        """
        self.plotTimer.start()

    def stopPlotTimer(self) -> None:
        """
        Stop the QTimer
        """
        self.plotTimer.stop()

    def butConnect(self) -> None:
        """
        Function defining what to do when a button is pressed.

        - checks if butConnect isChecked()
        - Starts a thread to connect/ disconnect the sensor
        - Thread ends with re-enabling the button
        """
        # Gets enabled again at the end of the thread
        self.ui.butConnect.setEnabled(False)

        # Disconnect
        if self.sensorConnected:
            self.sensorConnected = False

            self.startsensorDisonnect = threading.Thread(
                target=self.sensorDisconnect, name="sensorDisconnect"
            )
            self.startsensorDisonnect.start()
            self.ui.setPortName.setEnabled(True)

        # Connect
        else:
            devices: list[str] = [port.device for port in list_ports.comports()]
            if self.ui.setPortName.text() in devices:
                self.sensorConnected = True
                self.ui.butFile.setEnabled(False)
                self.startsensorConnect = threading.Thread(
                    target=self.sensorConnect, name="sensorConnect"
                )
                self.startsensorConnect.start()
            else:
                if len(devices) > 0:
                    self.ui.errorMessage = [
                        "Port not found",
                        f"Port: {self.ui.setPortName.text().upper()} was not detected!",
                        "Available ports:\n"
                        + "\n".join([port.device for port in list_ports.comports()]),
                    ]
                    self.error()
                else:
                    self.ui.errorMessage = [
                        "Port not found",
                        f"Port: {self.ui.setPortName.text().upper()} was not detected!",
                        "Available ports:\nNo ports found!",
                    ]
                    self.error()
                self.ui.butConnect.setText("Connect")
                self.ui.butConnect.setEnabled(True)
            del devices

    def sensorConnect(self) -> None:
        """
        Script to connect to the M5Din Meter.

        If connection fails, will raise an error dialog with the error.
        """
        self.ui.butConnect.setText("Connecting...")
        self.sensor()
        if self.sensor.failed:
            self.sensor.failed = False
            self.resetConnectUI()
            return

        # needs time or it will break
        sleep(0.5)
        try:
            vr = self.sensor.cmds.VR()
            if vr == "":
                raise RuntimeError("[ERROR]: Returned empty string.")
            else:
                self.ui.toolBox.setItemText(
                    self.ui.toolBox.indexOf(self.ui.sensorOptions),
                    "Sensor v:" + vr.split(":")[1][1:],
                )
        except RuntimeError:
            self.sensor.ClosePort()
            self.resetConnectUI()
            self.ui.errorMessage = [
                "Connection Error",
                "Connection Error",
                "[ERROR]: Retrieved no data.",
            ]
            self.error()
            return

        pos = self.sensor.cmds.GP()
        vel = self.sensor.cmds.GV()
        self.ui.setVelocity.setValue(vel)
        self.velocity = vel
        if pos >= 0 and pos < 47:
            self.homed = True
            self.enableElement(self.ui.butRecord, self.ui.butMove)
            self.ui.setPosition.setValue(pos)

        self.setConnectedUI()

    def sensorDisconnect(self) -> None:
        """
        Script to safely disconnect the M5Din Meter.

        Will first stop the recording, if running, with `butRecord()` function.
        """
        if self.recording:
            self.butRecord()
        self.sensor.ClosePort()
        sleep(0.5)  # Give some time to Windows/M5Din Meter to fully disconnect
        self.resetConnectUI()

    @Slot(str, str, str)
    @Slot(str, str, None)
    def error(self) -> bool:
        """
        Launches the error dialog.

        :param errorType: error type name, can be found with `Exception.__class__.__name__`
        :type errorType: str
        :param errorText: text why the error occured
        :type errorText: str

        :returns: Result of dialogue (button pressed), `True` for OK, `False` for Cancel
        :rtype: bool
        """
        if len(self.ui.errorMessage) == 3:
            if self.ui.errorMessage[2] == "[ERROR]: movement aborted, home to unlock":
                if self.recording:
                    self.recording = False
                    self.ui.butRecord.setText("Start")
                    self.enableElement(
                        self.ui.butClear,
                        self.ui.butFile,
                        self.ui.butSave,
                        self.ui.butSwitchManual,
                    )
                self.homed = False
                self.enableElement(self.ui.butHome)
                self.disableElement(self.ui.butRecord, self.ui.butMove)

        return bool(self.error_ui(*self.ui.errorMessage))

    def butFile(self) -> None:
        """
        Function for what `butFile` has to do.

        What to do is based on if the button is in the `isChecked()` state.
        - `if isChecked():` close file
        - `else:` opens dialog box to select/ create a .csv file
        """
        if self.fileOpen:
            self.fileOpen = False
            self.ui.butFile.setChecked(True)
            self.measurementLog.closeFile()
            del self.measurementLog
            self.ui.butFile.setText("-")
            self.butClear()
            self.ui.butSwitchManual.setEnabled(True)

        else:
            self.fileOpen = True
            self.ui.butFile.setChecked(True)
            self.filePath, _ = QtWidgets.QFileDialog.getSaveFileName(
                filter="CSV files (*.csv)"
            )
            # Cancel gives a 0 length string
            if self.filePath != "":
                self.measurementLog = Logging(self.filePath)
                self.measurementLog.createLogGUI()
                self.ui.butFile.setText(*self.filePath.split("/")[-1].split(".")[:-1])
                if len(self.data[1]) > 0:
                    self.ui.butSave.setEnabled(False)
                    self.thread_pool.start(self.saveToLog.run)
                self.ui.butSwitchManual.setEnabled(False)
            else:
                self.fileOpen = False
                self.ui.butFile.setText("-")
                self.ui.butFile.setChecked(False)

    def butRecord(self) -> None:
        """
        start button, disables/ enables most buttons and starts/ joins threads for the logging
        """
        if self.recording:
            self.recording = False
            self.ui.butRecord.setText("Start")
            self.enableElement(
                self.ui.butClear,
                self.ui.butFile,
                self.ui.butTare,
                self.ui.butSave,
                self.ui.butSingleRead,
                self.ui.butSwitchManual,
                self.ui.butUpdateVelocity,
                self.ui.butHome,
            )

            if not self.threadReachedEnd:
                pass  # one day will be able to wait for motor to stop.

        else:
            if len(self.data[0]) > 0:
                if not self.fileOpen:
                    self.ui.errorMessage = [
                        "Unsaved Data",
                        "Unsaved Data!",
                        "You have unsaved data, do you wish to continue?",
                    ]
                    if not self.error():  # Cancel
                        self.butSave()
                        return
                self.butClear()

            self.recording = True
            self.threadReachedEnd = False
            self.ui.butRecord.setText("Stop")
            self.ui.butRecord.setChecked(True)

            self.disableElement(
                self.ui.butClear,
                self.ui.butFile,
                self.ui.butTare,
                self.ui.butSave,
                self.ui.butSingleRead,
                self.ui.butSwitchManual,
                self.ui.butUpdateVelocity,
                self.ui.butHome,
            )

            self.sensor.ser.reset_input_buffer()

            if (
                self.ui.setStartPos.value() == self.ui.setEndPos.value()
                and self.plotIndexX != 0
                and self.ui.setTime.value() > 0
            ):
                self.switchPlotIndexX(0)
            elif self.plotIndexX != 1:
                self.switchPlotIndexX(1)

            self.mainLogWorker.logLess = self.ui.butFile.text() == "-"
            self.thread_pool.start(self.mainLogWorker.run)

    def butClear(self) -> None:
        """
        button that clears data in `self.data` and resets graph
        """
        del self.data
        self.data = [[], [], []]
        if self.MDMActive:
            self.graphMDM1.clear()
            self.graphMDM2.clear()
        else:
            self.ui.graph1.clear()
        if self.sensorConnected:
            self.sensor.ser.reset_input_buffer()
        self.ui.butSave.setEnabled(False)
        if self.fileOpen:
            self.butFile()

    def butTare(self) -> None:
        """
        button for Taring values sent from the M5Din Meter

        starts a thread to count down, end of thread re-enables button
        """
        self.disableElement(
            self.ui.butTare,
            self.ui.butConnect,
            self.ui.butRecord,
            self.ui.butSingleRead,
        )
        th = threading.Thread(target=self.butTareActive, name="tareActive")
        th.start()

    def butTareActive(self) -> None:
        """
        the actual Tare script
        """
        self.ui.butTare.setChecked(True)

        self.ui.butTare.setText("...")
        GaugeValue = self.sensor.tare()
        self.ui.setGaugeValue.setValue(GaugeValue)
        self.sensor.tareValue = GaugeValue
        self.ui.butTare.setText("Tare")

        if (not self.MDMActive) and self.homed:
            self.enableElement(self.ui.butRecord)
        self.enableElement(self.ui.butTare, self.ui.butConnect, self.ui.butSingleRead)
        self.ui.butTare.setChecked(False)

    def butSave(self) -> None:
        """
        Function for what `butSave` has to do.

        What to do is based on if `butFile` is in the `isChecked()` state.
        - `if isChecked():` do nothing as it is already saved
        - `else:` open new file and write data
        """
        if self.fileOpen:
            # When a file is selected it will already
            # write to the file when it reads a line
            self.disableElement(self.ui.butSave)
            self.ui.toolBox.setCurrentIndex(2)

        else:
            self.butFile()
            # Cancelling file selecting gives a 0 length string
            if self.filePath != "":
                self.disableElement(self.ui.butSave)
                self.ui.toolBox.setCurrentIndex(2)

    def saveStart(self) -> None:
        self.ui.butSave.setText("Saving...")

    def saveEnd(self) -> None:
        self.ui.butSave.setText("Save")

    def butSingleRead(self) -> None:
        self.singleReadToggle = True
        self.disableElement(
            self.ui.butSingleRead,
            self.ui.butRecord,
            self.ui.butConnect,
            self.ui.butTare,
        )
        self.thread_pool.start(self.mainLogWorker.singleRead)

    def singleReadEnd(self) -> None:
        if self.MDMActive:
            if self.fileMDMOpen:
                self.enableElement(self.ui.butReadForceMDM)
            if self.singleReadToggle:
                self.ui.SRCount.setText("{:.5f}".format(self.singleReadForce))
                self.singleReadToggle = False
            else:
                if self.readForceMDMToggle:
                    self.data[0].append(0)
                    self.data[1].append(
                        round(
                            self.data[1][-1] + self.stepSizeMDM,
                            len(str(self.stepSizeMDM).split(".")[-1]),
                        )
                    )
                    self.data[2].append(self.singleReadForce)

                    if re.search(
                        self.reMDMMatch, self.ui.xLabel_2.text()
                    ) and re.search(self.reMDMMatch, self.ui.yLabel_2.text()):
                        xUnit: list[str] = (
                            self.ui.xLabel_2.text().split("[")[-1].split("]")
                        )
                        yUnit: list[str] = (
                            self.ui.yLabel_2.text().split("[")[-1].split("]")
                        )
                        if len(xUnit) > 0 and len(yUnit) > 0:
                            self.txtLogMDM = (
                                self.txtLogMDM
                                + f"\n{self.data[1][-1]} {xUnit[0]}, {self.data[2][-1]} {yUnit[0]}"
                            )
                        else:
                            self.txtLogMDM = (
                                self.txtLogMDM
                                + f"\n{self.data[1][-1]}, {self.data[2][-1]}"
                            )
                    else:
                        self.txtLogMDM = (
                            self.txtLogMDM + f"\n{self.data[1][-1]}, {self.data[2][-1]}"
                        )
                    self.ui.plainTextEdit.setPlainText(self.txtLogMDM)
                    self.plainTextEditScrollbar = (
                        self.ui.plainTextEdit.verticalScrollBar()
                    )
                    self.plainTextEditScrollbar.setValue(
                        self.plainTextEditScrollbar.maximum()
                    )
                else:
                    self.data[0].append(0)
                    self.data[1].append(0.0)
                    self.data[2].append(self.singleReadForce)
                    self.readForceMDMToggle = True
                    if re.search(
                        self.reMDMMatch, self.ui.xLabel_2.text()
                    ) and re.search(self.reMDMMatch, self.ui.yLabel_2.text()):
                        xUnit: list[str] = (
                            self.ui.xLabel_2.text().split("[")[1].split("]")
                        )
                        yUnit: list[str] = (
                            self.ui.yLabel_2.text().split("[")[1].split("]")
                        )
                        if len(xUnit) > 0 and len(yUnit) > 0:
                            self.txtLogMDM = (
                                self.txtLogMDM
                                + f"{self.data[1][-1]} {xUnit[0]}, {self.data[2][-1]} {yUnit[0]}"
                            )
                    else:
                        self.txtLogMDM = (
                            self.txtLogMDM + f"{self.data[1][-1]}, {self.data[2][-1]}"
                        )
                    self.ui.plainTextEdit.setPlainText(self.txtLogMDM)
                    self.plainTextEditScrollbar = (
                        self.ui.plainTextEdit.verticalScrollBar()
                    )
                    self.plainTextEditScrollbar.setValue(
                        self.plainTextEditScrollbar.maximum()
                    )

                self.enableElement(
                    self.ui.butSwitchDirectionMDM, self.ui.butDeletePreviousMDM
                )

                self.measurementLog.writeLog(
                    [self.data[0][-1], self.data[1][-1], self.data[2][-1]]
                )
                self.updatePlotMDM()
        else:
            self.ui.SRCount.setText("{:.5f}".format(self.singleReadForce))
            if self.homed:
                self.ui.butRecord.setEnabled(True)
            self.singleReadToggle = False

        self.enableElement(self.ui.butSingleRead, self.ui.butTare, self.ui.butConnect)

    def singleReadSkipsUpdate(self) -> None:
        """
        Changes the value of singleReadSkips when textbox is changed
        """
        self.singleReadSkips = self.ui.setLineSkips.value()

    def singleReadLinesForcesUpdate(self) -> None:
        """
        Changes the value of singleReadForces when textbox is changed
        """
        self.singleReadForces = self.ui.setLineReads.value()

    def singleReadStepUpdate(self) -> None:
        """
        Changes the value of stepSizeMDM when textbox is changed
        """
        self.stepSizeMDM = self.ui.setStepSizeMDM.value()
        if self.switchDirectionMDMToggle:
            self.stepSizeMDM = -1 * self.stepSizeMDM

    def readForceMDM(self) -> None:
        self.disableElement(self.ui.butReadForceMDM, self.ui.butSwitchDirectionMDM)
        self.thread_pool.start(self.mainLogWorker.singleRead)

    def switchDirectionMDM(self) -> None:
        self.measurementLog.closeFile()
        del self.measurementLog

        self.readForceMDMToggle = False
        self.enableElement(self.ui.butDeletePreviousMDM)

        if self.switchDirectionMDMToggle:
            self.switchDirectionMDMToggle = False
            del self.txtLogMDM
            self.txtLogMDM = str()
            self.ui.plainTextEdit.clear()
            self.ui.butSwitchDirectionMDM.setText("Switch Direction")

            self.fileMDMOpen = False
            self.ui.butFileMDM.setChecked(False)
            self.ui.butFileMDM.setText("-")
            self.butClear()
            self.enableElement(self.ui.butSwitchManual, self.ui.butConnect)
            self.disableElement(self.ui.butReadForceMDM, self.ui.butSwitchDirectionMDM)

        else:
            self.switchDirectionMDMToggle = True
            self.ui.butSwitchDirectionMDM.setText("Stop")

            self.measurementLog = Logging(
                "".join(self.filePath.split(".")[:-1]) + "_out.csv"
            )
            self.measurementLog.createLogGUI()

            self.stepSizeMDM = -self.stepSizeMDM
            if len(self.data[1]) > 0:
                self.switchDistance: float = self.data[1][-1]
                self.switchForce: float = self.data[2][-1]
            else:
                self.switchDistance, self.switchForce = 0.0, 0.0
            del self.data

            self.data = [[0], [self.switchDistance], [self.switchForce]]

            self.measurementLog.writeLog([self.data[1][-1], self.data[2][-1]])

            self.readForceMDMToggle = True
            del self.txtLogMDM
            self.txtLogMDM = str()
            if re.search(self.reMDMMatch, self.ui.xLabel_2.text()) and re.search(
                self.reMDMMatch, self.ui.yLabel_2.text()
            ):
                xUnit: list[str] = self.ui.xLabel_2.text().split("[")[1].split("]")
                yUnit: list[str] = self.ui.yLabel_2.text().split("[")[1].split("]")
                if len(xUnit) > 0 and len(yUnit) > 0:
                    self.txtLogMDM = (
                        self.txtLogMDM
                        + f"{self.data[1][-1]} {xUnit[0]}, {self.data[2][-1]} {yUnit[0]}"
                    )
            else:
                self.txtLogMDM = (
                    self.txtLogMDM + f"{self.data[1][-1]}, {self.data[2][-1]}"
                )
            self.ui.plainTextEdit.setPlainText(self.txtLogMDM)
            self.plainTextEditScrollbar = self.ui.plainTextEdit.verticalScrollBar()
            self.plainTextEditScrollbar.setValue(self.plainTextEditScrollbar.maximum())

    def butSwitchMDM(self) -> None:
        self.butClear()
        if self.MDMActive:
            self.MDMActive = False
            # visibility
            self.ui.centerGraph.setVisible(True)
            self.ui.MDM.setVisible(False)

            # main ui buttons
            self.enableElement(self.ui.graphOptions, self.ui.butClear)
            if self.sensorConnected and self.homed:
                self.enableElement(self.ui.butRecord)

            # MDM
            self.disableElement(self.ui.MDM)

        else:
            self.MDMActive = True

            # visibility
            self.ui.centerGraph.setVisible(False)
            self.ui.MDM.setVisible(True)

            # main ui buttons
            self.disableElement(
                self.ui.graphOptions,
                self.ui.butSave,
                self.ui.butClear,
                self.ui.butRecord,
            )

            # MDM
            self.ui.MDM.setEnabled(True)

    def plotMDM(self, **kwargs) -> None:
        pg.setConfigOption("foreground", kwargs.pop("clrFg", "k"))
        pg.setConfigOption("background", kwargs.pop("clrBg", "w"))
        # self.ui.graphMDM.setBackground(background=kwargs.pop("clrBg", "w"))
        self.graphMDM1 = self.ui.graphMDM.plot(
            *self.data[1:],
            name=kwargs.pop("nameIn", "Approach"),
            symbol=kwargs.pop("symbolIn", None),
            pen=pg.mkPen(
                {
                    "color": kwargs.pop("colorIn", (0, 0, 255)),
                    "width": kwargs.pop("linewidthIn", 5),
                }
            ),
        )
        self.graphMDM2 = self.ui.graphMDM.plot(
            *self.data[1:],
            name=kwargs.pop("nameOut", "Retraction"),
            symbol=kwargs.pop("symbolOut", None),
            pen=pg.mkPen(
                {
                    "color": kwargs.pop("colorOut", (255, 127, 0)),
                    "width": kwargs.pop("linewidthOut", 5),
                }
            ),
        )
        self.ui.graphMDM.setLabel(
            kwargs.pop("labelyloc", "left"),
            kwargs.pop("labelytxt", self.ui.yLabel_2.text()),
        )
        self.ui.graphMDM.setLabel(
            kwargs.pop("labelxloc", "bottom"),
            kwargs.pop("labelxtxt", self.ui.xLabel_2.text()),
        )

        self.graphMDMLegend = self.ui.graphMDM.addLegend(
            offset=(1, 1), labelTextColor=(255, 255, 255)
        )
        self.graphMDMLegend.addItem(self.graphMDM1, name=self.graphMDM1.name())
        self.graphMDMLegend.addItem(self.graphMDM2, name=self.graphMDM2.name())

        self.ui.graphMDM.setTitle(self.ui.title_2.text(), color=(255, 255, 255))

        self.ui.yLabel_2.textChanged.connect(self.updatePlotMDMYLabel)
        self.ui.xLabel_2.textChanged.connect(self.updatePlotMDMXLabel)

    def updatePlotMDMTitle(self) -> None:
        self.ui.graphMDM.setTitle(self.ui.title_2.text(), color=(255, 255, 255))

    def updatePlotMDMYLabel(self) -> None:
        self.updatePlotLabel(
            graph=self.ui.graphMDM, labelLoc="left", labelTxt=self.ui.yLabel_2.text()
        )

    def updatePlotMDMXLabel(self) -> None:
        self.updatePlotLabel(
            graph=self.ui.graphMDM, labelLoc="bottom", labelTxt=self.ui.xLabel_2.text()
        )

    def updatePlotMDM(self) -> None:
        if self.switchDirectionMDMToggle:
            self.graphMDM2.setData(*self.data[1:])
        else:
            self.graphMDM1.setData(*self.data[1:])

    def butFileMDM(self) -> None:
        """
        Function for what `butFileMDM` has to do.

        - `if fileMDMOpen:` close file
        - `else:` opens dialog box to select/ create a .csv file
        """
        if self.fileMDMOpen:
            if not self.switchDirectionMDMToggle:
                self.switchDirectionMDM()
            else:
                self.disableElement(self.ui.butSwitchDirectionMDM)
            self.switchDirectionMDMToggle = False
            del self.txtLogMDM
            self.txtLogMDM = str()
            self.ui.plainTextEdit.clear()
            self.ui.butSwitchDirectionMDM.setText("Switch Direction")
            self.readForceMDMToggle = False

            self.fileMDMOpen = False
            self.ui.butFileMDM.setChecked(False)
            del self.measurementLog
            self.ui.butFileMDM.setText("-")
            self.butClear()
            self.enableElement(self.ui.butSwitchManual, self.ui.butConnect)
            self.disableElement(self.ui.butReadForceMDM, self.ui.butSwitchDirectionMDM)

        else:
            self.filePath, _ = QtWidgets.QFileDialog.getSaveFileName(
                filter="CSV files (*.csv)"
            )
            if self.filePath != "":
                self.fileMDMOpen = True
                self.ui.butFileMDM.setChecked(True)
                self.measurementLog = Logging(
                    "".join(self.filePath.split(".")[:-1]) + "_in.csv"
                )
                self.measurementLog.createLogGUI()
                self.ui.butFileMDM.setText(
                    *self.filePath.split("/")[-1].split(".")[:-1]
                )
                self.ui.butSwitchManual.setEnabled(False)
                if self.sensorConnected:
                    self.ui.butReadForceMDM.setEnabled(True)
            else:
                self.ui.butFileMDM.setText("-")

    def butDeletePreviousMDM(self) -> None:
        """
        Deletes the previous value in self.data

        main use for when MDM hits other side in capillary bridge experiment, or when the capillary bridge gets broken without being noticed
        """
        # data changes
        for i in range(len(self.data)):
            self.data[i] = self.data[i][:-1]

        # already switched and only 1 value left
        if len(self.data[1]) <= 1 and self.switchDirectionMDMToggle:
            self.disableElement(
                self.ui.butDeletePreviousMDM, self.ui.butSwitchDirectionMDM
            )
        # not switched and no values left
        elif len(self.data[1]) <= 0:
            self.readForceMDMToggle = False
            self.disableElement(
                self.ui.butDeletePreviousMDM, self.ui.butSwitchDirectionMDM
            )
        # there should be a better way to just
        # drop the last value, but this works for now
        # and does not seem to cause much trouble
        self.measurementLog.replaceFile(data=self.data)

        # text box changes
        self.txtLogMDM = str("\n").join(self.txtLogMDM.split("\n")[:-1])
        self.ui.plainTextEdit.setPlainText(self.txtLogMDM)
        self.plainTextEditScrollbar = self.ui.plainTextEdit.verticalScrollBar()
        self.plainTextEditScrollbar.setValue(self.plainTextEditScrollbar.maximum())

        self.updatePlotMDM()

    def setLoadPerCount(self) -> None:
        """
        Changes the value of LoadPerCount when textbox is changed

        Allows for changing the value while still getting live data
        """
        self.sensor.loadPerCount = self.ui.setNewtonPerCount.value()

    def butMove(self) -> None:
        """Handles move button press"""
        self.sensor.cmds.SP(self.ui.setPosition.value())

    def butUpdateVelocity(self) -> None:
        self.velocity = int(self.ui.setVelocity.value())
        self.sensor.cmds.SV(self.velocity)

    def butHome(self) -> None:
        self.ui.errorMessage = [
            "Home Warning",
            "Home Warning",
            "Make sure nothing is obstructing the path downwards.<br>The motor will not stop during homing!",
        ]
        if self.error():
            self.butUpdateVelocity()
            self.sensor.cmds.HM()
            self.homed = True
            self.enableElement(self.ui.butRecord, self.ui.butMove)

    def butForceStop(self) -> None:
        self.homed = False
        self.enableElement(self.ui.butHome)
        self.disableElement(self.ui.butRecord, self.ui.butMove)
        if self.recording:
            self.recording = False
            self.ui.butRecord.setText("Start")
            self.enableElement(
                self.ui.butClear,
                self.ui.butFile,
                self.ui.butTare,
                self.ui.butSave,
                self.ui.butSingleRead,
                self.ui.butSwitchManual,
            )
        try:
            self.sensor.cmds.ST()
        except RuntimeError as e:
            self.ui.errorMessage = [
                e.__class__.__name__,
                str(e),
                "Might have to unplug the adapter and sensor.",
            ]
            self.error()

    def butDisplayTare(self) -> None:
        self.sensor.cmds.TR()

    def butDisplayForce(self) -> None:
        self.sensor.cmds.SF(float(self.ui.setForceApplied.value()))

    def updateUnitDisplay(self) -> None:
        if self.sensorConnected:
            self.sensor.cmds.UU(str(self.ui.setUnitDisplay.text()))

    def swapPositions(self) -> None:
        startPos = self.ui.setStartPos.value()
        endPos = self.ui.setEndPos.value()
        self.ui.setStartPos.setValue(endPos)
        self.ui.setEndPos.setValue(startPos)


class mainLogWorker(QObject, QRunnable):
    startSignal = Signal()
    endSignal = Signal()
    errorSignal = Signal()
    switchXAxisSignal = Signal()
    singleReadStartSignal = Signal()
    singleReadEndSignal = Signal()

    def __init__(self, callerSelf: UserInterface) -> None:
        super().__init__()
        self.callerSelf: UserInterface = callerSelf
        self.logLess: bool = bool()
        self.singleReadForces: int = self.callerSelf.singleReadForces

    def run(self) -> None:
        # mm/s speed of stage
        trueVelocity: float = (self.callerSelf.velocity) / 60

        currentPos: int = self.callerSelf.sensor.cmds.GP()
        startPos: int = self.callerSelf.ui.setStartPos.value()
        endPos: int = self.callerSelf.ui.setEndPos.value()
        Position: float = 0.0

        travelTime: float = abs(endPos - startPos) / trueVelocity
        measurementTime: float = travelTime + self.callerSelf.ui.setTime.value()
        allowTimeSwitch = self.callerSelf.ui.setTime.value() != 0.0
        if currentPos != startPos:
            self.callerSelf.sensor.cmds.SP(startPos)
            # wait until the stage has reached the start position
            sleep(abs(startPos - currentPos) / trueVelocity + 1)
        self.singleReadForces = self.callerSelf.singleReadForces

        _skip: list[float] = [
            self.callerSelf.sensor.cmds.SR()
            for i in range(self.callerSelf.singleReadSkips)
        ]

        self.startSignal.emit()
        self.callerSelf.sensor.cmds.DC(False)
        time = float(0.0)
        self.callerSelf.sensor.T0 = perf_counter_ns()

        # start movement
        self.callerSelf.sensor.cmds.SP(endPos)

        while (time < measurementTime) and self.callerSelf.recording:
            try:
                time = round((perf_counter_ns() - self.callerSelf.sensor.T0) / 1e9, 8)
                if time < travelTime:
                    Position = trueVelocity * time
                elif self.callerSelf.plotIndexX != 0 and allowTimeSwitch:
                    Position = float(abs(endPos - startPos))
                    self.switchXAxisSignal.emit()
                Force = self.read()
                self.callerSelf.data[0].append(time)
                self.callerSelf.data[1].append(Position)
                self.callerSelf.data[2].append(Force)
                if not self.logLess:
                    # logs: t[s], s[mm], F[mN]
                    self.callerSelf.measurementLog.writeLog([time, Position, Force])

                self.singleReadForces = self.callerSelf.singleReadForces

            except ValueError:
                # I know this isn't the best way to deal with it, but it works fine (for now)
                pass

        self.callerSelf.sensor.cmds.DC()
        self.endSignal.emit()

        if self.callerSelf.recording:
            self.callerSelf.threadReachedEnd = True
            self.callerSelf.butRecord()

        if self.logLess:
            # self.callerSelf.unsavedData = self.callerSelf.data
            self.callerSelf.enableElement(self.callerSelf.ui.butSave)

    def read(self) -> float:
        forces: list[float] = [
            self.callerSelf.sensor.ForceFix(self.callerSelf.sensor.cmds.SR())
            for i in range(self.singleReadForces)
        ]
        Force = round(sum(forces) / self.singleReadForces, ndigits=8)
        return Force

    def singleRead(self) -> None:
        self.singleReadStartSignal.emit()
        self.singleReadForces = self.callerSelf.singleReadForces
        _skip: list[float] = [
            self.callerSelf.sensor.ForceFix(self.callerSelf.sensor.cmds.SR())
            for i in range(self.callerSelf.singleReadSkips)
        ]
        self.callerSelf.singleReadForce = self.read()
        self.singleReadEndSignal.emit()


class saveToLog(QObject, QRunnable):
    startSignal = Signal()
    endSignal = Signal()

    def __init__(self, callerSelf: UserInterface) -> None:
        super().__init__()
        self.callerSelf: UserInterface = callerSelf

    def run(self) -> None:
        self.startSignal.emit()
        self.callerSelf.measurementLog.writeLogFull(self.callerSelf.data)
        self.endSignal.emit()


class ForceSensorGUI(ForceSensor, QObject, QRunnable):
    errorSignal = Signal()

    def __init__(
        self, caller: UserInterface, PortName: str | None = None, **kwargs
    ) -> None:
        """
        Class that combines the ForceSensor class with GUI parts and multithreading.

        :param PortName: Portname over which to establish the connection. If None, the this class has to be called again with the port name.
        :type PortName: str | None
        """
        ForceSensor.__init__(self, PortName=PortName, kwargs=kwargs)
        QObject.__init__(self)
        QRunnable.__init__(self)

        self.caller: UserInterface = caller
        self.ui: Ui_MainWindow = caller.ui
        self.failed: bool = False

        if PortName is not None:
            self.tareValue: float = float(self.ui.setGaugeValue.value())
            self.loadPerCount: float = float(self.ui.setNewtonPerCount.value())

            self.PortName: str = PortName

            try:
                self.ser.setPort(self.PortName)
                self.ser.open()
                self.ser.setRTS(False)
                self.ser.setDTR(False)
            except Exception as e:
                self.failed = True
                self.ui.errorMessage = [
                    e.__class__.__name__,
                    str(e),
                    "Check if Port is not already in use.",
                ]
                self.errorSignal.emit()

    def __call__(self, **kwargs) -> None:
        """
        Opens up the serial port, checks the gauge value and makes sure data is available.
        """
        self.tareValue: float = float(self.ui.setGaugeValue.value())
        self.loadPerCount: float = float(self.ui.setNewtonPerCount.value())

        self.PortName: str = self.ui.setPortName.text()

        try:
            self.ser.setPort(self.PortName)
            self.ser.open()
            self.ser.setRTS(False)
            self.ser.setDTR(False)
        except Exception as e:
            self.failed = True
            self.ui.errorMessage = [
                e.__class__.__name__,
                str(e),
                "Check if Port is not already in use.",
            ]
            self.errorSignal.emit()


class ErrorInterface(QtWidgets.QDialog):
    def __init__(self) -> None:
        # roep de __init__() aan van de parent class
        super().__init__()

        self.ui = Ui_errorWindow()
        self.ui.setupUi(self)
        self.ui.ErrorText.setTextFormat(Qt.TextFormat.RichText)

    def __call__(
        self, windowTitle: str, errorText: str, additionalInfo: str | None = None
    ) -> int:
        """
        Enabling the window with the different types.

        :param windowTitle: sets the window title
        :type windowTitle: str
        :param errorText: sets the short error text in the window
        :type errorText: str
        :param additionalInfo: sets the additional information in the window
        :type additionalInfo: str | None

        :return: returns the value of the button pressed, 1 for OK, 0 for Cancel
        :rtype: int
        """
        self.setWindowTitle(windowTitle)
        if additionalInfo is not None:
            self.ui.ErrorText.setText(f"""
<b>{errorText}</b><br>
<br>
{additionalInfo}
""")
        else:
            self.ui.ErrorText.setText(f"<b>{errorText}</b>")

        return self.exec()


def start() -> None:
    """
    Basic main function that starts the GUI

    this function can be recreated to change values set in `UserInterface`

    Function:
    ```
    import sys
    from pyside6 import QtWidgets
    from use_the_force import gui
    def main() -> None:
        app = QtWidgets.QApplication(sys.argv)
        ui = gui.UserInterface()
        ui.show()
        ret = app.exec_()
        sys.exit(ret)
    ```
    """
    app = QtWidgets.QApplication(sys.argv)
    ui = UserInterface()
    ui.show()
    ret = app.exec_()
    sys.exit(ret)
