# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# SPDX-FileCopyrightText: 2025 sliptonic sliptonic@freecad.org
# SPDX-FileNotice: Part of the FreeCAD project.

################################################################################
#                                                                              #
#   FreeCAD is free software: you can redistribute it and/or modify            #
#   it under the terms of the GNU Lesser General Public License as             #
#   published by the Free Software Foundation, either version 2.1              #
#   of the License, or (at your option) any later version.                     #
#                                                                              #
#   FreeCAD is distributed in the hope that it will be useful,                 #
#   but WITHOUT ANY WARRANTY; without even the implied warranty                #
#   of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.                    #
#   See the GNU Lesser General Public License for more details.                #
#                                                                              #
#   You should have received a copy of the GNU Lesser General Public           #
#   License along with FreeCAD. If not, see https://www.gnu.org/licenses       #
#                                                                              #
################################################################################


__title__ = "CAM Mill Facing Operation"
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "Class and implementation of Mill Facing operation."
__contributors__ = ""

import FreeCAD
from PySide import QtCore
import Path
import Path.Op.Base as PathOp

import Path.Base.Generator.spiral_facing as spiral_facing
import Path.Base.Generator.facing_common as facing_common
import Path.Base.Generator.zigzag_facing as zigzag_facing
import Path.Base.Generator.directional_facing as directional_facing
import Path.Base.Generator.bidirectional_facing as bidirectional_facing
import Path.Base.Generator.linking as linking
import PathScripts.PathUtils as PathUtils
import Path.Base.FeedRate as FeedRate

# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader

Part = LazyLoader("Part", globals(), "Part")
Arcs = LazyLoader("draftgeoutils.arcs", globals(), "draftgeoutils.arcs")
if FreeCAD.GuiUp:
    FreeCADGui = LazyLoader("FreeCADGui", globals(), "FreeCADGui")


translate = FreeCAD.Qt.translate


if False:
    Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
    Path.Log.trackModule(Path.Log.thisModule())
else:
    Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())


class ObjectMillFacing(PathOp.ObjectOp):
    """Proxy object for Mill Facing operation."""

    def opFeatures(self, obj):
        """opFeatures(obj) ... return all standard features"""
        return (
            PathOp.FeatureTool
            | PathOp.FeatureDepths
            | PathOp.FeatureHeights
            | PathOp.FeatureStepDown
            | PathOp.FeatureCoolant
            | PathOp.FeatureLinking
        )

    def initOperation(self, obj):
        """initOperation(obj) ... Initialize the operation by
        managing property creation and property editor status."""
        self.propertiesReady = False

        self.initOpProperties(obj)  # Initialize operation-specific properties

    def initOpProperties(self, obj, warn=False):
        """initOpProperties(obj) ... create operation specific properties"""
        Path.Log.track()
        self.addNewProps = list()

        for prtyp, nm, grp, tt in self.opPropertyDefinitions():
            if not hasattr(obj, nm):
                obj.addProperty(prtyp, nm, grp, tt)
                self.addNewProps.append(nm)

        # Set enumeration lists for enumeration properties
        if len(self.addNewProps) > 0:
            ENUMS = self.propertyEnumerations()
            for n in ENUMS:
                if n[0] in self.addNewProps:
                    setattr(obj, n[0], n[1])
            if warn:
                newPropMsg = translate("CAM_MIllFacing", "New property added to")
                newPropMsg += ' "{}": {}'.format(obj.Label, self.addNewProps) + ". "
                newPropMsg += translate("CAM_MillFacing", "Check default value(s).")
                FreeCAD.Console.PrintWarning(newPropMsg + "\n")

        self.propertiesReady = True

    def onChanged(self, obj, prop):
        """onChanged(obj, prop) ... Called when a property changes"""
        if prop == "StepOver" and hasattr(obj, "StepOver"):
            # Validate StepOver is between 0 and 100 percent
            if obj.StepOver < 0:
                obj.StepOver = 0
            elif obj.StepOver > 100:
                obj.StepOver = 100

        if prop == "Active" and obj.ViewObject:
            obj.ViewObject.signalChangeIcon()

    def opPropertyDefinitions(self):
        """opPropertyDefinitions(obj) ... Store operation specific properties"""

        return [
            (
                "App::PropertyEnumeration",
                "CutMode",
                "Facing",
                QtCore.QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Set the cut mode for the operation.",
                ),
            ),
            (
                "App::PropertyEnumeration",
                "ClearingPattern",
                "Facing",
                QtCore.QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Set the clearing pattern for the operation.",
                ),
            ),
            (
                "App::PropertyAngle",
                "Angle",
                "Facing",
                QtCore.QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Set the angle for the operation.",
                ),
            ),
            (
                "App::PropertyPercent",
                "StepOver",
                "Facing",
                QtCore.QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Set the stepover percentage of tool diameter.",
                ),
            ),
            (
                "App::PropertyDistance",
                "AxialStockToLeave",
                "Facing",
                QtCore.QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Set the stock to leave for the operation.",
                ),
            ),
            (
                "App::PropertyDistance",
                "PassExtension",
                "Facing",
                QtCore.QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Distance to extend cuts beyond polygon boundary for tool disengagement.",
                ),
            ),
            (
                "App::PropertyDistance",
                "StockExtension",
                "Facing",
                QtCore.QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Extends the boundary in both direction.",
                ),
            ),
            (
                "App::PropertyBool",
                "Reverse",
                "Facing",
                QtCore.QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Reverse the cutting direction for the selected pattern.",
                ),
            ),
        ]

    @classmethod
    def propertyEnumerations(self, dataType="data"):
        """propertyEnumerations(dataType="data")... return property enumeration lists of specified dataType.
        Args:
            dataType = 'data', 'raw', 'translated'
        Notes:
        'data' is list of internal string literals used in code
        'raw' is list of (translated_text, data_string) tuples
        'translated' is list of translated string literals
        """
        Path.Log.track()

        enums = {
            "CutMode": [
                (translate("CAM_MillFacing", "Climb"), "Climb"),
                (translate("CAM_MillFacing", "Conventional"), "Conventional"),
            ],
            "ClearingPattern": [
                (translate("CAM_MillFacing", "ZigZag"), "ZigZag"),
                (translate("CAM_MillFacing", "Bidirectional"), "Bidirectional"),
                (translate("CAM_MillFacing", "Directional"), "Directional"),
                (translate("CAM_MillFacing", "Spiral"), "Spiral"),
            ],
        }

        if dataType == "raw":
            return enums

        data = list()
        idx = 0 if dataType == "translated" else 1

        Path.Log.debug(enums)

        for k, v in enumerate(enums):
            data.append((v, [tup[idx] for tup in enums[v]]))
        Path.Log.debug(data)

        return data

    def opPropertyDefaults(self, obj, job):
        """opPropertyDefaults(obj, job) ... returns a dictionary of default values
        for the operation's properties."""
        defaults = {
            "CutMode": "Climb",
            "ClearingPattern": "ZigZag",
            "Angle": 0,
            "StepOver": 25,
            "AxialStockToLeave": 0.0,
        }

        return defaults

    def opSetDefaultValues(self, obj, job):
        """opSetDefaultValues(obj, job) ... set default values for operation-specific properties"""
        Path.Log.track()

        # Set default values directly like other operations do
        obj.CutMode = "Climb"
        obj.ClearingPattern = "ZigZag"
        obj.Angle = 0.0
        obj.StepOver = 25  # 25% as percentage
        obj.AxialStockToLeave = 0.0
        obj.PassExtension = (
            3.0  # Default to 3mm, will be adjusted based on tool diameter in opExecute
        )
        obj.Reverse = False

    def opExecute(self, obj):
        """opExecute(obj) ... process Mill Facing operation"""
        Path.Log.track()
        Path.Log.debug("MillFacing.opExecute() starting")

        # Get tool information
        tool = obj.ToolController.Tool
        Path.Log.debug(f"Tool: {tool.Label if tool else 'None'}")
        tool_diameter = tool.Diameter.Value
        Path.Log.debug(f"Tool diameter: {tool_diameter}")

        # Prepare linking parameters
        solids = [base.Shape for base in self.job.Model.Group]
        linkingArgs = {
            "start_position": None,
            "target_position": None,
            "local_clearance": obj.SafeHeight.Value,
            "global_clearance": obj.ClearanceHeight.Value,
            "solids": solids,
            "tool_shape": None,
            "tool_diameter": None,
            "safety_margin": obj.LinkingSafetyMargin.Value,
        }
        if obj.LinkingMode == "Safest":
            linkingArgs["tool_shape"] = obj.ToolController.Tool.BitBody.Shape
        elif obj.LinkingMode == "Compromise":
            linkingArgs["tool_diameter"] = tool_diameter

        # Determine the step-downs
        finish_step = 0.0  # No finish step for facing
        Path.Log.debug(
            f"Depth parameters: clearance={obj.ClearanceHeight.Value}, safe={obj.SafeHeight.Value}, start={obj.StartDepth.Value}, step={obj.StepDown.Value}, final={obj.FinalDepth.Value + obj.AxialStockToLeave.Value}"
        )
        depthparams = PathUtils.depth_params(
            clearance_height=obj.ClearanceHeight.Value,
            safe_height=obj.SafeHeight.Value,
            start_depth=obj.StartDepth.Value,
            step_down=obj.StepDown.Value,
            z_finish_step=finish_step,
            final_depth=obj.FinalDepth.Value + obj.AxialStockToLeave.Value,
            user_depths=None,
        )
        Path.Log.debug(f"Depth params object: {depthparams}")

        # Always use the stock object top face for facing operations
        job = PathUtils.findParentJob(obj)
        Path.Log.debug(f"Job: {job.Label if job else 'None'}")
        if job and job.Stock:
            Path.Log.debug(f"Stock: {job.Stock.Label}")
            stock_faces = job.Stock.Shape.Faces
            Path.Log.debug(f"Number of stock faces: {len(stock_faces)}")

            # Find faces with normal pointing toward Z+ (upward)
            z_up_faces = []
            for face in stock_faces:
                # Get face normal at center
                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)
                Path.Log.debug(f"Face normal: {normal}, Z component: {normal.z}")

                # Check if normal points upward (Z+ direction) with some tolerance
                if normal.z > 0.9:  # Allow for slight deviation from perfect vertical
                    z_up_faces.append(face)
                    Path.Log.debug(f"Found upward-facing face at Z={face.BoundBox.ZMax}")

            if not z_up_faces:
                Path.Log.error("No upward-facing faces found in stock")
                raise ValueError("No upward-facing faces found in stock")

            # From the upward-facing faces, select the highest one
            top_face = max(z_up_faces, key=lambda f: f.BoundBox.ZMax)
            Path.Log.debug(f"Selected top face ZMax: {top_face.BoundBox.ZMax}")
            boundary_wire = top_face.OuterWire
            Path.Log.debug(f"Wire vertices: {len(boundary_wire.Vertexes)}")
        else:
            Path.Log.error("No stock found for facing operation")
            raise ValueError("No stock found for facing operation")

        boundary_wire = boundary_wire.makeOffset2D(
            obj.StockExtension.Value, 2
        )  # offset with intersection joins

        # Convert boundary to a rectangular polygon aligned to the cut angle.
        # Stock faces may have curved edges (e.g. cylindrical stock) and all
        # facing strategies assume a rectangular boundary.
        cut_angle = getattr(obj.Angle, "Value", obj.Angle)
        boundary_wire = facing_common.get_angled_polygon(boundary_wire, cut_angle)

        # Determine milling direction
        milling_direction = "climb" if obj.CutMode == "Climb" else "conventional"

        # Get operation parameters
        stepover_percent = obj.StepOver
        pass_extension = (
            obj.PassExtension.Value if hasattr(obj, "PassExtension") else tool_diameter * 0.5
        )
        retract_height = obj.SafeHeight.Value

        # Generate the base toolpath for one depth level based on clearing pattern
        try:
            if obj.ClearingPattern == "Spiral":
                # Spiral has different signature - no pass_extension or retract_height
                Path.Log.debug("Generating spiral toolpath")
                base_commands = spiral_facing.spiral(
                    polygon=boundary_wire,
                    tool_diameter=tool_diameter,
                    stepover_percent=stepover_percent,
                    milling_direction=milling_direction,
                    reverse=bool(getattr(obj, "Reverse", False)),
                    angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
                )
            elif obj.ClearingPattern == "ZigZag":
                Path.Log.debug("Generating zigzag toolpath")
                base_commands = zigzag_facing.zigzag(
                    polygon=boundary_wire,
                    tool_diameter=tool_diameter,
                    stepover_percent=stepover_percent,
                    pass_extension=pass_extension,
                    retract_height=retract_height,
                    milling_direction=milling_direction,
                    reverse=bool(getattr(obj, "Reverse", False)),
                    angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
                )
            elif obj.ClearingPattern == "Bidirectional":
                Path.Log.debug("Generating bidirectional toolpath")
                base_commands = bidirectional_facing.bidirectional(
                    polygon=boundary_wire,
                    tool_diameter=tool_diameter,
                    stepover_percent=stepover_percent,
                    pass_extension=pass_extension,
                    milling_direction=milling_direction,
                    reverse=bool(getattr(obj, "Reverse", False)),
                    angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
                )
            elif obj.ClearingPattern == "Directional":
                Path.Log.debug("Generating directional toolpath")
                base_commands = directional_facing.directional(
                    polygon=boundary_wire,
                    tool_diameter=tool_diameter,
                    stepover_percent=stepover_percent,
                    pass_extension=pass_extension,
                    retract_height=retract_height,
                    milling_direction=milling_direction,
                    reverse=bool(getattr(obj, "Reverse", False)),
                    angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
                )
            else:
                Path.Log.error(f"Unknown clearing pattern: {obj.ClearingPattern}")
                raise ValueError(f"Unknown clearing pattern: {obj.ClearingPattern}")

            Path.Log.debug(f"Generated {len(base_commands)} base commands")
            Path.Log.debug(base_commands)

        except Exception as e:
            Path.Log.error(f"Error generating toolpath: {e}")
            raise

        # clear commandlist
        self.commandlist = []

        # Be safe. Add first G0 to clearance height
        targetZ = obj.ClearanceHeight.Value
        self.commandlist.append(Path.Command("G0", {"Z": targetZ}))

        # Process each step-down using iterator protocol and add to commandlist
        depth_count = 0
        try:
            while True:
                depth = depthparams.next()
                depth_count += 1
                Path.Log.debug(f"Processing depth {depth_count}: {depth}")

                if depth_count == 1:
                    # First stepdown preamble:
                    # 1) Rapid to ClearanceHeight (already done at line 401)
                    # 2) Rapid to XY start position at ClearanceHeight
                    # 3) Rapid down to SafeHeight
                    # 4) Rapid down to cutting depth

                    # Find the first XY target from the base commands
                    first_xy = None
                    first_move_idx = None
                    for i, bc in enumerate(base_commands):
                        if "X" in bc.Parameters and "Y" in bc.Parameters:
                            first_xy = (bc.Parameters["X"], bc.Parameters["Y"])
                            first_move_idx = i
                            break

                    if first_xy is not None:
                        # 1) G0 to XY position at current height (ClearanceHeight from line 401)
                        pre1 = {"X": first_xy[0], "Y": first_xy[1]}
                        if not self.commandlist or any(
                            abs(pre1[k] - self.commandlist[-1].Parameters.get(k, pre1[k] + 1))
                            > 1e-9
                            for k in ("X", "Y")
                        ):
                            self.commandlist.append(Path.Command("G0", pre1))

                        # 2) G0 down to SafeHeight
                        pre2 = {"Z": obj.SafeHeight.Value}
                        if (
                            abs(pre2["Z"] - self.commandlist[-1].Parameters.get("Z", pre2["Z"] + 1))
                            > 1e-9
                        ):
                            self.commandlist.append(Path.Command("G0", pre2))

                        # 3) G0 down to cutting depth
                        pre3 = {"Z": depth}
                        if (
                            abs(pre3["Z"] - self.commandlist[-1].Parameters.get("Z", pre3["Z"] + 1))
                            > 1e-9
                        ):
                            self.commandlist.append(Path.Command("G0", pre3))

                    # Now append the base commands, skipping the generator's initial positioning move
                    for i, cmd in enumerate(base_commands):
                        # Skip the first move if it only positions at the start point
                        if i == first_move_idx:
                            # If this first move has only XY(Z) to the start point, skip it because we preambled it
                            pass
                        else:
                            new_params = dict(cmd.Parameters)
                            # Handle Z coordinate based on command type
                            if "Z" in new_params:
                                if cmd.Name == "G0":
                                    # For rapids, distinguish between true retracts and plunges
                                    # True retracts are at/near retract_height and should be preserved
                                    # Plunges are at/near polygon ZMin and should be clamped to depth
                                    if (
                                        abs(new_params["Z"] - retract_height) < 1.0
                                    ):  # Within 1mm of retract height
                                        # Keep as-is (true retract)
                                        pass
                                    else:
                                        # Not a retract - clamp to depth (includes plunges)
                                        new_params["Z"] = depth
                                else:
                                    # For G1 cutting moves, always use depth
                                    new_params["Z"] = depth
                            else:
                                # Missing Z coordinate - set based on command type
                                if cmd.Name == "G1":
                                    # Cutting moves always at depth
                                    new_params["Z"] = depth
                                else:
                                    # Rapids without Z - carry forward last Z
                                    if self.commandlist:
                                        new_params["Z"] = self.commandlist[-1].Parameters.get(
                                            "Z", depth
                                        )

                            # Fill in missing X,Y coordinates from last position
                            if self.commandlist:
                                last = self.commandlist[-1].Parameters
                                if "X" not in cmd.Parameters:
                                    new_params.setdefault("X", last.get("X"))
                                if "Y" not in cmd.Parameters:
                                    new_params.setdefault("Y", last.get("Y"))
                            # Skip zero-length moves (but allow Z-only moves for plunges/retracts)
                            if self.commandlist:
                                last_params = self.commandlist[-1].Parameters
                                # Only skip if X and Y are identical (allow Z-only moves)
                                if all(
                                    abs(new_params[k] - last_params.get(k, new_params[k] + 1))
                                    <= 1e-9
                                    for k in ("X", "Y")
                                ):
                                    # But if Z is different, keep it (it's a plunge or retract)
                                    # Use sentinel values that won't conflict with depth == 0
                                    z_new = new_params.get("Z", float("inf"))
                                    z_last = last_params.get("Z", float("-inf"))
                                    z_changed = abs(z_new - z_last) > 1e-9
                                    if not z_changed:
                                        continue
                            self.commandlist.append(Path.Command(cmd.Name, new_params))
                    Path.Log.debug(
                        f"First stepdown: Added {len(base_commands)} commands for depth {depth}"
                    )
                else:
                    # Subsequent stepdowns - handle linking
                    # Make a copy of base_commands and update Z depths
                    copy_commands = []
                    for cmd in base_commands:
                        new_params = dict(cmd.Parameters)
                        # Handle Z coordinate based on command type (same logic as first stepdown)
                        if "Z" in new_params:
                            if cmd.Name == "G0":
                                # For rapids, distinguish between true retracts and plunges
                                if (
                                    abs(new_params["Z"] - retract_height) < 1.0
                                ):  # Within 1mm of retract height
                                    # Keep as-is (true retract)
                                    pass
                                else:
                                    # Not a retract - clamp to depth (includes plunges)
                                    new_params["Z"] = depth
                            else:
                                # For G1 cutting moves, always use depth
                                new_params["Z"] = depth
                        else:
                            # Missing Z coordinate - set based on command type
                            if cmd.Name == "G1":
                                # Cutting moves always at depth
                                new_params["Z"] = depth
                            # For G0 without Z, we'll let it get filled in later from context
                        copy_commands.append(Path.Command(cmd.Name, new_params))

                    # Get the last position from self.commandlist
                    last_cmd = self.commandlist[-1]
                    last_position = FreeCAD.Vector(
                        last_cmd.Parameters.get("X", 0),
                        last_cmd.Parameters.get("Y", 0),
                        last_cmd.Parameters.get("Z", depth),
                    )

                    # Identify the initial retract+position+plunge bundle (G0s) before the next cut
                    bundle_start = None
                    bundle_end = None
                    target_xy = None
                    for i, cmd in enumerate(copy_commands):
                        if cmd.Name == "G0":
                            bundle_start = i
                            # collect consecutive G0s
                            j = i
                            while j < len(copy_commands) and copy_commands[j].Name == "G0":
                                # capture XY target if present
                                if (
                                    "X" in copy_commands[j].Parameters
                                    and "Y" in copy_commands[j].Parameters
                                ):
                                    target_xy = (
                                        copy_commands[j].Parameters.get("X"),
                                        copy_commands[j].Parameters.get("Y"),
                                    )
                                j += 1
                            bundle_end = j  # exclusive
                            break

                    if bundle_start is not None and target_xy is not None:
                        # Build target position at cutting depth
                        first_position = FreeCAD.Vector(target_xy[0], target_xy[1], depth)

                        # Generate collision-aware linking moves up to safe/clearance and back down
                        linkingArgs["start_position"] = last_position
                        linkingArgs["target_position"] = first_position
                        link_commands = linking.get_linking_moves(**linkingArgs)

                        # Append linking moves, ensuring full XYZ continuity
                        current = last_position
                        for lc in link_commands:
                            params = lc.Parameters
                            X = params["X"]
                            Y = params["Y"]
                            Z = params["Z"]
                            # Skip zero-length moves
                            if not (
                                abs(X - current.x) <= 1e-9
                                and abs(Y - current.y) <= 1e-9
                                and abs(Z - current.z) <= 1e-9
                            ):
                                self.commandlist.append(
                                    Path.Command(lc.Name, {"X": X, "Y": Y, "Z": Z})
                                )
                                current = FreeCAD.Vector(X, Y, Z)

                        # Remove the entire initial G0 bundle (up, XY, down) from the copy
                        del copy_commands[bundle_start:bundle_end]

                    # Append the copy commands, filling missing coords
                    for cc in copy_commands:
                        cp = dict(cc.Parameters)
                        if self.commandlist:
                            last = self.commandlist[-1].Parameters
                            # Only fill in coordinates that are truly missing from the original command
                            if "X" not in cc.Parameters:
                                cp.setdefault("X", last.get("X"))
                            if "Y" not in cc.Parameters:
                                cp.setdefault("Y", last.get("Y"))
                            # Don't carry forward Z - it should already be set correctly in copy_commands
                            if "Z" not in cc.Parameters:
                                # Only set Z if it wasn't in the original command
                                if cc.Name == "G1":
                                    cp["Z"] = depth  # Cutting moves at depth
                                else:
                                    cp.setdefault("Z", last.get("Z"))
                        # Skip zero-length moves
                        if self.commandlist:
                            last = self.commandlist[-1].Parameters
                            # Use sentinel values that won't conflict with depth == 0
                            if all(
                                abs(cp.get(k, float("inf")) - last.get(k, float("-inf"))) <= 1e-9
                                for k in ("X", "Y", "Z")
                            ):
                                continue
                        self.commandlist.append(Path.Command(cc.Name, cp))
                    Path.Log.debug(
                        f"Stepdown {depth_count}: Added linking + {len(copy_commands)} commands for depth {depth}"
                    )

        except StopIteration:
            Path.Log.debug(f"All depths processed. Total depth levels: {depth_count}")

        # Add final G0 to clearance height
        targetZ = obj.ClearanceHeight.Value
        if self.commandlist:
            last = self.commandlist[-1].Parameters
            lastZ = last.get("Z")
            if lastZ is None or abs(targetZ - lastZ) > 1e-9:
                # Prefer Z-only to avoid non-numeric XY issues
                self.commandlist.append(Path.Command("G0", {"Z": targetZ}))

        # Apply feedrates to the entire commandlist, with debug on failure
        try:
            FeedRate.setFeedRate(self.commandlist, obj.ToolController)
        except Exception:
            # Dump last 12 commands for diagnostics
            n = len(self.commandlist)
            start = max(0, n - 12)
            Path.Log.error("FeedRate failure. Dumping last commands:")
            for i in range(start, n):
                c = self.commandlist[i]
                Path.Log.error(f"  #{i}: {c.Name} {c.Parameters}")
            raise

        Path.Log.debug(f"Total commands in commandlist: {len(self.commandlist)}")
        Path.Log.debug("MillFacing.opExecute() completed successfully")
        Path.Log.debug(self.commandlist)


# Eclass


def Create(name, obj=None, parentJob=None):
    """Create(name) ... Creates and returns a Mill Facing operation."""
    if obj is None:
        obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
    obj.Proxy = ObjectMillFacing(obj, name, parentJob)
    return obj


def SetupProperties():
    """SetupProperties() ... Return list of properties required for the operation."""
    setup = []
    setup.append("CutMode")
    setup.append("ClearingPattern")
    setup.append("Angle")
    setup.append("StepOver")
    setup.append("AxialStockToLeave")
    setup.append("PassExtension")
    setup.append("Reverse")
    return setup
