import math
import re
import argparse
import copy

# --- Configuration ---
DEFAULT_SHARP_ANGLE_THRESHOLD = 45.0
DEFAULT_RETRACT_HEIGHT = 8.0
DEFAULT_PLUNGE_DEPTH = 0.0
DEFAULT_PLUNGE_FEEDRATE = 500.0
DEFAULT_SAFETY_Z_INIT = 10.0
RENUMBER_LINES = True
OUTPUT_LINE_INCREMENT = 1

# --- Regular Expressions for Parsing ---
# UPDATED: Added M, P, S explicitly to handle them as primary codes/addresses
GCODE_RE = re.compile(r"([GMXYZIJCFSP])([-+]?\d*\.?\d+)")
NCODE_RE = re.compile(r"^[Nn](\d+)")
COMMENT_RE = re.compile(r"\(.*?\)|;.*") # Made comment regex non-greedy

# --- Helper Functions ---

# CORRECTED parse_gcode_line function
def parse_gcode_line(line):
    """Parses a G-code line into a dictionary of addresses and values."""
    line_no_comment = COMMENT_RE.sub('', line).strip()
    params = {'comment': COMMENT_RE.search(line)}
    if not line_no_comment:
        params['G'] = -1 # Indicate empty line or comment-only line
        return params

    # Check for N code
    n_match = NCODE_RE.match(line_no_comment)
    if n_match:
        params['N'] = int(n_match.group(1))
        line_no_comment = NCODE_RE.sub('', line_no_comment).strip() # Remove N code for further parsing

    # Use the updated regex to find all address-value pairs directly
    found_codes = GCODE_RE.findall(line_no_comment.upper())

    # Handle lines with only N code or comments remaining
    if not found_codes and not params.get('comment'):
         params['G'] = -1 # Treat as non-command line
         return params

    for addr, val_str in found_codes:
        try:
            # Don't convert G/M code values here yet, handle modality later
            # Store all values as floats initially, except N
            val = float(val_str)
            if addr == 'G':
                 # Handle multiple G codes on a line (last one typically wins for modal group 1)
                 # Store as float for now, convert to int later if needed
                 params[addr] = val
            elif addr == 'M':
                 # Handle multiple M codes
                 # Store M codes in a list if multiple might be active?
                 # For simplicity, assume last M wins or store first/last as needed
                 params[addr] = int(val) # M codes are typically integers
            else:
                params[addr] = val
        except ValueError:
            print(f"WARN: Could not parse value '{val_str}' for address '{addr}' in line: {line}")
            continue # Skip this parameter if parsing fails

    # Convert final G code to int if present and it looks like an integer
    if 'G' in params:
        if params['G'] == int(params['G']):
             params['G'] = int(params['G'])
        # Else leave as float if it has decimal (e.g., G64.1) - though rare for G codes

    # If only comments/N found after parsing attempts, mark as non-command
    if not any(k in params for k in ['G', 'M', 'X', 'Y', 'Z', 'I', 'J', 'C', 'F', 'S', 'P']) and 'G' not in params:
         params['G'] = -1

    return params


# --- (Rest of the helper functions: calculate_angle, calculate_arc_endpoint_tangent, get_angle_difference, format_gcode_line remain the same) ---
def calculate_angle(x1, y1, x2, y2):
    """Calculates the angle of the vector from (x1, y1) to (x2, y2) in degrees."""
    dx = x2 - x1
    dy = y2 - y1
    if abs(dx) < 1e-6 and abs(dy) < 1e-6:
        return None # No movement, angle undefined
    angle_rad = math.atan2(dy, dx)
    angle_deg = math.degrees(angle_rad)
    # Normalize to 0-360
    return angle_deg % 360

def calculate_arc_endpoint_tangent(start_x, start_y, end_x, end_y, center_x, center_y, is_clockwise):
    """Calculates the tangent angle at the end of an arc segment."""
    # Vector from center to end point
    radius_vec_x = end_x - center_x
    radius_vec_y = end_y - center_y

    if abs(radius_vec_x) < 1e-6 and abs(radius_vec_y) < 1e-6:
        # Should not happen for valid arcs, but handle anyway
        # Fallback: calculate angle from start to end
        return calculate_angle(start_x, start_y, end_x, end_y)

    # Tangent vector is perpendicular to the radius vector
    if is_clockwise: # G2
        tangent_vec_x = radius_vec_y
        tangent_vec_y = -radius_vec_x
    else: # G3
        tangent_vec_x = -radius_vec_y
        tangent_vec_y = radius_vec_x

    angle_rad = math.atan2(tangent_vec_y, tangent_vec_x)
    angle_deg = math.degrees(angle_rad)
    # Normalize to 0-360
    return angle_deg % 360

def get_angle_difference(angle1, angle2):
    """Calculates the shortest difference between two angles (degrees)."""
    if angle1 is None or angle2 is None:
        return 0 # No difference if one angle is undefined
    diff = angle1 - angle2
    while diff <= -180:
        diff += 360
    while diff > 180:
        diff -= 360
    return diff

def format_gcode_line(params, n_code=None):
    """Formats a dictionary of parameters back into a G-code line."""
    if params.get('G', -1) == -1 and not params.get('M') and not params.get('comment') and not any(k in params for k in ['X', 'Y', 'Z', 'I', 'J', 'C', 'F', 'S', 'P']):
         # Check more thoroughly if it's just a comment/N line
         if params.get('comment') and len(params) == 1: # Only comment exists
              return params['comment'].group(0)
         if params.get('comment') and params.get('N') and len(params) == 2: # Only N and comment
              line = ""
              if n_code is not None: line += f"N{n_code} "
              line += params['comment'].group(0)
              return line.strip()
         # Otherwise might be an empty line result
         return "" # Skip empty/fully parsed lines


    parts = []
    if RENUMBER_LINES and n_code is not None: # Check RENUMBER_LINES flag
        parts.append(f"N{n_code}")
    elif 'N' in params and not RENUMBER_LINES: # Keep original N if not renumbering
        parts.append(f"N{params['N']}")


    # Order matters for readability: G, M, then others
    if 'G' in params and params['G'] != -1 :
        # Format G code appropriately (int or float)
        g_val = params['G']
        if g_val == int(g_val):
             parts.append(f"G{int(g_val)}")
        else:
             parts.append(f"G{g_val:.1f}") # Example format for Gxx.x

    if 'M' in params:
         # Assuming M codes are integers
         parts.append(f"M{int(params['M'])}")

    # Add other parameters (ensure consistent order)
    # Use the order from the regex + C for consistency
    for addr in ['X', 'Y', 'Z', 'I', 'J', 'C', 'F', 'S', 'P']:
        if addr in params:
            val = params[addr]
            # Format based on address type / typical precision
            if addr in ['X', 'Y', 'Z', 'I', 'J']:
                 parts.append(f"{addr}{val:.3f}")
            elif addr == 'C':
                 parts.append(f"{addr}{val:.3f}") # Angle precision
            elif addr == 'F':
                 parts.append(f"{addr}{val:.3f}") # Feed rate precision
            elif addr == 'S':
                 parts.append(f"{addr}{int(val)}") # Spindle speed usually integer
            elif addr == 'P':
                 # P can be int or float depending on G/M code context
                 if val == int(val):
                      parts.append(f"{addr}{int(val)}")
                 else:
                      parts.append(f"{addr}{val:.4f}") # P often needs higher precision (e.g., dwell, G64)
            else:
                 parts.append(f"{addr}{val}") # General case


    line = " ".join(parts)
    if params.get('comment'):
        # Ensure space before comment if line not empty
        if line:
            line += f" {params['comment'].group(0)}"
        else: # Line only contained N code perhaps
             line = f"N{n_code} {params['comment'].group(0)}" if RENUMBER_LINES and n_code is not None else params['comment'].group(0)


    return line.strip()


# --- Main Processing Logic (process_gcode) remains the same ---
def process_gcode(input_file, output_file, sharp_angle_threshold, retract_height, plunge_depth, plunge_feedrate, safety_z):
    """Processes the G-code file to add tangential C-axis control."""

    current_pos = {'X': None, 'Y': None, 'Z': None}
    current_c = 0.0 # Assume starting at 0
    last_angle = None
    retracted = True # Assume starting retracted
    g_modal = 0 # Track current G0/G1/G2/G3 state
    plunge_depth_detected = plunge_depth # Use default or detected value
    current_n = 0 # For renumbering

    with open(input_file, 'r') as infile, open(output_file, 'w') as outfile:
        original_lines = infile.readlines()
        processed_lines = [] # Store lines to write later

        for i, line in enumerate(original_lines):
            line_content = line.strip()
            if not line_content:
                processed_lines.append("") # Keep empty lines if desired
                continue

            params = parse_gcode_line(line_content)
            original_params = copy.deepcopy(params) # Keep original for reference if needed

            # Handle non-command lines (comments, empty after parse)
            if params.get('G', 0) == -1 and not params.get('M') and not any(k in params for k in ['X', 'Y', 'Z', 'I', 'J', 'C', 'F', 'S', 'P']):
                 # Just write the formatted line (comment/N code only)
                 n_code_to_use = current_n if RENUMBER_LINES else params.get('N')
                 formatted_line = format_gcode_line(params, n_code_to_use)
                 if formatted_line:
                    processed_lines.append(formatted_line)
                    if RENUMBER_LINES and formatted_line: current_n += OUTPUT_LINE_INCREMENT # Increment only if something was written
                 continue


            # --- Update State ---
            new_pos = copy.deepcopy(current_pos)
            moved_xy = False
            moved_z = False

            # Determine current G code for this line (check params or use modal)
            line_g_code = params.get('G') # Could be float or int from parser
            if line_g_code is not None:
                # Update modal G code if it's a motion command
                if int(line_g_code) in [0, 1, 2, 3]:
                     g_modal = int(line_g_code)
            elif any(k in params for k in ['X','Y','Z','I','J']): # If movement params exist, use modal G
                 line_g_code = g_modal
            else: # No G code on line and no movement
                 line_g_code = None # Or potentially keep g_modal if needed for other G codes?


            # Convert G code to int for comparisons
            g_code_int = int(line_g_code) if line_g_code is not None else g_modal


            if 'X' in params: new_pos['X'] = params['X']; moved_xy = True
            if 'Y' in params: new_pos['Y'] = params['Y']; moved_xy = True
            if 'Z' in params: new_pos['Z'] = params['Z']; moved_z = True

            # Check if critical positions are known before proceeding
            if current_pos['X'] is None and moved_xy:
                print(f"WARN: XY Movement on line ~{i+1} before initial position known. Assuming start at 0,0.")
                current_pos['X'] = 0.0
                current_pos['Y'] = 0.0
            if current_pos['Z'] is None and moved_z:
                 print(f"WARN: Z Movement on line ~{i+1} before initial position known. Assuming start at safety Z {safety_z}.")
                 current_pos['Z'] = safety_z # Use the safety Z


            # --- Logic ---
            commands_to_insert = [] # Commands generated BEFORE the current line


            is_cutting_move = (g_code_int in [1, 2, 3] and
                               current_pos['X'] is not None and
                               current_pos['Y'] is not None and
                               current_pos['Z'] is not None and
                               abs(current_pos['Z'] - plunge_depth_detected) < 1e-3 and # Check if at cutting depth
                               moved_xy)

            is_plunge_move = (g_code_int == 1 and moved_z and not moved_xy and
                              current_pos['Z'] is not None and # Need current Z to compare
                              current_pos['Z'] > plunge_depth_detected + 1e-3 and # Ensure coming from above
                               abs(new_pos['Z'] - plunge_depth_detected) < 1e-3) # Ensure ending at depth

            is_retract_move = (g_code_int == 0 and moved_z and not moved_xy and
                               current_pos['Z'] is not None and # Need current Z
                               new_pos['Z'] > current_pos['Z'] + 1e-3 and # Ensure moving up
                               abs(current_pos['Z'] - plunge_depth_detected) < 1e-3) # Ensure retracting from cut depth


            # --- Detect Plunge Depth ---
            # Detect first time Z goes down to the specified or default plunge depth with G1
            if g_code_int == 1 and moved_z and not moved_xy and abs(new_pos['Z'] - plunge_depth) < 1e-3:
                 if abs(plunge_depth_detected - plunge_depth) > 1e-3 : # If using default and found first plunge
                     plunge_depth_detected = new_pos['Z']
                     print(f"INFO: Auto-Detected Plunge Depth Z={plunge_depth_detected:.3f} at line ~{i+1}")
                 # No longer retracted after plunge starts
                 retracted = False


            # --- Calculate Angle and Handle Corners/Tangential Control ---
            segment_angle = None

            # Calculate angle only if moving in XY plane
            if moved_xy and current_pos['X'] is not None and current_pos['Y'] is not None:
                if g_code_int == 1:
                    segment_angle = calculate_angle(current_pos['X'], current_pos['Y'], new_pos['X'], new_pos['Y'])
                elif g_code_int in [2, 3]:
                    # Arc requires center point. Assume I, J are relative offsets from start
                    center_x = current_pos['X'] + params.get('I', 0.0)
                    center_y = current_pos['Y'] + params.get('J', 0.0)
                    # Need endpoint from params, not new_pos which might only have Z
                    end_x = params.get('X', current_pos['X'])
                    end_y = params.get('Y', current_pos['Y'])

                    segment_angle = calculate_arc_endpoint_tangent(
                        current_pos['X'], current_pos['Y'],
                        end_x, end_y,
                        center_x, center_y,
                        is_clockwise=(g_code_int == 2)
                    )

            # --- Corner Handling and C-Axis Insertion ---
            if segment_angle is not None:
                angle_diff = abs(get_angle_difference(segment_angle, last_angle)) if last_angle is not None else 0

                # Check if lift/rotate needed BEFORE this move
                # Condition: (Sharp turn while cutting) OR (First move after retract/plunge requires orientation)
                needs_pre_rotate = False
                if is_cutting_move and angle_diff > sharp_angle_threshold:
                     needs_pre_rotate = True
                     # Lift is required before rotation if turning sharply while already cutting
                     lift_params = {'G': 0, 'Z': retract_height, 'comment': original_params.get('comment')} # Use original comment context
                     commands_to_insert.append(format_gcode_line(lift_params))
                     print(f"DEBUG: Inserting Lift at line ~{i+1} due to angle diff {angle_diff:.1f}")
                     retracted = True # Temporarily set retracted state for logic below

                elif (is_plunge_move or (is_cutting_move and retracted)) and abs(get_angle_difference(segment_angle, current_c)) > 1.0:
                    # If plunging or starting cut after retract, pre-rotate if angle differs from current C
                    needs_pre_rotate = True
                    # No lift needed if already retracted or just plunging

                # Insert rotation if needed
                if needs_pre_rotate:
                    rotate_params = {'G': 0, 'C': segment_angle, 'comment': original_params.get('comment')}
                    commands_to_insert.append(format_gcode_line(rotate_params))
                    current_c = segment_angle # Update C state *after* rotation command

                # Insert plunge if we lifted earlier
                if is_cutting_move and angle_diff > sharp_angle_threshold: # This condition implies we lifted
                    plunge_params = {'G': 1, 'Z': plunge_depth_detected, 'F': plunge_feedrate, 'comment': original_params.get('comment')}
                    commands_to_insert.append(format_gcode_line(plunge_params))
                    retracted = False # We are now plunged back down
                    print(f"DEBUG: Inserting Plunge at line ~{i+1}")


                # --- Add C to the *current* cutting move command ---
                if is_cutting_move or is_plunge_move: # Add C if moving XY at depth or plunging
                     params['C'] = segment_angle
                     current_c = segment_angle # Update current C to match the move
                     last_angle = segment_angle # Update last angle for next comparison

                # If it's a G0 move while retracted, optionally pre-rotate C
                elif g_code_int == 0 and moved_xy and retracted:
                     # Check if next move is a plunge and rotate if necessary? More complex lookahead needed.
                     # For now, just execute the G0 XY move. Rotation happens before plunge.
                     pass


            # --- Add commands to insert (lift/rotate/plunge) to buffer ---
            for cmd in commands_to_insert:
                if cmd:
                     n_code_to_use = current_n if RENUMBER_LINES else None # Let format handle original N if not renumbering
                     processed_lines.append(format_gcode_line(parse_gcode_line(cmd), n_code_to_use)) # Reparse to get dict for formatting
                     if RENUMBER_LINES: current_n += OUTPUT_LINE_INCREMENT


            # --- Add the (potentially modified) original line ---
            n_code_to_use = current_n if RENUMBER_LINES else params.get('N')
            formatted_line = format_gcode_line(params, n_code_to_use)
            if formatted_line:
                processed_lines.append(formatted_line)
                if RENUMBER_LINES: current_n += OUTPUT_LINE_INCREMENT


            # --- Update Global State for next iteration ---
            # Update position AFTER processing the line based on its parameters
            if 'X' in params: current_pos['X'] = params['X']
            if 'Y' in params: current_pos['Y'] = params['Y']
            if 'Z' in params: current_pos['Z'] = params['Z']

            # Update retracted state based on the move that just happened
            if is_retract_move:
                retracted = True
                # last_angle = None # Reset last angle on retract? Optional.
            elif is_plunge_move or (is_cutting_move and not retracted): # If plunging or cutting while already down
                 retracted = False


        # --- Write all processed lines to the output file ---
        for line in processed_lines:
            outfile.write(line + '\n')

    print(f"Processing complete. Output written to {output_file}")
    if plunge_depth_detected == plunge_depth:
        print(f"WARN: Plunge depth Z={plunge_depth} was used (default or manual). Verify this is correct.")


# --- Command Line Argument Parsing (remains the same) ---
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Add tangential C-axis control to G-code for knife cutting.")
    parser.add_argument("input_file", help="Path to the input G-code file.")
    parser.add_argument("output_file", help="Path to write the modified G-code file.")
    parser.add_argument("-t", "--threshold", type=float, default=DEFAULT_SHARP_ANGLE_THRESHOLD,
                        help=f"Sharp angle threshold in degrees (default: {DEFAULT_SHARP_ANGLE_THRESHOLD})")
    parser.add_argument("-r", "--retract", type=float, default=DEFAULT_RETRACT_HEIGHT,
                        help=f"Z height for retraction (default: {DEFAULT_RETRACT_HEIGHT})")
    parser.add_argument("-p", "--plunge", type=float, default=DEFAULT_PLUNGE_DEPTH,
                        help=f"Cutting depth Z (default: {DEFAULT_PLUNGE_DEPTH}, tries to auto-detect)")
    parser.add_argument("-f", "--plungefeed", type=float, default=DEFAULT_PLUNGE_FEEDRATE,
                        help=f"Feed rate for plunge moves (default: {DEFAULT_PLUNGE_FEEDRATE})")
    parser.add_argument("-s", "--safetyz", type=float, default=DEFAULT_SAFETY_Z_INIT,
                        help=f"Assumed initial safe Z height (default: {DEFAULT_SAFETY_Z_INIT})")
    parser.add_argument("--no-renumber", action="store_true",
                        help="Do not renumber N codes in the output file.")


    args = parser.parse_args()

    RENUMBER_LINES = not args.no_renumber # Set global flag based on arg

    process_gcode(args.input_file,
                  args.output_file,
                  args.threshold,
                  args.retract,
                  args.plunge,
                  args.plungefeed,
                  args.safetyz)