from __future__ import annotations

import contextlib
import io
import tempfile
import unittest
import warnings
from pathlib import Path
from types import SimpleNamespace
from unittest import mock

from tests.python.support.paths import add_repo_path

add_repo_path("packages/cadpy/src")

from cadpy import generation
from cadpy.assembly_spec import (
    IDENTITY_TRANSFORM,
    AssemblyInstanceSpec,
    AssemblyNodeSpec,
    AssemblySpec,
)
from cadpy.catalog import CadSource
from cadpy.metadata import parse_generator_metadata
from cadpy.step_export import _create_bin_xcaf_doc, export_build123d_step_scene
from cadpy.step_scene import LoadedStepScene, _bbox_from_shape, scene_leaf_occurrences, scene_occurrence_shape


def _rounded_color(color: tuple[float, ...]) -> tuple[float, ...]:
    return tuple(round(component, 3) for component in color)


def _srgb_to_linear(component: float) -> float:
    if component <= 0.04045:
        return component / 12.92
    return ((component + 0.055) / 1.055) ** 2.4


class CompoundAssemblyGenerationTests(unittest.TestCase):
    def test_step_payload_rejects_legacy_output_field(self) -> None:
        with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): step_output"):
            generation._normalize_step_payload(
                {"shape": object(), "step_output": "legacy.step"},
                script_path=Path("part.py"),
            )

    def test_step_payload_preserves_instance_assembly_mates(self) -> None:
        payload = generation._normalize_step_payload(
            {
                "instances": [
                    {
                        "path": "servo.step",
                        "name": "servo",
                    }
                ],
                "assembly_mates": [
                    {
                        "sourceLabel": "servo_to_bracket",
                        "relation": "rigid",
                        "fixed": "servo_mount",
                        "moving": "bracket_foot",
                    }
                ],
            },
            script_path=Path("assembly.py"),
        )

        self.assertEqual("servo_to_bracket", payload["assembly_mates"][0]["sourceLabel"])

    def test_attach_envelope_assembly_mates_renumbers_for_scene(self) -> None:
        scene = LoadedStepScene(step_path=Path("assembly.step"), roots=[], prototype_shapes={})

        generation._attach_envelope_assembly_mates(
            scene,
            {
                "assembly_mates": [
                    {
                        "sourceLabel": "servo_to_bracket",
                        "relation": "rigid",
                        "fixed": "servo_mount",
                        "moving": "bracket_foot",
                    }
                ]
            },
            script_path=Path("assembly.py"),
        )

        self.assertEqual(
            [
                {
                    "id": "m1",
                    "label": "m1",
                    "sourceLabel": "servo_to_bracket",
                    "relation": "rigid",
                    "fixed": "servo_mount",
                    "moving": "bracket_foot",
                }
            ],
            scene.assembly_mates,
        )

    def test_dxf_payload_rejects_legacy_output_field(self) -> None:
        with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): dxf_output"):
            generation._normalize_dxf_payload(
                {"document": object(), "dxf_output": "legacy.dxf"},
                script_path=Path("part.py"),
            )

    def test_metadata_rejects_legacy_output_fields(self) -> None:
        cases = [
            ("gen_step", "return {'shape': object(), 'step_output': 'legacy.step'}", "step_output"),
            ("gen_dxf", "return {'document': object(), 'dxf_output': 'legacy.dxf'}", "dxf_output"),
            ("gen_urdf", "return {'xml': '<robot />', 'urdf_output': 'legacy.urdf'}", "urdf_output"),
            ("gen_sdf", "return {'xml': '<sdf version=\"1.12\" />', 'sdf_output': 'legacy.sdf'}", "sdf_output"),
        ]
        for function_name, return_line, field_name in cases:
            with self.subTest(function_name=function_name), tempfile.TemporaryDirectory(prefix="cadpy-output-field-") as tempdir:
                script_path = Path(tempdir) / "part.py"
                script_path.write_text(
                    "\n".join(
                        [
                            "def gen_step():",
                            "    return {'shape': object()}",
                            "",
                            f"def {function_name}():",
                            f"    {return_line}",
                            "",
                        ]
                    ),
                    encoding="utf-8",
                )

                with self.assertRaisesRegex(ValueError, f"unsupported field\\(s\\): {field_name}"):
                    parse_generator_metadata(script_path)

    def test_run_selected_specs_preserves_action_stdout(self) -> None:
        spec = SimpleNamespace(source_ref="part.py")
        stdout = io.StringIO()

        with contextlib.redirect_stdout(stdout):
            generation._run_selected_specs(
                [spec],
                action=lambda _spec: print("generator summary"),
                logger=generation.CliLogger("test", stream=io.StringIO()),
                success_message=None,
            )

        self.assertEqual("generator summary\n", stdout.getvalue())

    def test_compound_with_explicit_children_is_discovered_as_assembly(self) -> None:
        with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
            script_path = Path(tempdir) / "robot_arm.py"
            script_path.write_text(
                "\n".join(
                    [
                        "from build123d import Compound",
                        "",
                        "def gen_step():",
                        "    parts = []",
                        "    assembly = Compound(",
                        "        obj=parts,",
                        "        children=parts,",
                        "        label='robot_arm_static_display_pose',",
                        "    )",
                        "    return assembly",
                        "",
                    ]
                ),
                encoding="utf-8",
            )

            metadata = parse_generator_metadata(script_path)

        self.assertIsNotNone(metadata)
        self.assertEqual("assembly", metadata.kind)

    def test_compound_with_literal_obj_sequence_is_discovered_as_assembly(self) -> None:
        with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
            script_path = Path(tempdir) / "compound_arm.py"
            script_path.write_text(
                "\n".join(
                    [
                        "from build123d import Box, Compound",
                        "",
                        "def gen_step():",
                        "    left = Box(1, 1, 1)",
                        "    right = Box(1, 1, 1)",
                        "    return Compound(obj=[left, right], label='compound_arm')",
                        "",
                    ]
                ),
                encoding="utf-8",
            )

            metadata = parse_generator_metadata(script_path)

        self.assertIsNotNone(metadata)
        self.assertEqual("assembly", metadata.kind)

    def test_childless_compound_obj_sequence_is_runtime_assembly(self) -> None:
        import build123d

        left = build123d.Box(1, 1, 1)
        right = build123d.Box(1, 1, 1)
        shape = build123d.Compound(obj=[left, right], label="compound_arm")

        self.assertEqual("assembly", generation._shape_payload_entry_kind(shape, fallback="part"))

    def test_labeled_childless_compound_does_not_warn_without_color(self) -> None:
        import build123d

        left = build123d.Box(1, 1, 1)
        right = build123d.Box(1, 1, 1)
        shape = build123d.Compound(obj=[left, right], label="compound_arm")

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            _create_bin_xcaf_doc(shape)

        messages = [str(item.message) for item in caught]
        self.assertNotIn("Unknown Compound type, color not set", messages)

    def test_colored_child_shapes_survive_compound_assembly_export(self) -> None:
        import build123d

        with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
            left = build123d.Box(1, 1, 1)
            left.label = "red_child"
            left.color = build123d.Color(1, 0, 0)
            right = build123d.Pos(2, 0, 0) * build123d.Box(1, 1, 1)
            right.label = "blue_child"
            right.color = build123d.Color(0, 0, 1)
            shape = build123d.Compound(children=[left, right], label="colored_assembly")

            scene = export_build123d_step_scene(
                shape,
                Path(tempdir) / "colored_assembly.step",
                text_to_cad_entry_kind="assembly",
            )

        colors = {
            tuple(round(component, 3) for component in color)
            for color in scene.prototype_colors.values()
        }
        colors.update(
            tuple(round(component, 3) for component in node.color)
            for root in scene.roots
            for node in root.children
            if node.color is not None
        )

        self.assertEqual(1, len(scene.roots))
        self.assertEqual(2, len(scene.roots[0].children))
        self.assertIn((1.0, 0.0, 0.0, 1.0), colors)
        self.assertIn((0.0, 0.0, 1.0, 1.0), colors)

    def test_nested_colored_compound_keeps_parent_transform(self) -> None:
        import build123d

        with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
            child = build123d.Box(1, 1, 1)
            child.label = "motor_body"
            child.color = build123d.Color(0.1, 0.2, 0.3)
            expected_color = _rounded_color(child.color)
            expected_linear_color = _rounded_color(
                (
                    *(_srgb_to_linear(component) for component in expected_color[:3]),
                    expected_color[3],
                )
            )
            nested = build123d.Compound(children=[child], label="imported_motor")
            placed = build123d.Pos(20, 0, 0) * nested
            placed.label = "placed_motor"
            root = build123d.Compound(children=[placed], label="arm")

            scene = export_build123d_step_scene(
                root,
                Path(tempdir) / "arm.step",
                text_to_cad_entry_kind="assembly",
            )

        leaves = scene_leaf_occurrences(scene)
        self.assertEqual(1, len(leaves))
        bbox = _bbox_from_shape(scene_occurrence_shape(scene, leaves[0]))
        self.assertGreater(bbox["min"][0], 19.0)
        self.assertLess(bbox["max"][0], 21.0)
        self.assertIn(
            _rounded_color(leaves[0].color),
            {expected_color, expected_linear_color},
        )

    def test_shape_payload_can_export_with_assembly_entry_kind(self) -> None:
        import build123d

        with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
            script_path = Path(tempdir) / "robot_arm.py"
            script_path.write_text("def gen_step():\n    return None\n", encoding="utf-8")
            output_path = script_path.with_suffix(".step")
            scene = LoadedStepScene(step_path=output_path.resolve(), roots=[], prototype_shapes={})
            left = build123d.Box(1, 1, 1)
            right = build123d.Box(1, 1, 1)
            shape = build123d.Compound(children=[left, right], label="robot_arm")

            with (
                mock.patch.object(
                    generation,
                    "python_source_hash",
                    return_value=SimpleNamespace(
                        source_path="robot_arm.py",
                        source_hash="hash-123",
                    ),
                ),
                mock.patch.object(generation, "export_build123d_step_scene", return_value=scene) as export_scene,
            ):
                result = generation._write_shape_step_payload(
                    {"shape": shape},
                    output_path=output_path,
                    script_path=script_path,
                    logger=generation.CliLogger("test"),
                    entry_kind="assembly",
                )

        self.assertIs(result, scene)
        self.assertEqual("assembly", export_scene.call_args.kwargs["text_to_cad_entry_kind"])
        self.assertEqual("assembly", getattr(scene, "text_to_cad_entry_kind", None))
        self.assertEqual("shape", getattr(scene, "step_payload_kind", None))

    def test_effective_spec_follows_runtime_shape_entry_kind(self) -> None:
        step_path = Path("/tmp/compound.step")
        scene = LoadedStepScene(step_path=step_path, roots=[], prototype_shapes={})
        scene.text_to_cad_entry_kind = "assembly"
        spec = generation.EntrySpec(
            source_ref="compound.py",
            cad_ref="compound",
            kind="part",
            source_path=Path("/tmp/compound.py"),
            display_name="compound",
            source="generated",
            step_path=step_path,
            script_path=Path("/tmp/compound.py"),
        )

        effective = generation._effective_step_spec_for_scene(spec, scene)

        self.assertEqual("assembly", effective.kind)
        self.assertEqual("part", spec.kind)

    def test_artifact_outputs_use_runtime_shape_entry_kind(self) -> None:
        with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
            step_path = Path(tempdir) / "compound.step"
            script_path = Path(tempdir) / "compound.py"
            scene = LoadedStepScene(step_path=step_path.resolve(), roots=[], prototype_shapes={})
            scene.text_to_cad_entry_kind = "assembly"
            scene.source_kind = "python"
            scene.source_path = "compound.py"
            scene.source_hash = "source-hash"
            spec = generation.EntrySpec(
                source_ref="compound.py",
                cad_ref="compound",
                kind="part",
                source_path=script_path,
                display_name="compound",
                source="generated",
                step_path=step_path,
                script_path=script_path,
            )
            selector_bundle = generation.SelectorBundle(manifest={"stats": {}})

            with (
                mock.patch.object(generation, "_existing_topology_artifact_matches_spec_without_scene", return_value=False),
                mock.patch.object(generation, "_existing_topology_artifact_matches_options", return_value=False),
                mock.patch.object(generation, "_selector_options_for_part", return_value=generation.SelectorOptions()),
                mock.patch.object(generation, "mesh_step_scene"),
                mock.patch.object(generation, "scene_export_shape"),
                mock.patch.object(generation, "_reset_step_artifact_dir"),
                mock.patch.object(generation, "_run_artifact_jobs", return_value={"GLB/topology": selector_bundle}),
            ):
                result = generation._generate_part_outputs(
                    spec,
                    entries_by_step_path={step_path.resolve(): spec},
                    preloaded_scene=scene,
                    require_step_file=False,
                    force=True,
                )

            self.assertEqual("assembly", result.spec.kind)
            self.assertIs(result.selector_bundle, selector_bundle)

    def test_dependency_expansion_walks_flattened_grouping_node_leaves(self) -> None:
        with tempfile.TemporaryDirectory(prefix="cadpy-dependencies-") as tempdir:
            root = Path(tempdir)
            assembly_path = root / "grouped_assembly.py"
            assembly_step = root / "grouped_assembly.step"
            leaf_step = root / "nested_part.step"
            assembly_path.write_text("def gen_step():\n    return None\n", encoding="utf-8")
            leaf_step.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")

            identity = tuple(IDENTITY_TRANSFORM)
            leaf_instance = AssemblyInstanceSpec(
                instance_id="nested_part",
                source_path=leaf_step.resolve(),
                path="nested_part.step",
                name="nested_part",
                transform=identity,
            )
            leaf_node = AssemblyNodeSpec(
                instance_id="nested_part",
                name="nested_part",
                transform=identity,
                source_path=leaf_step.resolve(),
                path="nested_part.step",
                children=(),
            )
            grouping_node = AssemblyNodeSpec(
                instance_id="front_group",
                name="front_group",
                transform=identity,
                source_path=None,
                path=None,
                children=(leaf_node,),
            )
            assembly_spec = AssemblySpec(
                assembly_path=assembly_path.resolve(),
                instances=(leaf_instance,),
                children=(grouping_node,),
            )
            entry = generation.EntrySpec(
                source_ref="grouped_assembly.py",
                cad_ref="grouped_assembly",
                kind="assembly",
                source_path=assembly_path.resolve(),
                display_name="grouped_assembly",
                source="generated",
                step_path=assembly_step.resolve(),
                script_path=assembly_path.resolve(),
            )
            leaf_source = CadSource(
                source_ref="nested_part.step",
                cad_ref="nested_part",
                kind="part",
                source_path=leaf_step.resolve(),
                source="imported",
                origin_path=leaf_step.resolve(),
                step_path=leaf_step.resolve(),
            )

            with (
                mock.patch.object(generation, "read_assembly_spec", return_value=assembly_spec),
                mock.patch.object(generation, "_source_lookup_by_path", return_value={leaf_step.resolve(): leaf_source}),
            ):
                expanded = generation._expand_specs_with_file_dependencies([entry])

        self.assertEqual(
            [assembly_path.resolve(), leaf_step.resolve()],
            [spec.source_path for spec in expanded],
        )


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