import json
import struct
import unittest
from array import array
from pathlib import Path

import build123d
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS

from cadpy.glb import (
    export_assembly_glb_from_scene,
    export_native_glb_from_scene,
    export_part_glb_from_scene,
    read_step_display_edge_manifest_from_glb,
    read_step_topology_bundle_from_glb,
    read_step_topology_manifest_from_glb,
)
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION, glb_surface_edge_class_has_nonzero_values
from cadpy.step_scene import (
    LoadedStepScene,
    OccurrenceNode,
    SelectorBundle,
    SelectorOptions,
    SelectorProfile,
    _shape_hash,
    extract_selectors_from_scene,
    load_step_scene,
)
from tests.python.support.tmp_root import temporary_directory


def _identity_transform() -> tuple[float, ...]:
    return (
        1.0,
        0.0,
        0.0,
        0.0,
        0.0,
        1.0,
        0.0,
        0.0,
        0.0,
        0.0,
        1.0,
        0.0,
        0.0,
        0.0,
        0.0,
        1.0,
    )


def _meshed_box(width: float = 10, depth: float = 20, height: float = 30) -> object:
    shape = build123d.Box(width, depth, height).wrapped
    BRepMesh_IncrementalMesh(shape, 0.1, True, 0.1, True).Perform()
    return shape


def _single_leaf_scene(shape: object, **kwargs) -> tuple[LoadedStepScene, int]:
    prototype_key = _shape_hash(shape)
    scene = LoadedStepScene(
        step_path=Path("fixture.step"),
        roots=[
            OccurrenceNode(
                path=(1,),
                name="fixture",
                source_name="fixture",
                transform=_identity_transform(),
                local_transform=_identity_transform(),
                prototype_key=prototype_key,
            )
        ],
        prototype_shapes={prototype_key: shape},
        prototype_names={prototype_key: "fixture"},
        **kwargs,
    )
    return scene, prototype_key


def _read_glb_json(path: Path) -> dict[str, object]:
    payload = path.read_bytes()
    magic, version, _length = struct.unpack_from("<III", payload, 0)
    if magic != 0x46546C67 or version != 2:
        raise AssertionError("Not a GLB v2 file")
    chunk_length, chunk_type = struct.unpack_from("<I4s", payload, 12)
    if chunk_type != b"JSON":
        raise AssertionError("First GLB chunk is not JSON")
    return json.loads(payload[20:20 + chunk_length].decode("utf-8"))


class GlbExportTests(unittest.TestCase):
    def test_part_glb_embeds_step_topology_extension(self) -> None:
        with temporary_directory(prefix="cad-glb-test-") as temp_dir:
            step_path = Path(temp_dir) / "fixture.step"
            scene, _prototype_key = _single_leaf_scene(_meshed_box())
            bundle = SelectorBundle(
                manifest={
                    "schemaVersion": 1,
                    "profile": "artifact",
                    "stepPath": "fixture.step",
                    "stepHash": "step-hash-123",
                    "tables": {},
                    "occurrences": [],
                    "shapes": [],
                    "faces": [],
                    "edges": [],
                },
                buffers={"edgeIds": array("I", [7, 11]), "surfaceHalfEdges": array("I")},
            )

            glb_path = export_part_glb_from_scene(
                step_path,
                scene,
                linear_deflection=0.1,
                angular_deflection=0.1,
                selector_bundle=bundle,
            )

            gltf = _read_glb_json(glb_path)
            self.assertIn("STEP_topology", gltf.get("extensionsUsed", []))
            self.assertIn("STEP_topology", gltf.get("extensions", {}))
            extension = gltf.get("extensions", {}).get("STEP_topology", {})
            self.assertIn("indexView", extension)
            self.assertIn("edgeView", extension)
            self.assertIn("selectorView", extension)
            index = read_step_topology_manifest_from_glb(glb_path)
            self.assertIsNotNone(index)
            assert index is not None
            self.assertEqual("index", index.get("profile"))
            self.assertEqual("part", index.get("entryKind"))
            self.assertNotIn("cadRef", index)
            self.assertNotIn("cadPath", index)
            surface_edges = read_step_display_edge_manifest_from_glb(glb_path)
            self.assertIsNotNone(surface_edges)
            assert surface_edges is not None
            self.assertEqual("surface-edges", surface_edges.get("profile"))
            self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, surface_edges.get("schemaVersion"))
            self.assertEqual("surfaceHalfEdges", surface_edges.get("halfEdgesView"))
            self.assertIn("surfaceHalfEdges", surface_edges.get("buffers", {}).get("views", {}))
            self.assertNotIn("faces", surface_edges)
            self.assertNotIn("relations", surface_edges)
            embedded = read_step_topology_bundle_from_glb(glb_path)
            self.assertIsNotNone(embedded)
            assert embedded is not None
            self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, embedded.manifest["schemaVersion"])
            self.assertNotIn("cadRef", embedded.manifest)
            self.assertNotIn("cadPath", embedded.manifest)
            self.assertEqual([7, 11], list(embedded.buffers["edgeIds"]))

    def test_step_glb_surface_edge_class_attribute_is_populated_from_selector_topology(self) -> None:
        with temporary_directory(prefix="cad-glb-surface-edges-") as temp_dir:
            step_path = Path(temp_dir) / "cylinder.step"
            build123d.export_step(build123d.Cylinder(5, 10), step_path)
            scene = load_step_scene(step_path)
            bundle = extract_selectors_from_scene(
                scene,
                profile=SelectorProfile.ARTIFACT,
                options=SelectorOptions(linear_deflection=0.2, angular_deflection=0.2),
            )

            glb_path = export_part_glb_from_scene(
                step_path,
                scene,
                linear_deflection=0.2,
                angular_deflection=0.2,
                selector_bundle=bundle,
            )

            self.assertTrue(glb_surface_edge_class_has_nonzero_values(glb_path))

    def test_legacy_topology_json_and_bin_bundle_is_read_when_glb_lacks_extension(self) -> None:
        with temporary_directory(prefix="cad-glb-test-") as temp_dir:
            artifact_dir = Path(temp_dir) / ".fixture.step"
            artifact_dir.mkdir()
            glb_path = artifact_dir / "model.glb"
            glb_path.write_bytes(b"legacy visual glb without embedded topology")

            edge_ids = array("I", [19, 23])
            edge_bytes = edge_ids.tobytes()
            (artifact_dir / "topology.bin").write_bytes(edge_bytes)
            (artifact_dir / "topology.json").write_text(
                json.dumps(
                    {
                        "schemaVersion": 1,
                        "profile": "artifact",
                        "cadRef": "fixtures/legacy",
                        "buffers": {
                            "uri": "topology.bin",
                            "littleEndian": True,
                            "views": {
                                "edgeIds": {
                                    "dtype": "uint32",
                                    "byteOffset": 0,
                                    "byteLength": len(edge_bytes),
                                    "count": 2,
                                    "itemSize": 4,
                                }
                            },
                        },
                    }
                ),
                encoding="utf-8",
            )

            index = read_step_topology_manifest_from_glb(glb_path)
            self.assertIsNotNone(index)
            assert index is not None
            self.assertEqual("fixtures/legacy", index.get("cadRef"))

            bundle = read_step_topology_bundle_from_glb(glb_path)
            self.assertIsNotNone(bundle)
            assert bundle is not None
            self.assertEqual("fixtures/legacy", bundle.manifest.get("cadRef"))
            self.assertEqual([19, 23], list(bundle.buffers["edgeIds"]))

    def test_assembly_glb_preserves_face_colors_as_material_primitives(self) -> None:
        with temporary_directory(prefix="cad-glb-test-") as temp_dir:
            step_path = Path(temp_dir) / "fixture.step"
            shape = _meshed_box()
            face_hashes: list[int] = []
            explorer = TopExp_Explorer(shape, TopAbs_FACE)
            while explorer.More() and len(face_hashes) < 2:
                face_hashes.append(_shape_hash(TopoDS.Face_s(explorer.Current())))
                explorer.Next()
            scene, prototype_key = _single_leaf_scene(
                shape,
                prototype_face_colors={
                    _shape_hash(shape): {
                        face_hashes[0]: (1.0, 0.0, 0.0, 1.0),
                        face_hashes[1]: (0.0, 0.0, 1.0, 1.0),
                    }
                },
            )

            glb_path = export_assembly_glb_from_scene(
                step_path,
                scene,
                linear_deflection=0.1,
                angular_deflection=0.1,
            )

            gltf = _read_glb_json(glb_path)
            colors = [
                material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
                for material in gltf.get("materials", [])
            ]
            primitives = gltf.get("meshes", [])[0].get("primitives", [])
            primitive_materials = {primitive.get("material") for primitive in primitives}
            primitive_attributes = [primitive.get("attributes", {}) for primitive in primitives]
            self.assertEqual(prototype_key, _shape_hash(shape))
            self.assertIn([1.0, 0.0, 0.0, 1.0], colors)
            self.assertIn([0.0, 0.0, 1.0, 1.0], colors)
            self.assertGreaterEqual(len(primitive_materials), 2)
            self.assertTrue(all("NORMAL" in attributes for attributes in primitive_attributes))

    def test_assembly_glb_applies_occurrence_color_to_descendant_meshes(self) -> None:
        with temporary_directory(prefix="cad-glb-test-") as temp_dir:
            step_path = Path(temp_dir) / "fixture.step"
            shape = _meshed_box()
            prototype_key = _shape_hash(shape)
            scene = LoadedStepScene(
                step_path=step_path,
                roots=[
                    OccurrenceNode(
                        path=(1,),
                        name="assembly",
                        source_name="assembly",
                        transform=_identity_transform(),
                        prototype_key=None,
                        local_transform=_identity_transform(),
                        children=(
                            OccurrenceNode(
                                path=(1, 1),
                                name="part",
                                source_name="part",
                                transform=_identity_transform(),
                                local_transform=_identity_transform(),
                                prototype_key=prototype_key,
                            ),
                        ),
                    )
                ],
                prototype_shapes={prototype_key: shape},
                prototype_names={prototype_key: "fixture"},
            )

            glb_path = export_assembly_glb_from_scene(
                step_path,
                scene,
                linear_deflection=0.1,
                angular_deflection=0.1,
                occurrence_colors={"o1": (0.1, 0.2, 0.3, 1.0)},
            )

            gltf = _read_glb_json(glb_path)
            colors = [
                material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
                for material in gltf.get("materials", [])
            ]
            self.assertIn([0.1, 0.2, 0.3, 1.0], colors)

    def test_part_glb_uses_scene_material_colors(self) -> None:
        with temporary_directory(prefix="cad-glb-test-") as temp_dir:
            step_path = Path(temp_dir) / "fixture.step"
            shape = _meshed_box()
            scene, prototype_key = _single_leaf_scene(
                shape,
                prototype_colors={_shape_hash(shape): (0.168627, 0.184314, 0.2, 1.0)},
            )

            glb_path = export_part_glb_from_scene(
                step_path,
                scene,
                linear_deflection=0.1,
                angular_deflection=0.1,
            )

            gltf = _read_glb_json(glb_path)
            colors = [
                material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
                for material in gltf.get("materials", [])
            ]
            self.assertEqual(prototype_key, _shape_hash(shape))
            self.assertIn([0.168627, 0.184314, 0.2, 1.0], colors)

    def test_native_glb_is_y_up_without_step_topology_or_cad_extras(self) -> None:
        with temporary_directory(prefix="cad-glb-test-") as temp_dir:
            step_path = Path(temp_dir) / "fixture.step"
            shape = _meshed_box(width=10, depth=20, height=30)
            scene, _prototype_key = _single_leaf_scene(
                shape,
                prototype_colors={_shape_hash(shape): (0.168627, 0.184314, 0.2, 1.0)},
            )

            glb_path = export_native_glb_from_scene(
                step_path,
                scene,
                target_path=Path(temp_dir) / "fixture.glb",
                linear_deflection=0.1,
                angular_deflection=0.1,
            )

            gltf = _read_glb_json(glb_path)
            self.assertNotIn("extensionsUsed", gltf)
            self.assertNotIn("extensions", gltf)
            self.assertTrue(all("extras" not in node for node in gltf.get("nodes", [])))
            position_accessor = gltf["accessors"][0]
            self.assertEqual([-0.005, -0.015, -0.01], [round(value, 6) for value in position_accessor["min"]])
            self.assertEqual([0.005, 0.015, 0.01], [round(value, 6) for value in position_accessor["max"]])
            colors = [
                material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
                for material in gltf.get("materials", [])
            ]
            self.assertIn([0.168627, 0.184314, 0.2, 1.0], colors)

    def test_native_glb_converts_assembly_transforms_to_y_up(self) -> None:
        with temporary_directory(prefix="cad-glb-test-") as temp_dir:
            step_path = Path(temp_dir) / "fixture.step"
            shape = _meshed_box()
            prototype_key = _shape_hash(shape)
            translated = (
                1.0,
                0.0,
                0.0,
                10.0,
                0.0,
                1.0,
                0.0,
                20.0,
                0.0,
                0.0,
                1.0,
                30.0,
                0.0,
                0.0,
                0.0,
                1.0,
            )
            scene = LoadedStepScene(
                step_path=step_path,
                roots=[
                    OccurrenceNode(
                        path=(1,),
                        name="assembly",
                        source_name="assembly",
                        transform=_identity_transform(),
                        local_transform=_identity_transform(),
                        prototype_key=None,
                        children=(
                            OccurrenceNode(
                                path=(1, 1),
                                name="part",
                                source_name="part",
                                transform=translated,
                                local_transform=translated,
                                prototype_key=prototype_key,
                            ),
                        ),
                    )
                ],
                prototype_shapes={prototype_key: shape},
                prototype_names={prototype_key: "fixture"},
            )

            glb_path = export_native_glb_from_scene(
                step_path,
                scene,
                target_path=Path(temp_dir) / "assembly.glb",
                linear_deflection=0.1,
                angular_deflection=0.1,
            )

            gltf = _read_glb_json(glb_path)
            child = next(node for node in gltf["nodes"] if node.get("mesh") == 0)
            self.assertEqual("part", child.get("name"))
            self.assertEqual([0.01, 0.03, -0.02], [round(value, 6) for value in child["matrix"][12:15]])


if __name__ == "__main__":
    unittest.main()
