# Copyright (C) 2021 The Qt Company Ltd.
# Copyright (C) 2018 Unified Automation GmbH
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import os
from pathlib import Path
from textwrap import dedent

from ui_mainwindow import Ui_MainWindow
from opcuamodel import OpcUaModel
from certificatedialog import CertificateDialog
from helpdialog import HelpDialog

from PySide6.QtCore import (QCoreApplication, QDir, qDebug, QMetaObject,
                            Qt, QtMsgType, Slot, QUrl, qInstallMessageHandler,
                            qWarning, Q_ARG)
from PySide6.QtGui import QColor, QFontDatabase, QKeySequence
from PySide6.QtWidgets import (QHeaderView, QMainWindow, QMessageBox)
from PySide6.QtOpcUa import (QOpcUa, QOpcUaApplicationDescription,
                             QOpcUaAuthenticationInformation, QOpcUaProvider,
                             QOpcUaErrorState, QOpcUaPkiConfiguration,
                             QOpcUaUserTokenPolicy)

_main_window = None
_MESSAGE_TYPES = {QtMsgType.QtWarningMsg: "Warning",
                  QtMsgType.QtCriticalMsg: "Critical",
                  QtMsgType.QtFatalMsg: "Fatal", QtMsgType.QtInfoMsg: "Info",
                  QtMsgType.QtDebugMsg: "Debug"}


_MESSAGE_COLORS = {QtMsgType.QtWarningMsg: Qt.GlobalColor.darkYellow,
                   QtMsgType.QtCriticalMsg: Qt.GlobalColor.darkRed,
                   QtMsgType.QtFatalMsg: Qt.GlobalColor.darkRed,
                   QtMsgType.QtInfoMsg: Qt.GlobalColor.black,
                   QtMsgType.QtDebugMsg: Qt.GlobalColor.black}


def _messageLogContext(context):
    """Return a short, readable string from a QMessageLogContext."""
    result = " ("
    if context.file:
        result += Path(context.file).name + ":" + str(context.line)
    if context.function and "(" in context.function:
        function = context.function[:context.function.index("(")] + "()"
        space = function.rfind(" ")
        result += ", " + (function[space + 1:] if space != -1 else function)
    result += ")"
    return result


def _messageHandler(msg_type, context, message):
    global _main_window

    if _main_window:
        type = _MESSAGE_TYPES[msg_type]
        text = f'{type:<7}: {message}'
        color = QColor(_MESSAGE_COLORS[msg_type])
        QMetaObject.invokeMethod(_main_window, "log", Qt.QueuedConnection,
                                 Q_ARG(str, text),
                                 Q_ARG(str, _messageLogContext(context)),
                                 Q_ARG(QColor, color))


class MainWindow(QMainWindow):

    def __init__(self, initial_url, parent=None):
        global _main_window

        super(MainWindow, self).__init__(parent)

        _main_window = self

        self._end_point_list = []
        self._opcua_client = None
        self._client_connected = False
        self._identity = None
        self._pki_config = QOpcUaPkiConfiguration()
        self._endpoint = None
        self._old_messagehandler = qInstallMessageHandler(_messageHandler)

        self._ui = Ui_MainWindow()
        self._ui.setupUi(self)
        self._ui.log.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
        self.setWindowTitle(QCoreApplication.applicationName())
        self._ui.host.setText(initial_url)
        self._ui.quitAction.setShortcut(QKeySequence(Qt.CTRL | Qt.Key_Q))
        self._ui.quitAction.triggered.connect(self.close)
        self._ui.helpAction.setShortcut(QKeySequence(QKeySequence.HelpContents))
        self._ui.helpAction.triggered.connect(self.showHelpDialog)
        self._ui.aboutAction.triggered.connect(qApp.aboutQt)  # noqa: F821

        self._opcua_model = OpcUaModel(self._ui.centralwidget)
        self.mOpcUaProvider = QOpcUaProvider(self._ui.centralwidget)

        self.updateUiState()

        self._ui.opcUaPlugin.addItems(self.mOpcUaProvider.availableBackends())
        self._ui.treeView.setModel(self._opcua_model)

        if not self._ui.opcUaPlugin.count():
            self._ui.opcUaPlugin.setDisabled(True)
            self._ui.connectButton.setDisabled(True)
            error = "The list of available OPCUA plugins is empty. No connection possible."
            QMessageBox.critical(self, "No OPCUA plugins available", error)

        self._ui.host.returnPressed.connect(self.animateFindServersClick)
        self._ui.findServersButton.clicked.connect(self.findServers)
        self._ui.getEndpointsButton.clicked.connect(self.getEndpoints)
        self._ui.connectButton.clicked.connect(self.connectToServer)

        self.setupPkiConfiguration()

        self._identity = self._pki_config.applicationIdentity()

    def __del__(self):
        qInstallMessageHandler(self._old_messagehandler)

    @Slot()
    def animateFindServersClick(self):
        self._ui.findServersButton.animateClick()

    def setupPkiConfiguration(self):
        pkidir = os.path.join(os.path.dirname(__file__), 'pki')
        self._pki_config.setClientCertificateFile(pkidir + "/own/certs/opcuaviewer.der")
        self._pki_config.setPrivateKeyFile(pkidir + "/own/private/opcuaviewer.pem")
        self._pki_config.setTrustListDirectory(pkidir + "/trusted/certs")
        self._pki_config.setRevocationListDirectory(pkidir + "/trusted/crl")
        self._pki_config.setIssuerListDirectory(pkidir + "/issuers/certs")
        self._pki_config.setIssuerRevocationListDirectory(pkidir + "/issuers/crl")

        # create the folders if they don't exist yet
        self.createPkiFolders()

    def createClient(self):
        if not self._opcua_client:
            client_name = self._ui.opcUaPlugin.currentText()
            self._opcua_client = self.mOpcUaProvider.createClient(client_name)
        if not self._opcua_client:
            message = "Connecting to the given server failed. See the log for details."
            self.log(message, "", Qt.red)
            QMessageBox.critical(self, "Failed to connect to server", message)
            return

        self._opcua_client.connectError.connect(self.showErrorDialog)
        self._opcua_client.setApplicationIdentity(self._identity)
        self._opcua_client.setPkiConfiguration(self._pki_config)

        if (QOpcUaUserTokenPolicy.TokenType.Certificate
                in self._opcua_client.supportedUserTokenTypes()):
            authInfo = QOpcUaAuthenticationInformation()
            authInfo.setCertificateAuthentication()
            self._opcua_client.setAuthenticationInformation(authInfo)

        self._opcua_client.connected.connect(self.clientConnected)
        self._opcua_client.disconnected.connect(self.clientDisconnected)
        self._opcua_client.errorChanged.connect(self.clientError)
        self._opcua_client.stateChanged.connect(self.clientState)
        self._opcua_client.endpointsRequestFinished.connect(self.getEndpointsComplete)
        self._opcua_client.findServersFinished.connect(self.findServersComplete)

    @Slot()
    def findServers(self):
        locale_ids = []
        server_uris = []
        url = QUrl(self._ui.host.text())

        self.updateUiState()

        self.createClient()
        # set default port if missing
        if url.port() == -1:
            url.setPort(4840)

        if self._opcua_client:
            self._opcua_client.findServers(url, locale_ids, server_uris)
            qDebug(f"Discovering servers on {url.toString()}")

    @Slot(list, QOpcUa.UaStatusCode)
    def findServersComplete(self, servers, status_code):
        server = QOpcUaApplicationDescription()
        if QOpcUa.isSuccessStatus(status_code):
            self._ui.servers.clear()
            for server in servers:
                self._ui.servers.addItems(server.discoveryUrls())
        self.updateUiState()

    @Slot()
    def getEndpoints(self):
        self._ui.endpoints.clear()
        self.updateUiState()
        if self._ui.servers.currentIndex() >= 0:
            self.createClient()
            self._opcua_client.requestEndpoints(self._ui.servers.currentText())

    @Slot(list, QOpcUa.UaStatusCode)
    def getEndpointsComplete(self, endpoints, status_code):
        index = 0
        if QOpcUa.isSuccessStatus(status_code):
            self._end_point_list = endpoints
            for endpoint in endpoints:
                securityMode = str(endpoint.securityMode())
                securityMode = securityMode[securityMode.index('.') + 1:]
                securityPolicy = endpoint.securityPolicy()
                self._ui.endpoints.addItem(f"{securityPolicy} ({securityMode})", index)
                index = index + 1

        self.updateUiState()

    @Slot()
    def connectToServer(self):
        if self._client_connected:
            self._opcua_client.disconnectFromEndpoint()
            return

        if self._ui.endpoints.currentIndex() >= 0:
            self._endpoint = self._end_point_list[self._ui.endpoints.currentIndex()]
            self.createClient()
            self._opcua_client.connectToEndpoint(self._endpoint)

    @Slot()
    def clientConnected(self):
        self._client_connected = True
        self.updateUiState()

        self._opcua_client.namespaceArrayUpdated.connect(self.namespacesArrayUpdated)
        self._opcua_client.updateNamespaceArray()

    @Slot()
    def clientDisconnected(self):
        self._client_connected = False
        self._opcua_client = None
        self._opcua_model.setOpcUaClient(None)
        self.updateUiState()

    @Slot(list)
    def namespacesArrayUpdated(self, namespace_array):
        if not namespace_array:
            qWarning("Failed to retrieve the namespaces array")
            return

        self._opcua_client.namespaceArrayUpdated.disconnect(self.namespacesArrayUpdated)
        self._opcua_model.setOpcUaClient(self._opcua_client)
        self._ui.treeView.header().setSectionResizeMode(1, QHeaderView.Interactive)

    def clientError(self, error):
        qWarning(f"Client error changed {error}")

    def clientState(self, state):
        qDebug(f"Client state changed {state}")

    def updateUiState(self):
        # allow changing the backend only if it was not already created
        self._ui.opcUaPlugin.setEnabled(not self._opcua_client)
        text = "Disconnect" if self._client_connected else "Connect"
        self._ui.connectButton.setText(text)

        if self._client_connected:
            self._ui.host.setEnabled(False)
            self._ui.servers.setEnabled(False)
            self._ui.endpoints.setEnabled(False)
            self._ui.findServersButton.setEnabled(False)
            self._ui.getEndpointsButton.setEnabled(False)
            self._ui.connectButton.setEnabled(True)
        else:
            self._ui.host.setEnabled(True)
            self._ui.servers.setEnabled(self._ui.servers.count() > 0)
            self._ui.endpoints.setEnabled(self._ui.endpoints.count() > 0)
            self._ui.findServersButton.setDisabled(len(self._ui.host.text()) == 0)
            self._ui.getEndpointsButton.setEnabled(self._ui.servers.currentIndex() != -1)
            self._ui.connectButton.setEnabled(self._ui.endpoints.currentIndex() != -1)

        if not self._opcua_client:
            self._ui.servers.setEnabled(False)
            self._ui.endpoints.setEnabled(False)
            self._ui.getEndpointsButton.setEnabled(False)
            self._ui.connectButton.setEnabled(False)

    @Slot(str, str, QColor)
    def log(self, text, context, color):
        cf = self._ui.log.currentCharFormat()
        cf.setForeground(color)
        self._ui.log.setCurrentCharFormat(cf)
        self._ui.log.appendPlainText(text)
        if context:
            cf.setForeground(Qt.gray)
            self._ui.log.setCurrentCharFormat(cf)
            self._ui.log.insertPlainText(context)

    def createPkiPath(self, path):
        msg = f"Creating PKI path '{path}': "
        dir = QDir()
        ret = dir.mkpath(path)
        if ret:
            qDebug(msg + "SUCCESS.")
        else:
            qWarning(msg + "FAILED.")
        return ret

    def createPkiFolders(self):
        result = self.createPkiPath(self._pki_config.trustListDirectory())
        if not result:
            return result

        result = self.createPkiPath(self._pki_config.revocationListDirectory())
        if not result:
            return result

        result = self.createPkiPath(self._pki_config.issuerListDirectory())
        if not result:
            return result

        result = self.createPkiPath(self._pki_config.issuerRevocationListDirectory())
        if not result:
            return result

        return result

    @Slot(QOpcUaErrorState)
    def showErrorDialog(self, error_state):
        result = 0
        status_code = QOpcUa.statusToString(error_state.errorCode())
        if error_state.isClientSideError():
            msg = "The client reported: "
        else:
            msg = "The server reported: "

        step = error_state.connectionStep()
        if step == QOpcUaErrorState.ConnectionStep.CertificateValidation:
            msg += dedent(
                """
                Server certificate validation failed with error {:04X} ({}).
                Click 'Abort' to abort the connect, or 'Ignore' to continue connecting.
                """).format(error_state.errorCode(), status_code)
            dialog = CertificateDialog(self)
            result = dialog.showCertificate(msg, self._endpoint.serverCertificate(),
                                            self._pki_config.trustListDirectory())
            error_state.setIgnoreError(result == 1)
        elif step == QOpcUaErrorState.ConnectionStep.OpenSecureChannel:
            msg += "OpenSecureChannel failed with error {:04X} ({}).".format(
                error_state.errorCode(), status_code)
            QMessageBox.warning(self, "Connection Error", msg)
        elif step == QOpcUaErrorState.ConnectionStep.CreateSession:
            msg += "CreateSession failed with error {:04X} ({}).".format(
                error_state.errorCode(), status_code)
            QMessageBox.warning(self, "Connection Error", msg)
        elif step == QOpcUaErrorState.ConnectionStep.ActivateSession:
            msg += "ActivateSession failed with error {:04X} ({}).".format(
                error_state.errorCode(), status_code)
            QMessageBox.warning(self, "Connection Error", msg)

    @Slot()
    def showHelpDialog(self):
        dialog = HelpDialog(self)
        dialog.exec()
