# Copyright (c) 2022 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
# Modified by BigRep GmbH 2024
from typing import Dict, List, Optional, Set

from PyQt6.QtNetwork import QNetworkReply

from UM import i18nCatalog
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger  # To log errors talking to the API.
from UM.Settings.Interfaces import ContainerInterface
from UM.Signal import Signal
from UM.Util import parseBool

from cura.API import Account
from cura.API.Account import SyncState
from cura.CuraApplication import CuraApplication
from cura.Settings.CuraStackBuilder import CuraStackBuilder
from cura.Settings.GlobalStack import GlobalStack

from .BigRepConnectOutputDeviceResponse import BigRepConnectOutputDeviceResponse
from .BigRepConnectOutputDevice import BigRepConnectOutputDevice
from .BigRepConnectApiClient import BigRepConnectApiClient

I18N_CATALOG = i18nCatalog("cura")

class BigRepConnectOutputDeviceManager:
    """The cloud output device manager is responsible for using the BigRep CONNECT API.
    """

    META_SERIAL_NUMBER = "serial_number"
    META_FIRMWARE_VERSION = "firmware_version"
    META_LINKED_TO_ACCOUNT = "linked_to_account"

    SYNC_SERVICE_NAME = "BigRepConnectOutputDeviceManager"

    # The translation catalog for this device.
    i18n_catalog = i18nCatalog("cura")

    # Signal emitted when the list of discovered devices changed.
    discoveredDevicesChanged = Signal()

    def __init__(self) -> None:
        # Persistent dict containing the remote clusters for the authenticated user.
        self._connect_output_devices: Dict[str, BigRepConnectOutputDevice] = {} # {serial_number:str, printer:BigRepConnectOutputDevice}

        # Dictionary containing all the cloud printers loaded in Cura
        self._connect_printers: Dict[str, GlobalStack] = {}

        self._account: Account = CuraApplication.getInstance().getCuraAPI().account
        self._account.userProfileChanged.connect(self._onUserProfileChanged)
        self._api = BigRepConnectApiClient(CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error)))

        # Ensure we don't start twice.
        self._running = False

        self._syncing = False
        CuraApplication.getInstance().getContainerRegistry().containerRemoved.connect(self._printerRemoved)

    def _onUserProfileChanged(self) -> None:
        self.start()

    def start(self):
        """Starts running the cloud output device manager, thus periodically requesting cloud data.""" # TODO: Why periodically? Due to synRequested?

        if self._running:
            return
        if not self._account.isLoggedIn:
            return
        self._running = True
        self._getConnectOutputDevices()

        self._account.syncRequested.connect(self._getConnectOutputDevices)

    def stop(self):
        """Stops running the cloud output device manager."""

        if not self._running:
            return
        self._running = False
        self._onGetConnectPrintersFinished([])  # Make sure we remove all cloud output devices.

    def refreshConnections(self) -> None:
        """Force refreshing connections."""

        self._connectToActiveMachine()

    def _onLoginStateChanged(self, is_logged_in: bool) -> None: # TODO
        """Called when the uses logs in or out"""

        if is_logged_in:
            self.start()
        else:
            self.stop()

    def _getConnectOutputDevices(self) -> None:
        """Gets all CONNECT printers from the API."""

        if self._syncing:
            return

        self._syncing = True
        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING)
        self._api.getConnectOutputDevices(self._onGetConnectOutputDevicesFinished, self._onGetConnectOutputDevicesFailed)

    def _onGetConnectOutputDevicesFinished(self, output_devices_data: List[BigRepConnectOutputDeviceResponse]) -> None: # TODO
        """Callback for when the request for getting the clusters is successful and finished."""

        # Fetch all machines all available machines and filter all printers with a serial number
        container_stack = CuraApplication.getInstance().getContainerRegistry().findContainerStacks(
                                       type = "machine")
        self._connect_printers = {m.getMetaDataEntry(self.META_SERIAL_NUMBER): m for m in container_stack if m.getMetaDataEntry(self.META_SERIAL_NUMBER, None)}
        
        # All NEWLY discovered connect output devices
        new_connect_output_devices = []
        all_connect_output_devices: Dict[str, BigRepConnectOutputDeviceResponse] = {c.serial_number: c for c in output_devices_data}

        # Add the new printers in Cura.
        for serial_number, output_device_data in all_connect_output_devices.items():
            # IF THE CLUSTER ID IS NOT PART OF THE OUTOUT DEVICES WE HAVE FOUND SO FAR THEN ADD TO NEW LIST
            if serial_number not in self._connect_output_devices: 
                new_connect_output_devices.append(output_device_data)
            if serial_number in self._connect_printers:
                # If a printer was previously not linked to the account and is rediscovered, mark the printer as linked
                # to the current account
                if not parseBool(self._connect_printers[serial_number].getMetaDataEntry(self.META_LINKED_TO_ACCOUNT, "true")):
                    self._connect_printers[serial_number].setMetaDataEntry(self.META_LINKED_TO_ACCOUNT, True)

        # We want a machine stack per remote printer that we discovered. Create them now!
        self._createMachineStacksForDiscoveredOutputDevices(new_connect_output_devices)

        # TODO: [FW] Adjust online/offline status of all printers; Requires further information from CONNECT
        # self._updateOnlinePrinters(all_connect_output_devices)

        # TODO: [FW] Remove all offline status of all printers; Requires further information from CONNECT
        # Remove the CloudOutput device for offline printers
        # offline_device_keys = set(self._connect_output_devices.keys()) - set(online_clusters.keys())
        # for device_id in offline_device_keys:
        #     self._onDiscoveredDeviceRemoved(device_id)

        # Handle devices that were previously added in CONNECT but do not exist in the account anymore (i.e. they were
        # removed from the account)
        removed_device_keys = set(self._connect_printers.keys()) - set(all_connect_output_devices.keys())
        if removed_device_keys:
            self._devicesRemovedFromAccount(removed_device_keys)

        if new_connect_output_devices or removed_device_keys: # or TODO: [FW] offline_device_keys
            self.discoveredDevicesChanged.emit()

        # TODO: [FW] Requires offline device implementation
        # if offline_device_keys:
        #     # If the removed device was active we should connect to the new active device
        #     self._connectToActiveMachine()

        self._syncing = False
        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)

        Logger.debug("Synced cloud printers with account.")

    def _onGetConnectOutputDevicesFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: # [x]
        self._syncing = False
        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)

    def _createMachineStacksForDiscoveredOutputDevices(self, new_output_devices_data) -> None:
        """**Synchronously** create machines for discovered devices

        Any new machines are made available to the user.
        May take a long time to complete. This currently forcefully calls the "processEvents", which isn't
        the nicest solution out there. We might need to consider moving this into a job later!
        """
        new_output_devices: List[BigRepConnectOutputDevice] = []
        connect_output_device_added = False

        machine_manager = CuraApplication.getInstance().getMachineManager()

        for output_device_data in new_output_devices_data:
            output_device = BigRepConnectOutputDevice(self._api, output_device_data)

            # Create a machine if we don't already have it. Do not make it the active machine.
            # We only need to add it if it wasn't already added by "local" network or by cloud.
            if machine_manager.getMachine(output_device.printerType, {self.META_SERIAL_NUMBER: output_device.key}) is None:
                new_output_devices.append(output_device)
            elif output_device.getId() not in self._connect_output_devices:
                self._connect_output_devices[output_device.getId()] = output_device
                connect_output_device_added = True
            # If a printer that was removed from the account is re-added, change its metadata to mark it not removed
            # from the account
            elif not parseBool(self._connect_printers[output_device.key].getMetaDataEntry(self.META_LINKED_TO_ACCOUNT, "true")):
                self._connect_printers[output_device.key].setMetaDataEntry(self.META_LINKED_TO_ACCOUNT, True)
            # As adding a lot of machines might take some time, ensure that the GUI (and progress message) is updated
            CuraApplication.getInstance().processEvents()

        # Inform the Cloud printers model about new devices.
        discovered_output_devices_list = [{
                "key": d.getId(),
                "name": d.name,
                "machine_type": d.printerTypeName,
                "firmware_version": d.firmwareVersion} for d in new_output_devices]
        discovered_cloud_printers_model = CuraApplication.getInstance().getDiscoveredCloudPrintersModel()
        discovered_cloud_printers_model.addDiscoveredCloudPrinters(discovered_output_devices_list)

        if not new_output_devices:
            if connect_output_device_added:
                self._connectToActiveMachine()
            return

        # Sort new_devices on online status first, alphabetical second.
        # Since the first device might be activated in case there is no active printer yet,
        # it would be nice to prioritize online devices
        new_output_devices.sort(key = lambda x: ("b{}").format(x.name.lower()))

        new_output_devices_added = []

        for idx, output_device in enumerate(new_output_devices):
            self._connect_output_devices[output_device.getId()] = output_device

            # If there is no active machine, activate the first available cloud printer
            activate = not CuraApplication.getInstance().getMachineManager().activeMachine

            if self._createMachineFromDiscoveredDevice(output_device.getId(), activate = activate):
                new_output_devices_added.append(output_device)

    def _connectToActiveMachine(self) -> None:
        """Callback for when the active machine was changed by the user"""
        active_machine = CuraApplication.getInstance().getGlobalContainerStack()
        if not active_machine:
            return

        # Check if we should directly connect with a "normal" BigRepConnectOutputDevice or that we should connect to an
        # 'abstract' one
        output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
        stored_serial_number = active_machine.getMetaDataEntry(self.META_SERIAL_NUMBER)

        # Copy of the device list, to prevent modifying the list while iterating, if a device gets added asynchronously.
        _connect_output_devices_copy: List[BigRepConnectOutputDevice] = list(self._connect_output_devices.values())
        for device in _connect_output_devices_copy:
            if device.key == stored_serial_number:
                # Connect to it if the stored ID matches.
                self._connectToOutputDevice(device, active_machine)
            elif device.key in output_device_manager.getOutputDeviceIds():
                # Remove device if it is not meant for the active machine.
                output_device_manager.removeOutputDevice(device.key)
    
    def _connectToOutputDevice(self, device: BigRepConnectOutputDevice, machine: GlobalStack) -> None:
        """Connects to an output device and makes sure it is registered in the output device manager."""

        self._setOutputDeviceMetadata(device, machine)

        if not device.isConnected():
            device.connect()

        output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
        if device.key not in output_device_manager.getOutputDeviceIds():
            output_device_manager.addOutputDevice(device)    

    def _devicesRemovedFromAccount(self, removed_device_serial_numbers: Set[str]) -> None:
        """
        Removes the BigRepConnectOutputDevice from the received device serial numbers and marks the specific printers as "removed from
        account". In addition, it generates a message to inform the user about the printers that are no longer linked to
        his/her account. The message is not generated if all the printers have been previously reported as not linked
        to the account.

        :param removed_device_serial_numbers: Set of device serial numbers, whose BigRepConnectOutputDevice needs to be removed
        :return: None
        """

        if not CuraApplication.getInstance().getCuraAPI().account.isLoggedIn:
            return

        # Do not report device serial numbers which have been previously marked as non-linked to the account
        ignored_device_serial_numbers = set()
        for device_serial_number in removed_device_serial_numbers:
            if not parseBool(self._connect_printers[device_serial_number].getMetaDataEntry(self.META_LINKED_TO_ACCOUNT, "true")):
                ignored_device_serial_numbers.add(device_serial_number)

        # Keep the reported_device_serial_numbers list in a class variable, so that the message button actions can access it and
        # take the necessary steps to fulfill their purpose.
        self.reported_device_serial_numbers = removed_device_serial_numbers - ignored_device_serial_numbers
        if not self.reported_device_serial_numbers:
            return

        output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()

        # Remove the output device from the printers
        for device_serial_number in removed_device_serial_numbers:
            global_stack: Optional[GlobalStack] = self._connect_printers.get(device_serial_number, None)
            if not global_stack:
                continue
            if device_serial_number in output_device_manager.getOutputDeviceIds():
                output_device_manager.removeOutputDevice(device_serial_number)
            if device_serial_number in self._connect_output_devices:
                del self._connect_output_devices[device_serial_number]

            # Update the printer's metadata to mark it as not linked to the account
            global_stack.setMetaDataEntry(self.META_LINKED_TO_ACCOUNT, False)

    def _createMachineFromDiscoveredDevice(self, serial_number: str, activate: bool = True) -> bool:
        output_device = self._connect_output_devices.get(serial_number)
        if not output_device:
            return False

        # Create a new machine.
        # We do not use MachineManager.addMachine here because we need to set the cluster ID before activating it.
        new_machine = CuraStackBuilder.createMachine(output_device.name, output_device.printerType, show_warning_message=False)
        if not new_machine:
            Logger.error(f"Failed creating a new machine for {output_device.name}")
            return False

        self._setOutputDeviceMetadata(output_device, new_machine)

        if activate:
            CuraApplication.getInstance().getMachineManager().setActiveMachine(new_machine.getId())

        return True

    def _setOutputDeviceMetadata(self, device: BigRepConnectOutputDevice, machine: GlobalStack):
        machine.setName(device.name)
        machine.setMetaDataEntry(self.META_SERIAL_NUMBER, device.key)
        machine.setMetaDataEntry(self.META_FIRMWARE_VERSION, device.firmwareVersion)
        machine.setMetaDataEntry(self.META_LINKED_TO_ACCOUNT, True)
        machine.addConfiguredConnectionType(device.connectionType.value)

    def _printerRemoved(self, container: ContainerInterface) -> None:
        """
        Callback connected to the containerRemoved signal. Invoked when a CONNECT printer is removed from Blade to remove
        the printer's reference from the _connect_output_devices.

        :param container: The ContainerInterface passed to this function whenever the ContainerRemoved signal is emitted
        :return: None
        """
        if isinstance(container, GlobalStack):
            serial_number = container.getMetaDataEntry(self.META_SERIAL_NUMBER, None)
            if serial_number in self._connect_output_devices.keys():
                del self._connect_output_devices[serial_number]