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

# ***************************************************************************
# *   Copyright (c) 2022 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                                                                   *
# *                                                                         *
# ***************************************************************************

from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import Path
import Path.Base.Generator.dogboneII as dogboneII
import Path.Base.Language as PathLanguage
import Path.Dressup.Utils as PathDressup
import PathScripts.PathUtils as PathUtils
import math

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

PI = math.pi


def calc_length_adaptive(kink, angle, nominal_length, custom_length):
    Path.Log.track(kink, angle, nominal_length, custom_length)

    if Path.Geom.isRoughly(abs(kink.deflection()), 0):
        return 0

    # If the kink poses a 180deg turn the adaptive length is undefined. Mathematically
    # it's infinite but that is not practical.
    # We define the adaptive length to be the nominal length for this case.
    if Path.Geom.isRoughly(abs(kink.deflection()), PI):
        return nominal_length

    # The distance of the (estimated) corner from the kink position depends only on the
    # deflection of the kink.
    # Some sample values to build up intuition:
    #           deflection :   dog bone  : norm distance : calc
    #      ----------------:-------------:---------------:--------------
    #               0      :    -PI/2    :   1
    #              PI/6    :  -5*PI/12   :   1.03528     : 1/cos(  (pi/6) / 2)
    #              PI/4    :  -3*PI/8    :   1.08239     : 1/cos(  (pi/4) / 2)
    #              PI/3    :    -PI/3    :   1.1547      : 1/cos(  (pi/3) / 2)
    #              PI/2    :    -PI/4    :   1.41421     : 1/cos(  (pi/2) / 2)
    #            2*PI/3    :    -PI/6    :   2           : 1/cos((2*pi/3) / 2)
    #            3*PI/4    :    -PI/8    :   2.61313     : 1/cos((3*pi/4) / 2)
    #            5*PI/6    :    -PI/12   :   3.8637      : 1/cos((5*pi/6) / 2)
    #              PI      :      0      :   nan  <-- see above
    # The last column can be geometrically derived or found by experimentation.
    dist = nominal_length / math.cos(kink.deflection() / 2)

    # The depth of the bone depends on the direction of the bone in relation to the
    # direction of the corner. If the direction is identical then the depth is the same
    # as the distance of the corner minus the nominal_length (which corresponds to the
    # radius of the tool).
    # If the corner's direction is PI/4 off the bone angle the intersecion of the tool
    # with the corner is the projection of the corner onto the bone.
    # If the corner's direction is perpendicular to the bone's angle there is, strictly
    # speaking no intersection and the bone is ineffective. However, giving it our
    # best shot we should probably move the entire depth.

    da = Path.Geom.normalizeAngle(kink.normAngle() - angle)
    depth = dist * math.cos(da)
    if depth < 0:
        Path.Log.debug(
            f"depth={depth:4f}: kink={kink}, angle={180*angle/PI}, dist={dist:.4f}, da={180*da/PI} -> depth=0.0"
        )
        depth = 0
    else:
        height = dist * abs(math.sin(da))
        if height < nominal_length:
            depth = depth - math.sqrt(nominal_length * nominal_length - height * height)
        Path.Log.debug(
            f"{kink}: angle={180*angle/PI}, dist={dist:.4f}, da={180*da/PI}, depth={depth:.4f}"
        )

    return depth


def calc_length_nominal(kink, angle, nominal_length, custom_length):
    return nominal_length


def calc_length_custom(kink, angle, nominal_length, custom_length):
    return custom_length


class Style(object):
    """Style - enumeration class for the supported bone styles"""

    Dogbone = "Dogbone"
    Tbone_H = "T-bone horizontal"
    Tbone_V = "T-bone vertical"
    Tbone_L = "T-bone long edge"
    Tbone_S = "T-bone short edge"
    All = [Dogbone, Tbone_H, Tbone_V, Tbone_L, Tbone_S]

    Generator = {
        Dogbone: dogboneII.GeneratorDogbone,
        Tbone_H: dogboneII.GeneratorTBoneHorizontal,
        Tbone_V: dogboneII.GeneratorTBoneVertical,
        Tbone_S: dogboneII.GeneratorTBoneOnShort,
        Tbone_L: dogboneII.GeneratorTBoneOnLong,
    }


class Side(object):
    """Side - enumeration class for the side of the path to attach bones"""

    Left = "Left"
    Right = "Right"
    All = [Left, Right]

    @classmethod
    def oppositeOf(cls, side):
        if side == cls.Left:
            return cls.Right
        if side == cls.Right:
            return cls.Left
        return None


class Incision(object):
    """Incision - enumeration class for the different depths of bone incision"""

    Fixed = "fixed"
    Adaptive = "adaptive"
    Custom = "custom"
    All = [Adaptive, Fixed, Custom]

    Calc = {
        Fixed: calc_length_nominal,
        Adaptive: calc_length_adaptive,
        Custom: calc_length_custom,
    }


def insertBone(obj, kink):
    """insertBone(kink, side) - return True if a bone should be inserted into the kink"""
    if not kink.isKink():
        Path.Log.debug("not a kink")
        return False

    if obj.Side == Side.Right and kink.goesRight():
        return False
    if obj.Side == Side.Left and kink.goesLeft():
        return False
    return True


class BoneState(object):
    def __init__(self, bone, nr, enabled=True):
        self.bone = bone
        self.bones = {nr: bone}
        self.enabled = enabled
        pos = bone.position()
        self.pos = FreeCAD.Vector(pos.x, pos.y, 0)

    def isEnabled(self):
        return self.enabled

    def addBone(self, bone, nr):
        self.bones[nr] = bone

    def position(self):
        return self.pos

    def boneTip(self):
        return self.bone.tip()

    def boneIDs(self):
        return sorted(self.bones)

    def zLevels(self):
        return sorted([bone.position().z for bone in self.bones.values()])

    def length(self):
        return self.bone.length


class Proxy(object):
    def __init__(self, obj, base):
        obj.addProperty(
            "App::PropertyLink",
            "Base",
            "Base",
            QT_TRANSLATE_NOOP("App::Property", "The base path to dress up"),
        )
        obj.Base = base

        obj.addProperty(
            "App::PropertyEnumeration",
            "Side",
            "Dressup",
            QT_TRANSLATE_NOOP("App::Property", "The side of path to insert bones"),
        )
        obj.Side = Side.All
        if hasattr(base, "BoneBlacklist"):
            obj.Side = base.Side
        else:
            side = Side.Right
            if hasattr(obj.Base, "Side") and obj.Base.Side == "Inside":
                side = Side.Left
            if hasattr(obj.Base, "Direction") and obj.Base.Direction == "CCW":
                side = Side.oppositeOf(side)
            obj.Side = side

        obj.addProperty(
            "App::PropertyEnumeration",
            "Style",
            "Dressup",
            QT_TRANSLATE_NOOP("App::Property", "The style of bones"),
        )
        obj.Style = Style.All
        obj.Style = Style.Dogbone

        obj.addProperty(
            "App::PropertyEnumeration",
            "Incision",
            "Dressup",
            QT_TRANSLATE_NOOP("App::Property", "The algorithm to determine the bone length"),
        )
        obj.Incision = Incision.All
        obj.Incision = Incision.Adaptive

        obj.addProperty(
            "App::PropertyLength",
            "Custom",
            "Dressup",
            QT_TRANSLATE_NOOP("App::Property", "Dressup length if incision is set to 'custom'"),
        )
        obj.Custom = 0.0

        obj.addProperty(
            "App::PropertyIntegerList",
            "BoneBlacklist",
            "Dressup",
            QT_TRANSLATE_NOOP("App::Property", "Bones that aren't dressed up"),
        )
        obj.BoneBlacklist = []

        obj.addProperty(
            "App::PropertyBool",
            "OnlyClosedProfiles",
            "Dressup",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Create bones only for outer closed profiles\nCan be useful for multi profile operations, e.g. Pocket with ZigZagOffset pattern",
            ),
        )
        self.onDocumentRestored(obj)

    def onDocumentRestored(self, obj):
        self.obj = obj
        obj.setEditorMode("BoneBlacklist", 2)  # hide

    def dumps(self):
        return None

    def loads(self, state):
        return None

    def onChanged(self, obj, prop):
        if prop == "Path" and obj.ViewObject:
            obj.ViewObject.signalChangeIcon()

    def toolRadius(self, obj):
        return PathDressup.toolController(obj.Base).Tool.Diameter.Value / 2

    def createBone(self, obj, move0, move1):
        if move0.isRapid() and move1.isRapid():
            return None
        kink = dogboneII.Kink(move0, move1)
        Path.Log.debug(f"{obj.Label}.createBone({kink})")
        if insertBone(obj, kink):
            generator = Style.Generator[obj.Style]
            calc_length = Incision.Calc[obj.Incision]
            nominal = self.toolRadius(obj)
            custom = obj.Custom.Value
            return dogboneII.generate(kink, generator, calc_length, nominal, custom)
        return None

    # Get start index of closed loop in Area
    def findStartIndexClosedProfile(self, source, startAreaIndex, endAreaIndex):
        points = []
        points.append(source[endAreaIndex].positionEnd())
        points.append(source[endAreaIndex - 1].positionEnd())
        for i in range(endAreaIndex - 2, startAreaIndex - 1, -1):
            point = source[i].positionBegin()
            for j, p in enumerate(points):
                # compare this point with all points before
                if Path.Geom.pointsCoincide(point, p):
                    return i, endAreaIndex - j
            points.append(point)
        return None, None

    # Compare two boundboxes
    def isEquelBoundboxes(self, bb1, bb2):
        if not Path.Geom.isRoughly(bb1.XMin, bb2.XMin):
            return False
        if not Path.Geom.isRoughly(bb1.XMax, bb2.XMax):
            return False
        if not Path.Geom.isRoughly(bb1.YMin, bb2.YMin):
            return False
        if not Path.Geom.isRoughly(bb1.YMax, bb2.YMax):
            return False
        if not Path.Geom.isRoughly(bb1.ZMin, bb2.ZMin):
            return False
        if not Path.Geom.isRoughly(bb1.ZMax, bb2.ZMax):
            return False
        return True

    # Search inner profiles which should be excluded
    def getIndexInnerProfiles(self, source, indexList):
        boundboxList = []
        for i, areaIndexList in enumerate(indexList):
            minX, minY, maxX, maxY = None, None, None, None
            for index in areaIndexList:
                point = source[index].positionEnd()
                minX = point.x if minX is None or point.x < minX else minX
                maxX = point.x if maxX is None or point.x > maxX else maxX
                minY = point.y if minY is None or point.y < minY else minY
                maxY = point.y if maxY is None or point.y > maxY else maxY
            boundbox = FreeCAD.BoundBox(minX, minY, 0, maxX, maxY, 0)
            boundboxList.append(boundbox)

        excludeList = []
        for i, boundbox in enumerate(boundboxList):
            for bb in boundboxList:
                if not self.isEquelBoundboxes(boundbox, bb) and bb.isInside(boundbox):
                    excludeList.append(i)
                    break
        return excludeList

    # Check command
    def isCuttingMove(self, instr):
        result = instr.isMove() and not instr.isRapid() and not instr.isPlunge()
        return result

    def getIndexOuterClosedProfiles(self, source):
        closedProfilesIndex = []
        startArea = None
        endArea = None
        for i, instr in enumerate(source):
            if (
                startArea is None
                and endArea is None
                and self.isCuttingMove(source[i])
                and (i == 0 or not self.isCuttingMove(source[i - 1]))
            ):
                # start mill index of the area
                startArea = i

            if (
                startArea is not None
                and endArea is None
                and (i == len(source) - 1 or not self.isCuttingMove(source[i + 1]))
            ):
                # end mill index of the area
                endArea = i

            if startArea and endArea:
                p1 = source[startArea].positionBegin()
                p2 = source[endArea].positionEnd()
                if Path.Geom.pointsCoincide(p1, p2):
                    # simple case
                    # one closed profile in the area
                    closedProfilesIndex.append(list(range(startArea, endArea + 1)))
                else:
                    # points is not coincide
                    # try to find last closed profile in the area
                    startIndex, endIndex = self.findStartIndexClosedProfile(
                        source, startArea, endArea
                    )
                    if startIndex is not None and endIndex is not None:
                        closedProfilesIndex.append(list(range(startIndex, endIndex + 1)))

                startArea = None
                endArea = None

        excludeList = self.getIndexInnerProfiles(source, closedProfilesIndex)

        outerClosedProfilesIndex = []
        for i, area in enumerate(closedProfilesIndex):
            if i not in excludeList:
                for j in area:
                    outerClosedProfilesIndex.append(j)

        return outerClosedProfilesIndex

    def execute(self, obj):
        Path.Log.track(obj.Label)
        maneuver = PathLanguage.Maneuver()
        bones = []
        lastMove = None
        moveAfterPlunge = None
        dressingUpDogbone = hasattr(obj.Base, "BoneBlacklist")

        if obj.Base and obj.Base.Path and obj.Base.Path.Commands:
            source = PathLanguage.Maneuver.FromPath(PathUtils.getPathWithPlacement(obj.Base)).instr

            # get indexes of outer closed profile in each multi work area
            if hasattr(obj, "OnlyClosedProfiles") and obj.OnlyClosedProfiles:
                closedProfilesIndex = self.getIndexOuterClosedProfiles(source)
            else:
                closedProfilesIndex = None

            for index, instr in enumerate(source):
                # Path.Log.debug(f"instr: {instr}")
                if instr.isMove():
                    thisMove = instr
                    bone = None
                    if thisMove.isPlunge() or (
                        closedProfilesIndex is not None and index not in closedProfilesIndex
                    ):
                        if lastMove and moveAfterPlunge and lastMove.leadsInto(moveAfterPlunge):
                            bone = self.createBone(obj, lastMove, moveAfterPlunge)
                        lastMove = None
                        moveAfterPlunge = None
                    else:
                        if moveAfterPlunge is None:
                            moveAfterPlunge = thisMove
                        if lastMove:
                            bone = self.createBone(obj, lastMove, thisMove)
                        lastMove = thisMove
                    if bone:
                        enabled = len(bones) not in obj.BoneBlacklist
                        if enabled and not (
                            dressingUpDogbone and obj.Base.Proxy.includesBoneAt(bone.position())
                        ):
                            maneuver.addInstructions(bone.instr)
                        else:
                            Path.Log.debug(f"{bone.kink} disabled {enabled}")
                        bones.append(bone)
                    maneuver.addInstruction(thisMove)
                else:
                    # non-move instructions get added verbatim
                    maneuver.addInstruction(instr)

        else:
            Path.Log.info(f"No Path found to dress up in op {obj.Base}")
        self.maneuver = maneuver
        self.bones = bones
        self.boneTips = None
        obj.Path = maneuver.toPath()

    def boneStates(self, obj):
        state = {}
        if hasattr(self, "bones"):
            for nr, bone in enumerate(self.bones):
                pos = bone.position()
                loc = f"({pos.x:.4f}, {pos.y:.4f})"
                if state.get(loc, None):
                    state[loc].addBone(bone, nr)
                else:
                    state[loc] = BoneState(bone, nr)
                if nr in obj.BoneBlacklist:
                    state[loc].enabled = False
        return state.values()

    def includesBoneAt(self, pos):
        if hasattr(self, "bones"):
            for nr, bone in enumerate(self.bones):
                if Path.Geom.pointsCoincide(bone.position(), pos):
                    return nr not in self.obj.BoneBlacklist
        return False


def Create(base, name="DressupDogbone"):
    obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
    pxy = Proxy(obj, base)

    obj.Proxy = pxy

    return obj
