# SPDX-License-Identifier: MIT
"""Validate a saved Courtyard Pavilion fixture and optional render."""

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

import bpy
from mathutils import Vector


TOLERANCE = 1e-4
SCENE_NAME = "BMCP_Pavilion"
ROOT_COLLECTION = "BMCP_Pavilion_v1"
CAMERA_TARGET = Vector((0.0, 0.0, 1.6))
BOX_CORNER_SIGNS = (
    (-1.0, -1.0, -1.0),
    (1.0, -1.0, -1.0),
    (1.0, 1.0, -1.0),
    (-1.0, 1.0, -1.0),
    (-1.0, -1.0, 1.0),
    (1.0, -1.0, 1.0),
    (1.0, 1.0, 1.0),
    (-1.0, 1.0, 1.0),
)
BOX_POLYGON_TOPOLOGY = (
    (0, 3, 2, 1),
    (4, 5, 6, 7),
    (0, 1, 5, 4),
    (1, 2, 6, 5),
    (2, 3, 7, 6),
    (3, 0, 4, 7),
)
GROUND_VERTICES = (
    (-20.0, -20.0, 0.0),
    (20.0, -20.0, 0.0),
    (20.0, 20.0, 0.0),
    (-20.0, 20.0, 0.0),
)
GROUND_POLYGON_TOPOLOGY = ((0, 1, 2, 3),)


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


class Validator:
    def __init__(self):
        self.checks = 0
        self.failures = []

    def check(self, condition, message):
        self.checks += 1
        if not condition:
            self.failures.append(message)

    def equal(self, actual, expected, label):
        self.check(actual == expected, f"{label}: expected {expected!r}, got {actual!r}")

    def close(self, actual, expected, label, tolerance=TOLERANCE):
        self.check(
            math.isclose(float(actual), float(expected), abs_tol=tolerance, rel_tol=0.0),
            f"{label}: expected {expected!r}, got {actual!r}",
        )

    def vector_close(self, actual, expected, label, tolerance=TOLERANCE):
        actual_values = tuple(float(value) for value in actual)
        expected_values = tuple(float(value) for value in expected)
        self.equal(len(actual_values), len(expected_values), f"{label} length")
        for index, (actual_value, expected_value) in enumerate(
            zip(actual_values, expected_values)
        ):
            self.close(actual_value, expected_value, f"{label}[{index}]", tolerance)


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


def parse_args():
    parser = PavilionArgumentParser(description="Validate a Courtyard Pavilion fixture.")
    parser.add_argument("--blend", required=True)
    parser.add_argument("--expect-columns", required=True, type=int)
    parser.add_argument("--expect-render")
    parser.add_argument("--report", required=True)
    args = parser.parse_args(script_arguments())
    if not 3 <= args.expect_columns <= 16:
        parser.error("--expect-columns must be an integer from 3 through 16")
    if not Path(args.blend).is_file():
        parser.error("--blend must name an existing file")
    if not Path(args.report).expanduser().resolve(strict=False).parent.is_dir():
        parser.error("the --report parent directory must exist")
    require_fresh_background(parser)
    return args


def require_fresh_background(parser):
    scene = bpy.data.scenes.get("Scene")
    factory_objects = {"Cube": "MESH", "Camera": "CAMERA", "Light": "LIGHT"}
    if not bpy.app.background:
        parser.error("the checker must run in Blender background mode")
    if bpy.data.filepath or set(bpy.data.scenes.keys()) != {"Scene"}:
        parser.error("the checker must start with --factory-startup")
    if scene is None or {obj.name: obj.type for obj in scene.objects} != factory_objects:
        parser.error("the checker must start with --factory-startup")


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


def sha256_file(path):
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def world_bounds(obj):
    points = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
    return (
        tuple(min(point[axis] for point in points) for axis in range(3)),
        tuple(max(point[axis] for point in points) for axis in range(3)),
    )


def validate_collection_tree(scene, columns, validator):
    validator.equal(
        sorted(collection.name for collection in scene.collection.children),
        [ROOT_COLLECTION],
        "scene root collections",
    )
    validator.equal(len(scene.collection.objects), 0, "scene root direct object count")
    root = scene.collection.children.get(ROOT_COLLECTION)
    if root is None:
        return None, {}, []

    validator.equal(len(root.objects), 0, "pavilion root direct object count")
    validator.equal(
        sorted(collection.name for collection in root.children),
        ["PAV_Site", "PAV_Structure", "PAV_Studio"],
        "pavilion child collections",
    )
    children = {collection.name: collection for collection in root.children}
    if set(children) != {"PAV_Structure", "PAV_Site", "PAV_Studio"}:
        return root, children, list(root.all_objects)

    expected_columns = {f"PAV_Column_{index:02d}" for index in range(1, columns + 1)}
    expected_structure = {
        "PAV_Plinth",
        "PAV_Wall_Back",
        "PAV_Wall_Side_L",
        "PAV_Wall_Side_R",
        "PAV_Roof_Slab",
        *expected_columns,
    }
    memberships = {
        "PAV_Structure": expected_structure,
        "PAV_Site": {"SITE_Ground"},
        "PAV_Studio": {"CAM_Hero", "LGT_Sun"},
    }
    for name, expected in memberships.items():
        collection = children[name]
        validator.equal(
            {obj.name for obj in collection.objects}, expected, f"{name} membership"
        )
        validator.equal(len(collection.children), 0, f"{name} child collection count")
        for obj in collection.objects:
            validator.equal(
                sorted(item.name for item in obj.users_collection),
                [name],
                f"{obj.name} collection links",
            )

    pavilion_objects = list(root.all_objects)
    validator.equal(
        len(children["PAV_Structure"].objects), 5 + columns, "structure object count"
    )
    validator.equal(len(children["PAV_Site"].objects), 1, "site object count")
    validator.equal(len(children["PAV_Studio"].objects), 2, "studio object count")
    validator.equal(
        sum(obj.type == "MESH" for obj in pavilion_objects),
        6 + columns,
        "pavilion mesh count",
    )
    validator.equal(
        {obj.name for obj in pavilion_objects if obj.name.startswith("PAV_Column_")},
        expected_columns,
        "contiguous column names",
    )
    return root, children, pavilion_objects


def expected_geometry(columns):
    geometry = {
        "PAV_Plinth": ((0.0, 0.0, 0.15), (9.0, 6.0, 0.30), (-4.5, -3.0, 0.0), (4.5, 3.0, 0.30)),
        "PAV_Wall_Back": ((0.0, 2.60, 1.80), (8.0, 0.20, 3.02), (-4.0, 2.50, 0.29), (4.0, 2.70, 3.31)),
        "PAV_Wall_Side_L": ((-4.10, 0.15, 1.80), (0.20, 5.10, 3.02), (-4.20, -2.40, 0.29), (-4.0, 2.70, 3.31)),
        "PAV_Wall_Side_R": ((4.10, 0.15, 1.80), (0.20, 5.10, 3.02), (4.0, -2.40, 0.29), (4.20, 2.70, 3.31)),
        "PAV_Roof_Slab": ((0.0, 0.0, 3.42), (9.60, 6.60, 0.24), (-4.80, -3.30, 3.30), (4.80, 3.30, 3.54)),
        "SITE_Ground": ((0.0, 0.0, -0.02), (40.0, 40.0, 0.0), (-20.0, -20.0, -0.02), (20.0, 20.0, -0.02)),
    }
    spacing = 8.16 / (columns - 1)
    for index in range(columns):
        x_position = -4.08 + index * spacing
        geometry[f"PAV_Column_{index + 1:02d}"] = (
            (x_position, -2.60, 1.80),
            (0.24, 0.24, 3.02),
            (x_position - 0.12, -2.72, 0.29),
            (x_position + 0.12, -2.48, 3.31),
        )
    return geometry


def expected_mesh_geometry(name, dimensions):
    if name == "SITE_Ground":
        return GROUND_VERTICES, GROUND_POLYGON_TOPOLOGY
    half_dimensions = tuple(float(value) / 2.0 for value in dimensions)
    vertices = tuple(
        tuple(sign[axis] * half_dimensions[axis] for axis in range(3))
        for sign in BOX_CORNER_SIGNS
    )
    return vertices, BOX_POLYGON_TOPOLOGY


def validate_geometry(scene, columns, validator, metrics):
    geometry = expected_geometry(columns)
    for name, (location, dimensions, minimum, maximum) in geometry.items():
        obj = scene.objects.get(name)
        validator.check(obj is not None, f"missing geometry object {name}")
        if obj is None:
            continue
        validator.equal(obj.type, "MESH", f"{name} type")
        validator.vector_close(obj.location, location, f"{name} location")
        validator.vector_close(obj.rotation_euler, (0.0, 0.0, 0.0), f"{name} rotation")
        validator.vector_close(obj.scale, (1.0, 1.0, 1.0), f"{name} scale")
        validator.vector_close(obj.dimensions, dimensions, f"{name} dimensions")
        actual_minimum, actual_maximum = world_bounds(obj)
        validator.vector_close(actual_minimum, minimum, f"{name} bounds min")
        validator.vector_close(actual_maximum, maximum, f"{name} bounds max")
        expected_vertices, expected_polygons = expected_mesh_geometry(name, dimensions)
        validator.equal(
            len(obj.data.vertices), len(expected_vertices), f"{name} vertex count"
        )
        for index, (vertex, expected_vertex) in enumerate(
            zip(obj.data.vertices, expected_vertices)
        ):
            validator.vector_close(
                vertex.co, expected_vertex, f"{name} vertex {index}"
            )
        polygon_topology = tuple(
            tuple(polygon.vertices) for polygon in obj.data.polygons
        )
        validator.equal(
            polygon_topology, expected_polygons, f"{name} polygon topology"
        )

    plinth = scene.objects.get("PAV_Plinth")
    roof = scene.objects.get("PAV_Roof_Slab")
    vertical_names = [
        "PAV_Wall_Back",
        "PAV_Wall_Side_L",
        "PAV_Wall_Side_R",
        *(f"PAV_Column_{index:02d}" for index in range(1, columns + 1)),
    ]
    for name in vertical_names:
        obj = scene.objects.get(name)
        if obj is not None:
            minimum, maximum = world_bounds(obj)
            validator.close(minimum[2], 0.29, f"{name} plinth overlap plane")
            validator.close(maximum[2], 3.31, f"{name} roof overlap plane")
    if plinth is not None and roof is not None:
        plinth_min, plinth_max = world_bounds(plinth)
        roof_min, roof_max = world_bounds(roof)
        validator.close(plinth_max[2], 0.30, "plinth top contact plane")
        validator.close(roof_min[2], 3.30, "roof bottom contact plane")
        validator.close(plinth_min[0] - roof_min[0], 0.30, "roof projection left")
        validator.close(roof_max[0] - plinth_max[0], 0.30, "roof projection right")
        validator.close(plinth_min[1] - roof_min[1], 0.30, "roof projection front")
        validator.close(roof_max[1] - plinth_max[1], 0.30, "roof projection back")

    triangle_total = 0
    for obj in scene.objects:
        if obj.type == "MESH":
            obj.data.calc_loop_triangles()
            triangle_total += len(obj.data.loop_triangles)
    metrics["triangle_count"] = triangle_total
    validator.equal(triangle_total, 12 * (5 + columns) + 2, "triangle count")


def walk_nodes(node_tree, seen=None):
    if node_tree is None:
        return
    if seen is None:
        seen = set()
    pointer = node_tree.as_pointer()
    if pointer in seen:
        return
    seen.add(pointer)
    for node in node_tree.nodes:
        yield node
        if node.type == "GROUP" and node.node_tree is not None:
            yield from walk_nodes(node.node_tree, seen)


def validate_materials(scene, pavilion_objects, validator):
    expected = {
        "MAT_Concrete_Plinth": (0.62, 0.75),
        "MAT_Concrete_Structure": (0.78, 0.65),
        "MAT_Ground": (0.35, 0.90),
    }
    expected_assignments = {
        "PAV_Plinth": "MAT_Concrete_Plinth",
        "SITE_Ground": "MAT_Ground",
    }
    mesh_objects = [obj for obj in pavilion_objects if obj.type == "MESH"]
    fixture_materials = set()
    for obj in mesh_objects:
        validator.equal(len(obj.material_slots), 1, f"{obj.name} material slot count")
        material = obj.material_slots[0].material if len(obj.material_slots) else None
        validator.check(material is not None, f"{obj.name} has an assigned material")
        if material is None:
            continue
        fixture_materials.add(material)
        expected_name = expected_assignments.get(obj.name, "MAT_Concrete_Structure")
        validator.equal(material.name, expected_name, f"{obj.name} material")

    validator.equal(
        {material.name for material in fixture_materials},
        set(expected),
        "fixture material names",
    )
    for material in fixture_materials:
        validator.check(material.use_nodes, f"{material.name} uses nodes")
        tree = material.node_tree
        if tree is None:
            continue
        nodes = list(walk_nodes(tree))
        validator.equal(
            sorted(node.type for node in tree.nodes),
            ["BSDF_PRINCIPLED", "OUTPUT_MATERIAL"],
            f"{material.name} top-level node types",
        )
        validator.equal(
            sum(node.type == "BSDF_PRINCIPLED" for node in tree.nodes),
            1,
            f"{material.name} Principled node count",
        )
        validator.equal(
            sum(node.type in {"TEX_IMAGE", "TEX_ENVIRONMENT"} for node in nodes),
            0,
            f"{material.name} reachable image texture count",
        )
        principled = next(
            (node for node in tree.nodes if node.type == "BSDF_PRINCIPLED"), None
        )
        outputs = [node for node in tree.nodes if node.type == "OUTPUT_MATERIAL"]
        validator.equal(len(outputs), 1, f"{material.name} output node count")
        if principled is not None and len(outputs) == 1:
            surface_links = outputs[0].inputs["Surface"].links
            validator.check(
                len(surface_links) == 1 and surface_links[0].from_node == principled,
                f"{material.name} output must be driven by its Principled BSDF",
            )
            if material.name not in expected:
                continue
            grey, roughness = expected[material.name]
            validator.vector_close(
                principled.inputs["Base Color"].default_value[:3],
                (grey, grey, grey),
                f"{material.name} base color",
            )
            validator.close(
                principled.inputs["Roughness"].default_value,
                roughness,
                f"{material.name} roughness",
            )

    world = scene.world
    validator.check(world is not None, "pavilion world exists")
    if world is None:
        return
    validator.equal(world.name, "BMCP_Pavilion_World", "pavilion world name")
    validator.check(world.use_nodes, "pavilion world uses nodes")
    if world.node_tree is None:
        return
    nodes = list(walk_nodes(world.node_tree))
    validator.equal(
        sorted(node.type for node in world.node_tree.nodes),
        ["BACKGROUND", "OUTPUT_WORLD"],
        "world top-level node types",
    )
    validator.equal(
        sum(node.type in {"TEX_IMAGE", "TEX_ENVIRONMENT"} for node in nodes),
        0,
        "pavilion world reachable image texture count",
    )
    backgrounds = [node for node in world.node_tree.nodes if node.type == "BACKGROUND"]
    validator.equal(len(backgrounds), 1, "world background node count")
    if len(backgrounds) == 1:
        outputs = [node for node in world.node_tree.nodes if node.type == "OUTPUT_WORLD"]
        validator.equal(len(outputs), 1, "world output node count")
        if len(outputs) == 1:
            surface_links = outputs[0].inputs["Surface"].links
            validator.check(
                len(surface_links) == 1 and surface_links[0].from_node == backgrounds[0],
                "world output must be driven by its Background node",
            )
        validator.vector_close(
            backgrounds[0].inputs["Color"].default_value[:3],
            (0.35, 0.35, 0.35),
            "world background RGB",
        )
        validator.close(
            backgrounds[0].inputs["Strength"].default_value,
            1.0,
            "world background strength",
        )


def validate_studio(scene, validator):
    cameras = [obj for obj in scene.objects if obj.type == "CAMERA"]
    lights = [obj for obj in scene.objects if obj.type == "LIGHT"]
    validator.equal(len(cameras), 1, "pavilion camera count")
    validator.equal(len(lights), 1, "pavilion light count")
    validator.equal(scene.camera.name if scene.camera else None, "CAM_Hero", "active camera")
    validator.equal(
        sum(obj.type == "EMPTY" for obj in scene.objects), 0, "pavilion empty count"
    )

    camera = scene.objects.get("CAM_Hero")
    if camera is not None and camera.type == "CAMERA":
        validator.equal(camera.data.type, "PERSP", "camera projection")
        validator.close(camera.data.lens, 35.0, "camera lens")
        validator.close(camera.data.sensor_width, 36.0, "camera sensor width")
        validator.vector_close(camera.location, (12.0, -11.0, 4.6), "camera location")
        validator.equal(len(camera.constraints), 0, "camera constraint count")
        validator.equal(camera.parent, None, "camera parent")
        rotation = camera.matrix_world.to_quaternion()
        forward = rotation @ Vector((0.0, 0.0, -1.0))
        aim = (CAMERA_TARGET - camera.matrix_world.translation).normalized()
        validator.check(forward.normalized().dot(aim) > 0.9999, "camera aim dot product")
        actual_up = rotation @ Vector((0.0, 1.0, 0.0))
        expected_up = aim.to_track_quat("-Z", "Y") @ Vector((0.0, 1.0, 0.0))
        validator.vector_close(
            actual_up.normalized(), expected_up.normalized(), "camera up vector"
        )

    light = scene.objects.get("LGT_Sun")
    if light is not None and light.type == "LIGHT":
        validator.equal(light.data.type, "SUN", "light type")
        validator.close(light.data.energy, 3.0, "sun strength")
        validator.close(light.data.angle, math.radians(2.0), "sun angle")
        validator.vector_close(
            light.rotation_euler,
            (math.radians(52.0), 0.0, math.radians(130.0)),
            "sun rotation",
        )
        validator.equal(len(light.constraints), 0, "light constraint count")


def validate_render_settings(scene, validator):
    validator.equal(scene.render.engine, "CYCLES", "render engine")
    validator.equal(scene.cycles.device, "CPU", "Cycles device")
    validator.equal(scene.cycles.samples, 96, "Cycles samples")
    validator.check(scene.cycles.use_adaptive_sampling, "adaptive sampling enabled")
    validator.close(scene.cycles.adaptive_threshold, 0.02, "adaptive threshold")
    validator.check(scene.cycles.use_denoising, "render denoising enabled")
    validator.equal(scene.cycles.denoiser, "OPENIMAGEDENOISE", "render denoiser")
    validator.equal(scene.render.resolution_x, 1280, "resolution X")
    validator.equal(scene.render.resolution_y, 800, "resolution Y")
    validator.equal(scene.render.resolution_percentage, 100, "resolution percentage")
    validator.equal(scene.render.image_settings.file_format, "PNG", "image format")
    validator.equal(scene.render.image_settings.color_depth, "8", "image color depth")
    validator.equal(scene.render.image_settings.color_mode, "RGBA", "image color mode")
    validator.equal(scene.render.film_transparent, False, "film transparency")
    validator.equal(scene.view_settings.view_transform, "AgX", "view transform")
    validator.equal(scene.view_settings.look, "None", "view look")
    validator.close(scene.view_settings.exposure, 0.0, "view exposure")
    validator.close(scene.view_settings.gamma, 1.0, "view gamma")
    validator.equal(scene.frame_current, 1, "current frame")


def validate_no_modifiers(pavilion_objects, validator):
    for obj in pavilion_objects:
        validator.equal(len(obj.modifiers), 0, f"{obj.name} modifier count")


def validate_factory_scene(validator):
    scene = bpy.data.scenes.get("Scene")
    validator.check(scene is not None, "factory Scene is preserved")
    if scene is None:
        return
    validator.equal(
        {obj.name: obj.type for obj in scene.objects},
        {"Cube": "MESH", "Camera": "CAMERA", "Light": "LIGHT"},
        "factory object membership",
    )
    validator.equal(
        sorted(collection.name for collection in scene.collection.children),
        ["Collection"],
        "factory collection membership",
    )
    validator.equal(len(scene.collection.objects), 0, "factory root direct object count")
    validator.equal(scene.camera.name if scene.camera else None, "Camera", "factory camera")
    validator.equal(scene.world.name if scene.world else None, "World", "factory world")

    expected_transforms = {
        "Cube": ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)),
        "Camera": ((7.35889149, -6.92579079, 4.95830917), (1.10931897, 0.0, 0.81492817)),
        "Light": ((4.07624531, 1.00545394, 5.90386200), (0.65032798, 0.05521711, 1.86639082)),
    }
    for name, (location, rotation) in expected_transforms.items():
        obj = scene.objects.get(name)
        if obj is None:
            continue
        validator.vector_close(obj.location, location, f"factory {name} location")
        validator.vector_close(obj.rotation_euler, rotation, f"factory {name} rotation")
        validator.vector_close(obj.scale, (1.0, 1.0, 1.0), f"factory {name} scale")
        validator.equal(len(obj.modifiers), 0, f"factory {name} modifier count")
        validator.equal(
            sorted(collection.name for collection in obj.users_collection),
            ["Collection"],
            f"factory {name} collection links",
        )

    cube = scene.objects.get("Cube")
    if cube is not None and cube.type == "MESH":
        validator.equal(
            (len(cube.data.vertices), len(cube.data.edges), len(cube.data.polygons)),
            (8, 12, 6),
            "factory Cube mesh counts",
        )
        validator.equal(
            [slot.material.name if slot.material else None for slot in cube.material_slots],
            ["Material"],
            "factory Cube materials",
        )
    camera = scene.objects.get("Camera")
    if camera is not None and camera.type == "CAMERA":
        validator.close(camera.data.lens, 50.0, "factory camera lens")
        validator.close(camera.data.sensor_width, 36.0, "factory camera sensor width")
    light = scene.objects.get("Light")
    if light is not None and light.type == "LIGHT":
        validator.equal(light.data.type, "POINT", "factory light type")
        validator.close(light.data.energy, 1000.0, "factory light energy")
        validator.close(light.data.shadow_soft_size, 0.1, "factory light radius")


def validate_render_image(path, validator, metrics):
    validator.check(path.is_file(), f"render file exists: {path}")
    if not path.is_file():
        return
    image = bpy.data.images.load(str(path), check_existing=False)
    width, height = (int(value) for value in image.size)
    validator.equal((width, height), (1280, 800), "render PNG dimensions")
    validator.equal(image.file_format, "PNG", "render file format")

    pixels = array("f", [0.0]) * (width * height * image.channels)
    image.pixels.foreach_get(pixels)
    count = width * height
    luminance_sum = 0.0
    luminance_square_sum = 0.0
    for offset in range(0, len(pixels), image.channels):
        luminance = (
            0.2126 * pixels[offset]
            + 0.7152 * pixels[offset + 1]
            + 0.0722 * pixels[offset + 2]
        )
        luminance_sum += luminance
        luminance_square_sum += luminance * luminance
    mean = luminance_sum / count
    variance = max(0.0, luminance_square_sum / count - mean * mean)
    metrics["render"] = {
        "path": str(path),
        "width": width,
        "height": height,
        "luminance_mean": mean,
        "luminance_variance": variance,
        "sha256": sha256_file(path),
    }
    validator.check(variance > 1e-8, "render luminance variance is non-zero")


def validate_scene(columns, render_path):
    validator = Validator()
    metrics = {}
    scene = bpy.data.scenes.get(SCENE_NAME)
    validator.check(scene is not None, f"scene {SCENE_NAME} exists")
    if scene is not None:
        validator.equal(scene.name, SCENE_NAME, "pavilion scene name")
        _, _, pavilion_objects = validate_collection_tree(scene, columns, validator)
        validate_geometry(scene, columns, validator, metrics)
        validate_materials(scene, pavilion_objects, validator)
        validate_studio(scene, validator)
        validate_render_settings(scene, validator)
        validate_no_modifiers(pavilion_objects, validator)
    validate_factory_scene(validator)
    if render_path is not None:
        validate_render_image(render_path, validator, metrics)
    return validator, metrics


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


def main():
    args = parse_args()
    blend_path = Path(args.blend).expanduser().resolve()
    report_path = Path(os.path.abspath(os.path.expanduser(args.report)))
    render_path = (
        Path(args.expect_render).expanduser().resolve(strict=False)
        if args.expect_render
        else None
    )
    refuse_existing(report_path)

    bpy.ops.wm.open_mainfile(
        filepath=str(blend_path), load_ui=False, use_scripts=False
    )
    validator, metrics = validate_scene(args.expect_columns, render_path)
    report = {
        "schema_version": 1,
        "passed": not validator.failures,
        "checks": validator.checks,
        "failures": validator.failures,
        "expected_columns": args.expect_columns,
        "blend": {"path": str(blend_path), "sha256": sha256_file(blend_path)},
        "metrics": metrics,
        "blender": {
            "version": bpy.app.version_string,
            "build_hash": bpy.app.build_hash.decode("utf-8"),
        },
        "checker_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
    }
    write_report(report_path, report)
    raise SystemExit(0 if report["passed"] else 1)


if __name__ == "__main__":
    main()
