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

# ***************************************************************************
# *                                                                         *
# *   based upon linuxcnc_post.py (c) sliptonic (shopinthewoods@gmail.com)  *
# *                                                                         *
# *   changed, but not enough to claim copyrights 2019-2021                 *
# *   maintainer: A.H.M. Steenveld                                          *
# *                                                                         *
# *   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                                                                   *
# *                                                                         *
# ***************************************************************************

# See: https://wiki.freecad.org/Path_Post
#      https://wiki.freecad.org/Path_Postprocessor_Customization
#      for details on post processors like this one.


import FreeCAD
from FreeCAD import Units
import Path
import Path.Base.Util as PathUtil
import PathScripts.PathUtils as PathUtils
import argparse
import datetime

# import shlex
import Path.Post.Utils as PostUtils
from builtins import open as pyopen

VERSION = "0.0.4"

TOOLTIP = """ Post processor for UC-CNC.

This is a postprocessor file for the Path workbench. It is used to
take a pseudo-G-code fragment outputted by a Path object, and output
real G-code. This postprocessor, once placed in the appropriate
Path/Tool folder, can be used directly from inside FreeCAD,
via the GUI importer or via python scripts with:

import UCCNC_post
UCCNC_post.export(object,"/path/to/file.ncc","")

This postprocessor was tested on UC-CNC v1.2111, an UC100 and a Stepcraft 420.
It was tested on FreeCAD v0.17, v0.18 and v0.19

Other (Stepcraft) machines using UC-CNC and UC* controllers should be easy to adapt.
"""

# PREAMBLE_ possible values:
#    Multi line text with gcode. Preamble gcode
#    The preamble text will appear at the beginning of the GCODE output file.
PREAMBLE_DEFAULT = """G17 (Default: XY-plane)
G54 (Default: First coordinate system)
G40 (Default: Cutter radius compensation none)
G49 (Default: Tool Length Offsets: cancel tool length)
G90 (Default: Absolute distance mode selection)
G80 (Cancel canned cycle)
"""

PREAMBLE_DEFAULT_NO_COMMENT = """G17
G54
G40
G49
G90
G80
"""


# POSTAMBLE possible values:
#    Multi line text with gcode. Postable gcode
#    The postamble text will appear following the last operation.
POSTAMBLE_DEFAULT = """M05 (stop spindle)
G17 (Default: XY-plane)
G54 (Default: First coordinate system)
G40 (Default: Cutter radius compensation none)
G90 (Default: Absolute distance mode selection)
G80 (Cancel canned cycle)
M30 (Stop program and rewind code)
"""

POSTAMBLE_DEFAULT_NO_COMMENT = """M05
G17
G54
G40
G90
G80
M30
"""

# PRE_OPERATION: Pre operation text will be inserted before every operation
PRE_OPERATION = """"""

# POST_OPERATION: Post operation text will be inserted after every operation
POST_OPERATION = """"""

# TOOL_CHANGE: Tool Change commands will be inserted before a tool change
TOOL_CHANGE = """"""

################################
# Other configuration settings #
################################

# GCODE_PROCESSOR possible options
#    string     The target GCode processor name.
GCODE_PROCESSOR = "UC-CNC"

# SHOW_EDITOR possible values:
#    bool       Show gcode before saving.
#    True        before the file is written it is shown to the user for inspection
#    False       the file is written directly
#                set with --no-show-editor
if FreeCAD.GuiUp:
    SHOW_EDITOR = True
else:
    SHOW_EDITOR = False

# PROG_NAME possible values:
#   text    Name of the G-Code program
#                set with --name
PROG_NAME = "prog1"

# OUTPUT_HEADER possible values:
#   bool       Use of a document header
#    True        Use a predefined header.
#    False       Do not use a predefined header.
#                set with --no-header
OUTPUT_HEADER = True

# OUTPUT_COMMENTS possible values:
#    bool      (Dont) use comments in output
#    True        Use comments in output.
#    False       Suppress comments in output.
#                set with --no-comment
OUTPUT_COMMENTS = True

# OUTPUT_LINE_NUMBERS possible values:
#    bool      (Dont) use line numbers in output
#    True        Add a line number to each output line.
#    False       Do not add a line number.
#                set with --line-numbers
# note: line numbers Nxxxx are not supported by UC-CNC and are silently ignored.
OUTPUT_LINE_NUMBERS = False

# LINE_NUMBER_START possible values:
#    int    unsigned integer. Line number starting value
#    0..N
LINE_NUMBER_START = 0

# LINE_NUMBER_STEP possible values:
#    int    unsigned integer. Line number increment value
#    1..N
LINE_NUMBER_STEP = 1

# PREAMBLE possible values:
#    Multi line text with gcode. Preamble gcode
#    The preamble text will appear at the beginning of the GCODE output file.
#    set with --preamble
PREAMBLE = PREAMBLE_DEFAULT

# POSTAMBLE possible values:
#    Multi line text with gcode. Postable gcode
#    The postamble text will appear following the last operation.
#    set with --postable
POSTAMBLE = POSTAMBLE_DEFAULT

# MODAL possible values:
#   bool       Repeat/suppress repeated command arguments.
#    True        commands are suppressed if the same as previous line.
#    False       commands are repeated if the same as previous line.
#                set with --modal
MODAL = False

# REPEAT_ARGUMENTS possible values:
#   bool       Duplicate/suppressed axis values from the previous line.
#    True        All arguments are repreated in each command.
#    False       Equal values for arguments from the previous command are not repeated.
#                set with --repeat
REPEAT_ARGUMENTS = False

# USE_TLO possible values:
#   bool        Set tool length offset.
#    True        G43 will be output following tool changes
#    False       No G43 used.
#                set with --tool-length-offset
USE_TLO = False

# PRECISION possible values:
#   int         Number of digits in axis positions
#     0...N
#                set with --precision N
PRECISION = 3

# UNITS possible values:
#   GCODE       Code to switch to specific units
#     G20        US imperial [inch]
#     G21        Metric [mm]
#                set with --inches
# note: G20/G21 are not supported by UC-CNC, units are configured in a program profile.
#       In code G20/G21 commands are silently ignored by UC-CNC
#       UNITS is included in the post processor to mirror the profile settings.
UNITS_US_IMP = "G20"
UNITS_METRIC = "G21"
UNITS = UNITS_METRIC

# UNIT_FORMAT possible values: (see UNITS)
#   text        Text with specific units
#     "inch"     US imperial [inch]
#     "mm"       Metric [mm]
# note: G20/G21 are not supported by UC-CNC, units are configured in a program profile.
#       In code G20/G21 commands are silently ignored by UC-CNC
#       UNITS is included in the post processor to mirror the profile settings.
UNIT_FORMAT_US_IMP = "in"
UNIT_FORMAT_METRIC = "mm"
UNIT_FORMAT = UNIT_FORMAT_METRIC

# UNIT_SPEED_FORMAT possible values: (see UNITS)
#   text         Text with specific units over time units
#     "inch/min"  US imperial [inch]
#     "mm/min"    Metric [mm]
# note: G20/G21 are not supported by UC-CNC, units are configured in a program profile.
#       In code G20/G21 commands are silently ignored by UC-CNC
#       UNITS is included in the post processor to mirror the profile settings.
UNIT_SPEED_FORMAT_US_IMP = "in/min"
UNIT_SPEED_FORMAT_METRIC = "mm/min"
UNIT_SPEED_FORMAT = UNIT_SPEED_FORMAT_METRIC

##################################################
# No more configuration settings after this line #
##################################################

# see: https://docs.python.org/3/library/argparse.html
parser = argparse.ArgumentParser(prog=__name__, add_help=False)
parser.add_argument("--name", help="GCode program name")
parser.add_argument("--no-header", action="store_true", help="suppress header output")
parser.add_argument("--no-comments", action="store_true", help="suppress comment output")
parser.add_argument("--line-numbers", action="store_true", help="suppress prefix with line numbers")
parser.add_argument(
    "--no-show-editor",
    action="store_true",
    help="don't pop up editor before writing output",
)
parser.add_argument("--precision", default="3", help="number of digits of precision, default=3")
parser.add_argument(
    "--preamble",
    help='set commands to be issued before the first command, default="G17\\nG54\\G40\\nG49\\nG90\\nG80\\n"',
)
parser.add_argument(
    "--postamble",
    help='set commands to be issued after the last command, default="M05\\nG17\\nG54\\nG0\\nG90\\nG80\\nM30\\n"',
)
parser.add_argument("--inches", action="store_true", help="lengths in [in], G20")
parser.add_argument("--metric", action="store_true", help="lengths in [mm], G21")
parser.add_argument(
    "--modal", action="store_true", help="repeat/suppress repeated command arguments"
)
parser.add_argument(
    "--tool-length-offset",
    action="store_true",
    help="suppress tool length offset G43 following tool changes",
)
parser.add_argument("--repeat", action="store_true", help="repeat axis arguments")
TOOLTIP_ARGS = parser.format_help()


# debug option, trace to screen while processing to see where things break up.
trace_gcode = False

now = datetime.datetime.now()

LINENR = 0
COMMAND_SPACE = " "
UNIT_DEFAULT_CHANGED = False

# counting warnings and problems.
# Each warning/problem will appear as a WARNING:/PROBLEM: comment in the GCode output.
warnings_count = 0
problems_count = 0

HEADER = """(Exported by FreeCAD for {})
(Post Processor: {}, version {})
(CAM file: {})
(Output Time: {})
"""


def processArguments(argstring):
    global SHOW_EDITOR  # Show gcode before saving.
    global PROG_NAME  # Name of the G-Code program
    global OUTPUT_HEADER  # Use of a document header
    global OUTPUT_COMMENTS  # (Dont) use comments in output
    global OUTPUT_LINE_NUMBERS  # (Dont) use line numbers in output
    global PREAMBLE  # Preamble gcode
    global POSTAMBLE  # Postable gcode
    global MODAL  # Repeat/suppress repeated command arguments.
    global USE_TLO  # Set tool length offset
    global PRECISION  # Number of digits in feed and axis values
    global UNITS  # Code to switch to specific units
    global UNIT_FORMAT  # Text with specific units
    global UNIT_SPEED_FORMAT  # Text with specific units over time units
    global UNIT_DEFAULT_CHANGED  # tracing changes in UNIT settings.
    global REPEAT_ARGUMENTS  # Repeat or suppress axis values if the same as previous line.

    try:
        UNIT_DEFAULT_CHANGED = False
        args = parser.parse_args(argstring.split())

        if args.name is not None:
            PROG_NAME = args.name

        if args.no_header:
            OUTPUT_HEADER = False

        if args.no_comments:
            OUTPUT_COMMENTS = False

        if args.line_numbers:
            OUTPUT_LINE_NUMBERS = True

        if args.no_show_editor:
            SHOW_EDITOR = False

        PRECISION = args.precision

        if args.preamble is not None:
            PREAMBLE = args.preamble.replace("\\n", "\n")
        elif OUTPUT_COMMENTS:
            PREAMBLE = PREAMBLE_DEFAULT
        else:
            PREAMBLE = PREAMBLE_DEFAULT_NO_COMMENT

        if args.postamble is not None:
            POSTAMBLE = args.postamble.replace("\\n", "\n")
        elif OUTPUT_COMMENTS:
            POSTAMBLE = POSTAMBLE_DEFAULT
        else:
            POSTAMBLE = POSTAMBLE_DEFAULT_NO_COMMENT

        if args.inches and (UNITS != UNITS_US_IMP):
            print("Units: US Imperial [inch], check your UC-CNC profile.")
            UNITS = UNITS_US_IMP
            UNIT_FORMAT = UNIT_FORMAT_US_IMP
            UNIT_SPEED_FORMAT = UNIT_SPEED_FORMAT_US_IMP
            UNIT_DEFAULT_CHANGED = True

        if args.metric and (UNITS != UNITS_METRIC):
            print("Units: Metric [mm], check your UC-CNC profile.")
            UNITS = UNITS_METRIC
            UNIT_FORMAT = UNIT_FORMAT_METRIC
            UNIT_SPEED_FORMAT = UNIT_SPEED_FORMAT_METRIC
            UNIT_DEFAULT_CHANGED = True

        if args.modal:
            MODAL = True

        if args.tool_length_offset:
            USE_TLO = True

        if args.repeat:
            REPEAT_ARGUMENTS = True

    except Exception:
        return False

    return True


def append0(line):
    result = line
    if trace_gcode:
        print("export: >>" + result)
    return result


def append(line):
    result = linenumber() + line
    if trace_gcode:
        print("export: >>" + result)
    return result


def export(objectslist, filename, argstring):

    if not processArguments(argstring):
        print("export: process arguments failed, '{}'".format(argstring))
        return None

    global warnings_count
    global problems_count

    warnings_count = 0
    problems_count = 0

    for obj in objectslist:
        if not hasattr(obj, "Path"):
            print(
                "the object " + obj.Name + " is not a path. Please select only path and Compounds."
            )
            return None

    print("export: postprocessing...")
    gcode = append0("%" + PROG_NAME + "\n")
    if not argstring:
        gcode += append("(" + __name__ + " with default settings)\n")
    else:
        gcode += append("({} {})\n".format(__name__, argstring))

    # write header
    if OUTPUT_HEADER:
        for line in HEADER.format(
            GCODE_PROCESSOR,
            __name__,
            VERSION,
            FreeCAD.ActiveDocument.FileName,
            str(now),
        ).splitlines(False):
            if line:
                gcode += append(line + "\n")

    # Write the preamble
    # G20/G21 not supported by UC-CNC, *always* report the configured units.
    gcode += append("(Units: '" + UNIT_FORMAT + "' and '" + UNIT_SPEED_FORMAT + "')\n")
    if UNIT_DEFAULT_CHANGED:
        gcode += append("(WARNING: Units default changed, check your UC-CNC profile)\n")
        warnings_count += 1

    if OUTPUT_COMMENTS:
        gcode += append("(preamble: begin)\n")
        # for obj in objectslist:
        #    if isinstance(obj.Proxy, Path.Tool.Controller.ToolController):
        #        gcode += append("(T{}={})\n".format(obj.ToolNumber, item.Name))
        # error: global name 'PathScripts' is not defined
    for line in PREAMBLE.splitlines():
        gcode += append(line + "\n")
    if OUTPUT_COMMENTS:
        gcode += append("(preamble: done)\n")

    # write the code body
    for obj in objectslist:

        # Skip inactive operations
        if not PathUtil.activeForOp(obj):
            continue

        # pre_op
        if OUTPUT_COMMENTS:
            gcode += append("(operation initialise: %s)\n" % obj.Label)
        for line in PRE_OPERATION.splitlines(True):
            gcode += append(line)

        # turn coolant on if required
        coolantMode = PathUtil.coolantModeForOp(obj)
        if coolantMode == "Mist":
            if OUTPUT_COMMENTS:
                gcode += append("M7 (coolant: mist on)\n")
            else:
                gcode += append("M7\n")
        if coolantMode == "Flood":
            if OUTPUT_COMMENTS:
                gcode += append("M8 (coolant: flood on)\n")
            else:
                gcode += append("M8\n")

        # process the operation gcode
        if OUTPUT_COMMENTS:
            gcode += append("(operation start: %s)\n" % obj.Label)
        gcode += parse(obj)
        if OUTPUT_COMMENTS:
            gcode += append("(operation done: %s)\n" % obj.Label)

        # post_op
        for line in POST_OPERATION.splitlines(True):
            gcode += append(line)

        # turn coolant off if required
        if not coolantMode == "None":
            if OUTPUT_COMMENTS:
                gcode += append("M9 (coolant: off)\n")
            else:
                gcode += append("M9\n")

        if OUTPUT_COMMENTS:
            gcode += append("(operation finalised: %s)\n" % obj.Label)

    # do the post_amble
    if OUTPUT_COMMENTS:
        gcode += append("(postamble: begin)\n")
    for line in POSTAMBLE.splitlines(True):
        gcode += append(line)
    if OUTPUT_COMMENTS:
        gcode += append("(postamble: done)\n")

    # Show the results
    if SHOW_EDITOR:
        dia = PostUtils.GCodeEditorDialog()
        dia.editor.setText(gcode)
        result = dia.exec_()
        if result:
            final = dia.editor.toPlainText()
        else:
            final = gcode
    else:
        final = gcode

    if (0 < problems_count) or (0 < warnings_count):
        print(
            "export: postprocessing: done, warnings: {}, problems: {}, see GCode for details.".format(
                warnings_count, problems_count
            )
        )
    else:
        print("export: postprocessing: done (none of the problems detected).")

    if not filename == "-":
        print("export: writing to '{}'".format(filename))
        gfile = pyopen(filename, "w")
        gfile.write(final)
        gfile.close()

    return final


def linenumber():
    global LINENR

    if LINENR <= 0:
        LINENR = LINE_NUMBER_START
    if OUTPUT_LINE_NUMBERS is True:
        line = LINENR
        LINENR += LINE_NUMBER_STEP
        return "N{:03d} ".format(line)
    return ""


def parse(pathobj):
    out = ""
    lastcommand = None
    precision_string = "." + str(PRECISION) + "f"
    currLocation = {}  # keep track for no doubles

    # Command which can not skip parameters
    mandatoryCmd = ("G81", "G82", "G83")

    # Int parameters which can not be skipped
    mandatoryParamInt = ("D", "H", "S", "T")

    # Float parameters which can not be skipped
    mandatoryParamFloat = ("I", "J", "K")

    # The params list control the order of parameters
    params = ("X", "Y", "Z", "A", "B", "C", "I", "J", "K", "R", "F", "S", "T", "H", "L", "Q")
    firstmove = Path.Command("G0", {"X": -1, "Y": -1, "Z": -1, "F": 0.0})
    currLocation.update(firstmove.Parameters)  # set First location Parameters

    if hasattr(pathobj, "Group"):
        # We have a compound or project.

        # if OUTPUT_COMMENTS:
        #    out += linenumber() + "(compound: " + pathobj.Label + ")\n"
        for p in pathobj.Group:
            out += parse(p)
        return out
    else:
        # parsing simple path

        # groups might contain non-path things like stock.
        if not hasattr(pathobj, "Path"):
            return out

        # if OUTPUT_COMMENTS:
        #    out += linenumber() + "(" + pathobj.Label + ")\n"

        for c in PathUtils.getPathWithPlacement(pathobj).Commands:
            commandlist = []  # list of elements in the command, code and params.
            command = c.Name.strip()  # command M or G code or comment string
            commandlist.append(command)

            # if modal: only print the command if it is not the same as the last one
            if MODAL is True:
                if command == lastcommand:
                    commandlist.pop(0)

            if c.Name.startswith("(") and not OUTPUT_COMMENTS:  # command is a comment
                continue

            # Now add the remaining parameters in order
            for param in params:
                if param in c.Parameters:
                    if param == "F" and (
                        currLocation[param] != c.Parameters[param] or REPEAT_ARGUMENTS
                    ):
                        if c.Name not in ["G0", "G00"]:  # No F in G0
                            speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity)
                            if speed.getValueAs(UNIT_SPEED_FORMAT) > 0.0:
                                commandlist.append(
                                    param
                                    + format(
                                        float(speed.getValueAs(UNIT_SPEED_FORMAT)),
                                        precision_string,
                                    )
                                )
                        else:
                            continue
                    elif param in mandatoryParamInt:
                        commandlist.append(param + str(int(c.Parameters[param])))
                    elif param in mandatoryParamFloat:
                        commandlist.append(
                            param + format(float(c.Parameters[param]), precision_string)
                        )
                    else:
                        if (
                            (not REPEAT_ARGUMENTS and c.Name not in mandatoryCmd)
                            and (param in currLocation)
                            and (currLocation[param] == c.Parameters[param])
                        ):
                            continue
                        else:
                            pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length)
                            commandlist.append(
                                param + format(float(pos.getValueAs(UNIT_FORMAT)), precision_string)
                            )

            # store the latest command
            lastcommand = command
            currLocation.update(c.Parameters)

            # Check for Tool Change:
            if command == "M6":
                for line in TOOL_CHANGE.splitlines(True):
                    out += linenumber() + line

                # add height offset
                if USE_TLO:
                    tool_height = "\nG43 H" + str(int(c.Parameters["T"]))
                    commandlist.append(tool_height)

            if command == "message":
                if OUTPUT_COMMENTS is False:
                    out = []
                else:
                    commandlist.pop(0)  # remove the command

            # prepend a line number and append a newline
            if len(commandlist) >= 1:
                if OUTPUT_LINE_NUMBERS:
                    commandlist.insert(0, (linenumber()))

                # append the line to the final output
                for w in commandlist:
                    out += w.strip() + COMMAND_SPACE
                if trace_gcode:
                    print("parse : >>{}".format(out))
                out = out.strip() + "\n"

        return out
