"""Compile a bounded declarative Scene Recipe into Blender data blocks."""

from __future__ import annotations

import math
from typing import Any

import bpy
from mathutils import Vector

MAX_EXPANDED_OBJECTS = 1000
MAX_MESH_POLYGONS = 1_000_000


def rgba(value: str, alpha: float = 1.0) -> tuple[float, float, float, float]:
    return tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)) + (alpha,)


def look_at(obj: Any, target: Vector) -> None:
    direction = target - obj.location
    if direction.length == 0:
        raise ValueError("BLENDER_LOOK_AT_ZERO_LENGTH")
    obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()


class SceneBuilder:
    def __init__(self, spec: dict[str, Any]) -> None:
        self.spec = spec
        self.unit_scale = 1.0 if spec["units"] == "meters" else 0.001
        self.objects: dict[str, Any] = {}
        self.materials: dict[str, Any] = {}
        self.collections: dict[str, Any] = {}
        self._object_specs: dict[str, dict[str, Any]] = {}

    def length(self, value: float) -> float:
        return float(value) * self.unit_scale

    def vector(self, values: list[float]) -> Vector:
        return Vector(tuple(self.length(item) for item in values))

    def reset(self) -> None:
        bpy.ops.wm.read_factory_settings(use_empty=True)
        scene = bpy.context.scene
        scene.unit_settings.system = "METRIC"
        scene.unit_settings.length_unit = "METERS"
        scene.unit_settings.scale_length = 1.0

    def create_material(self, spec: dict[str, Any], identifier: str | None = None) -> Any:
        material_id = identifier or spec["id"]
        if material_id in self.materials:
            return self.materials[material_id]
        value = bpy.data.materials.new(material_id)
        value.use_nodes = True
        opacity = float(spec.get("opacity", 1.0))
        value.diffuse_color = rgba(spec.get("base_color", "#808080"), opacity)
        principled = value.node_tree.nodes.get("Principled BSDF")
        if principled is not None:
            inputs = principled.inputs
            inputs["Base Color"].default_value = rgba(spec.get("base_color", "#808080"))
            inputs["Metallic"].default_value = float(spec.get("metallic", 0.0))
            inputs["Roughness"].default_value = float(spec.get("roughness", 0.5))
            inputs["Alpha"].default_value = opacity
            emission = inputs.get("Emission Color") or inputs.get("Emission")
            if emission is not None and spec.get("emission_color"):
                emission.default_value = rgba(spec["emission_color"])
            strength = inputs.get("Emission Strength")
            if strength is not None:
                strength.default_value = float(spec.get("emission_strength", 0.0))
        if opacity < 1.0:
            try:
                value.surface_render_method = "DITHERED"
            except (AttributeError, TypeError):
                value.blend_method = "BLEND"
        self.materials[material_id] = value
        return value

    def generated_material(
        self,
        identifier: str,
        color: str,
        metallic: float = 0.0,
        roughness: float = 0.5,
    ) -> Any:
        return self.create_material(
            {
                "id": identifier,
                "base_color": color,
                "metallic": metallic,
                "roughness": roughness,
            },
            identifier,
        )

    def _link_collection(self, obj: Any, collection_id: str | None) -> None:
        if not collection_id:
            return
        collection = self.collections.get(collection_id)
        if collection is None:
            collection = bpy.data.collections.new(collection_id)
            bpy.context.scene.collection.children.link(collection)
            self.collections[collection_id] = collection
        for current in list(obj.users_collection):
            current.objects.unlink(obj)
        collection.objects.link(obj)

    def _register(self, identifier: str, obj: Any, spec: dict[str, Any]) -> Any:
        if identifier in self.objects:
            raise ValueError(f"BLENDER_DUPLICATE_OBJECT_ID:{identifier}")
        obj.name = str(spec.get("name") or identifier)
        obj["zcbot_id"] = identifier
        obj.hide_render = not bool(spec.get("visible", True))
        self._link_collection(obj, spec.get("collection"))
        self.objects[identifier] = obj
        self._object_specs[identifier] = spec
        if len(bpy.context.scene.objects) > MAX_EXPANDED_OBJECTS:
            raise ValueError("BLENDER_EXPANDED_OBJECT_LIMIT")
        return obj

    def _assign_material(self, obj: Any, material_id: str | None) -> None:
        if material_id is None:
            return
        material = self.materials.get(material_id)
        if material is None:
            raise ValueError(f"BLENDER_UNKNOWN_MATERIAL:{material_id}")
        if getattr(obj.data, "materials", None) is not None:
            obj.data.materials.append(material)

    def _apply_transform(self, obj: Any, transform: dict[str, Any] | None) -> None:
        transform = transform or {}
        obj.location = self.vector(transform.get("position", [0, 0, 0]))
        obj.rotation_euler = tuple(
            math.radians(float(item)) for item in transform.get("rotation_deg", [0, 0, 0])
        )
        obj.scale = tuple(float(item) for item in transform.get("scale", [1, 1, 1]))

    def _mesh_object(
        self,
        identifier: str,
        spec: dict[str, Any],
        vertices: list[tuple[float, float, float]],
        faces: list[tuple[int, ...]],
    ) -> Any:
        mesh = bpy.data.meshes.new(identifier + "Mesh")
        mesh.from_pydata(vertices, [], faces)
        mesh.update()
        obj = bpy.data.objects.new(identifier, mesh)
        bpy.context.scene.collection.objects.link(obj)
        return self._register(identifier, obj, spec)

    def _extrude(self, identifier: str, spec: dict[str, Any], geometry: dict[str, Any]) -> Any:
        profile = [(self.length(x), self.length(y)) for x, y in geometry["profile"]]
        depth = self.length(geometry["depth"])
        vertices = [(x, y, -depth / 2) for x, y in profile]
        vertices += [(x, y, depth / 2) for x, y in profile]
        count = len(profile)
        faces: list[tuple[int, ...]] = [tuple(range(count - 1, -1, -1)), tuple(range(count, count * 2))]
        faces.extend((index, (index + 1) % count, (index + 1) % count + count, index + count) for index in range(count))
        return self._mesh_object(identifier, spec, vertices, faces)

    def _revolve(self, identifier: str, spec: dict[str, Any], geometry: dict[str, Any]) -> Any:
        profile = [(self.length(radius), self.length(z)) for radius, z in geometry["profile"]]
        segments = int(geometry.get("segments", 64))
        angle = math.radians(float(geometry.get("angle_deg", 360)))
        closed = math.isclose(angle, math.tau)
        ring_count = segments if closed else segments + 1
        vertices = [
            (radius * math.cos(angle * ring / segments), radius * math.sin(angle * ring / segments), z)
            for ring in range(ring_count)
            for radius, z in profile
        ]
        width = len(profile)
        faces: list[tuple[int, ...]] = []
        span_count = segments if closed else ring_count - 1
        for ring in range(span_count):
            next_ring = (ring + 1) % ring_count
            for index in range(width - 1):
                a = ring * width + index
                b = ring * width + index + 1
                c = next_ring * width + index + 1
                d = next_ring * width + index
                faces.append((a, b, c, d))
        return self._mesh_object(identifier, spec, vertices, faces)

    def _sweep(self, identifier: str, spec: dict[str, Any], geometry: dict[str, Any]) -> Any:
        curve = bpy.data.curves.new(identifier + "Curve", "CURVE")
        curve.dimensions = "3D"
        curve.resolution_u = int(geometry.get("resolution", 2))
        curve.bevel_depth = self.length(geometry["radius"])
        curve.bevel_resolution = min(6, int(geometry.get("resolution", 2)))
        spline = curve.splines.new("POLY")
        path = geometry["path"]
        spline.points.add(len(path) - 1)
        for point, coordinate in zip(spline.points, path):
            value = self.vector(coordinate)
            point.co = (*value, 1.0)
        obj = bpy.data.objects.new(identifier, curve)
        bpy.context.scene.collection.objects.link(obj)
        return self._register(identifier, obj, spec)

    def _pipe(self, identifier: str, spec: dict[str, Any], geometry: dict[str, Any]) -> Any:
        start = self.vector(geometry["start"])
        end = self.vector(geometry["end"])
        direction = end - start
        if direction.length == 0:
            raise ValueError("BLENDER_PIPE_ZERO_LENGTH")
        outer = self.length(geometry["outer_radius"])
        inner = outer - self.length(geometry["wall_thickness"])
        if inner <= 0:
            raise ValueError("BLENDER_PIPE_WALL_INVALID")
        segments = int(geometry.get("vertices", 48))
        vertices: list[tuple[float, float, float]] = []
        for z in (-direction.length / 2, direction.length / 2):
            for radius in (outer, inner):
                vertices.extend(
                    (radius * math.cos(math.tau * i / segments), radius * math.sin(math.tau * i / segments), z)
                    for i in range(segments)
                )
        faces: list[tuple[int, ...]] = []
        outer_bottom, inner_bottom, outer_top, inner_top = (0, segments, segments * 2, segments * 3)
        for i in range(segments):
            j = (i + 1) % segments
            faces.extend(
                [
                    (outer_bottom + i, outer_bottom + j, outer_top + j, outer_top + i),
                    (inner_bottom + j, inner_bottom + i, inner_top + i, inner_top + j),
                    (outer_top + i, outer_top + j, inner_top + j, inner_top + i),
                    (outer_bottom + j, outer_bottom + i, inner_bottom + i, inner_bottom + j),
                ]
            )
        rotation = direction.to_track_quat("Z", "Y")
        center = (start + end) / 2
        vertices = [tuple(rotation @ Vector(vertex) + center) for vertex in vertices]
        obj = self._mesh_object(identifier, spec, vertices, faces)
        return obj

    def _geometry(self, identifier: str, spec: dict[str, Any]) -> Any:
        geometry = spec["geometry"]
        kind = geometry["type"]
        if kind == "box":
            bpy.ops.mesh.primitive_cube_add(size=1)
            obj = bpy.context.object
            obj.dimensions = tuple(self.length(item) for item in geometry["size"])
            bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
        elif kind == "cylinder":
            bpy.ops.mesh.primitive_cylinder_add(
                vertices=int(geometry.get("vertices", 48)),
                radius=self.length(geometry["radius"]),
                depth=self.length(geometry["depth"]),
            )
            obj = bpy.context.object
        elif kind == "cone":
            if geometry["radius1"] == 0 and geometry["radius2"] == 0:
                raise ValueError("BLENDER_CONE_RADII_INVALID")
            bpy.ops.mesh.primitive_cone_add(
                vertices=int(geometry.get("vertices", 48)),
                radius1=self.length(geometry["radius1"]),
                radius2=self.length(geometry["radius2"]),
                depth=self.length(geometry["depth"]),
            )
            obj = bpy.context.object
        elif kind == "sphere":
            bpy.ops.mesh.primitive_uv_sphere_add(
                segments=int(geometry.get("segments", 48)),
                ring_count=int(geometry.get("rings", 24)),
                radius=self.length(geometry["radius"]),
            )
            obj = bpy.context.object
        elif kind == "torus":
            bpy.ops.mesh.primitive_torus_add(
                major_segments=int(geometry.get("major_segments", 64)),
                minor_segments=int(geometry.get("minor_segments", 16)),
                location=(0, 0, 0),
                major_radius=self.length(geometry["major_radius"]),
                minor_radius=self.length(geometry["minor_radius"]),
            )
            obj = bpy.context.object
        elif kind == "plane":
            bpy.ops.mesh.primitive_plane_add(size=1)
            obj = bpy.context.object
            obj.dimensions = (self.length(geometry["size"][0]), self.length(geometry["size"][1]), 0)
            bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
        elif kind == "extrude":
            return self._extrude(identifier, spec, geometry)
        elif kind == "revolve":
            return self._revolve(identifier, spec, geometry)
        elif kind == "sweep":
            return self._sweep(identifier, spec, geometry)
        elif kind == "pipe":
            return self._pipe(identifier, spec, geometry)
        else:
            raise ValueError(f"BLENDER_GEOMETRY_UNSUPPORTED:{kind}")
        return self._register(identifier, obj, spec)

    def _text(self, identifier: str, spec: dict[str, Any]) -> Any:
        bpy.ops.object.text_add()
        obj = bpy.context.object
        obj.data.body = spec["text"]
        obj.data.size = self.length(spec.get("text_size", 1.0))
        obj.data.extrude = self.length(spec.get("text_extrude", 0.02))
        return self._register(identifier, obj, spec)

    def _empty(self, identifier: str, spec: dict[str, Any]) -> Any:
        obj = bpy.data.objects.new(identifier, None)
        bpy.context.scene.collection.objects.link(obj)
        return self._register(identifier, obj, spec)

    def add_generated_geometry(
        self,
        identifier: str,
        geometry: dict[str, Any],
        *,
        parent: Any,
        material: Any,
        position: tuple[float, float, float] = (0, 0, 0),
        rotation_deg: tuple[float, float, float] = (0, 0, 0),
    ) -> Any:
        spec = {
            "id": identifier,
            "kind": "geometry",
            "geometry": geometry,
            "transform": {"position": list(position), "rotation_deg": list(rotation_deg)},
        }
        obj = self._geometry(identifier, spec)
        self._apply_transform(obj, spec["transform"])
        if getattr(obj.data, "materials", None) is not None:
            obj.data.materials.append(material)
        obj.parent = parent
        return obj

    def _generator(self, identifier: str, spec: dict[str, Any]) -> Any:
        root = self._empty(identifier, spec)
        if spec["generator"] != "rotary_kiln":
            raise ValueError("BLENDER_GENERATOR_UNSUPPORTED")
        from rotary_kiln import build_rotary_kiln

        build_rotary_kiln(self, root, identifier, spec["parameters"])
        return root

    def _build_object(self, spec: dict[str, Any]) -> Any:
        identifier = spec["id"]
        kind = spec["kind"]
        if kind == "geometry":
            obj = self._geometry(identifier, spec)
        elif kind == "generator":
            obj = self._generator(identifier, spec)
        elif kind == "text":
            obj = self._text(identifier, spec)
        elif kind == "empty":
            obj = self._empty(identifier, spec)
        else:
            raise ValueError(f"BLENDER_OBJECT_KIND_UNSUPPORTED:{kind}")
        self._apply_transform(obj, spec.get("transform"))
        self._assign_material(obj, spec.get("material"))
        return obj

    def _parent_objects(self) -> None:
        parents = {
            identifier: spec.get("parent")
            for identifier, spec in self._object_specs.items()
            if spec.get("parent")
        }
        for identifier in parents:
            seen: set[str] = set()
            current: str | None = identifier
            while current is not None:
                if current in seen:
                    raise ValueError("BLENDER_PARENT_CYCLE")
                seen.add(current)
                current = parents.get(current)
        for identifier, spec in self._object_specs.items():
            parent_id = spec.get("parent")
            if not parent_id:
                continue
            parent = self.objects.get(parent_id)
            if parent is None:
                raise ValueError(f"BLENDER_UNKNOWN_PARENT:{parent_id}")
            child = self.objects[identifier]
            child.parent = parent

    def _add_modifiers(self) -> None:
        boolean_targets: set[str] = set()
        for identifier, spec in self._object_specs.items():
            modifiers = spec.get("modifiers") or []
            obj = self.objects[identifier]
            if modifiers and obj.type != "MESH":
                raise ValueError(f"BLENDER_MODIFIER_TARGET_INVALID:{identifier}")
            for index, modifier_spec in enumerate(modifiers, start=1):
                kind = modifier_spec["type"]
                modifier = obj.modifiers.new(f"zcbot_{kind}_{index:02d}", kind.upper())
                if kind == "bevel":
                    modifier.width = self.length(modifier_spec["width"])
                    modifier.segments = int(modifier_spec.get("segments", 3))
                elif kind == "mirror":
                    axes = modifier_spec["axes"]
                    modifier.use_axis[0] = "x" in axes
                    modifier.use_axis[1] = "y" in axes
                    modifier.use_axis[2] = "z" in axes
                elif kind == "array":
                    modifier.count = int(modifier_spec["count"])
                    modifier.use_relative_offset = False
                    modifier.use_constant_offset = True
                    modifier.constant_offset_displace = self.vector(modifier_spec["offset"])
                elif kind == "solidify":
                    modifier.thickness = self.length(modifier_spec["thickness"])
                elif kind == "boolean":
                    target_id = modifier_spec["target"]
                    target = self.objects.get(target_id)
                    if target is None or target.type != "MESH" or target == obj:
                        raise ValueError(f"BLENDER_BOOLEAN_TARGET_INVALID:{target_id}")
                    modifier.operation = {
                        "union": "UNION", "difference": "DIFFERENCE", "intersect": "INTERSECT"
                    }[modifier_spec["operation"]]
                    modifier.object = target
                    boolean_targets.add(target_id)
                else:
                    raise ValueError(f"BLENDER_MODIFIER_UNSUPPORTED:{kind}")
        for target_id in boolean_targets:
            self.objects[target_id].hide_render = True

    def _configure_world(self) -> None:
        scene = bpy.context.scene
        render = self.spec["render"]
        scene.render.resolution_x = int(render.get("width", 1280))
        scene.render.resolution_y = int(render.get("height", 720))
        scene.render.resolution_percentage = 100
        scene.render.image_settings.file_format = "PNG"
        scene.render.film_transparent = bool(render.get("transparent_background", False))
        for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"):
            try:
                scene.render.engine = engine
                break
            except TypeError:
                continue
        if scene.world is None:
            scene.world = bpy.data.worlds.new("SceneWorld")
        background = render.get("background_color", "#20252B")
        scene.world.use_nodes = True
        scene.world.color = rgba(background)[:3]
        node = scene.world.node_tree.nodes.get("Background")
        if node is not None:
            node.inputs["Color"].default_value = rgba(background)
            node.inputs["Strength"].default_value = 0.35

    def _create_light(self, spec: dict[str, Any]) -> None:
        light_type = spec["type"].upper()
        data = bpy.data.lights.new(spec["id"] + "Data", light_type)
        data.energy = float(spec["energy"])
        data.color = rgba(spec.get("color", "#FFFFFF"))[:3]
        if light_type == "AREA":
            data.shape = "DISK"
            data.size = self.length(spec.get("size", 5.0))
        if light_type == "SPOT":
            data.spot_size = math.radians(float(spec.get("spot_size_deg", 45)))
        obj = bpy.data.objects.new(spec["id"], data)
        bpy.context.scene.collection.objects.link(obj)
        obj.location = self.vector(spec["position"])
        if spec.get("target") is not None and light_type != "POINT":
            look_at(obj, self.vector(spec["target"]))

    def _configure_lights(self) -> None:
        lights = self.spec.get("lights") or []
        if lights:
            for spec in lights:
                self._create_light(spec)
            return
        self._create_light(
            {"id": "DefaultSun", "type": "sun", "position": [-8, -10, 12], "target": [0, 0, 0], "energy": 3.0}
        )
        self._create_light(
            {"id": "DefaultArea", "type": "area", "position": [4, -6, 8], "target": [0, 0, 0], "energy": 1200, "size": 6}
        )

    def compile(self) -> None:
        self.reset()
        material_specs = self.spec.get("materials") or []
        material_ids = [item["id"] for item in material_specs]
        if len(material_ids) != len(set(material_ids)):
            raise ValueError("BLENDER_DUPLICATE_MATERIAL_ID")
        for material_spec in material_specs:
            self.create_material(material_spec)
        object_ids = [item["id"] for item in self.spec["objects"]]
        if len(object_ids) != len(set(object_ids)):
            raise ValueError("BLENDER_DUPLICATE_OBJECT_ID")
        for object_spec in self.spec["objects"]:
            self._build_object(object_spec)
        self._parent_objects()
        self._add_modifiers()
        self._configure_world()
        self._configure_lights()
        polygon_count = sum(
            len(obj.data.polygons) for obj in bpy.context.scene.objects if obj.type == "MESH"
        )
        if polygon_count > MAX_MESH_POLYGONS:
            raise ValueError("BLENDER_MESH_COMPLEXITY_LIMIT")

    def configure_camera(self, view: dict[str, Any]) -> None:
        scene = bpy.context.scene
        for obj in [item for item in scene.objects if item.type == "CAMERA"]:
            bpy.data.objects.remove(obj, do_unlink=True)
        data = bpy.data.cameras.new("SceneCameraData")
        data.type = "PERSP" if view["projection"] == "perspective" else "ORTHO"
        data.lens = float(view.get("lens_mm", 50))
        data.ortho_scale = self.length(view.get("ortho_scale", 10))
        data.clip_end = self.length(view.get("clip_end", 10000))
        camera = bpy.data.objects.new("SceneCamera", data)
        scene.collection.objects.link(camera)
        camera.location = self.vector(view["position"])
        look_at(camera, self.vector(view["target"]))
        scene.camera = camera

    def manifest(self, views: list[dict[str, Any]]) -> dict[str, Any]:
        meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
        return {
            "recipe_version": self.spec["recipe_version"],
            "title": self.spec["title"],
            "units": self.spec["units"],
            "rendered_views": [
                {"name": item["name"], "output_key": item["output_key"]} for item in views
            ],
            "objects": [
                {
                    "id": str(obj.get("zcbot_id", "")),
                    "name": obj.name,
                    "type": obj.type,
                    "vertices": len(obj.data.vertices) if obj.type == "MESH" else 0,
                    "polygons": len(obj.data.polygons) if obj.type == "MESH" else 0,
                }
                for obj in bpy.context.scene.objects
                if obj.type not in {"CAMERA", "LIGHT"}
            ],
            "object_count": len(bpy.context.scene.objects),
            "mesh_count": len(meshes),
            "vertex_count": sum(len(obj.data.vertices) for obj in meshes),
            "polygon_count": sum(len(obj.data.polygons) for obj in meshes),
        }
