"""
materials.py
------------
Shader catalogue and material management for knot_animation.

Shader builders
---------------
Each `_inner_<SHADER_ID>` function receives an already-cleared node tree
(nodes, links, y_offset) and returns the final output shader socket.
They do NOT add an Output Material node — that is the caller's responsibility.

Adding a new shader
-------------------
1. Define `_inner_MYSHADER(nodes, links, y=0, color=..., roughness=...,
                             metallic=..., emission_strength=..., **kwargs)`.
2. Add its name to `_TRANSPARENT_SHADERS` below ONLY if it needs alpha blending.

No other changes are required — INNER_FACTORIES and SHADER_IDS are built
automatically from the naming convention.
"""
from __future__ import annotations

import bpy

from .constants import KNOT_OBJ_NAME, KNOT_MAT_NAME, SHADER_BLEND_MAT_NAME


# ---------------------------------------------------------------------------
# Inner shader builders
# Convention: _inner_<SHADER_ID>(nodes, links, y, color, roughness, metallic,
#                                  emission_strength, **kwargs) -> output_socket
# ---------------------------------------------------------------------------

def _inner_GLOSS_BLUE(nodes, links, y=0, color=(0.2, 0.6, 1.0, 1.0),
                      roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
    mix  = nodes.new("ShaderNodeMixShader"); mix.location  = (300, y)
    glos = nodes.new("ShaderNodeBsdfGlossy"); glos.location = (0, y + 80)
    emit = nodes.new("ShaderNodeEmission");  emit.location = (0, y - 80)
    glos.inputs["Color"].default_value     = color
    glos.inputs["Roughness"].default_value = roughness
    emit.inputs["Color"].default_value     = (0.4, 0.8, 1.0, 1.0)
    emit.inputs["Strength"].default_value  = emission_strength * 0.5
    mix.inputs["Fac"].default_value        = 0.3
    links.new(glos.outputs["BSDF"],     mix.inputs[1])
    links.new(emit.outputs["Emission"], mix.inputs[2])
    return mix.outputs["Shader"]


def _inner_NEON_GLOW(nodes, links, y=0, color=(0.0, 1.0, 0.9, 1.0),
                     roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
    mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y)
    em1 = nodes.new("ShaderNodeEmission"); em1.location = (0, y + 80)
    em2 = nodes.new("ShaderNodeEmission"); em2.location = (0, y - 80)
    em1.inputs["Color"].default_value    = color
    em1.inputs["Strength"].default_value = emission_strength * 3.0
    em2.inputs["Color"].default_value    = (1.0, 0.05, 0.8, 1.0)
    em2.inputs["Strength"].default_value = emission_strength * 3.0
    mix.inputs["Fac"].default_value      = 0.5
    links.new(em1.outputs["Emission"], mix.inputs[1])
    links.new(em2.outputs["Emission"], mix.inputs[2])
    return mix.outputs["Shader"]


def _inner_METALLIC(nodes, links, y=0, color=(1.0, 0.78, 0.28, 1.0),
                    roughness=0.05, metallic=1.0, emission_strength=1.0, **kwargs):
    prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
    prin.inputs["Base Color"].default_value        = color
    prin.inputs["Metallic"].default_value          = metallic
    prin.inputs["Roughness"].default_value         = roughness
    prin.inputs["Specular IOR Level"].default_value = 1.0
    return prin.outputs["BSDF"]


def _inner_GLASS(nodes, links, y=0, color=(0.85, 0.95, 1.0, 1.0),
                 roughness=0.0, metallic=0.0, emission_strength=1.0, **kwargs):
    prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
    prin.inputs["Base Color"].default_value        = color
    prin.inputs["Metallic"].default_value          = metallic
    prin.inputs["Roughness"].default_value         = roughness
    prin.inputs["IOR"].default_value               = 1.45
    prin.inputs["Transmission Weight"].default_value = 1.0
    return prin.outputs["BSDF"]


def _inner_HOLOGRAM(nodes, links, y=0, color=(0.2, 1.0, 0.5, 1.0),
                    roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
    mix   = nodes.new("ShaderNodeMixShader");       mix.location   = (400, y)
    emit  = nodes.new("ShaderNodeEmission");        emit.location  = (0,   y + 100)
    trans = nodes.new("ShaderNodeBsdfTransparent"); trans.location = (0,   y - 100)
    fres  = nodes.new("ShaderNodeFresnel");         fres.location  = (0,   y + 220)
    emit.inputs["Color"].default_value    = color
    emit.inputs["Strength"].default_value = emission_strength * 2.0
    fres.inputs["IOR"].default_value      = 1.3
    links.new(fres.outputs["Fac"],       mix.inputs["Fac"])
    links.new(emit.outputs["Emission"],  mix.inputs[1])
    links.new(trans.outputs["BSDF"],     mix.inputs[2])
    return mix.outputs["Shader"]


def _inner_LAVA(nodes, links, y=0, color=(1.0, 0.1, 0.0, 1.0),
                roughness=1.0, metallic=0.0, emission_strength=1.0, **kwargs):
    add   = nodes.new("ShaderNodeAddShader");  add.location   = (500, y)
    diff  = nodes.new("ShaderNodeBsdfDiffuse"); diff.location  = (0,   y + 100)
    emit  = nodes.new("ShaderNodeEmission");   emit.location  = (0,   y - 100)
    ramp  = nodes.new("ShaderNodeValToRGB");   ramp.location  = (-250, y - 100)
    noise = nodes.new("ShaderNodeTexNoise");   noise.location = (-500, y - 100)
    coord = nodes.new("ShaderNodeTexCoord");   coord.location = (-700, y - 100)
    diff.inputs["Color"].default_value     = (color[0] * 0.08, color[1] * 0.08, color[2] * 0.08, 1.0)
    diff.inputs["Roughness"].default_value = roughness
    emit.inputs["Strength"].default_value  = emission_strength * 4.0
    noise.inputs["Scale"].default_value    = 4.0
    noise.inputs["Detail"].default_value   = 8.0
    cr = ramp.color_ramp
    cr.elements[0].position = 0.0; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0)
    cr.elements[1].position = 1.0; cr.elements[1].color = color
    cr.elements.new(0.55).color = (color[0], color[1] * 0.1, color[2] * 0.1, 1.0)
    cr.elements.new(0.85).color = (color[0], color[1] * 0.9, color[2] * 0.9, 1.0)
    links.new(coord.outputs["Generated"], noise.inputs["Vector"])
    links.new(noise.outputs["Fac"],       ramp.inputs["Fac"])
    links.new(ramp.outputs["Color"],      emit.inputs["Color"])
    links.new(diff.outputs["BSDF"],       add.inputs[0])
    links.new(emit.outputs["Emission"],   add.inputs[1])
    return add.outputs["Shader"]


def _inner_IRIDESCENT(nodes, links, y=0, color=(1.0, 1.0, 1.0, 1.0),
                      roughness=0.05, metallic=0.0, emission_strength=1.0, **kwargs):
    add  = nodes.new("ShaderNodeAddShader");  add.location  = (500, y)
    glos = nodes.new("ShaderNodeBsdfGlossy"); glos.location = (0,   y + 100)
    emit = nodes.new("ShaderNodeEmission");   emit.location = (0,   y - 100)
    ramp = nodes.new("ShaderNodeValToRGB");   ramp.location = (-250, y - 100)
    lw   = nodes.new("ShaderNodeLayerWeight"); lw.location  = (-450, y - 100)
    glos.inputs["Roughness"].default_value = roughness
    emit.inputs["Strength"].default_value  = emission_strength * 1.2
    lw.inputs["Blend"].default_value       = 0.5
    cr = ramp.color_ramp
    cr.interpolation = 'LINEAR'
    rainbow = [
        (0.0,  (1.0, 0.0, 0.0, 1.0)),
        (0.17, (1.0, 0.5, 0.0, 1.0)),
        (0.33, (1.0, 1.0, 0.0, 1.0)),
        (0.5,  (0.0, 1.0, 0.0, 1.0)),
        (0.67, (0.0, 0.5, 1.0, 1.0)),
        (0.83, (0.5, 0.0, 1.0, 1.0)),
        (1.0,  (1.0, 0.0, 0.5, 1.0)),
    ]
    for i, (pos, col) in enumerate(rainbow):
        if i < 2:
            cr.elements[i].position = pos
            cr.elements[i].color    = col
        else:
            cr.elements.new(pos).color = col
    links.new(lw.outputs["Facing"],     ramp.inputs["Fac"])
    links.new(ramp.outputs["Color"],    emit.inputs["Color"])
    links.new(ramp.outputs["Color"],    glos.inputs["Color"])
    links.new(glos.outputs["BSDF"],     add.inputs[0])
    links.new(emit.outputs["Emission"], add.inputs[1])
    return add.outputs["Shader"]


def _inner_MATTE_CLAY(nodes, links, y=0, color=(0.6, 0.4, 0.3, 1.0),
                      roughness=0.95, metallic=0.0, emission_strength=1.0, **kwargs):
    prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
    prin.inputs["Base Color"].default_value = color
    prin.inputs["Roughness"].default_value  = roughness
    return prin.outputs["BSDF"]


def _inner_GHOST(nodes, links, y=0, color=(0.7, 0.8, 1.0, 1.0),
                 roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
    mix   = nodes.new("ShaderNodeMixShader");       mix.location   = (400, y)
    trans = nodes.new("ShaderNodeBsdfTransparent"); trans.location = (0,   y)
    emit  = nodes.new("ShaderNodeEmission");        emit.location  = (0,   y + 150)
    fres  = nodes.new("ShaderNodeFresnel");         fres.location  = (0,   y + 300)
    emit.inputs["Color"].default_value    = color
    emit.inputs["Strength"].default_value = emission_strength * 2.0
    fres.inputs["IOR"].default_value      = 1.05
    links.new(fres.outputs["Fac"],        mix.inputs["Fac"])
    links.new(trans.outputs["BSDF"],      mix.inputs[1])
    links.new(emit.outputs["Emission"],   mix.inputs[2])
    return mix.outputs["Shader"]


def _inner_CAR_PAINT(nodes, links, y=0, color=(0.8, 0.05, 0.1, 1.0),
                     roughness=0.3, metallic=0.8, emission_strength=1.0, **kwargs):
    prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
    prin.inputs["Base Color"].default_value  = color
    prin.inputs["Metallic"].default_value    = metallic
    prin.inputs["Roughness"].default_value   = roughness
    prin.inputs["Coat Weight"].default_value    = 1.0
    prin.inputs["Coat Roughness"].default_value = 0.03
    return prin.outputs["BSDF"]


def _inner_CHROME(nodes, links, y=0, color=(0.95, 0.95, 0.95, 1.0),
                  roughness=0.01, metallic=1.0, emission_strength=1.0, **kwargs):
    prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
    prin.inputs["Base Color"].default_value = color
    prin.inputs["Metallic"].default_value   = metallic
    prin.inputs["Roughness"].default_value  = roughness
    return prin.outputs["BSDF"]


def _inner_RUBY_GLASS(nodes, links, y=0, color=(1.0, 0.05, 0.05, 1.0),
                      roughness=0.02, metallic=0.0, emission_strength=1.0, **kwargs):
    prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
    prin.inputs["Base Color"].default_value          = color
    prin.inputs["Metallic"].default_value            = metallic
    prin.inputs["Roughness"].default_value           = roughness
    prin.inputs["IOR"].default_value                 = 1.6
    prin.inputs["Transmission Weight"].default_value = 1.0
    return prin.outputs["BSDF"]


def _inner_FROSTED_ICE(nodes, links, y=0, color=(0.9, 0.95, 1.0, 1.0),
                       roughness=0.4, metallic=0.0, emission_strength=1.0, **kwargs):
    prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
    prin.inputs["Base Color"].default_value          = color
    prin.inputs["Metallic"].default_value            = metallic
    prin.inputs["Roughness"].default_value           = roughness
    prin.inputs["IOR"].default_value                 = 1.31
    prin.inputs["Transmission Weight"].default_value = 1.0
    return prin.outputs["BSDF"]


def _inner_LICHEN_ROUGH(nodes, links, y=0, color=(0.3, 0.6, 0.1, 1.0),
                        roughness=0.9, metallic=0.0, emission_strength=1.0, **kwargs):
    mix   = nodes.new("ShaderNodeMixShader");  mix.location   = (300, y)
    diff1 = nodes.new("ShaderNodeBsdfDiffuse"); diff1.location = (0,   y + 100)
    diff2 = nodes.new("ShaderNodeBsdfDiffuse"); diff2.location = (0,   y - 100)
    noise = nodes.new("ShaderNodeTexNoise");   noise.location = (-400, y)
    ramp  = nodes.new("ShaderNodeValToRGB");   ramp.location  = (-200, y)
    coord = nodes.new("ShaderNodeTexCoord");   coord.location = (-600, y)
    diff1.inputs["Color"].default_value     = (0.15, 0.15, 0.15, 1.0)
    diff1.inputs["Roughness"].default_value = 1.0
    diff2.inputs["Color"].default_value     = color
    diff2.inputs["Roughness"].default_value = roughness
    noise.inputs["Scale"].default_value  = 8.0
    noise.inputs["Detail"].default_value = 6.0
    cr = ramp.color_ramp
    cr.elements[0].position = 0.45; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0)
    cr.elements[1].position = 0.55; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0)
    links.new(coord.outputs["Generated"], noise.inputs["Vector"])
    links.new(noise.outputs["Fac"],       ramp.inputs["Fac"])
    links.new(ramp.outputs["Color"],      mix.inputs["Fac"])
    links.new(diff1.outputs["BSDF"],      mix.inputs[1])
    links.new(diff2.outputs["BSDF"],      mix.inputs[2])
    return mix.outputs["Shader"]


def _inner_COPPER_PATINA(nodes, links, y=0, color=(0.2, 0.6, 0.45, 1.0),
                         roughness=0.15, metallic=1.0, emission_strength=1.0, **kwargs):
    mix   = nodes.new("ShaderNodeMixShader");      mix.location   = (300, y)
    prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0,   y + 120)
    prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0,   y - 120)
    noise = nodes.new("ShaderNodeTexNoise");       noise.location = (-400, y)
    ramp  = nodes.new("ShaderNodeValToRGB");       ramp.location  = (-200, y)
    coord = nodes.new("ShaderNodeTexCoord");       coord.location = (-600, y)
    prin1.inputs["Base Color"].default_value = (0.95, 0.45, 0.3, 1.0)
    prin1.inputs["Metallic"].default_value   = metallic
    prin1.inputs["Roughness"].default_value  = roughness
    prin2.inputs["Base Color"].default_value = color
    prin2.inputs["Metallic"].default_value   = 0.0
    prin2.inputs["Roughness"].default_value  = 0.85
    noise.inputs["Scale"].default_value  = 6.0
    noise.inputs["Detail"].default_value = 4.0
    cr = ramp.color_ramp
    cr.elements[0].position = 0.4; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0)
    cr.elements[1].position = 0.6; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0)
    links.new(coord.outputs["Generated"], noise.inputs["Vector"])
    links.new(noise.outputs["Fac"],       ramp.inputs["Fac"])
    links.new(ramp.outputs["Color"],      mix.inputs["Fac"])
    links.new(prin1.outputs["BSDF"],      mix.inputs[1])
    links.new(prin2.outputs["BSDF"],      mix.inputs[2])
    return mix.outputs["Shader"]


def _inner_ZEBRA_STRIPES(nodes, links, y=0, color=(0.98, 0.98, 0.98, 1.0),
                         roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
    mix   = nodes.new("ShaderNodeMixShader");      mix.location   = (300, y)
    prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0,   y + 100)
    prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0,   y - 100)
    wave  = nodes.new("ShaderNodeTexWave");        wave.location  = (-400, y)
    ramp  = nodes.new("ShaderNodeValToRGB");       ramp.location  = (-200, y)
    coord = nodes.new("ShaderNodeTexCoord");       coord.location = (-600, y)
    prin1.inputs["Base Color"].default_value = (0.02, 0.02, 0.02, 1.0)
    prin1.inputs["Metallic"].default_value   = metallic
    prin1.inputs["Roughness"].default_value  = roughness
    prin2.inputs["Base Color"].default_value = color
    prin2.inputs["Metallic"].default_value   = metallic
    prin2.inputs["Roughness"].default_value  = roughness
    wave.inputs["Scale"].default_value = 5.0
    cr = ramp.color_ramp
    cr.interpolation = 'CONSTANT'
    cr.elements[0].position = 0.0; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0)
    cr.elements[1].position = 0.5; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0)
    links.new(coord.outputs["Generated"], wave.inputs["Vector"])
    links.new(wave.outputs["Color"],      ramp.inputs["Fac"])
    links.new(ramp.outputs["Color"],      mix.inputs["Fac"])
    links.new(prin1.outputs["BSDF"],      mix.inputs[1])
    links.new(prin2.outputs["BSDF"],      mix.inputs[2])
    return mix.outputs["Shader"]


def _inner_WOOD_VENEER(nodes, links, y=0, color=(0.4, 0.2, 0.08, 1.0),
                       roughness=0.2, metallic=0.0, emission_strength=1.0, **kwargs):
    prin    = nodes.new("ShaderNodeBsdfPrincipled"); prin.location    = (300, y)
    noise   = nodes.new("ShaderNodeTexNoise");       noise.location   = (-100, y)
    ramp    = nodes.new("ShaderNodeValToRGB");       ramp.location    = (100, y)
    mapping = nodes.new("ShaderNodeMapping");        mapping.location = (-300, y)
    coord   = nodes.new("ShaderNodeTexCoord");       coord.location   = (-500, y)
    mapping.inputs["Scale"].default_value = (1.0, 10.0, 1.0)
    noise.inputs["Scale"].default_value  = 4.0
    noise.inputs["Detail"].default_value = 4.0
    cr = ramp.color_ramp
    cr.interpolation = 'LINEAR'
    cr.elements[0].position = 0.0; cr.elements[0].color = (color[0] * 0.4, color[1] * 0.4, color[2] * 0.4, 1.0)
    cr.elements[1].position = 1.0; cr.elements[1].color = color
    cr.elements.new(0.5).color = (color[0] * 0.7, color[1] * 0.7, color[2] * 0.7, 1.0)
    prin.inputs["Metallic"].default_value   = metallic
    prin.inputs["Roughness"].default_value  = roughness
    links.new(coord.outputs["Generated"],  mapping.inputs["Vector"])
    links.new(mapping.outputs["Vector"],   noise.inputs["Vector"])
    links.new(noise.outputs["Fac"],        ramp.inputs["Fac"])
    links.new(ramp.outputs["Color"],       prin.inputs["Base Color"])
    return prin.outputs["BSDF"]


def _inner_CARBON_FIBER(nodes, links, y=0, color=(0.1, 0.1, 0.1, 1.0),
                        roughness=0.15, metallic=0.0, emission_strength=1.0, **kwargs):
    mix   = nodes.new("ShaderNodeMixShader");      mix.location   = (300, y)
    prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0,   y + 100)
    prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0,   y - 100)
    check = nodes.new("ShaderNodeTexChecker");     check.location = (-200, y)
    coord = nodes.new("ShaderNodeTexCoord");       coord.location = (-400, y)
    prin1.inputs["Base Color"].default_value = (0.05, 0.05, 0.05, 1.0)
    prin1.inputs["Metallic"].default_value   = 0.5
    prin1.inputs["Roughness"].default_value  = roughness
    prin2.inputs["Base Color"].default_value = color
    prin2.inputs["Metallic"].default_value   = 0.5
    prin2.inputs["Roughness"].default_value  = roughness
    check.inputs["Scale"].default_value = 25.0
    links.new(coord.outputs["Generated"], check.inputs["Vector"])
    links.new(check.outputs["Color"],     mix.inputs["Fac"])
    links.new(prin1.outputs["BSDF"],      mix.inputs[1])
    links.new(prin2.outputs["BSDF"],      mix.inputs[2])
    return mix.outputs["Shader"]


def _inner_PEARLESCENT(nodes, links, y=0, color=(1.0, 0.4, 0.7, 1.0),
                       roughness=0.05, metallic=0.1, emission_strength=1.0, **kwargs):
    prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (300, y)
    ramp = nodes.new("ShaderNodeValToRGB");       ramp.location = (100, y)
    lw   = nodes.new("ShaderNodeLayerWeight");    lw.location   = (-100, y)
    lw.inputs["Blend"].default_value = 0.35
    cr = ramp.color_ramp
    cr.interpolation = 'LINEAR'
    cr.elements[0].position = 0.0; cr.elements[0].color = (0.1, 0.8, 1.0, 1.0)
    cr.elements[1].position = 1.0; cr.elements[1].color = color
    prin.inputs["Metallic"].default_value       = metallic
    prin.inputs["Roughness"].default_value      = roughness
    prin.inputs["Coat Weight"].default_value    = 1.0
    prin.inputs["Coat Roughness"].default_value = 0.02
    links.new(lw.outputs["Facing"],    ramp.inputs["Fac"])
    links.new(ramp.outputs["Color"],   prin.inputs["Base Color"])
    return prin.outputs["BSDF"]


def _inner_PLASMA_GLOW(nodes, links, y=0, color=(1.0, 0.2, 0.8, 1.0),
                       roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
    emit  = nodes.new("ShaderNodeEmission");    emit.location  = (300, y)
    ramp  = nodes.new("ShaderNodeValToRGB");    ramp.location  = (100, y)
    voro  = nodes.new("ShaderNodeTexVoronoi");  voro.location  = (-100, y)
    coord = nodes.new("ShaderNodeTexCoord");    coord.location = (-300, y)
    voro.inputs["Scale"].default_value     = 6.0
    cr = ramp.color_ramp
    cr.interpolation = 'LINEAR'
    cr.elements[0].position = 0.0; cr.elements[0].color = (0.1, 0.0, 0.3, 1.0)
    cr.elements[1].position = 1.0; cr.elements[1].color = (1.0, 0.9, 0.5, 1.0)
    cr.elements.new(0.5).color = color
    emit.inputs["Strength"].default_value = emission_strength * 8.0
    links.new(coord.outputs["Generated"], voro.inputs["Vector"])
    links.new(voro.outputs["Distance"],   ramp.inputs["Fac"])
    links.new(ramp.outputs["Color"],      emit.inputs["Color"])
    return emit.outputs["Emission"]


# ---------------------------------------------------------------------------
# Auto-discovery — P5 fix
# INNER_FACTORIES and SHADER_IDS are built from the _inner_* naming convention.
# Python 3.7+ dicts preserve insertion order, so SHADER_IDS matches the
# function definition order above.
# ---------------------------------------------------------------------------

INNER_FACTORIES: dict = {
    name[len("_inner_"):]: fn
    for name, fn in globals().items()
    if name.startswith("_inner_") and callable(fn)
}

# Stable ordered list used by EnumProperty items and the unregister purge.
SHADER_IDS: list = list(INNER_FACTORIES)

# Shaders that require alpha blending on the material.
_TRANSPARENT_SHADERS: frozenset = frozenset({
    "GLASS", "HOLOGRAM", "GHOST", "RUBY_GLASS", "FROSTED_ICE",
})


# ---------------------------------------------------------------------------
# Material name helpers
# ---------------------------------------------------------------------------

def _shader_mat_name(shader_id: str) -> str:
    return f"KnotShader_{shader_id}"


# ---------------------------------------------------------------------------
# Shader material cache
# ---------------------------------------------------------------------------

def _get_shader_material(shader_id: str):
    """Look up the named shader material.  Returns None if not yet built.

    This is the HANDLER-SAFE path — it never allocates.
    """
    return bpy.data.materials.get(_shader_mat_name(shader_id))


def _ensure_shader_material(shader_id: str):
    """Get-or-create the named shader material with default properties.

    MAIN THREAD ONLY.
    """
    mat_name = _shader_mat_name(shader_id)
    mat = bpy.data.materials.get(mat_name)
    if mat is None:
        mat = bpy.data.materials.new(name=mat_name)
        if hasattr(mat, "use_nodes"):
            try:
                mat.use_nodes = True
            except AttributeError:
                pass
        nodes = mat.node_tree.nodes
        links = mat.node_tree.links
        nodes.clear()
        out = nodes.new("ShaderNodeOutputMaterial")
        out.location = (600, 0)
        sock = INNER_FACTORIES[shader_id](nodes, links, y=0)
        links.new(sock, out.inputs["Surface"])
        if shader_id in _TRANSPARENT_SHADERS:
            mat.blend_method = 'BLEND'
    return mat


def _ensure_item_preset_material(item):
    """Ensure the per-KnotItem preset material exists and is up to date.

    MAIN THREAD ONLY.  Call before playback or from property update callbacks.
    """
    import random
    if not item.uid:
        item.uid = f"knot_{random.randint(100000, 999999)}"

    mat_name = f"KnotItem_Preset_{item.uid}"
    mat = bpy.data.materials.get(mat_name)
    if mat is None:
        mat = bpy.data.materials.new(name=mat_name)

    if hasattr(mat, "use_nodes"):
        try:
            mat.use_nodes = True
        except AttributeError:
            pass

    nodes = mat.node_tree.nodes
    links = mat.node_tree.links
    nodes.clear()

    out = nodes.new("ShaderNodeOutputMaterial")
    out.location = (600, 0)

    color            = (item.preset_color[0], item.preset_color[1], item.preset_color[2], 1.0)
    roughness        = item.preset_roughness
    metallic         = item.preset_metallic
    emission_strength = item.preset_emission_strength

    sock = INNER_FACTORIES[item.shader_id](
        nodes, links, y=0,
        color=color,
        roughness=roughness,
        metallic=metallic,
        emission_strength=emission_strength,
    )
    links.new(sock, out.inputs["Surface"])

    if item.shader_id in _TRANSPARENT_SHADERS:
        mat.blend_method = 'BLEND'
    else:
        mat.blend_method = 'OPAQUE'
    return mat


def get_effective_material(item):
    """Return the bpy.types.Material currently active for this KnotItem."""
    if item.material_mode == 'PROJECT':
        return item.project_material
    return _ensure_item_preset_material(item)


def _update_viewport_knot_material(scene) -> None:
    """Instantly update the viewport AnimKnot object's material slot."""
    obj = bpy.data.objects.get(KNOT_OBJ_NAME)
    if not obj:
        return
    idx = scene.knot_list_index
    if 0 <= idx < len(scene.knot_list):
        item = scene.knot_list[idx]
        mat = get_effective_material(item)
        if mat:
            if len(obj.data.materials) == 0:
                obj.data.materials.append(mat)
            else:
                obj.data.materials[0] = mat


# ---------------------------------------------------------------------------
# Node-copy helper for cross-material blend materials
# ---------------------------------------------------------------------------

def _copy_nodes_to_blend(src_mat, dst_mat, offset_y: int, prefix: str):
    """Robustly duplicate a source material's node setup into a blend material.

    Returns the final output shader socket so the caller can wire it into a
    MixShader.  Falls back to a plain Principled BSDF if anything goes wrong.
    """
    if not src_mat or getattr(src_mat, "node_tree", None) is None:
        nodes = dst_mat.node_tree.nodes
        fallback = nodes.new("ShaderNodeBsdfPrincipled")
        fallback.name = f"{prefix}_fallback"
        fallback.location = (-300, offset_y)
        if src_mat:
            fallback.inputs["Base Color"].default_value = src_mat.diffuse_color
        return fallback.outputs["BSDF"]

    nodes = dst_mat.node_tree.nodes
    links = dst_mat.node_tree.links

    node_map = {}
    for src_node in src_mat.node_tree.nodes:
        if src_node.type == 'OUTPUT_MATERIAL':
            continue
        new_node = nodes.new(src_node.bl_idname)
        new_node.name     = f"{prefix}_{src_node.name}"
        new_node.label    = src_node.label
        new_node.location = (src_node.location.x - 300, src_node.location.y + offset_y)

        # Copy only non-readonly primitive values
        for prop in src_node.bl_rna.properties:
            if prop.identifier in {'rna_type', 'type', 'inputs', 'outputs',
                                   'internal_links', 'bl_idname'}:
                continue
            if not prop.is_readonly and prop.type in {'BOOLEAN', 'INT', 'FLOAT', 'STRING', 'ENUM'}:
                try:
                    setattr(new_node, prop.identifier, getattr(src_node, prop.identifier))
                except Exception:
                    pass

        # Special-case: ColorRamp needs manual element duplication
        if src_node.type == 'VALTO_RGB':
            try:
                new_ramp = new_node.color_ramp
                src_ramp = src_node.color_ramp
                new_ramp.interpolation = src_ramp.interpolation
                while len(new_ramp.elements) < len(src_ramp.elements):
                    new_ramp.elements.new(0.5)
                while len(new_ramp.elements) > len(src_ramp.elements):
                    new_ramp.elements.remove(new_ramp.elements[-1])
                for idx, el in enumerate(src_ramp.elements):
                    new_ramp.elements[idx].position = el.position
                    new_ramp.elements[idx].color    = el.color
            except Exception:
                pass

        for i, inp in enumerate(src_node.inputs):
            try:
                new_node.inputs[i].default_value = inp.default_value
            except Exception:
                pass

        node_map[src_node] = new_node

    # Reconnect internal links
    for link in src_mat.node_tree.links:
        if link.to_node.type == 'OUTPUT_MATERIAL':
            continue
        try:
            from_node = node_map.get(link.from_node)
            to_node   = node_map.get(link.to_node)
            if not from_node or not to_node:
                continue
            try:
                out_sock = from_node.outputs[link.from_socket.identifier]
            except (KeyError, IndexError):
                f_idx    = list(link.from_node.outputs).index(link.from_socket)
                out_sock = from_node.outputs[f_idx] if f_idx < len(from_node.outputs) else None
            try:
                in_sock = to_node.inputs[link.to_socket.identifier]
            except (KeyError, IndexError):
                t_idx   = list(link.to_node.inputs).index(link.to_socket)
                in_sock = to_node.inputs[t_idx] if t_idx < len(to_node.inputs) else None
            if out_sock and in_sock:
                links.new(out_sock, in_sock)
        except Exception:
            pass

    # Find the source surface output socket via the Material Output node
    src_out = next(
        (n for n in src_mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL' and n.is_active_output),
        None,
    ) or next(
        (n for n in src_mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL'),
        None,
    )

    if src_out and len(src_out.inputs["Surface"].links) > 0:
        src_link    = src_out.inputs["Surface"].links[0]
        mapped_node = node_map.get(src_link.from_node)
        if mapped_node and len(mapped_node.outputs) > 0:
            try:
                return mapped_node.outputs[src_link.from_socket.identifier]
            except (KeyError, IndexError):
                pass
            try:
                f_idx = list(src_link.from_node.outputs).index(src_link.from_socket)
                if f_idx < len(mapped_node.outputs):
                    return mapped_node.outputs[f_idx]
            except (ValueError, IndexError):
                pass
            return mapped_node.outputs[0]

    # Final fallback
    fallback = nodes.new("ShaderNodeBsdfPrincipled")
    fallback.name     = f"{prefix}_fallback"
    fallback.location = (-300, offset_y)
    return fallback.outputs["BSDF"]


# ---------------------------------------------------------------------------
# Playlist blend material system
# ---------------------------------------------------------------------------

def prebuild_playlist_blend_materials(scene) -> None:
    """Pre-build cross-fade blend materials for every adjacent playlist pair.

    Called from the main thread (operators, property callbacks) so the frame
    handler never needs to allocate node trees itself.
    """
    if not hasattr(scene, "knot_list") or len(scene.knot_list) == 0:
        return

    for i, item in enumerate(scene.knot_list):
        if item.transition_frames <= 0:
            continue
        next_item = scene.knot_list[(i + 1) % len(scene.knot_list)]
        mat_a = get_effective_material(item)
        mat_b = get_effective_material(next_item)

        blend_name = f"KnotBlend_Item_{i}"
        blend_mat  = bpy.data.materials.get(blend_name)
        if not blend_mat:
            blend_mat = bpy.data.materials.new(name=blend_name)

        if hasattr(blend_mat, "use_nodes"):
            try:
                blend_mat.use_nodes = True
            except AttributeError:
                pass

        nodes = blend_mat.node_tree.nodes
        links = blend_mat.node_tree.links
        nodes.clear()

        out = nodes.new("ShaderNodeOutputMaterial")
        out.location = (600, 0)
        mix = nodes.new("ShaderNodeMixShader")
        mix.name     = "_KnotBlendMix"
        mix.location = (300, 0)

        sock_a = _copy_nodes_to_blend(mat_a, blend_mat,  250, "A")
        sock_b = _copy_nodes_to_blend(mat_b, blend_mat, -250, "B")

        links.new(sock_a,               mix.inputs[1])
        links.new(sock_b,               mix.inputs[2])
        links.new(mix.outputs["Shader"], out.inputs["Surface"])

        if (mat_a and mat_a.blend_method == 'BLEND') or (mat_b and mat_b.blend_method == 'BLEND'):
            blend_mat.blend_method = 'BLEND'
        else:
            blend_mat.blend_method = 'OPAQUE'


def prewarm_materials_and_blends(scene) -> None:
    """Ensure all playlist preset materials are generated and transitions pre-built."""
    if not hasattr(scene, "knot_list"):
        return
    for item in scene.knot_list:
        if item.material_mode == 'PRESET':
            _ensure_item_preset_material(item)
    prebuild_playlist_blend_materials(scene)
