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

# ***************************************************************************
# *   Copyright (c) 2020 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 Part
import Path
import Path.Op.Base as PathOp
import Path.Op.EngraveBase as PathEngraveBase
import PathScripts.PathUtils as PathUtils
import math
from PySide.QtCore import QT_TRANSLATE_NOOP

__doc__ = "Class and implementation of CAM Vcarve operation"

PRIMARY = 0
SECONDARY = 1
EXTERIOR1 = 2
EXTERIOR2 = 3
COLINEAR = 4
TWIN = 5
BORDERLINE = 6

# There is a bug in logging library. To enable debugging - set True also in Gui/Vcarve.py

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


def _collectVoronoiWires(vd):
    edges = [e for e in vd.Edges if e.Color == PRIMARY]
    vertex = {}
    for e in edges:
        for v in e.Vertices:
            i = v.Index
            j = vertex.get(i, [])
            j.append(e)
            vertex[i] = j

    # knots are the start and end points of a wire
    knots = [i for i in vertex if len(vertex[i]) == 1]
    knots.extend([i for i in vertex if len(vertex[i]) > 2])
    if len(knots) == 0:
        for i in vertex:
            if len(vertex[i]) > 0:
                knots.append(i)
                break

    def consume(v, edge):
        vertex[v] = [e for e in vertex[v] if e.Index != edge.Index]
        return len(vertex[v]) == 0

    def traverse(vStart, edge, edges):
        if vStart == edge.Vertices[0].Index:
            vEnd = edge.Vertices[1].Index
            edges.append(edge)
        else:
            vEnd = edge.Vertices[0].Index
            edges.append(edge.Twin)

        consume(vStart, edge)
        if consume(vEnd, edge):
            return None
        return vEnd

    wires = []
    while knots:
        we = []
        vFirst = knots[0]
        vStart = vFirst
        vLast = vFirst
        if len(vertex[vStart]):
            while vStart is not None:
                vLast = vStart
                edges = vertex[vStart]
                if len(edges) > 0:
                    edge = edges[0]
                    vStart = traverse(vStart, edge, we)
                else:
                    vStart = None
            wires.append(we)
        if len(vertex[vFirst]) == 0:
            knots = [v for v in knots if v != vFirst]
        if len(vertex[vLast]) == 0:
            knots = [v for v in knots if v != vLast]
    return wires


def _sortVoronoiWires(wires, start=FreeCAD.Vector(0, 0, 0)):
    def closestTo(start, point):
        p = None
        length = None
        for i in point:
            if length is None or length > start.distanceToPoint(point[i]):
                length = start.distanceToPoint(point[i])
                p = i
        return (p, length)

    begin = {}
    end = {}

    for i, w in enumerate(wires):
        begin[i] = w[0].Vertices[0].toPoint()
        end[i] = w[-1].Vertices[1].toPoint()

    result = []
    while begin:
        (bIdx, bLen) = closestTo(start, begin)
        (eIdx, eLen) = closestTo(start, end)
        if bLen < eLen:
            result.append(wires[bIdx])
            start = end[bIdx]
            del begin[bIdx]
            del end[bIdx]
        else:
            result.append([e.Twin for e in reversed(wires[eIdx])])
            start = begin[eIdx]
            del begin[eIdx]
            del end[eIdx]

    return result


class _Geometry(object):
    """POD class so the limits only have to be calculated once."""

    def __init__(self, zStart, zStop, zScale, zStepDown):
        self.start = zStart
        self.stop = zStop
        self.scale = zScale
        self.stepDown = zStepDown
        self.stepDownPass = 1

        # offset is used in finishing passes to override
        # any calculated vcarving depths. Usually going deeper 0.1-0.2 mm on finishing pass can help
        # remove "fuzzy skin" or other imperfections.
        self.offset = 0

    def incrementStepDownDepth(self, maximumUsableDepth):
        """
        Increase stepDown depth before starting new carving pass.
        :returns: True if successful, False if maximum depth achieved
        """

        # do not allow one to increase depth if we are already at stop depth
        if self.maximumDepth == self.stop:
            return False

        # do not allow one to increase depth if we are already at
        # maximum usable depth

        if self.maximumDepth <= maximumUsableDepth:
            return False

        self.stepDownPass += 1
        return True

    @property
    def maximumDepth(self):
        """
        Return maximum vcarving depth computed from step down setting and pass number
        """

        if self.stepDown == 0:
            return self.stop

        return max(self.stop, self.start - (self.stepDownPass * self.stepDown))

    @classmethod
    def FromTool(cls, tool, zStart, zFinal, zStepDown=0):
        rMax = float(tool.Diameter) / 2.0
        rMin = float(tool.TipDiameter) / 2.0
        toolangle = math.tan(math.radians(tool.CuttingEdgeAngle.Value / 2.0))
        zScale = 1.0 / toolangle
        zStop = zStart - rMax * zScale
        zOff = rMin * zScale

        return _Geometry(zStart + zOff, max(zStop + zOff, zFinal), zScale, zStepDown)

    @classmethod
    def FromObj(cls, obj, model):
        if obj.BaseShapes and hasattr(obj.BaseShapes[0], "Shape"):
            zStart = obj.BaseShapes[0].Shape.BoundBox.ZMax
        elif obj.Base and obj.Base[0][0] and hasattr(obj.Base[0][0], "Shape"):
            if len(obj.Base[0]) > 1 and "Face" in obj.Base[0][1][0]:
                faceName = obj.Base[0][1][0]
                faceIndex = int(faceName.replace("Face", "")) - 1
                face = obj.Base[0][0].Shape.Faces[faceIndex]
                zStart = face.BoundBox.ZMax
            else:
                zStart = obj.Base[0][0].Shape.BoundBox.ZMax
        else:
            zStart = model.Shape.BoundBox.ZMax
            Path.Log.error("Base object not set")
        finalDepth = obj.FinalDepth.Value
        stepDown = abs(obj.StepDown.Value)

        return cls.FromTool(obj.ToolController.Tool, zStart, finalDepth, stepDown)


def _calculate_depth(MIC, geom):
    # given a maximum inscribed circle (MIC) and tool angle,
    # return depth of cut relative to zStart.
    depth = geom.start - round(MIC * geom.scale, 4)

    return max(depth, geom.maximumDepth) + geom.offset


def _get_maximumUsableDepth(wires, geom):
    """
    Calculate maximum engraving depth for a list of wires
    belonging to one face.
    """

    def _get_depth(MIC, geom):
        """Similar logic to _calculate_depth but without stepdown and offset calculations"""
        depth = geom.start - round(MIC * geom.scale, 4)
        return max(depth, geom.stop)

    min_depth = None

    for wire in wires:
        for edge in wire:
            dist = edge.getDistances()
            depth = min(_get_depth(dist[0], geom), _get_depth(dist[1], geom))

            if min_depth is None:
                min_depth = depth
            else:
                min_depth = min(min_depth, depth)

    return min_depth


def _getPartEdge(edge, geom):
    dist = edge.getDistances()
    zBegin = _calculate_depth(dist[0], geom)
    zEnd = _calculate_depth(dist[1], geom)
    return edge.toShape(zBegin, zEnd)


def _getPartEdges(obj, vWire, geom):
    edges = []
    for e in vWire:
        edges.append(_getPartEdge(e, geom))
    return edges


class ObjectVcarve(PathEngraveBase.ObjectOp):
    """Proxy class for Vcarve operation."""

    def opFeatures(self, obj):
        """opFeatures(obj) ... return all standard features and edges based geometries"""
        return (
            PathOp.FeatureTool
            | PathOp.FeatureHeights
            | PathOp.FeatureDepths
            | PathOp.FeatureStepDown
            | PathOp.FeatureBaseFaces
            | PathOp.FeatureCoolant
        )

    def setupAdditionalProperties(self, obj):
        if not hasattr(obj, "BaseShapes"):
            obj.addProperty(
                "App::PropertyLinkList",
                "BaseShapes",
                "Path",
                QT_TRANSLATE_NOOP("App::Property", "Additional base objects to be engraved"),
            )
        obj.setEditorMode("BaseShapes", 2)  # hide

        if not hasattr(obj, "OptimizeMovements"):

            obj.addProperty(
                "App::PropertyBool",
                "OptimizeMovements",
                "Path",
                QT_TRANSLATE_NOOP("App::Property", "Optimize movements"),
            )
            obj.OptimizeMovements = False

        if not hasattr(obj, "FinishingPass"):
            obj.addProperty(
                "App::PropertyBool",
                "FinishingPass",
                "Path",
                QT_TRANSLATE_NOOP("App::Property", "Add finishing pass"),
            )
            obj.FinishingPass = False

        if not hasattr(obj, "FinishingPassZOffset"):
            obj.addProperty(
                "App::PropertyDistance",
                "FinishingPassZOffset",
                "Path",
                QT_TRANSLATE_NOOP("App::Property", "Finishing pass Z offset"),
            )

            obj.FinishingPassZOffset = "0.00"

    def initOperation(self, obj):
        """initOperation(obj) ... create vcarve specific properties."""
        obj.addProperty(
            "App::PropertyFloat",
            "Discretize",
            "Path",
            QT_TRANSLATE_NOOP("App::Property", "The deflection value for discretizing arcs"),
        )
        obj.addProperty(
            "App::PropertyFloat",
            "Colinear",
            "Path",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Cutoff for removing colinear segments (degrees). \
                        default=10.0.",
            ),
        )
        obj.addProperty(
            "App::PropertyFloat",
            "Tolerance",
            "Path",
            QT_TRANSLATE_NOOP("App::Property", "Vcarve Tolerance"),
        )

        obj.Colinear = 10.0
        obj.Discretize = 0.25
        obj.Tolerance = Path.Preferences.defaultGeometryTolerance()
        self.setupAdditionalProperties(obj)

    def opOnDocumentRestored(self, obj):
        # upgrade ...
        self.setupAdditionalProperties(obj)

    def buildMedialWires(self, obj, faces):
        """
        constructs a medial axis path using openvoronoi
        :returns: dictionary - each face object is a key containing list of wires"""

        wires_by_face = dict()
        self.voronoiDebugCache = dict()

        def is_exterior(vertex, face):
            vector = FreeCAD.Vector(vertex.toPoint(face.BoundBox.ZMin))
            (u, v) = face.Surface.parameter(vector)
            # isPartOfDomain is faster than face.IsInside(...)
            return not face.isPartOfDomain(u, v)

        def insert_many_wires(vd, wires):
            for wire in wires:
                Path.Log.debug("discretize value: {}".format(obj.Discretize))
                pts = wire.discretize(QuasiDeflection=obj.Discretize)
                ptv = [FreeCAD.Vector(p.x, p.y) for p in pts]
                # Check over the last point before just closing the polygon
                # by adding the start again.  If the discretizer was aiming
                # for the last point and missed by a little bit, closing the
                # polygon as is could result in OpenVoronoi truncating the
                # coordinates to a self-intersecting polygon which is invalid.
                # Instead, if the last point is close to the first, remove it
                # and let the final append close the polygon.
                # See issue 8064
                if len(ptv) > 0:
                    dist = ptv[-1].distanceToPoint(ptv[0])
                    if dist < FreeCAD.Base.Precision.confusion():
                        Path.Log.debug(
                            "Removing bad carve point: {} from polygon origin".format(dist)
                        )
                        del ptv[-1]
                ptv.append(ptv[0])

                for i in range(len(ptv) - 1):
                    vd.addSegment(ptv[i], ptv[i + 1])

        for f in faces:
            voronoiWires = []
            vd = Path.Voronoi.Diagram()
            insert_many_wires(vd, f.Wires)

            vd.construct()

            for e in vd.Edges:
                if e.isPrimary():
                    if e.isBorderline():
                        e.Color = BORDERLINE
                    else:
                        e.Color = PRIMARY
                else:
                    e.Color = SECONDARY

            # filter our colinear edged so there are fewer ones
            # to iterate over in colorExterior which is slow
            vd.colorColinear(COLINEAR, obj.Colinear)

            vd.colorExterior(EXTERIOR1)
            vd.colorExterior(EXTERIOR2, lambda v: is_exterior(v, f))

            # if colorTwin is done before colorExterior we seem to have
            # much more weird exterior edges needed to be filtered out,
            # keep it here to be safe
            vd.colorTwins(TWIN)

            wires = _collectVoronoiWires(vd)
            wires = _sortVoronoiWires(wires)
            voronoiWires.extend(wires)

            wires_by_face[f] = voronoiWires
            self.voronoiDebugCache = wires_by_face

        return wires_by_face

    def buildCommandList(self, obj, faces):
        """
        Build command list to cut wires - based on voronoi
        wire list from buildMedialWires
        """

        def getCurrentPosition(wire):
            """
            Calculate CNC head position assuming it reached the end of the wire
            """

            if not wire:
                return None

            lastEdge = wire[-1]
            return lastEdge.valueAt(lastEdge.LastParameter)

        def cutWires(wires, pathlist, optimizeMovements=False):
            currentPosition = None
            for w in wires:
                pWire = _getPartEdges(obj, w, geom)
                if pWire:
                    pathlist.extend(_cutWire(pWire, currentPosition))

                    # movement optimization only works if we provide current head position
                    if optimizeMovements:
                        currentPosition = getCurrentPosition(pWire)

        def canSkipRepositioning(currentPosition, newPosition):
            """
            Calculate if it makes sense to raise head to safe height and reposition before
            starting to cut another edge
            """

            if not currentPosition:
                return False

            # get vertex position on X/Y plane only
            v0 = FreeCAD.Base.Vector(currentPosition.x, currentPosition.y)
            v1 = FreeCAD.Base.Vector(newPosition.x, newPosition.y)

            return v0.distanceToPoint(v1) <= 0.5

        def _cutWire(wire, currentPosition=None):
            path = []

            e = wire[0]
            newPosition = e.valueAt(e.FirstParameter)

            # raise and reposition the head only if new wire starts further than 0.5 mm
            # from current head position
            if not canSkipRepositioning(currentPosition, newPosition):
                path.append(Path.Command("G0", {"Z": obj.SafeHeight.Value}))
                path.append(
                    Path.Command(
                        "G0", {"X": newPosition.x, "Y": newPosition.y, "Z": obj.SafeHeight.Value}
                    )
                )

            hSpeed = obj.ToolController.HorizFeed.Value
            vSpeed = obj.ToolController.VertFeed.Value
            path.append(
                Path.Command(
                    "G1", {"X": newPosition.x, "Y": newPosition.y, "Z": newPosition.z, "F": vSpeed}
                )
            )
            for e in wire:
                path.extend(Path.Geom.cmdsForEdge(e, hSpeed=hSpeed, vSpeed=vSpeed))

            return path

        pathlist = []
        pathlist.append(Path.Command("(starting)"))

        geom = _Geometry.FromObj(obj, self.model[0])

        # iterate over each face separately
        for face, wires in self.buildMedialWires(obj, faces).items():

            # If using depth step-down, calculate maximum usable depth for current face.
            # This is done to avoid adding additional step-down engraving passes when it
            # would make no sense as depth is limited by Maximum Inscribed Circle anyway.

            maximumUsableDepth = geom.stop

            if geom.stepDown > 0:
                _maximumUsableDepth = _get_maximumUsableDepth(wires, geom)
                if _maximumUsableDepth is not None:
                    maximumUsableDepth = _maximumUsableDepth
                    Path.Log.debug(f"Maximum usable depth for current face: {maximumUsableDepth}")

            # first pass
            cutWires(wires, pathlist, obj.OptimizeMovements)

            # subsequent stepDown depth passes (if any)
            while geom.incrementStepDownDepth(maximumUsableDepth):
                cutWires(wires, pathlist, obj.OptimizeMovements)

            # add finishing pass if enabled

            if obj.FinishingPass:
                geom.offset = obj.FinishingPassZOffset.Value

                cutWires(wires, pathlist, obj.OptimizeMovements)

        self.commandlist = pathlist

    def opExecute(self, obj):
        """opExecute(obj) ... process engraving operation"""
        Path.Log.track()

        self.voronoiDebugCache = None

        if obj.ToolController is None:
            return

        if not hasattr(obj.ToolController.Tool, "CuttingEdgeAngle"):
            Path.Log.info(
                translate(
                    "CAM_Vcarve",
                    "VCarve requires an engraving cutter with a cutting edge angle",
                )
            )
            return

        if obj.ToolController.Tool.CuttingEdgeAngle >= 180.0:
            Path.Log.info(
                translate("CAM_Vcarve", "Engraver cutting edge angle must be < 180 degrees.")
            )
            return

        try:
            faces = []

            for base in obj.BaseShapes:
                faces.extend(base.Shape.Faces)

            for base in obj.Base:
                for sub in base[1]:
                    shape = getattr(base[0].Shape, sub)
                    if isinstance(shape, Part.Face):
                        faces.append(shape)

            if not faces:
                for model in self.model:
                    if model.isDerivedFrom("Sketcher::SketchObject") or model.isDerivedFrom(
                        "Part::Part2DObject"
                    ):
                        faces.extend(model.Shape.Faces)

            if faces:
                self.buildCommandList(obj, faces)
            else:
                Path.Log.error(
                    translate(
                        "PathVcarve",
                        "The Job Base Object has no engraveable element. Engraving operation will produce no output.",
                    )
                )

        except Exception:
            Path.Log.warning(
                "Error processing Base object. Engraving operation will produce no output."
            )
            import traceback

            Path.Log.error(f"Engraving operation exception: {traceback.format_exc()}")

    def opUpdateDepths(self, obj, ignoreErrors=False):
        """updateDepths(obj) ... engraving is always done at the top most z-value"""
        job = PathUtils.findParentJob(obj)
        self.opSetDefaultValues(obj, job)

    def opSetDefaultValues(self, obj, job):
        """opSetDefaultValues(obj) ... set depths for vcarving"""
        if PathOp.FeatureDepths & self.opFeatures(obj):
            if job and len(job.Model.Group) > 0:
                bb = job.Proxy.modelBoundBox(job)
                obj.OpStartDepth = bb.ZMax
                obj.OpFinalDepth = job.Stock.Shape.BoundBox.ZMin
            else:
                obj.OpFinalDepth = -0.1

    def isToolSupported(self, obj, tool):
        """isToolSupported(obj, tool) ... returns True if v-carve op can work with tool."""
        return (
            hasattr(tool, "Diameter")
            and hasattr(tool, "CuttingEdgeAngle")
            and hasattr(tool, "TipDiameter")
        )

    def debugVoronoi(self, obj):
        """Debug function to display calculated voronoi edges"""

        if not getattr(self, "voronoiDebugCache", None):
            Path.Log.error("debugVoronoi: empty debug cache. Recompute VCarve operation first")
            return

        vPart = FreeCAD.activeDocument().addObject("App::Part", f"{obj.Name}-VoronoiDebug")

        wiresToShow = []

        for face, wires in self.voronoiDebugCache.items():
            for wire in wires:
                currentPartWire = Part.Wire()
                currentPartWire.fixTolerance(0.01)
                for edge in wire:
                    currentEdge = edge.toShape()

                    for v in currentEdge.Vertexes:
                        v.fixTolerance(0.1)

                    currentPartWire.add(currentEdge)
                wiresToShow.append(currentPartWire)

        for w in wiresToShow:
            vPart.addObject(Part.show(w))


def SetupProperties():
    return ["Discretize"]


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