# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING

from UM.Logger import Logger
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.Interfaces import ContainerInterface
from UM.Signal import Signal

from cura.Machines.ContainerNode import ContainerNode
from cura.Machines.MaterialNode import MaterialNode

import UM.FlameProfiler

if TYPE_CHECKING:
    from typing import Dict
    from cura.Machines.MachineNode import MachineNode


##  This class represents an extruder variant in the container tree.
#
#   The subnodes of these nodes are materials.
#
#   This node contains materials with ALL filament diameters underneath it. The
#   tree of this variant is not specific to one global stack, so because the
#   list of materials can be different per stack depending on the compatible
#   material diameter setting, we cannot filter them here. Filtering must be
#   done in the model.
class VariantNode(ContainerNode):
    def __init__(self, container_id: str, machine: "MachineNode") -> None:
        super().__init__(container_id)
        self.machine = machine
        self.materials = {}  # type: Dict[str, MaterialNode]  # Mapping material base files to their nodes.
        self.materialsChanged = Signal()

        container_registry = ContainerRegistry.getInstance()
        self.variant_name = container_registry.findContainersMetadata(id = container_id)[0]["name"]  # Store our own name so that we can filter more easily.
        container_registry.containerAdded.connect(self._materialAdded)
        container_registry.containerRemoved.connect(self._materialRemoved)
        self._loadAll()

    ##  (Re)loads all materials under this variant.
    @UM.FlameProfiler.profile
    def _loadAll(self) -> None:
        container_registry = ContainerRegistry.getInstance()

        if not self.machine.has_materials:
            self.materials["empty_material"] = MaterialNode("empty_material", variant = self)
            return  # There should not be any materials loaded for this printer.

        # Find all the materials for this variant's name.
        else:  # Printer has its own material profiles. Look for material profiles with this printer's definition.
            base_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = "fdmprinter")
            printer_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id)
            variant_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = self.variant_name)  # If empty_variant, this won't return anything.
            materials_per_base_file = {material["base_file"]: material for material in base_materials}
            materials_per_base_file.update({material["base_file"]: material for material in printer_specific_materials})  # Printer-specific profiles override global ones.
            materials_per_base_file.update({material["base_file"]: material for material in variant_specific_materials})  # Variant-specific profiles override all of those.
            materials = list(materials_per_base_file.values())

        # Filter materials based on the exclude_materials property.
        filtered_materials = [material for material in materials if material["id"] not in self.machine.exclude_materials]

        for material in filtered_materials:
            base_file = material["base_file"]
            if base_file not in self.materials:
                self.materials[base_file] = MaterialNode(material["id"], variant = self)
                self.materials[base_file].materialChanged.connect(self.materialsChanged)
        if not self.materials:
            self.materials["empty_material"] = MaterialNode("empty_material", variant = self)

    ##  Finds the preferred material for this printer with this nozzle in one of
    #   the extruders.
    #
    #   If the preferred material is not available, an arbitrary material is
    #   returned. If there is a configuration mistake (like a typo in the
    #   preferred material) this returns a random available material. If there
    #   are no available materials, this will return the empty material node.
    #   \param approximate_diameter The desired approximate diameter of the
    #   material.
    #   \return The node for the preferred material, or any arbitrary material
    #   if there is no match.
    def preferredMaterial(self, approximate_diameter: int) -> MaterialNode:
        for base_material, material_node in self.materials.items():
            if self.machine.preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
                return material_node
            
        # First fallback: Check if we should be checking for the 175 variant.
        if approximate_diameter == 2:
            preferred_material = self.machine.preferred_material + "_175"
            for base_material, material_node in self.materials.items():
                if preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
                    return material_node
        
        # Second fallback: Choose any material with matching diameter.
        for material_node in self.materials.values():
            if material_node.getMetaDataEntry("approximate_diameter") and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
                Logger.log("w", "Could not find preferred material %s, falling back to whatever works", self.machine.preferred_material)
                return material_node

        fallback = next(iter(self.materials.values()))  # Should only happen with empty material node.
        Logger.log("w", "Could not find preferred material {preferred_material} with diameter {diameter} for variant {variant_id}, falling back to {fallback}.".format(
            preferred_material = self.machine.preferred_material,
            diameter = approximate_diameter,
            variant_id = self.container_id,
            fallback = fallback.container_id
        ))
        return fallback

    ##  When a material gets added to the set of profiles, we need to update our
    #   tree here.
    @UM.FlameProfiler.profile
    def _materialAdded(self, container: ContainerInterface) -> None:
        if container.getMetaDataEntry("type") != "material":
            return  # Not interested.
        if not ContainerRegistry.getInstance().findContainersMetadata(id = container.getId()):
            # CURA-6889
            # containerAdded and removed signals may be triggered in the next event cycle. If a container gets added
            # and removed in the same event cycle, in the next cycle, the connections should just ignore the signals.
            # The check here makes sure that the container in the signal still exists.
            Logger.log("d", "Got container added signal for container [%s] but it no longer exists, do nothing.",
                       container.getId())
            return
        if not self.machine.has_materials:
            return  # We won't add any materials.
        material_definition = container.getMetaDataEntry("definition")

        base_file = container.getMetaDataEntry("base_file")
        if base_file in self.machine.exclude_materials:
            return  # Material is forbidden for this printer.
        if base_file not in self.materials:  # Completely new base file. Always better than not having a file as long as it matches our set-up.
            if material_definition != "fdmprinter" and material_definition != self.machine.container_id:
                return
            material_variant = container.getMetaDataEntry("variant_name")
            if material_variant is not None and material_variant != self.variant_name:
                return
        else:  # We already have this base profile. Replace the base profile if the new one is more specific.
            new_definition = container.getMetaDataEntry("definition")
            if new_definition == "fdmprinter":
                return  # Just as unspecific or worse.
            material_variant = container.getMetaDataEntry("variant_name")
            if new_definition != self.machine.container_id or material_variant != self.variant_name:
                return  # Doesn't match this set-up.
            original_metadata = ContainerRegistry.getInstance().findContainersMetadata(id = self.materials[base_file].container_id)[0]
            if "variant_name" in original_metadata or material_variant is None:
                return  # Original was already specific or just as unspecific as the new one.

        if "empty_material" in self.materials:
            del self.materials["empty_material"]
        self.materials[base_file] = MaterialNode(container.getId(), variant = self)
        self.materials[base_file].materialChanged.connect(self.materialsChanged)
        self.materialsChanged.emit(self.materials[base_file])

    @UM.FlameProfiler.profile
    def _materialRemoved(self, container: ContainerInterface) -> None:
        if container.getMetaDataEntry("type") != "material":
            return  # Only interested in materials.
        base_file = container.getMetaDataEntry("base_file")
        if base_file not in self.materials:
            return  # We don't track this material anyway. No need to remove it.

        original_node = self.materials[base_file]
        del self.materials[base_file]
        self.materialsChanged.emit(original_node)

        # Now a different material from the same base file may have been hidden because it was not as specific as the one we deleted.
        # Search for any submaterials from that base file that are still left.
        materials_same_base_file = ContainerRegistry.getInstance().findContainersMetadata(base_file = base_file)
        if materials_same_base_file:
            most_specific_submaterial = None
            for submaterial in materials_same_base_file:
                if submaterial["definition"] == self.machine.container_id:
                    if submaterial.get("variant_name", "empty") == self.variant_name:
                        most_specific_submaterial = submaterial
                        break  # most specific match possible
                    if submaterial.get("variant_name", "empty") == "empty":
                        most_specific_submaterial = submaterial

            if most_specific_submaterial is None:
                Logger.log("w", "Material %s removed, but no suitable replacement found", base_file)
            else:
                Logger.log("i", "Material %s (%s) overridden by %s", base_file, self.variant_name, most_specific_submaterial.get("id"))
                self.materials[base_file] = MaterialNode(most_specific_submaterial["id"], variant = self)
                self.materialsChanged.emit(self.materials[base_file])

        if not self.materials:  # The last available material just got deleted and there is nothing with the same base file to replace it.
            self.materials["empty_material"] = MaterialNode("empty_material", variant = self)
            self.materialsChanged.emit(self.materials["empty_material"])