#!/usr/bin/env python3

import os
import sys
import signal
import argparse
import threading


def make_qt5_compatible():
    setattr(Qt,          'AlignCenter', Qt.AlignmentFlag.AlignCenter)
    setattr(Qt,          'UserRole',    Qt.ItemDataRole.UserRole)
    setattr(Qt,          'Horizontal',  Qt.Orientation.Horizontal)
    setattr(Qt,          'SmoothTransformation', Qt.TransformationMode.SmoothTransformation)
    setattr(QMessageBox, 'Ok',          QMessageBox.StandardButton.Ok)


QT_HELP_TEXT = '''\
Qt options:
  -style <style>            Sets the widget style (e.g. fusion, windows, gtk)
  -stylesheet <file>        Loads a Qt stylesheet file
  -platform <platform>      Qt platform backend (e.g. xcb, wayland, windows)
  -platformpluginpath <path> Path to platform plugins
  -display <name>           Display name (on X11)
  -reverse                  Enables right-to-left layout direction
  -qmljsdebugger=<args>     Enables QML/JS debugging\
'''

argp = argparse.ArgumentParser(
    prog='nbfc-qt',
    description='Qt-based GUI for NBFC-Linux',
    epilog=QT_HELP_TEXT,
    formatter_class=argparse.RawDescriptionHelpFormatter)

argp.add_argument('--version', action='version', version='%(prog)s 0.5.0')

grp = argp.add_argument_group(title='Widgets')

grp.add_argument('--service',
    help='Start with service widget',
    dest='widget', action='store_const', const='service')

grp.add_argument('--fans',
    help='Start with fans widget',
    dest='widget', action='store_const', const='fans')

grp.add_argument('--basic',
    help='Start with basic configuration widget',
    dest='widget', action='store_const', const='basic')

grp.add_argument('--rated',
    help='Start with rated configurations widget',
    dest='widget', action='store_const', const='rated')

grp.add_argument('--sensors',
    help='Start with sensors widget',
    dest='widget', action='store_const', const='sensors')

grp.add_argument('--update',
    help='Start with update widget',
    dest='widget', action='store_const', const='update')

grp = argp.add_argument_group(title='Qt version')

grp.add_argument('--qt5',
    help='Use PyQt5',
    dest='qt_version', action='store_const', const=5)

grp.add_argument('--qt6',
    help='Use PyQt6',
    dest='qt_version', action='store_const', const=6)

opts, qt_args = argp.parse_known_args()


if opts.qt_version is None:
    try:
        from PyQt6.QtWidgets import *
        from PyQt6.QtCore import Qt, QTimer, QThread, QObject, pyqtSignal
        from PyQt6.QtGui import QAction, QPixmap
        make_qt5_compatible()
    except ImportError:
        try:
            from PyQt5.QtWidgets import *
            from PyQt5.QtCore import Qt, QTimer, QThread, QObject, pyqtSignal
            from PyQt5.QtGui import QPixmap
        except ImportError:
            print("Please install Python Qt bindings (PyQt5 or PyQt6)")
            sys.exit(1)

elif opts.qt_version == 5:
    from PyQt5.QtWidgets import *
    from PyQt5.QtCore import Qt, QTimer, QThread, QObject, pyqtSignal
    from PyQt5.QtGui import QPixmap

elif opts.qt_version == 6:
    from PyQt6.QtWidgets import *
    from PyQt6.QtCore import Qt, QTimer, QThread, QObject, pyqtSignal
    from PyQt6.QtGui import QAction, QPixmap
    make_qt5_compatible()




def validate_fan_index(fan_temperature_source, fan_count):
    if 'FanIndex' not in fan_temperature_source:
        return ['Missing field: FanIndex']

    fan_index = fan_temperature_source['FanIndex']

    if not isinstance(fan_index, int):
        return ['FanIndex: Invalid type (expected integer)']

    if fan_index < 0:
        return ['FanIndex: Cannot be negative']

    if fan_index >= fan_count:
        return ['FanIndex: No fan found for FanIndex `%d`' % fan_index]

    return []

def validate_algorithm_type(fan_temperature_source):
    if 'TemperatureAlgorithmType' not in fan_temperature_source:
        return []

    algorithm = fan_temperature_source['TemperatureAlgorithmType']

    if not isinstance(algorithm, str):
        return ['TemperatureAlgorithmType: Invalid type (expected string)']

    if algorithm not in ('Average', 'Min', 'Max'):
        return ['TemperatureAlgorithmType: Invalid value: %s' % algorithm]

    return []

def validate_sensors(fan_temperature_source):
    if 'Sensors' not in fan_temperature_source:
        return []

    sensors = fan_temperature_source['Sensors']

    if not isinstance(sensors, list):
        return ['Sensors: Invalid type (expected array)']

    errs = []

    for i, sensor in enumerate(fan_temperature_source['Sensors']):
        if not isinstance(sensor, str):
            errs.append('Sensors[%d]: Invalid type (expected string)' % i)

    return errs

def validate_fan_temperature_sources(fan_temperature_sources, fan_count):
    '''
    Checks `fan_temperature_sources` for errors.

    Args:
        fan_temperature_sources (list): A list of FanTemperatureSource objects.
        fan_count (int): The fan count of the service.

    Returns:
        list: A list of error strings describing what's wrong. This list is
              empty if no errors are found.
    '''

    errors = []

    for i, fan_temperature_source in enumerate(fan_temperature_sources):

        for err in validate_fan_index(fan_temperature_source, fan_count):
            errors.append('FanTemperatureSources[%d]: %s' % (i, err))

        for err in validate_algorithm_type(fan_temperature_source):
            errors.append('FanTemperatureSources[%d]: %s' % (i, err))

        for err in validate_sensors(fan_temperature_source):
            errors.append('FanTemperatureSources[%d]: %s' % (i, err))

        for field in fan_temperature_source:
            if field not in ('FanIndex', 'TemperatureAlgorithmType', 'Sensors'):
                errors.append('FanTemperatureSources[%d]: Unknown field: %s' % (i, field))

    return errors

def fix_fan_temperature_source(fan_temperature_source, fan_count):

    if 'FanIndex' not in fan_temperature_source:
        return None

    fan_index = fan_temperature_source.get('FanIndex', None)

    if not isinstance(fan_index, int):
        return None

    if fan_index < 0:
        return None

    if fan_index >= fan_count:
        return None


    algorithm = None

    if 'TemperatureAlgorithmType' in fan_temperature_source:
        algorithm = fan_temperature_source['TemperatureAlgorithmType']

        if algorithm not in ('Average', 'Min', 'Max'):
            algorithm = None


    sensors = []

    if 'Sensors' in fan_temperature_source:
        if isinstance(fan_temperature_source['Sensors'], list):
            for sensor in fan_temperature_source['Sensors']:
                if not isinstance(sensor, str):
                    continue

                sensors.append(sensor)


    obj = {'FanIndex': fan_index}

    if algorithm:
        obj['TemperatureAlgorithmType'] = algorithm

    if sensors:
        obj['Sensors'] = sensors

    return obj

def fix_fan_temperature_sources(fan_temperature_sources, fan_count):
    '''
    Fixes an invalid FanTemperatureSources config.

    Args:
        fan_temperature_sources (list): A list of FanTemperatureSource objects.
        fan_count (int): The fan count of the service.

    Returns:
        list: A list of fixed FanTemperatureSource objects.
    '''

    result = []

    for fan_temperature_source in fan_temperature_sources:
        o = fix_fan_temperature_source(fan_temperature_source, fan_count)
        if o:
            result.append(o)

    return result

'''
Error messages.
'''

CANNOT_CONFIGURE_MSG = "You cannot change the configuration because you are not root"

CANNOT_CONTROL_MSG = "You cannot control the service because you are not root"
ABOUT_NBFC_LINUX = """\
<b>NBFC-Linux</b> is a fan control utility designed for Linux systems. <br />
<br />
<b>Author</b>: <a href="https://github.com/braph">Benjamin Abendroth</a> <br />
<br />
<b>License</b>: GPL-3.0 <br />
<br />
<b>Project Homepage</b>: <a href="https://github.com/nbfc-linux/nbfc-linux">GitHub.com/NBFC-Linux/NBFC-Linux</a> <br />
<br />
<b>Donation Link</b>: <a href="https://paypal.me/BenjaminAbendroth">PayPal.me/BenjaminAbendroth</a> <br />
"""
#!/usr/bin/env python3

import os
import re
import json
import socket
import subprocess
from collections import namedtuple

Sensor = namedtuple('Sensor', ['name', 'description'])

class NbfcClientError(Exception):
    pass

class NbfcClient:
    """
    A client to interact with the NBFC service using Unix sockets.
    """

    def __init__(self):
        """
        Initializes the NbfcClient instance by retrieving necessary file paths.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        self.socket_file = self.get_compile_time_variable('socket_file')
        self.config_file = self.get_compile_time_variable('config_file')
        self.model_configs_dir = self.get_compile_time_variable('model_configs_dir')
        self.model_configs_dir_mutable = '/var/lib/nbfc/configs'


    def call_nbfc(self, args):
        """
        Calls the `nbfc` binary with the given arguments and returns the output.

        Args:
            args (list of str):
                The arguments to pass to the NBFC client.

        Returns:
            str:
                The output (STDOUT) from the client command.

        Raises:
            NbfcClientError:
                - If the `nbfc` binary could not be found.

                - If the client command returns a non-zero exit code.
                  The exception's text is the output written to STDERR.
        """

        command = ['nbfc'] + args

        try:
            result = subprocess.run(command, capture_output=True, text=True, check=False)
        except FileNotFoundError as e:
            raise NbfcClientError('Could not find the `nbfc` program. Is NBFC-Linux installed?') from e

        if result.returncode != 0:
            raise NbfcClientError(result.stderr.rstrip())

        return result.stdout.rstrip()

    def socket_communicate(self, data):
        """
        Sends a JSON-encoded message to the NBFC service via a Unix socket
        and returns the response.

        Args:
            data (dict):
                The data to send to the NBFC service.

        Returns:
            dict:
                The response from the NBFC service.

        Raises:
            NbfcClientError:
                If the socket file could not be found.

            PermissionError:
                If the socket file could not be opened.

            JSONDecodeError:
                If the received JSON is invalid.

            TypeError:
                If `data` could not be serialized to JSON.
        """

        with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:

            try:
                sock.connect(self.socket_file)
            except FileNotFoundError:
                raise NbfcClientError(f'Could not find {self.socket_file}. Is the service running?') from None

            message = "%s\nEND" % json.dumps(data)
            sock.sendall(message.encode('utf-8'))

            response = b''
            while True:
                data = sock.recv(1024)
                if not data:
                    break

                response += data

                if b'\nEND' in response:
                    break

            response = response.decode('utf-8')
            response = response.replace('\nEND', '')
            response = json.loads(response)
            return response


    def get_compile_time_variable(self, variable):
        """
        Retrieves the value of a compile-time variable defined in the NBFC binary.

        Args:
            variable (str):
                The name of the compile-time variable to retrieve.

        Returns:
            str:
                The value assigned to the compile time variable.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """
        return self.call_nbfc(['show-variable', variable])

    def get_version(self):
        """
        Return the version of the nbfc client.

        Returns:
            str:
                The version in form of "MAJOR.MINOR.PATCH".

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        output = self.call_nbfc(['--version'])

        match = re.search(r'\d+\.\d+\.\d+', output)

        if not match:
            raise NbfcClientError('Could not extract version')

        return match[0]

    def start(self, readonly=False):
        """
        Starts the NBFC service.

        Args:
            readonly (bool):
                If True, starts the service in read-only mode.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        args = ['start']

        if readonly:
            args.append('-r')

        self.call_nbfc(args)

    def restart(self, readonly=False):
        """
        Restarts the NBFC service.

        Args:
            readonly (bool):
                If True, starts the service in read-only mode.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        args = ['restart']

        if readonly:
            args.append('-r')

        self.call_nbfc(args)

    def stop(self):
        """
        Stops the NBFC service.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        self.call_nbfc(['stop'])

    def get_model_name(self):
        """
        Retrieve the model name of the notebook.

        Returns:
            str:
                The model name of the notebook.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        return self.call_nbfc(['get-model-name'])

    def list_configs(self):
        """
        List all available model configurations.

        Returns:
            list of str:
                A list of all available model configurations.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        configs = self.call_nbfc(['config', '-l'])
        configs = configs.strip()

        if configs:
            return configs.split('\n')
        else:
            return []

    def recommended_configs(self):
        """
        List recommended model configurations.

        Returns:
            list of str:
                A list of recommended configurations.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        Note:
            This returns recommended configurations based solely on comparing
            your model name with configuration file names.
            This recommendation does not imply any further significance or validation
            of the configurations beyond the string matching.
        """

        configs = self.call_nbfc(['config', '-r'])
        configs = configs.strip()

        if configs:
            return configs.split('\n')
        else:
            return []

    def get_available_sensors(self):
        """
        Returns a list of all available sensors.

        Returns:
            list of Sensor:
                A list of all available sensors.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        sensors = []

        output = self.call_nbfc(['complete-sensors'])

        for line in output.split('\n'):
            parts = line.split('\t', maxsplit=1)

            if len(parts) == 2:
                name, description = parts

                if name == 'none':
                    continue

                sensors.append(Sensor(name, description))

        return sensors

    def rate_configs(self):
        """
        Returns a list of rated configuration files.

        Returns:
            The decoded JSON data from `nbfc rate-config --all --json --min-score 0`

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        output = self.call_nbfc(['rate-config', '--all', '--json', '--min-score', '0'])
        return json.loads(output)


    def get_status(self):
        """
        Retrieves the status of the NBFC service.

        Returns:
            dict:
                The status information of the NBFC service.

        Raises:
            NbfcClientError:
                If there is an error in the response from the service.
        """

        response = self.socket_communicate({'Command': 'status'})

        if 'Error' in response:
            raise NbfcClientError(response['Error'])

        return response

    def set_fan_speed(self, speed, fan=None):
        """
        Sets the fan speed.

        Args:
            speed (float, int, str):
                The desired fan speed in percent or "auto" for setting
                fan to auto-mode.

            fan (int, optional):
                The fan index to set the speed for. If not given, set the
                speed for all available fans.

        Raises:
            NbfcClientError:
                If there is an error in the response from the service.
        """

        request = {'Command': 'set-fan-speed', 'Speed': speed}

        if fan is not None:
            request['Fan'] = fan

        response = self.socket_communicate(request)

        if 'Error' in response:
            raise NbfcClientError(response['Error'])


    def get_service_config(self):
        """
        Retrieves the current service configuration.

        Returns:
            dict:
                The current service configuration.

        Raises:
            PermissionError:
                If the file could not be read due to insufficient permissions.

            IsADirectoryError:
                If the file is a directory.

            JSONDecodeError:
                If the configuration file is not valid JSON.
        """

        try:
            with open(self.config_file, 'r', encoding='UTF-8') as fh:
                return json.load(fh)
        except FileNotFoundError:
            return {}

    def set_service_config(self, config):
        """
        Writes a new configuration to the service config file.

        Args:
            config (dict):
                The configuration data to write.

        Raises:
            PermissionError:
                If the program does not have permission to write to the config file.

            IsADirectoryError:
                If the config file is a directory.

            TypeError:
                If `config` could not be serialized to JSON.
        """

        with open(self.config_file, 'w', encoding='UTF-8') as fh:
            json.dump(config, fh, indent=1)

    def get_model_configuration_file(self):
        """
        Retrieve the file path of the model configuration file.

        Returns:
            str:
                The resolved file path of the model configuration file.

        Raises:
            NbfcClientError:
                If the configuration file could not be resolved.
        """

        config = self.get_service_config()

        if 'SelectedConfigId' not in config:
            raise NbfcClientError('Configuration has no model configuration ("SelectedConfigId") set')

        config_id = config['SelectedConfigId']

        if config_id.startswith('/'):
            return config_id

        model_config_path = os.path.join(self.model_configs_dir_mutable, config_id + '.json')
        if os.path.exists(model_config_path):
            return model_config_path

        model_config_path = os.path.join(self.model_configs_dir, config_id + '.json')
        if os.path.exists(model_config_path):
            return model_config_path

        raise NbfcClientError(f'No configuration file found for SelectedConfigId = "{config_id}"')

    def get_model_configuration(self):
        """
        Return the model configuration.

        Returns:
            dict:
                The model configuration.

        Raises:
            FileNotFoundError:
                If the file could not be found.

            PermissionError:
                If the file could not be read due to insufficient permissions.

            IsADirectoryError:
                If the file is a directory.

            JSONDecodeError:
                If the file contains invalid JSON.
        """

        config_file = self.get_model_configuration_file()

        with open(config_file, 'r', encoding='UTF-8') as fh:
            return json.load(fh)
def make_version_tuple(version_string):
    return tuple(map(int, version_string.split('.')))

class Globals(QObject):
    model_config_changed = pyqtSignal()
    restart_service = pyqtSignal(bool)

    is_root = (os.geteuid() == 0)
    nbfc_client = None

    def init(self):
        self.nbfc_client = NbfcClient()

    def set_model_config(self, model_config):
        '''
        Save `model_config` to the service configuration file.

        This may raise an exception.
        '''

        service_config = self.nbfc_client.get_service_config()

        old_model_config = service_config.get('SelectedConfigId', '')

        service_config['SelectedConfigId'] = model_config

        self.nbfc_client.set_service_config(service_config)

        if old_model_config != model_config:
            self.model_config_changed.emit()

    def set_model_config_and_restart(self, model_config, read_only):
        '''
        Save `model_config` to the service configuration file and restart
        the service.

        This may raise an exception.
        '''

        self.set_model_config(model_config)
        self.restart_service.emit(read_only)


REQUIRED_NBFC_VERSION = '0.4.0'
GITHUB_URL = 'https://github.com/nbfc-linux/nbfc-linux'
GLOBALS = Globals()

def show_error_message(parent, title, message):
    QMessageBox.critical(parent, title, message, QMessageBox.Ok)

class SubprocessWorker(QThread):
    output_line = pyqtSignal(str)
    error_line  = pyqtSignal(str)
    finished    = pyqtSignal(int)

    def __init__(self, command):
        super().__init__()
        self.command = command

    def run(self):
        process = subprocess.Popen(
            self.command,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1
        )

        def read_stream(stream, signal):
            for line in stream:
                signal.emit(line)
            stream.close()

        stdout_thread = threading.Thread(target=read_stream, args=(process.stdout, self.output_line))
        stderr_thread = threading.Thread(target=read_stream, args=(process.stderr, self.error_line))

        stdout_thread.start()
        stderr_thread.start()

        stdout_thread.join()
        stderr_thread.join()

        exit_code = process.wait()
        self.finished.emit(exit_code)
class ApplyButtonsWidget(QWidget):
    def __init__(self):
        super().__init__()


        layout = QVBoxLayout()
        self.setLayout(layout)


        self.read_only_checkbox = QCheckBox("(Re-)start in read-only mode", self)
        layout.addWidget(self.read_only_checkbox)


        hbox_layout = QHBoxLayout()
        layout.addLayout(hbox_layout)

        self.save_button = QPushButton("Save", self)
        hbox_layout.addWidget(self.save_button)

        self.apply_button = QPushButton("Apply with (re-)start", self)
        hbox_layout.addWidget(self.apply_button)


        self.error_label = QLabel("", self)
        self.error_label.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.error_label)

    def enable(self):
        self.error_label.setHidden(True)
        self.read_only_checkbox.setEnabled(True)
        self.save_button.setEnabled(True)
        self.apply_button.setEnabled(True)

    def disable(self, reason):
        self.error_label.setText(reason)
        self.error_label.setHidden(False)
        self.read_only_checkbox.setEnabled(False)
        self.save_button.setEnabled(False)
        self.apply_button.setEnabled(False)
class ServiceControlWidget(QWidget):
    def __init__(self):
        super().__init__()


        self.timer = QTimer(self)
        self.timer.setInterval(500)
        self.timer.timeout.connect(self.update)


        layout = QVBoxLayout()
        self.setLayout(layout)


        self.status_label = QLabel("", self)
        layout.addWidget(self.status_label)


        self.log = QPlainTextEdit(self)
        self.log.setReadOnly(True)
        self.log.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
        layout.addWidget(self.log)


        self.read_only_checkbox = QCheckBox("Start in read-only mode", self)
        layout.addWidget(self.read_only_checkbox)


        hbox = QHBoxLayout()
        layout.addLayout(hbox)

        self.start_button = QPushButton("Start", self)
        self.start_button.clicked.connect(self.start_button_clicked)
        hbox.addWidget(self.start_button)

        self.stop_button = QPushButton("Stop", self)
        self.stop_button.clicked.connect(self.stop_button_clicked)
        hbox.addWidget(self.stop_button)


        self.message = QLabel("", self)
        self.message.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.message)


        GLOBALS.restart_service.connect(self.service_restart)


    def start(self):
        self.update()
        self.timer.start()

    def stop(self):
        self.timer.stop()


    def update(self):
        try:
            status = GLOBALS.nbfc_client.get_status()
            if status['ReadOnly']:
                self.status_label.setText("<b>Running</b> (<i>read-only</i>)")
            else:
                self.status_label.setText("<b>Running</b> (<i>control enabled</i>)")
            self.start_button.setEnabled(False)
            self.stop_button.setEnabled(True)
        except Exception as e:
            self.status_label.setText("<b>Stopped</b>")
            self.start_button.setEnabled(True)
            self.stop_button.setEnabled(False)

        if not GLOBALS.is_root:
            self.read_only_checkbox.setEnabled(False)
            self.start_button.setEnabled(False)
            self.stop_button.setEnabled(False)
            self.message.setVisible(True)
            self.message.setText(CANNOT_CONTROL_MSG)
        else:
            self.message.setVisible(False)

    def call_with_log(self, args):
        self.log.clear()

        self.worker = SubprocessWorker(args)
        self.worker.output_line.connect(self.handle_output)
        self.worker.error_line.connect(self.handle_output)
        self.worker.start()

    def handle_output(self, line):
        self.log.appendPlainText(line.rstrip())

    def service_start(self, readonly):
        args = ['nbfc', 'start']

        if readonly:
            args.append('-r')

        self.call_with_log(args)

    def service_restart(self, readonly):
        args = ['nbfc', 'restart']

        if readonly:
            args.append('-r')

        self.call_with_log(args)

    def service_stop(self):
        GLOBALS.nbfc_client.stop()


    def start_button_clicked(self):
        self.service_start(self.read_only_checkbox.isChecked())

    def stop_button_clicked(self):
        self.service_stop()
class FanWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.fan_index = None


        layout = QVBoxLayout()
        self.setLayout(layout)


        grid_layout = QGridLayout()
        layout.addLayout(grid_layout)


        label = QLabel("Name", self)
        self.name_label = QLabel("", self)
        grid_layout.addWidget(label, 0, 0)
        grid_layout.addWidget(self.name_label, 0, 1)

        label = QLabel("Temperature", self)
        self.temperature_label = QLabel("", self)
        grid_layout.addWidget(label, 1, 0)
        grid_layout.addWidget(self.temperature_label, 1, 1)

        label = QLabel("Auto mode", self)
        self.auto_mode_label = QLabel("", self)
        grid_layout.addWidget(label, 2, 0)
        grid_layout.addWidget(self.auto_mode_label, 2, 1)

        label = QLabel("Critical", self)
        self.critical_label = QLabel("", self)
        grid_layout.addWidget(label, 3, 0)
        grid_layout.addWidget(self.critical_label, 3, 1)

        label = QLabel("Current speed", self)
        self.current_speed_label = QLabel("", self)
        grid_layout.addWidget(label, 4, 0)
        grid_layout.addWidget(self.current_speed_label, 4, 1)

        label = QLabel("Target speed", self)
        self.target_speed_label = QLabel("", self)
        grid_layout.addWidget(label, 5, 0)
        grid_layout.addWidget(self.target_speed_label, 5, 1)

        label = QLabel("Speed steps", self)
        self.speed_steps_label = QLabel("", self)
        grid_layout.addWidget(label, 6, 0)
        grid_layout.addWidget(self.speed_steps_label, 6, 1)


        self.auto_mode_checkbox = QCheckBox("Auto mode", self)
        self.auto_mode_checkbox.stateChanged.connect(self.update_fan_speed)
        layout.addWidget(self.auto_mode_checkbox)


        self.speed_slider = QSlider(Qt.Horizontal)
        self.speed_slider.setMinimum(0)
        self.speed_slider.setMaximum(100)
        self.speed_slider.setTickInterval(1)
        self.speed_slider.valueChanged.connect(self.update_fan_speed)
        layout.addWidget(self.speed_slider)

    def update_fan_speed(self, *_):
        auto_mode = self.auto_mode_checkbox.isChecked()

        if auto_mode:
            GLOBALS.nbfc_client.set_fan_speed('auto', self.fan_index)
        else:
            GLOBALS.nbfc_client.set_fan_speed(self.speed_slider.value(), self.fan_index)

        self.speed_slider.setEnabled(not auto_mode)

    def update(self, fan_index, fan_data):
        self.fan_index = fan_index
        self.name_label.setText(fan_data['Name'])
        self.temperature_label.setText(str(fan_data['Temperature']))
        self.auto_mode_label.setText(str(fan_data['AutoMode']))
        self.critical_label.setText(str(fan_data['Critical']))
        self.current_speed_label.setText(str(fan_data['CurrentSpeed']))
        self.target_speed_label.setText(str(fan_data['TargetSpeed']))
        self.speed_steps_label.setText(str(fan_data['SpeedSteps']))
        self.auto_mode_checkbox.setChecked(fan_data['AutoMode'])
        self.speed_slider.setValue(int(fan_data['RequestedSpeed']))
class FanControlWidget(QStackedWidget):
    def __init__(self):
        super().__init__()


        self.timer = QTimer(self)
        self.timer.setInterval(500)
        self.timer.timeout.connect(self.update)


        self.error_widget = QWidget()
        error_layout = QVBoxLayout()
        self.error_widget.setLayout(error_layout)
        self.error_label = QLabel("", self)
        error_layout.addWidget(self.error_label)
        self.addWidget(self.error_widget)


        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.addWidget(self.scroll_area)

        fans_widget = QWidget()
        self.scroll_area.setWidget(fans_widget)

        fans_container = QVBoxLayout()
        fans_widget.setLayout(fans_container)

        self.fans_layout = QVBoxLayout()
        fans_container.addLayout(self.fans_layout)
        fans_container.addStretch()


    def start(self):
        self.update()
        self.timer.start()

    def stop(self):
        self.timer.stop()


    def update(self):
        try:
            status = GLOBALS.nbfc_client.get_status()
            self.setCurrentWidget(self.scroll_area)
        except Exception as e:
            self.error_label.setText(str(e))
            self.setCurrentWidget(self.error_widget)
            return

        while self.fans_layout.count() < len(status['Fans']):
            widget = FanWidget()
            self.fans_layout.addWidget(widget)

        while self.fans_layout.count() > len(status['Fans']):
            widget = self.fans_layout.itemAt(self.fans_layout.count() - 1).widget()
            self.fans_layout.removeWidget(widget)
            widget.deleteLater()

        for fan_index, fan_data in enumerate(status['Fans']):
            widget = self.fans_layout.itemAt(fan_index).widget()
            widget.update(fan_index, fan_data)
CONFIRMATION_TEXT = """\
You are about to view configurations with names similar to your laptop model. <br />
<br />
Please note that similar model names do not guarantee compatibility. The register configuration may be completely different. <br />
<br />
Using a configuration that does <b>not exactly match</b> your laptop model can be <b>very dangerous and may cause hardware damage.</b> <br />
<br />
Please use the <b>Rated Configs</b> tab to find verified configuration recommendations for your device."""

class ConfirmationDialog(QDialog):
    def __init__(self, callback, parent=None):
        super().__init__(parent)
        self.callback = callback
        self.setModal(True)
        self.setWindowTitle("Warning")

        layout = QVBoxLayout()
        self.setLayout(layout)

        label = QLabel(CONFIRMATION_TEXT)
        label.setWordWrap(True)
        layout.addWidget(label)

        self.checkbox = QCheckBox("I understand the risks")
        layout.addWidget(self.checkbox)

        button = QPushButton("Close")
        button.clicked.connect(self.close_clicked)
        layout.addWidget(button)

    def close_clicked(self):
        self.callback(self.checkbox.isChecked())
        self.accept()

class BasicConfigWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.confirmed_risks = False


        layout = QVBoxLayout()
        self.setLayout(layout)


        hbox = QHBoxLayout()
        layout.addLayout(hbox)

        label = QLabel("Your laptop model:", self)
        hbox.addWidget(label)

        self.model_name_label = QLabel("", self)
        hbox.addWidget(self.model_name_label)


        hbox = QHBoxLayout()
        layout.addLayout(hbox)

        self.selected_config_input = QLineEdit(self)
        self.selected_config_input.textChanged.connect(self.update_apply_buttons)
        self.selected_config_input.setPlaceholderText("Configuration File")
        hbox.addWidget(self.selected_config_input)

        self.reset_button = QPushButton("Reset", self)
        self.reset_button.clicked.connect(self.reset_button_clicked)
        hbox.addWidget(self.reset_button)


        self.list_all_radio = QRadioButton("List all configurations", self)
        self.list_all_radio.clicked.connect(self.list_all_radio_checked)
        layout.addWidget(self.list_all_radio)

        self.list_recommended_radio = QRadioButton("List similar named configurations", self)
        self.list_recommended_radio.clicked.connect(self.list_recommended_radio_checked)
        layout.addWidget(self.list_recommended_radio)

        self.custom_file_radio = QRadioButton("Custom file")
        self.custom_file_radio.clicked.connect(self.custom_file_radio_checked)
        layout.addWidget(self.custom_file_radio)


        hbox = QHBoxLayout()

        self.configurations_combobox = QComboBox()
        hbox.addWidget(self.configurations_combobox)

        self.set_button = QPushButton("Set", self)
        self.set_button.clicked.connect(self.set_button_clicked)
        hbox.addWidget(self.set_button)

        layout.addLayout(hbox)


        self.select_file_button = QPushButton("Select file ...", self)
        self.select_file_button.clicked.connect(self.select_file_button_clicked)
        layout.addWidget(self.select_file_button)


        layout.addStretch()


        self.apply_buttons_widget = ApplyButtonsWidget()
        self.apply_buttons_widget.save_button.clicked.connect(self.save_button_clicked)
        self.apply_buttons_widget.apply_button.clicked.connect(self.apply_button_clicked)
        layout.addWidget(self.apply_buttons_widget)


        self.list_all_radio.setChecked(True)
        self.list_all_radio_checked()
        self.update_apply_buttons()

        try:
            self.reset_config()
        except:
            pass

        try:
            model = GLOBALS.nbfc_client.get_model_name()
            self.model_name_label.setText(f"<b>{model}</b>")
        except:
            self.model_name_label.setText("<b>Could not get model name</b>")


    def start(self):
        pass

    def stop(self):
        pass


    def update_apply_buttons(self):
        if not GLOBALS.is_root:
            self.apply_buttons_widget.disable(CANNOT_CONFIGURE_MSG)
        elif not self.selected_config_input.text():
            self.apply_buttons_widget.disable("No model configuration selected")
        else:
            self.apply_buttons_widget.enable()

    def reset_config(self):
        '''
        Reset the `SelectedConfigId` field to its original value.

        This may raise an exception.
        '''

        config = GLOBALS.nbfc_client.get_service_config()

        SelectedConfigId = config.get('SelectedConfigId', '')

        self.selected_config_input.setText(SelectedConfigId)


    def reset_button_clicked(self):
        try:
            self.reset_config()
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def save_button_clicked(self):
        try:
            config = self.selected_config_input.text()
            GLOBALS.set_model_config(config)
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def apply_button_clicked(self):
        try:
            config = self.selected_config_input.text()
            read_only = self.apply_buttons_widget.read_only_checkbox.isChecked()
            GLOBALS.set_model_config_and_restart(config, read_only)
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def update_configuration_combobox(self, available_configs):
        self.configurations_combobox.clear()
        self.configurations_combobox.addItems(available_configs)

        if self.configurations_combobox.count():
            self.set_button.setEnabled(True)
        else:
            self.set_button.setEnabled(False)

    def list_all_radio_checked(self):
        self.select_file_button.setVisible(False)
        self.configurations_combobox.setVisible(True)
        self.set_button.setVisible(True)

        configs = GLOBALS.nbfc_client.list_configs()
        self.update_configuration_combobox(configs)

    def list_recommended_radio_checked(self):
        if not self.confirmed_risks:
            def confirmation_dialog_exited(confirmed):
                if confirmed:
                    self.confirmed_risks = True
                    self.list_recommended_radio.setChecked(True)
                    self.list_recommended_radio_checked()

            self.list_all_radio.setChecked(True)
            dialog = ConfirmationDialog(confirmation_dialog_exited, self)
            dialog.exec()
            return

        self.select_file_button.setVisible(False)
        self.configurations_combobox.setVisible(True)
        self.set_button.setVisible(True)

        configs = GLOBALS.nbfc_client.recommended_configs()
        self.update_configuration_combobox(configs)

    def custom_file_radio_checked(self):
        self.select_file_button.setVisible(True)
        self.set_button.setVisible(False)
        self.configurations_combobox.setVisible(False)

    def set_button_clicked(self):
        selected = self.configurations_combobox.currentText()
        if selected:
            self.selected_config_input.setText(selected)

    def select_file_button_clicked(self):
        path, _ = QFileDialog.getOpenFileName(self, "Choose Configuration File", "", "JSON Files (*.json)")
        if path:
            self.selected_config_input.setText(path)
RATE_CONFIG_HELP_TEXT = """\
The rating feature analyzes whether a configuration can be executed safely on the current system. A positive rating does not necessarily mean that the configuration will work correctly, only that it appears reasonable and non-destructive.<br />
<br />
Configurations that reference the same registers and ACPI methods are grouped together.<br />
<br />
<b>FULL MATCH</b><br />
Indicates that the register is a known fan register.<br />
<br />
<b>PARTIAL MATCH</b><br />
Indicates that the register name contains <b>FAN</b>, <b>RPM</b>, or <b>PWM</b>.<br />
<br />
<b>MINIMAL MATCH</b><br />
Indicates that the register name starts with the letter '<b>F</b>'.<br />
<br />
<b>BAD REGISTER</b><br />
Indicates that the register name is a known bad register (likely a battery-related register).<br />
<br />
<b>NO MATCH</b><br />
Indicates that none of the matching rules apply.<br />
<br />
<b>NOT FOUND</b><br />
Indicates that the register is not named in the firmware and additional information could not be retrieved.<br />
<br />
For fan registers, at least a <b>MINIMAL MATCH</b> is required to consider a configuration usable.<br />
<br />
For RegisterWriteConfiguration registers, some registers may not yet be present in the rule database. In these cases, a <b>NO MATCH</b> result may still be acceptable.<br />
<br />
If in doubt, it is recommended to dump the firmware using <b>sudo nbfc acpi-dump dsl</b> and manually analyze the registers used by the configuration file. This requires some technical knowledge."""

class RateConfigsHelpWidget(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("NBFC Rated Configs Help")
        self.resize(400, 400)

        layout = QVBoxLayout()
        self.setLayout(layout)

        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        layout.addWidget(scroll_area)

        label = QLabel(RATE_CONFIG_HELP_TEXT)
        label.setWordWrap(True)
        scroll_area.setWidget(label)

        button = QPushButton("Close")
        button.clicked.connect(self.close_clicked)
        layout.addWidget(button)

    def close_clicked(self):
        self.close()

def RegisterType_To_HumanReadable(s):
    if s == 'FanReadRegister':
        return 'Fan read register'

    if s == 'FanWriteRegister':
        return 'Fan write register'

    if s == 'RegisterWriteConfigurationRegister':
        return 'Misc register'

    return '?'

def RegisterScore_To_HumanReadable(s):
    if s == 'FullMatch':
        return 'FULL MATCH'

    if s == 'PartialMatch':
        return 'PARTIAL MATCH'

    if s == 'MinimalMatch':
        return 'MINIMAL MATCH'

    if s == 'NoMatch':
        return 'NO MATCH'

    if s == 'NotFound':
        return 'NOT FOUND'

    if s == 'BadRegister':
        return 'BAD REGISTER'

    return '?'

def MethodScore_To_HumanReadable(s):
    if s == 'Found':
        return 'FOUND'

    if s == 'NotFound':
        return 'NOT FOUND'

    return '?'

class RegisterRating(QWidget):
    def __init__(self, data):
        super().__init__()


        grid_layout = QGridLayout()
        self.setLayout(grid_layout)


        off = data['offset']
        grid_layout.addWidget(QLabel("Register Offset"),        0, 0)
        grid_layout.addWidget(QLabel("%d (0x%X)" % (off, off)), 0, 1)

        typ = RegisterType_To_HumanReadable(data['type'])
        grid_layout.addWidget(QLabel("Type"), 1, 0)
        grid_layout.addWidget(QLabel(typ),    1, 1)

        score = RegisterScore_To_HumanReadable(data['score'])
        grid_layout.addWidget(QLabel("Score"), 2, 0)
        grid_layout.addWidget(QLabel(score),   2, 1)

        if 'info' in data:
            name = data['info']['name']
            grid_layout.addWidget(QLabel("Name"), 3, 0)
            grid_layout.addWidget(QLabel(name),   3, 1)

class MethodRating(QWidget):
    def __init__(self, data):
        super().__init__()


        grid_layout = QGridLayout()
        self.setLayout(grid_layout)


        call = data['call']
        grid_layout.addWidget(QLabel("Method call"), 0, 0)
        grid_layout.addWidget(QLabel(call),          0, 1)

        score = MethodScore_To_HumanReadable(data['score'])
        grid_layout.addWidget(QLabel("Score"), 1, 0)
        grid_layout.addWidget(QLabel(score),   1, 1)

class RateConfigDetails(QWidget):
    def __init__(self, data):
        super().__init__()


        layout = QVBoxLayout()
        self.setLayout(layout)


        for rating in data['rating']['register_ratings']:
            layout.addWidget(RegisterRating(rating))


        for rating in data['rating']['method_ratings']:
            layout.addWidget(MethodRating(rating))

class RateConfigDetailsWindow(QWidget):
    def __init__(self, data):
        super().__init__()


        layout = QVBoxLayout()
        self.setLayout(layout)


        label = QLabel("Score: %.2f / 10" % data['rating']['score'], self)
        layout.addWidget(label)


        self.files_combobox = QComboBox()
        self.files_combobox.addItems(data['files'])
        layout.addWidget(self.files_combobox)


        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        layout.addWidget(self.scroll_area)


        self.rate_config_details = RateConfigDetails(data)
        self.scroll_area.setWidget(self.rate_config_details)


        self.apply_buttons_widget = ApplyButtonsWidget()
        self.apply_buttons_widget.save_button.clicked.connect(self.save_button_clicked)
        self.apply_buttons_widget.apply_button.clicked.connect(self.apply_button_clicked)
        self.apply_buttons_widget.enable()
        layout.addWidget(self.apply_buttons_widget)


    def save_button_clicked(self):
        selected_config = self.files_combobox.currentText()
        GLOBALS.set_model_config(selected_config)

    def apply_button_clicked(self):
        selected_config = self.files_combobox.currentText()
        read_only = self.apply_buttons_widget.read_only_checkbox.isChecked()
        GLOBALS.set_model_config_and_restart(selected_config, read_only)

class RateConfigsWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.configs = []


        layout = QVBoxLayout()
        self.setLayout(layout)


        hbox = QHBoxLayout()
        layout.addLayout(hbox)

        self.threshold_label = QLabel("Minimum score")

        self.threshold_spin = QDoubleSpinBox()
        self.threshold_spin.setRange(0.0, 10.0)
        self.threshold_spin.setDecimals(2)
        self.threshold_spin.setSingleStep(0.10)
        self.threshold_spin.setValue(9.0)
        self.threshold_spin.valueChanged.connect(self.threshold_spin_changed)

        self.load_button = QPushButton("Load configs")
        self.load_button.clicked.connect(self.load_button_clicked)

        self.help_button = QPushButton("Help")
        self.help_button.clicked.connect(self.help_button_clicked)

        hbox.addWidget(self.threshold_label)
        hbox.addWidget(self.threshold_spin)
        hbox.addWidget(self.load_button)
        hbox.addWidget(self.help_button)


        self.warning_label = QLabel("Warning! Unsafe configurations are shown")
        self.warning_label.setAlignment(Qt.AlignCenter)
        self.warning_label.setStyleSheet("""
            background-color: #cc0000;
            color: white;
            font-weight: bold""")
        self.warning_label.setVisible(False)
        layout.addWidget(self.warning_label)


        self.rated_configs_list = QListWidget(self)
        self.rated_configs_list.currentItemChanged.connect(self.rate_configs_item_changed)
        self.rated_configs_list.itemActivated.connect(self.rate_configs_item_activated)
        layout.addWidget(self.rated_configs_list)


        button_layout = QHBoxLayout()
        self.show_button = QPushButton("Show Details", self)
        self.show_button.clicked.connect(self.show_button_clicked)
        self.show_button.setEnabled(False)
        button_layout.addWidget(self.show_button)
        layout.addLayout(button_layout)


    def start(self):
        pass

    def stop(self):
        pass


    def load_rated_configs_list(self):
        try:
            configs = GLOBALS.nbfc_client.rate_configs()
        except NbfcClientError as e:
            show_error_message(self, "Error", str(e))
            return

        configs = sorted(configs, key=lambda o: o['rating']['priority'], reverse=True)
        configs = sorted(configs, key=lambda o: o['rating']['score'], reverse=True)
        self.configs = configs
        self.show_rated_configs_list()

    def show_rated_configs_list(self):
        self.rated_configs_list.clear()
        min_score = self.threshold_spin.value()

        for data in self.configs:
            if data['rating']['score'] < min_score:
                continue

            item = QListWidgetItem('%s (%.2f / 10)' % (data['files'][0], data['rating']['score']))
            self.rated_configs_list.addItem(item)

    def show_details(self, data):
        self.details_widget = RateConfigDetailsWindow(data)
        self.details_widget.setWindowTitle("Rating Details")
        self.details_widget.resize(400, 400)
        self.details_widget.show()


    def rate_configs_item_changed(self, current, previous):
        self.show_button.setEnabled(bool(current))

    def rate_configs_item_activated(self, item):
        self.show_details(self.configs[self.rated_configs_list.row(item)])

    def load_button_clicked(self):
        self.load_rated_configs_list()

    def help_button_clicked(self):
        self.help_widget = RateConfigsHelpWidget()
        self.help_widget.show()

    def threshold_spin_changed(self):
        self.warning_label.setVisible(self.threshold_spin.value() < 9.0)
        self.show_rated_configs_list()

    def show_button_clicked(self):
        cur = self.rated_configs_list.currentRow()
        if cur == -1:
            return

        self.show_details(self.configs[cur])
class TemperatureSourceWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.fan_index = None


        layout = QVBoxLayout()
        self.setLayout(layout)


        algorithm_label = QLabel("Algorithm:", self)
        layout.addWidget(algorithm_label)

        algorithm_layout = QHBoxLayout()
        self.default_radio = QRadioButton("Default", self)
        self.average_radio = QRadioButton("Average", self)
        self.max_radio = QRadioButton("Max", self)
        self.min_radio = QRadioButton("Min", self)
        algorithm_layout.addWidget(self.default_radio)
        algorithm_layout.addWidget(self.average_radio)
        algorithm_layout.addWidget(self.max_radio)
        algorithm_layout.addWidget(self.min_radio)
        self.default_radio.setChecked(True)
        layout.addLayout(algorithm_layout)


        temperature_sources_label = QLabel("Temperature Sources", self)
        layout.addWidget(temperature_sources_label)

        self.temperature_sources = QListWidget(self)
        layout.addWidget(self.temperature_sources)


        self.sensors = QComboBox(self)
        self.sensors.currentIndexChanged.connect(self.sensors_changed)
        layout.addWidget(self.sensors)


        self.custom_sensor = QLineEdit(self)
        self.custom_sensor.setVisible(False)
        layout.addWidget(self.custom_sensor)


        button_layout = QHBoxLayout()
        self.add_button = QPushButton("Add", self)
        self.del_button = QPushButton("Delete", self)
        self.add_button.clicked.connect(self.add_button_clicked)
        self.del_button.clicked.connect(self.del_button_clicked)
        button_layout.addWidget(self.add_button)
        button_layout.addWidget(self.del_button)
        layout.addLayout(button_layout)

    def set_fan_index(self, index):
        self.fan_index = index

    def set_available_sensors(self, available_sensors):
        for sensor in available_sensors:
            self.sensors.addItem("%s (%s)" % (sensor.name, sensor.description), sensor.name)

        self.sensors.addItem("Custom Shell Command",  "<command>")
        self.sensors.addItem("Custom Sensor or File", "<custom>")

    def update(self, fan_temperature_source):
        {
            'Default': self.default_radio,
            'Average': self.average_radio,
            'Max':     self.max_radio,
            'Min':     self.min_radio
        }[fan_temperature_source.get('TemperatureAlgorithmType', 'Default')].setChecked(True)

        self.temperature_sources.clear()

        for sensor in fan_temperature_source.get('Sensors', []):
            try:
                item = self.find_sensor_item(sensor)
                new_item = QListWidgetItem(item.text())
                new_item.setData(Qt.UserRole, item.data(Qt.UserRole))
            except:
                new_item = QListWidgetItem(sensor)
                new_item.setData(Qt.UserRole, sensor)

            self.temperature_sources.addItem(new_item)

    def find_sensor_item(self, sensor):
        for i in range(self.sensors.count()):
            item = self.sensors.item(i)
            if item.data(Qt.UserRole) == sensor:
                return item

        raise Exception('No sensor found for %s' % sensor)

    def sensors_changed(self, index):
        data  = self.sensors.itemData(index, Qt.UserRole)

        if data == '<custom>':
            self.custom_sensor.setVisible(True)
            self.custom_sensor.setPlaceholderText("Sensor Name or File")
        elif data == '<command>':
            self.custom_sensor.setVisible(True)
            self.custom_sensor.setPlaceholderText("Shell Command")
        else:
            self.custom_sensor.setVisible(False)

    def add_button_clicked(self):
        index = self.sensors.currentIndex()
        text  = self.sensors.itemText(index)
        data  = self.sensors.itemData(index, Qt.UserRole)

        if data == '<custom>':
            text = self.custom_sensor.text()
            if not text.strip():
                return

            data = text
        elif data == '<command>':
            text = '$ %s' % self.custom_sensor.text()
            if not text.strip():
                return

            data = text

        new_item = QListWidgetItem(text)
        new_item.setData(Qt.UserRole, data)
        self.temperature_sources.addItem(new_item)

    def del_button_clicked(self):
        self.temperature_sources.takeItem(self.temperature_sources.currentRow())

    def get_config(self):
        sensors = []
        for i in range(self.temperature_sources.count()):
            sensor = self.temperature_sources.item(i).data(Qt.UserRole)
            sensors.append(sensor)

        algorithm = None
        if self.average_radio.isChecked():
            algorithm = 'Average'
        elif self.max_radio.isChecked():
            algorithm = 'Max'
        elif self.min_radio.isChecked():
            algorithm = 'Min'

        cfg = {'FanIndex': self.fan_index}

        if algorithm:
            cfg['TemperatureAlgorithmType'] = algorithm

        if sensors:
            cfg['Sensors'] = sensors

        return cfg
class TemperatureSourcesWidget(QStackedWidget):
    def __init__(self):
        super().__init__()

        GLOBALS.model_config_changed.connect(self.setup_ui)


        self.error_widget = QWidget()

        error_layout = QVBoxLayout()
        self.error_widget.setLayout(error_layout)

        self.error_label = QLabel("", self)
        error_layout.addWidget(self.error_label)

        button_layout = QHBoxLayout()
        error_layout.addLayout(button_layout)

        self.retry_button = QPushButton("Retry", self)
        self.retry_button.clicked.connect(self.retry_button_clicked)
        button_layout.addWidget(self.retry_button)

        self.fix_button = QPushButton("Fix errors automatically", self)
        self.fix_button.clicked.connect(self.fix_button_clicked)
        button_layout.addWidget(self.fix_button)

        self.addWidget(self.error_widget)


        self.main_widget = QWidget()
        main_layout = QVBoxLayout()
        self.main_widget.setLayout(main_layout)

        self.tab_widget = QTabWidget(self)
        main_layout.addWidget(self.tab_widget)

        self.apply_buttons_widget = ApplyButtonsWidget()
        self.apply_buttons_widget.save_button.clicked.connect(self.save_button_clicked)
        self.apply_buttons_widget.apply_button.clicked.connect(self.apply_button_clicked)
        main_layout.addWidget(self.apply_buttons_widget)
        self.addWidget(self.main_widget)

        self.setup_ui()


    def start(self):
        pass

    def stop(self):
        pass


    def save(self):
        config = GLOBALS.nbfc_client.get_service_config()
        config['FanTemperatureSources'] = self.get_fan_temperature_sources()
        if not len(config['FanTemperatureSources']):
            del config['FanTemperatureSources']
        GLOBALS.nbfc_client.set_service_config(config)

    def setup_ui(self, fix_errors=False):
        if not GLOBALS.is_root:
            self.apply_buttons_widget.disable(CANNOT_CONFIGURE_MSG)
        else:
            self.apply_buttons_widget.enable()


        try:
            config = GLOBALS.nbfc_client.get_service_config()
            fan_temperature_sources = config.get('FanTemperatureSources', [])
            model_config = GLOBALS.nbfc_client.get_model_configuration()
        except Exception as e:
            self.setCurrentWidget(self.error_widget)
            self.error_label.setText(str(e))
            self.fix_button.setEnabled(False)
            self.retry_button.setEnabled(True)
            self.apply_buttons_widget.disable("")
            return


        available_sensors = GLOBALS.nbfc_client.get_available_sensors()


        errors = validate_fan_temperature_sources(
            fan_temperature_sources,
            len(model_config['FanConfigurations']))

        if errors and not fix_errors:
            self.setCurrentWidget(self.error_widget)
            self.error_label.setText('\n\n'.join(errors))
            self.fix_button.setEnabled(True)
            self.retry_button.setEnabled(True)
            self.apply_buttons_widget.disable("")
            return
        elif errors and fix_errors:
            fan_temperature_sources = fix_fan_temperature_sources(
                fan_temperature_sources,
                len(model_config['FanConfigurations']))

        self.setCurrentWidget(self.main_widget)


        while self.tab_widget.count() < len(model_config['FanConfigurations']):
            widget = TemperatureSourceWidget()
            self.tab_widget.addTab(widget, "")

        while self.tab_widget.count() > len(model_config['FanConfigurations']):
            last_index = self.tab_widget.count() - 1
            widget = self.tab_widget.widget(last_index)
            self.tab_widget.removeTab(last_index)
            widget.deleteLater()


        for i, fan_config in enumerate(model_config['FanConfigurations']):
            widget = self.tab_widget.widget(i)
            self.tab_widget.setTabText(i, fan_config.get('FanDisplayName', 'Fan #%d' % i))
            widget.set_available_sensors(available_sensors)
            widget.set_fan_index(i)


        for fan_temperature_source in fan_temperature_sources:
            fan_index = fan_temperature_source['FanIndex']
            widget = self.tab_widget.widget(fan_index)
            widget.update(fan_temperature_source)

    def get_fan_temperature_sources(self):
        fan_temperature_sources = []

        for i in range(self.tab_widget.count()):
            widget = self.tab_widget.widget(i)
            config = widget.get_config()

            if len(config) > 1:
                fan_temperature_sources.append(config)

        return fan_temperature_sources


    def save_button_clicked(self):
        try:
            self.save()
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def apply_button_clicked(self):
        try:
            self.save()
            GLOBALS.restart_service.emit(self.apply_buttons_widget.read_only_checkbox.isChecked())
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def fix_button_clicked(self):
        self.setup_ui(fix_errors=True)

    def retry_button_clicked(self):
        self.setup_ui(fix_errors=False)
class UpdateWidget(QWidget):
    def __init__(self):
        super().__init__()


        layout = QVBoxLayout()
        self.setLayout(layout)


        label = QLabel("Fetch new configuration files from the internet", self)
        layout.addWidget(label)


        self.log = QPlainTextEdit(self)
        self.log.setReadOnly(True)
        self.log.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
        layout.addWidget(self.log)


        self.error_label = QLabel("", self)
        self.error_label.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.error_label)


        self.update_button = QPushButton("Update", self)
        self.update_button.clicked.connect(self.update_button_clicked)
        layout.addWidget(self.update_button)


        if GLOBALS.is_root:
            self.error_label.setVisible(False)
        else:
            self.update_button.setEnabled(False)
            self.error_label.setText("You cannot update the configurations because you are not root")
            self.error_label.setVisible(True)


    def start(self):
        pass

    def stop(self):
        pass


    def update_button_clicked(self):
        self.log.clear()
        self.update_button.setEnabled(False)

        self.worker = SubprocessWorker(['nbfc', 'update'])
        self.worker.output_line.connect(self.handle_output)
        self.worker.error_line.connect(self.handle_output)
        self.worker.finished.connect(self.command_finished)
        self.worker.start()

    def handle_output(self, line):
        self.log.appendPlainText(line.rstrip())

    def command_finished(self, exitstatus):
        self.update_button.setEnabled(True)
import subprocess

class ImageLoaderWorker(QObject):
    finished = pyqtSignal(bytes)

    def __init__(self, url):
        super().__init__()
        self.url = url

    def run(self):
        try:
            import requests

            response = requests.get(self.url)
            response.raise_for_status()

            self.finished.emit(response.content)
        except Exception:
            pass

class SponsorWidget(QLabel):
    def __init__(self, parent):
        super().__init__(parent)

        self.setVisible(False)
        self.setMaximumHeight(100)
        self.setAlignment(Qt.AlignCenter)

        try:
            sponsor = GLOBALS.nbfc_client.get_model_configuration()['Sponsor']
            self.url = sponsor['URL']

            if 'Description' in sponsor:
                self.setToolTip(f"{sponsor['Name']} - {sponsor['Description']}")
            else:
                self.setToolTip(sponsor['Name'])

            self.thread = QThread()
            self.worker = ImageLoaderWorker(sponsor['BannerURL'])
            self.worker.moveToThread(self.thread)
            self.thread.started.connect(self.worker.run)
            self.worker.finished.connect(self.on_image_loaded)
            self.worker.finished.connect(self.thread.quit)
            self.thread.start()
        except Exception:
            pass
        
    def on_image_loaded(self, content):
        pixmap = QPixmap()
        pixmap.loadFromData(content)
        pixmap = pixmap.scaledToHeight(100, Qt.SmoothTransformation)
        self.setPixmap(pixmap)
        self.setVisible(True)

    def mousePressEvent(self, event):
        if self.url:
            subprocess.run(['xdg-open', self.url])

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()


        self.setWindowTitle("NBFC Client")
        self.resize(400, 400)


        container = QWidget()
        layout = QVBoxLayout()
        container.setLayout(layout)


        sponsor_widget = SponsorWidget(self)
        layout.addWidget(sponsor_widget)


        self.tab_widget = QTabWidget(self)
        layout.addWidget(self.tab_widget)


        self.widgets = {}
        self.widgets['service'] = ServiceControlWidget()
        self.widgets['fans']    = FanControlWidget()
        self.widgets['basic']   = BasicConfigWidget()
        self.widgets['sensors'] = TemperatureSourcesWidget()
        self.widgets['update']  = UpdateWidget()
        self.widgets['rated']   = RateConfigsWidget()

        self.tab_widget.addTab(self.widgets['service'], "Service")
        self.tab_widget.addTab(self.widgets['fans'],    "Fans")
        self.tab_widget.addTab(self.widgets['basic'],   "Basic Configuration")
        self.tab_widget.addTab(self.widgets['rated'],   "Rated Configs")
        self.tab_widget.addTab(self.widgets['sensors'], "Sensors")
        self.tab_widget.addTab(self.widgets['update'],  "Update")

        self.tab_widget.currentChanged.connect(self.tab_widget_changed)
        self.tab_widget_changed(0)


        self.setCentralWidget(container)


        menuBar = self.menuBar()
        applicationMenu = menuBar.addMenu("&Application")

        aboutAction = QAction("&About", self)
        aboutAction.triggered.connect(self.about_menu_clicked)
        applicationMenu.addAction(aboutAction)

        quitAction = QAction("&Quit", self)
        quitAction.setShortcut("Ctrl+Q")
        quitAction.triggered.connect(lambda: QApplication.quit())
        applicationMenu.addAction(quitAction)


    def setTabById(self, id_):
        self.tab_widget.setCurrentWidget(self.widgets[id_])


    def about_menu_clicked(self):
        QMessageBox.about(self, "About NBFC-Linux", ABOUT_NBFC_LINUX)

    def tab_widget_changed(self, current_index):
        for i in range(self.tab_widget.count()):
            widget = self.tab_widget.widget(i)
            if i == current_index:
                widget.start()
            else:
                widget.stop()

signal.signal(signal.SIGINT, signal.SIG_DFL)

app = QApplication([sys.argv[0]] + qt_args)

try:
    GLOBALS.init()
except Exception as e:
    show_error_message(None, "Error", str(e))
    sys.exit(1)

try:
    current_version = GLOBALS.nbfc_client.get_version()
except Exception as e:
    show_error_message(None, "Error", f"Could not get version of NBFC client: {e}")
    sys.exit(1)

if make_version_tuple(current_version) < make_version_tuple(REQUIRED_NBFC_VERSION):
    errmsg = f'''\
NBFC-Linux version <b>{REQUIRED_NBFC_VERSION}</b> or newer is required to run this program. <br />
<br />
You can get the latest version from <a href="{GITHUB_URL}">GitHub.com</a>'''
    show_error_message(None, "Version Error", errmsg)
    sys.exit(1)

main_window = MainWindow()
main_window.show()
if opts.widget:
    main_window.setTabById(opts.widget)
sys.exit(app.exec())
