"""Blender Python script: cinematic product highlight render.

4-segment camera animation (480 frames @ 24fps = 20s):
  Segment 1 (1-120):   Establishing shot — slow orbit + push-in, 50mm
  Segment 2 (121-240): Detail sweep — low arc, telephoto 85mm, shallow DOF
  Segment 3 (241-360): Crane up — rising pull-back, wide 35mm
  Segment 4 (361-480): Hero close — final push-in, 65mm, smooth deceleration

Usage (from Blender):
  blender --background --python cinematic_render.py -- \
    <glb_path> <frames_dir> <frame_count> <degrees> <width> <height> \
    <engine> <samples> <part_colors_json> \
    [template_path] [target_collection] [material_library_path] [material_map_json] \
    [part_names_ordered_json] [lighting_only] [cycles_device] [shadow_catcher] \
    [rotation_x] [rotation_y] [rotation_z] [turntable_axis] [bg_color] [transparent_bg]

Named arguments (after --):
    --mesh-attributes <json>
    --usd-path <path>
    --focal-length <mm>           (ignored — cinematic uses per-segment lenses)
    --sensor-width <mm>
    --material-override <name>
    --camera-orbit                (always true for cinematic — camera moves, not model)
"""
import bpy
import sys
import os
import json
import math
from mathutils import Vector, Matrix

# ── Colour palette (matches turntable_render.py / blender_render.py) ─────────
PALETTE_HEX = [
    "#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
    "#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]

def _srgb_to_linear(c: int) -> float:
    v = c / 255.0
    return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4

def _hex_to_linear(hex_color: str) -> tuple:
    h = hex_color.lstrip('#')
    return (
        _srgb_to_linear(int(h[0:2], 16)),
        _srgb_to_linear(int(h[2:4], 16)),
        _srgb_to_linear(int(h[4:6], 16)),
        1.0,
    )

PALETTE_LINEAR = [_hex_to_linear(h) for h in PALETTE_HEX]

SMOOTH_ANGLE = 30  # degrees


# ── Helper functions (copied from turntable_render.py) ───────────────────────

def _ensure_collection(name: str):
    """Return a collection by name, creating it if needed."""
    if name in bpy.data.collections:
        return bpy.data.collections[name]
    col = bpy.data.collections.new(name)
    bpy.context.scene.collection.children.link(col)
    return col


def _assign_palette_material(part_obj, index):
    """Assign a palette colour material to a mesh part."""
    color = PALETTE_LINEAR[index % len(PALETTE_LINEAR)]
    mat = bpy.data.materials.new(name=f"Part_{index}")
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes.get("Principled BSDF")
    if bsdf:
        bsdf.inputs["Base Color"].default_value = color
        bsdf.inputs["Metallic"].default_value = 0.35
        bsdf.inputs["Roughness"].default_value = 0.40
        try:
            bsdf.inputs["Specular IOR Level"].default_value = 0.5
        except KeyError:
            pass
    part_obj.data.materials.clear()
    part_obj.data.materials.append(mat)


def _apply_smooth(part_obj, angle_deg):
    """Apply smooth or flat shading to a mesh object."""
    bpy.context.view_layer.objects.active = part_obj
    part_obj.select_set(True)
    if angle_deg > 0:
        try:
            bpy.ops.object.shade_smooth_by_angle(angle=math.radians(angle_deg))
        except AttributeError:
            bpy.ops.object.shade_smooth()
            part_obj.data.use_auto_smooth = True
            part_obj.data.auto_smooth_angle = math.radians(angle_deg)
    else:
        bpy.ops.object.shade_flat()


import re as _re


def _apply_rotation(parts, rx, ry, rz):
    """Apply Euler XYZ rotation (degrees) to all parts by modifying matrix_world."""
    if not parts or (rx == 0.0 and ry == 0.0 and rz == 0.0):
        return
    from mathutils import Euler
    rot_mat = Euler((math.radians(rx), math.radians(ry), math.radians(rz)), 'XYZ').to_matrix().to_4x4()
    for p in parts:
        p.matrix_world = rot_mat @ p.matrix_world
    bpy.ops.object.select_all(action='DESELECT')
    for p in parts:
        p.select_set(True)
    bpy.context.view_layer.objects.active = parts[0]
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
    print(f"[cinematic_render] applied rotation ({rx}, {ry}, {rz}) to {len(parts)} parts")


def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
    """Apply topology-based shading settings from OCC analysis."""
    if not mesh_attributes or mesh_attributes.get("error"):
        return
    curved_ratio = mesh_attributes.get("curved_ratio", 0.0)
    threshold_deg = mesh_attributes.get("sharp_angle_threshold_deg", 30.0)
    threshold_rad = threshold_deg * math.pi / 180.0
    for obj in objects:
        if obj.type != 'MESH':
            continue
        if curved_ratio > 0.3:
            for poly in obj.data.polygons:
                poly.use_smooth = True
        obj.data.use_auto_smooth = True
        obj.data.auto_smooth_angle = threshold_rad


def _import_glb(glb_file):
    """Import OCC-generated GLB into Blender.

    Returns list of Blender mesh objects, centred at world origin.
    """
    bpy.ops.object.select_all(action='DESELECT')
    bpy.ops.import_scene.gltf(filepath=glb_file)
    parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']

    if not parts:
        print(f"ERROR: No mesh objects imported from {glb_file}")
        sys.exit(1)

    print(f"[cinematic_render] imported {len(parts)} part(s) from GLB: "
          f"{[p.name for p in parts[:5]]}")

    # Centre combined bbox at world origin
    all_corners = []
    for p in parts:
        all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)

    if all_corners:
        mins = Vector((min(v.x for v in all_corners),
                       min(v.y for v in all_corners),
                       min(v.z for v in all_corners)))
        maxs = Vector((max(v.x for v in all_corners),
                       max(v.y for v in all_corners),
                       max(v.z for v in all_corners)))
        center = (mins + maxs) * 0.5
        all_imported = list(bpy.context.selected_objects)
        root_objects = [o for o in all_imported if o.parent is None]
        for obj in root_objects:
            obj.location -= center

    return parts


def _resolve_part_name(index, part_obj, part_names_ordered):
    """Get the STEP part name for a Blender part by index."""
    base_name = _re.sub(r'\.\d{3}$', '', part_obj.name)
    if part_names_ordered and index < len(part_names_ordered):
        return part_names_ordered[index]
    return base_name


def _apply_material_library(parts, mat_lib_path, mat_map, part_names_ordered=None):
    """Append materials from library .blend and assign to parts via material_map."""
    if not mat_lib_path or not os.path.isfile(mat_lib_path):
        print(f"[cinematic_render] material library not found: {mat_lib_path}")
        return

    needed = set(mat_map.values())
    if not needed:
        return

    appended = {}
    for mat_name in needed:
        inner_path = f"{mat_lib_path}/Material/{mat_name}"
        try:
            bpy.ops.wm.append(
                filepath=inner_path,
                directory=f"{mat_lib_path}/Material/",
                filename=mat_name,
                link=False,
            )
            if mat_name in bpy.data.materials:
                appended[mat_name] = bpy.data.materials[mat_name]
                print(f"[cinematic_render] appended material: {mat_name}")
            else:
                print(f"[cinematic_render] WARNING: material '{mat_name}' not found after append")
        except Exception as exc:
            print(f"[cinematic_render] WARNING: failed to append material '{mat_name}': {exc}")

    if not appended:
        return

    assigned_count = 0
    for i, part in enumerate(parts):
        base_name = _re.sub(r'\.\d{3}$', '', part.name)
        _prev = None
        while _prev != base_name:
            _prev = base_name
            base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
        part_key = base_name.lower().strip()
        mat_name = mat_map.get(part_key)

        if not mat_name:
            for key, val in sorted(mat_map.items(), key=lambda x: len(x[0]), reverse=True):
                if len(key) >= 5 and len(part_key) >= 5 and (
                    part_key.startswith(key) or key.startswith(part_key)
                ):
                    mat_name = val
                    break

        if not mat_name and part_names_ordered and i < len(part_names_ordered):
            step_name = part_names_ordered[i]
            step_key = step_name.lower().strip()
            mat_name = mat_map.get(step_key)
            if not mat_name:
                _p2 = None
                while _p2 != step_key:
                    _p2 = step_key
                    step_key = _re.sub(r'_af\d+$', '', step_key)
                mat_name = mat_map.get(step_key)

        if mat_name and mat_name in appended:
            part.data.materials.clear()
            part.data.materials.append(appended[mat_name])
            assigned_count += 1
            print(f"[cinematic_render] assigned '{mat_name}' to part '{part.name}'")

    print(f"[cinematic_render] material assignment: {assigned_count}/{len(parts)} parts matched")


# ── Cinematic camera animation ───────────────────────────────────────────────

TOTAL_FRAMES = 250
NUM_SEGMENTS = 3
SEGMENT_LENGTH = TOTAL_FRAMES // NUM_SEGMENTS  # ~83 frames per segment

# 3 segments — full product always visible, no clipping
# Distance factor is multiplied by bsphere_radius — keep > 2.5× to ensure full object is in frame
SEGMENTS = [
    # Segment 1: Establishing orbit — slow 60° orbit at comfortable distance
    {
        "az_start": 0.0, "az_end": 60.0,
        "el_start": 25.0, "el_end": 20.0,
        "dist_start": 3.5, "dist_end": 3.0,
        "lens_start": 50.0, "lens_end": 50.0,
        "dof": False,
    },
    # Segment 2: Low angle sweep — arc 50° from lower perspective
    {
        "az_start": 60.0, "az_end": 110.0,
        "el_start": 15.0, "el_end": 20.0,
        "dist_start": 3.0, "dist_end": 3.2,
        "lens_start": 50.0, "lens_end": 65.0,
        "dof": False,
    },
    # Segment 3: Crane up — rise to high angle with pull-back
    {
        "az_start": 110.0, "az_end": 150.0,
        "el_start": 20.0, "el_end": 50.0,
        "dist_start": 3.2, "dist_end": 4.0,
        "lens_start": 50.0, "lens_end": 40.0,
        "dof": False,
    },
]


def _lerp(a: float, b: float, t: float) -> float:
    """Linear interpolation."""
    return a + (b - a) * t


def _spherical_to_xyz(azimuth_deg: float, elevation_deg: float,
                      distance: float, center: Vector) -> Vector:
    """Convert spherical coordinates to Cartesian position."""
    az = math.radians(azimuth_deg)
    el = math.radians(elevation_deg)
    x = center.x + distance * math.cos(el) * math.cos(az)
    y = center.y + distance * math.cos(el) * math.sin(az)
    z = center.z + distance * math.sin(el)
    return Vector((x, y, z))


def _get_segment_params(frame: int, bsphere_radius: float):
    """Compute camera parameters for a given frame.

    Returns (azimuth_deg, elevation_deg, distance, lens_mm, use_dof).
    """
    # Determine which segment and local t (0-1) — linear interpolation
    seg_index = min((frame - 1) // SEGMENT_LENGTH, len(SEGMENTS) - 1)
    local_frame = (frame - 1) - seg_index * SEGMENT_LENGTH
    t = local_frame / max(SEGMENT_LENGTH - 1, 1)  # linear, no easing

    seg = SEGMENTS[seg_index]

    azimuth = _lerp(seg["az_start"], seg["az_end"], t)
    elevation = _lerp(seg["el_start"], seg["el_end"], t)
    dist_factor = _lerp(seg["dist_start"], seg["dist_end"], t)
    distance = dist_factor * bsphere_radius
    lens = _lerp(seg["lens_start"], seg["lens_end"], t)
    use_dof = seg["dof"]

    return azimuth, elevation, distance, lens, use_dof


def _setup_cinematic_camera(parts, bbox_center, bsphere_radius, total_frames, width=1920, height=1080):
    """Create camera and keyframe the cinematic animation.

    Takes aspect ratio into account — wider formats push the camera
    further back so the product isn't clipped vertically.

    Returns the camera object.
    """
    # Starting azimuth: offset so segment 1 starts from a good angle (40deg)
    base_azimuth = 40.0

    # Aspect ratio correction: for wide formats (16:9), vertical FOV is tighter
    # so we need more distance. For square/portrait, no correction needed.
    aspect_correction = max(1.0, (width / height) * 0.7) if height > 0 else 1.0

    # Create camera
    start_az, start_el, start_dist, start_lens, _ = _get_segment_params(1, bsphere_radius)
    start_pos = _spherical_to_xyz(base_azimuth + start_az, start_el, start_dist, bbox_center)
    bpy.ops.object.camera_add(location=start_pos)
    cam_obj = bpy.context.active_object
    bpy.context.scene.camera = cam_obj
    cam_obj.data.lens = start_lens
    cam_obj.data.clip_start = max(bsphere_radius * 0.001, 0.0001)
    cam_obj.data.clip_end = bsphere_radius * 20.0

    # Set sensor width
    cam_obj.data.sensor_width = 36.0

    # DOF defaults (will be toggled per-segment)
    cam_obj.data.dof.use_dof = False
    cam_obj.data.dof.focus_distance = bsphere_radius * 2.0
    cam_obj.data.dof.aperture_fstop = bsphere_radius * 8.0

    print(f"[cinematic_render] animating {total_frames} frames, bsphere_radius={bsphere_radius:.4f}")

    # Keyframe every frame for smooth cinematic motion
    for frame in range(1, total_frames + 1):
        bpy.context.scene.frame_set(frame)

        azimuth, elevation, distance, lens, use_dof = _get_segment_params(frame, bsphere_radius)
        distance *= aspect_correction  # scale for aspect ratio
        azimuth += base_azimuth  # offset from base viewing angle

        # Camera position from spherical coordinates
        position = _spherical_to_xyz(azimuth, elevation, distance, bbox_center)
        cam_obj.location = position
        cam_obj.keyframe_insert(data_path="location", frame=frame)

        # Point camera at center using track quaternion
        direction = bbox_center - cam_obj.location
        rot = direction.to_track_quat('-Z', 'Y')
        cam_obj.rotation_euler = rot.to_euler()
        cam_obj.keyframe_insert(data_path="rotation_euler", frame=frame)

        # Focal length animation
        cam_obj.data.lens = lens
        cam_obj.data.keyframe_insert(data_path="lens", frame=frame)

        # DOF animation
        cam_obj.data.dof.use_dof = use_dof
        cam_obj.data.dof.keyframe_insert(data_path="use_dof", frame=frame)
        if use_dof:
            cam_obj.data.dof.focus_distance = direction.length
            cam_obj.data.dof.aperture_fstop = bsphere_radius * 8.0
        cam_obj.data.dof.keyframe_insert(data_path="focus_distance", frame=frame)
        cam_obj.data.dof.keyframe_insert(data_path="aperture_fstop", frame=frame)

    # Set all keyframes to LINEAR interpolation (no bezier smoothing)
    def _set_linear(anim_data):
        if not anim_data or not anim_data.action:
            return
        action = anim_data.action
        # Blender 5.0+: action.fcurves may be action.channels or similar
        curves = None
        if hasattr(action, 'fcurves'):
            curves = action.fcurves
        elif hasattr(action, 'channels'):
            curves = action.channels
        if curves:
            for fc in curves:
                for kp in fc.keyframe_points:
                    kp.interpolation = 'LINEAR'

    try:
        _set_linear(cam_obj.animation_data)
        _set_linear(cam_obj.data.animation_data)
        if hasattr(cam_obj.data, 'dof') and cam_obj.data.dof:
            _set_linear(getattr(cam_obj.data.dof, 'animation_data', None))
    except Exception as e:
        print(f"[cinematic_render] WARNING: could not set LINEAR interpolation: {e}", flush=True)

    print(f"[cinematic_render] camera keyframed: {total_frames} frames across {len(SEGMENTS)} segments", flush=True)
    return cam_obj


# ── Main ─────────────────────────────────────────────────────────────────────

def main():
    argv = sys.argv
    # Everything after "--" is our args
    args = argv[argv.index("--") + 1:]

    glb_path = args[0]
    frames_dir = args[1]
    frame_count = int(args[2])
    degrees = int(args[3])  # kept for arg compatibility, not used in cinematic
    width = int(args[4])
    height = int(args[5])
    engine = args[6]
    samples = int(args[7])
    part_colors_json = args[8] if len(args) > 8 else "{}"

    # Template + material library args (same positional layout as turntable_render.py)
    template_path = args[9] if len(args) > 9 and args[9] else ""
    target_collection = args[10] if len(args) > 10 else "Product"
    material_library_path = args[11] if len(args) > 11 and args[11] else ""
    material_map_raw = args[12] if len(args) > 12 else "{}"
    part_names_ordered_raw = args[13] if len(args) > 13 else "[]"
    lighting_only = args[14] == "1" if len(args) > 14 else False
    cycles_device = args[15].lower() if len(args) > 15 else "auto"
    shadow_catcher = args[16] == "1" if len(args) > 16 else False
    rotation_x = float(args[17]) if len(args) > 17 else 0.0
    rotation_y = float(args[18]) if len(args) > 18 else 0.0
    rotation_z = float(args[19]) if len(args) > 19 else 0.0
    turntable_axis = args[20] if len(args) > 20 else "world_z"  # unused in cinematic
    bg_color = args[21] if len(args) > 21 else ""
    transparent_bg = args[22] == "1" if len(args) > 22 else False

    # Named argument: --mesh-attributes <json>
    _mesh_attrs: dict = {}
    if "--mesh-attributes" in argv:
        _idx = argv.index("--mesh-attributes")
        try:
            _mesh_attrs = json.loads(argv[_idx + 1])
        except Exception:
            pass

    # Named argument: --usd-path <path>
    usd_path = ""
    if "--usd-path" in argv:
        _usd_idx = argv.index("--usd-path")
        usd_path = argv[_usd_idx + 1] if _usd_idx + 1 < len(argv) else ""

    # Named argument: --sensor-width <mm>
    _sensor_width = None
    if "--sensor-width" in argv:
        _idx = argv.index("--sensor-width")
        _sensor_width = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None

    # Named argument: --material-override <material_name>
    _material_override = None
    if "--material-override" in argv:
        _idx = argv.index("--material-override")
        _material_override = argv[_idx + 1] if _idx + 1 < len(argv) else None

    # Cinematic always uses camera orbit (camera moves, model stays)
    camera_orbit = True

    # Override frame count to cinematic default if not explicitly set differently
    if frame_count <= 0:
        frame_count = TOTAL_FRAMES

    # Ensure scripts dir is on path for shared module imports
    _scripts_dir = os.path.dirname(os.path.abspath(__file__))
    if _scripts_dir not in sys.path:
        sys.path.insert(0, _scripts_dir)

    # Pre-load USD import helper
    _import_usd_file = None
    if usd_path:
        from import_usd import import_usd_file as _import_usd_file  # type: ignore[assignment]

    # Shared material helpers (handle USD stub collisions correctly)
    from _blender_materials import (
        apply_material_library_direct as _apply_material_library_direct,
        apply_material_library as _apply_material_library_shared,
        build_mat_map_lower as _build_mat_map_lower,
        assign_failed_material as _assign_failed_material,
    )

    os.makedirs(frames_dir, exist_ok=True)

    try:
        part_colors = json.loads(part_colors_json)
    except json.JSONDecodeError:
        part_colors = {}

    try:
        material_map = json.loads(material_map_raw) if material_map_raw else {}
    except json.JSONDecodeError:
        material_map = {}

    try:
        part_names_ordered = json.loads(part_names_ordered_raw) if part_names_ordered_raw else []
    except json.JSONDecodeError:
        part_names_ordered = []

    # Validate template path
    if template_path and not os.path.isfile(template_path):
        print(f"[cinematic_render] ERROR: template_path was provided but file not found: {template_path}")
        print("[cinematic_render] Ensure the blend-templates directory is accessible on this worker.")
        sys.exit(1)

    use_template = bool(template_path)

    print(f"[cinematic_render] engine={engine}, samples={samples}, size={width}x{height}, "
          f"frames={frame_count}")
    print(f"[cinematic_render] part_names_ordered: {len(part_names_ordered)} entries")
    if use_template:
        print(f"[cinematic_render] template={template_path}, collection={target_collection}, lighting_only={lighting_only}")
    else:
        print("[cinematic_render] no template -- using factory settings (Mode A)")
    if material_library_path:
        print(f"[cinematic_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")

    # ── SCENE SETUP ──────────────────────────────────────────────────────────
    _usd_mat_lookup: dict = {}

    if use_template:
        # ── MODE B: Template-based render ────────────────────────────────────
        print(f"[cinematic_render] Opening template: {template_path}")
        bpy.ops.wm.open_mainfile(filepath=template_path)

        target_col = _ensure_collection(target_collection)

        if usd_path and _import_usd_file:
            parts, _usd_mat_lookup = _import_usd_file(usd_path)
        else:
            parts = _import_glb(glb_path)
        _apply_rotation(parts, rotation_x, rotation_y, rotation_z)
        _apply_mesh_attributes(parts, _mesh_attrs)

        # Move imported parts into target collection
        for part in parts:
            for col in list(part.users_collection):
                col.objects.unlink(part)
            target_col.objects.link(part)

        # Apply smooth shading
        for part in parts:
            _apply_smooth(part, SMOOTH_ANGLE)

        # Apply material override if set
        if _material_override:
            print(f"[cinematic_render] material_override active: all parts -> {_material_override}", flush=True)
            if _usd_mat_lookup:
                _usd_mat_lookup = {k: _material_override for k in _usd_mat_lookup}
            if material_map:
                material_map = {k: _material_override for k in material_map}

        # Material assignment: USD primvar path first, then name-matching fallback
        if material_library_path and _usd_mat_lookup:
            _apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
            if material_map:
                _unassigned = [p for p in parts if not p.data.materials or
                               (len(p.data.materials) == 1 and p.data.materials[0] and
                                p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
                if _unassigned:
                    print(f"[cinematic_render] {len(_unassigned)} parts without USD primvar -- "
                          f"falling back to name-matching", flush=True)
                    _apply_material_library_shared(
                        _unassigned, material_library_path,
                        _build_mat_map_lower(material_map), part_names_ordered,
                    )
        elif material_library_path and material_map:
            _apply_material_library_shared(
                parts, material_library_path,
                _build_mat_map_lower(material_map), part_names_ordered,
            )
        # Palette fallback for any parts still without materials
        for i, part in enumerate(parts):
            if not part.data.materials or len(part.data.materials) == 0:
                _assign_palette_material(part, i)

        # ── Shadow catcher (Cycles only, template mode only) ─────────────────
        if shadow_catcher:
            sc_col_name = "Shadowcatcher"
            sc_obj_name = "Shadowcatcher"
            for vl in bpy.context.scene.view_layers:
                def _enable_col_recursive(layer_col):
                    if layer_col.collection.name == sc_col_name:
                        layer_col.exclude = False
                        layer_col.collection.hide_render = False
                        layer_col.collection.hide_viewport = False
                        return True
                    for child in layer_col.children:
                        if _enable_col_recursive(child):
                            return True
                    return False
                _enable_col_recursive(vl.layer_collection)

            sc_obj = bpy.data.objects.get(sc_obj_name)
            if sc_obj:
                all_world_z = []
                for part in parts:
                    for corner in part.bound_box:
                        all_world_z.append((part.matrix_world @ Vector(corner)).z)
                if all_world_z:
                    sc_obj.location.z = min(all_world_z)
                print(f"[cinematic_render] shadow catcher enabled, plane Z={sc_obj.location.z:.4f}")
            else:
                print(f"[cinematic_render] WARNING: shadow catcher object '{sc_obj_name}' not found in template")

        print(f"[cinematic_render] template mode: {len(parts)} parts imported into collection '{target_collection}'")

    else:
        # ── MODE A: Factory settings ─────────────────────────────────────────
        bpy.ops.wm.read_factory_settings(use_empty=True)

        if usd_path and _import_usd_file:
            parts, _usd_mat_lookup = _import_usd_file(usd_path)
        else:
            parts = _import_glb(glb_path)
        _apply_rotation(parts, rotation_x, rotation_y, rotation_z)
        _apply_mesh_attributes(parts, _mesh_attrs)

        for i, part in enumerate(parts):
            _apply_smooth(part, SMOOTH_ANGLE)

        # Apply material override if set
        if _material_override:
            print(f"[cinematic_render] material_override active (Mode A): all parts -> {_material_override}", flush=True)
            if _usd_mat_lookup:
                _usd_mat_lookup = {k: _material_override for k in _usd_mat_lookup}
            if material_map:
                material_map = {k: _material_override for k in material_map}

        # Material assignment: USD primvar path first, then name-matching fallback
        if material_library_path and _usd_mat_lookup:
            _apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
            if material_map:
                _unassigned = [p for p in parts if not p.data.materials or
                               (len(p.data.materials) == 1 and p.data.materials[0] and
                                p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
                if _unassigned:
                    _apply_material_library_shared(
                        _unassigned, material_library_path,
                        _build_mat_map_lower(material_map), part_names_ordered,
                    )
        elif material_library_path and material_map:
            _apply_material_library_shared(
                parts, material_library_path,
                _build_mat_map_lower(material_map), part_names_ordered,
            )
        else:
            for i, part in enumerate(parts):
                step_name = _resolve_part_name(i, part, part_names_ordered)
                color_hex = part_colors.get(step_name)
                if color_hex:
                    mat = bpy.data.materials.new(name=f"mat_{part.name}")
                    mat.use_nodes = True
                    bsdf = mat.node_tree.nodes.get("Principled BSDF")
                    if bsdf:
                        color = _hex_to_linear(color_hex)
                        bsdf.inputs["Base Color"].default_value = color
                        bsdf.inputs["Metallic"].default_value = 0.35
                        bsdf.inputs["Roughness"].default_value = 0.40
                        try:
                            bsdf.inputs["Specular IOR Level"].default_value = 0.5
                        except KeyError:
                            pass
                    part.data.materials.clear()
                    part.data.materials.append(mat)
                else:
                    _assign_palette_material(part, i)
        # Palette fallback for any parts still without materials
        for i, part in enumerate(parts):
            if not part.data.materials or len(part.data.materials) == 0:
                _assign_palette_material(part, i)

    # ── Combined bounding box / bounding sphere ──────────────────────────────
    all_corners = []
    for part in parts:
        all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box)

    bbox_min = Vector((
        min(v.x for v in all_corners),
        min(v.y for v in all_corners),
        min(v.z for v in all_corners),
    ))
    bbox_max = Vector((
        max(v.x for v in all_corners),
        max(v.y for v in all_corners),
        max(v.z for v in all_corners),
    ))

    bbox_center = (bbox_min + bbox_max) * 0.5
    bbox_dims = bbox_max - bbox_min
    bsphere_radius = max(bbox_dims.length * 0.5, 0.001)

    print(f"[cinematic_render] bbox_dims={tuple(round(d, 4) for d in bbox_dims)}, "
          f"bsphere_radius={bsphere_radius:.4f}")

    # ── Lighting -- only in Mode A (factory settings) ────────────────────────
    if not use_template:
        light_dist = bsphere_radius * 6.0

        bpy.ops.object.light_add(type='SUN', location=(
            bbox_center.x + light_dist * 0.5,
            bbox_center.y - light_dist * 0.35,
            bbox_center.z + light_dist,
        ))
        sun = bpy.context.active_object
        sun.data.energy = 4.0
        sun.rotation_euler = (math.radians(45), 0, math.radians(30))

        bpy.ops.object.light_add(type='AREA', location=(
            bbox_center.x - light_dist * 0.4,
            bbox_center.y + light_dist * 0.4,
            bbox_center.z + light_dist * 0.7,
        ))
        fill = bpy.context.active_object
        fill.data.energy = max(800.0, bsphere_radius ** 2 * 2000.0)
        fill.data.size = max(4.0, bsphere_radius * 4.0)

        # World background
        world = bpy.data.worlds.new("World")
        bpy.context.scene.world = world
        world.use_nodes = True
        bg = world.node_tree.nodes["Background"]
        bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
        bg.inputs["Strength"].default_value = 0.15

    # ── Cinematic camera animation ───────────────────────────────────────────
    # Remove any existing template camera — cinematic always creates its own
    if bpy.context.scene.camera:
        old_cam = bpy.context.scene.camera
        bpy.data.objects.remove(old_cam, do_unlink=True)

    scene = bpy.context.scene
    scene.frame_start = 1
    scene.frame_end = frame_count

    camera = _setup_cinematic_camera(parts, bbox_center, bsphere_radius, frame_count, width, height)

    # ── Colour management ────────────────────────────────────────────────────
    if not use_template:
        scene.view_settings.view_transform = 'Standard'
        scene.view_settings.exposure = 0.0
        scene.view_settings.gamma = 1.0
        try:
            scene.view_settings.look = 'None'
        except Exception:
            pass

    # ── Render engine ────────────────────────────────────────────────────────
    if engine == "eevee":
        eevee_ok = False
        for eevee_id in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'):
            try:
                scene.render.engine = eevee_id
                eevee_ok = True
                print(f"[cinematic_render] EEVEE engine id: {eevee_id}")
                break
            except TypeError:
                continue
        if eevee_ok:
            for attr in ('taa_render_samples', 'samples'):
                try:
                    setattr(scene.eevee, attr, samples)
                    break
                except AttributeError:
                    continue
        else:
            print("[cinematic_render] WARNING: EEVEE not available, falling back to Cycles")
            engine = "cycles"

    if engine != "eevee":
        scene.render.engine = 'CYCLES'
        scene.cycles.samples = samples
        scene.cycles.use_denoising = True
        scene.cycles.denoiser = 'OPENIMAGEDENOISE'
        print(f"[cinematic_render] cycles_device={cycles_device}")
        gpu_found = False
        if cycles_device != "cpu":
            try:
                cycles_prefs = bpy.context.preferences.addons['cycles'].preferences
                for device_type in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'):
                    try:
                        cycles_prefs.compute_device_type = device_type
                        cycles_prefs.get_devices()
                        gpu_devs = [d for d in cycles_prefs.devices if d.type != 'CPU']
                        if gpu_devs:
                            for d in gpu_devs:
                                d.use = True
                            scene.cycles.device = 'GPU'
                            gpu_found = True
                            print(f"[cinematic_render] Cycles GPU ({device_type})")
                            break
                    except Exception:
                        continue
            except Exception:
                pass
        if gpu_found:
            print(f"RENDER_DEVICE_USED: engine=CYCLES device=GPU compute_type={device_type}", flush=True)
        else:
            scene.cycles.device = 'CPU'
            print("[cinematic_render] WARNING: GPU not found -- falling back to CPU")
            print("RENDER_DEVICE_USED: engine=CYCLES device=CPU compute_type=NONE (fallback)", flush=True)
            import os as _os
            if _os.environ.get("CYCLES_DEVICE", "auto").lower() == "gpu":
                print("GPU_REQUIRED_BUT_CPU_USED: strict mode active (CYCLES_DEVICE=gpu)", flush=True)
                sys.exit(2)

    # ── Render settings ──────────────────────────────────────────────────────
    scene.render.resolution_x = width
    scene.render.resolution_y = height
    scene.render.resolution_percentage = 100
    scene.render.image_settings.file_format = 'PNG'

    # ── White background (default for cinematic) ────────────────────────────
    # Set world to white unless template provides its own
    if not template_path:
        world = bpy.data.worlds.new("CinematicWorld")
        scene.world = world
        world.use_nodes = True
        bg_node = world.node_tree.nodes.get("Background")
        if bg_node:
            bg_node.inputs["Color"].default_value = (1.0, 1.0, 1.0, 1.0)
            bg_node.inputs["Strength"].default_value = 1.0
        print("[cinematic_render] white background set", flush=True)

    # ── Transparent background (override if requested) ────────────────────
    if bg_color or transparent_bg:
        scene.render.film_transparent = True
        if bg_color:
            print(f"[cinematic_render] film_transparent=True for FFmpeg bg_color compositing ({bg_color})", flush=True)
        else:
            print("[cinematic_render] transparent_bg enabled (alpha PNG frames)", flush=True)

    # ── Persistent data (Cycles BVH caching between frames) ──────────────────
    scene.render.use_persistent_data = True
    print("[cinematic_render] persistent_data enabled -- BVH cached between frames", flush=True)

    # ── Render all frames ────────────────────────────────────────────────────
    import time as _time
    _render_start = _time.time()
    for frame in range(1, frame_count + 1):
        scene.frame_set(frame)
        scene.render.filepath = os.path.join(frames_dir, f"frame_{frame:04d}")
        bpy.ops.render.render(write_still=True)
        elapsed = _time.time() - _render_start
        fps_so_far = frame / elapsed
        print(f"[cinematic_render] Frame {frame}/{frame_count} -- {elapsed:.1f}s elapsed ({fps_so_far:.2f} fps)", flush=True)

    total = _time.time() - _render_start
    print(f"[cinematic_render] Cinematic render complete: {frame_count} frames in {total:.1f}s ({frame_count/total:.2f} fps avg)", flush=True)


if __name__ == "__main__":
    main()
