"""Scene-level helpers for Blender headless renders."""
from __future__ import annotations

import math


def ensure_collection(name: str):
    """Return a collection by name, creating it if needed."""
    import bpy  # type: ignore[import]

    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 apply_smooth_batch(parts: list, angle_deg: float) -> None:
    """Apply smooth shading to ALL parts in a single operator call.

    bpy.ops.object.shade_smooth_by_angle() operates on all selected objects
    at once (one C-level call), so batching reduces O(n) operator overhead to O(1).
    Per-part calls cost ~90ms each × 175 parts = 16s; batch call costs ~0.2s total.
    """
    import bpy  # type: ignore[import]

    bpy.ops.object.select_all(action='DESELECT')
    mesh_parts = [p for p in parts if p.type == 'MESH']
    for part in mesh_parts:
        part.select_set(True)
    if not mesh_parts:
        return
    bpy.context.view_layer.objects.active = mesh_parts[0]
    if angle_deg > 0:
        try:
            bpy.ops.object.shade_smooth_by_angle(angle=math.radians(angle_deg))
        except AttributeError:
            bpy.ops.object.shade_smooth()
            for part in mesh_parts:
                if hasattr(part.data, 'use_auto_smooth'):
                    part.data.use_auto_smooth = True
                    part.data.auto_smooth_angle = math.radians(angle_deg)
    else:
        bpy.ops.object.shade_flat()
    bpy.ops.object.select_all(action='DESELECT')


def apply_sharp_edges_from_occ(parts: list, sharp_edge_pairs: list) -> None:
    """Mark edges sharp using OCC-derived vertex-pair data.

    `sharp_edge_pairs` is a list of [[x0,y0,z0],[x1,y1,z1]] in mm.
    Blender mesh coordinates are in metres (STEP mm * 0.001 scale applied).
    We match each OCC vertex pair against bmesh vertex positions with a 0.5 mm
    tolerance (0.0005 m) and mark the matched edge as sharp.
    """
    if not sharp_edge_pairs:
        return

    import time as _time
    import bmesh  # type: ignore[import]
    import mathutils  # type: ignore[import]

    _t0 = _time.monotonic()

    SCALE = 0.001   # mm → m
    TOL   = 0.0005  # 0.5 mm in metres

    # OCC STEP space (Z-up, mm) → Blender (Z-up, m):
    # RWGltf applies Z→Y-up, Blender import applies Y→Z-up.
    # Net: Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001)
    occ_pairs = []
    for pair in sharp_edge_pairs:
        v0 = mathutils.Vector((pair[0][0] * SCALE, -pair[0][2] * SCALE, pair[0][1] * SCALE))
        v1 = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE))
        occ_pairs.append((v0, v1))

    _t_convert = _time.monotonic()
    print(f"[blender_render] TIMING sharp_edges_convert={_t_convert - _t0:.3f}s ({len(occ_pairs)} pairs)", flush=True)

    # ── Spatial pre-filter: build a KD-tree over OCC pair midpoints ────────
    # For each part, query the midpoint KD-tree with the part's bbox radius
    # to get only nearby pairs instead of testing all N pairs × M parts.
    _t_spatial = _time.monotonic()
    pair_midpoints = []
    pair_radii = []  # half-length of each pair (max distance from midpoint to endpoint)
    for v0, v1 in occ_pairs:
        mid = (v0 + v1) * 0.5
        pair_midpoints.append(mid)
        pair_radii.append((v0 - mid).length)

    pair_kd = mathutils.kdtree.KDTree(len(pair_midpoints))
    for i, mid in enumerate(pair_midpoints):
        pair_kd.insert(mid, i)
    pair_kd.balance()
    _t_spatial_done = _time.monotonic()
    print(f"[blender_render] TIMING sharp_edges_spatial_index={_t_spatial_done - _t_spatial:.3f}s", flush=True)

    marked_total = 0
    kd_build_time = 0.0
    match_time = 0.0
    pairs_tested_total = 0
    for obj in parts:
        _t_kd = _time.monotonic()
        bm = bmesh.new()
        bm.from_mesh(obj.data)
        bm.verts.ensure_lookup_table()
        bm.edges.ensure_lookup_table()

        # Build KD-tree on vertices in WORLD space — OCC pairs are world coords,
        # but mesh vertices are in local space (assembly node transform in GLB).
        world_mat = obj.matrix_world
        kd = mathutils.kdtree.KDTree(len(bm.verts))
        for v in bm.verts:
            kd.insert(world_mat @ v.co, v.index)
        kd.balance()

        # Compute part's world-space bounding box center and search radius
        from mathutils import Vector  # type: ignore[import]
        corners = [world_mat @ Vector(c) for c in obj.bound_box]
        bbox_min = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
        bbox_max = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
        bbox_center = (bbox_min + bbox_max) * 0.5
        bbox_half_diag = (bbox_max - bbox_min).length * 0.5

        kd_build_time += _time.monotonic() - _t_kd

        _t_match = _time.monotonic()
        marked = 0

        # Query pair midpoints within bbox_half_diag + max_pair_radius + tolerance
        # This guarantees we don't miss any pair whose endpoints could be inside the bbox
        max_pair_radius = max(pair_radii) if pair_radii else 0.0
        search_radius = bbox_half_diag + max_pair_radius + TOL
        nearby = pair_kd.find_range(bbox_center, search_radius)
        pairs_tested_total += len(nearby)

        for _co, pair_idx, _dist in nearby:
            v0_occ, v1_occ = occ_pairs[pair_idx]
            _co0, idx0, dist0 = kd.find(v0_occ)
            _co1, idx1, dist1 = kd.find(v1_occ)
            if dist0 > TOL or dist1 > TOL:
                continue
            if idx0 == idx1:
                continue  # degenerate — both endpoints map to same vertex
            bv0 = bm.verts[idx0]
            bv1 = bm.verts[idx1]
            edge = bm.edges.get((bv0, bv1))
            if edge is None:
                edge = bm.edges.get((bv1, bv0))
            if edge is not None and edge.smooth:
                edge.smooth = False
                marked += 1
        match_time += _time.monotonic() - _t_match

        bm.to_mesh(obj.data)
        bm.free()
        marked_total += marked

    _total = _time.monotonic() - _t0
    pairs_skipped = len(occ_pairs) * len(parts) - pairs_tested_total
    print(f"[blender_render] TIMING sharp_edges={_total:.2f}s "
          f"(kd_build={kd_build_time:.2f}s, matching={match_time:.2f}s, "
          f"pairs={len(occ_pairs)}, parts={len(parts)}, marked={marked_total}, "
          f"tested={pairs_tested_total}, skipped={pairs_skipped})", flush=True)


def setup_shadow_catcher(parts: list) -> None:
    """Enable the Shadowcatcher collection in the template and position its plane.

    The template must contain a 'Shadowcatcher' collection with a 'Shadowcatcher'
    mesh object. The plane is moved to the lowest Z of the product bounding box.
    """
    import bpy  # type: ignore[import]
    from mathutils import Vector  # type: ignore[import]

    sc_col_name = "Shadowcatcher"
    sc_obj_name = "Shadowcatcher"

    # Enable the Shadowcatcher collection in all view layers
    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"[blender_render] shadow catcher enabled, plane Z={sc_obj.location.z:.4f}")
    else:
        print(f"[blender_render] WARNING: shadow catcher object '{sc_obj_name}' not found in template")
