# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# *   Copyright (c) 2017 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.Util as PathUtil
import Path.Base.SetupSheetOpPrototype as PathSetupSheetOpPrototype
from PySide.QtCore import QT_TRANSLATE_NOOP

__title__ = "Setup Sheet for a Job."
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "A container for all default values and job specific configuration values."

_RegisteredOps: dict = {}


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 Template:

    HorizRapid = "HorizRapid"
    VertRapid = "VertRapid"
    CoolantMode = "CoolantMode"
    SafeHeightOffset = "SafeHeightOffset"
    SafeHeightExpression = "SafeHeightExpression"
    ClearanceHeightOffset = "ClearanceHeightOffset"
    ClearanceHeightExpression = "ClearanceHeightExpression"
    StartDepthExpression = "StartDepthExpression"
    FinalDepthExpression = "FinalDepthExpression"
    StepDownExpression = "StepDownExpression"
    Fixtures = "Fixtures"
    OrderOutputBy = "OrderOutputBy"
    SplitOutput = "SplitOutput"

    All = [
        HorizRapid,
        VertRapid,
        CoolantMode,
        SafeHeightOffset,
        SafeHeightExpression,
        ClearanceHeightOffset,
        ClearanceHeightExpression,
        StartDepthExpression,
        FinalDepthExpression,
        StepDownExpression,
    ]


def _traverseTemplateAttributes(attrs, codec):
    Path.Log.debug(attrs)
    coded = {}
    for key, value in attrs.items():
        if type(value) == dict:
            Path.Log.debug("%s is a dict" % key)
            coded[key] = _traverseTemplateAttributes(value, codec)
        elif type(value) == list:
            Path.Log.debug("%s is a list" % key)
            coded[key] = [_traverseTemplateAttributes(attr, codec) for attr in value]
        elif isinstance(value, str):
            Path.Log.debug("%s is a string" % key)
            coded[key] = codec(value)
        else:
            Path.Log.debug("%s is %s" % (key, type(value)))
            coded[key] = value
    return coded


class SetupSheet:
    """Property container object used by a Job to hold global reference values."""

    TemplateReference = "${SetupSheet}"

    DefaultSafeHeightOffset = "3 mm"
    DefaultClearanceHeightOffset = "5 mm"
    DefaultSafeHeightExpression = "OpStockZMax+${SetupSheet}.SafeHeightOffset"
    DefaultClearanceHeightExpression = "OpStockZMax+${SetupSheet}.ClearanceHeightOffset"

    DefaultStartDepthExpression = "OpStartDepth"
    DefaultFinalDepthExpression = "OpFinalDepth"
    DefaultStepDownExpression = "OpToolDiameter"

    DefaultCoolantModes = ["None", "Flood", "Mist"]

    def __init__(self, obj):
        self.obj = obj
        obj.addProperty(
            "App::PropertySpeed",
            "VertRapid",
            "ToolController",
            QT_TRANSLATE_NOOP("App::Property", "Default speed for horizontal rapid moves."),
        )
        obj.addProperty(
            "App::PropertySpeed",
            "HorizRapid",
            "ToolController",
            QT_TRANSLATE_NOOP("App::Property", "Default speed for vertical rapid moves."),
        )
        obj.addProperty(
            "App::PropertyStringList",
            "CoolantModes",
            "CoolantMode",
            QT_TRANSLATE_NOOP("App::Property", "Coolant Modes"),
        )
        obj.addProperty(
            "App::PropertyEnumeration",
            "CoolantMode",
            "CoolantMode",
            QT_TRANSLATE_NOOP("App::Property", "Default coolant mode."),
        )
        obj.addProperty(
            "App::PropertyLength",
            "SafeHeightOffset",
            "OperationHeights",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "The usage of this field depends on SafeHeightExpression - by default its value is added to the start depth and used for the safe height of an operation.",
            ),
        )
        obj.addProperty(
            "App::PropertyString",
            "SafeHeightExpression",
            "OperationHeights",
            QT_TRANSLATE_NOOP("App::Property", "Expression for the safe height of new operations."),
        )
        obj.addProperty(
            "App::PropertyLength",
            "ClearanceHeightOffset",
            "OperationHeights",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "The usage of this field depends on ClearanceHeightExpression - by default is value is added to the start depth and used for the clearance height of an operation.",
            ),
        )
        obj.addProperty(
            "App::PropertyString",
            "ClearanceHeightExpression",
            "OperationHeights",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Expression for the clearance height of new operations.",
            ),
        )
        obj.addProperty(
            "App::PropertyString",
            "StartDepthExpression",
            "OperationDepths",
            QT_TRANSLATE_NOOP(
                "App::Property", "Expression used for the start depth of new operations."
            ),
        )
        obj.addProperty(
            "App::PropertyString",
            "FinalDepthExpression",
            "OperationDepths",
            QT_TRANSLATE_NOOP(
                "App::Property", "Expression used for the final depth of new operations."
            ),
        )
        obj.addProperty(
            "App::PropertyString",
            "StepDownExpression",
            "OperationDepths",
            QT_TRANSLATE_NOOP("App::Property", "Expression used for step down of new operations."),
        )

        obj.SafeHeightOffset = self.decodeAttributeString(self.DefaultSafeHeightOffset)
        obj.ClearanceHeightOffset = self.decodeAttributeString(self.DefaultClearanceHeightOffset)
        obj.SafeHeightExpression = self.decodeAttributeString(self.DefaultSafeHeightExpression)
        obj.ClearanceHeightExpression = self.decodeAttributeString(
            self.DefaultClearanceHeightExpression
        )

        obj.StartDepthExpression = self.decodeAttributeString(self.DefaultStartDepthExpression)
        obj.FinalDepthExpression = self.decodeAttributeString(self.DefaultFinalDepthExpression)
        obj.StepDownExpression = self.decodeAttributeString(self.DefaultStepDownExpression)

        obj.CoolantModes = self.DefaultCoolantModes
        obj.CoolantMode = self.DefaultCoolantModes

        obj.Proxy = self

    def dumps(self):
        return None

    def loads(self, state):
        for obj in FreeCAD.ActiveDocument.Objects:
            if hasattr(obj, "Proxy") and obj.Proxy == self:
                self.obj = obj
                break
        return None

    def hasDefaultToolRapids(self):
        return Path.Geom.isRoughly(self.obj.VertRapid.Value, 0) and Path.Geom.isRoughly(
            self.obj.HorizRapid.Value, 0
        )

    def hasDefaultOperationHeights(self):
        if (
            self.obj.SafeHeightOffset.UserString
            != FreeCAD.Units.Quantity(self.DefaultSafeHeightOffset).UserString
        ):
            return False
        if (
            self.obj.ClearanceHeightOffset.UserString
            != FreeCAD.Units.Quantity(self.DefaultClearanceHeightOffset).UserString
        ):
            return False
        if self.obj.SafeHeightExpression != self.decodeAttributeString(
            self.DefaultSafeHeightExpression
        ):
            return False
        if self.obj.ClearanceHeightExpression != self.decodeAttributeString(
            self.DefaultClearanceHeightExpression
        ):
            return False
        return True

    def hasDefaultOperationDepths(self):
        if self.obj.StartDepthExpression != self.DefaultStartDepthExpression:
            return False
        if self.obj.FinalDepthExpression != self.DefaultFinalDepthExpression:
            return False
        if self.obj.StepDownExpression != self.DefaultStepDownExpression:
            return False
        return True

    def hasDefaultCoolantMode(self):
        return self.obj.CoolantMode == "None"

    def setFromTemplate(self, attrs):
        """setFromTemplate(attrs) ... sets the default values from the given dictionary."""
        for name in Template.All:
            if attrs.get(name) is not None:
                setattr(self.obj, name, attrs[name])

        for opName, op in _RegisteredOps.items():
            opSetting = attrs.get(opName)
            if opSetting is not None:
                prototype = op.prototype(opName)
                for propName in op.properties():
                    value = opSetting.get(propName)
                    if value is not None:
                        prop = prototype.getProperty(propName)
                        propertyName = OpPropertyName(opName, propName)
                        propertyGroup = OpPropertyGroup(opName)
                        prop.setupProperty(
                            self.obj,
                            propertyName,
                            propertyGroup,
                            prop.valueFromString(value),
                        )

    def templateAttributes(
        self,
        includeRapids=True,
        includeCoolantMode=True,
        includeHeights=True,
        includeDepths=True,
        includeOps=None,
    ):
        """templateAttributes(includeRapids, includeHeights, includeDepths) ... answers a dictionary with the default values."""
        attrs = {}

        if includeRapids:
            attrs[Template.VertRapid] = self.obj.VertRapid.UserString
            attrs[Template.HorizRapid] = self.obj.HorizRapid.UserString

        if includeCoolantMode:
            attrs[Template.CoolantMode] = self.obj.CoolantMode

        if includeHeights:
            attrs[Template.SafeHeightOffset] = self.obj.SafeHeightOffset.UserString
            attrs[Template.SafeHeightExpression] = self.obj.SafeHeightExpression
            attrs[Template.ClearanceHeightOffset] = self.obj.ClearanceHeightOffset.UserString
            attrs[Template.ClearanceHeightExpression] = self.obj.ClearanceHeightExpression

        if includeDepths:
            attrs[Template.StartDepthExpression] = self.obj.StartDepthExpression
            attrs[Template.FinalDepthExpression] = self.obj.FinalDepthExpression
            attrs[Template.StepDownExpression] = self.obj.StepDownExpression

        if includeOps:
            for opName in includeOps:
                settings = {}
                op = _RegisteredOps[opName]
                for propName in op.properties():
                    prop = OpPropertyName(opName, propName)
                    if hasattr(self.obj, prop):
                        settings[propName] = PathUtil.getPropertyValueString(self.obj, prop)
                attrs[opName] = settings

        return attrs

    def expressionReference(self):
        """expressionReference() ... returns the string to be used in expressions"""
        # Using the Name here and not the Label (both would be valid) because the Name 'fails early'.
        #
        # If there is a Name/Label conflict and an expression is bound to the Name we'll get an error
        # on creation (Property not found). Not good, but at least there's some indication that
        # something's afoul.
        #
        # If the expression is based on the Label everything works out nicely - until the document is
        # saved and loaded from disk. The Labels change in order to avoid the Name/Label conflict
        # but the expression stays the same. If the user's lucky the expression is broken because the
        # conflicting object doesn't have the properties reference by the expressions. If the user is
        # not so lucky those properties also exist in the other object, there is no indication that
        # anything is wrong but the expressions will substitute the values from the wrong object.
        #
        # I prefer the question: "why do I get this error when I create ..." over "my cnc machine just
        # rammed it's tool head into the table ..." or even "I saved my file and now it's corrupt..."
        #
        # https://forum.freecad.org/viewtopic.php?f=10&t=24839
        # https://forum.freecad.org/viewtopic.php?f=10&t=24845
        return self.obj.Name

    def encodeAttributeString(self, attr):
        """encodeAttributeString(attr) ... return the encoded string of a template attribute."""
        return str(attr.replace(self.expressionReference(), self.TemplateReference))

    def decodeAttributeString(self, attr):
        """decodeAttributeString(attr) ... return the decoded string of a template attribute."""
        return str(attr.replace(self.TemplateReference, self.expressionReference()))

    def encodeTemplateAttributes(self, attrs):
        """encodeTemplateAttributes(attrs) ... return a dictionary with all values encoded."""
        return _traverseTemplateAttributes(attrs, self.encodeAttributeString)

    def decodeTemplateAttributes(self, attrs):
        """decodeTemplateAttributes(attrs) ... expand template attributes to reference the receiver where applicable."""
        return _traverseTemplateAttributes(attrs, self.decodeAttributeString)

    def operationsWithSettings(self):
        """operationsWithSettings() ... returns a list of operations which currently have some settings defined."""
        ops = []
        for name, value in _RegisteredOps.items():
            for prop in value.registeredPropertyNames(name):
                if hasattr(self.obj, prop):
                    ops.append(name)
                    break
        return list(sorted(ops))

    def setOperationProperties(self, obj, opName):
        Path.Log.track(obj.Label, opName)
        try:
            op = _RegisteredOps[opName]
            for prop in op.properties():
                propName = OpPropertyName(opName, prop)
                if hasattr(self.obj, propName):
                    setattr(obj, prop, getattr(self.obj, propName))
        except Exception:
            Path.Log.info("SetupSheet has no support for {}".format(opName))

    def onDocumentRestored(self, obj):

        if not hasattr(obj, "CoolantModes"):
            obj.addProperty(
                "App::PropertyStringList",
                "CoolantModes",
                "CoolantMode",
                QT_TRANSLATE_NOOP("App::Property", "Coolant Modes"),
            )
            obj.CoolantModes = self.DefaultCoolantModes

        if not hasattr(obj, "CoolantMode"):
            obj.addProperty(
                "App::PropertyEnumeration",
                "CoolantMode",
                "CoolantMode",
                QT_TRANSLATE_NOOP("App::Property", "Default coolant mode."),
            )
            obj.CoolantMode = self.DefaultCoolantModes


def Create(name="SetupSheet"):
    obj = FreeCAD.ActiveDocument.addObject("App::FeaturePython", name)
    obj.Proxy = SetupSheet(obj)
    return obj


class _RegisteredOp(object):
    def __init__(self, factory, properties):
        self.factory = factory
        self.properties = properties

    def registeredPropertyNames(self, name):
        return [OpPropertyName(name, prop) for prop in self.properties()]

    def prototype(self, name):
        ptt = PathSetupSheetOpPrototype.OpPrototype(name)
        self.factory("OpPrototype.%s" % name, ptt)
        return ptt


def RegisterOperation(name, objFactory, setupProperties):
    global _RegisteredOps
    _RegisteredOps[name] = _RegisteredOp(objFactory, setupProperties)


def OpNamePrefix(name):
    return name.replace("Path", "").replace(" ", "").replace("_", "")


def OpPropertyName(opName, propName):
    return "{}{}".format(OpNamePrefix(opName), propName)


def OpPropertyGroup(opName):
    return "Op {}".format(opName)
