#!/usr/bin/env python

# Script to remove commands of a gcode file before and after the given range.
# All gcodes before the given line will be replaced with equivalent rapid
# movements to get to the right position. Initial rapid movement will first move
# to the maximum Z value used in the removed portion, then move to XY,
# then move down to the correct Z.

import argparse
import re
from copy import copy

for pygcode_lib_type in ('installed_lib', 'relative_lib'):
    try:
        # pygcode
        from pygcode import Machine, Mode
        from pygcode import Line, Comment
        from pygcode import GCodePlaneSelect, GCodeSelectXYPlane
        from pygcode import GCodeRapidMove

    except ImportError:
        import sys, os, inspect
        # Add pygcode (relative to this test-path) to the system path
        _this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
        sys.path.insert(0, os.path.join(_this_path, '..', 'src'))
        if pygcode_lib_type == 'installed_lib':
            continue # import was attempted before sys.path addition. retry import
        raise # otherwise the raised ImportError is a genuine problem
    break


# =================== Command Line Arguments ===================
# --- Types
def range_type(value):
    """
    Return (is_first, is_last) such that is_first(n, pos) will return True if
    the gcode's current line is the first to be cropped, and similarly for the
    last line.
    :param value: string given as argument
    :return: (is_first, is_last) callables
    """
    # All files are cropped from one line, to another, identifying these lines is
    # done via the [first] and [last] cropping criteria.
    #   - Line numbers (parameters: n)
    #   - Machine position (parameters: a,b,c,x,y,z)
    # Comparisons, all with <letter><comparison><number>
    #   - = (or ==) equal to
    #   - != not equal to
    #   - < less than
    #   - <= less than or equal to
    #   - > greater than
    #   - >= greater than or equal to
    match = re.search(r'^(?P<first>[^:]*):(?P<last>[^:]*)$', value)
    if not match:
        raise argparse.ArgumentTypeError("'%s' range invalid format" % value)

    def _cmp(cmp_str):
        """
        Convert strings like
            'x>10.3'
        into a callable equivalent to:
            lambda n, pos: pos.X > 10.3
        where:
            n is the file's line number
            pos is the machine's position (Position) instance
        :param cmp_str: comparison string of the form: '<param><cmp><value>'
        :return: callable
        """
        CMP_MAP = {
            '=': lambda a, b: a == b,
            '==': lambda a, b: a == b,
            '!=': lambda a, b: a != b,
            '<': lambda a, b: a < b,
            '<=': lambda a, b: a <= b,
            '>': lambda a, b: a > b,
            '>=': lambda a, b: a >= b,
        }
        # split comparison into (param, cmp, value)
        m = re.search(
            r'''^\s*
                (
                    (?P<param>[abcnxyz])?\s*  # parameter
                    (?P<cmp>(==?|!=|<=?|>=?))   # comparison
                )?\s* # parameter & comparison defaults to "n="
                (?P<value>-?\d+(\.\d+)?)\s*
            $''',
            cmp_str, re.IGNORECASE | re.MULTILINE | re.VERBOSE
        )
        if not m:
            raise argparse.ArgumentTypeError("'%s' range comparison invalid" % cmp_str)
        (param, cmp, val) = (
            (m.group('param') or 'N').upper(),  # default to 'N'
            m.group('cmp') or '=',  # default to '='
            m.group('value')
        )

        # convert to lambda
        if param == 'N':
            if float(val) % 1:
                raise argparse.ArgumentTypeError("'%s' line number must be an integer" % cmp_str)
            return lambda n, pos: CMP_MAP[cmp](n, float(val))
        else:
            return lambda n, pos: CMP_MAP[cmp](getattr(pos, param), float(val))

    def _cmp_group(group_str, default):
        """
        Split given group_str by ',' and return callable that will return True
        only if all comparisons are true.
        So if group_str is:
            x>=10.4,z>1
        return will be a callable equivalent to:
            lambda n, pos: (pos.X >= 10.4) and (pos.Z > 1)
        (see _cmp for more detail)
        :param group_str: string of _cmp valid strings delimited by ','s
        :param default: default callable if group_str is falsey
        :return: callable that returns True if all cmp's are true
        """
        if not group_str:
            return default
        cmp_list = []
        for cmp_str in group_str.split(','):
            cmp_list.append(_cmp(cmp_str))
        return lambda n, pos: all(x(n, pos) for x in cmp_list)


    is_first = _cmp_group(match.group('first'), lambda n, pos: True)
    is_last = _cmp_group(match.group('last'), lambda n, pos: False)

    return (is_first, is_last)


# --- Defaults


# --- Create Parser
parser = argparse.ArgumentParser(
    description="Remove gcode before and after given 'from' and 'to' conditions.",
    epilog="Range Format:"
    """
    range must be of the format:
        [condition[,condition...]]:[condition[,condition...]]
    the first condition(s) are true for the first line included in the cropped area
    the second set are true for the first line excluded after the cropped area

    Conditions:
    each condition is of the format:
        {variable}{operation}{number}
    or, more specifically:
        [[{a,b,c,n,x,y,z}]{=,!=,<,<=,>,>=}]{number}

    Condition Variables:
        n     - file's line number
        a|b|c - machine's angular axes
        x|y|z - machine's linear axes

    Example Ranges:
        "100:200" will crop lines 100-199 (inclusive)
        "z<=-2:" will isolate everything after the machine crosses z=-2
        "x>10,y>10:n>=123" starts cropped area where both x and y exceed 10,
            but only before line 123

    Limitations:
        Only takes points from start and finish of a gcode operation, so a line
        through a condition region, or an arc that crosses a barrier will NOT
        trigger the start or stop of cropping.
        Probe alignment operations will not change virtual machine's position.
    """,
    formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
    'infile', type=argparse.FileType('r'),
    help="gcode file to crop",
)
parser.add_argument(
    'range', type=range_type,
    help="file range to crop, format [from]:[to] (details below)",
)


# --- Parse Arguments
args = parser.parse_args()


# =================== Cropping File ===================

# --- Machine
class NullMachine(Machine):
    MODE_CLASS = type('NullMode', (Mode,), {'default_mode': ''})

machine = NullMachine()

pre_crop = True
post_crop = False

(is_first, is_last) = args.range

for (i, line_str) in enumerate(args.infile.readlines()):
    line = Line(line_str)

    # remember machine's state before processing the current line
    old_machine = copy(machine)
    machine.process_block(line.block)

    if pre_crop:
        if is_first(i + 1, machine.pos):
            # First line inside cropping range
            pre_crop = False

            # Set machine's accumulated mode (from everything that's been cut)
            mode_str = str(old_machine.mode)
            if mode_str:
                print(Comment("machine mode before cropping"))
                print(mode_str)

            # Getting machine's current (modal) selected plane
            plane = old_machine.mode.plane_selection
            if not isinstance(plane, GCodePlaneSelect):
                plane = GCodeSelectXYPlane()  # default to XY plane

            # --- position machine before first cropped line
            print(Comment("traverse into position, up, over, and down"))
            # rapid move to Z (maximum Z the machine has experienced thus far)
            print(GCodeRapidMove(**{
                plane.normal_axis: getattr(old_machine.abs_range_max, plane.normal_axis),
            }))
            # rapid move to X,Y
            print(GCodeRapidMove(**dict(
                (k, v) for (k, v) in old_machine.pos.values.items()
                if k in plane.plane_axes
            )))
            # rapid move to Z (machine.pos.Z)
            print(GCodeRapidMove(**{
                plane.normal_axis: getattr(old_machine.pos, plane.normal_axis),
            }))
            print('')

    if (pre_crop, post_crop) == (False, False):
        if is_last(i + 1, machine.pos):
            # First line **outside** the area being cropped
            #   (ie: this line won't be output)
            post_crop = True  # although, irrelevant because...
            break
        else:
            # inside cropping area
            print(line)
