# SPDX-License-Identifier: LGPL-2.1-or-later

# Unit tests for the Path.Tool.Shape module and its document utilities.
import unittest
from unittest.mock import patch, MagicMock, call
from Path.Tool.shape import doc
import os


mock_freecad = MagicMock(Name="FreeCAD_Mock")
mock_freecad.Console = MagicMock()
mock_freecad.Console.PrintWarning = MagicMock()
mock_freecad.Console.PrintError = MagicMock()

mock_obj = MagicMock(Name="Object_Mock")
mock_obj.Label = "MockObjectLabel"
mock_obj.Name = "MockObjectName"

mock_doc = MagicMock(Name="Document_Mock")
mock_doc.Objects = [mock_obj]


class TestPathToolShapeDoc(unittest.TestCase):
    def setUp(self):
        """Reset mocks before each test."""
        # Resetting the top-level mock recursively resets its children
        # (newDocument, getDocument, openDocument, closeDocument, Console, etc.)
        # and their call counts, return_values, side_effects.
        mock_freecad.reset_mock()
        mock_doc.reset_mock()
        mock_obj.reset_mock()

        # Re-establish default state/attributes potentially cleared by reset_mock
        # or needed for tests.
        mock_doc.Objects = [mock_obj]
        mock_obj.Label = "MockObjectLabel"
        mock_obj.Name = "MockObjectName"
        mock_obj.getTypeIdOfProperty = MagicMock(return_value="App::PropertyString")
        # Ensure mock_doc also has a Name attribute used in tests/code
        mock_doc.Name = "Document_Mock"  # Used in closeDocument calls

        # Clear attributes potentially added by setattr in previous tests.
        # reset_mock() doesn't remove attributes added this way.
        # Focus on attributes known to be added by tests in this file.
        for attr_name in ["Diameter", "Length", "Height"]:
            if hasattr(mock_obj, attr_name):
                try:
                    delattr(mock_obj, attr_name)
                except AttributeError:
                    pass  # Ignore if already gone

    """Tests for the document utility functions in Path/Tool/Shape/doc.py"""

    def test_doc_find_shape_object_body_priority(self):
        """Test find_shape_object prioritizes PartDesign::Body."""
        body_obj = MagicMock(Name="Body_Mock")
        body_obj.isDerivedFrom = lambda typeName: typeName == "PartDesign::Body"
        part_obj = MagicMock(Name="Part_Mock")
        part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature"
        mock_doc.Objects = [part_obj, body_obj]
        found = doc.find_shape_object(mock_doc)
        self.assertEqual(found, body_obj)

    def test_doc_find_shape_object_part_fallback(self):
        """Test find_shape_object falls back to Part::Feature."""
        part_obj = MagicMock(Name="Part_Mock")
        part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature"
        other_obj = MagicMock(Name="Other_Mock")
        other_obj.isDerivedFrom = lambda typeName: False
        mock_doc.Objects = [other_obj, part_obj]
        found = doc.find_shape_object(mock_doc)
        self.assertEqual(found, part_obj)

    def test_doc_find_shape_object_first_obj_fallback(self):
        """Test find_shape_object falls back to the first object."""
        other_obj1 = MagicMock(Name="Other1_Mock")
        other_obj1.isDerivedFrom = lambda typeName: False
        other_obj2 = MagicMock(Name="Other2_Mock")
        other_obj2.isDerivedFrom = lambda typeName: False
        mock_doc.Objects = [other_obj1, other_obj2]
        found = doc.find_shape_object(mock_doc)
        self.assertEqual(found, other_obj1)

    def test_doc_find_shape_object_no_objects(self):
        """Test find_shape_object returns None if document has no objects."""
        mock_doc.Objects = []
        found = doc.find_shape_object(mock_doc)
        self.assertIsNone(found)

    def test_doc_get_object_properties_found(self):
        """Test get_object_properties extracts existing properties."""
        setattr(mock_obj, "Diameter", "10 mm")
        setattr(mock_obj, "Length", "50 mm")
        params = doc.get_object_properties(mock_obj, ["Diameter", "Length"])
        # Expecting just the values, not tuples
        self.assertEqual(
            params,
            {
                "Diameter": ("10 mm", "App::PropertyString"),
                "Length": ("50 mm", "App::PropertyString"),
            },
        )
        mock_freecad.Console.PrintWarning.assert_not_called()

    def test_doc_get_object_properties_missing(self):
        """Test get_object_properties handles missing properties with warning."""
        # Re-import doc within the patch context to use the mocked FreeCAD
        import Path.Tool.shape.doc as doc_patched

        setattr(mock_obj, "Diameter", "10 mm")
        # Explicitly delete Height to ensure hasattr returns False for MagicMock
        if hasattr(mock_obj, "Height"):
            delattr(mock_obj, "Height")
        params = doc_patched.get_object_properties(mock_obj, ["Diameter", "Height"])
        # Expecting just the values, not tuples
        self.assertEqual(
            params,
            {
                "Diameter": ("10 mm", "App::PropertyString"),
                "Height": (None, "App::PropertyString"),
            },
        )  # Height is missing

    @patch("FreeCAD.openDocument")
    @patch("FreeCAD.getDocument")
    @patch("FreeCAD.closeDocument")
    def test_ShapeDocFromBytes(self, mock_close_doc, mock_get_doc, mock_open_doc):
        """Test ShapeDocFromBytes loads doc from a byte string."""
        content = b"fake_content"
        mock_opened_doc = MagicMock(Name="OpenedDoc_Mock")
        mock_get_doc.return_value = mock_opened_doc

        temp_file_path = None
        try:
            with doc.ShapeDocFromBytes(content=content) as temp_doc:
                # Verify temp file creation and content
                mock_open_doc.assert_called_once()
                temp_file_path = mock_open_doc.call_args[0][0]
                self.assertTrue(os.path.exists(temp_file_path))
                with open(temp_file_path, "rb") as f:
                    self.assertEqual(f.read(), content)

                self.assertEqual(temp_doc, mock_open_doc.return_value)

            # Verify cleanup after exiting the context
            mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name)
            self.assertFalse(os.path.exists(temp_file_path))

        finally:
            # Ensure cleanup even if test fails before assertion
            if temp_file_path and os.path.exists(temp_file_path):
                os.remove(temp_file_path)

    @patch("FreeCAD.openDocument")
    @patch("FreeCAD.getDocument")
    @patch("FreeCAD.closeDocument")
    def test_ShapeDocFromBytes_open_exception(self, mock_close_doc, mock_get_doc, mock_open_doc):
        """Test ShapeDocFromBytes propagates exceptions and cleans up."""
        content = b"fake_content_exception"
        load_error = Exception("Fake load error")
        mock_open_doc.side_effect = load_error

        temp_file_path = None
        try:
            with self.assertRaises(Exception) as cm:
                with doc.ShapeDocFromBytes(content=content):
                    pass

            self.assertEqual(cm.exception, load_error)

            # Verify temp file was created before the exception
            mock_open_doc.assert_called_once()
            temp_file_path = mock_open_doc.call_args[0][0]
            self.assertTrue(os.path.exists(temp_file_path))
            with open(temp_file_path, "rb") as f:
                self.assertEqual(f.read(), content)

            mock_get_doc.assert_not_called()
            # closeDocument is called in __exit__ only if _doc is not None,
            # which it will be if openDocument failed.
            mock_close_doc.assert_not_called()

        finally:
            # Verify cleanup after exiting the context (even with exception)
            if temp_file_path and os.path.exists(temp_file_path):
                os.remove(temp_file_path)
            # Assert removal only if temp_file_path was set
            if temp_file_path:
                self.assertFalse(os.path.exists(temp_file_path))

    @patch("FreeCAD.openDocument")
    @patch("FreeCAD.getDocument")
    @patch("FreeCAD.closeDocument")
    def test_ShapeDocFromBytes_exit_cleans_up(self, mock_close_doc, mock_get_doc, mock_open_doc):
        """Test ShapeDocFromBytes __exit__ cleans up temp file."""
        content = b"fake_content_cleanup"
        mock_opened_doc = MagicMock(Name="OpenedDoc_Cleanup_Mock")
        mock_get_doc.return_value = mock_opened_doc

        temp_file_path = None
        try:
            with doc.ShapeDocFromBytes(content=content):
                mock_open_doc.assert_called_once()
                temp_file_path = mock_open_doc.call_args[0][0]
                self.assertTrue(os.path.exists(temp_file_path))
                with open(temp_file_path, "rb") as f:
                    self.assertEqual(f.read(), content)
                # No assertions on the returned doc here, focus is on cleanup
                pass  # Exit the context

            # Verify cleanup after exiting the context
            mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name)
            self.assertFalse(os.path.exists(temp_file_path))

        finally:
            # Ensure cleanup even if test fails before assertion
            if temp_file_path and os.path.exists(temp_file_path):
                os.remove(temp_file_path)


# Test execution
if __name__ == "__main__":
    unittest.main()
