#  Greyscalegorilla Blender Connect
#  Copyright (C) 2024 Greyscalegorilla, Inc.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.

import bpy
import mathutils
import errno
import threading
import os
import time
import json
import socket
import math
import re
from bpy.app.handlers import persistent
from queue import Queue
import PyOpenColorIO as OCIO

ATTACH_DEBUGGER = False
if ATTACH_DEBUGGER:
    import ptvsd
    ptvsd.enable_attach(address=('localhost', 5678))

bl_info = {
    "name": "Greyscalegorilla Connect",
    "version": (1, 4, 1),
    "blender": (3, 4, 0),
    "category": "Import",
    "auto_run": True,
}

IS_DEBUG = False
MESSAGING_VERSION = "1.0"


error_codes = {
    "InvalidMetadata": 1,
    "ModelsNotFound": 2,
    "ModelsNotLoaded": 3,
    "MaterialsNotFound": 4,
    "MaterialsNotLoaded": 5,
}


def log_debug(message):
    if IS_DEBUG:
        print(f"Greyscalegorilla Connect (DEBUG) - {message}")


def log_info(message):
    print(f"Greyscalegorilla Connect - {message}")


def log_error(message):
    print(f"Greyscalegorilla Connect - Error: {message}")


class TaskManager:
    def __init__(self):
        self.queue = Queue()
        self.runners = {}

    def add(self, task_type, payload):
        task = self.create_task(task_type, payload)
        self.queue.put(task)

    def check_pending(self):
        while not self.queue.empty():
            task = self.queue.get()
            self.execute(task)
        return 1.0

    def execute(self, task):
        task_runner = self.runners[task['type']]
        if not task_runner:
            log_error(
                f'No task runner registered for task type: {task["type"]}')
            return
        task_runner(task['payload'])

    def register(self, task_type, runner):
        self.runners[task_type] = runner

    def create_task(self, task_type, payload):
        return {"type": task_type, "payload": payload}


class CRCException(Exception):
    pass


class Protocol:
    def __init__(self):
        self.start_delimiter = b"!!L:"
        self.end_delimiter = b'E!!'
        self.start_delimiter_size = len(self.start_delimiter)
        self.end_delimiter_size = len(self.end_delimiter)
        self.length_size = 2
        self.crc_size = 2
        self.prefix_size = len(self.start_delimiter) + self.length_size
        self.suffix_size = len(self.end_delimiter) + self.crc_size

    def encode_message(self, message):
        prefix = self.generate_prefix(message)
        suffix = self.generate_suffix(message)
        encoded_message = prefix + message.encode() + suffix
        return encoded_message

    def generate_prefix(self, message):
        length = len(message)
        prefix = self.start_delimiter + length.to_bytes(2, byteorder='big')
        return prefix

    def generate_suffix(self, message):
        crc = self.crc16(message.encode())
        suffix = crc.to_bytes(2, byteorder='big') + self.end_delimiter
        return suffix

    def decode_buffer(self, buffer):
        messages = buffer.split(self.end_delimiter)
        decoded_messages = []
        for message in messages:
            if message.startswith(self.start_delimiter):
                prefix = message[0:self.prefix_size]
                length = int.from_bytes(
                    prefix[self.start_delimiter_size:self.start_delimiter_size + self.length_size], byteorder='big')
                received_message = message[self.prefix_size:self.prefix_size + length]
                suffix = message[self.prefix_size + length:]
                crc_received = int.from_bytes(
                    suffix[0:self.crc_size], byteorder='big')
                crc_calculated = self.crc16(received_message)
                if crc_received == crc_calculated:
                    decoded_messages.append(received_message.decode())
                else:
                    raise CRCException(
                        f'Calculated and received checksum do not match.\nExpected checksum: {crc_calculated}, received checksum: {crc_received}')
        return decoded_messages

    def crc16(self, message):
        poly = 0x1021
        crc = 0xffff

        for byte in message:
            v = 0x80
            for _ in range(8):
                if crc & 0x8000:
                    xor_flag = 1
                else:
                    xor_flag = 0

                crc = crc << 1
                if byte & v:
                    crc += 1
                if xor_flag:
                    crc = crc ^ poly
                v = v >> 1

        return crc & 0xffff


protocol = Protocol()


def isNumeric(value):
    t = type(value)
    return (t == float) or (t == int)


def asVector(value):
    if not (hasattr(value, "__iter__") and (len(value) == 3)):
        return None
    for v in value:
        if not isNumeric(v):
            return None
    return value


def isColor(value):
    if not (hasattr(value, '__iter__') and ('r' in value) and ('g' in value) and ('b' in value)):
        return False
    for ch in ['r', 'g', 'b', 'a']:
        if (ch in value) and not isNumeric(value[ch]):
            return False
    return True


class ColorConverter:
    def __init__(self):
        self.is_ocio = False
        ocio_config_file = os.environ.get('OCIO')
        if ocio_config_file is None:
            # look to the default Blender OCIO config. For 4.0 is at:
            # C:/Program Files/Blender Foundation/Blender 4.0/4.0/datafiles/colormanagement/config.ocio
            bl_dir = os.path.dirname(bpy.app.binary_path)
            bl_version = bpy.app.version
            ocio_dir = os.path.join(bl_dir, '{}.{}'.format(bl_version[0], bl_version[1]), 'datafiles', 'colormanagement')
            ocio_config_file = os.path.join(ocio_dir, 'config.ocio')

        if ocio_config_file is not None and os.path.exists(ocio_config_file):
            self.config = OCIO.Config.CreateFromFile(ocio_config_file)
            # The following gets the output color space when saving images, but we want the rendering color space
            # defined in the ocio config file
            #linear_space = bpy.context.scene.render.image_settings.linear_colorspace_settings.name
            linear_space = self.GetRole('scene_linear')
            if self.HasColorSpace('sRGB') and self.HasColorSpace(linear_space):
                self.processor = self.config.getProcessor('sRGB', linear_space)
                self.cpu_processor = self.processor.getDefaultCPUProcessor()
                self.is_ocio = True
        # if something goes wrong, we turn is_ocio off and default to Linear Rec.709

    # we need to iterate because of the type returned by getColorSpaceNames
    def HasColorSpace(self, in_name: str) -> bool:
        return in_name in self.config.getColorSpaceNames()
    
    def GetRole(self, in_name: str) -> str:
        for role_name, space_name in self.config.getRoles():
            if role_name == in_name:
                return space_name
        return ''    

    def SrgbToLinearRec709(self, in_srgb: dict) -> dict:
        def inverse_gamma_correct(channel):
            if channel <= 0.04045:
                return channel / 12.92
            else:
                return ((channel + 0.055) / 1.055) ** 2.4

        linear_r = inverse_gamma_correct(in_srgb['r'])
        linear_g = inverse_gamma_correct(in_srgb['g'])
        linear_b = inverse_gamma_correct(in_srgb['b'])
        return {'r':linear_r, 'g':linear_g, 'b':linear_b}
    
    def ToLinear(self, in_srgb: dict, in_clamp: bool = True) -> dict:
        if self.is_ocio:
            color = (in_srgb['r'], in_srgb['g'], in_srgb['b'])
            # convert to the rendering color space
            color_t = self.cpu_processor.applyRGB(color)
            if not in_clamp:
                return {'r': color_t[0], 'g': color_t[1], 'b': color_t[2]}
            return {'r': max(0, min(1, color_t[0])), 'g': max(0, min(1, color_t[1])), 'b': max(0, min(1, color_t[2]))}
        else:
            color_t = self.SrgbToLinearRec709(in_srgb)
            if not in_clamp:
                return color_t
            return {'r': max(0, min(1, color_t['r'])), 'g': max(0, min(1, color_t['g'])), 'b': max(0, min(1, color_t['b']))}


# List of the inputs of the Principled BSDF in Blender 4 and their corresponding names in the Arnold standard_surface shader:
# https://docs.blender.org/manual/en/latest/render/shader_nodes/shader/principled.html
#
#                              Blander4                Blender3
#
# 04 NodeSocketFloatFactor    "Alpha"                  "Alpha"                  <- opacity (RGB)
# 14 NodeSocketFloatFactor    "Anisotropic"            "Anisotropic"            <- specular_anisotropy
# 15 NodeSocketFloatFactor    "Anisotropic Rotation"   "Anisotropic Rotation"   <- specular_rotation
# 00 NodeSocketColor          "Base Color"             "Base Color"             <- base_color * base <------
# 20 NodeSocketFloat          "Coat IOR"               N/A                      <- coat_IOR
# 22 NodeSocketVector         "Coat Normal"            "Clearcoat Normal"       <- coat_normal
# 18 NodeSocketFloatFactor    "Coat Weight"            "Clearcoat"              <- coat
# 19 NodeSocketFloatFactor    "Coat Roughness"         "Clearcoat Roughness"    <- coat_roughness
# 21 NodeSocketColor          "Coat Tint"              N/A                      <- coat_color
# 26 NodeSocketColor          "Emission Color"         "Emission"               <- emission_color
# 27 NodeSocketFloat          "Emission Strength"      "Emission Strength"      <- emission
# 03 NodeSocketFloat          "IOR"                    "IOR"                    <- specular_IOR
# 01 NodeSocketFloatFactor    "Metallic"               "Metallic"               <- metalness
# 05 NodeSocketVector         "Normal"            
# 02 NodeSocketFloatFactor    "Roughness"              "Roughness"              <- specular_roughness
# 24 NodeSocketFloatFactor    "Sheen Roughness"        N/A                      <- sheen_roughness
# 25 NodeSocketColor          "Sheen Tint"             "Sheen Tint (FLOAT)"     <- sheen_color * specular <------
# 23 NodeSocketFloatFactor    "Sheen Weight"           "Sheen"                  <- sheen
# 12 NodeSocketFloatFactor    "Specular IOR Level"
# 13 NodeSocketColor          "Specular Tint"          "Specular Tint" (FLOAT)  <- specular_color
# 11 NodeSocketFloatFactor    "Subsurface Anisotropy"  "Subsurface Anisotropy"  <- subsurface_anisotropy
# 10 NodeSocketFloatFactor    "Subsurface IOR"    
# 08 NodeSocketVector         "Subsurface Radius"      "Subsurface Radius"      <- subsurface_radius
# 09 NodeSocketFloatDistance  "Subsurface Scale"       N/A                      <- subsurface_scale
# 07 NodeSocketFloatFactor    "Subsurface Weight"      "Subsurface"             <- subsurface
# 16 NodeSocketVector         "Tangent"           
# 17 NodeSocketFloatFactor    "Transmission Weight"    "Transmission"           <- transmission
# 06 NodeSocketFloat          "Weight"                 SKIP, NOT SHOWING IN THE UI
#
# List of Blender3 parameters, then used to compile the Blender3 column above:
#
# 21 NodeSocketFloatFactor("Alpha") at 0x000002428781EA08>
# 10 NodeSocketFloatFactor("Anisotropic") at 0x00000242877DC208>
# 11 NodeSocketFloatFactor("Anisotropic Rotation") at 0x000002428781FE08>
# 00 NodeSocketColor("Base Color") at 0x00000242877DE408>
# 14 NodeSocketFloatFactor("Clearcoat") at 0x000002428781F808>
# 23 NodeSocketVector("Clearcoat Normal") at 0x000002428781E608>
# 15 NodeSocketFloatFactor("Clearcoat Roughness") at 0x000002428781F608>
# 19 NodeSocketColor("Emission") at 0x000002428781EE08>
# 20 NodeSocketFloat("Emission Strength") at 0x000002428781EC08>
# 16 NodeSocketFloat("IOR") at 0x000002428781F408>
# 06 NodeSocketFloatFactor("Metallic") at 0x00000242877DCA08>
# 22 NodeSocketVector("Normal") at 0x000002428781E808>
# 09 NodeSocketFloatFactor("Roughness") at 0x00000242877DC408>
# 12 NodeSocketFloatFactor("Sheen") at 0x000002428781FC08>
# 13 NodeSocketFloatFactor("Sheen Tint") at 0x000002428781FA08>
# 07 NodeSocketFloatFactor("Specular") at 0x00000242877DC808>
# 08 NodeSocketFloatFactor("Specular Tint") at 0x00000242877DC608>
# 01 NodeSocketFloatFactor("Subsurface") at 0x00000242877DD608>
# 05 NodeSocketFloatFactor("Subsurface Anisotropy") at 0x00000242877DCC08>
# 03 NodeSocketColor("Subsurface Color") at 0x00000242877DD008>
# 04 NodeSocketFloatFactor("Subsurface IOR") at 0x00000242877DCE08>
# 02 NodeSocketVector("Subsurface Radius") at 0x00000242877DD208>
# 24 NodeSocketVector("Tangent") at 0x000002428781E408>
# 17 NodeSocketFloatFactor("Transmission") at 0x000002428781F208>
# 18 NodeSocketFloatFactor("Transmission Roughness") at 0x000002428781F008>
# 25 NodeSocketFloat("Weight") at 0x000002428781E208>



class ShaderParams:
    def __init__(self, meta, shaderName):

        self.standard_surface_parameters = {
            "base":                    "UNSUPPORTED",
            "base_color":              "Base Color",
            "caustics":                "UNSUPPORTED",
            "coat":                    "Coat Weight",
            "coat_affect_color":       "UNSUPPORTED",
            "coat_affect_roughness":   "UNSUPPORTED",
            "coat_anisotropy":         "UNSUPPORTED",
            "coat_color":              "Coat Tint",
            "coat_IOR":                "Coat IOR",
            "coat_normal":             "Coat Normal",
            "coat_rotation":           "UNSUPPORTED",
            "coat_roughness":          "Coat Roughness",
            "diffuse_roughness":       "UNSUPPORTED",
            "emission":                "Emission Strength",
            "emission_color":          "Emission Color",
            "internal_reflections":    "UNSUPPORTED",
            "metalness":               "Metallic",
            "opacity":                 "Alpha",
            "sheen":                   "Sheen Weight",
            "sheen_color":             "Sheen Tint",
            "sheen_roughness":         "Sheen Roughness",
            "specular":                "UNSUPPORTED",
            "specular_anisotropy":     "Anisotropic",
            "specular_color":          "Specular Tint",
            "specular_IOR":            "IOR",
            "specular_rotation":       "Anisotropic Rotation",
            "specular_roughness":      "Roughness",
            "subsurface":              "Subsurface Weight",
            "subsurface_anisotropy":   "Subsurface Anisotropy",
            "subsurface_color":        "UNSUPPORTED",
            "subsurface_radius":       "Subsurface Radius",
            "subsurface_scale":        "Subsurface Scale",
            "subsurface_type":         "UNSUPPORTED",
            "thin_film_thickness":     "UNSUPPORTED",
            "thin_film_IOR":           "UNSUPPORTED",
            "thin_walled":             "UNSUPPORTED",
            "transmission":            "Transmission Weight",
            "transmission_color":      "UNSUPPORTED",
            "transmission_depth":      "UNSUPPORTED",
            "transmission_dispersion": "UNSUPPORTED",
            "transmission_scatter":    "UNSUPPORTED"
        }

        # overrides for the different parameter names in Blender 3:
        if bpy.app.version < (4, 00, 0):
            self.standard_surface_parameters["coat"]             = "Clearcoat"
            self.standard_surface_parameters["coat_color"]       = "UNSUPPORTED"
            self.standard_surface_parameters["coat_IOR"]         = "UNSUPPORTED"
            self.standard_surface_parameters["coat_normal"]      = "Clearcoat Normal"
            self.standard_surface_parameters["coat_roughness"]   = "Clearcoat Roughness"
            self.standard_surface_parameters["emission_color"]   = "Emission"
            self.standard_surface_parameters["sheen"]            = "Sheen"
            self.standard_surface_parameters["sheen_color"]      = "UNSUPPORTED"
            self.standard_surface_parameters["sheen_roughness"]  = "UNSUPPORTED"
            self.standard_surface_parameters["subsurface"]       = "Subsurface"
            self.standard_surface_parameters["subsurface_color"] = "Subsurface Color"
            self.standard_surface_parameters["subsurface_scale"] = "UNSUPPORTED"
            self.standard_surface_parameters["transmission"]     = "Transmission"

            # the following disabled, because float in Blender 3, not colors
            self.standard_surface_parameters["specular_color"]    = "UNSUPPORTED"
            self.standard_surface_parameters["sheen_color"]       = "UNSUPPORTED"

        self.normal_map_parameters = {
            "invert_y": "UNSUPPORTED",
            "strength": "Strength"
        }

        self.shaderName = shaderName
        self.shaderDict = {}
        self.params = {}

        if shaderName == "standard_surface":
            self.shaderDict = self.standard_surface_parameters
        elif shaderName == "normal_map":
            self.shaderDict = self.normal_map_parameters
        elif shaderName == "ramp_rgb":
            pass
        else:
            return
        
        self.color_converter = ColorConverter()

        # fill the default param dict
        if shaderName == "standard_surface":
            white = self.Color(1, 1, 1)
            self.params["base"] = 1.0 # although UNSUPPORTED, to multiply base_color
            self.params["base_color"] = white
            self.params["coat"] = 0
            self.params["coat_color"] = white
            self.params["coat_IOR"] = 1.5
            self.params["coat_normal"] = self.Vector(0.0, 0.0, 0.0) # Not in the unique params json
            self.params["coat_roughness"] = 0.1
            self.params["emission"] = 0
            self.params["emission_color"] = white # Not in the unique params json
            self.params["metalness"] = 0
            self.params["opacity"] = white # Not in the unique params json
            self.params["sheen_roughness"] = 0.3 # because it's 0.5 in the Principled BSDF
            self.params["specular"] = 1 # although UNSUPPORTED, , to multiply specular_color
            self.params["specular_anisotropy"] = 0
            self.params["specular_color"] = white
            self.params["specular_IOR"] = 1.5
            self.params["specular_rotation"] = 0
            self.params["specular_roughness"] = 0.2
            self.params["subsurface"] = 0
            self.params["subsurface_color"] = white
            self.params["subsurface_radius"] = self.Color(0.1, 0.1, 0.1)
            #self.params["subsurface_type"] = "randomwalk"
            self.params["transmission"] = 0
            self.params["transmission_color"] = white # although UNSUPPORTED, to override base_color

       # Special handling for the ramp_rgb metadata, whise position and color are arrays
        # We need to read the nb_keys first, then the position_i and color_i
        if shaderName == "ramp_rgb":
            value = self.GetSimpleParamValue(meta, "nb_keys")
            if value is None:
                self.Add("nb_keys", 0)
            else:
                self.Add("nb_keys", value)
                self.params["position"] = []
                self.params["color"] = []
                for i in range(int(value)):
                    value = self.GetSimpleParamValue(meta, "position_" + str(i))
                    self.params["position"].append(value)
                    value = self.GetSimpleParamValue(meta, "color_" + str(i))
                    value = self.color_converter.ToLinear(value)
                    self.params["color"].append(value)
                # falloff:    
                value = self.GetSimpleParamValue(meta, "falloff")
                self.Add("falloff", 1.0 if value is None else value)
                # neon color
                value = self.GetSimpleParamValue(meta, "neon_color")
                if value is not None:
                    value = self.color_converter.ToLinear(value)
                    self.Add("neon_color", value)
                # roughness weight:
                value = self.GetSimpleParamValue(meta, "carpaint_roughness_weight")
                self.Add("carpaint_roughness_weight", 1.0 if value is None else value)

        else:
            for arnoldName in self.shaderDict.keys():
                isSupported = self.shaderDict[arnoldName] != "UNSUPPORTED"
                # - base and specular are not parameters in cycles, but we want to 
                #   multiply the related colors when they get set
                # - transmission_color is not a parameter in cycles, but we want to
                #   override base_color when transmission is set
                # - subsurface_color not a parameter in Blender 4.0, but we want to
                #   override base_color when subsurface is set
                allow = arnoldName in ("base", "specular", "transmission_color", "subsurface_color")
                if isSupported or allow:
                    value = self.GetSimpleParamValue(meta, arnoldName)
                    if value is not None:
                        self.Add(arnoldName, value)

        self.ConvertColors()

    def Color(self, r, g, b):
        return {'r': r, 'g': g, 'b': b}

    def Vector(self, x, y, z):
        return {'x': x, 'y': y, 'z': z}

    def Add(self, name, value):
        self.params[name] = value

    def Has(self, name):
        return name in self.params
    
    def GetSimpleParamValue(self, meta, param_name):
        if 'params' in meta and self.shaderName in meta['params'] and param_name in meta['params'][self.shaderName]:
            return meta['params'][self.shaderName][param_name]
        return None
        
    def Get(self, arnold_name):
        try:
            v = self.params[arnold_name]
            if isinstance(v, dict):
                if 'r' in v:
                    return (v['r'], v['g'], v['b'])
                elif 'x' in v: # only case is coat_normal by now
                    return (v['x'], v['y'], v['z'])
            else:
                return v
        except Exception:
            error_message = f"Error getting parameter name: {arnold_name}"
            log_error(error_message)
        

    def Set(self, node, blender_name, value):
        # we must do this extra check, against parameters entered in the dict
        # because of the default set, but possibly not supported by the renderer
        if blender_name == "UNSUPPORTED":
            return
        
        try:
            v = value
            if isinstance(v, dict):
                if 'r' in v:
                    if blender_name == "Alpha": # opacity -> Alpha
                        alpha = (v['r'] + v['g'] + v['b']) * 0.3333
                        node.inputs[blender_name].default_value = alpha
                    else:
                        node.inputs[blender_name].default_value[0] = v['r']
                        node.inputs[blender_name].default_value[1] = v['g']
                        node.inputs[blender_name].default_value[2] = v['b']
                elif 'x' in v: # only case is coat_normal by now
                    node.inputs[blender_name].default_value[0] = v['x']
                    node.inputs[blender_name].default_value[1] = v['y']
                    node.inputs[blender_name].default_value[2] = v['z']
            elif isinstance(v, (list, tuple)):
                    node.inputs[blender_name].default_value[0] = v[0]
                    node.inputs[blender_name].default_value[1] = v[1]
                    node.inputs[blender_name].default_value[2] = v[2]
            else:
                node.inputs[blender_name].default_value = v

        except Exception:
            error_message = f"Error setting parameter name: {blender_name}"
            log_error(error_message)


    def ConvertColors(self):
        if len(self.shaderDict) == 0:
            return
        
        for arnoldName in self.params.keys():
            # skipping subsurface_radius, for CON-714
            if arnoldName == "subsurface_radius":
                continue
            value = self.params[arnoldName]
            if isinstance(value, dict) and 'r' in value:
                self.params[arnoldName] = self.color_converter.ToLinear(value)

    
    def SetAll(self, node):
        if len(self.shaderDict) == 0:
            return

        for arnoldName in self.params.keys():
            value = self.params[arnoldName]
            name = self.shaderDict[arnoldName]
            if arnoldName in ("base_color", "specular_color"):
                # base and specular are tagged as UNSUPPORTED, but we want to multiply the color
                scalerName = "base" if arnoldName == "base_color" else "specular"
                scaler = self.params[scalerName]
                if scaler is not None:
                    value = self.Color(value['r'] * scaler, value['g'] * scaler, value['b'] * scaler)

            self.Set(node, name, value)


class MaterialLoader:
    def __init__(self):
        self.on_materials_loaded = lambda: log_info("Materials loaded.")
        self.on_materials_error = lambda error: log_info(
            f"Error while loading materials. {error}")
        self.on_material_loaded = lambda material_name: log_info(
            f"Material {material_name} loaded.")
        self.on_material_error = lambda material_name: log_info(
            f"Error loading material {material_name}.")
        self.on_no_assets_found = lambda path: log_info(
            f"No materials found at path: {path}")
        self.supported_extensions = ['.jpg', '.tif', '.tiff', '.exr']
        self.ocio = 'OCIO' in os.environ
        self.known_maps = [[
            # to be found in the filenames
            'ambientocclusion', 'specularlevel', 'specularedgecolor', 'opacity', 'metallic',
            'roughness', 'basecolor', 'puzzlematte', 'normal', 'normal-wrinkle', 'height',
            'anisotropy', 'anisotropylevel', 'anisotropyangle', 'emissioncolor', 'emissionintensity',
            'sheenopacity', 'sheencolor', 'indexofrefraction', 'absorptiondistance', 'dispersion',
            'absorptioncolor', 'translucency', 'scatteringweight', 'scatteringdistance',
            'scatteringdistancescale', 'coatnormalscale', 'coatopacity', 'coatspecularlevel',
            'coatnormal', 'coatroughness', 'transcolor', 'transweight'],
            # node labels:
            ['Ambient Occlusion', 'Specular Level', 'Specular Edge Color', 'Opacity', 'Metallic',
            'Roughness', 'Base Color', 'Puzzle Matte', 'Normal', 'Normal Wrinkle', 'Height',
            'Anisotropy', 'Anisotropy Level', 'Anisotropy Angle', 'Emission Color', 'Emission Intensity',
            'Sheen Opacity', 'Sheen Color', 'Index of Refraction', 'Absorption Distance', 'Dispersion',
            'Absorption Color', 'Translucency', 'Scattering Weight', 'Scattering Distance',
            'Scattering Distance Scale', 'Coat Normal Scale', 'Coat Opacity', 'Coat Specular Level',
            'Coat Normal', 'Coat Roughness', 'Transmission Color', 'Transmission Weight']]
        
        self.standardSurfaceParams = {}
        self.normalMapParams = {}
        rampRgbParams = {}
        self.is_triplanar = False

    def getEnabledSocket(self, sockets, name, skip=0):
        for s in sockets.values():
            if s.enabled and (s.name == name):
                if skip > 0:
                    skip -= 1
                else:
                    return s

    def get_materials_from_path(self, path):
        def process_path(path, materials_metadata=[]):
            
            if os.path.isdir(path):
                for entry in os.listdir(path):
                    process_path(os.path.join(path, entry), materials_metadata)
            else:
                (root, ext) = os.path.splitext(
                    os.path.basename(os.fsdecode(path)))
                if ext.lower() == '.gsgm':
                    try:
                        material_metadata = self.get_material_metadata(path)
                        is_material = material_metadata.get('meta') and material_metadata.get(
                            'meta').get('type') == 'material'
                        if material_metadata and is_material:
                            material_metadata['name'] = root.replace('_', ' ').strip()
                            materials_metadata.append(material_metadata)
                        else:
                            error_message = f"No materials metadata found at path: {path}"
                            log_error(error_message)
                            materials_metadata.append({
                                "error": {
                                    "error_code": error_codes['MaterialsNotFound'],
                                    "message": error_message,
                                    "path_to_asset": path,
                                }
                            })
                    except Exception:
                        error_message = "Error retrieving materials metadata at path."
                        log_error(error_message)
                        materials_metadata.append({
                            "error": {
                                "error_code": error_codes['MaterialsNotFound'],
                                "message": error_message,
                                "path_to_asset": path,
                            },
                        })
            return materials_metadata

        materials_metadata = process_path(path)
        if not len(materials_metadata):
            error_message = "No Greyscalegorilla materials found at path."
            log_error(error_message)
            materials_metadata.append({
                'error': {
                    "error_code": error_codes['MaterialsNotFound'],
                    "message": error_message,
                    "path_to_asset": path,
                }
            })
        return materials_metadata


    def get_material_metadata(self, material_path):

        material_params = {}
        with open(material_path, mode='r', encoding='utf8') as f:
            meta = json.loads(f.read())
            material_params = {'maps': {}, 'meta': meta}
            dir_path = os.path.dirname(material_path)
            for entry in os.listdir(dir_path):
                if not entry.startswith('.'):
                    (root, ext) = os.path.splitext(
                        os.fsdecode(entry).lower()
                    )
                    if ext in self.supported_extensions:
                        for known_map in self.known_maps[0]:
                            if root.endswith("_" + known_map):
                                if known_map not in material_params['maps']:
                                    material_params['maps'][known_map] = []
                                material_params['maps'][known_map].append({
                                    "map_ext": ext,
                                    "map_path": os.path.join(dir_path, entry)
                                })
            material_params['path'] = material_path

        base_name = os.path.basename(material_path)
        file_name, _ = os.path.splitext(base_name)
        material_params['file_name'] = file_name

        return material_params

    def add_material(self, material_metadata):
        mat = None
        try:
            material_name = material_metadata['file_name']
            meta = material_metadata['meta']

            self.standardSurfaceParams = ShaderParams(meta, "standard_surface")
            self.normalMapParams = ShaderParams(meta, "normal_map")
            self.rampRgbParams = ShaderParams(meta, "ramp_rgb")

            is_version_4_or_later = bpy.app.version >= (4, 00, 0)

            def meta_value(path, defaultValue):
                value = meta
                for k in path:
                    if not (hasattr(value, '__iter__') and (k in value)):
                        return defaultValue
                    value = value[k]
                return value

            isEnglish = bpy.app.translations.locale == 'en_US'

            def localizeText(text):
                return text if isEnglish else bpy.app.translations.pgettext(text)
           
            material_maps = material_metadata['maps']

            mat = bpy.data.materials.new(material_name)
            mat.use_nodes = 1

            links = mat.node_tree.links
            nodes = mat.node_tree.nodes

            pbsdf = nodes[localizeText('Principled BSDF')]
            self.standardSurfaceParams.SetAll(pbsdf)

            if len(material_maps) > 0:
                mapping = nodes.new(localizeText('ShaderNodeMapping'))
                mapping.location = (-800, 300)

                textureCoordinates = nodes.new(localizeText('ShaderNodeTexCoord'))

                coords = 'Object' if self.is_triplanar else 'UV'
                links.new(textureCoordinates.outputs[coords], mapping.inputs['Vector'])
                textureCoordinates.location = (-1000, 300)

                if self.is_triplanar:
                    # add a single scaler for the triplanar scale
                    scaler = nodes.new(localizeText('ShaderNodeValue'))
                    scaler.label = 'Triplanar Scale'
                    scaler.outputs['Value'].default_value = 1
                    links.new(scaler.outputs['Value'], mapping.inputs['Scale'])

            def imageTexture(mapName, colorspace):
                img = None

                if self.ocio:
                    if colorspace == 'sRGB':
                        colorspace = 'srgb_texture'
                    else:
                        colorspace = 'Non-Color'

                if mapName in material_maps:
                    bestPath = sorted(
                        material_maps[mapName],
                        key=lambda i: self.supported_extensions.index(
                            i['map_ext']
                        )
                    )[0]['map_path']

                    try:
                        img = bpy.data.images.load(bestPath)
                        img.colorspace_settings.name = colorspace
                    except Exception as e:
                        log_error(e)

                def getOutputSocket(inputSocketLocation):
                    node = nodes.new(localizeText('ShaderNodeTexImage'))
                    node.location = inputSocketLocation + mathutils.Vector((-300, 0))
                    node.hide = True
                    node.image = img

                    # Special case for anisotropy angle, set the filter type to "closest"
                    if mapName == "anisotropyangle":
                        node.interpolation = 'Closest'

                    if self.is_triplanar:
                        node.projection = 'BOX' 

                    links.new(mapping.outputs['Vector'], node.inputs['Vector'])

                    return node.outputs['Color']

                return img and getOutputSocket
            
            
            def carpaintOrNeonMap(is_neon):
                def getOutputSocket(inputSocketLocation):

                    ramp_rgb_params = self.rampRgbParams.params
                    nb_keys = ramp_rgb_params['nb_keys'] if 'nb_keys' in ramp_rgb_params else 0
                    if nb_keys == 0:
                        return None

                    prefix = 'Neon ' if is_neon else 'Carpaint '
                    position = ramp_rgb_params['position']
                    color = ramp_rgb_params['color']

                    ramp = nodes.new(localizeText('ShaderNodeValToRGB'))
                    ramp.label = prefix + 'Ramp'
                    ramp.location = inputSocketLocation + mathutils.Vector((-300, 0))
                    ramp.hide = True
                    elements = ramp.color_ramp.elements

                    # remove the second default key
                    elements.remove(elements[1])
                    # set the first one to the first color and position
                    elements[0].color = (color[0]['r'], color[0]['g'], color[0]['b'], 1)
                    elements[0].position = position[0]
                    # add the remaining keys
                    for i in range(nb_keys-1):
                        new_el = elements.new(i+1)
                        new_el.position = position[i+1]
                        new_el.color = (color[i+1]['r'], color[i+1]['g'], color[i+1]['b'], 1)

                    ramp.color_ramp.interpolation = 'CARDINAL'

                    # dot of normal and eye as the ramp factor:
                    incidence = nodes.new(localizeText('ShaderNodeVectorMath'))
                    incidence.label = prefix + 'Incidence'
                    incidence.operation = 'DOT_PRODUCT'

                    geometry_node = nodes.new(localizeText('ShaderNodeNewGeometry'))
                    links.new(geometry_node.outputs['Normal'], incidence.inputs[0])
                    links.new(geometry_node.outputs['Incoming'], incidence.inputs[1])

                    # falloff parameter
                    falloff = nodes.new(localizeText('ShaderNodeValue'))
                    falloff.label = prefix + 'Falloff'
                    falloff.outputs['Value'].default_value = ramp_rgb_params['falloff']
                    # power the facing ratio by the falloff parameter
                    power = nodes.new(localizeText('ShaderNodeMath'))
                    power.operation = 'POWER'
                    links.new(incidence.outputs['Value'], power.inputs[0])
                    links.new(falloff.outputs['Value'], power.inputs[1])
                    # power output to the ramp shader
                    links.new(power.outputs['Value'], ramp.inputs['Fac'])

                    if is_neon:
                        # multiply the ramp output by the neon color
                        #mul = nodes.new(localizeText('ShaderNodeMath'))
                        mul = nodes.new(localizeText('ShaderNodeVectorMath'))
                        mul.operation = 'MULTIPLY'
                        neon_color = nodeRGB(ramp_rgb_params['neon_color'], 'Neon Color')
                        links.new(neon_color(mul.location), mul.inputs[0])
                        links.new(ramp.outputs['Color'], mul.inputs[1])
                        # return the multiply node, that will plug into the emission color
                        #return mul.outputs['Value']
                        return mul.outputs['Vector']
                    else:
                        # return the ramp node, that will plug into the base color
                        return ramp.outputs['Color']
                
                return getOutputSocket
            

            def roughnessWeight():
                roughness = imageTexture('roughness', 'Non-Color')
                def getOutputSocket(inputSocketLocation):
                    if not roughness:
                        return None
                    # Multiply the roughness by the carpaint weight
                    weight = nodes.new(localizeText('ShaderNodeValue'))
                    weight.label = 'Weight'
                    weight.outputs['Value'].default_value = self.rampRgbParams.params['carpaint_roughness_weight']
                    mul = nodes.new(localizeText('ShaderNodeVectorMath'))
                    mul.operation = 'MULTIPLY'
                    links.new(weight.outputs['Value'], mul.inputs[0])
                    links.new(roughness(mathutils.Vector((0, 0))), mul.inputs[1])
                    return mul.outputs['Vector']
                # returning the "and" we return None on a void texture, instead of the function
                return roughness and getOutputSocket


            def normalMap(getInputSocket, getStrengthInputSocket):
                def getOutputSocket(inputSocketLocation):
                    location = inputSocketLocation + mathutils.Vector((-200, 0))

                    node = nodes.new(localizeText('ShaderNodeNormalMap'))

                    node.location = location
                    node.hide = True

                    node.space = 'TANGENT'
                    if getStrengthInputSocket:
                        links.new(getStrengthInputSocket(
                            location + mathutils.Vector((0, -50))), node.inputs['Strength'])
                    else:
                        node.inputs['Strength'].default_value = 1.0
                    links.new(getInputSocket(location), node.inputs['Color'])

                    self.normalMapParams.SetAll(node)

                    return node.outputs['Normal']

                return getInputSocket and getOutputSocket

            def mixColors(blendType, getFactor, getInputSocket1, getInputSocket2):
                def getOutputSocket(inputSocketLocation):
                    node = nodes.new(localizeText('ShaderNodeMix'))
                    node.location = inputSocketLocation + \
                        mathutils.Vector((-200, 50))
                    node.hide = True
                    node.data_type = 'RGBA'
                    node.blend_type = blendType
                    node.clamp_result = True
                    node.clamp_factor = True

                    factor = getFactor(node.location)
                    factorInput = self.getEnabledSocket(
                        node.inputs, 'Factor')
                    if isinstance(factor, bpy.types.NodeSocket):
                        links.new(factor, factorInput)
                    else:
                        factorInput.default_value = factor

                    links.new(getInputSocket1(node.location + mathutils.Vector((0, 50))),
                              self.getEnabledSocket(node.inputs, 'A'))
                    links.new(getInputSocket2(node.location),
                              self.getEnabledSocket(node.inputs, 'B'))

                    return self.getEnabledSocket(node.outputs, 'Result')

                return getOutputSocket

            def multiplyColors(getInputSocket1, getInputSocket2):
                return mixColors('MULTIPLY', lambda _: 1, getInputSocket1, getInputSocket2)

            def multiply(data_type, getInputSocket1, getInputSocket2):
                if getInputSocket1 and getInputSocket2:
                    def getMixOutputSocket(inputSocketLocation):
                        location = inputSocketLocation + \
                            mathutils.Vector((-200, 0))

                        node = nodes.new(localizeText('ShaderNodeMix'))
                        node.data_type = data_type
                        node.blend_type = 'MULTIPLY'
                        node.location = location
                        node.hide = True

                        self.getEnabledSocket(
                            node.inputs, 'Factor').default_value = 1
                        links.new(getInputSocket1(location),
                                  self.getEnabledSocket(node.inputs, 'A'))
                        links.new(getInputSocket2(
                            location + mathutils.Vector((0, -130))), self.getEnabledSocket(node.inputs, 'B'))

                        return self.getEnabledSocket(node.outputs, 'Result')
                    return getMixOutputSocket
                else:
                    return getInputSocket1 or getInputSocket2

            def valueOutput(val, label):
                def getOutputSocket(inputSocketLocation):
                    node = nodes.new(localizeText('ShaderNodeValue'))
                    node.location = inputSocketLocation + \
                        mathutils.Vector((-200, 0))
                    node.hide = False
                    node.label = label
                    outputSocket = node.outputs['Value']
                    outputSocket.default_value = val
                    return outputSocket

                return getOutputSocket

            def nodeRGB(color, label):
                def getOutputSocket(inputSocketLocation):
                    node = nodes.new(localizeText('ShaderNodeRGB'))
                    node.location = inputSocketLocation + \
                        mathutils.Vector((-200, 0))
                    node.hide = False
                    node.label = label
                    color_output_socket = node.outputs['Color']
                    out = color_output_socket.default_value
                    out[0] = color['r']
                    out[1] = color['g']
                    out[2] = color['b']
                    out[3] = color['a'] if ('a' in color) else 1.0

                    return color_output_socket

                return getOutputSocket

            def mapRange(getInputSocket, toMinMax):
                def getOutputSocket(inputSocketLocation):
                    node = nodes.new(localizeText('ShaderNodeMapRange'))
                    node.location = inputSocketLocation + \
                        mathutils.Vector((-200, 0))
                    node.hide = True

                    node.data_type = 'FLOAT'
                    node.interpolation_type = 'LINEAR'
                    node.clamp = True

                    links.new(getInputSocket(node.location),
                              self.getEnabledSocket(node.inputs, 'Value'))

                    self.getEnabledSocket(
                        node.inputs, 'From Min').default_value = 0
                    self.getEnabledSocket(
                        node.inputs, 'From Max').default_value = 0
                    self.getEnabledSocket(
                        node.inputs, 'To Min').default_value = toMinMax[0]
                    self.getEnabledSocket(
                        node.inputs, 'To Max').default_value = toMinMax[1]

                    return self.getEnabledSocket(node.outputs, 'Result')

                return getOutputSocket

            def attachPBSDFnode(socketY, inputSocketName, getInputSocket):
                if getInputSocket:
                    output = getInputSocket(mathutils.Vector((0, socketY)))
                    links.new(output, pbsdf.inputs[inputSocketName])
                    return lambda _: output
                else:
                    return None
                
            def getShaderNodes(shader, depth, out_shaders):
                found = False
                for s in out_shaders:
                    if s[0] == shader:
                        found = True
                        # keep the max depth of the shader, if shared
                        if depth > s[1]:
                            s[1] = depth
                        break
                if not found:
                    out_shaders.append([shader, depth])
                depth+=1
                for input in shader.inputs:
                    if input.is_linked:
                        input_shader = input.links[0].from_node
                        getShaderNodes(input_shader, depth, out_shaders)

            def sortShadersByDepth(shaders, shaders_by_depth):
                for s in shaders:
                    found = False
                    for s2 in shaders_by_depth:
                        if s2[0] == s[1]:
                            s2.append(s[0])
                            found = True
                            break
                    if not found:
                        shaders_by_depth.append([s[1], s[0]])

                shaders_by_depth.sort(key=lambda x: x[0])

            def renameImageNodes(shader):
                if shader.type == "TEX_IMAGE":
                    path = shader.image.filepath
                    path = path.split('.')[0]
                    for known_map in self.known_maps[0]:
                        if path.endswith("_" + known_map):
                            index = self.known_maps[0].index(known_map)
                            label = self.known_maps[1][index]
                            shader.label = label
                            break

                for input in shader.inputs:
                    if input.is_linked:
                        input_shader = input.links[0].from_node
                        renameImageNodes(input_shader)

            def reorderShaderGraph(mat):
                h_spacing, v_spacing = 240, 100
                material_output = mat.node_tree.nodes.get(localizeText("Material Output"))
                shaders, shaders_by_depth = [], []
                getShaderNodes(material_output, 0, shaders)
                # shaders contains
                # [Material Output, 0],
                # [Principled BSDF, 1],
                # [Tex0, 2],
                # [Tex1, 2],
                # [Displacement, 1]
                # and so on, that is the raw list of shaders and their max depth in the graph
                sortShadersByDepth(shaders, shaders_by_depth)
                # shaders_by_depth contains
                # [0, Material Output],
                # [1, Principled BSDF, Displacement]
                # [2, Tex0, Tex1, ... whatever connected to Principled BSDF and Displacement]
                # and so on, up to the max depth of the graph
                
                max_height = 0
                for s in shaders_by_depth:
                    max_height = max(max_height, len(s)-1)
                max_pixel_height = (max_height + 1) * v_spacing

                material_output_location = shaders_by_depth[0][1].location
                location_base_y = material_output_location.y + max_pixel_height / 2                

                level = 0
                for s in shaders_by_depth:
                    depth = s[0]
                    loc_x = -h_spacing * depth + material_output_location.x
                    pixels_per_node_at_this_depth = max_pixel_height / len(s)

                    for i in range(1, len(s)):
                        node = s[i]
                        loc_y = location_base_y - pixels_per_node_at_this_depth * i
                        # special spacing for the bsdf and displacement in case the graph
                        # is not high enough
                        if level == 1 and len(s) == 3 and max_height < 8:
                            loc_y = material_output_location.y + (loc_y - material_output_location.y) * 1.7

                        if level < 3 or not hasPuzzleMatte:
                            node.location = (loc_x, loc_y)

                        hide = node.type not in ['BSDF_PRINCIPLED', 'OUTPUT_MATERIAL']
                        node.hide = hide
                        node.width = node.bl_width_min * 1.33

                    level+= 1 

                # finally rename the image nodes to their labels
                renameImageNodes(material_output)


            getPuzzlematteOutput = imageTexture('puzzlematte', 'Non-Color')
            hasPuzzleMatte = False
            getBaseColorOutput = None
            base_color_set = False

            is_transmissive = False
            is_carpaint = False
            is_neon = False
            if 'subtype' in meta:
                subtype = meta['subtype']
                is_transmissive = subtype in ['liquid', 'glass', 'transparent']
                is_carpaint = subtype == 'carpaint'
                is_neon = subtype == 'neon'

            if is_carpaint:
                getBaseColorOutput = carpaintOrNeonMap(False)
                # Mutually exclude the carpaint and transmission cases,
                # just in case the subtype is not set correctly
                is_transmissive = False
                base_color_set = True

            transmissionWeightMapOutputSocket = imageTexture('transweight', 'Non-Color')
            if is_transmissive:
                # Handle transmission for liquid and glass subtypes
                transmission = self.standardSurfaceParams.params["transmission"]
                has_transmission_weight_map = transmissionWeightMapOutputSocket is not None

                # If transmission is enabled, we want to override the base color with the transmission color,
                # not having the principled shader a transmission color input
                if transmission > 0 or has_transmission_weight_map:
                    transmissionColorMapOutputSocket = imageTexture('transcolor', 'sRGB')
                    has_transmission_color_map = transmissionColorMapOutputSocket is not None
                    if has_transmission_color_map:
                        # Override the base color output socket
                        getBaseColorOutput = transmissionColorMapOutputSocket
                    else:
                        # Set the base color to the transmission color
                        color = self.standardSurfaceParams.Get("transmission_color")
                        self.standardSurfaceParams.Set(pbsdf, 'Base Color', color)

                    base_color_set = True

                elif is_version_4_or_later:
                    # Another special case, if transmission is not enabled, but we have subsurface enabled
                    # and the base is 0, indicating for some materials like White Chocolate that the
                    # subsurface color is dominant and to be used as the base color, not being subsurface color
                    # exposed anymore in Blender 4.0.
                    subsurface = self.standardSurfaceParams.params["subsurface"]
                    base = self.standardSurfaceParams.params["base"]
                    if base == 0 and subsurface > 0:
                        subsurface_color = self.standardSurfaceParams.Get("subsurface_color")
                        self.standardSurfaceParams.Set(pbsdf, 'Base Color', subsurface_color)
                        base_color_set = True

            if not base_color_set:
                getBaseColorOutput = imageTexture('basecolor', 'sRGB')

            if getBaseColorOutput and getPuzzlematteOutput:
                hasPuzzleMatte = True
                getRegularBaseColorOutput = getBaseColorOutput

                def getOutputSocket(inputSocketLocation):

                    def metaPuzzlematteValue(name, defaultValue):
                        return meta_value(['standardsurface', 'base', 'puzzlemattecolors', name], defaultValue)

                    def puzzlematteColorNode(label, name, defaultColor):
                        color = metaPuzzlematteValue(name, None)
                        return nodeRGB(color if isColor(color) else defaultColor, label)

                    baseColor   = puzzlematteColorNode('Base Color',    'baseColor',   {'r':0.694, 'g':0.694, 'b':0.694})
                    layer1Color = puzzlematteColorNode('Layer 1 Color', 'layer1Color', {'r':0.0,   'g':0.078, 'b':0.133})
                    layer2Color = puzzlematteColorNode('Layer 2 Color', 'layer2Color', {'r':0.509, 'g':0.392, 'b':0.246})
                    layer3Color = puzzlematteColorNode('Layer 3 Color', 'layer3Color', {'r':1.0,   'g':0.15,  'b':0.102})

                    separateColorsNode = nodes.new(localizeText('ShaderNodeSeparateColor'))
                    separateColorsNode.location = inputSocketLocation + \
                        mathutils.Vector((-600, 50))
                    separateColorsNode.hide = True
                    separateColorsNode.mode = 'RGB'
                    links.new(getPuzzlematteOutput(
                        separateColorsNode.location), separateColorsNode.inputs['Color'])
                    separateColorsOutputs = separateColorsNode.outputs

                    def layer(maskChannel, getColor1, getColor2):
                        return lambda location: mixColors('MIX', lambda _: self.getEnabledSocket(separateColorsOutputs, maskChannel), getColor1, getColor2)(location + mathutils.Vector((200, -50)))

                    layer1 = layer('Red',   baseColor, layer1Color)
                    layer2 = layer('Green', layer1,    layer2Color)
                    layer3 = layer('Blue',  layer2,    layer3Color)

                    baseColorOutput = getRegularBaseColorOutput(inputSocketLocation + mathutils.Vector((-400, 0)))

                    return mixColors('MIX',
                                     valueOutput(metaPuzzlematteValue('enabled', True), 'Use Puzzlematte'),
                                     lambda _: baseColorOutput,
                                     multiplyColors(layer3, lambda _: baseColorOutput)
                                     )(inputSocketLocation)

                getBaseColorOutput = getOutputSocket

            baseColorOutput = (getBaseColorOutput
                               and
                               attachPBSDFnode(187, 'Base Color',
                                               multiply('RGBA', getBaseColorOutput, imageTexture('ambientocclusion', 'Non-Color'))))

            getSubsurfaceWeight = imageTexture('scatteringweight', 'Non-Color')
            getSubsurfaceRadius = imageTexture('scatteringdistancescale', 'Non-Color') or (getSubsurfaceWeight and valueOutput(.5, 'Subsurface Radius'))
            getSubsurfaceRadiusScale = imageTexture('scatteringdistance', 'Non-Color')

            input_name = 'Subsurface Weight' if is_version_4_or_later else 'Subsurface'
            attachPBSDFnode(172, input_name, getSubsurfaceWeight)

            if getSubsurfaceRadius:
                sssScaleCoeff = 1.0 if is_version_4_or_later else 0.01
                attachPBSDFnode(151, 'Subsurface Radius',
                                multiply('RGBA', getSubsurfaceRadius,
                                         multiply('FLOAT', getSubsurfaceRadiusScale, valueOutput(sssScaleCoeff, 'Subsurface Scale Coefficient'))))

            # In blender 4 SSS now uses the Base Color directly and Subsurface Color has been removed
            if not is_version_4_or_later:
                if getSubsurfaceWeight or getSubsurfaceRadius:
                    attachPBSDFnode(129, 'Subsurface Color', baseColorOutput)

            attachPBSDFnode(56, 'Metallic', imageTexture('metallic', 'Non-Color'))

            input_name = 'Specular IOR Level' if is_version_4_or_later else 'Specular'
            attachPBSDFnode(12, input_name, imageTexture('specularlevel', 'Non-Color'))
 
            if is_carpaint:
                attachPBSDFnode(-10, 'Roughness', roughnessWeight())
            else:
                attachPBSDFnode(-10, 'Roughness', imageTexture('roughness', 'Non-Color'))

            attachPBSDFnode(-30, 'Anisotropic', imageTexture('anisotropylevel', 'Non-Color'))
            attachPBSDFnode(-50, 'Anisotropic Rotation', imageTexture('anisotropyangle', 'Non-Color'))

            input_name = 'Sheen Weight' if is_version_4_or_later else 'Sheen'
            attachPBSDFnode(-77, input_name, imageTexture('sheenopacity', 'Non-Color'))

            if is_version_4_or_later:
                attachPBSDFnode(-98, 'Sheen Tint', imageTexture('sheencolor', 'sRGB'))

            input_name = 'Coat Weight' if is_version_4_or_later else 'Clearcoat'
            attachPBSDFnode(-119, input_name, imageTexture('coatopacity', 'Non-Color'))

            input_name = 'Coat Roughness' if is_version_4_or_later else 'Clearcoat Roughness'
            attachPBSDFnode(-142, input_name, imageTexture('coatroughness', 'Non-Color'))

            attachPBSDFnode(-160, 'IOR', imageTexture('indexofrefraction', 'Non-Color'))
            attachPBSDFnode(-209, 'Transmission Roughness', None)

            input_name = 'Emission Color' if is_version_4_or_later else 'Emission'
            emission = carpaintOrNeonMap(True) if is_neon else imageTexture('emissioncolor', 'sRGB')
            attachPBSDFnode(-222, input_name, emission)

            attachPBSDFnode(-246, 'Emission Strength', imageTexture('emissionintensity', 'Non-Color'))
            attachPBSDFnode(-275, 'Alpha', imageTexture('opacity', 'Non-Color'))

            getNormalMapOutputSocket = normalMap(imageTexture('normal', 'Non-Color'), None)
            getNormalWrinkleMapOutputSocket = normalMap(imageTexture('normal-wrinkle', 'Non-Color'), None)
            if getNormalWrinkleMapOutputSocket:
                getRegularNormalMapOutputSocket = getNormalMapOutputSocket

                def getVectorRotateOutputSocket(inputSocketLocation):
                    rotateNode = nodes.new(localizeText('ShaderNodeVectorRotate'))
                    rotateNode.location = inputSocketLocation + \
                        mathutils.Vector((-200, 0))

                    rotateNode.rotation_type = 'AXIS_ANGLE'
                    rotateNode.invert = False
                    links.new(getRegularNormalMapOutputSocket(
                        rotateNode.location + mathutils.Vector((0, -120))), rotateNode.inputs['Vector'])
                    center = rotateNode.inputs['Center'].default_value
                    center[0] = center[1] = center[2] = 0.0

                    crossProductNode = nodes.new(localizeText('ShaderNodeVectorMath'))
                    crossProductNode.hide = True
                    crossProductNode.location = rotateNode.location + \
                        mathutils.Vector((-200, -190))
                    crossProductNode.operation = 'CROSS_PRODUCT'
                    normalWrinkleMapOutputSocket = getNormalWrinkleMapOutputSocket(
                        crossProductNode.location + mathutils.Vector((-200, 0)))
                    links.new(normalWrinkleMapOutputSocket,
                              self.getEnabledSocket(crossProductNode.inputs, 'Vector', 1))

                    texCoordNode = nodes.new(localizeText('ShaderNodeNewGeometry'))
                    texCoordNode.location = crossProductNode.location + \
                        mathutils.Vector((-400, -50))
                    texCoordNodeOutput = texCoordNode.outputs['Normal']

                    links.new(texCoordNodeOutput,
                              self.getEnabledSocket(crossProductNode.inputs, 'Vector', 0))
                    links.new(
                        crossProductNode.outputs['Vector'], rotateNode.inputs['Axis'])

                    angleMulNode = nodes.new(localizeText('ShaderNodeMath'))
                    angleMulNode.hide = True
                    angleMulNode.location = rotateNode.location + \
                        mathutils.Vector((-200, -230))
                    angleMulNode.operation = 'MULTIPLY'
                    links.new(
                        angleMulNode.outputs['Value'], rotateNode.inputs['Angle'])

                    wrinkleWeightNode = nodes.new(localizeText('ShaderNodeValue'))
                    wrinkleWeightNode.location = angleMulNode.location + \
                        mathutils.Vector((-200, -100))
                    wrinkleWeightNode.hide = False
                    wrinkleWeightNode.label = "Wrinkle Weight"
                    wrinkleWeightNodeOutput = wrinkleWeightNode.outputs['Value']
                    wrinkleWeightNodeOutput.default_value = 0.15
                    links.new(wrinkleWeightNodeOutput, self.getEnabledSocket(
                        angleMulNode.inputs, 'Value', 1))

                    arcCosNode = nodes.new(localizeText('ShaderNodeMath'))
                    arcCosNode.hide = True
                    arcCosNode.location = angleMulNode.location + \
                        mathutils.Vector((-200, 0))
                    arcCosNode.operation = 'ARCCOSINE'
                    links.new(arcCosNode.outputs['Value'], self.getEnabledSocket(
                        angleMulNode.inputs, 'Value', 0))

                    dotProductNode = nodes.new(localizeText('ShaderNodeVectorMath'))
                    dotProductNode.hide = True
                    dotProductNode.location = arcCosNode.location + \
                        mathutils.Vector((-200, 0))
                    dotProductNode.operation = 'DOT_PRODUCT'
                    links.new(dotProductNode.outputs['Value'], self.getEnabledSocket(
                        arcCosNode.inputs, 'Value'))
                    links.new(normalWrinkleMapOutputSocket, self.getEnabledSocket(
                        dotProductNode.inputs, 'Vector', 0))

                    links.new(texCoordNodeOutput, self.getEnabledSocket(
                        dotProductNode.inputs, 'Vector', 1))

                    return rotateNode.outputs['Vector']

                getNormalMapOutputSocket = getVectorRotateOutputSocket

            attachPBSDFnode(-290, 'Normal', getNormalMapOutputSocket)

            coat_normal_port = 'Coat Normal' if is_version_4_or_later else 'Clearcoat Normal'
            attachPBSDFnode(-312, coat_normal_port,
                            normalMap(imageTexture('coatnormal', 'Non-Color'), imageTexture('coatnormalscale', 'Non-Color')))

            getHeightMapOutputSocket = imageTexture('height', 'Non-Color')
            if getHeightMapOutputSocket:
                if bpy.app.version >= (4, 1, 0):
                    mat.displacement_method = 'BOTH'
                else:
                    mat.cycles.displacement_method = 'BOTH'

                node = nodes.new(localizeText('ShaderNodeDisplacement'))
                node.location = mathutils.Vector((300, 130))

                links.new(getHeightMapOutputSocket(mathutils.Vector((600, -90))), node.inputs['Height'])
                links.new(node.outputs['Displacement'], nodes[localizeText('Material Output')].inputs['Displacement'])

                # Height Scale can source from the json
                heightScale = meta_value(['standardsurface', 'height', 'scale'], None)
                heightScaleValue = heightScale if isNumeric(heightScale) else 1
                heightScaleValue*= 0.01
                heightScaleNode = valueOutput(heightScaleValue, 'Height Scale')
                links.new(heightScaleNode(node.location + mathutils.Vector((200, -400))), node.inputs['Scale'])

            input_name = 'Transmission Weight' if is_version_4_or_later else 'Transmission'
            attachPBSDFnode(-310, input_name, transmissionWeightMapOutputSocket)

            reorderShaderGraph(mat)
            self.on_material_loaded(material_name)
            return mat

        except Exception as e:
            if mat:
                self.on_material_error(material_name)
                bpy.data.materials.remove(mat)
                error_message = f"Error loading material: {material_name}"
                log_error(error_message)
                material_metadata["error"] = {
                    "error_code": error_codes['MaterialsNotLoaded'],
                    "message": error_message,
                    # "path_to_asset": path,
                }
            raise e


    def load_materials(self, payload):
        path = payload.get('path')
        materials_metadata = self.get_materials_from_path(path)
        on_materials_success = payload.get('on_success')
        on_materials_error = payload.get('on_error')

        # self.is_triplanar = IsTriplanar()
        self.is_triplanar = payload.get('triplanar')

        for material_metadata in materials_metadata:
            if 'error' not in material_metadata:
                self.add_material(material_metadata)

        # Iterate over all materials metadata having parsed and processed all of the materials metadata
        errors = []
        successes = []
        for material_metadata in materials_metadata:
            if 'error' in material_metadata:
                errors.append(material_metadata['error'])
            else:
                successes.append(
                    {"path_to_asset": material_metadata['path'].replace('\\', '/')})
        if errors:
            on_materials_error(errors)
        on_materials_success(successes)


class ModelLoader:
    def __init__(self):
        self.on_models_loaded = lambda: log_info("Models loaded.")
        self.on_models_error = lambda error: log_error(
            f"Error while loading models. {error}")
        self.on_model_loaded = lambda model_name: log_info(
            f"Model {model_name} loaded.")
        self.on_model_error = lambda model_name: log_error(
            f"Error loading model {model_name}.")
        self.on_no_assets_found = lambda path: log_info(
            f"No models found at path: {path}")

    def get_model_metadata(self, mesh_path):
        mesh_params = {}
        with open(mesh_path, mode='r', encoding='utf8') as f:
            meta = json.loads(f.read())
            mesh_params = {'meta': meta}
            dir_path = os.path.dirname(mesh_path)
            for entry in os.listdir(dir_path):
                if not entry.startswith('.'):
                    (root, ext) = os.path.splitext(
                        os.fsdecode(entry).lower()
                    )
            mesh_params['path'] = mesh_path
        base_name = os.path.basename(mesh_path)
        file_name, _ = os.path.splitext(base_name)
        return mesh_params

    def get_models_from_path(self, path):
        def process_path(path, models_metadata=[]):
            if os.path.isdir(path):
                for entry in os.listdir(path):
                    process_path(os.path.join(path, entry), models_metadata)
            else:
                (root, ext) = os.path.splitext(
                    os.path.basename(os.fsdecode(path)))
                if ext.lower() == '.gsga':
                    try:
                        model_metadata = self.get_model_metadata(path)
                        is_model = model_metadata.get(
                            'meta') and model_metadata.get('meta').get('type') == 'model'
                        if model_metadata and is_model:
                            model_metadata['name'] = root.replace(
                                ' ', '_').strip()
                            mesh_formats = ['.obj', '.fbx', '.abc']
                            mesh_directory = os.path.dirname(path)
                            for file_name in os.listdir(mesh_directory):
                                file_path = os.path.join(
                                    mesh_directory, file_name)
                                if os.path.isfile(file_path) and any(file_name.lower().endswith(format) for format in mesh_formats):
                                    model_metadata['mesh_path'] = file_path
                                    break
                            models_metadata.append(model_metadata)
                    except Exception:
                        error_message = f"Error retrieving models metadata at path: {path}"
                        log_error(error_message)
                        models_metadata.append({
                            'error': {
                                "error_code": error_codes['ModelsNotFound'],
                                "message": error_message,
                                "path_to_asset": path,
                            }
                        })

            return models_metadata

        models_metadata = process_path(path)
        if not len(models_metadata):
            error_message = f"No Greyscalegorilla models found at path: {path}"
            log_error(error_message)
            models_metadata.append({
                'error': {
                    "error_code": error_codes['ModelsNotFound'],
                    "message": error_message,
                    # "path_to_asset": path,
                }
            })

        return models_metadata

    def add_mesh(self, model_metadata):
        try:
            file_path = model_metadata['mesh_path']
            bpy.ops.object.select_all(action='DESELECT')
            file_extension = file_path.split('.')[-1].lower()
            import_operators = {
                'obj': bpy.ops.import_scene.obj,
                'fbx': bpy.ops.import_scene.fbx,
                'abc': bpy.ops.wm.alembic_import,
            }
            if file_extension in import_operators:
                import_operators[file_extension](filepath=file_path)
                imported_object = bpy.context.selected_objects[0]
                # Get the root node imported
                while imported_object.parent:
                    imported_object = imported_object.parent
                imported_object.name = model_metadata['name']
                self.on_model_loaded(model_metadata['name'])
        except Exception:
            error_message = "Error loading model"
            model_metadata['error'] = {
                "error_code": error_codes['ModelsNotLoaded'],
                "message": error_message,
                "path_to_asset": model_metadata['path']
            }
            self.on_model_error(model_metadata['name'])

    def load_models(self, payload):
        models_metadata = self.get_models_from_path(payload.get('path'))
        on_models_success = payload.get('on_success')
        on_models_error = payload.get('on_error')
        for model_metadata in models_metadata:
            if 'error' not in model_metadata:
                self.add_mesh(model_metadata)

        errors = []
        succeses = []
        for model_metadata in models_metadata:
            if 'error' in model_metadata:
                errors = [model_metadata['error']]
            else:
                succeses.append(
                    {"path_to_asset": model_metadata['path'].replace('\\', '/')})

        if errors:
            on_models_error(errors)
        on_models_success(succeses)


# This is used for Smartsending textured lights and surface imperfections, not actual material textures.
# Initially copied from the other loader classes, that could be simplified later as well.
#
class TextureLoader:
    def __init__(self):
        return
    
    def get_sequence_range(self, directory_path, extension):
        files = [f for f in os.listdir(directory_path) if f.lower().endswith(extension)]
        
        if not files:
            return False, '', 0, 0
            
        # Find files that match the pattern name_###.<extension>
        pattern = re.compile(r'(.+?)(\d+)\.(jpg|tif)$', re.IGNORECASE)
        
        first_file_name, first_frame, last_frame, matches = '', 100000, 0, 0

        for file in files:
            match = pattern.match(file)
            if match:
                if first_file_name == '':
                    first_file_name = file
                number = match.group(2)
                # Strip the leading zeros
                plain_number = int(number.lstrip('0') or '0')
                first_frame = min(first_frame, plain_number)
                last_frame = max(last_frame, plain_number)
                matches += 1

        return matches > 1, first_file_name, first_frame, last_frame

    def get_textures_from_path(self, path):
        def process_path(path, textures_metadata=[]):
            if os.path.isdir(path):
                for entry in os.listdir(path):
                    process_path(os.path.join(path, entry), textures_metadata)
            else:
                (root, ext) = os.path.splitext(os.path.basename(os.fsdecode(path)))
                if ext.lower() == '.gsga':
                    with open(path, mode='r', encoding='utf8') as f:
                        texture_meta = {}
                        json_meta = json.loads(f.read())
                        texture_type = json_meta.get('type')
                        is_texture = texture_type in ('hdri', 'texture', 'gobo')
                        is_sequence = False
                        if is_texture:
                            texture_meta['name'] = root.replace(' ', '_').strip()
                            texture_meta['type'] = texture_type
                            texture_directory = os.path.dirname(path)

                            if texture_type == 'gobo':
                                # Check if the texture is a jpg sequence
                                extension = '.jpg'
                                is_sequence, first_file_name, first, last = self.get_sequence_range(texture_directory, extension)
                                if not is_sequence:
                                    # Try again with .tif
                                    extension = '.tif'
                                    is_sequence, first_file_name, first, last = self.get_sequence_range(texture_directory, extension)

                                if is_sequence:
                                    if extension == '.jpg':
                                        texture_meta['jpg_texture_path'] = os.path.join(texture_directory, first_file_name)
                                    else:
                                        texture_meta['tif_texture_path'] = os.path.join(texture_directory, first_file_name)
                                    texture_meta['start_frame'] = first
                                    texture_meta['nb_frames'] = last - first

                            texture_meta['is_sequence'] = is_sequence
                            if not is_sequence:
                                # Collect the image files in this directory, regardless of the texture type
                                # defined in the gsga file.
                                for file_name in os.listdir(texture_directory):
                                    file_path = os.path.join(texture_directory, file_name)
                                    if os.path.isfile(file_path):
                                        lower_file_name = file_name.lower()
                                        # check if file_name contains 'preview'
                                        if 'preview' in lower_file_name:
                                            continue
                                        if lower_file_name.endswith('.hdr'):
                                            texture_meta['hdr_texture_path'] = file_path
                                        elif lower_file_name.endswith('.exr'):
                                            texture_meta['exr_texture_path'] = file_path
                                        elif lower_file_name.endswith('.jpg'):
                                            texture_meta['jpg_texture_path'] = file_path
                                        elif lower_file_name.endswith('.tif'):
                                            texture_meta['tif_texture_path'] = file_path

                            textures_metadata.append(texture_meta)

            return textures_metadata
        
        textures_metadata = process_path(path)
        if not len(textures_metadata):
            log_error(f"No texture found at path: {path}")

        return textures_metadata
    
    def load_surface_imperfections(self, in_path):
        si = SurfaceImperfections()
        if si.IsValid():
            si.Create(in_path)
        return

    def load_textures(self, payload):

        def get_best_texture(in_texture_metadata):
            path = ''
            # Look for the path independently from the type (hdri, gobo...), because any extension could be
            # used for any type of light. I think our current convention is to use
            # - hdri for dome and area light
            # - jpg for gobo
            # but nothing prevents to change this. For now, let's give precedence to hdr and exr.
            if 'hdr_texture_path' in in_texture_metadata:
                path = in_texture_metadata['hdr_texture_path']
            elif 'exr_texture_path' in in_texture_metadata:
                path = in_texture_metadata['exr_texture_path']
            elif 'jpg_texture_path' in in_texture_metadata:
                path = in_texture_metadata['jpg_texture_path']
            elif 'tif_texture_path' in in_texture_metadata:
                path = in_texture_metadata['tif_texture_path']
            else:
                log_error(f"No valid file extension found")
            return path
        
        # If in_user_selected is True, the new texture applies only to an existing light
        #
        def create_light_or_switch_texture(light, in_path, in_user_selected, in_start_frame=0, in_nb_frames=0):
            if in_user_selected:
                if not light.IsValid():
                    return
            else:
                light.Create()
            light.SetTexture(in_path, in_start_frame, in_nb_frames)


        # Called when Smartsending textured lights
        #
        def area_light(in_path, in_user_selected):
            create_light_or_switch_texture(BlAreaLight(True), in_path, in_user_selected)

        def gobo_light(in_path, in_user_selected, in_start_frame=0, in_nb_frames=0):
            create_light_or_switch_texture(BlGoboLight(True), in_path, in_user_selected, in_start_frame, in_nb_frames)

        def dome_light(in_path, in_user_selected):
            create_light_or_switch_texture(BlDomeLight(), in_path, in_user_selected)

 
        path = payload.get('path')
        # user_selected (for lights) is False in case of Send, True if the texture is selected in the Studio UI
        user_selected = payload.get('user_selected')
        surface_imperfections = payload.get('surface_imperfections')
        textures_metadata = self.get_textures_from_path(path)

        # Surface Imperfections:
        if surface_imperfections:
            for texture_metadata in textures_metadata:
                path = get_best_texture(texture_metadata)
                if not path == '':
                    self.load_surface_imperfections(path)
            return
        
        # Textured Lights:
        for texture_metadata in textures_metadata:
            path = get_best_texture(texture_metadata)
            if path == '':
                continue

            # If the texture is a sequence, we need to set the start frame and number of frames
            start_frame = 0
            nb_frames = 0
            if texture_metadata.get('is_sequence'):
                start_frame = texture_metadata.get('start_frame')
                nb_frames = texture_metadata.get('nb_frames')

            if texture_metadata['type'] == 'hdri':
                dome_light(path, user_selected)
            elif texture_metadata['type'] == 'texture':
                area_light(path, user_selected)
            elif texture_metadata['type'] == 'gobo':
                gobo_light(path, user_selected, start_frame, nb_frames)


class GreyscalegorillaSocketsServer(threading.Thread):
    def __init__(self, port):
        threading.Thread.__init__(self)
        self.threading_event = threading.Event()
        self.host = "localhost"
        self.port = port
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.connections = []
        self.material_loader = MaterialLoader()
        self.model_loader = ModelLoader()
        self.texture_loader = TextureLoader()
        self.task_manager = TaskManager()
        self.task_manager.register('load_model', self.model_loader.load_models)
        self.task_manager.register('load_material', self.material_loader.load_materials)
        self.task_manager.register('load_texture', self.texture_loader.load_textures)
        self.running = False

    def is_running(self):
        return self.running

    def run(self):
        try:
            self.socket.bind((self.host, self.port))
            self.socket.listen(5)
            log_info(f"Running on port: {self.port}")
            self.running = True
            self.exit_handler = ExitHandler(self.threading_event)
            self.exit_handler.start()
            self.accept_connections()
        except socket.error as e:
            win_connection_error = hasattr(
                errno, 'ENOTSOCK') and errno.ENOTSOCK == e.errno
            unix_connection_error = hasattr(
                errno, 'WSAENOTSOCK') and errno.WSAENOTSOCK == e.errno
            if win_connection_error or unix_connection_error:
                pass
        except Exception:
            log_error(
                f"Server could not start at port: {self.port}." +
                "Please make sure this is the only Blender instance running " +
                "and there\'s no other applications occupying this port."
            )

    def accept_connections(self):
        while not self.threading_event.is_set():
            try:
                client_socket, client_address = self.socket.accept()
                log_info("Connection with Studio established.")
                log_debug(f"Connected to client at {client_address}")

                self.client_handler = threading.Thread(
                    target=self.handle_client, args=(client_socket, client_address))
                self.client_handler.start()
                self.connections.append(
                    (self.client_handler, client_socket, client_address))
            except socket.error as e:
                win_connection_error = hasattr(
                    errno, 'ENOTSOCK') and errno.ENOTSOCK == e.errno
                unix_connection_error = hasattr(
                    errno, 'WSAENOTSOCK') and errno.WSAENOTSOCK == e.errno
                if win_connection_error or unix_connection_error:
                    break

    def handle_client(self, client_socket, client_address):
        try:
            ack_message = protocol.encode_message(
                "greyscalegorillaconnect-connected")
            client_socket.sendall(ack_message)
            while not self.threading_event.is_set():
                messages = self.receive_messages(client_socket)
                if not messages:
                    continue
                self.handle_messages(messages, client_socket, client_address)
        except socket.error as e:
            win_connection_reset = hasattr(
                errno, 'ECONNRESET') and errno.ECONNRESET == e.errno
            unix_connection_reset = hasattr(
                errno, 'WSAECONNRESET') and errno.WSAECONNRESET == e.errno
            if win_connection_reset or unix_connection_reset:
                log_info("Sockets connection aborted.")
            else:
                log_error(f"Error with client {client_address}: {e}")
        finally:
            log_debug(f"Client {client_address} disconnected")
            log_info("Studio disconnected.")
            client_socket.close()
            self.remove_connection(client_address)

    def receive_messages(self, client_socket):
        try:
            data = client_socket.recv(1024)
            return protocol.decode_buffer(data)
        except CRCException:
            self.handle_crc_exception(client_socket)
            return []

    def handle_messages(self, messages, client_socket, client_address):
        if len(messages) == 0:
            return
        message = messages.pop(0)
        if message == "ping":
            response = "pong"
            encoded_message = protocol.encode_message(response)
            client_socket.sendall(encoded_message)
        elif message == "exit Greyscalegorilla Connect":
            self.stop()
        else:
            try:
                asset_metadata = json.loads(message)
                is_blender = asset_metadata.get('dcc') == 'BLENDER'
                has_path_to_asset = asset_metadata.get('path_to_asset')
                has_triplanar = asset_metadata.get('triplanar') is not None
                is_valid_asset = asset_metadata.get('asset_type') in ('MODEL', 'MATERIAL', 'HDRI', 'TEXTURE')
                event_type = asset_metadata.get('event_type')
                asset_type = asset_metadata.get('asset_type')
                is_surface_imperfections = False
                tags = asset_metadata.get('tags')
                if tags and asset_type == 'TEXTURE':
                    for tag in tags:
                        if 'imperfection' in tag.lower():
                            is_surface_imperfections = True
                            break

                if is_blender and (not has_path_to_asset or not is_valid_asset):
                    raise Exception()
            except Exception:
                errors = [{
                    "error_code": error_codes['InvalidMetadata'],
                    "message": 'Invalid JSON message.',
                }]
                self.on_task_error(client_socket, errors)
                self.on_task_completed(client_socket, [])

            if asset_metadata.get('dcc') != 'BLENDER':
                return
            if asset_type == 'MODEL' and asset_metadata.get('path_to_asset'):
                self.task_manager.add('load_model', {
                    "path": asset_metadata.get('path_to_asset'),
                    "on_success": lambda completed: self.on_task_completed(client_socket, completed),
                    "on_error": lambda errors: self.on_task_error(client_socket, errors)
                })
            elif asset_type == 'MATERIAL' and asset_metadata.get('path_to_asset'):
                self.task_manager.add('load_material', {
                    "path": asset_metadata.get('path_to_asset'),
                    "triplanar": asset_metadata.get('triplanar') if has_triplanar else False,
                    "on_success": lambda completed: self.on_task_completed(client_socket, completed),
                    "on_error": lambda errors: self.on_task_error(client_socket, errors)
                })
            elif asset_type in ('HDRI', 'TEXTURE') and asset_metadata.get('path_to_asset'):
                self.task_manager.add('load_texture', {
                    "path": asset_metadata.get('path_to_asset'),
                    "user_selected": event_type == 'UserSelected' if event_type else False,
                    "surface_imperfections": is_surface_imperfections
                })


    def remove_connection(self, client_address):
        for i, (_, _, addr) in enumerate(self.connections):
            if addr == client_address:
                del self.connections[i]
                break

    def stop(self):
        self.threading_event.set()
        for _, sock, _ in self.connections:
            sock.close()
        self.socket.close()

    def on_task_completed(self, client_socket, succeses):
        log_debug('Replying...')
        response = {
            "type": "completed",
            "version": MESSAGING_VERSION,
            "completed": succeses,
        }
        encoded_message = protocol.encode_message(
            json.dumps(response, indent=4))
        client_socket.sendall(encoded_message)

    def on_task_error(self, client_socket, errors):
        for error in errors:
            if error.get('path_to_asset'):
                error['path_to_asset'] = error['path_to_asset'].replace(
                    '\\', '/')
            if error.get('message'):
                error['message'] = error['message'].replace(
                    '\\', '/')
        plus_app_response = {
            "type": "error",
            "version": MESSAGING_VERSION,
            "errors": errors,
        }
        encoded_response = protocol.encode_message(
            json.dumps(plus_app_response, indent=4))
        client_socket.sendall(encoded_response)


class ExitHandler(threading.Thread):
    def __init__(self, exit_event):
        self.exit_event = exit_event
        threading.Thread.__init__(self)

    def run(self):
        try:
            run_checker = True
            port_number = int(
                bpy.context.preferences.addons["greyscalegorilla_blender_connect"].preferences.sockets_port)
            while run_checker and not self.exit_event.is_set():
                time.sleep(1)
                for i in threading.enumerate():
                    if (i.getName() == "MainThread" and not i.is_alive()):
                        host, port = 'localhost', port_number
                        s = socket.socket()
                        s.connect((host, port))
                        data = "exit Greyscalegorilla Connect"
                        s.send(protocol.encode_message(data))
                        log_debug('Sending exit message')
                        s.close()
                        run_checker = False
                        break
        except Exception as e:
            log_debug(
                f"Greyscalegorilla Connect - Error initializing ExitHandler. Error: str(e)")


class GreyscalegorillaConnect(bpy.types.Operator):

    bl_idname = "greyscalegorilla.connect"
    bl_label = "Greyscalegorilla Connect"

    def execute(self, context):
        try:
            try:
                port_number = int(
                    bpy.context.preferences.addons["greyscalegorilla_blender_connect"].preferences.sockets_port)
            except ValueError:
                pass
            if port_number < 1024 or port_number > 49151:
                error_message = 'Greyscalegorilla Connect: Please select a number between 1024 and 49151'
                log_error(error_message)
                return
            if not globals().get('server'):
                globals()['server'] = GreyscalegorillaSocketsServer(
                    port_number)
            bpy.app.timers.register(
                globals()['server'].task_manager.check_pending)
            if not globals()['server'].is_running():
                globals()['server'].start()
            return {'FINISHED'}
        except Exception as e:
            log_error(
                f"Can not start Greyscalegorilla Connect. Error: {str(e)}")
            return {"CANCELLED"}


class GreyscalegorillaDisconnect(bpy.types.Operator):
    bl_idname = "greyscalegorilla.disconnect"
    bl_label = "Disconnect to Socket Server"
    bl_description = "Disconnect from Greyscalegorilla Connect"

    def execute(self, context):
        if globals().get('server'):
            log_debug('Addon stopped')
            globals()['server'].stop()
            del globals()['server']

        return {'FINISHED'}


class GreyscalegorillaReconnect(bpy.types.Operator):

    bl_idname = "greyscalegorilla.reconnect"
    bl_label = "Greyscalegorilla Reconnect"
    bl_description = "Restart Greyscalegorilla Connect at specified port"

    def execute(self, context):
        try:
            if globals().get('server'):
                globals()['server'].stop()
                del globals()['server']
            bpy.ops.greyscalegorilla.connect()
        except Exception:
            return {'FINISHED'}

        return {'FINISHED'}


class GSGBridgePreferencesPanel(bpy.types.AddonPreferences):
    bl_idname = __name__
    sockets_port: bpy.props.StringProperty(
        default="8910",
        description="Port number used to communicate with Greyscalegorilla Studio."
    )

    def draw(self, context):
        layout = self.layout
        layout.operator(
            "greyscalegorilla.reconnect", text="Update Port")
        # layout.operator(
        #     "greyscalegorilla.disconnect", text="Stop")
        layout.prop(self, "sockets_port",
                    text="Port")


class GreyscalegorillaButtonAction(bpy.types.Operator):
    """Greyscalegorilla Connect button action"""
    bl_idname = "greyscalegorilla.button"
    bl_label = "Dummy button"

    def execute(self, context):
        log_info(
            "Button clicked. Please update GreyscalegorillaButtonAction.execute method to override this behaviour")
        return {'FINISHED'}


class GreyscalegorillaPanel(bpy.types.Panel):
    """Greyscalegorilla Connect"""
    bl_label = "Greyscalegorilla Connect"
    bl_idname = "OBJECT_PT_simple"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = 'Greyscalegorilla Connect'

    def draw(self, context):
        layout = self.layout
        layout.operator(GreyscalegorillaButtonAction.bl_idname)


@persistent
def load_plugin(dummy=None, dummy2=None):
    try:
        bpy.ops.greyscalegorilla.connect()
    except Exception:
        pass


def register():
    bpy.utils.register_class(GreyscalegorillaConnect)
    bpy.utils.register_class(GreyscalegorillaDisconnect)
    bpy.utils.register_class(GreyscalegorillaReconnect)
    bpy.utils.register_class(GSGBridgePreferencesPanel)
    bpy.utils.register_class(GreyscalegorillaButtonAction)
    bpy.app.timers.register(load_plugin)
    if len(bpy.app.handlers.load_post) > 0:
        if "load_plugin" in bpy.app.handlers.load_post[0].__name__.lower() or load_plugin in bpy.app.handlers.load_post:
            return
    bpy.app.handlers.load_post.append(load_plugin)


def unregister():
    bpy.ops.greyscalegorilla.disconnect()
    bpy.utils.unregister_class(GSGBridgePreferencesPanel)
    bpy.utils.unregister_class(GreyscalegorillaReconnect)
    bpy.utils.unregister_class(GreyscalegorillaDisconnect)
    bpy.utils.unregister_class(GreyscalegorillaConnect)
    bpy.utils.unregister_class(GreyscalegorillaButtonAction)
    if len(bpy.app.handlers.load_post) > 0:
        if "load_plugin" in bpy.app.handlers.load_post[0].__name__.lower() or load_plugin in bpy.app.handlers.load_post:
            bpy.app.handlers.load_post.remove(load_plugin)


# Class handling the creation of the surface imperfections nodes
# A texture connected to a default ramp. The texture is optionally driven by the triplanar mapping node.
#
class SurfaceImperfections:
    def __init__(self):
        self._object = None
        self._nodes = None
        self._links = None

        for obj in bpy.context.selected_objects:
            if obj.parent_type == 'OBJECT':
                self._object = obj
                break

        if self._object is None:
            log_info("No object selected for sending surface imperfections.")
            return

        if self._object.active_material is None:
            log_info("No active material found for sending surface imperfections.")
            return
        
        self._nodes = self._object.active_material.node_tree.nodes
        self._links = self._object.active_material.node_tree.links

    def IsValid(self):
        return self._nodes is not None

    def GetBBox(self):
        if self._object is None:
            return None
        
        out_bbox = mathutils.Vector((100000, 100000, -100000, -100000))
        for node in self._nodes:
            out_bbox[0] = min(out_bbox[0], node.location.x)
            out_bbox[1] = min(out_bbox[1], node.location.y)
            out_bbox[2] = max(out_bbox[2], node.location.x)
            out_bbox[3] = max(out_bbox[3], node.location.y)
        return out_bbox

    def GetMappingNode(self, in_triplanar):
        projection_type = 'BOX' if in_triplanar else 'FLAT'
        for node in self._nodes:
            if node.type == 'TEX_IMAGE' and node.projection == projection_type:
                for input in node.inputs:
                    if input.is_linked and input.bl_label == 'Vector':
                        input_shader = input.links[0].from_node
                        if input_shader.type == 'MAPPING':
                            return input_shader
        return None

    def Create(self, in_texture_path):
        if self._object is None:
            return
        # Get the current graph bbox, in order to place the created nodes
        # at the top-left corner of the graph (arbitrary decision)
        bbox = self.GetBBox()
        loc_x = bbox[0]
        loc_y = bbox[3]

        texture_node = self._nodes.new(type='ShaderNodeTexImage')
        texture_node.hide = True
        texture_node.location = (loc_x, loc_y)
        texture_node.image = bpy.data.images.load(in_texture_path)
        texture_node.image.colorspace_settings.name = 'Non-Color'

        triplanar_mapping_node = self.GetMappingNode(True)
        if triplanar_mapping_node is not None:
            # We're in a triplanar graph, so we want to connect the unique mapping node to the texture node
            # The only other setting required is the BOX mode
            texture_node.projection = 'BOX'
            self._links.new(triplanar_mapping_node.outputs['Vector'], texture_node.inputs['Vector'])
        else:
            # Otherwise, look for a mapping node in the graph
            mapping_node = self.GetMappingNode(False)
            if mapping_node is not None:
                self._links.new(mapping_node.outputs['Vector'], texture_node.inputs['Vector'])

        # Connect the texture to a ramp shader
        ramp = self._nodes.new('ShaderNodeValToRGB')
        ramp.label = 'Surface Imperfection Ramp'
        ramp.hide = True
        ramp.location = (loc_x + 300, loc_y)

        self._links.new(texture_node.outputs['Color'], ramp.inputs['Fac'])


# Light classes and functions. To be moved them to a separate module should we change the installation instructions.

# Base class for all light types:

class BaseLight:
    def __init__(self, in_type, in_use_selected=False):
        self._light = None
        self._light_data = None
        self._nodes = None
        self._links = None
        self._gsg_type = in_type

    def LocalizeText(self, in_text):
        isEnglish = bpy.app.translations.locale == 'en_US'
        return in_text if isEnglish else bpy.app.translations.pgettext(in_text)

    def FindNodeType(self, in_node_type):
        for node in self._nodes:
            if node.type == in_node_type:
                return node
        return False

    def FindLink(self, in_from_type, in_to_type):
        for link in self._links:
            if link.from_node.type == in_from_type and link.to_node.type == in_to_type:
                return link.from_node
        return None

    def CreateTextureNode(self):
        texture_node = self._nodes.new(type=self.LocalizeText('ShaderNodeTexImage'))
        texture_node.select = True
        texture_node.location = (-300, 300)
        return texture_node

    def GetTextureImagePath(self, in_texture_node=None):
        if in_texture_node is None or in_texture_node.image is None:
            return ''        
        return in_texture_node.image.filepath


class BlLightObject(BaseLight):
    def __init__(self, in_type, in_use_selected=False):
        super().__init__(in_type, in_use_selected)

        if not in_use_selected or len(bpy.context.selected_objects) == 0:
            return

        light_object = None
        selected_object = bpy.context.selected_objects[0]
        if (selected_object.type == 'LIGHT'):
            light_object = selected_object
        # If the selected object is an empty (the controller ?), check if it has a child light
        elif (selected_object.type == 'EMPTY'):
            for child in selected_object.children:
                if child.type == 'LIGHT':
                    light_object = child
                    break
            if light_object is None:
                # Get the parent of the selected object
                parent = selected_object.parent
                if parent is not None and parent.type == 'EMPTY':
                    for child in parent.children:
                        if child.type == 'LIGHT':
                            light_object = child
                            break

        if light_object is not None:
            self._light = light_object
            self._light_data = self._light.data
            self._nodes = self._light_data.node_tree.nodes
            self._links = self._light_data.node_tree.links


    def IsValid(self):
        return self._light is not None and self._light_data is not None and self._light.type == 'LIGHT'

    def Create(self):
        data_type = 'AREA' if self._gsg_type == 'area' else 'SPOT'
        light_name = 'Area Light' if self._gsg_type == 'area' else 'Gobo Light'
        self._light_data = bpy.data.lights.new(name="Light Data", type=data_type)
        self._light = bpy.data.objects.new(name=light_name, object_data=self._light_data)
        # Enable the node graph for the light
        self._light_data.use_nodes = True
        self._nodes = self._light_data.node_tree.nodes
        self._links = self._light_data.node_tree.links
        bpy.context.collection.objects.link(self._light)
        self.AddControllerAndTarget()

    def AddControllerAndTarget(self):
        # capitalize the first letter of the type
        type_name = self._gsg_type.capitalize()
        # Parent null
        parent_obj = bpy.data.objects.new(type_name + ' Light Controller', None)
        bpy.context.collection.objects.link(parent_obj)
        self._light.parent = parent_obj
        # Target null
        target_obj = bpy.data.objects.new(type_name + ' Light Target', None)
        bpy.context.collection.objects.link(target_obj)
        target_obj.parent = parent_obj
        # Constraint the interest of the light to the target null
        self._light.constraints.new(type='TRACK_TO')
        self._light.constraints['Track To'].target = target_obj

    def SetCurrent(self):
        # Deselect all objects in the scene and select the light object
        for obj in bpy.context.selected_objects:
            obj.select_set(False)
        if self._light is not None:
            self._light.select_set(True)
            bpy.context.view_layer.objects.active = self._light

    def GetTextureNode(self):
        emission = self.FindNodeType('EMISSION')
        if emission is None:
            return None
        for link in self._links:
            if link.from_node.type == 'TEX_IMAGE' and link.to_node == emission:
                return link.from_node
        return None

    def SetTexture(self, in_path, in_start_frame=0, in_nb_frames=0):
        is_sequence = in_nb_frames > 0
        # Check if the light has a texture node already
        texture_node = self.GetTextureNode()
        create = texture_node is None
        if create:
            # If not, create it and connect it to the emission shader
            texture_node = self.CreateTextureNode()
        elif in_path == self.GetTextureImagePath(texture_node):
            # the texture node already exists, return if the user is trying to set the same texture again
            return

        texture_node.image = bpy.data.images.load(in_path, check_existing=True)
        texture_node.image.colorspace_settings.name = 'Non-Color'
        if create:
            # Connect the texture node to the emission shader of the light
            self._links.new(texture_node.outputs['Color'], self._nodes['Emission'].inputs['Color'])

        texture_node.image.source = 'SEQUENCE' if is_sequence else 'FILE'
        if is_sequence:
            texture_node.image_user.frame_start = in_start_frame
            texture_node.image_user.frame_duration = in_nb_frames

        texture_node.image.reload()

    def SetStrength(self, in_strength):
        if self._light is not None:
            self._nodes['Emission'].inputs['Strength'].default_value = in_strength

    def Scale(self, in_v):
        if self._light is not None:
            self._light.scale = (in_v.x, in_v.y, in_v.z)

    def Rotate(self, in_v):
        if self._light is not None:
            self._light.rotation_euler = (in_v.x, in_v.y, in_v.z)

    def Translate(self, in_v):
        if self._light is not None:
            self._light.location = (in_v.x, in_v.y, in_v.z)



# Area light class, derives from the light object class

class BlAreaLight(BlLightObject):
    def __init__(self, in_use_selected=False):
        super().__init__('area', in_use_selected)

    def IsValid(self):
        return super().IsValid() and self._light_data.type == 'AREA'

    def Create(self):
        super().Create()
        self._light.data.spread = 0.01
        self.Scale(mathutils.Vector((20, 20, 20)))
        self.Translate(mathutils.Vector((10, -10, 10)))
        self.SetStrength(10)
        self.SetCurrent()


# Spot light class, derives from the light object class

class BlGoboLight(BlLightObject):
    def __init__(self, in_use_selected=False):
        super().__init__('gobo', in_use_selected)

    def IsValid(self):
        return super().IsValid() and self._light_data.type == 'SPOT'
    
    def Create(self):
        super().Create()
        self.Translate(mathutils.Vector((10, -10, 10)))
        self.SetStrength(1000)
        self._light_data.spot_size = math.radians(65)
        self.SetCurrent()


# Dome light, derives from the base light class

class BlDomeLight(BaseLight):
    def __init__(self):
        super().__init__('dome', in_use_selected=False)
        world = bpy.data.worlds['World']
        world.use_nodes = True
        # Nodes and links can be initialized here, because we're not creating a new light object
        self._nodes = world.node_tree.nodes
        self._links = world.node_tree.links

    def IsValid(self):
        # Check the network is already in place and the connections are valid.
        if self.FindNodeType('OUTPUT_WORLD') and self.FindNodeType('BACKGROUND') and self.FindNodeType('TEX_ENVIRONMENT'):
            return self.FindLink('TEX_ENVIRONMENT', 'BACKGROUND') is not None and self.FindLink('BACKGROUND', 'OUTPUT_WORLD') is not None
        return False

    def GetTextureNode(self):
        return self.FindLink('TEX_ENVIRONMENT', 'BACKGROUND')

    def SetTexture(self, in_path, *args):
        # Check if the light has a texture node already
        texture_node = self.GetTextureNode()
        if texture_node is None or in_path == self.GetTextureImagePath(texture_node):
            return
        
        texture_node.image = bpy.data.images.load(in_path, check_existing=True)
        texture_node.image.colorspace_settings.name = 'Non-Color'
        texture_node.image.reload()

    def Create(self):
        output_world_node = None
        for node in self._nodes:
            if node.type == 'OUTPUT_WORLD':
                output_world_node = node
                break

        if output_world_node is None:
            output_world_node = self._nodes.new(type=self.LocalizeText('ShaderNodeOutputWorld'))
            output_world_node.location = (0, 0)

        bg_node = self.FindLink('BACKGROUND', 'OUTPUT_WORLD')
        if bg_node is None:
            bg_node = self._nodes.new(type=self.LocalizeText('ShaderNodeBackground'))
            bg_node.location = (-200, 300)
            # Connect the background node to the output world node
            self._links.new(bg_node.outputs['Background'], output_world_node.inputs['Surface'])

        env_tex_node = self.FindLink('TEX_ENVIRONMENT', 'BACKGROUND')
        if env_tex_node is None:
            # Add an environment texture node
            env_tex_node = self._nodes.new(type=self.LocalizeText('ShaderNodeTexEnvironment'))
            env_tex_node.location = (-400, 300)

        # This is the Blender naming for 'latlong'
        env_tex_node.projection = 'EQUIRECTANGULAR'

        # connect the environment texture node to the background node
        self._links.new(env_tex_node.outputs['Color'], bg_node.inputs['Color'])
        return
