# ChangeAtZ script - Change printing parameters at a given height
# This script is the successor of the TweakAtZ plugin for legacy Cura.
# It contains code from the TweakAtZ plugin V1.0-V4.x and from the ExampleScript by Jaime van Kessel, Ultimaker B.V.
# It runs with the PostProcessingPlugin which is released under the terms of the AGPLv3 or higher.
# This script is licensed under the Creative Commons - Attribution - Share Alike (CC BY-SA) terms

# Authors of the ChangeAtZ plugin / script:
# Written by Steven Morlock, smorloc@gmail.com
# Modified by Ricardo Gomez, ricardoga@otulook.com, to add Bed Temperature and make it work with Cura_13.06.04+
# Modified by Stefan Heule, Dim3nsioneer@gmx.ch since V3.0 (see changelog below)
# Modified by Jaime van Kessel (Ultimaker), j.vankessel@ultimaker.com to make it work for 15.10 / 2.x
# Modified by Ghostkeeper (Ultimaker), rubend@tutanota.com, to debug.
# Modified by Wes Hanney, https://github.com/novamxd, Retract Length + Speed, Clean up
# Modified by Alex Jaxon, https://github.com/legend069, Added option to modify Build Volume Temperature


# history / changelog:
# V3.0.1:   TweakAtZ-state default 1 (i.e. the plugin works without any TweakAtZ comment)
# V3.1:     Recognizes UltiGCode and deactivates value reset, fan speed added, alternatively layer no. to tweak at,
# extruder three temperature disabled by "#Ex3"
# V3.1.1:   Bugfix reset flow rate
# V3.1.2:   Bugfix disable TweakAtZ on Cool Head Lift
# V3.2:     Flow rate for specific extruder added (only for 2 extruders), bugfix parser,
# added speed reset at the end of the print
# V4.0:     Progress bar, tweaking over multiple layers, M605&M606 implemented, reset after one layer option,
# extruder three code removed, tweaking print speed, save call of Publisher class,
# uses previous value from other plugins also on UltiGCode
# V4.0.1:	Bugfix for doubled G1 commands
# V4.0.2:	Uses Cura progress bar instead of its own
# V4.0.3:	Bugfix for cool head lift (contributed by luisonoff)
# V4.9.91:	First version for Cura 15.06.x and PostProcessingPlugin
# V4.9.92:	Modifications for Cura 15.10
# V4.9.93:	Minor bugfixes (input settings) / documentation
# V4.9.94:	Bugfix Combobox-selection; remove logger
# V5.0:		Bugfix for fall back after one layer and doubled G0 commands when using print speed tweak, Initial version for Cura 2.x
# V5.0.1:	Bugfix for calling unknown property 'bedTemp' of previous settings storage and unknown variable 'speed'
# V5.1:		API Changes included for use with Cura 2.2
# V5.2.0:	Wes Hanney. Added support for changing Retract Length and Speed. Removed layer spread option. Fixed issue of cumulative ChangeAtZ
# mods so they can now properly be stacked on top of each other. Applied code refactoring to clean up various coding styles. Added comments.
# Broke up functions for clarity. Split up class so it can be debugged outside of Cura.
# V5.2.1:	Wes Hanney. Added support for firmware based retractions. Fixed issue of properly restoring previous values in single layer option.
# Added support for outputting changes to LCD (untested). Added type hints to most functions and variables. Added more comments. Created GCodeCommand
# class for better detection of G1 vs G10 or G11 commands, and accessing arguments. Moved most GCode methods to GCodeCommand class. Improved wording
# of Single Layer vs Keep Layer to better reflect what was happening.
# V5.3.0    Alex Jaxon, Added option to modify Build Volume Temperature keeping current format
#



# Uses -
# M220 S<factor in percent> - set speed factor override percentage
# M221 S<factor in percent> - set flow factor override percentage
# M221 S<factor in percent> T<0-#toolheads> - set flow factor override percentage for single extruder
# M104 S<temp> T<0-#toolheads> - set extruder <T> to target temperature <S>
# M140 S<temp> - set bed target temperature
# M106 S<PWM> - set fan speed to target speed <S>
# M207 S<mm> F<mm/m> - set the retract length <S> or feed rate <F>
# M117 - output the current changes

from typing import List, Dict
from ..Script import Script
import re


# this was broken up into a separate class so the main ChangeAtZ script could be debugged outside of Cura
class ChangeAtZ(Script):
    version = "5.3.0"

    def getSettingDataString(self):
        return """{
            "name": "ChangeAtZ """ + self.version + """(Experimental)",
            "key": "ChangeAtZ",
            "metadata": {},
            "version": 2,
            "settings": {
                "caz_enabled": {
                    "label": "Enabled",
                    "description": "Allows adding multiple ChangeAtZ mods and disabling them as needed.",
                    "type": "bool",
                    "default_value": true
                },
                "a_trigger": {
                    "label": "Trigger",
                    "description": "Trigger at height or at layer no.",
                    "type": "enum",
                    "options": {
                        "height": "Height",
                        "layer_no": "Layer No."
                    },
                    "default_value": "height"
                },
                "b_targetZ": {
                    "label": "Change Height",
                    "description": "Z height to change at",
                    "unit": "mm",
                    "type": "float",
                    "default_value": 5.0,
                    "minimum_value": "0",
                    "minimum_value_warning": "0.1",
                    "maximum_value_warning": "230",
                    "enabled": "a_trigger == 'height'"
                },
                "b_targetL": {
                    "label": "Change Layer",
                    "description": "Layer no. to change at",
                    "unit": "",
                    "type": "int",
                    "default_value": 1,
                    "minimum_value": "-100",
                    "minimum_value_warning": "-1",
                    "enabled": "a_trigger == 'layer_no'"
                },
                "c_behavior": {
                    "label": "Apply To",
                    "description": "Target Layer + Subsequent Layers is good for testing changes between ranges of layers, ex: Layer 0 to 10 or 0mm to 5mm. Single layer is good for testing changes at a single layer, ex: at Layer 10 or 5mm only.",
                    "type": "enum",
                    "options": {
                        "keep_value": "Target Layer + Subsequent Layers",
                        "single_layer": "Target Layer Only"
                    },
                    "default_value": "keep_value"
                },
                "caz_output_to_display": {
                    "label": "Output to Display",
                    "description": "Displays the current changes to the LCD",
                    "type": "bool",
                    "default_value": false
                },
                "e1_Change_speed": {
                    "label": "Change Speed",
                    "description": "Select if total speed (print and travel) has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "e2_speed": {
                    "label": "Speed",
                    "description": "New total speed (print and travel)",
                    "unit": "%",
                    "type": "int",
                    "default_value": 100,
                    "minimum_value": "1",
                    "minimum_value_warning": "10",
                    "maximum_value_warning": "200",
                    "enabled": "e1_Change_speed"
                },
                "f1_Change_printspeed": {
                    "label": "Change Print Speed",
                    "description": "Select if print speed has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "f2_printspeed": {
                    "label": "Print Speed",
                    "description": "New print speed",
                    "unit": "%",
                    "type": "int",
                    "default_value": 100,
                    "minimum_value": "1",
                    "minimum_value_warning": "10",
                    "maximum_value_warning": "200",
                    "enabled": "f1_Change_printspeed"
                },
                "g1_Change_flowrate": {
                    "label": "Change Flow Rate",
                    "description": "Select if flow rate has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "g2_flowrate": {
                    "label": "Flow Rate",
                    "description": "New Flow rate",
                    "unit": "%",
                    "type": "int",
                    "default_value": 100,
                    "minimum_value": "1",
                    "minimum_value_warning": "10",
                    "maximum_value_warning": "200",
                    "enabled": "g1_Change_flowrate"
                },
                "g3_Change_flowrateOne": {
                    "label": "Change Flow Rate 1",
                    "description": "Select if first extruder flow rate has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "g4_flowrateOne": {
                    "label": "Flow Rate One",
                    "description": "New Flow rate Extruder 1",
                    "unit": "%",
                    "type": "int",
                    "default_value": 100,
                    "minimum_value": "1",
                    "minimum_value_warning": "10",
                    "maximum_value_warning": "200",
                    "enabled": "g3_Change_flowrateOne"
                },
                "g5_Change_flowrateTwo": {
                    "label": "Change Flow Rate 2",
                    "description": "Select if second extruder flow rate has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "g6_flowrateTwo": {
                    "label": "Flow Rate two",
                    "description": "New Flow rate Extruder 2",
                    "unit": "%",
                    "type": "int",
                    "default_value": 100,
                    "minimum_value": "1",
                    "minimum_value_warning": "10",
                    "maximum_value_warning": "200",
                    "enabled": "g5_Change_flowrateTwo"
                },
                "h1_Change_bedTemp": {
                    "label": "Change Bed Temp",
                    "description": "Select if Bed Temperature has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "h2_bedTemp": {
                    "label": "Bed Temp",
                    "description": "New Bed Temperature",
                    "unit": "C",
                    "type": "float",
                    "default_value": 60,
                    "minimum_value": "0",
                    "minimum_value_warning": "30",
                    "maximum_value_warning": "120",
                    "enabled": "h1_Change_bedTemp"
                },
                "h1_Change_buildVolumeTemperature": {
                    "label": "Change Build Volume Temperature",
                    "description": "Select if Build Volume Temperature has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "h2_buildVolumeTemperature": {
                    "label": "Build Volume Temperature",
                    "description": "New Build Volume Temperature",
                    "unit": "C",
                    "type": "float",
                    "default_value": 20,
                    "minimum_value": "0",
                    "minimum_value_warning": "10",
                    "maximum_value_warning": "50",
                    "enabled": "h1_Change_buildVolumeTemperature"
                },
                "i1_Change_extruderOne": {
                    "label": "Change Extruder 1 Temp",
                    "description": "Select if First Extruder Temperature has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "i2_extruderOne": {
                    "label": "Extruder 1 Temp",
                    "description": "New First Extruder Temperature",
                    "unit": "C",
                    "type": "float",
                    "default_value": 190,
                    "minimum_value": "0",
                    "minimum_value_warning": "160",
                    "maximum_value_warning": "250",
                    "enabled": "i1_Change_extruderOne"
                },
                "i3_Change_extruderTwo": {
                    "label": "Change Extruder 2 Temp",
                    "description": "Select if Second Extruder Temperature has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "i4_extruderTwo": {
                    "label": "Extruder 2 Temp",
                    "description": "New Second Extruder Temperature",
                    "unit": "C",
                    "type": "float",
                    "default_value": 190,
                    "minimum_value": "0",
                    "minimum_value_warning": "160",
                    "maximum_value_warning": "250",
                    "enabled": "i3_Change_extruderTwo"
                },
                "j1_Change_fanSpeed": {
                    "label": "Change Fan Speed",
                    "description": "Select if Fan Speed has to be changed",
                    "type": "bool",
                    "default_value": false
                },
                "j2_fanSpeed": {
                    "label": "Fan Speed",
                    "description": "New Fan Speed (0-100)",
                    "unit": "%",
                    "type": "int",
                    "default_value": 100,
                    "minimum_value": "0",
                    "minimum_value_warning": "0",
                    "maximum_value_warning": "100",
                    "enabled": "j1_Change_fanSpeed"
                },
                "caz_change_retract": {
                    "label": "Change Retraction",
                    "description": "Indicates you would like to modify retraction properties.",
                    "type": "bool",
                    "default_value": false
                },
                "caz_retractstyle": {
                    "label": "Retract Style",
                    "description": "Specify if you're using firmware retraction or linear move based retractions. Check your printer settings to see which you're using.",
                    "type": "enum",
                    "options": {
                        "linear": "Linear Move",
                        "firmware": "Firmware"
                    },
                    "default_value": "linear",
                    "enabled": "caz_change_retract"
                },
                "caz_change_retractfeedrate": {
                    "label": "Change Retract Feed Rate",
                    "description": "Changes the retraction feed rate during print",
                    "type": "bool",
                    "default_value": false,
                    "enabled": "caz_change_retract"
                },
                "caz_retractfeedrate": {
                    "label": "Retract Feed Rate",
                    "description": "New Retract Feed Rate (mm/s)",
                    "unit": "mm/s",
                    "type": "float",
                    "default_value": 40,
                    "minimum_value": "0",
                    "minimum_value_warning": "0",
                    "maximum_value_warning": "100",
                    "enabled": "caz_change_retractfeedrate"
                },
                "caz_change_retractlength": {
                    "label": "Change Retract Length",
                    "description": "Changes the retraction length during print",
                    "type": "bool",
                    "default_value": false,
                    "enabled": "caz_change_retract"
                },
                "caz_retractlength": {
                    "label": "Retract Length",
                    "description": "New Retract Length (mm)",
                    "unit": "mm",
                    "type": "float",
                    "default_value": 6,
                    "minimum_value": "0",
                    "minimum_value_warning": "0",
                    "maximum_value_warning": "20",
                    "enabled": "caz_change_retractlength"
                }      
            }
        }"""

    def __init__(self):
        super().__init__()

    def execute(self, data):

        caz_instance = ChangeAtZProcessor()

        caz_instance.targetValues = {}

        # copy over our settings to our change z class
        self.setIntSettingIfEnabled(caz_instance, "e1_Change_speed", "speed", "e2_speed")
        self.setIntSettingIfEnabled(caz_instance, "f1_Change_printspeed", "printspeed", "f2_printspeed")
        self.setIntSettingIfEnabled(caz_instance, "g1_Change_flowrate", "flowrate", "g2_flowrate")
        self.setIntSettingIfEnabled(caz_instance, "g3_Change_flowrateOne", "flowrateOne", "g4_flowrateOne")
        self.setIntSettingIfEnabled(caz_instance, "g5_Change_flowrateTwo", "flowrateTwo", "g6_flowrateTwo")
        self.setFloatSettingIfEnabled(caz_instance, "h1_Change_bedTemp", "bedTemp", "h2_bedTemp")
        self.setFloatSettingIfEnabled(caz_instance, "h1_Change_buildVolumeTemperature", "buildVolumeTemperature", "h2_buildVolumeTemperature")
        self.setFloatSettingIfEnabled(caz_instance, "i1_Change_extruderOne", "extruderOne", "i2_extruderOne")
        self.setFloatSettingIfEnabled(caz_instance, "i3_Change_extruderTwo", "extruderTwo", "i4_extruderTwo")
        self.setIntSettingIfEnabled(caz_instance, "j1_Change_fanSpeed", "fanSpeed", "j2_fanSpeed")
        self.setFloatSettingIfEnabled(caz_instance, "caz_change_retractfeedrate", "retractfeedrate", "caz_retractfeedrate")
        self.setFloatSettingIfEnabled(caz_instance, "caz_change_retractlength", "retractlength", "caz_retractlength")

        # is this mod enabled?
        caz_instance.enabled = self.getSettingValueByKey("caz_enabled")

        # are we emitting data to the LCD?
        caz_instance.displayChangesToLcd = self.getSettingValueByKey("caz_output_to_display")

        # are we doing linear move retractions?
        caz_instance.linearRetraction = self.getSettingValueByKey("caz_retractstyle") == "linear"

        # see if we're applying to a single layer or to all layers hence forth
        caz_instance.applyToSingleLayer = self.getSettingValueByKey("c_behavior") == "single_layer"

        # used for easy reference of layer or height targeting
        caz_instance.targetByLayer = self.getSettingValueByKey("a_trigger") == "layer_no"

        # change our target based on what we're targeting
        caz_instance.targetLayer = self.getIntSettingByKey("b_targetL", None)
        caz_instance.targetZ = self.getFloatSettingByKey("b_targetZ", None)

        # run our script
        return caz_instance.execute(data)

    # Sets the given TargetValue in the ChangeAtZ instance if the trigger is specified
    def setIntSettingIfEnabled(self, caz_instance, trigger, target, setting):

        # stop here if our trigger isn't enabled
        if not self.getSettingValueByKey(trigger):
            return

        # get our value from the settings
        value = self.getIntSettingByKey(setting, None)

        # skip if there's no value or we can't interpret it
        if value is None:
            return

        # set our value in the target settings
        caz_instance.targetValues[target] = value

    # Sets the given TargetValue in the ChangeAtZ instance if the trigger is specified
    def setFloatSettingIfEnabled(self, caz_instance, trigger, target, setting):

        # stop here if our trigger isn't enabled
        if not self.getSettingValueByKey(trigger):
            return

        # get our value from the settings
        value = self.getFloatSettingByKey(setting, None)

        # skip if there's no value or we can't interpret it
        if value is None:
            return

        # set our value in the target settings
        caz_instance.targetValues[target] = value

    # Returns the given settings value as an integer or the default if it cannot parse it
    def getIntSettingByKey(self, key, default):

        # change our target based on what we're targeting
        try:
            return int(self.getSettingValueByKey(key))
        except:
            return default

    # Returns the given settings value as an integer or the default if it cannot parse it
    def getFloatSettingByKey(self, key, default):

        # change our target based on what we're targeting
        try:
            return float(self.getSettingValueByKey(key))
        except:
            return default


# This is a utility class for getting details of gcodes from a given line
class GCodeCommand:

    # The GCode command itself (ex: G10)
    command = None,

    # Contains any arguments passed to the command. The key is the argument name, the value is the value of the argument.
    arguments = {}

    # Contains the components of the command broken into pieces
    components = []

    # Constructor. Sets up defaults
    def __init__(self):
        self.reset()

    # Gets a GCode Command from the given single line of GCode
    @staticmethod
    def getFromLine(line: str):

        # obviously if we don't have a command, we can't return anything
        if line is None or len(line) == 0:
            return None

        # we only support G or M commands
        if line[0] != "G" and line[0] != "M":
            return None

        # remove any comments
        line = re.sub(r";.*$", "", line)

        # break into the individual components
        command_pieces = line.strip().split(" ")

        # our return command details
        command = GCodeCommand()

        # stop here if we don't even have something to interpret
        if len(command_pieces) == 0:
            return None

        # stores all the components of the command within the class for later
        command.components = command_pieces

        # set the actual command
        command.command = command_pieces[0]

        # stop here if we don't have any parameters
        if len(command_pieces) == 1:
            return None

        # return our indexed command
        return command

    # Handy function for reading a linear move command
    @staticmethod
    def getLinearMoveCommand(line: str):

        # get our command from the line
        linear_command = GCodeCommand.getFromLine(line)

        # if it's not a linear move, we don't care
        if linear_command is None or (linear_command.command != "G0" and linear_command.command != "G1"):
            return None

        # convert our values to floats (or defaults)
        linear_command.arguments["F"] = linear_command.getArgumentAsFloat("F", None)
        linear_command.arguments["X"] = linear_command.getArgumentAsFloat("X", None)
        linear_command.arguments["Y"] = linear_command.getArgumentAsFloat("Y", None)
        linear_command.arguments["Z"] = linear_command.getArgumentAsFloat("Z", None)
        linear_command.arguments["E"] = linear_command.getArgumentAsFloat("E", None)

        # return our new command
        return linear_command

    # Gets the value of a parameter or returns the default if there is none
    def getArgument(self, name: str, default: str = None) -> str:

        # parse our arguments (only happens once)
        self.parseArguments()

        # if we don't have the parameter, return the default
        if name not in self.arguments:
            return default

        # otherwise return the value
        return self.arguments[name]

    # Gets the value of a parameter as a float or returns the default
    def getArgumentAsFloat(self, name: str, default: float = None) -> float:

        # try to parse as a float, otherwise return the default
        try:
            return float(self.getArgument(name, default))
        except:
            return default

    # Gets the value of a parameter as an integer or returns the default
    def getArgumentAsInt(self, name: str, default: int = None) -> int:

        # try to parse as a integer, otherwise return the default
        try:
            return int(self.getArgument(name, default))
        except:
            return default

    # Allows retrieving values from the given GCODE line
    @staticmethod
    def getDirectArgument(line: str, key: str, default: str = None) -> str:

        if key not in line or (";" in line and line.find(key) > line.find(";") and ";ChangeAtZ" not in key and ";LAYER:" not in key):
            return default

        # allows for string lengths larger than 1
        sub_part = line[line.find(key) + len(key):]

        if ";ChangeAtZ" in key:
            m = re.search("^[0-4]", sub_part)
        elif ";LAYER:" in key:
            m = re.search("^[+-]?[0-9]*", sub_part)
        else:
            # the minus at the beginning allows for negative values, e.g. for delta printers
            m = re.search(r"^[-]?[0-9]*\.?[0-9]*", sub_part)
        if m is None:
            return default

        try:
            return m.group(0)
        except:
            return default

    # Converts the command parameter to a int or returns the default
    @staticmethod
    def getDirectArgumentAsFloat(line: str, key: str, default: float = None) -> float:

        # get the value from the command
        value = GCodeCommand.getDirectArgument(line, key, default)

        # stop here if it's the default
        if value == default:
            return value

        try:
            return float(value)
        except:
            return default

    # Converts the command parameter to a int or returns the default
    @staticmethod
    def getDirectArgumentAsInt(line: str, key: str, default: int = None) -> int:

        # get the value from the command
        value = GCodeCommand.getDirectArgument(line, key, default)

        # stop here if it's the default
        if value == default:
            return value

        try:
            return int(value)
        except:
            return default

    # Parses the arguments of the command on demand, only once
    def parseArguments(self):

        # stop here if we don't have any remaining components
        if len(self.components) <= 1:
            return None

        # iterate and index all of our parameters, skip the first component as it's the command
        for i in range(1, len(self.components)):

            # get our component
            component = self.components[i]

            # get the first character of the parameter, which is the name
            component_name = component[0]

            # get the value of the parameter (the rest of the string
            component_value = None

            # get our value if we have one
            if len(component) > 1:
                component_value = component[1:]

            # index the argument
            self.arguments[component_name] = component_value

        # clear the components to we don't process again
        self.components = []

    # Easy function for replacing any GCODE parameter variable in a given GCODE command
    @staticmethod
    def replaceDirectArgument(line: str, key: str, value: str) -> str:
        return re.sub(r"(^|\s)" + key + r"[\d\.]+(\s|$)", r"\1" + key + str(value) + r"\2", line)

    # Resets the model back to defaults
    def reset(self):
        self.command = None
        self.arguments = {}


# The primary ChangeAtZ class that does all the gcode editing. This was broken out into an
# independent class so it could be debugged using a standard IDE
class ChangeAtZProcessor:

    # Holds our current height
    currentZ = None

    # Holds our current layer number
    currentLayer = None

    # Indicates if we're only supposed to apply our settings to a single layer or multiple layers
    applyToSingleLayer = False

    # Indicates if this should emit the changes as they happen to the LCD
    displayChangesToLcd = False

    # Indicates that this mod is still enabled (or not)
    enabled = True

    # Indicates if we're processing inside the target layer or not
    insideTargetLayer = False

    # Indicates if we have restored the previous values from before we started our pass
    lastValuesRestored = False

    # Indicates if the user has opted for linear move retractions or firmware retractions
    linearRetraction = True

    # Indicates if we're targeting by layer or height value
    targetByLayer = True

    # Indicates if we have injected our changed values for the given layer yet
    targetValuesInjected = False

    # Holds the last extrusion value, used with detecting when a retraction is made
    lastE = None

    # An index of our gcodes which we're monitoring
    lastValues = {}

    # The detected layer height from the gcode
    layerHeight = None

    # The target layer
    targetLayer = None

    # Holds the values the user has requested to change
    targetValues = {}

    # The target height in mm
    targetZ = None

    # Used to track if we've been inside our target layer yet
    wasInsideTargetLayer = False

    # boots up the class with defaults
    def __init__(self):
        self.reset()

    # Modifies the given GCODE and injects the commands at the various targets
    def execute(self, data):

        # short cut the whole thing if we're not enabled
        if not self.enabled:
            return data

        # our layer cursor
        index = 0

        for active_layer in data:

            # will hold our updated gcode
            modified_gcode = ""

            # mark all the defaults for deletion
            active_layer = self.markChangesForDeletion(active_layer)

            # break apart the layer into commands
            lines = active_layer.split("\n")

            # evaluate each command individually
            for line in lines:

                # trim or command
                line = line.strip()

                # skip empty lines
                if len(line) == 0:
                    continue

                # update our layer number if applicable
                self.processLayerNumber(line)

                # update our layer height if applicable
                self.processLayerHeight(line)

                # check if we're at the target layer or not
                self.processTargetLayer()

                # process any changes to the gcode
                modified_gcode += self.processLine(line)

            # remove any marked defaults
            modified_gcode = self.removeMarkedChanges(modified_gcode)

            # append our modified line
            data[index] = modified_gcode

            index += 1

        # return our modified gcode
        return data

    # Builds the restored layer settings based on the previous settings and returns the relevant GCODE lines
    def getChangedLastValues(self) -> Dict[str, any]:

        # capture the values that we've changed
        changed = {}

        # for each of our target values, get the value to restore
        # no point in restoring values we haven't changed
        for key in self.targetValues:

            # skip target values we can't restore
            if key not in self.lastValues:
                continue

            # save into our changed
            changed[key] = self.lastValues[key]

        # return our collection of changed values
        return changed

    # Builds the relevant display feedback for each of the values
    def getDisplayChangesFromValues(self, values: Dict[str, any]) -> str:

        # stop here if we're not outputting data
        if not self.displayChangesToLcd:
            return ""

        # will hold all the default settings for the target layer
        codes = []

        # looking for wait for bed temp
        if "bedTemp" in values:
            codes.append("BedTemp: " + str(round(values["bedTemp"])))

        # looking for wait for Build Volume Temperature
        if "buildVolumeTemperature" in values:
            codes.append("buildVolumeTemperature: " + str(round(values["buildVolumeTemperature"])))

        # set our extruder one temp (if specified)
        if "extruderOne" in values:
            codes.append("Extruder 1 Temp: " + str(round(values["extruderOne"])))

        # set our extruder two temp (if specified)
        if "extruderTwo" in values:
            codes.append("Extruder 2 Temp: " + str(round(values["extruderTwo"])))

        # set global flow rate
        if "flowrate" in values:
            codes.append("Extruder A Flow Rate: " + str(values["flowrate"]))

        # set extruder 0 flow rate
        if "flowrateOne" in values:
            codes.append("Extruder 1 Flow Rate: " + str(values["flowrateOne"]))

        # set second extruder flow rate
        if "flowrateTwo" in values:
            codes.append("Extruder 2 Flow Rate: " + str(values["flowrateTwo"]))

        # set our fan speed
        if "fanSpeed" in values:
            codes.append("Fan Speed: " + str(values["fanSpeed"]))

        # set feedrate percentage
        if "speed" in values:
            codes.append("Print Speed: " + str(values["speed"]))

        # set print rate percentage
        if "printspeed" in values:
            codes.append("Linear Print Speed: " + str(values["printspeed"]))

        # set retract rate
        if "retractfeedrate" in values:
            codes.append("Retract Feed Rate: " + str(values["retractfeedrate"]))

        # set retract length
        if "retractlength" in values:
            codes.append("Retract Length: " + str(values["retractlength"]))

        # stop here if there's nothing to output
        if len(codes) == 0:
            return ""

        # output our command to display the data
        return "M117 " + ", ".join(codes) + "\n"

    # Converts the last values to something that can be output on the LCD
    def getLastDisplayValues(self) -> str:

        # convert our last values to something we can output
        return self.getDisplayChangesFromValues(self.getChangedLastValues())

    # Converts the target values to something that can be output on the LCD
    def getTargetDisplayValues(self) -> str:

        # convert our target values to something we can output
        return self.getDisplayChangesFromValues(self.targetValues)

    # Builds the the relevant GCODE lines from the given collection of values
    def getCodeFromValues(self, values: Dict[str, any]) -> str:

        # will hold all the desired settings for the target layer
        codes = self.getCodeLinesFromValues(values)

        # stop here if there are no values that require changing
        if len(codes) == 0:
            return ""

        # return our default block for this layer
        return ";[CAZD:\n" + "\n".join(codes) + "\n;:CAZD]"

    # Builds the relevant GCODE lines from the given collection of values
    def getCodeLinesFromValues(self, values: Dict[str, any]) -> List[str]:

        # will hold all the default settings for the target layer
        codes = []

        # looking for wait for bed temp
        if "bedTemp" in values:
            codes.append("M140 S" + str(values["bedTemp"]))

        # looking for wait for Build Volume Temperature
        if "buildVolumeTemperature" in values:
            codes.append("M141 S" + str(values["buildVolumeTemperature"]))

        # set our extruder one temp (if specified)
        if "extruderOne" in values:
            codes.append("M104 S" + str(values["extruderOne"]) + " T0")

        # set our extruder two temp (if specified)
        if "extruderTwo" in values:
            codes.append("M104 S" + str(values["extruderTwo"]) + " T1")

        # set our fan speed
        if "fanSpeed" in values:

            # convert our fan speed percentage to PWM
            fan_speed = int((float(values["fanSpeed"]) / 100.0) * 255)

            # add our fan speed to the defaults
            codes.append("M106 S" + str(fan_speed))

        # set global flow rate
        if "flowrate" in values:
            codes.append("M221 S" + str(values["flowrate"]))

        # set extruder 0 flow rate
        if "flowrateOne" in values:
            codes.append("M221 S" + str(values["flowrateOne"]) + " T0")

        # set second extruder flow rate
        if "flowrateTwo" in values:
            codes.append("M221 S" + str(values["flowrateTwo"]) + " T1")

        # set feedrate percentage
        if "speed" in values:
            codes.append("M220 S" + str(values["speed"]) + "")

        # set print rate percentage
        if "printspeed" in values:
            codes.append(";PRINTSPEED " + str(values["printspeed"]) + "")

        # set retract rate
        if "retractfeedrate" in values:

            if self.linearRetraction:
                codes.append(";RETRACTFEEDRATE " + str(values["retractfeedrate"] * 60) + "")
            else:
                codes.append("M207 F" + str(values["retractfeedrate"] * 60) + "")

        # set retract length
        if "retractlength" in values:

            if self.linearRetraction:
                codes.append(";RETRACTLENGTH " + str(values["retractlength"]) + "")
            else:
                codes.append("M207 S" + str(values["retractlength"]) + "")

        return codes

    # Builds the restored layer settings based on the previous settings and returns the relevant GCODE lines
    def getLastValues(self) -> str:

        # build the gcode to restore our last values
        return self.getCodeFromValues(self.getChangedLastValues())

    # Builds the gcode to inject either the changed values we want or restore the previous values
    def getInjectCode(self) -> str:

        # if we're now outside of our target layer and haven't restored our last values, do so now
        if not self.insideTargetLayer and self.wasInsideTargetLayer and not self.lastValuesRestored:

            # mark that we've injected the last values
            self.lastValuesRestored = True

            # inject the defaults
            return self.getLastValues() + "\n" + self.getLastDisplayValues()

        # if we're inside our target layer but haven't added our values yet, do so now
        if self.insideTargetLayer and not self.targetValuesInjected:

            # mark that we've injected the target values
            self.targetValuesInjected = True

            # inject the defaults
            return self.getTargetValues() + "\n" + self.getTargetDisplayValues()

        # nothing to do
        return ""

    # Returns the unmodified GCODE line from previous ChangeAtZ edits
    @staticmethod
    def getOriginalLine(line: str) -> str:

        # get the change at z original (cazo) details
        original_line = re.search(r"\[CAZO:(.*?):CAZO\]", line)

        # if we didn't get a hit, this is the original line
        if original_line is None:
            return line

        return original_line.group(1)

    # Builds the target layer settings based on the specified values and returns the relevant GCODE lines
    def getTargetValues(self) -> str:

        # build the gcode to change our current values
        return self.getCodeFromValues(self.targetValues)

    # Determines if the current line is at or below the target required to start modifying
    def isTargetLayerOrHeight(self) -> bool:

        # target selected by layer no.
        if self.targetByLayer:

            # if we don't have a current layer, we're not there yet
            if self.currentLayer is None:
                return False

            # if we're applying to a single layer, stop if our layer is not identical
            if self.applyToSingleLayer:
                return self.currentLayer == self.targetLayer
            else:
                return self.currentLayer >= self.targetLayer

        else:

            # if we don't have a current Z, we're not there yet
            if self.currentZ is None:
                return False

            # if we're applying to a single layer, stop if our Z is not identical
            if self.applyToSingleLayer:
                return self.currentZ == self.targetZ
            else:
                return self.currentZ >= self.targetZ

    # Marks any current ChangeAtZ layer defaults in the layer for deletion
    @staticmethod
    def markChangesForDeletion(layer: str):
        return re.sub(r";\[CAZD:", ";[CAZD:DELETE:", layer)

    # Grabs the current height
    def processLayerHeight(self, line: str):

        # stop here if we haven't entered a layer yet
        if self.currentLayer is None:
            return

        # get our gcode command
        command = GCodeCommand.getFromLine(line)

        # skip if it's not a command we're interested in
        if command is None:
            return

        # stop here if this isn't a linear move command
        if command.command != "G0" and command.command != "G1":
            return

        # get our value from the command
        current_z = command.getArgumentAsFloat("Z", None)

        # stop here if we don't have a Z value defined, we can't get the height from this command
        if current_z is None:
            return

        # stop if there's no change
        if current_z == self.currentZ:
            return

        # set our current Z value
        self.currentZ = current_z

        # if we don't have a layer height yet, set it based on the current Z value
        if self.layerHeight is None:
            self.layerHeight = self.currentZ

    # Grabs the current layer number
    def processLayerNumber(self, line: str):

        # if this isn't a layer comment, stop here, nothing to update
        if ";LAYER:" not in line:
            return

        # get our current layer number
        current_layer = GCodeCommand.getDirectArgumentAsInt(line, ";LAYER:", None)

        # this should never happen, but if our layer number hasn't changed, stop here
        if current_layer == self.currentLayer:
            return

        # update our current layer
        self.currentLayer = current_layer

    # Makes any linear move changes and also injects either target or restored values depending on the plugin state
    def processLine(self, line: str) -> str:

        # used to change the given line of code
        modified_gcode = ""

        # track any values that we may be interested in
        self.trackChangeableValues(line)

        # if we're not inside the target layer, simply read the any
        # settings we can and revert any ChangeAtZ deletions
        if not self.insideTargetLayer:

            # read any settings if we haven't hit our target layer yet
            if not self.wasInsideTargetLayer:
                self.processSetting(line)

            # if we haven't hit our target yet, leave the defaults as is (unmark them for deletion)
            if "[CAZD:DELETE:" in line:
                line = line.replace("[CAZD:DELETE:", "[CAZD:")

        # if we're targeting by Z, we want to add our values before the first linear move
        if "G1 " in line or "G0 " in line:
            modified_gcode += self.getInjectCode()

        # modify our command if we're still inside our target layer, otherwise pass unmodified
        if self.insideTargetLayer:
            modified_gcode += self.processLinearMove(line) + "\n"
        else:
            modified_gcode += line + "\n"

        # if we're targeting by layer we want to add our values just after the layer label
        if ";LAYER:" in line:
            modified_gcode += self.getInjectCode()

        # return our changed code
        return modified_gcode

    # Handles any linear moves in the current line
    def processLinearMove(self, line: str) -> str:

        # if it's not a linear motion command we're not interested
        if not ("G1 " in line or "G0 " in line):
            return line

        # always get our original line, otherwise the effect will be cumulative
        line = self.getOriginalLine(line)

        # get our command from the line
        linear_command = GCodeCommand.getLinearMoveCommand(line)

        # if it's not a linear move, we don't care
        if linear_command is None:
            return line

        # get our linear move parameters
        feed_rate = linear_command.arguments["F"]
        x_coord = linear_command.arguments["X"]
        y_coord = linear_command.arguments["Y"]
        z_coord = linear_command.arguments["Z"]
        extrude_length = linear_command.arguments["E"]

        # set our new line to our old line
        new_line = line

        # handle retract length
        new_line = self.processRetractLength(extrude_length, feed_rate, new_line, x_coord, y_coord, z_coord)

        # handle retract feed rate
        new_line = self.processRetractFeedRate(extrude_length, feed_rate, new_line, x_coord, y_coord, z_coord)

        # handle print speed adjustments
        if extrude_length is not None:  # Only for extrusion moves.
            new_line = self.processPrintSpeed(feed_rate, new_line)

        # set our current extrude position
        self.lastE = extrude_length if extrude_length is not None else self.lastE

        # if no changes have been made, stop here
        if new_line == line:
            return line

        # return our updated command
        return self.setOriginalLine(new_line, line)

    # Handles any changes to print speed for the given linear motion command
    def processPrintSpeed(self, feed_rate: float, new_line: str) -> str:

        # if we're not setting print speed or we don't have a feed rate, stop here
        if "printspeed" not in self.targetValues or feed_rate is None:
            return new_line

        # get our requested print speed
        print_speed = int(self.targetValues["printspeed"])

        # if they requested no change to print speed (ie: 100%), stop here
        if print_speed == 100:
            return new_line

        # get our feed rate from the command
        feed_rate = GCodeCommand.getDirectArgumentAsFloat(new_line, "F") * (float(print_speed) / 100.0)

        # change our feed rate
        return GCodeCommand.replaceDirectArgument(new_line, "F", feed_rate)

    # Handles any changes to retraction length for the given linear motion command
    def processRetractLength(self, extrude_length: float, feed_rate: float, new_line: str, x_coord: float, y_coord: float, z_coord: float) -> str:

        # if we don't have a retract length in the file we can't add one
        if "retractlength" not in self.lastValues or self.lastValues["retractlength"] == 0:
            return new_line

        # if we're not changing retraction length, stop here
        if "retractlength" not in self.targetValues:
            return new_line

        # retractions are only F (feed rate) and E (extrude), at least in cura
        if x_coord is not None or y_coord is not None or z_coord is not None:
            return new_line

        # since retractions require both F and E, and we don't have either, we can't process
        if feed_rate is None or extrude_length is None:
            return new_line

        # stop here if we don't know our last extrude value
        if self.lastE is None:
            return new_line

        # if there's no change in extrude we have nothing to change
        if self.lastE == extrude_length:
            return new_line

        # if our last extrude was lower than our current, we're restoring, so skip
        if self.lastE < extrude_length:
            return new_line

        # get our desired retract length
        retract_length = float(self.targetValues["retractlength"])

        # subtract the difference between the default and the desired
        extrude_length -= (retract_length - self.lastValues["retractlength"])

        # replace our extrude amount
        return GCodeCommand.replaceDirectArgument(new_line, "E", extrude_length)

    # Used for picking out the retract length set by Cura
    def processRetractLengthSetting(self, line: str):

        # skip if we're not doing linear retractions
        if not self.linearRetraction:
            return

        # get our command from the line
        linear_command = GCodeCommand.getLinearMoveCommand(line)

        # if it's not a linear move, we don't care
        if linear_command is None:
            return

        # get our linear move parameters
        feed_rate = linear_command.arguments["F"]
        x_coord = linear_command.arguments["X"]
        y_coord = linear_command.arguments["Y"]
        z_coord = linear_command.arguments["Z"]
        extrude_length = linear_command.arguments["E"]

        # the command we're looking for only has extrude and feed rate
        if x_coord is not None or y_coord is not None or z_coord is not None:
            return

        # if either extrude or feed is missing we're likely looking at the wrong command
        if extrude_length is None or feed_rate is None:
            return

        # cura stores the retract length as a negative E just before it starts printing
        extrude_length = extrude_length * -1

        # if it's a negative extrude after being inverted, it's not our retract length
        if extrude_length < 0:
            return

        # what ever the last negative retract length is it wins
        self.lastValues["retractlength"] = extrude_length

    # Handles any changes to retraction feed rate for the given linear motion command
    def processRetractFeedRate(self, extrude_length: float, feed_rate: float, new_line: str, x_coord: float, y_coord: float, z_coord: float) -> str:

        # skip if we're not doing linear retractions
        if not self.linearRetraction:
            return new_line

        # if we're not changing retraction length, stop here
        if "retractfeedrate" not in self.targetValues:
            return new_line

        # retractions are only F (feed rate) and E (extrude), at least in cura
        if x_coord is not None or y_coord is not None or z_coord is not None:
            return new_line

        # since retractions require both F and E, and we don't have either, we can't process
        if feed_rate is None or extrude_length is None:
            return new_line

        # get our desired retract feed rate
        retract_feed_rate = float(self.targetValues["retractfeedrate"])

        # convert to units/min
        retract_feed_rate *= 60

        # replace our feed rate
        return GCodeCommand.replaceDirectArgument(new_line, "F", retract_feed_rate)

    # Used for finding settings in the print file before we process anything else
    def processSetting(self, line: str):

        # if we're in layers already we're out of settings
        if self.currentLayer is not None:
            return

        # check our retract length
        self.processRetractLengthSetting(line)

    # Sets the flags if we're at the target layer or not
    def processTargetLayer(self):

        # skip this line if we're not there yet
        if not self.isTargetLayerOrHeight():

            # flag that we're outside our target layer
            self.insideTargetLayer = False

            # skip to the next line
            return

        # flip if we hit our target layer
        self.wasInsideTargetLayer = True

        # flag that we're inside our target layer
        self.insideTargetLayer = True

    # Removes all the ChangeAtZ layer defaults from the given layer
    @staticmethod
    def removeMarkedChanges(layer: str) -> str:
        return re.sub(r";\[CAZD:DELETE:[\s\S]+?:CAZD\](\n|$)", "", layer)

    # Resets the class contents to defaults
    def reset(self):

        self.targetValues = {}
        self.applyToSingleLayer = False
        self.lastE = None
        self.currentZ = None
        self.currentLayer = None
        self.targetByLayer = True
        self.targetLayer = None
        self.targetZ = None
        self.layerHeight = None
        self.lastValues = {"speed": 100}
        self.linearRetraction = True
        self.insideTargetLayer = False
        self.targetValuesInjected = False
        self.lastValuesRestored = False
        self.wasInsideTargetLayer = False
        self.enabled = True

    # Sets the original GCODE line in a given GCODE command
    @staticmethod
    def setOriginalLine(line, original) -> str:
        return line + ";[CAZO:" + original + ":CAZO]"

    # Tracks the change in gcode values we're interested in
    def trackChangeableValues(self, line: str):

        # simulate a print speed command
        if ";PRINTSPEED" in line:
            line = line.replace(";PRINTSPEED ", "M220 S")

        # simulate a retract feedrate command
        if ";RETRACTFEEDRATE" in line:
            line = line.replace(";RETRACTFEEDRATE ", "M207 F")

        # simulate a retract length command
        if ";RETRACTLENGTH" in line:
            line = line.replace(";RETRACTLENGTH ", "M207 S")

        # get our gcode command
        command = GCodeCommand.getFromLine(line)

        # stop here if it isn't a G or M command
        if command is None:
            return

        # handle retract length changes
        if command.command == "M207":

            # get our retract length if provided
            if "S" in command.arguments:
                self.lastValues["retractlength"] = command.getArgumentAsFloat("S")

            # get our retract feedrate if provided, convert from mm/m to mm/s
            if "F" in command.arguments:
                self.lastValues["retractfeedrate"] = command.getArgumentAsFloat("F") / 60.0

            # move to the next command
            return

        # handle bed temp changes
        if command.command == "M140" or command.command == "M190":

            # get our bed temp if provided
            if "S" in command.arguments:
                self.lastValues["bedTemp"] = command.getArgumentAsFloat("S")

            # move to the next command
            return

        # handle Build Volume Temperature changes, really shouldn't want to wait for enclousure temp mid print though.
        if command.command == "M141" or command.command == "M191":

            # get our bed temp if provided
            if "S" in command.arguments:
                self.lastValues["buildVolumeTemperature"] = command.getArgumentAsFloat("S")

            # move to the next command
            return

        # handle extruder temp changes
        if command.command == "M104" or command.command == "M109":

            # get our temperature
            temperature = command.getArgumentAsFloat("S")

            # don't bother if we don't have a temperature
            if temperature is None:
                return

            # get our extruder, default to extruder one
            extruder = command.getArgumentAsInt("T", None)

            # set our extruder temp based on the extruder
            if extruder is None or extruder == 0:
                self.lastValues["extruderOne"] = temperature

            if extruder is None or extruder == 1:
                self.lastValues["extruderTwo"] = temperature

            # move to the next command
            return

        # handle fan speed changes
        if command.command == "M106":

            # get our bed temp if provided
            if "S" in command.arguments:
                self.lastValues["fanSpeed"] = (command.getArgumentAsInt("S") / 255.0) * 100

            # move to the next command
            return

        # handle flow rate changes
        if command.command == "M221":

            # get our flow rate
            temperature = command.getArgumentAsFloat("S")

            # don't bother if we don't have a flow rate (for some reason)
            if temperature is None:
                return

            # get our extruder, default to global
            extruder = command.getArgumentAsInt("T", None)

            # set our extruder temp based on the extruder
            if extruder is None:
                self.lastValues["flowrate"] = temperature
            elif extruder == 1:
                self.lastValues["flowrateOne"] = temperature
            elif extruder == 1:
                self.lastValues["flowrateTwo"] = temperature

            # move to the next command
            return

        # handle print speed changes
        if command.command == "M220":

            # get our speed if provided
            if "S" in command.arguments:
                self.lastValues["speed"] = command.getArgumentAsInt("S")

            # move to the next command
            return
