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

# ***************************************************************************
# *   Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com>               *
# *                                                                         *
# *   This program is free software; you can redistribute it and/or modify  *
# *   it under the terms of the GNU Lesser General Public License (LGPL)    *
# *   as published by the Free Software Foundation; either version 2 of     *
# *   the License, or (at your option) any later version.                   *
# *   for detail see the LICENCE text file.                                 *
# *                                                                         *
# *   This program 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 Library General Public License for more details.                  *
# *                                                                         *
# *   You should have received a copy of the GNU Library General Public     *
# *   License along with this program; if not, write to the Free Software   *
# *   Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  *
# *   USA                                                                   *
# *                                                                         *
# ***************************************************************************


import FreeCAD
import Path
import Path.Base.Generator.threadmilling as threadmilling
import Path.Op.Base as PathOp
import Path.Op.CircularHoleBase as PathCircularHoleBase
import math
from PySide.QtCore import QT_TRANSLATE_NOOP

__title__ = "CAM Thread Milling Operation"
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "CAM thread milling operation."

# math.sqrt(3)/2 ... 60deg triangle height
SQRT_3_DIVIDED_BY_2 = 0.8660254037844386

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())

translate = FreeCAD.Qt.translate

# Constants
LeftHand = "LeftHand"
RightHand = "RightHand"
ThreadTypeCustomExternal = "CustomExternal"
ThreadTypeCustomInternal = "CustomInternal"
ThreadTypeImperialExternal2A = "ImperialExternal2A"
ThreadTypeImperialExternal3A = "ImperialExternal3A"
ThreadTypeImperialInternal2B = "ImperialInternal2B"
ThreadTypeImperialInternal3B = "ImperialInternal3B"
ThreadTypeMetricExternal4G6G = "MetricExternal4G6G"
ThreadTypeMetricExternal6G = "MetricExternal6G"
ThreadTypeMetricInternal6H = "MetricInternal6H"
DirectionClimb = "Climb"
DirectionConventional = "Conventional"

ThreadOrientations = [LeftHand, RightHand]

ThreadTypeData = {
    ThreadTypeImperialExternal2A: "imperial-external-2A.csv",
    ThreadTypeImperialExternal3A: "imperial-external-3A.csv",
    ThreadTypeImperialInternal2B: "imperial-internal-2B.csv",
    ThreadTypeImperialInternal3B: "imperial-internal-3B.csv",
    ThreadTypeMetricExternal4G6G: "metric-external-4G6G.csv",
    ThreadTypeMetricExternal6G: "metric-external-6G.csv",
    ThreadTypeMetricInternal6H: "metric-internal-6H.csv",
}

ThreadTypesExternal = [
    ThreadTypeCustomExternal,
    ThreadTypeImperialExternal2A,
    ThreadTypeImperialExternal3A,
    ThreadTypeMetricExternal4G6G,
    ThreadTypeMetricExternal6G,
]
ThreadTypesInternal = [
    ThreadTypeCustomInternal,
    ThreadTypeImperialInternal2B,
    ThreadTypeImperialInternal3B,
    ThreadTypeMetricInternal6H,
]
ThreadTypesImperial = [
    ThreadTypeImperialExternal2A,
    ThreadTypeImperialExternal3A,
    ThreadTypeImperialInternal2B,
    ThreadTypeImperialInternal3B,
]
ThreadTypesMetric = [
    ThreadTypeMetricExternal4G6G,
    ThreadTypeMetricExternal6G,
    ThreadTypeMetricInternal6H,
]
ThreadTypes = ThreadTypesInternal + ThreadTypesExternal
Directions = [DirectionClimb, DirectionConventional]


def _isThreadInternal(obj):
    return obj.ThreadType in ThreadTypesInternal


def threadSetupInternal(obj, zTop, zBottom):
    Path.Log.track()
    if obj.ThreadOrientation == RightHand:
        # Right hand thread, G2, top down -> conventional milling
        if obj.Direction == DirectionConventional:
            return ("G2", zTop, zBottom)
        # For climb milling we need to cut the thread from the bottom up
        # in the opposite direction -> G3
        return ("G3", zBottom, zTop)
    # Left hand thread, G3, top down -> climb milling
    if obj.Direction == DirectionClimb:
        return ("G3", zTop, zBottom)
    # for conventional milling, cut bottom up with G2
    return ("G2", zBottom, zTop)


def threadSetupExternal(obj, zTop, zBottom):
    Path.Log.track()
    if obj.ThreadOrientation == RightHand:
        # right hand thread, G2, top down -> climb milling
        if obj.Direction == DirectionClimb:
            return ("G2", zTop, zBottom)
        # for conventional, mill bottom up the other way around
        return ("G3", zBottom, zTop)
    # left hand thread, G3, top down -> conventional milling
    if obj.Direction == DirectionConventional:
        return ("G3", zTop, zBottom)
    # for climb milling need to go bottom up and the other way
    return ("G2", zBottom, zTop)


def threadSetup(obj):
    """Return (cmd, zbegin, zend) of thread milling operation"""
    Path.Log.track()

    zTop = obj.StartDepth.Value
    zBottom = obj.FinalDepth.Value

    if _isThreadInternal(obj):
        return threadSetupInternal(obj, zTop, zBottom)
    else:
        return threadSetupExternal(obj, zTop, zBottom)


def threadRadii(internal, majorDia, minorDia, toolDia, toolCrest):
    """threadRadii(majorDia, minorDia, toolDia, toolCrest) ... returns the minimum and maximum radius for thread."""
    Path.Log.track(internal, majorDia, minorDia, toolDia, toolCrest)
    if toolCrest is None:
        toolCrest = 0.0
    # As it turns out metric and imperial standard threads follow the same rules.
    # The determining factor is the height of the full 60 degree triangle H.
    # - The minor diameter is 1/4 * H smaller than the pitch diameter.
    # - The major diameter is 3/8 * H bigger than the pitch diameter
    # Since we already have the outer diameter it's simpler to just add 1/8 * H
    # to get the outer tip of the thread.
    H = ((majorDia - minorDia) / 2.0) * 1.6  # (D - d)/2 = 5/8 * H
    if internal:
        # mill inside out
        outerTip = majorDia / 2.0 + H / 8.0
        # Compensate for the crest of the tool
        toolTip = outerTip - toolCrest * SQRT_3_DIVIDED_BY_2
        radii = ((minorDia - toolDia) / 2.0, toolTip - toolDia / 2.0)
    else:
        # mill outside in
        innerTip = minorDia / 2.0 - H / 4.0
        # Compensate for the crest of the tool
        toolTip = innerTip - toolCrest * SQRT_3_DIVIDED_BY_2
        radii = ((majorDia + toolDia) / 2.0, toolTip + toolDia / 2.0)
    Path.Log.track(radii)
    return radii


def threadPasses(count, radii, internal, majorDia, minorDia, toolDia, toolCrest):
    Path.Log.track(count, radii, internal, majorDia, minorDia, toolDia, toolCrest)
    # the logic goes as follows, total area to be removed:
    #   A = H * W  ... where H is the depth and W is half the width of a thread
    #     H = k * sin(30) = k * 1/2  -> k = 2 * H
    #     W = k * cos(30) = k * sqrt(3)/2
    #     -> W = (2 * H) * sqrt(3) / 2 = H * sqrt(3)
    #   A = sqrt(3) * H^2
    # Each pass has to remove the same area
    #   An = A / count = sqrt(3) * H^2 / count
    # Because each successive pass doesn't have to remove the aera of the previous
    # passes the result for the height:
    #   Ai = (i + 1) * An = (i + 1) * sqrt(3) * Hi^2 = sqrt(3) * H^2 / count
    #   Hi = sqrt(H^2 * (i + 1) / count)
    #   Hi = H * sqrt((i + 1) / count)
    minor, major = radii(internal, majorDia, minorDia, toolDia, toolCrest)
    H = float(major - minor)
    Hi = [H * math.sqrt((i + 1) / count) for i in range(count)]

    # For external threads threadRadii returns the radii in reverse order because that's
    # the order in which they have to get milled. As a result H ends up being negative
    # and the math for internal and external threads is identical.
    passes = [minor + h for h in Hi]
    Path.Log.debug(f"threadPasses({minor}, {major}) -> H={H} : {Hi}  --> {passes}")

    return passes


def elevatorRadius(obj, center, internal, tool):
    """elevatorLocation(obj, center, internal, tool) ... return suitable location for the tool elevator"""
    Path.Log.track(center, internal, tool.Diameter)
    if internal:
        dy = float(obj.MinorDiameter - tool.Diameter) / 2 - 1
        if dy < 0:
            if obj.MinorDiameter < tool.Diameter:
                Path.Log.error(
                    "The selected tool is too big (d={}) for milling a thread with minor diameter D={}".format(
                        tool.Diameter, obj.MinorDiameter
                    )
                )
            dy = 0
    else:
        dy = float(obj.MajorDiameter + tool.Diameter) / 2 + 1

    return dy


class ObjectThreadMilling(PathCircularHoleBase.ObjectOp):
    """Proxy object for thread milling operation."""

    @classmethod
    def propertyEnumerations(self, dataType="data"):
        """helixOpPropertyEnumerations(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()

        # Enumeration lists for App::PropertyEnumeration properties
        enums = {
            "ThreadType": [
                (
                    translate("CAM_ThreadMilling", "Custom External"),
                    ThreadTypeCustomExternal,
                ),
                (
                    translate("CAM_ThreadMilling", "Custom Internal"),
                    ThreadTypeCustomInternal,
                ),
                (
                    translate("CAM_ThreadMilling", "Imperial External (2A)"),
                    ThreadTypeImperialExternal2A,
                ),
                (
                    translate("CAM_ThreadMilling", "Imperial External (3A)"),
                    ThreadTypeImperialExternal3A,
                ),
                (
                    translate("CAM_ThreadMilling", "Imperial Internal (2B)"),
                    ThreadTypeImperialInternal2B,
                ),
                (
                    translate("CAM_ThreadMilling", "Imperial Internal (3B)"),
                    ThreadTypeImperialInternal3B,
                ),
                (
                    translate("CAM_ThreadMilling", "Metric External (4G6G)"),
                    ThreadTypeMetricExternal4G6G,
                ),
                (
                    translate("CAM_ThreadMilling", "Metric External (6G)"),
                    ThreadTypeMetricExternal6G,
                ),
                (
                    translate("CAM_ThreadMilling", "Metric Internal (6H)"),
                    ThreadTypeMetricInternal6H,
                ),
            ],
            "ThreadOrientation": [
                (
                    translate("CAM_ThreadMilling", "LeftHand"),
                    LeftHand,
                ),
                (
                    translate("CAM_ThreadMilling", "RightHand"),
                    RightHand,
                ),
            ],
            "Direction": [
                (
                    translate("CAM_ThreadMilling", "Climb"),
                    DirectionClimb,
                ),
                (
                    translate("CAM_ThreadMilling", "Conventional"),
                    DirectionConventional,
                ),
            ],
        }

        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 circularHoleFeatures(self, obj):
        Path.Log.track()
        return PathOp.FeatureBaseGeometry

    def initCircularHoleOperation(self, obj):
        Path.Log.track()
        obj.addProperty(
            "App::PropertyEnumeration",
            "ThreadOrientation",
            "Thread",
            QT_TRANSLATE_NOOP("App::Property", "Set thread orientation"),
        )
        # obj.ThreadOrientation = ThreadOrientations
        obj.addProperty(
            "App::PropertyEnumeration",
            "ThreadType",
            "Thread",
            QT_TRANSLATE_NOOP("App::Property", "Currently only internal"),
        )
        # obj.ThreadType = ThreadTypes
        obj.addProperty(
            "App::PropertyString",
            "ThreadName",
            "Thread",
            QT_TRANSLATE_NOOP("App::Property", "Defines which standard thread was chosen"),
        )
        obj.addProperty(
            "App::PropertyLength",
            "MajorDiameter",
            "Thread",
            QT_TRANSLATE_NOOP("App::Property", "Set thread's major diameter"),
        )
        obj.addProperty(
            "App::PropertyLength",
            "MinorDiameter",
            "Thread",
            QT_TRANSLATE_NOOP("App::Property", "Set thread's minor diameter"),
        )
        obj.addProperty(
            "App::PropertyLength",
            "Pitch",
            "Thread",
            QT_TRANSLATE_NOOP("App::Property", "Set thread's pitch - used for metric threads"),
        )
        obj.addProperty(
            "App::PropertyInteger",
            "TPI",
            "Thread",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Set thread's TPI (turns per inch) - used for imperial threads",
            ),
        )
        obj.addProperty(
            "App::PropertyInteger",
            "ThreadFit",
            "Thread",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Override to control how loose or tight the threads are milled",
            ),
        )
        obj.addProperty(
            "App::PropertyInteger",
            "Passes",
            "Operation",
            QT_TRANSLATE_NOOP("App::Property", "Set how many passes are used to cut the thread"),
        )
        obj.addProperty(
            "App::PropertyEnumeration",
            "Direction",
            "Operation",
            QT_TRANSLATE_NOOP("App::Property", "Direction of thread cutting operation"),
        )
        obj.addProperty(
            "App::PropertyBool",
            "LeadInOut",
            "Operation",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Set to True to get lead in and lead out arcs at the start and end of the thread cut",
            ),
        )
        obj.addProperty(
            "App::PropertyLink",
            "ClearanceOp",
            "Operation",
            QT_TRANSLATE_NOOP("App::Property", "Operation to clear the inside of the thread"),
        )

        for n in self.propertyEnumerations():
            setattr(obj, n[0], n[1])

    def threadPassRadii(self, obj):
        Path.Log.track(obj.Label)
        rMajor = (obj.MajorDiameter.Value - self.tool.Diameter) / 2.0
        rMinor = (obj.MinorDiameter.Value - self.tool.Diameter) / 2.0
        if obj.Passes < 1:
            obj.Passes = 1
        rPass = (rMajor - rMinor) / obj.Passes
        passes = [rMajor]
        for i in range(1, obj.Passes):
            passes.append(rMajor - rPass * i)
        return list(reversed(passes))

    def executeThreadMill(self, obj, loc, gcode, zStart, zFinal, pitch):
        Path.Log.track(obj.Label, loc, gcode, zStart, zFinal, pitch)
        elevator = elevatorRadius(obj, loc, _isThreadInternal(obj), self.tool)

        move2clearance = Path.Command("G0", {"Z": obj.ClearanceHeight.Value, "F": self.vertRapid})
        self.commandlist.append(move2clearance)

        start = None
        for radius in threadPasses(
            obj.Passes,
            threadRadii,
            _isThreadInternal(obj),
            obj.MajorDiameter.Value,
            obj.MinorDiameter.Value,
            float(self.tool.Diameter),
            float(self.tool.Crest),
        ):
            if not start is None:
                # and not _isThreadInternal(obj):
                # and not obj.LeadInOut:
                # external thread without lead in/out have to go up and over
                # in other words we need a move to clearance and not take any
                # shortcuts when moving to the elevator position
                self.commandlist.append(move2clearance)
                start = None
            commands, start = threadmilling.generate(
                loc,
                gcode,
                zStart,
                zFinal,
                pitch,
                radius,
                obj.LeadInOut,
                elevator,
                start,
            )

            for cmd in commands:
                p = cmd.Parameters
                if cmd.Name in ["G0"]:
                    p.update({"F": self.vertRapid})
                if cmd.Name in ["G1", "G2", "G3"]:
                    p.update({"F": self.horizFeed})
                cmd.Parameters = p
            self.commandlist.extend(commands)

        self.commandlist.append(
            Path.Command("G0", {"Z": obj.ClearanceHeight.Value, "F": self.vertRapid})
        )

    def circularHoleExecute(self, obj, holes):
        Path.Log.track()
        if self.isToolSupported(obj, self.tool):
            self.commandlist.append(Path.Command("(Begin Thread Milling)"))

            (cmd, zStart, zFinal) = threadSetup(obj)
            pitch = obj.Pitch.Value
            if obj.TPI > 0:
                pitch = 25.4 / obj.TPI
            if pitch <= 0:
                Path.Log.error("Cannot create thread with pitch {}".format(pitch))
                return

            # rapid to clearance height
            for loc in holes:
                self.executeThreadMill(
                    obj,
                    FreeCAD.Vector(loc["x"], loc["y"], 0),
                    cmd,
                    zStart,
                    zFinal,
                    pitch,
                )
        else:
            Path.Log.error("No suitable Tool found for thread milling operation")

    def opSetDefaultValues(self, obj, job):
        Path.Log.track()
        obj.ThreadOrientation = RightHand
        obj.ThreadType = ThreadTypeMetricInternal6H
        obj.ThreadFit = 50
        obj.Pitch = 1
        obj.TPI = 0
        obj.Passes = 1
        obj.Direction = DirectionClimb
        obj.LeadInOut = False

    def isToolSupported(self, obj, tool):
        """Thread milling only supports thread milling cutters."""
        support = hasattr(tool, "Diameter") and hasattr(tool, "Crest")
        Path.Log.track(tool.Label, support)
        return support


def SetupProperties():
    setup = []
    setup.append("ThreadOrientation")
    setup.append("ThreadType")
    setup.append("ThreadName")
    setup.append("ThreadFit")
    setup.append("MajorDiameter")
    setup.append("MinorDiameter")
    setup.append("Pitch")
    setup.append("TPI")
    setup.append("Passes")
    setup.append("Direction")
    setup.append("LeadInOut")
    return setup


def Create(name, obj=None, parentJob=None):
    """Create(name) ... Creates and returns a thread milling operation."""
    if obj is None:
        obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
    obj.Proxy = ObjectThreadMilling(obj, name, parentJob)
    if obj.Proxy:
        obj.Proxy.findAllHoles(obj)
    return obj
