"""FreeCAD Macro: Cut Object for Magnets.

SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)

Cuts an object along a plane and adds connector holes for magnets with
surface collision detection.

Requirements:
    - FreeCAD 0.19 or later
    - An object selected in the 3D view

Usage:
    1. Select the object to cut
    2. Run the macro
    3. Configure cut plane and hole parameters
    4. Click "Execute Cut"
"""

# FreeCAD Addon Manager metadata
__Name__ = "Cut Object for Magnets"
__Comment__ = "Cut an object along a plane and add aligned magnet holes with surface collision detection"
__Author__ = "Sean P. Kane"
__Version__ = "0.5.0-beta"
__Date__ = "2026-01-05"
__License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
__Icon__ = ""
__Help__ = "Select an object to cut, run the macro, configure cut plane and magnet hole parameters, then click Execute Cut. Creates two parts with aligned magnet holes."
__Status__ = "Beta"
__Requires__ = "FreeCAD 0.19+"
__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues"
__Files__ = ""

import FreeCAD as App
import FreeCADGui as Gui
import Part
from PySide import QtGui


class HolePlacementError(Exception):
    """Raised when hole placement fails."""

    pass


class CutObjectForMagnetsDialog(QtGui.QDialog):
    """Dialog for configuring cut parameters and magnet holes."""

    def __init__(self, parent=None):
        super(CutObjectForMagnetsDialog, self).__init__(parent)
        self.setWindowTitle("Cut Object for Magnets")
        self.setModal(True)
        self.setup_ui()

    def setup_ui(self):
        """Initialize the user interface."""
        layout = QtGui.QVBoxLayout()

        # Object selection - allow user to choose which body to cut
        obj_group = QtGui.QGroupBox("Object to Cut")
        obj_layout = QtGui.QFormLayout()
        self.obj_combo = QtGui.QComboBox()
        self.obj_combo.setToolTip("Select the object to cut")
        self._populate_cuttable_objects()
        obj_layout.addRow("Body:", self.obj_combo)
        obj_group.setLayout(obj_layout)
        layout.addWidget(obj_group)

        # Cut plane configuration
        plane_group = QtGui.QGroupBox("Cut Plane")
        plane_layout = QtGui.QFormLayout()

        # Plane type selector
        self.plane_type_combo = QtGui.QComboBox()
        self.plane_type_combo.addItems(["Preset Plane", "Model Plane"])
        self.plane_type_combo.currentIndexChanged.connect(self._on_plane_type_changed)
        plane_layout.addRow("Plane Type:", self.plane_type_combo)

        # Preset plane combo (XY, XZ, YZ)
        self.plane_combo = QtGui.QComboBox()
        self.plane_combo.addItems(["XY", "XZ", "YZ"])
        plane_layout.addRow("Preset:", self.plane_combo)

        # Model plane combo (populated with available planes)
        self.model_plane_combo = QtGui.QComboBox()
        self.model_plane_combo.setVisible(False)
        plane_layout.addRow("Model Plane:", self.model_plane_combo)

        # Offset (only for preset planes)
        self.offset_spin = QtGui.QDoubleSpinBox()
        self.offset_spin.setRange(-10000, 10000)
        self.offset_spin.setValue(0.0)
        self.offset_spin.setSuffix(" mm")
        self.offset_spin.setToolTip("Offset from origin along plane normal")
        plane_layout.addRow("Offset:", self.offset_spin)

        plane_group.setLayout(plane_layout)
        layout.addWidget(plane_group)

        # Populate model planes
        self._populate_model_planes()

        # Hole configuration
        hole_group = QtGui.QGroupBox("Magnet Holes")
        hole_layout = QtGui.QFormLayout()

        self.diameter_spin = QtGui.QDoubleSpinBox()
        self.diameter_spin.setRange(0.1, 100)
        self.diameter_spin.setValue(3.0)
        self.diameter_spin.setSuffix(" mm")
        self.diameter_spin.setDecimals(2)
        self.diameter_spin.setToolTip(
            "Diameter of magnet holes (e.g., magnet diameter)"
        )
        hole_layout.addRow("Diameter:", self.diameter_spin)

        self.depth_spin = QtGui.QDoubleSpinBox()
        self.depth_spin.setRange(0.1, 100)
        self.depth_spin.setValue(3.0)
        self.depth_spin.setSuffix(" mm")
        self.depth_spin.setDecimals(2)
        self.depth_spin.setToolTip("Depth of holes from cut surface")
        hole_layout.addRow("Depth:", self.depth_spin)

        self.hole_count_spin = QtGui.QSpinBox()
        self.hole_count_spin.setRange(1, 100)
        self.hole_count_spin.setValue(6)
        self.hole_count_spin.setToolTip(
            "Total number of magnet holes to create, evenly spaced along the cut edge"
        )
        hole_layout.addRow("Number of Holes:", self.hole_count_spin)

        self.clearance_preferred_spin = QtGui.QDoubleSpinBox()
        self.clearance_preferred_spin.setRange(0.1, 20)
        self.clearance_preferred_spin.setValue(2.0)
        self.clearance_preferred_spin.setSuffix(" mm")
        self.clearance_preferred_spin.setDecimals(1)
        self.clearance_preferred_spin.setToolTip(
            "Preferred distance from hole edge to object surface (used for initial placement)"
        )
        hole_layout.addRow("Edge Clearance (Preferred):", self.clearance_preferred_spin)

        self.clearance_min_spin = QtGui.QDoubleSpinBox()
        self.clearance_min_spin.setRange(0.1, 20)
        self.clearance_min_spin.setValue(0.5)
        self.clearance_min_spin.setSuffix(" mm")
        self.clearance_min_spin.setDecimals(1)
        self.clearance_min_spin.setToolTip(
            "Minimum acceptable distance from hole edge to object surface (used during repositioning)"
        )
        hole_layout.addRow("Edge Clearance (Minimum):", self.clearance_min_spin)

        hole_group.setLayout(hole_layout)
        layout.addWidget(hole_group)

        # Progress and status
        self.progress_bar = QtGui.QProgressBar()
        self.progress_bar.setVisible(False)
        layout.addWidget(self.progress_bar)

        self.status_label = QtGui.QLabel("")
        self.status_label.setWordWrap(True)
        layout.addWidget(self.status_label)

        # Buttons
        button_box = QtGui.QDialogButtonBox()
        self.execute_btn = button_box.addButton(
            "Execute Cut", QtGui.QDialogButtonBox.AcceptRole
        )
        cancel_btn = button_box.addButton(QtGui.QDialogButtonBox.Cancel)

        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)

        layout.addWidget(button_box)
        self.setLayout(layout)

    def _populate_cuttable_objects(self):
        """Populate the object combo box with objects that can be cut."""
        if not App.ActiveDocument:
            return

        self.obj_combo.clear()
        self.cuttable_objects = {}  # Map combo box text to actual objects

        # First, collect all BaseFeature objects that belong to Bodies
        # These should not be offered as cuttable objects
        base_features = set()
        for obj in App.ActiveDocument.Objects:
            if hasattr(obj, "TypeId") and obj.TypeId == "PartDesign::Body":
                if hasattr(obj, "BaseFeature") and obj.BaseFeature:
                    base_features.add(obj.BaseFeature.Name)

        for obj in App.ActiveDocument.Objects:
            # Only include objects with shapes that aren't planes
            if hasattr(obj, "Shape") and hasattr(obj.Shape, "Volume"):
                # Skip planes and other non-solid objects
                if hasattr(obj, "TypeId") and "Plane" in obj.TypeId:
                    continue
                # Skip objects with zero or near-zero volume
                if obj.Shape.Volume < 0.001:
                    continue
                # Skip hidden objects (intermediate Part::Feature objects)
                if hasattr(obj, "ViewObject") and obj.ViewObject:
                    if not obj.ViewObject.Visibility:
                        continue
                # Skip objects that are BaseFeatures of Bodies
                if obj.Name in base_features:
                    continue
                # Skip objects with _Base suffix (macro-created intermediates)
                if obj.Name.endswith("_Base") or obj.Label.endswith("_Base"):
                    continue

                # Get object type for display
                obj_type = _get_object_type(obj)
                if obj_type:
                    label = f"{obj.Label} ({obj_type})"
                else:
                    label = obj.Label

                self.obj_combo.addItem(label)
                self.cuttable_objects[label] = obj

        if self.obj_combo.count() == 0:
            self.obj_combo.addItem("No cuttable objects available")

    def set_selected_object(self, obj_name: str):
        """Set the default selected object in the combo box."""
        for i in range(self.obj_combo.count()):
            if obj_name in self.obj_combo.itemText(i):
                self.obj_combo.setCurrentIndex(i)
                break

    def get_selected_object(self):
        """Get the currently selected object to cut."""
        current_text = self.obj_combo.currentText()
        if current_text == "No cuttable objects available":
            return None
        return self.cuttable_objects.get(current_text)

    def set_default_plane(self, plane_label: str):
        """Set a specific plane as the default selection.

        Args:
            plane_label: The label text to match in the model plane combo
        """
        # Switch to Model Plane mode
        self.plane_type_combo.setCurrentIndex(1)  # "Model Plane"
        self._on_plane_type_changed(1)

        # Find and select the matching plane
        for i in range(self.model_plane_combo.count()):
            if plane_label in self.model_plane_combo.itemText(i):
                self.model_plane_combo.setCurrentIndex(i)
                break

    def _populate_model_planes(self):
        """Populate the model plane combo box with available planes and faces."""
        if not App.ActiveDocument:
            return

        self.model_plane_combo.clear()
        self.plane_objects = {}  # Map combo box text to actual objects

        # Find all datum planes in the document
        for obj in App.ActiveDocument.Objects:
            # Check for PartDesign datum planes
            if hasattr(obj, "TypeId"):
                if "PartDesign::Plane" in obj.TypeId or "Part::Plane" in obj.TypeId:
                    label = f"Plane: {obj.Label}"
                    self.model_plane_combo.addItem(label)
                    self.plane_objects[label] = ("plane", obj)

            # Also allow using faces of objects as planes
            if hasattr(obj, "Shape") and hasattr(obj.Shape, "Faces"):
                if len(obj.Shape.Faces) > 0:
                    for idx, face in enumerate(obj.Shape.Faces):
                        # Only add planar faces
                        if isinstance(face.Surface, Part.Plane):
                            label = f"Face: {obj.Label} (Face{idx + 1})"
                            self.model_plane_combo.addItem(label)
                            self.plane_objects[label] = ("face", obj, idx)

        if self.model_plane_combo.count() == 0:
            self.model_plane_combo.addItem("No planes available")

    def _on_plane_type_changed(self, index):
        """Handle plane type selection change."""
        is_model_plane = index == 1

        # Show/hide appropriate controls
        self.plane_combo.setVisible(not is_model_plane)
        self.model_plane_combo.setVisible(is_model_plane)
        self.offset_spin.setEnabled(not is_model_plane)

    def get_selected_model_plane(self) -> tuple | None:
        """Get the selected model plane object.

        Returns:
            Tuple of (type, object, [face_index]) or None
        """
        if self.plane_type_combo.currentText() != "Model Plane":
            return None

        current_text = self.model_plane_combo.currentText()
        if current_text == "No planes available":
            return None

        return self.plane_objects.get(current_text)

    def get_parameters(self) -> dict:
        """Get all parameters from the dialog."""
        params = {
            "plane_type": self.plane_type_combo.currentText(),
            "plane": self.plane_combo.currentText(),
            "offset": self.offset_spin.value(),
            "diameter": self.diameter_spin.value(),
            "depth": self.depth_spin.value(),
            "hole_count": self.hole_count_spin.value(),
            "clearance_preferred": self.clearance_preferred_spin.value(),
            "clearance_min": self.clearance_min_spin.value(),
            "model_plane": self.get_selected_model_plane(),
        }
        return params

    def set_status(self, message: str, is_error: bool = False):
        """Update status message."""
        if is_error:
            self.status_label.setStyleSheet("color: red;")
        else:
            self.status_label.setStyleSheet("color: green;")
        self.status_label.setText(message)

    def set_progress(self, value: int, maximum: int = 100):
        """Update progress bar."""
        if not self.progress_bar.isVisible():
            self.progress_bar.setVisible(True)
        self.progress_bar.setMaximum(maximum)
        self.progress_bar.setValue(value)
        QtGui.QApplication.processEvents()


class SmartCutter:
    """Handles cutting objects and placing magnet holes with collision detection."""

    def __init__(self, obj: Part.Feature, params: dict):
        """Initialize the cutter.

        Args:
            obj: FreeCAD object to cut
            params: Dictionary of parameters from dialog
        """
        self.obj = obj
        self.params = params
        self.shape = obj.Shape
        # Detect existing holes from previous cuts
        self.existing_holes = self._detect_existing_holes()

    def _detect_existing_holes(self) -> list[dict]:
        """Detect existing magnet holes in the source object.

        Finds cylindrical faces that appear to be magnet holes based on
        their radius matching common magnet sizes (or the current diameter).

        Returns:
            List of dicts with hole info: center, axis, radius, depth
        """
        holes = []
        target_radius = self.params.get("diameter", 3.0) / 2

        # Group cylindrical faces by their axis and approximate center
        # (a single hole creates one cylindrical face)
        for face in self.shape.Faces:
            if face.Surface.__class__.__name__ != "Cylinder":
                continue

            radius = face.Surface.Radius

            # Only consider holes with radius close to target (within 50% tolerance)
            # or small holes that are likely magnets (radius < 10mm)
            if radius > 10 and abs(radius - target_radius) > target_radius * 0.5:
                continue

            # Get the cylinder axis and a point on the axis
            axis = face.Surface.Axis
            center = face.Surface.Center

            # Get the face's bounding box to estimate hole depth
            bbox = face.BoundBox
            # The "depth" along the axis
            depth = max(bbox.XLength, bbox.YLength, bbox.ZLength)

            holes.append(
                {
                    "center": App.Vector(center),
                    "axis": App.Vector(axis),
                    "radius": radius,
                    "depth": depth,
                    "face_center": face.CenterOfMass,
                }
            )

        App.Console.PrintMessage(
            f"Detected {len(holes)} existing holes in source object\n"
        )
        return holes

    def _project_existing_holes_to_cut_plane(
        self, cut_normal: App.Vector, cut_point: App.Vector
    ) -> list[App.Vector]:
        """Project existing hole positions onto the new cut plane.

        For each existing hole, finds where its axis intersects the cut plane.
        Only includes holes whose axis is roughly perpendicular to the cut plane
        (i.e., holes that would connect through the cut).

        Args:
            cut_normal: Normal vector of the cut plane
            cut_point: A point on the cut plane

        Returns:
            List of positions on the cut plane where existing holes should appear
        """
        projected_positions = []

        for hole in self.existing_holes:
            hole_axis = hole["axis"]
            hole_center = hole["center"]

            # Check if hole axis is roughly parallel to cut normal
            # (meaning the hole goes "through" perpendicular to the cut)
            dot = abs(hole_axis.dot(cut_normal))
            if dot < 0.7:  # Not aligned enough
                continue

            # Project the hole center onto the cut plane by finding where the
            # hole axis line intersects the plane. Uses parametric line-plane
            # intersection formula.
            denominator = hole_axis.dot(cut_normal)
            if abs(denominator) < 0.001:
                continue  # Parallel to plane, no intersection

            t = (cut_point - hole_center).dot(cut_normal) / denominator
            intersection = hole_center + hole_axis * t

            projected_positions.append(intersection)

        App.Console.PrintMessage(
            f"Projected {len(projected_positions)} existing holes to cut plane\n"
        )
        return projected_positions

    def get_cut_plane_normal_and_point(self) -> tuple[App.Vector, App.Vector]:
        """Get plane normal vector and point based on selected plane.

        Returns:
            Tuple of (normal_vector, point_on_plane)
        """
        plane_type = self.params.get("plane_type", "Preset Plane")

        # Handle model planes
        if plane_type == "Model Plane":
            model_plane = self.params.get("model_plane")
            if not model_plane:
                raise HolePlacementError("No model plane selected")

            return self._extract_plane_from_model(model_plane)

        # Handle preset planes
        plane = self.params["plane"]
        offset = self.params["offset"]

        if plane == "XY":
            normal = App.Vector(0, 0, 1)
            point = App.Vector(0, 0, offset)
        elif plane == "XZ":
            normal = App.Vector(0, 1, 0)
            point = App.Vector(0, offset, 0)
        elif plane == "YZ":
            normal = App.Vector(1, 0, 0)
            point = App.Vector(offset, 0, 0)
        else:
            # Default to XY
            normal = App.Vector(0, 0, 1)
            point = App.Vector(0, 0, offset)

        return normal, point

    def _extract_plane_from_model(
        self, model_plane: tuple
    ) -> tuple[App.Vector, App.Vector]:
        """Extract normal and point from a FreeCAD plane object or face.

        Args:
            model_plane: Tuple of (type, object, [face_index])

        Returns:
            Tuple of (normal_vector, point_on_plane)
        """
        plane_type = model_plane[0]

        if plane_type == "plane":
            # Datum plane object
            plane_obj = model_plane[1]

            # Get the placement of the plane
            placement = plane_obj.Placement
            normal = placement.Rotation.multVec(App.Vector(0, 0, 1))
            point = placement.Base

            return normal, point

        elif plane_type == "face":
            # Face of an object
            obj = model_plane[1]
            face_idx = model_plane[2]
            face = obj.Shape.Faces[face_idx]

            # Get normal at the center of the face
            u_mid = (face.ParameterRange[0] + face.ParameterRange[1]) / 2
            v_mid = (face.ParameterRange[2] + face.ParameterRange[3]) / 2
            normal = face.normalAt(u_mid, v_mid)
            point = face.CenterOfMass

            return normal, point
        else:
            raise HolePlacementError(f"Unknown plane type: {plane_type}")

    def cut_object(self) -> tuple[Part.Shape, Part.Shape]:
        """Cut the object along the specified plane.

        Works with arbitrary plane orientations by creating a large half-space
        (box) that is properly rotated to align with the cutting plane.

        Returns:
            Tuple of (bottom_part, top_part) where:
                - bottom_part is the portion in the negative normal direction
                - top_part is the portion in the positive normal direction
        """
        normal, point = self.get_cut_plane_normal_and_point()

        # Create a large cutting box
        bbox = self.shape.BoundBox
        size = max(bbox.XLength, bbox.YLength, bbox.ZLength) * 3

        # Create a box centered in XY at origin, extending from Z=0 to Z=size
        # This box will represent the half-space "above" the cutting plane
        half = size / 2
        box = Part.makeBox(size, size, size, App.Vector(-half, -half, 0))

        # Rotate the box so its bottom face (originally Z=0) aligns with the plane
        # We need a rotation that transforms the Z-axis to the plane normal
        z_axis = App.Vector(0, 0, 1)
        rotation = App.Rotation(z_axis, normal)

        # Apply the rotation using a transformation matrix
        box = box.transformed(App.Matrix(rotation.toMatrix()))

        # Translate the box so the rotated Z=0 plane passes through the cut point
        box.translate(point)

        # Perform cuts
        # "bottom" = original minus the half-space above the plane
        # "top" = original intersected with the half-space above the plane
        try:
            bottom_part = self.shape.cut(box)
            top_part = self.shape.common(box)
            return bottom_part, top_part
        except Exception as e:
            raise HolePlacementError(f"Failed to cut object: {e!s}") from e

    def get_cut_face_center(
        self, part: Part.Shape, normal: App.Vector
    ) -> App.Vector | None:
        """Find the center of the cut face on a part.

        Args:
            part: The part shape
            normal: Normal vector of the cut plane

        Returns:
            Center point of cut face or None if not found
        """
        # Get the cut plane point to filter candidates
        _, cut_point = self.get_cut_plane_normal_and_point()

        best_face = None
        best_dist = float("inf")

        for face in part.Faces:
            # Check if face is roughly parallel to cut plane
            face_normal = face.normalAt(0, 0)
            dot = abs(face_normal.dot(normal))
            if dot > 0.99:  # Nearly parallel
                # Check how close this face is to the cut plane
                face_center = face.CenterOfMass
                # Project face center onto plane normal and measure distance to cut point
                dist_along_normal = abs((face_center - cut_point).dot(normal))
                if dist_along_normal < best_dist:
                    best_dist = dist_along_normal
                    best_face = face

        if best_face is not None:
            return best_face.CenterOfMass
        return None

    def is_hole_safe(
        self,
        center: App.Vector,
        direction: App.Vector,
        part: Part.Shape,
        clearance: float | None = None,
    ) -> bool:
        """Check if a hole at this position would penetrate the outer surface.

        The safety check ensures that a hole with the specified clearance
        around it won't break through the outer walls of the part.

        Args:
            center: Center point of hole on cut surface
            direction: Direction of hole (into the part)
            part: Part shape to check against
            clearance: Optional clearance to use for safety check. If not provided,
                      uses the minimum clearance from params.

        Returns:
            True if hole is safe, False if it would penetrate
        """
        diameter = self.params["diameter"]
        depth = self.params["depth"]
        if clearance is None:
            clearance = self.params["clearance_min"]

        # Normalize direction
        dir_normalized = App.Vector(direction).normalize()

        # Create a test cylinder that represents the hole + clearance margin
        # Start the test cylinder slightly INSIDE the part to avoid the cut face
        # boundary issue (the test should check if the hole fits within the
        # solid material, not including the cut face surface itself)
        radius_check = (diameter / 2) + clearance
        start_offset = 0.5  # Start slightly inside the part

        # Position the test cylinder to start inside the part
        start_pos = center + (dir_normalized * start_offset)
        test_length = depth - start_offset  # Reduce length accordingly

        # Only do the check if we have enough depth
        if test_length <= 0:
            return True  # Hole is very shallow, assume safe

        # Create test cylinder
        test_cylinder = Part.makeCylinder(
            radius_check, test_length, start_pos, dir_normalized
        )

        # Check if cylinder is fully contained within the part
        try:
            intersection = part.common(test_cylinder)

            # If intersection volume is significantly less than cylinder volume,
            # the hole would break through the outer surface
            cylinder_vol = test_cylinder.Volume
            intersection_vol = intersection.Volume

            # Allow 5% tolerance for floating point errors and minor surface irregularities
            if intersection_vol < cylinder_vol * 0.95:
                return False

            return True
        except Exception:
            # If boolean operation fails, consider it unsafe
            return False

    def generate_hole_positions(
        self, cut_face_center: App.Vector, cut_face: Part.Face
    ) -> tuple[list[App.Vector], Part.Wire, float, list[float]]:
        """Generate hole positions evenly distributed along the perimeter of the cut face.

        Instead of a grid pattern, this distributes N holes evenly along the
        outer edge(s) of the cut face. This works better for magnet holes
        that need to align when parts are joined.

        Uses the preferred clearance for initial hole placement. If holes fail
        safety checks, the repositioning logic will try clearances down to minimum.

        Args:
            cut_face_center: Center of the cut face
            cut_face: The cut face geometry

        Returns:
            Tuple of:
                - List of hole center positions
                - The outer wire (perimeter) of the cut face
                - Total perimeter length
                - List of original perimeter parameters for each position
        """
        hole_count = self.params["hole_count"]
        # Use preferred clearance for initial placement
        clearance = self.params["clearance_preferred"]
        diameter = self.params["diameter"]

        # Get the outer wire (perimeter) of the cut face
        # For faces with holes (like ring shapes), there may be multiple wires
        # The outer wire is typically the longest one
        wires = cut_face.Wires
        if not wires:
            App.Console.PrintError("Cut face has no wires (edges)\n")
            return [], None, 0, []

        # Find the outer wire (longest perimeter)
        outer_wire = max(wires, key=lambda w: w.Length)
        perimeter_length = outer_wire.Length

        App.Console.PrintMessage(
            f"Cut face perimeter length: {perimeter_length:.2f} mm\n"
        )

        # Calculate the inset distance from the edge
        # Holes should be placed inward from the edge by clearance + radius
        inset = clearance + (diameter / 2)

        # Get the normal vector for the cut plane
        normal, _ = self.get_cut_plane_normal_and_point()
        normal = App.Vector(normal).normalize()

        # Distribute holes evenly along the perimeter
        # Calculate spacing between holes
        if hole_count < 1:
            return [], outer_wire, perimeter_length, []

        # For N holes distributed around a closed perimeter, the spacing between
        # adjacent holes (including wrap-around from last to first) equals
        # perimeter_length divided by hole_count. This ensures equal distance
        # between all holes, including first and last.
        spacing = perimeter_length / hole_count

        App.Console.PrintMessage(
            f"Placing {hole_count} holes with {spacing:.2f} mm spacing\n"
        )

        positions = []
        original_params = []

        for i in range(hole_count):
            # Parameter along the wire (0 to perimeter_length)
            # Place holes evenly spaced with a small offset to avoid starting
            # exactly at position 0 (which is often a corner/vertex where
            # determining the inward direction can be problematic)
            # Offset by half the spacing so holes are centered in their segments
            param = (i * spacing) + (spacing / 2)
            # Wrap around if we exceed perimeter length
            if param >= perimeter_length:
                param = param - perimeter_length

            # Get the point on the edge at this parameter
            # We need to walk along the wire's edges
            edge_point = self._get_point_at_length(outer_wire, param)
            if edge_point is None:
                continue

            # Now we need to move this point INWARD from the edge
            # toward the center of the face (or the solid material for ring shapes)
            inset_point = self._get_inset_point(edge_point, cut_face, normal, inset)

            if inset_point:
                positions.append(inset_point)
                original_params.append(param)

        App.Console.PrintMessage(f"Generated {len(positions)} hole positions\n")
        return positions, outer_wire, perimeter_length, original_params

    def _get_point_at_length(self, wire: Part.Wire, length: float) -> App.Vector | None:
        """Get a point on the wire at a specific length along it.

        Args:
            wire: The wire to traverse
            length: Distance along the wire

        Returns:
            Point at that distance, or None if not found
        """
        cumulative_length = 0.0

        for edge in wire.Edges:
            edge_length = edge.Length

            if cumulative_length + edge_length >= length:
                # The point is on this edge
                # Calculate how far along this edge
                remaining = length - cumulative_length
                # Parameter is normalized (0 to 1) along the edge
                param = remaining / edge_length if edge_length > 0 else 0

                # Get the point using edge parameter space
                # Edge parameters go from edge.FirstParameter to edge.LastParameter
                first_param = edge.FirstParameter
                last_param = edge.LastParameter
                edge_param = first_param + param * (last_param - first_param)

                try:
                    point = edge.valueAt(edge_param)
                    return App.Vector(point)
                except Exception:
                    return None

            cumulative_length += edge_length

        # If we get here, length exceeded wire length (shouldn't happen with valid input)
        return None

    def _get_inset_point(
        self,
        edge_point: App.Vector,
        cut_face: Part.Face,
        normal: App.Vector,
        inset: float,
    ) -> App.Vector | None:
        """Get a point that is inset from the edge toward the face interior.

        For simple shapes, this moves toward the face center.
        For ring shapes, it moves toward the solid material.

        Args:
            edge_point: Point on the edge
            cut_face: The cut face
            normal: Normal vector of the cut plane
            inset: Distance to move inward

        Returns:
            Inset point on the face, or None if invalid
        """
        # Get the center of mass of the face
        face_center = cut_face.CenterOfMass

        # Direction from edge point toward center (projected onto the plane)
        to_center = face_center - edge_point

        # Remove any component along the normal (project onto plane)
        to_center = to_center - normal * (to_center.dot(normal))

        if to_center.Length < 0.001:
            # Edge point is at center, can't determine direction
            return None

        # Normalize the direction
        to_center_normalized = App.Vector(to_center).normalize()

        # Move inward by the inset distance
        inset_point = edge_point + (to_center_normalized * inset)

        # Verify the inset point is actually on the face
        # (important for ring shapes where center of mass may be in the hole)
        try:
            dist_info = cut_face.distToShape(Part.Vertex(inset_point))
            dist = dist_info[0]

            if dist < 0.5:
                # Point is on or very close to the face
                closest_on_face = dist_info[1][0][0]
                return App.Vector(closest_on_face)
            else:
                # Point is not on the face - for ring shapes, the inset point
                # may land in the hole. Return the closest point on the face
                # from the already-computed dist_info.
                return App.Vector(dist_info[1][0][0])
        except Exception as e:
            App.Console.PrintWarning(f"Failed to validate inset point: {e}\n")
            return None

    def _find_alternative_position(
        self,
        original_pos: App.Vector,
        bottom_part: Part.Shape,
        top_part: Part.Shape,
        bottom_cut_face: Part.Face,
        top_cut_face: Part.Face,
        outer_wire: Part.Wire,
        perimeter_length: float,
        original_param: float,
        normal: App.Vector,
    ) -> App.Vector | None:
        """Try to find an alternative hole position when the original fails safety check.

        This method checks BOTH parts to ensure the repositioned hole works for both
        the bottom and top pieces.

        Strategy:
        1. Try reducing clearance from preferred toward minimum (at same position)
        2. Try moving further inward from the edge (increased inset with preferred clearance)
        3. Try positions along the perimeter in both directions

        Args:
            original_pos: The original position that failed
            bottom_part: The bottom part shape
            top_part: The top part shape
            bottom_cut_face: The bottom cut face
            top_cut_face: The top cut face
            outer_wire: The outer wire (perimeter)
            perimeter_length: Total perimeter length
            original_param: Original parameter along the perimeter
            normal: Normal vector of the cut plane

        Returns:
            Alternative position if found, None otherwise
        """
        diameter = self.params["diameter"]
        clearance_preferred = self.params["clearance_preferred"]
        clearance_min = self.params["clearance_min"]

        # Build a list of clearances to try, from preferred down to minimum
        # We try: preferred, 75% toward min, 50% toward min, 25% toward min, min
        clearance_steps = []
        if clearance_preferred > clearance_min:
            step_size = (clearance_preferred - clearance_min) / 4
            for i in range(5):  # 0=preferred, 4=min
                clearance_steps.append(clearance_preferred - (i * step_size))
        else:
            clearance_steps = [clearance_min]

        def is_safe_for_both(pos: App.Vector, check_clearance: float) -> bool:
            """Check if position is safe for both bottom and top parts at given clearance."""
            # Check bottom part (holes go in -normal direction)
            if not self.is_hole_safe(pos, -normal, bottom_part, check_clearance):
                return False
            # Check top part (holes go in +normal direction)
            if not self.is_hole_safe(pos, normal, top_part, check_clearance):
                return False
            return True

        # Strategy 1: Try reducing clearance at the SAME position
        # This keeps holes in their ideal locations when possible
        if original_param is not None:
            edge_point = self._get_point_at_length(outer_wire, original_param)
            if edge_point:
                for try_clearance in clearance_steps[
                    1:
                ]:  # Skip preferred, we already tried it
                    inset = try_clearance + (diameter / 2)
                    inset_pos = self._get_inset_point(
                        edge_point, bottom_cut_face, normal, inset
                    )
                    if inset_pos and is_safe_for_both(inset_pos, try_clearance):
                        return inset_pos

        # Strategy 2: Try moving further inward from the edge (multiplied inset)
        # Using each clearance level
        if original_param is not None:
            edge_point = self._get_point_at_length(outer_wire, original_param)
            if edge_point:
                for try_clearance in clearance_steps:
                    base_inset = try_clearance + (diameter / 2)
                    for multiplier in [1.5, 2.0, 2.5, 3.0]:
                        increased_inset = base_inset * multiplier
                        inset_pos = self._get_inset_point(
                            edge_point, bottom_cut_face, normal, increased_inset
                        )
                        if inset_pos and is_safe_for_both(inset_pos, try_clearance):
                            return inset_pos

        # Strategy 3: Try positions along the perimeter in both directions
        # Search up to 20% of segment length in each direction
        if original_param is not None and perimeter_length > 0:
            segment_length = perimeter_length / self.params["hole_count"]

            # Try offsets in both directions: +5%, +10%, +15%, +20%, -5%, -10%, etc.
            offsets = []
            for pct in [0.05, 0.10, 0.15, 0.20]:
                offsets.append(segment_length * pct)
                offsets.append(-segment_length * pct)

            for offset in offsets:
                new_param = (original_param + offset) % perimeter_length
                edge_point = self._get_point_at_length(outer_wire, new_param)
                if edge_point is None:
                    continue

                # Try different clearance levels and inset distances
                for try_clearance in clearance_steps:
                    base_inset = try_clearance + (diameter / 2)
                    for multiplier in [1.0, 1.5, 2.0, 2.5]:
                        inset = base_inset * multiplier
                        inset_pos = self._get_inset_point(
                            edge_point, bottom_cut_face, normal, inset
                        )
                        if inset_pos and is_safe_for_both(inset_pos, try_clearance):
                            return inset_pos

        return None

    def _check_hole_overlap(
        self, positions: list[App.Vector], new_pos: App.Vector
    ) -> bool:
        """Check if a new hole position would overlap with existing holes.

        Holes must have at least one hole diameter of space between them.

        Args:
            positions: List of already accepted hole positions
            new_pos: The new position to check

        Returns:
            True if position is valid (no overlap), False if it would overlap
        """
        diameter = self.params["diameter"]
        # Minimum distance = 2 * diameter (one hole width between holes)
        min_distance = diameter * 2

        for existing_pos in positions:
            # Calculate distance in XY plane (on the cut face)
            dist = (new_pos - existing_pos).Length
            if dist < min_distance:
                return False

        return True

    def execute(self, progress_callback=None):
        """Execute the complete cutting and hole placement operation.

        This method:
        1. Cuts the object along the specified plane
        2. Creates PartDesign::Body objects for each half
        3. Validates hole positions against BOTH parts (not just one)
        4. Checks for minimum spacing between holes (2x diameter)
        5. Creates PartDesign::Hole features in both parts

        Each major step is wrapped in a FreeCAD transaction, allowing
        users to undo individual steps via Edit → Undo in the GUI.

        Args:
            progress_callback: Optional callback function for progress updates

        Returns:
            Tuple of (bottom_body, top_body) - PartDesign::Body objects
        """
        doc = App.ActiveDocument

        # Count cylindrical faces (holes) in a shape
        def count_cylindrical_faces(shape):
            count = 0
            for face in shape.Faces:
                if face.Surface.__class__.__name__ == "Cylinder":
                    count += 1
            return count

        # Log detailed information about the source object
        App.Console.PrintMessage(
            f"\n{'=' * 60}\n"
            f"Starting cut operation on: {self.obj.Label} ({self.obj.Name})\n"
            f"Object type: {self.obj.TypeId}\n"
            f"Shape faces: {len(self.shape.Faces)}, volume: {self.shape.Volume:.2f}mm³\n"
            f"Cylindrical faces (existing holes): {count_cylindrical_faces(self.shape)}\n"
        )

        # If cutting a PartDesign::Body, log its structure
        if hasattr(self.obj, "Group"):
            App.Console.PrintMessage(
                f"Body Group: {[f'{o.Name} ({o.TypeId})' for o in self.obj.Group]}\n"
            )
        if hasattr(self.obj, "BaseFeature") and self.obj.BaseFeature:
            App.Console.PrintMessage(f"Body BaseFeature: {self.obj.BaseFeature.Name}\n")
        if hasattr(self.obj, "Tip") and self.obj.Tip:
            App.Console.PrintMessage(
                f"Body Tip: {self.obj.Tip.Name} ({self.obj.Tip.TypeId})\n"
            )
        App.Console.PrintMessage(f"{'=' * 60}\n\n")

        if progress_callback:
            progress_callback(10, "Cutting object...")

        # Cut the object (returns Part.Shape objects)
        # Note: This is a pure geometry operation, no document changes yet
        bottom_shape, top_shape = self.cut_object()

        # Log cut results
        App.Console.PrintMessage(
            f"Cut results:\n"
            f"  Bottom shape: {len(bottom_shape.Faces)} faces, "
            f"volume={bottom_shape.Volume:.2f}mm³, "
            f"cylindrical faces={count_cylindrical_faces(bottom_shape)}\n"
            f"  Top shape: {len(top_shape.Faces)} faces, "
            f"volume={top_shape.Volume:.2f}mm³, "
            f"cylindrical faces={count_cylindrical_faces(top_shape)}\n"
        )

        if progress_callback:
            progress_callback(25, "Finding cut faces...")

        # Get cut plane normal
        normal, _ = self.get_cut_plane_normal_and_point()

        # Find cut faces (on shapes, before converting to bodies)
        bottom_face_center = self.get_cut_face_center(bottom_shape, -normal)
        top_face_center = self.get_cut_face_center(top_shape, normal)

        if not bottom_face_center or not top_face_center:
            raise HolePlacementError("Could not find cut faces")

        if progress_callback:
            progress_callback(35, "Generating hole positions...")

        # Find the actual cut face from bottom part
        bottom_cut_face = None
        for face in bottom_shape.Faces:
            if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1:
                bottom_cut_face = face
                break

        if not bottom_cut_face:
            raise HolePlacementError("Could not find bottom cut face")

        # Find the actual cut face from top part (for repositioning on top part)
        top_cut_face = None
        for face in top_shape.Faces:
            if face.CenterOfMass.distanceToPoint(top_face_center) < 0.1:
                top_cut_face = face
                break

        if not top_cut_face:
            raise HolePlacementError("Could not find top cut face")

        # Get the cut plane point for projecting existing holes
        _, cut_point = self.get_cut_plane_normal_and_point()

        # Project existing holes from previous cuts onto the new cut plane
        # These holes MUST be preserved to maintain magnet alignment
        existing_hole_positions = self._project_existing_holes_to_cut_plane(
            normal, cut_point
        )

        # Generate NEW hole positions for this cut
        new_positions, outer_wire, perimeter_length, original_params = (
            self.generate_hole_positions(bottom_face_center, bottom_cut_face)
        )

        App.Console.PrintMessage(
            f"Hole positions: {len(existing_hole_positions)} existing + "
            f"{len(new_positions)} new\n"
        )

        # Combine existing and new positions
        # Existing holes are mandatory - they maintain magnet alignment from previous cuts
        # New holes are added for this cut's magnet connections
        initial_positions = existing_hole_positions + new_positions

        if not initial_positions:
            raise HolePlacementError("No valid hole positions found")

        if progress_callback:
            progress_callback(45, "Validating hole positions on both parts...")

        # Validate each position against BOTH parts and check for overlap
        # This ensures holes are placed identically in both parts
        validated_positions = []
        holes_repositioned = 0
        holes_skipped = 0
        num_existing = len(existing_hole_positions)

        # Use preferred clearance for initial validation
        clearance_preferred = self.params["clearance_preferred"]

        for idx, pos in enumerate(initial_positions):
            is_existing_hole = idx < num_existing

            # For new holes, get the original parameter for repositioning
            if not is_existing_hole:
                new_idx = idx - num_existing
                original_param = (
                    original_params[new_idx] if new_idx < len(original_params) else None
                )
            else:
                original_param = None

            # Check if position is safe for both parts using preferred clearance
            bottom_safe = self.is_hole_safe(
                pos, -normal, bottom_shape, clearance_preferred
            )
            top_safe = self.is_hole_safe(pos, normal, top_shape, clearance_preferred)

            final_pos = None

            if bottom_safe and top_safe:
                # Position is good for both parts
                final_pos = pos
            elif is_existing_hole:
                # Existing holes should be preserved IF they pass minimum clearance
                # If they fail even minimum clearance, they would break the wall
                clearance_min = self.params["clearance_min"]
                bottom_safe_min = self.is_hole_safe(
                    pos, -normal, bottom_shape, clearance_min
                )
                top_safe_min = self.is_hole_safe(pos, normal, top_shape, clearance_min)
                if bottom_safe_min and top_safe_min:
                    final_pos = pos
                    App.Console.PrintWarning(
                        f"Existing hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) "
                        f"uses minimum clearance\n"
                    )
                else:
                    # Existing hole would break through wall - skip it
                    # This happens when cutting through a face that had holes,
                    # and some holes are now outside the new cut face boundary
                    App.Console.PrintWarning(
                        f"Skipping existing hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) "
                        f"- would break through outer wall (outside cut face boundary)\n"
                    )
                    holes_skipped += 1
                    continue
            else:
                # Try to find an alternative position that works for both
                alternative = self._find_alternative_position(
                    pos,
                    bottom_shape,
                    top_shape,
                    bottom_cut_face,
                    top_cut_face,
                    outer_wire,
                    perimeter_length,
                    original_param,
                    normal,
                )
                if alternative:
                    final_pos = alternative
                    holes_repositioned += 1
                    App.Console.PrintMessage(
                        f"Repositioned new hole {idx + 1} from ({pos.x:.2f}, {pos.y:.2f}) "
                        f"to ({alternative.x:.2f}, {alternative.y:.2f})\n"
                    )

            if final_pos:
                # Check for overlap with already validated positions
                # But existing holes always get added (they're mandatory)
                if is_existing_hole or self._check_hole_overlap(
                    validated_positions, final_pos
                ):
                    validated_positions.append(final_pos)
                else:
                    holes_skipped += 1
                    App.Console.PrintWarning(
                        f"Skipping new hole at ({final_pos.x:.2f}, {final_pos.y:.2f}) "
                        f"- too close to another hole (need {self.params['diameter'] * 2:.1f}mm spacing)\n"
                    )
            else:
                holes_skipped += 1
                App.Console.PrintWarning(
                    f"Skipping hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) "
                    f"- could not find safe position for both parts\n"
                )

        if not validated_positions:
            raise HolePlacementError("No valid hole positions found after validation")

        App.Console.PrintMessage(
            f"Validated {len(validated_positions)} hole positions "
            f"({holes_repositioned} repositioned, {holes_skipped} skipped)\n"
        )

        if progress_callback:
            progress_callback(55, "Creating PartDesign bodies...")

        # Transaction 1: Create bottom body from cut shape
        doc.openTransaction("Create Bottom Body")
        try:
            bottom_body = self._create_body_from_shape(
                bottom_shape, f"{self.obj.Label}_Bottom"
            )
            doc.commitTransaction()
        except Exception:
            doc.abortTransaction()
            raise

        # Transaction 2: Create top body from cut shape
        doc.openTransaction("Create Top Body")
        try:
            top_body = self._create_body_from_shape(top_shape, f"{self.obj.Label}_Top")
            doc.commitTransaction()
        except Exception:
            doc.abortTransaction()
            raise

        if progress_callback:
            progress_callback(65, "Finding cut faces on bodies...")

        # Find cut face names on the new bodies
        # Note: Face normals point OUTWARD from each solid piece:
        # - Bottom piece's cut face normal points toward top (same as plane normal)
        # - Top piece's cut face normal points toward bottom (opposite to plane normal)
        bottom_face_name = self._find_cut_face_name(bottom_body, normal)
        top_face_name = self._find_cut_face_name(top_body, -normal)

        App.Console.PrintMessage(
            f"Cut faces: bottom={bottom_face_name}, top={top_face_name}\n"
        )

        if progress_callback:
            progress_callback(75, "Creating hole sketch for bottom part...")

        # Transaction 3: Create hole sketch for bottom body
        doc.openTransaction("Create Bottom Hole Sketch")
        try:
            bottom_sketch = self._create_hole_sketch(
                bottom_body, bottom_face_name, validated_positions
            )
            doc.commitTransaction()
        except Exception:
            doc.abortTransaction()
            raise

        if progress_callback:
            progress_callback(82, "Creating hole sketch for top part...")

        # Transaction 4: Create hole sketch for top body
        doc.openTransaction("Create Top Hole Sketch")
        try:
            top_sketch = self._create_hole_sketch(
                top_body, top_face_name, validated_positions
            )
            doc.commitTransaction()
        except Exception:
            doc.abortTransaction()
            raise

        if progress_callback:
            progress_callback(
                88, f"Creating {len(validated_positions)} holes in bottom part..."
            )

        # Transaction 5: Create hole feature in bottom body
        doc.openTransaction("Create Bottom Magnet Holes")
        try:
            self._create_hole_feature(
                bottom_body,
                bottom_sketch,
                self.params["diameter"],
                self.params["depth"],
            )
            doc.commitTransaction()
        except Exception:
            doc.abortTransaction()
            raise

        if progress_callback:
            progress_callback(95, "Creating holes in top part...")

        # Transaction 6: Create hole feature in top body
        doc.openTransaction("Create Top Magnet Holes")
        try:
            self._create_hole_feature(
                top_body, top_sketch, self.params["diameter"], self.params["depth"]
            )
            doc.commitTransaction()
        except Exception:
            doc.abortTransaction()
            raise

        if progress_callback:
            progress_callback(96, "Separating cut parts...")

        # Transaction 7: Move top body away from bottom body (100mm separation)
        doc.openTransaction("Separate Cut Parts")
        try:
            # Move the top body along the cut plane normal direction
            # This creates a 100mm gap between the cut faces
            separation_distance = 100.0  # mm
            offset_vector = App.Vector(
                normal.x * separation_distance,
                normal.y * separation_distance,
                normal.z * separation_distance,
            )

            # Get current placement and add offset
            current_placement = top_body.Placement
            new_base = current_placement.Base + offset_vector
            top_body.Placement = App.Placement(
                new_base, current_placement.Rotation, App.Vector(0, 0, 0)
            )

            doc.commitTransaction()
            App.Console.PrintMessage(
                f"Separated parts by {separation_distance}mm along cut normal\n"
            )
        except Exception as e:
            doc.abortTransaction()
            App.Console.PrintWarning(f"Could not separate parts: {e}\n")

        if progress_callback:
            progress_callback(98, "Hiding original objects...")

        # Transaction 8: Hide original object and cutting plane
        doc.openTransaction("Hide Original Objects")
        try:
            # Hide the original object
            if hasattr(self.obj, "ViewObject") and self.obj.ViewObject:
                self.obj.ViewObject.Visibility = False

            # Hide the cutting plane if it's a model plane
            if self.params.get("plane_type") == "Model Plane":
                model_plane = self.params.get("model_plane")
                if model_plane and len(model_plane) >= 2:
                    plane_obj = model_plane[1]
                    if hasattr(plane_obj, "ViewObject") and plane_obj.ViewObject:
                        plane_obj.ViewObject.Visibility = False

            doc.commitTransaction()
        except Exception:
            # Don't fail the whole operation if hiding fails
            doc.abortTransaction()
            App.Console.PrintWarning(
                "Could not hide original objects (GUI may not be available)\n"
            )

        if progress_callback:
            progress_callback(100, "Complete!")

        return bottom_body, top_body

    def _create_body_from_shape(self, shape: Part.Shape, name: str):
        """Create a PartDesign::Body containing the given shape.

        Uses Body.BaseFeature property to wrap an existing shape, allowing
        PartDesign features (like Hole) to be added to imported/boolean geometry.

        Args:
            shape: The Part.Shape to wrap
            name: Name for the new body

        Returns:
            The created PartDesign::Body object
        """
        doc = App.ActiveDocument

        # Log diagnostic information about the input shape
        App.Console.PrintMessage(
            f"Creating body '{name}' from shape with {len(shape.Faces)} faces, "
            f"volume={shape.Volume:.2f}mm³\n"
        )

        # First create a Part::Feature to hold the shape
        # This is needed because BaseFeature references a document object, not a raw shape
        base_feature_name = f"{name}_Base"
        feature = doc.addObject("Part::Feature", base_feature_name)
        feature.Shape = shape

        # Create PartDesign::Body
        body = doc.addObject("PartDesign::Body", name)

        # Set the BaseFeature property to reference the Part::Feature
        # Note: This is a property, not created via newObject()
        body.BaseFeature = feature

        # Hide the intermediate Part::Feature (it's now part of the body)
        if hasattr(feature, "ViewObject") and feature.ViewObject:
            feature.ViewObject.Visibility = False

        doc.recompute()

        # Log the created body structure
        App.Console.PrintMessage(
            f"Created body '{name}': BaseFeature={body.BaseFeature.Name if body.BaseFeature else 'None'}, "
            f"Group={[obj.Name for obj in body.Group]}\n"
        )

        return body

    def _get_internal_base_feature(self, body):
        """Get the internal PartDesign::FeatureBase from a body.

        When you set body.BaseFeature = some_part_feature, FreeCAD creates
        an internal PartDesign::FeatureBase in body.Group. This internal
        feature is what sketches should be attached to, not the external
        Part::Feature.

        Args:
            body: The PartDesign::Body to search

        Returns:
            The PartDesign::FeatureBase object

        Raises:
            HolePlacementError: If no FeatureBase is found
        """
        for obj in body.Group:
            if obj.TypeId == "PartDesign::FeatureBase":
                return obj

        raise HolePlacementError(
            f"Body {body.Label} has no PartDesign::FeatureBase in Group"
        )

    def _find_cut_face_name(self, body, normal: App.Vector) -> str:
        """Find the name of the cut face on a PartDesign::Body's internal FeatureBase.

        For planar cuts on flat objects, this searches for faces with matching normals.
        For curved objects (like vases), it finds the largest face whose center
        lies closest to the cut plane.

        Note: We use the internal PartDesign::FeatureBase (from body.Group) because:
        1. It's the stable internal representation of the imported shape
        2. Sketches must be attached to PartDesign features, not Part::Feature
        3. body.Tip might be a failed Hole feature from a previous run
        4. body.BaseFeature is the external Part::Feature, not suitable for sketch attachment

        Args:
            body: The PartDesign::Body to search
            normal: Expected normal direction of the cut face

        Returns:
            Face name string like "Face1", "Face2", etc.

        Raises:
            HolePlacementError: If no matching face is found
        """
        # Get the internal PartDesign::FeatureBase - this is what sketches attach to
        base_feature = self._get_internal_base_feature(body)

        shape = base_feature.Shape

        # Normalize the target normal
        target_normal = App.Vector(normal).normalize()

        # Get the cut plane point
        _, cut_point = self.get_cut_plane_normal_and_point()

        # Find candidates: faces with matching normal AND close to cut plane
        # This handles both:
        # 1. Fresh cuts (single matching face)
        # 2. Re-cuts of already-cut objects (multiple planar faces, need the NEW one)
        candidates = []

        for i, face in enumerate(shape.Faces):
            try:
                face_normal = face.normalAt(0.5, 0.5)
                dot = face_normal.dot(target_normal)

                # Skip faces with wrong normal direction
                if dot < 0.3:
                    continue

                # Calculate distance from face center to the cut plane
                face_center = face.CenterOfMass
                dist_to_plane = abs((face_center - cut_point).dot(target_normal))

                candidates.append(
                    {
                        "index": i,
                        "dist": dist_to_plane,
                        "dot": dot,
                        "area": face.Area,
                        "surface_type": face.Surface.__class__.__name__,
                    }
                )
            except Exception:
                continue

        if not candidates:
            raise HolePlacementError(
                f"Could not find any face on body {body.Label} with normal "
                f"matching the cut plane direction"
            )

        # Strategy 1: Look for planar faces with exact normal match AND close to cut plane
        # This is the ideal case - a flat face created by the current cut
        planar_matches = [
            c
            for c in candidates
            if c["surface_type"] == "Plane" and c["dot"] > 0.99 and c["dist"] < 5.0
        ]

        # Log all planar matches for debugging
        if planar_matches:
            App.Console.PrintMessage(
                f"Found {len(planar_matches)} planar face candidates close to cut plane:\n"
            )
            for m in planar_matches[:5]:  # Show up to 5
                App.Console.PrintMessage(
                    f"  Face{m['index'] + 1}: dist={m['dist']:.2f}mm, "
                    f"dot={m['dot']:.3f}, area={m['area']:.1f}mm²\n"
                )

        if planar_matches:
            # Sort by distance to plane (closest first), then by area (largest first)
            planar_matches.sort(key=lambda x: (x["dist"], -x["area"]))
            best = planar_matches[0]
            App.Console.PrintMessage(
                f"Selected cut face (planar, exact match): Face{best['index'] + 1} "
                f"(dist={best['dist']:.2f}mm, dot={best['dot']:.3f}, "
                f"area={best['area']:.1f}mm²)\n"
            )
            return f"Face{best['index'] + 1}"

        # Strategy 2: Look for any face with good normal match close to the cut plane
        # This handles curved objects where cut face might not be perfectly planar
        close_matches = [c for c in candidates if c["dist"] < 5.0 and c["dot"] > 0.5]

        if close_matches:
            # Sort by dot product (best match first), then distance, then area
            close_matches.sort(key=lambda x: (-x["dot"], x["dist"], -x["area"]))
            best = close_matches[0]
            App.Console.PrintMessage(
                f"Found cut face (close to plane): Face{best['index'] + 1} "
                f"(dist={best['dist']:.2f}mm, dot={best['dot']:.3f}, "
                f"area={best['area']:.1f}mm², type={best['surface_type']})\n"
            )
            return f"Face{best['index'] + 1}"

        # Strategy 3: Fallback - best dot product match regardless of distance
        # This might pick a face from a previous cut, but it's better than failing
        candidates.sort(key=lambda x: (-x["dot"], x["dist"], -x["area"]))
        best = candidates[0]
        App.Console.PrintWarning(
            f"Warning: No face close to cut plane found. Using best normal match: "
            f"Face{best['index'] + 1} (dist={best['dist']:.2f}mm, dot={best['dot']:.3f})\n"
        )
        return f"Face{best['index'] + 1}"

    def _world_to_sketch_coords(self, world_pos: App.Vector, sketch) -> App.Vector:
        """Transform world coordinates to sketch-local 2D coordinates.

        Sketches use a local 2D coordinate system. This transforms a 3D world
        position to the corresponding 2D position in the sketch plane.

        Args:
            world_pos: Position in world (document) coordinates
            sketch: The Sketcher::SketchObject with placement info

        Returns:
            Position in sketch-local coordinates (Z should be ~0)
        """
        # Get sketch placement (transforms sketch coords to world)
        placement = sketch.Placement

        # Inverse transform: world to sketch local
        inv_placement = placement.inverse()
        local_pos = inv_placement.multVec(world_pos)

        # Return 2D (Z should be ~0 for points on the sketch plane)
        return App.Vector(local_pos.x, local_pos.y, 0)

    def _create_hole_sketch(
        self, body, cut_face_name: str, positions: list[App.Vector]
    ):
        """Create a sketch with points at hole center positions.

        The sketch is attached to the cut face and contains points that
        will be used as hole centers for the PartDesign::Hole feature.

        Args:
            body: The PartDesign::Body to add the sketch to
            cut_face_name: Name of the face to attach the sketch to
            positions: List of hole center positions in world coordinates

        Returns:
            The created Sketcher::SketchObject
        """
        # Create sketch attached to cut face on the internal PartDesign::FeatureBase
        # Sketches must reference a PartDesign feature (not Part::Feature), not the body
        sketch = body.newObject("Sketcher::SketchObject", "HoleCenters")

        # Get the internal PartDesign::FeatureBase (not body.BaseFeature which is Part::Feature)
        # This is the stable internal representation that sketches can attach to
        base_feature = self._get_internal_base_feature(body)

        # AttachmentSupport format: list of (feature, [face_names])
        # Note: In FreeCAD 1.0+, use AttachmentSupport instead of deprecated Support
        sketch.AttachmentSupport = [(base_feature, cut_face_name)]
        sketch.MapMode = "FlatFace"

        # Recompute to establish sketch placement
        App.ActiveDocument.recompute()

        # Add point at each hole position
        # Points need to be in sketch-local coordinates
        for pos in positions:
            local_pos = self._world_to_sketch_coords(pos, sketch)
            sketch.addGeometry(
                Part.Point(App.Vector(local_pos.x, local_pos.y, 0)),
                False,  # Not construction geometry
            )

        App.ActiveDocument.recompute()
        return sketch

    def _create_hole_feature(self, body, sketch, diameter: float, depth: float):
        """Create a PartDesign::Hole feature from a sketch with point geometry.

        The Hole feature creates cylindrical holes at each point in the sketch.
        These holes are parametric and can be edited after creation.

        Args:
            body: The PartDesign::Body containing the sketch
            sketch: Sketch with points defining hole centers
            diameter: Hole diameter in mm
            depth: Hole depth in mm

        Returns:
            The created PartDesign::Hole feature
        """
        hole = body.newObject("PartDesign::Hole", "MagnetHoles")
        hole.Profile = sketch
        hole.Diameter = diameter
        hole.Depth = depth
        hole.DepthType = "Dimension"  # Fixed depth (not "ThroughAll")
        hole.Threaded = False
        hole.HoleCutType = "None"  # Simple hole (no countersink/counterbore)

        App.ActiveDocument.recompute()

        # Validate the hole feature was created successfully
        if hasattr(hole, "isValid") and callable(hole.isValid):
            is_valid = hole.isValid()
        else:
            # Check if the shape has non-zero volume as a proxy for validity
            is_valid = hasattr(hole, "Shape") and hole.Shape.Volume > 0

        App.Console.PrintMessage(
            f"Created hole feature '{hole.Name}' on body '{body.Label}': "
            f"valid={is_valid}, "
            f"profile={sketch.Name}, "
            f"diameter={diameter}mm, depth={depth}mm\n"
        )

        # Log body shape info after hole creation
        if hasattr(body, "Shape"):
            App.Console.PrintMessage(
                f"Body '{body.Label}' after holes: "
                f"{len(body.Shape.Faces)} faces, "
                f"volume={body.Shape.Volume:.2f}mm³\n"
            )

        return hole

    def _create_holes_boolean(
        self, part: Part.Shape, direction: App.Vector, positions: list[App.Vector]
    ) -> Part.Shape:
        """Create holes using boolean operations (fallback method).

        This is the original hole creation method using Part.makeCylinder
        and boolean cut operations. Kept as fallback if PartDesign::Hole
        fails for certain geometry types.

        Args:
            part: Part shape to add holes to
            direction: Direction of holes (pointing INTO the part)
            positions: List of validated hole positions

        Returns:
            Part with holes cut
        """
        diameter = self.params["diameter"]
        depth = self.params["depth"]

        # Normalize direction vector
        dir_normalized = App.Vector(direction).normalize()

        result = part
        holes_created = 0

        for pos in positions:
            # Create hole - start slightly OUTSIDE the part (offset back from cut face)
            # so the boolean cut operation works correctly
            offset = 0.1
            start_pos = pos - (dir_normalized * offset)
            hole_length = depth + offset

            try:
                hole = Part.makeCylinder(
                    diameter / 2, hole_length, start_pos, dir_normalized
                )
                result = result.cut(hole)
                holes_created += 1
            except Exception as e:
                App.Console.PrintWarning(
                    f"Failed to create hole at ({pos.x:.2f}, {pos.y:.2f}): {e!s}\n"
                )

        App.Console.PrintMessage(f"Created {holes_created} holes\n")
        return result


def _is_plane_object(obj) -> bool:
    """Check if an object is a datum plane or has a planar face."""
    if hasattr(obj, "TypeId"):
        if "Plane" in obj.TypeId:
            return True
    return False


def _get_object_type(obj) -> str:
    """Get a human-readable type description for an object."""
    if hasattr(obj, "TypeId"):
        type_id = obj.TypeId
        if "Part::" in type_id:
            return type_id.replace("Part::", "")
        if "PartDesign::" in type_id:
            return type_id.replace("PartDesign::", "")
        if "Mesh::" in type_id:
            return "Mesh"
        return type_id
    if hasattr(obj, "Shape"):
        return "Shape"
    return ""


def main():
    """Main macro entry point."""
    # Check for active document
    if not App.ActiveDocument:
        QtGui.QMessageBox.warning(
            None, "No Document", "Please open or create a document first."
        )
        return

    # Get current selection to use as defaults
    selection = Gui.Selection.getSelection()

    # Build a map of BaseFeature -> Body for resolving intermediate objects
    base_to_body = {}
    for obj in App.ActiveDocument.Objects:
        if hasattr(obj, "TypeId") and obj.TypeId == "PartDesign::Body":
            if hasattr(obj, "BaseFeature") and obj.BaseFeature:
                base_to_body[obj.BaseFeature.Name] = obj

    # Determine default object and plane from selection
    default_obj = None
    selected_plane = None

    for sel_obj in selection:
        if _is_plane_object(sel_obj):
            selected_plane = sel_obj
        elif hasattr(sel_obj, "Shape") and not default_obj:
            # Check if this is actually a BaseFeature of a Body
            # If so, use the Body instead
            if sel_obj.Name in base_to_body:
                default_obj = base_to_body[sel_obj.Name]
            # Skip hidden objects and _Base suffixed objects
            elif sel_obj.Name.endswith("_Base") or sel_obj.Label.endswith("_Base"):
                # Try to find the corresponding body
                body_name = sel_obj.Name.replace("_Base", "")
                body = App.ActiveDocument.getObject(body_name)
                if body and hasattr(body, "Shape"):
                    default_obj = body
            elif hasattr(sel_obj, "ViewObject") and sel_obj.ViewObject:
                if sel_obj.ViewObject.Visibility:
                    default_obj = sel_obj
            else:
                default_obj = sel_obj

    # Show dialog - user can select/change object in the dialog
    dialog = CutObjectForMagnetsDialog()

    # Set the default object (from selection) if we found one
    if default_obj:
        dialog.set_selected_object(default_obj.Label)

    # If a plane was selected, set it as the default cut plane
    if selected_plane:
        dialog.set_default_plane(selected_plane.Label)

    if dialog.exec_() != QtGui.QDialog.Accepted:
        return

    # Get the object selected in the dialog (user may have changed it)
    obj = dialog.get_selected_object()
    if obj is None:
        QtGui.QMessageBox.warning(
            None, "No Object Selected", "Please select an object to cut."
        )
        return

    params = dialog.get_parameters()

    # Validate model plane selection
    if params["plane_type"] == "Model Plane":
        if not params["model_plane"]:
            QtGui.QMessageBox.warning(
                None,
                "No Plane Selected",
                "Please select a model plane or switch to preset plane mode.",
            )
            return
        if dialog.model_plane_combo.currentText() == "No planes available":
            QtGui.QMessageBox.warning(
                None,
                "No Planes Available",
                "No datum planes or planar faces found in the document.\n\n"
                "Create a datum plane (Part Design → Create datum plane) or\n"
                "switch to preset plane mode.",
            )
            return

    try:
        # Create cutter with the object selected in the dialog
        cutter = SmartCutter(obj, params)

        # Execute with progress updates
        def progress_update(value, message=""):
            dialog.set_status(message)
            dialog.set_progress(value)

        bottom_body, top_body = cutter.execute(progress_update)

        # Bodies are already created in the document by execute()
        # Original object is hidden in execute() Transaction 8

        App.ActiveDocument.recompute()

        dialog.set_status(
            f"Success! Created {bottom_body.Label} and {top_body.Label}\n"
            f"Original object hidden. Holes are parametric - edit them in the feature tree."
        )

        App.Console.PrintMessage(
            f"Cut complete: {bottom_body.Label}, {top_body.Label}\n"
            f"Holes created as PartDesign::Hole features (editable in feature tree)\n"
        )

    except HolePlacementError as e:
        dialog.set_status(f"Error: {e!s}", is_error=True)
        App.Console.PrintError(f"Cut failed: {e!s}\n")
    except Exception as e:
        dialog.set_status(f"Unexpected error: {e!s}", is_error=True)
        App.Console.PrintError(f"Unexpected error: {e!s}\n")
        import traceback

        traceback.print_exc()


if __name__ == "__main__":
    main()
