# coding: utf-8
# python 2 only

# Copyright (c) 2026 TormachTips.com. All rights reserved.
# Licensed under the TormachTips Personal Use License.
# Permission is granted only for private personal use and private personal modification.
# No sharing, publication, distribution, resale, sublicensing, screenshots, code excerpts,
# benchmarks, or videos are permitted without prior written permission.
# Requests:         tormach.1100m@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#############################################
##                                         ##
##         Lowest Z Finder 0.96            ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.96 - public release - 3/25/26

import threading
import time
import os
import re
from ui_hooks import plugin
import singletons
import constants
import types

CURRENT_VER      = "0.96"
SCRIPT_NAME      = "Lowest Z Finder Plugin"
DESCRIPTION      = "Finds the lowest Z value each tool in your program goes. Good for sanity checks against crashes."
ENABLED          = 1
DEV_MACHINE      = 1
DEV_MACHINE_FLAG = "/home/operator/gcode/python/dev_machine.txt"

class UserPlugin(plugin):
    def __init__(self):
        try:
            import update_checker
            update_checker.tormachtips(__file__, CURRENT_VER)
        except Exception:
            pass                
        plugin.__init__(self, "Lowest Z Reporter")
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = DEV_MACHINE
        else:
            plugin_enabled = ENABLED
        if plugin_enabled:
            self.ui = None
            self.error_handler = None
            t = threading.Thread(target=self._setup_hook)
            t.daemon = True
            t.start()
            return
        else:
            if dev_machine_found:
                self.error_handler.write("[Lowest Z Reporter] Dev machine found. Plugin loaded, but disabled by DEV_MACHINE.", constants.ALARM_LEVEL_QUIET)
            else:
                self.error_handler.write("[Lowest Z Reporter] Plugin loaded, but disabled.", constants.ALARM_LEVEL_QUIET)
                self.error_handler.write("[Lowest Z Reporter] To enable, open script, find ENABLED = 0 and change to ENABLED = 1", constants.ALARM_LEVEL_QUIET)
            return
            
    def _setup_hook(self):
        while True:
            try:
                if hasattr(singletons, 'g_Machine') and singletons.g_Machine:
                    self.ui = singletons.g_Machine
                    err_handler = getattr(self.ui, 'error_handler', None)
                    if not err_handler:
                        raise Exception("No error_handler found")
                    if getattr(err_handler, '_lowest_z_wrapped', False):
                        self.error_handler = err_handler
                        break
                    self.error_handler = err_handler
                    original_write     = err_handler.write
                    def wrapped_write(this, msg, level=constants.ALARM_LEVEL_QUIET, **kwargs):
                        try:
                            if isinstance(msg, basestring):
                                low = msg.lower()
                                if "loaded program" in low or "reloaded program" in low:
                                    self._on_program_loaded(msg)
                        except Exception, e:
                            try:
                                original_write("[Lowest Z] Hook callback error: " + str(e),constants.ALARM_LEVEL_LOW)
                            except:
                                pass
                        return original_write(msg, level, **kwargs)
                    err_handler.write = types.MethodType(wrapped_write, err_handler)
                    err_handler._lowest_z_wrapped = True
                    self.error_handler.write("[Lowest Z] Hook installed successfully",constants.ALARM_LEVEL_QUIET)
                    break
            except Exception, e:
                print("[Lowest Z Plugin] Hook setup retry: " + str(e))
            time.sleep(0.5)
    
    def _on_program_loaded(self, msg):
        try:
            parts = re.split(r'(?:Loaded|Reloaded)\s+program', msg.strip(), flags=re.IGNORECASE)
            if len(parts) != 2:
                return
            filename  = parts[1].strip()
            full_path = filename if filename.startswith('/') else os.path.join('/home/operator', filename)
            if not os.path.isfile(full_path):
                self.error_handler.write("[Lowest Z] File not found: " + full_path,constants.ALARM_LEVEL_LOW)
                return
            results, tool_order, g10_found = self._find_lowest_z(full_path)
            output_lines = [" ", "Lowest Z per tool:"]
            if not tool_order:
                output_lines.append("  (no M6 tool changes or Z moves detected)")
            else:
                for tool in tool_order:
                    info = results.get(tool)
                    if not info or info['z'] is None:
                        output_lines.append("  T%d : no Z moves" % tool)
                    else:
                        z = info['z']
                        line_no = info['line']
                        if z == int(z):
                            z_display = str(int(z))
                        else:
                            z_display = "{:.4g}".format(z)
                        output_lines.append("\tOn line %d,\tT%d\tgoes to\tZ%s" % (line_no, tool, z_display))                        
            if g10_found:
                output_lines.append(" ")
                output_lines.append("WARNING: G10 detected — Z values may be offset (work coordinate shift)!")
            output_lines.append(" ")
            for line in reversed(output_lines):
                self.error_handler.write(line, constants.ALARM_LEVEL_QUIET)
        except Exception, e:
            self.error_handler.write("[Lowest Z] Error: " + str(e), constants.ALARM_LEVEL_LOW)
    
    def _commit_segment(self, current_tool, segment_lowest_z, segment_lowest_line, results, tool_order):
        if current_tool is None:
            return
        if current_tool not in results:
            results[current_tool] = {'z': None, 'line': None}
            tool_order.append(current_tool)
        if segment_lowest_z is None:
            return
        old = results[current_tool]['z']
        if old is None or segment_lowest_z < old:
            results[current_tool]['z']    = segment_lowest_z
            results[current_tool]['line'] = segment_lowest_line
    
    def _find_lowest_z(self, filename):
        t_pattern           = re.compile(r'\bT(\d+)\b', re.IGNORECASE)
        m6_pattern          = re.compile(r'\bM0?6\b', re.IGNORECASE)
        z_pattern           = re.compile(r'\bZ\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+|\[.*?\]))',re.IGNORECASE)
        g10_pattern         = re.compile(r'\bG10\b', re.IGNORECASE)
        current_tool        = None
        segment_lowest_z    = None
        segment_lowest_line = None
        results             = {}
        tool_order          = []
        g10_found           = False
        with open(filename, 'r') as f:
            for line_no, raw_line in enumerate(f, 1):               
                line = re.sub(r'\(.*?\)', '', raw_line)
                line = re.sub(r';.*$', '', line)
                line = line.strip()
                if not line:
                    continue
                if g10_pattern.search(line):
                    g10_found = True
                has_m6  = m6_pattern.search(line)
                t_match = t_pattern.search(line)
                if has_m6 and t_match:
                    self._commit_segment(current_tool, segment_lowest_z, segment_lowest_line,results, tool_order)
                    current_tool        = int(t_match.group(1))
                    segment_lowest_z    = None
                    segment_lowest_line = None
                    continue
                if current_tool is not None:
                    z_matches = z_pattern.findall(line)
                    for z_str in z_matches:
                        z_val = None
                        try:
                            z_val = float(z_str)
                        except ValueError:
                            if z_str.startswith('[') and z_str.endswith(']'):
                                expr = z_str[1:-1].strip()
                                if '#' not in expr:
                                    try:
                                        z_val = float(eval(expr, {"__builtins__": None}, {}))
                                    except Exception:
                                        pass  
                        if z_val is not None:
                            if segment_lowest_z is None or z_val < segment_lowest_z:
                                segment_lowest_z = z_val
                                segment_lowest_line = line_no                            
        self._commit_segment(current_tool, segment_lowest_z, segment_lowest_line,results, tool_order)
        return results, tool_order, g10_found

DESCRIPTION_LONG = """This plugin will show you the lowest value 
    each tool in your loaded program will go. </font></p>
    <p><font face="Verdana" size="2">It also watches and reports G10 (which can 
    reset tool offset values). </font></p>
    <p><font face="Verdana" size="2">I use this all the time as a sanity check 
    to ensure I'm giving myself enough Z clearance. </font></p>
    <p><a href="images/lowestz.jpg">
    <img border="2" src="images/lowestz_small.jpg" xthumbnail-orig-image="images/lowestz.jpg" width="200" height="150"></a>"""