import bpy
import math
import mathutils
import json
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union

# ============================================================
# PATHS
# ============================================================

ROBOT_JSON_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\robot.json"
OUTPUT_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\render.png"
RENDER_WIDTH = 1200
RENDER_HEIGHT = 800

# ============================================================
# FALLBACK DEFAULT MATERIALS (placeholders)
# ============================================================

DEFAULT_MATERIALS = {
    "wood": {
        "baseColor": (0.72, 0.52, 0.33, 1.0),
        "roughness": 0.85,
        "metallic": 0.0,
    },
    "plaWhite": {
        "baseColor": (0.95, 0.95, 0.95, 1.0),
        "roughness": 0.45,
        "metallic": 0.0,
    },
    "steel": {
        "baseColor": (0.72, 0.72, 0.75, 1.0),
        "roughness": 0.25,
        "metallic": 1.0,
    },
    "powderCoatBlue": {
        "baseColor": (0.15, 0.25, 0.70, 1.0),
        "roughness": 0.55,
        "metallic": 0.0,
    },
    "marbleStone": {
        "baseColor": (0.85, 0.85, 0.87, 1.0),
        "roughness": 0.95,
        "metallic": 0.0,
    },
    "defaultPlastic": {
        "baseColor": (0.95, 0.95, 0.95, 1.0),
        "roughness": 0.40,
        "metallic": 0.0,
    },
}

STATE_KEYS = ["x", "y", "z", "a", "b", "c", "e"]

# ============================================================
# JSON LOADING
# ============================================================

robot: Dict[str, Any] = {}
if Path(ROBOT_JSON_FILE).exists():
    with open(ROBOT_JSON_FILE, "r", encoding="utf-8") as f:
        robot = json.load(f)
else:
    # Minimal fallback so the script can still run during development.
    robot = {
        "renderingInfo": {
            "cameraPosition": [-500, -1100, 900],
            "cameraTarget": [0, 0, 200],
            "cameraUpVector": [0, 0, 1],
            "lightPosition": [-1000, -1000, 2000],
            "lightTarget": [0, 0, 0],
            "lightUpVector": [0, 0, 1],
            "metric": "mm",
            "materials": {},
        },
        "defaultPosition": {k: 0 for k in STATE_KEYS},
        "recognized": {k: None for k in STATE_KEYS},
        "movements": {k: None for k in STATE_KEYS},
        "links": {},
    }

rendering_info = robot.get("renderingInfo", {})
metric = rendering_info.get("metric", "mm")
scale_factor = 0.001 if metric == "mm" else 1.0

# Merge current state from multiple places.
# Priority: movements -> recognized -> defaultPosition -> 0
state: Dict[str, float] = {k: 0.0 for k in STATE_KEYS}
for source_name in ("defaultPosition", "recognized", "movements"):
    source = robot.get(source_name, {}) or {}
    for k in STATE_KEYS:
        v = source.get(k, None)
        if v is not None:
            state[k] = float(v)

# ============================================================
# HELPERS
# ============================================================

def mm_to_m(value: float) -> float:
    return value * scale_factor


def resolve_scalar(value: Any, state_map: Dict[str, float]) -> float:
    """Resolve numbers or symbolic placeholders like 'x'/'a'."""
    if value is None:
        return 0.0
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        key = value.strip().lower()
        if key in state_map:
            return float(state_map[key])
        try:
            return float(key)
        except ValueError:
            return 0.0
    return 0.0


def resolve_vector(value: Any, state_map: Dict[str, float], default_len: int = 3) -> Tuple[float, ...]:
    if value is None:
        return tuple(0.0 for _ in range(default_len))
    if isinstance(value, (int, float, str)):
        return (resolve_scalar(value, state_map),)
    if isinstance(value, (list, tuple)):
        resolved = [resolve_scalar(v, state_map) for v in value]
        if len(resolved) < default_len:
            resolved.extend([0.0] * (default_len - len(resolved)))
        return tuple(resolved[:default_len])
    return tuple(0.0 for _ in range(default_len))


def resolve_vec3_m(value: Any, state_map: Dict[str, float]) -> Tuple[float, float, float]:
    x, y, z = resolve_vector(value, state_map, default_len=3)
    return mm_to_m(x), mm_to_m(y), mm_to_m(z)


def normalize_axis(axis: Iterable[Any]) -> mathutils.Vector:
    ax = mathutils.Vector((float(axis[0]), float(axis[1]), float(axis[2])))
    if ax.length == 0:
        return mathutils.Vector((1.0, 0.0, 0.0))
    return ax.normalized()


def create_or_get_material(name: str, fallback: str = "defaultPlastic") -> bpy.types.Material:
    if name in bpy.data.materials:
        return bpy.data.materials[name]

    info = (robot.get("renderingInfo", {}) or {}).get("materials", {}) or {}
    spec = None

    # Support both dict-style and old list-style material definitions.
    if isinstance(info, dict):
        spec = info.get(name)
    elif isinstance(info, list):
        for entry in info:
            if isinstance(entry, dict) and name in entry:
                spec = entry[name]
                break

    if not isinstance(spec, dict):
        spec = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback])
    else:
        # Accept partial specs from JSON.
        base = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]).copy()
        if "baseColor" in spec:
            color = tuple(spec["baseColor"])
            base["baseColor"] = (*color[:3], 1.0) if len(color) == 3 else tuple(color[:4])
        if "roughness" in spec:
            base["roughness"] = float(spec["roughness"])
        if "metallic" in spec:
            base["metallic"] = float(spec["metallic"])
        spec = base

    mat = bpy.data.materials.new(name=name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes.get("Principled BSDF")
    if bsdf is not None:
        bsdf.inputs["Base Color"].default_value = spec["baseColor"]
        bsdf.inputs["Roughness"].default_value = spec["roughness"]
        bsdf.inputs["Metallic"].default_value = spec["metallic"]
    return mat

def import_stl(filepath: str):

    filepath = str(Path(filepath).resolve())

    if not Path(filepath).exists():
        raise FileNotFoundError(
            f"STL file not found:\\n{filepath}"
        )

    before = set(bpy.data.objects)

    bpy.ops.wm.stl_import(filepath=filepath)

    after = [
        obj for obj in bpy.data.objects
        if obj not in before
    ]

    return after


def link_object(obj: bpy.types.Object):
    if obj.name not in bpy.context.collection.objects:
        bpy.context.collection.objects.link(obj)


def create_empty(name: str, location=(0, 0, 0), rotation=(0, 0, 0)) -> bpy.types.Object:
    empty = bpy.data.objects.new(name, None)
    bpy.context.collection.objects.link(empty)
    empty.location = location
    empty.rotation_euler = rotation
    return empty


def euler_deg_xyz(values: Any) -> Tuple[float, float, float]:
    x, y, z = resolve_vector(values, state, default_len=3)
    return math.radians(x), math.radians(y), math.radians(z)


def safe_parent(child: bpy.types.Object, parent: Optional[bpy.types.Object]):
    if parent is not None:
        child.parent = parent
        # Keep current world transform visually stable after parenting.
        child.matrix_parent_inverse = parent.matrix_world.inverted()


# ============================================================
# CLEAN SCENE
# ============================================================

bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)

# ============================================================
# UNITS / WORLD
# ============================================================

scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.length_unit = "MILLIMETERS"
scene.unit_settings.scale_length = scale_factor

world = scene.world
if world is None:
    world = bpy.data.worlds.new("World")
    scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes["Background"]
bg.inputs[0].default_value = (0.70, 0.85, 1.0, 1.0)  # light blue sky
bg.inputs[1].default_value = 0.2

# ============================================================
# RENDER SETTINGS
# ============================================================

scene.render.engine = "CYCLES"
scene.view_settings.exposure = -1.5
scene.cycles.samples = 64
scene.cycles.preview_samples = 32
scene.render.resolution_x = RENDER_WIDTH
scene.render.resolution_y = RENDER_HEIGHT
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.filepath = OUTPUT_FILE
scene.render.film_transparent = False

# ============================================================
# FLOOR / CHECKERBOARD
# ============================================================

# 2m x 2m floor, centered at origin.
bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, mm_to_m(-28.0)))
floor = bpy.context.active_object

checker_mat = bpy.data.materials.new(name="Checkerboard")
checker_mat.use_nodes = True
nodes = checker_mat.node_tree.nodes
links = checker_mat.node_tree.links
nodes.clear()

output_node = nodes.new(type="ShaderNodeOutputMaterial")
bsdf_node = nodes.new(type="ShaderNodeBsdfPrincipled")
checker_node = nodes.new(type="ShaderNodeTexChecker")
mapping_node = nodes.new(type="ShaderNodeMapping")
texcoord_node = nodes.new(type="ShaderNodeTexCoord")

checker_node.inputs["Color1"].default_value = (0.82, 0.82, 0.82, 1.0)
checker_node.inputs["Color2"].default_value = (0.18, 0.18, 0.18, 1.0)

# 100mm checker squares across a 2m x 2m floor => 20 tiles each direction.
mapping_node.inputs["Scale"].default_value = (20.0, 20.0, 20.0)

links.new(texcoord_node.outputs["UV"], mapping_node.inputs["Vector"])
links.new(mapping_node.outputs["Vector"], checker_node.inputs["Vector"])
links.new(checker_node.outputs["Color"], bsdf_node.inputs["Base Color"])
links.new(bsdf_node.outputs["BSDF"], output_node.inputs["Surface"])
floor.data.materials.append(checker_mat)

# ============================================================
# CAMERA
# ============================================================

cam_data = bpy.data.cameras.new("Camera")
cam_obj = bpy.data.objects.new("Camera", cam_data)
bpy.context.collection.objects.link(cam_obj)

cam_pos = resolve_vec3_m(rendering_info.get("cameraPosition", [-500, -1100, 900]), state)
cam_target = resolve_vec3_m(rendering_info.get("cameraTarget", [0, 0, 0]), state)

cam_obj.location = cam_pos
cam_data.lens = 50

cam_vec = mathutils.Vector(cam_target) - mathutils.Vector(cam_pos)
if cam_vec.length == 0:
    cam_vec = mathutils.Vector((1, 0, 0))
cam_obj.rotation_euler = cam_vec.to_track_quat("-Z", "Y").to_euler()
scene.camera = cam_obj

# ============================================================
# LIGHTS
# ============================================================

sun_data = bpy.data.lights.new(name="Sun", type="SUN")
sun_obj = bpy.data.objects.new(name="Sun", object_data=sun_data)
bpy.context.collection.objects.link(sun_obj)

sun_pos = resolve_vec3_m(rendering_info.get("lightPosition", [-1000, -1000, 2000]), state)
light_target = resolve_vec3_m(rendering_info.get("lightTarget", [0, 0, 0]), state)
sun_obj.location = sun_pos
light_vec = mathutils.Vector(light_target) - mathutils.Vector(sun_pos)
if light_vec.length == 0:
    light_vec = mathutils.Vector((1, 0, -1))
sun_obj.rotation_euler = light_vec.to_track_quat("-Z", "Y").to_euler()
sun_data.energy = 1.0

area_data = bpy.data.lights.new(name="AreaLight", type="AREA")
area_obj = bpy.data.objects.new(name="AreaLight", object_data=area_data)
bpy.context.collection.objects.link(area_obj)
area_obj.location = (mm_to_m(-800), mm_to_m(-1200), mm_to_m(1500))
area_obj.rotation_euler = (math.radians(60), 0.0, math.radians(-20))
area_data.energy = 300
area_data.size = 2.0

# ============================================================
# ROBOT BUILDING
# ============================================================

links_def = robot.get("links")
if links_def is None:
    # Backward compatibility with older name.
    links_def = robot.get("ElementInfos", {})

created_nodes: Dict[str, bpy.types.Object] = {}

# Create all link containers first.
for link_name in links_def.keys():
    created_nodes[link_name] = create_empty(f"{link_name}_link")

# Parent/position link containers.
for link_name, link_info in links_def.items():
    parent_name = link_info.get("parent")
    parent_obj = created_nodes.get(parent_name) if parent_name else None

    link_obj = created_nodes[link_name]
    safe_parent(link_obj, parent_obj)

    # Static mounting transform relative to parent.
    # Keep the extra info, but rename it to mountRotation in your JSON.
    mount_pos = link_info.get("mountPosition", link_info.get("originInParentCoordinates", [0, 0, 0]))
    mount_rot = link_info.get("mountRotation", link_info.get("rotationInParentCoordinates", [0, 0, 0]))

    link_obj.location = resolve_vec3_m(mount_pos, state)
    link_obj.rotation_euler = euler_deg_xyz(mount_rot)

    # Joint transform (child-owned).
    joint = link_info.get("jointToParent") or link_info.get("joint")
    if isinstance(joint, dict):
        joint_origin = joint.get("origin", [0, 0, 0])
        joint_rot = joint.get("rotation", [0, 0, 0])
        joint_type = joint.get("type", "fixed")
        control_var = str(joint.get("variable", joint.get("control", ""))).lower()
        axis = joint.get("axis", [1, 0, 0])

        joint_offset = create_empty(f"{link_name}_joint")
        safe_parent(joint_offset, link_obj)
        joint_offset.location = resolve_vec3_m(joint_origin, state)
        joint_offset.rotation_euler = euler_deg_xyz(joint_rot)

        # Motion node under the joint offset.
        motion_node = create_empty(f"{link_name}_motion")
        safe_parent(motion_node, joint_offset)

        if joint_type == "linear":
            # Linear joint moves along its local axis by the control value.
            move_val_mm = state.get(control_var, 0.0) if control_var else 0.0
            axis_v = normalize_axis(axis)
            motion_node.location = axis_v * mm_to_m(move_val_mm)
        elif joint_type == "revolute":
            # Revolute joint rotates around its local axis by the control value.
            angle_deg = state.get(control_var, 0.0) if control_var else 0.0
            axis_v = normalize_axis(axis)
            # Convert axis-angle to Euler in local space by using rotation_difference.
            quat = mathutils.Quaternion(axis_v, math.radians(angle_deg))
            motion_node.rotation_euler = quat.to_euler()
        else:
            # fixed / unknown => no motion
            pass

        # The link container sits under the motion node.
        safe_parent(link_obj, motion_node)

# Import and attach all meshes for every link.
for link_name, link_info in links_def.items():
    link_obj = created_nodes[link_name]
    model_list = link_info.get("model", [])
    if not isinstance(model_list, list):
        model_list = []

    # Optional single-file shorthand.
    if "stlFile" in link_info:
        model_list = model_list + [{"stlFile": link_info["stlFile"]}]

    for idx, model_def in enumerate(model_list):
        stl_file = model_def.get("stlFile")
        if not stl_file:
            continue

        base_dir = Path(ROBOT_JSON_FILE).parent if Path(ROBOT_JSON_FILE).exists() else Path.cwd()
        stl_path = (base_dir / stl_file).resolve()
        if not stl_path.exists():
            # Try the file as given.
            stl_path = Path(stl_file).resolve()

        imported = import_stl(str(stl_path))

        # Create a mesh container for each imported STL so a link can have many surfaces.
        mesh_container = create_empty(f"{link_name}_mesh_{idx}")
        safe_parent(mesh_container, link_obj)

        origin_of_model = model_def.get("originOfModel", [0, 0, 0])
        rot_of_model = model_def.get("rotationOfModelDegree", [0, 0, 0])
        mesh_container.location = resolve_vec3_m(origin_of_model, state)
        mesh_container.rotation_euler = euler_deg_xyz(rot_of_model)

        material_name = model_def.get("material", "defaultPlastic")
        material = create_or_get_material(material_name)

        for obj in imported:
            if obj.type != "MESH":
                continue
            safe_parent(obj, mesh_container)
            # Keep STL imports at their own local origin; only scale to meters.
            obj.scale = (scale_factor, scale_factor, scale_factor)
            if len(obj.data.materials) == 0:
                obj.data.materials.append(material)
            else:
                obj.data.materials[0] = material


# ============================================================
# DEBUG WORLD AXES
# ============================================================

def create_axis_arrow(
    name,
    direction,
    color,
    length_mm=200,
    radius_mm=2,
    cone_radius_mm=5,
    cone_length_mm=20
):
    length = mm_to_m(length_mm)
    radius = mm_to_m(radius_mm)
    cone_radius = mm_to_m(cone_radius_mm)
    cone_length = mm_to_m(cone_length_mm)

    dir_vec = mathutils.Vector(direction).normalized()

    # --------------------------------------------------------
    # CYLINDER
    # --------------------------------------------------------

    bpy.ops.mesh.primitive_cylinder_add(
        radius=radius,
        depth=length - cone_length
    )

    cyl = bpy.context.active_object
    cyl.name = f"{name}_shaft"

    # Blender cylinder points along Z by default
    cyl.rotation_mode = 'QUATERNION'
    cyl.rotation_quaternion = (
        mathutils.Vector((0, 0, 1))
        .rotation_difference(dir_vec)
    )

    cyl.location = dir_vec * ((length - cone_length) * 0.5)

    # --------------------------------------------------------
    # CONE
    # --------------------------------------------------------

    bpy.ops.mesh.primitive_cone_add(
        radius1=cone_radius,
        depth=cone_length
    )

    cone = bpy.context.active_object
    cone.name = f"{name}_tip"

    cone.rotation_mode = 'QUATERNION'
    cone.rotation_quaternion = (
        mathutils.Vector((0, 0, 1))
        .rotation_difference(dir_vec)
    )

    cone.location = dir_vec * (length - cone_length * 0.5)

    # --------------------------------------------------------
    # MATERIAL
    # --------------------------------------------------------

    mat = bpy.data.materials.new(name=f"{name}_material")
    mat.use_nodes = True

    bsdf = mat.node_tree.nodes["Principled BSDF"]

    bsdf.inputs["Base Color"].default_value = (
        color[0],
        color[1],
        color[2],
        1.0
    )

    bsdf.inputs["Roughness"].default_value = 0.3
    bsdf.inputs["Metallic"].default_value = 0.0

    cyl.data.materials.append(mat)
    cone.data.materials.append(mat)


# ------------------------------------------------------------
# CREATE XYZ AXES
# ------------------------------------------------------------

# X = red
create_axis_arrow(
    "AxisX",
    (1, 0, 0),
    (1, 0, 0)
)

# Y = green
create_axis_arrow(
    "AxisY",
    (0, 1, 0),
    (0, 1, 0)
)

# Z = blue
create_axis_arrow(
    "AxisZ",
    (0, 0, 1),
    (0, 0, 1)
)


# ============================================================
# FINAL RENDER
# ============================================================

bpy.ops.render.render(write_still=True)
print("Finished rendering:", OUTPUT_FILE)
