# SPDX-License-Identifier: MIT
"""Build the deterministic Courtyard Pavilion fixture in factory-startup Blender."""

import argparse
import hashlib
import json
import math
import os
import sys
from pathlib import Path

import bpy
from mathutils import Matrix, Vector


SCENE_NAME = "BMCP_Pavilion"
ROOT_COLLECTION = "BMCP_Pavilion_v1"
STRUCTURE_COLLECTION = "PAV_Structure"
SITE_COLLECTION = "PAV_Site"
STUDIO_COLLECTION = "PAV_Studio"
CAMERA_TARGET = Vector((0.0, 0.0, 1.6))
RESOLUTION = (1280, 800)
SAMPLES = 96


class PavilionArgumentParser(argparse.ArgumentParser):
    def error(self, message):
        self.print_usage(sys.stderr)
        self.exit(3, f"{self.prog}: error: {message}\n")


def script_arguments():
    if "--" not in sys.argv:
        return []
    return sys.argv[sys.argv.index("--") + 1 :]


def parse_args():
    parser = PavilionArgumentParser(
        description="Build the deterministic Courtyard Pavilion fixture."
    )
    parser.add_argument("--columns", type=int, default=6)
    parser.add_argument("--out", required=True)
    parser.add_argument("--render", action="store_true")
    parser.add_argument("--save-blend", action="store_true")
    args = parser.parse_args(script_arguments())
    if not 3 <= args.columns <= 16:
        parser.error("--columns must be an integer from 3 through 16")
    return args


def refuse_existing(path, label):
    if os.path.lexists(path):
        print(f"Refusing existing {label}: {path}", file=sys.stderr)
        raise SystemExit(2)


def require_factory_startup():
    if not bpy.app.background:
        raise SystemExit("This script must run in Blender background mode")
    if bpy.data.filepath:
        raise SystemExit("Expected an untouched --factory-startup session")

    scene = bpy.data.scenes.get("Scene")
    expected_objects = {"Cube": "MESH", "Camera": "CAMERA", "Light": "LIGHT"}
    if scene is None or set(bpy.data.scenes.keys()) != {"Scene"}:
        raise SystemExit("Expected an untouched --factory-startup Scene")
    if {obj.name: obj.type for obj in scene.objects} != expected_objects:
        raise SystemExit("Expected the three factory-startup objects")
    if [collection.name for collection in scene.collection.children] != ["Collection"]:
        raise SystemExit("Expected the factory-startup Collection")


def make_file_browser_directories_portable():
    for screen in bpy.data.screens:
        for area in screen.areas:
            for space in area.spaces:
                if space.type == "FILE_BROWSER" and space.params is not None:
                    space.params.directory = b"//"


def vector_values(values):
    return [round(float(value), 6) for value in values]


def factory_signature():
    scene = bpy.data.scenes["Scene"]
    objects = {}
    for obj in sorted(scene.objects, key=lambda item: item.name):
        details = {
            "type": obj.type,
            "data": obj.data.name,
            "location": vector_values(obj.location),
            "rotation_euler": vector_values(obj.rotation_euler),
            "scale": vector_values(obj.scale),
            "collections": sorted(collection.name for collection in obj.users_collection),
            "modifiers": len(obj.modifiers),
            "materials": [
                slot.material.name if slot.material else None
                for slot in obj.material_slots
            ],
        }
        if obj.type == "MESH":
            details["mesh_counts"] = [
                len(obj.data.vertices),
                len(obj.data.edges),
                len(obj.data.polygons),
            ]
        elif obj.type == "CAMERA":
            details["camera"] = [obj.data.lens, obj.data.sensor_width]
        elif obj.type == "LIGHT":
            details["light"] = [
                obj.data.type,
                obj.data.energy,
                obj.data.shadow_soft_size,
            ]
        objects[obj.name] = details
    return {
        "scene": scene.name,
        "camera": scene.camera.name if scene.camera else None,
        "world": scene.world.name if scene.world else None,
        "root_objects": sorted(obj.name for obj in scene.collection.objects),
        "children": sorted(child.name for child in scene.collection.children),
        "objects": objects,
    }


def create_material(name, grey, roughness):
    material = bpy.data.materials.new(name)
    material.use_nodes = True
    material.diffuse_color = (grey, grey, grey, 1.0)
    principled = material.node_tree.nodes.get("Principled BSDF")
    principled.inputs["Base Color"].default_value = (grey, grey, grey, 1.0)
    principled.inputs["Roughness"].default_value = roughness
    return material


def create_box(name, dimensions, location, material, collection):
    half_x, half_y, half_z = (value / 2.0 for value in dimensions)
    vertices = [
        (-half_x, -half_y, -half_z),
        (half_x, -half_y, -half_z),
        (half_x, half_y, -half_z),
        (-half_x, half_y, -half_z),
        (-half_x, -half_y, half_z),
        (half_x, -half_y, half_z),
        (half_x, half_y, half_z),
        (-half_x, half_y, half_z),
    ]
    faces = [
        (0, 3, 2, 1),
        (4, 5, 6, 7),
        (0, 1, 5, 4),
        (1, 2, 6, 5),
        (2, 3, 7, 6),
        (3, 0, 4, 7),
    ]
    mesh = bpy.data.meshes.new(f"{name}_Mesh")
    mesh.from_pydata(vertices, [], faces)
    mesh.materials.append(material)
    obj = bpy.data.objects.new(name, mesh)
    obj.location = location
    collection.objects.link(obj)
    return obj


def create_ground(material, collection):
    mesh = bpy.data.meshes.new("SITE_Ground_Mesh")
    mesh.from_pydata(
        [(-20.0, -20.0, 0.0), (20.0, -20.0, 0.0), (20.0, 20.0, 0.0), (-20.0, 20.0, 0.0)],
        [],
        [(0, 1, 2, 3)],
    )
    mesh.materials.append(material)
    ground = bpy.data.objects.new("SITE_Ground", mesh)
    ground.location = (0.0, 0.0, -0.02)
    collection.objects.link(ground)
    return ground


def configure_world(scene):
    world = bpy.data.worlds.new("BMCP_Pavilion_World")
    world.use_nodes = True
    world.color = (0.35, 0.35, 0.35)
    background = world.node_tree.nodes.get("Background")
    background.inputs["Color"].default_value = (0.35, 0.35, 0.35, 1.0)
    background.inputs["Strength"].default_value = 1.0
    scene.world = world


def configure_render(scene):
    scene.render.engine = "CYCLES"
    scene.cycles.device = "CPU"
    scene.cycles.samples = SAMPLES
    scene.cycles.use_adaptive_sampling = True
    scene.cycles.adaptive_threshold = 0.02
    scene.cycles.use_denoising = True
    scene.cycles.denoiser = "OPENIMAGEDENOISE"
    scene.render.resolution_x, scene.render.resolution_y = RESOLUTION
    scene.render.resolution_percentage = 100
    scene.render.image_settings.file_format = "PNG"
    scene.render.image_settings.color_depth = "8"
    scene.render.image_settings.color_mode = "RGBA"
    scene.render.film_transparent = False
    scene.view_settings.view_transform = "AgX"
    scene.view_settings.look = "None"
    scene.view_settings.exposure = 0.0
    scene.view_settings.gamma = 1.0
    scene.frame_set(1)


def create_scene(columns):
    scene = bpy.data.scenes.new(SCENE_NAME)
    root = bpy.data.collections.new(ROOT_COLLECTION)
    structure = bpy.data.collections.new(STRUCTURE_COLLECTION)
    site = bpy.data.collections.new(SITE_COLLECTION)
    studio = bpy.data.collections.new(STUDIO_COLLECTION)
    scene.collection.children.link(root)
    root.children.link(structure)
    root.children.link(site)
    root.children.link(studio)

    plinth_material = create_material("MAT_Concrete_Plinth", 0.62, 0.75)
    structure_material = create_material("MAT_Concrete_Structure", 0.78, 0.65)
    ground_material = create_material("MAT_Ground", 0.35, 0.90)

    create_box("PAV_Plinth", (9.0, 6.0, 0.30), (0.0, 0.0, 0.15), plinth_material, structure)
    create_box("PAV_Wall_Back", (8.0, 0.20, 3.02), (0.0, 2.60, 1.80), structure_material, structure)
    create_box("PAV_Wall_Side_L", (0.20, 5.10, 3.02), (-4.10, 0.15, 1.80), structure_material, structure)
    create_box("PAV_Wall_Side_R", (0.20, 5.10, 3.02), (4.10, 0.15, 1.80), structure_material, structure)
    create_box("PAV_Roof_Slab", (9.60, 6.60, 0.24), (0.0, 0.0, 3.42), structure_material, structure)

    spacing = 8.16 / (columns - 1)
    for index in range(columns):
        x_position = -4.08 + index * spacing
        create_box(
            f"PAV_Column_{index + 1:02d}",
            (0.24, 0.24, 3.02),
            (x_position, -2.60, 1.80),
            structure_material,
            structure,
        )

    create_ground(ground_material, site)

    camera_data = bpy.data.cameras.new("CAM_Hero")
    camera_data.lens = 35.0
    camera_data.sensor_width = 36.0
    camera = bpy.data.objects.new("CAM_Hero", camera_data)
    camera.location = (12.0, -11.0, 4.6)
    camera.rotation_euler = (CAMERA_TARGET - camera.location).to_track_quat("-Z", "Y").to_euler()
    studio.objects.link(camera)
    scene.camera = camera

    light_data = bpy.data.lights.new("LGT_Sun", type="SUN")
    light_data.energy = 3.0
    light_data.angle = math.radians(2.0)
    light = bpy.data.objects.new("LGT_Sun", light_data)
    light.rotation_euler = (math.radians(52.0), 0.0, math.radians(130.0))
    studio.objects.link(light)

    configure_world(scene)
    configure_render(scene)
    return scene


def world_bounds(obj):
    transform = Matrix.LocRotScale(
        obj.location, obj.rotation_euler.to_quaternion(), obj.scale
    )
    points = [transform @ Vector(corner) for corner in obj.bound_box]
    minimum = [min(point[axis] for point in points) for axis in range(3)]
    maximum = [max(point[axis] for point in points) for axis in range(3)]
    return {"min": vector_values(minimum), "max": vector_values(maximum)}


def triangle_count(scene):
    total = 0
    for obj in scene.objects:
        if obj.type == "MESH":
            obj.data.calc_loop_triangles()
            total += len(obj.data.loop_triangles)
    return total


def scene_report(scene, columns, factory_preserved):
    objects = {}
    mesh_objects = []
    for obj in sorted(scene.objects, key=lambda item: item.name):
        details = {
            "type": obj.type,
            "location": vector_values(obj.location),
            "rotation_euler": vector_values(obj.rotation_euler),
            "scale": vector_values(obj.scale),
            "modifiers": len(obj.modifiers),
            "materials": [
                slot.material.name if slot.material else None
                for slot in obj.material_slots
            ],
        }
        if obj.type == "MESH":
            details["dimensions"] = vector_values(obj.dimensions)
            details["world_bounds"] = world_bounds(obj)
            mesh_objects.append(obj)
        objects[obj.name] = details

    all_points = []
    for obj in mesh_objects:
        transform = Matrix.LocRotScale(
            obj.location, obj.rotation_euler.to_quaternion(), obj.scale
        )
        all_points.extend(transform @ Vector(corner) for corner in obj.bound_box)
    scene_bounds = {
        "min": vector_values(min(point[axis] for point in all_points) for axis in range(3)),
        "max": vector_values(max(point[axis] for point in all_points) for axis in range(3)),
    }
    camera = scene.objects["CAM_Hero"]
    light = scene.objects["LGT_Sun"]
    materials = {}
    for material_name in (
        "MAT_Concrete_Plinth",
        "MAT_Concrete_Structure",
        "MAT_Ground",
    ):
        material = bpy.data.materials[material_name]
        principled = material.node_tree.nodes["Principled BSDF"]
        materials[material_name] = {
            "base_color": round(
                float(principled.inputs["Base Color"].default_value[0]), 6
            ),
            "roughness": round(
                float(principled.inputs["Roughness"].default_value), 6
            ),
        }
    background = scene.world.node_tree.nodes["Background"]
    return {
        "schema_version": 1,
        "scene": scene.name,
        "columns": columns,
        "column_spacing": round(8.16 / (columns - 1), 6),
        "object_names": sorted(objects),
        "objects": objects,
        "world_bounds": scene_bounds,
        "materials": materials,
        "camera": {
            "name": camera.name,
            "lens_mm": camera.data.lens,
            "sensor_width_mm": camera.data.sensor_width,
            "location": vector_values(camera.location),
            "aim_target": vector_values(CAMERA_TARGET),
        },
        "light": {
            "name": light.name,
            "type": light.data.type,
            "strength": light.data.energy,
            "angle_degrees": round(math.degrees(light.data.angle), 6),
            "rotation_euler": vector_values(light.rotation_euler),
        },
        "world": {
            "background_rgb": vector_values(
                background.inputs["Color"].default_value[:3]
            ),
            "strength": round(
                float(background.inputs["Strength"].default_value), 6
            ),
        },
        "render": {
            "engine": scene.render.engine,
            "device": scene.cycles.device,
            "samples": scene.cycles.samples,
            "adaptive_threshold": scene.cycles.adaptive_threshold,
            "denoiser": scene.cycles.denoiser,
            "resolution": [scene.render.resolution_x, scene.render.resolution_y],
            "resolution_percentage": scene.render.resolution_percentage,
            "format": scene.render.image_settings.file_format,
            "color_depth": scene.render.image_settings.color_depth,
            "view_transform": scene.view_settings.view_transform,
            "frame": scene.frame_current,
        },
        "triangle_count": triangle_count(scene),
        "factory_scene_preserved": factory_preserved,
        "blender": {
            "version": bpy.app.version_string,
            "build_hash": bpy.app.build_hash.decode("utf-8"),
        },
        "builder_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
    }


def write_json_exclusive(path, payload):
    with path.open("x", encoding="utf-8") as handle:
        json.dump(payload, handle, indent=2, sort_keys=True)
        handle.write("\n")


def main():
    args = parse_args()
    output = Path(os.path.abspath(os.path.expanduser(args.out)))
    refuse_existing(output, "--out path")
    require_factory_startup()
    factory_before = factory_signature()

    try:
        output.mkdir(parents=True, exist_ok=False)
    except FileExistsError:
        refuse_existing(output, "--out path")
        raise

    scene = create_scene(args.columns)
    if bpy.context.window is not None:
        bpy.context.window.scene = scene
        bpy.context.view_layer.update()
    factory_preserved = factory_signature() == factory_before
    if not factory_preserved:
        raise RuntimeError("Factory scene changed while building the pavilion")

    write_json_exclusive(
        output / "scene_report.json",
        scene_report(scene, args.columns, factory_preserved),
    )

    if args.save_blend:
        blend_path = output / "pavilion.blend"
        refuse_existing(blend_path, "blend artifact")
        make_file_browser_directories_portable()
        bpy.ops.wm.save_as_mainfile(filepath=str(blend_path), check_existing=True)
    if args.render:
        render_path = output / "render.png"
        refuse_existing(render_path, "render artifact")
        scene.render.filepath = str(render_path)
        bpy.ops.render.render(write_still=True, scene=scene.name)


if __name__ == "__main__":
    main()
