# 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

###########################################
##                                       ##
##        Cycle Time Monitor 1.10        ##
##          www.tormachtips.com          ##
##                                       ##
###########################################

# 1.10 - Uses median historical cycle and tool-segment times for outlier-resistant typical runtime display  - 6/06/2026
# 1.09 - Combines Cycle Time Estimator & Program Completion, adds Tool Segment Estimation vs Actual Logging - 6/01/2026

# This combined plugin estimates cycle time on file load, tracks actual per-tool segment
# elapsed time during program execution, writes a clean estimate-vs-actual pipe log,
# and optionally sends email notifications on tool-change prompts and completion.
#
# This script supercedes the program_completion and cycle_estimator scripts. 
# They should be disabled and this used instead. 
#
# The email notifier is controlled by notify_mode in:
# /home/operator/gcode/python/program_completion_notifier_config.ini
#
# Ctrl+H cycles:
#   0 = Script disabled
#   1 = Notify on completion only
#   2 = Notify on tool changes and completion
#
# Legacy enabled is still accepted:
#   enabled = 0 maps to notify_mode = 0
#   enabled = 1 maps to notify_mode = 1

import linuxcnc
import threading
import time
from datetime import datetime
import os
import math
import subprocess
import sys
import types
import ConfigParser
import smtplib
import gtk
import glib
import re
import Tkinter as tk
from email.mime.text import MIMEText
from ui_hooks import plugin
import singletons
import constants

CURRENT_VER               = "1.10"
SCRIPT_NAME               = "Cycle Time Monitor"
DESCRIPTION               = "Estimates cycle time, logs actual per-tool segment time, and sends optional completion/tool-change notifications."
ENABLED                   = 1
DEV_MACHINE               = 1
DEV_MACHINE_FLAG          = "/home/operator/gcode/python/dev_machine.txt"
ARCHIVE_GCODE_LOG         = "/home/operator/gcode/python/tormachtips_gcode_log.txt"
NOTIFIER_CONFIG           = "/home/operator/gcode/python/cycle_time_config.ini"
SEGMENT_LOG               = "/home/operator/gcode/python/cycle_time_main_log.txt"
TOOL_SEGMENT_TIMING_LOG   = "/home/operator/gcode/python/cycle_time_tool_log.txt"
CYCLE_RUN_TIMING_LOG      = "/home/operator/gcode/python/cycle_time_cycle_log.txt"
CYCLE_TIME_TAB_STATUS     = "/home/operator/gcode/python/cycle_time_tab_status.txt"
CYCLE_TIME_TAB_REFRESH_MS = 1000
TOOL_LOG_PROGRAM_WIDTH    = 100
TOOL_LOG_RUN_WIDTH        = 20
TOOL_LOG_SEGMENT_WIDTH    = 10
TOOL_LOG_TOOL_WIDTH       = 6
TOOL_LOG_RESULT_WIDTH     = 10
TOOL_LOG_TIME_WIDTH       = 10
TOOL_LOG_DELTA_WIDTH      = 10
TOOL_LOG_PERCENT_WIDTH    = 8
LOG_FILENAME_WIDTH        = 100
LOG_SEGMENT_WIDTH         = 10
LOG_TOOL_WIDTH            = 6
LOG_EVENT_WIDTH           = 28
MODE_DISABLED             = 0
MODE_COMPLETE_ONLY        = 1
MODE_TOOL_AND_DONE        = 2
TOOL_CHANGE_SECS          = 15.0
WRITE_ANNO_FILE           = 0
DEFAULT_UNITS             = "inches"
DEFAULT_FEEDRATE          = 50
DEFAULT_RAPIDRATE         = 100
TOOLCHANGE_SECONDS        = 5
G30_SECONDS               = 5
M30_CHECKER               = 1
M30_POPUP_DELAY           = 5
DEFAULT_CONFIG_TEXT       = """[settings]
notify_mode = 1
; notify_mode:
;   0 = Script disabled
;   1 = Notify on completion only
;   2 = Notify on tool changes and completion
;
; Ctrl+H cycles through these three modes.
[email]
sender = your_email@example.com
recipient = recipient@example.com
subject = {event}
body = {event}\\n\\nProgram: {filename}\\nElapsed: {elapsed}\\nTool: {tool}\\nPath: {path}
smtp_server = smtp.gmail.com
smtp_port = 465
smtp_username = your_email@example.com
smtp_password = app_password"""

class M30PopupProcess(object):   
    def __init__(self, full_path):
        self.full_path = full_path

    def _is_nc_file(self):
        return os.path.splitext(self.full_path)[1].lower() == '.nc'

    def _get_last_non_whitespace_line(self):
        last_line = None
        with open(self.full_path, 'r') as f:
            for raw in f:
                stripped = raw.strip()
                if stripped:
                    last_line = stripped
        return last_line

    def _last_line_has_end_code(self):
        try:
            last_line = self._get_last_non_whitespace_line()
            if not last_line:
                return False
            return re.search(r'^\s*(?:M30|M2)\s*(?:\(.*\)|;.*)?\s*$', last_line, re.IGNORECASE) is not None                   
        except:
            return True

    def _append_m30(self):
        with open(self.full_path, 'a') as f:
            f.write('\nM30\n')

    def run(self):
        if not os.path.isfile(self.full_path):
            return 0
        if self._is_nc_file():
            pass
        else:
            return 0
        if self._last_line_has_end_code():
            return 0
        root = tk.Tk()
        root.title("EOF Marker Not Found")
        root.resizable(False, False)
        msg = "G-code does not end with M2 or M30.\n\nAdd M30 to end of this program?\n\nTo disable this message, open cycle_time_monitor_plugin.py\nand set M30_CHECKER to 0."
        tk.Label(root, text=msg, justify='left', padx=20, pady=15).pack()
        btn_frame = tk.Frame(root, padx=10, pady=10)
        btn_frame.pack()

        def do_yes():
            try:
                if not self._last_line_has_end_code():
                    self._append_m30()
            finally:
                root.destroy()

        def do_no():
            root.destroy()

        tk.Button(btn_frame, text="Yes", width=10, command=do_yes).pack(side='left', padx=5)
        tk.Button(btn_frame, text="No", width=10, command=do_no).pack(side='left', padx=5)
        try:
            root.attributes('-topmost', True)
            root.lift()
        except:
            pass
        root.mainloop()
        return 0

class LastRunHistory(object):
    def __init__(self, archive_log_path, error_handler=None):
        self.archive_log_path = archive_log_path
        self.error_handler = error_handler

    def _write_error(self, msg):
        if self.error_handler:
            self.error_handler.write(msg, constants.ALARM_LEVEL_LOW)

    def format_elapsed_since(self, delta):
        total_seconds = int(delta.total_seconds())
        if total_seconds < 0:
            total_seconds = 0
        days = total_seconds // 86400
        hours = (total_seconds % 86400) // 3600
        minutes = (total_seconds % 3600) // 60
        if days > 0:
            return "{}d {}h ago".format(days, hours)
        if hours > 0:
            return "{}h {}m ago".format(hours, minutes)
        return "{}m ago".format(minutes)

    def get_run_history(self, full_path):
        if not os.path.isfile(self.archive_log_path):
            return 0, None, None
        target_candidates = set([
            full_path,
            os.path.relpath(full_path, '/home/operator'),
            os.path.relpath(full_path, '/home/operator/gcode')])
        last_run_dt = None
        last_run_type = None
        run_count = 0
        try:
            with open(self.archive_log_path, 'r') as f:
                for raw in f:
                    line = raw.strip()
                    if not line:
                        continue
                    parts = [p.strip() for p in line.split('|', 3)]
                    if len(parts) != 4:
                        continue
                    dt_txt, machine, logged_file, status = parts
                    logged_file = logged_file.strip()
                    if logged_file not in target_candidates:
                        continue
                    status_l = status.lower()
                    if not status_l.startswith('program stopped'):
                        continue
                    run_count += 1
                    try:
                        dt_val = datetime.strptime(dt_txt, '%Y-%m-%d %H:%M:%S.%f')
                    except:
                        try:
                            dt_val = datetime.strptime(dt_txt, '%Y-%m-%d %H:%M:%S')
                        except:
                            continue
                    run_type = None
                    if 'complete run' in status_l:
                        run_type = 'complete'
                    elif 'partial run' in status_l:
                        run_type = 'partial'
                    if last_run_dt is None or dt_val > last_run_dt:
                        last_run_dt = dt_val
                        last_run_type = run_type
        except Exception, e:
            self._write_error("Archive history read error: " + str(e))
            return 0, None, None
        return run_count, last_run_dt, last_run_type

class CycleTimeEstimator(object):
    def __init__(self, error_handler=None):
        self.error_handler = error_handler
        self.tool_segments = []

    def _write_error(self, msg):
        if self.error_handler:
            self.error_handler.write(msg, constants.ALARM_LEVEL_LOW)

    def _format_time_comment(self, seconds):
        seconds = max(1, int(math.ceil(seconds)))
        if seconds < 60:
            return '{}s'.format(seconds)
        m, s = divmod(seconds, 60)
        return '{}m {}s'.format(m, s)

    def estimate_runtime(self, gcode_path):
        self.tool_segments = []
        if not os.path.isfile(gcode_path):
            return 0.0, DEFAULT_UNITS
        total_estimate, units = self._first_pass_runtime(gcode_path)
        total_actual, _, self.tool_segments = self._second_pass_annotation(gcode_path, total_estimate, units)
        return round(total_actual / 60.0, 2), units

    def _first_pass_runtime(self, gcode_path):
        units = DEFAULT_UNITS
        tmp_feed = DEFAULT_FEEDRATE
        tmp_mode = None
        tmp_pos = {'X': 0.0, 'Y': 0.0, 'Z': 0.0}
        total_seconds_estimate = 0.0
        with open(gcode_path) as f:
            for raw in f:
                l = re.split(r'[;#(]', raw.strip())[0].upper()
                if not l:
                    continue
                if 'G20' in l:
                    units = 'inches'
                elif 'G21' in l:
                    units = 'mm'
                g4_match = re.search(r'\bG4\b(?:\s+P\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)))?', l)
                if g4_match:
                    try:
                        dwell_seconds = float(g4_match.group(1) or 0.0)
                        total_seconds_estimate += max(0.0, dwell_seconds)
                    except:
                        pass
                    continue
                if 'G0' in l:
                    tmp_mode = 'G0'
                elif 'G1' in l:
                    tmp_mode = 'G1'
                fm = re.search(r'F\s*(\d+\.?\d*|\.\d+)', l)
                if fm:
                    try:
                        tmp_feed = float(fm.group(1))
                        if units == 'mm':
                            tmp_feed /= 25.4
                    except:
                        pass
                if re.match(r'^G[23]\s', l):
                    t = self._process_arc(l, tmp_pos, tmp_feed, units, tmp_mode)
                    if t:
                        total_seconds_estimate += t
                    continue
                coords = self._extract_coords(l)
                if coords:
                    t = self._process_linear_move(tmp_pos, coords, tmp_mode, tmp_feed, DEFAULT_RAPIDRATE, units)
                    total_seconds_estimate += t
                    tmp_pos.update(coords)
        return total_seconds_estimate, units

    def _extract_tool_change(self, code):
        m6_tool_match = re.search(r'\bM6\b[^;#(]*?\bT\s*(\d+)\b', code)
        if m6_tool_match:
            return int(m6_tool_match.group(1))
        tool_m6_match = re.search(r'\bT\s*(\d+)\b[^;#(]*?\bM6\b', code)
        if tool_m6_match:
            return int(tool_m6_match.group(1))
        return None

    def _second_pass_annotation(self, gcode_path, total_estimate, units):
        pos = {'X': 0.0, 'Y': 0.0, 'Z': 0.0}
        last_feedrate = DEFAULT_FEEDRATE
        motion_mode = None
        prev_tool = None
        total_seconds = 0.0
        inject_g30_delay = False
        annotated_lines = []
        output_path = None
        tool_segments = []
        active_tool = None
        segment_start_seconds = 0.0
        if WRITE_ANNO_FILE:
            filename = os.path.basename(gcode_path)
            output_path = os.path.join('/home/operator/gcode', filename + '.time.nc')
        with open(gcode_path) as f:
            for raw_line in f:
                code = re.split(r'[;#(]', raw_line.strip())[0].strip().upper()
                tool_num = self._extract_tool_change(code)
                if tool_num is not None and tool_num != active_tool:
                    if active_tool is not None or total_seconds > segment_start_seconds:
                        tool_segments.append((active_tool, total_seconds - segment_start_seconds))
                    active_tool = tool_num
                    segment_start_seconds = total_seconds
                annotated, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay = \
                    self._process_line(raw_line, pos, motion_mode, last_feedrate, prev_tool, total_seconds, total_estimate, inject_g30_delay, units)
                if WRITE_ANNO_FILE:
                    annotated_lines.append(annotated)
        if active_tool is not None or total_seconds > segment_start_seconds:
            tool_segments.append((active_tool, total_seconds - segment_start_seconds))
        if WRITE_ANNO_FILE:
            with open(output_path, 'w') as outf:
                for line in annotated_lines:
                    outf.write(line + '\n')
        return total_seconds, output_path, tool_segments

    def _process_line(self, raw_line, pos, motion_mode, last_feedrate, prev_tool, total_seconds, total_estimate, inject_g30_delay, units):
        original_line = raw_line.rstrip('\r\n')
        line = original_line.strip()
        if not line or line.startswith(('#', ';', '(')):
            return original_line, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
        code = re.split(r'[;#(]', line)[0].strip().upper()
        tool_num = self._extract_tool_change(code)
        if tool_num is not None:
            if tool_num != prev_tool:
                total_seconds += TOOLCHANGE_SECONDS
                prev_tool = tool_num
                remaining = max(0.0, total_estimate - total_seconds)
                annotated = original_line + ' ; takes ' + self._format_time_comment(TOOLCHANGE_SECONDS) + ' | ' + self._format_time_comment(remaining) + ' left (5 sec padding for tool change)'
                return annotated, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
            return original_line, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
        if 'G20' in code:
            units = 'inches'
        elif 'G21' in code:
            units = 'mm'
        g4_match = re.search(r'\bG4\b(?:\s+P\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)))?', code)
        if g4_match:
            try:
                dwell_seconds = max(0.0, float(g4_match.group(1) or 0.0))
            except:
                dwell_seconds = 0.0
            total_seconds += dwell_seconds
            remaining = max(0.0, total_estimate - total_seconds)
            annotated = original_line + ' ; takes ' + self._format_time_comment(dwell_seconds) + ' | ' + self._format_time_comment(remaining) + ' left (G4 dwell)'
            return annotated, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
        if 'G0' in code:
            motion_mode = 'G0'
        elif 'G1' in code:
            motion_mode = 'G1'
        feed_match = re.search(r'F\s*(\d+\.?\d*|\.\d+)', code)
        if feed_match:
            try:
                last_feedrate = float(feed_match.group(1))
                if units == 'mm':
                    last_feedrate /= 25.4
            except:
                pass
        if re.match(r'^G[23]\s', code):
            arc_time = self._process_arc(code, pos, last_feedrate, units, motion_mode)
            if arc_time is not None:
                total_seconds += arc_time
                remaining = max(0.0, total_estimate - total_seconds)
                annotated = original_line + ' ; takes ' + self._format_time_comment(arc_time) + ' | ' + self._format_time_comment(remaining) + ' left'
                return annotated, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
            return original_line, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
        if 'G30' in code:
            total_seconds += G30_SECONDS
            remaining = max(0.0, total_estimate - total_seconds)
            annotated = original_line + ' ; takes ' + self._format_time_comment(G30_SECONDS) + ' | ' + self._format_time_comment(remaining) + ' left (%d second G30 pre-move)' % G30_SECONDS
            inject_g30_delay = True
            return annotated, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
        z_motion_match = re.search(r'\bG[01]\b[^;#(]*\bZ\s*[+-]?(?:\d+(?:\.\d*)?|\.\d+)', code)
        if inject_g30_delay and z_motion_match:
            total_seconds += G30_SECONDS
            remaining = max(0.0, total_estimate - total_seconds)
            annotated = original_line + ' ; takes ' + self._format_time_comment(G30_SECONDS) + ' | ' + self._format_time_comment(remaining) + ' left (%d second G30 pad)' % G30_SECONDS
            inject_g30_delay = False
            return annotated, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
        coords = self._extract_coords(code)
        if coords:
            move_time = self._process_linear_move(pos, coords, motion_mode, last_feedrate, DEFAULT_RAPIDRATE, units)
            total_seconds += move_time
            pos.update(coords)
            remaining = max(0.0, total_estimate - total_seconds)
            annotated = original_line + ' ; takes ' + self._format_time_comment(move_time) + ' | ' + self._format_time_comment(remaining) + ' left'
            return annotated, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay
        return original_line, total_seconds, pos, motion_mode, last_feedrate, prev_tool, inject_g30_delay

    def _extract_coords(self, line):
        pattern = r'([XYZ])\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))'
        return {a: float(v) for a, v in re.findall(pattern, line)}

    def _process_arc(self, line, pos, feedrate, units, mode):
        try:
            clockwise = 'G2' in line
            num = r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)'
            end_coords = {a: float(v) for a, v in re.findall(r'([XYZ])\s*(' + num + ')', line)}
            ijk = {a: float(v) for a, v in re.findall(r'([IJK])\s*(' + num + ')', line)}
            p_match = re.search(r'P\s*([+-]?\d+\.?\d*|\.\d+)', line)
            turns = float(p_match.group(1)) if p_match else None
            x0, y0 = pos.get('X', 0.0), pos.get('Y', 0.0)
            x1 = end_coords.get('X', x0)
            y1 = end_coords.get('Y', y0)
            i = ijk.get('I', 0.0)
            j = ijk.get('J', 0.0)
            cx, cy = x0 + i, y0 + j
            r = math.hypot(i, j)
            start_angle = math.atan2(y0 - cy, x0 - cx)
            end_angle = math.atan2(y1 - cy, x1 - cx)
            if turns is not None:
                arc_length = 2 * math.pi * r * turns
            else:
                delta = end_angle - start_angle
                delta = (delta + math.pi) % (2 * math.pi) - math.pi
                if clockwise and delta > 0:
                    delta -= 2 * math.pi
                elif not clockwise and delta < 0:
                    delta += 2 * math.pi
                arc_length = abs(delta) * r
            z0 = pos.get('Z', 0.0)
            z1 = end_coords.get('Z', z0)
            z_delta = z1 - z0
            path_length = math.hypot(arc_length, z_delta) if abs(z_delta) > 1e-6 else arc_length
            if units == 'mm':
                path_length /= 25.4
            rate = feedrate if feedrate > 0 else DEFAULT_FEEDRATE
            arc_time_sec = (path_length / rate) * 60.0
            pos.update(end_coords)
            return arc_time_sec
        except Exception, e:
            self._write_error("Arc parse failed: " + str(e))
            return None

    def _process_linear_move(self, pos, new_coords, mode, feedrate, rapidrate, units):
        if not mode:
            return 0.0
        dist = math.sqrt(sum((new_coords.get(a, pos[a]) - pos[a]) ** 2 for a in 'XYZ'))
        if dist < 1e-6:
            return 0.0
        if units == 'mm':
            dist /= 25.4
        rate = rapidrate if mode == 'G0' else (feedrate if feedrate > 0 else DEFAULT_FEEDRATE)
        return (dist / rate) * 60.0

def format_minutes_and_seconds(total_minutes):
    total_seconds = int(round(total_minutes * 60))
    minutes = total_seconds // 60
    seconds = total_seconds % 60
    return "%d min %d sec" % (minutes, seconds)

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, SCRIPT_NAME)
        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.config_file = NOTIFIER_CONFIG
            self.active_program_path = None
            self.active_start_time = None
            self.original_interp_task_status_change = None
            self.original_set_message_line_text = None
            self.original_load_gcode_file = None
            self.last_tool_change_key = ""
            self.last_tool_change_time = 0.0
            self.active_segment_tool = ""
            self.active_segment_start_time = None
            self.active_segment_number = 0
            self.active_run_id = ""
            self.active_total_estimate_seconds = None
            self.loaded_estimate_rows = []
            self.loaded_total_estimate_seconds = None
            self.loaded_estimate_program_path = ""
            self.loaded_estimate_program_signature = (0, 0)
            self.segment_estimates = {}
            self.cycle_time_tab_actual_segments = {}
            self.cycle_time_tab_cycle_actual_seconds = None
            self.cycle_time_tab_cycle_result = ""
            self.cycle_time_tab_waiting_for_tool_change = False
            self.cycle_time_tab_waiting_tool_text = ""
            self.cycle_time_tab_waiting_since = None
            self.cycle_time_tab_refresh_timer = None
            self.cycle_time = CycleTimeEstimator()
            self.last_run = LastRunHistory(ARCHIVE_GCODE_LOG)
            self._m30_prompt_lock = threading.Lock()
            self._m30_prompt_active = {}
            self.ensure_default_config()
            t = threading.Thread(target=self.setup_hook)
            t.daemon = True
            t.start()
            glib.timeout_add(3000, self.setup_key_handler)
            self.cycle_time_tab_refresh_timer = glib.timeout_add(CYCLE_TIME_TAB_REFRESH_MS, self.refresh_cycle_time_tab_snapshot)            
            self.write_status("loaded (Ctrl+H cycles notification mode)")
            self.write_status("current mode: " + self.get_mode_text(self.get_notify_mode()))
            return
        if dev_machine_found:
            self.write_status("dev machine found. Plugin loaded, but disabled by DEV_MACHINE.")
        else:
            self.write_status("loaded, but disabled.")
            self.write_status("To enable, open script, find ENABLED = 0 and change to ENABLED = 1")

    def write_status(self, msg, level=constants.ALARM_LEVEL_QUIET):
        try:
            self.error_handler.write("[" + SCRIPT_NAME + "] " + msg, level)
        except Exception:
            try:
                ui = getattr(singletons, "g_Machine", None)
                err_handler = getattr(ui, "error_handler", None)
                if err_handler:
                    err_handler.write("[" + SCRIPT_NAME + "] " + msg, level)
            except Exception:
                pass

    def ensure_default_config(self):
        try:
            if os.path.exists(self.config_file):
                self.upgrade_config_if_needed()
                return
            config_dir = os.path.dirname(self.config_file)
            if config_dir and not os.path.isdir(config_dir):
                os.makedirs(config_dir)
            handle = open(self.config_file, "wb")
            try:
                handle.write(DEFAULT_CONFIG_TEXT)
            finally:
                handle.close()
            self.write_status("created default config: " + self.config_file)
        except Exception as e:
            self.write_status("failed to create default config: " + str(e), constants.ALARM_LEVEL_QUIET)

    def upgrade_config_if_needed(self):
        try:
            config = ConfigParser.ConfigParser()
            config.read(self.config_file)
            changed = False
            if not config.has_section("settings"):
                config.add_section("settings")
                changed = True
            if not config.has_option("settings", "notify_mode"):
                if config.has_option("settings", "enabled"):
                    try:
                        enabled = config.getint("settings", "enabled")
                    except Exception:
                        enabled = 1
                    if enabled:
                        config.set("settings", "notify_mode", str(MODE_COMPLETE_ONLY))
                    else:
                        config.set("settings", "notify_mode", str(MODE_DISABLED))
                else:
                    config.set("settings", "notify_mode", str(MODE_COMPLETE_ONLY))
                changed = True
            if not config.has_section("email"):
                config.add_section("email")
                changed = True
            defaults = ConfigParser.ConfigParser()
            defaults.readfp(FakeConfigFile(DEFAULT_CONFIG_TEXT))
            for option in defaults.options("email"):
                if not config.has_option("email", option):
                    config.set("email", option, defaults.get("email", option))
                    changed = True
            if changed:
                with open(self.config_file, "w") as f:
                    config.write(f)
                self.write_status("updated config format: " + self.config_file)
        except Exception as e:
            self.write_status("config upgrade skipped: " + str(e), constants.ALARM_LEVEL_QUIET)

    def _load_config(self):
        if not os.path.exists(self.config_file):
            raise IOError("Config file not found")
        config = ConfigParser.ConfigParser()
        config.read(self.config_file)
        return config

    def get_notify_mode(self):
        try:
            config = self._load_config()
            if config.has_option("settings", "notify_mode"):
                mode = config.getint("settings", "notify_mode")
                if mode < MODE_DISABLED:
                    return MODE_DISABLED
                if mode > MODE_TOOL_AND_DONE:
                    return MODE_TOOL_AND_DONE
                return mode
            if config.has_option("settings", "enabled"):
                enabled = config.getint("settings", "enabled")
                if enabled:
                    return MODE_COMPLETE_ONLY
                return MODE_DISABLED
        except Exception:
            pass
        return MODE_DISABLED

    def set_notify_mode(self, mode):
        config = self._load_config()
        if not config.has_section("settings"):
            config.add_section("settings")
        config.set("settings", "notify_mode", str(mode))
        if config.has_option("settings", "enabled"):
            config.set("settings", "enabled", "1" if mode else "0")
        with open(self.config_file, "w") as f:
            config.write(f)

    def get_mode_text(self, mode):
        if mode == MODE_DISABLED:
            return "Email notifications disabled. Segment timing remains active."
        if mode == MODE_COMPLETE_ONLY:
            return "Email on completion only. Segment timing remains active."
        if mode == MODE_TOOL_AND_DONE:
            return "Email on tool changes and completion. Segment timing remains active."
        return "Unknown mode"

    def toggle_enabled(self):
        self.cycle_notify_mode()

    def cycle_notify_mode(self):
        try:
            current_mode = self.get_notify_mode()
            if current_mode == MODE_DISABLED:
                new_mode = MODE_COMPLETE_ONLY
            elif current_mode == MODE_COMPLETE_ONLY:
                new_mode = MODE_TOOL_AND_DONE
            else:
                new_mode = MODE_DISABLED
            self.set_notify_mode(new_mode)
            self.write_status("Mode: " + self.get_mode_text(new_mode), constants.ALARM_LEVEL_QUIET)
        except Exception as e:
            self.write_status("toggle error: " + str(e), constants.ALARM_LEVEL_QUIET)

    def notify_completion_enabled(self):
        return self.get_notify_mode() in (MODE_COMPLETE_ONLY, MODE_TOOL_AND_DONE)

    def notify_tool_change_enabled(self):
        return self.get_notify_mode() == MODE_TOOL_AND_DONE

    def get_current_program_path(self, ui):
        try:
            if hasattr(ui, "get_current_gcode_path"):
                path = ui.get_current_gcode_path()
                if path:
                    return path
            if hasattr(ui, "current_gcode_file_path"):
                path = ui.current_gcode_file_path
                if path:
                    return path
            if hasattr(ui, "last_gcode_program_path"):
                path = ui.last_gcode_program_path
                if path:
                    return path
        except Exception:
            pass
        return ""

    def get_first_program_tool(self, program_path):
        if not program_path or not os.path.isfile(program_path):
            return ""
        try:
            with open(program_path, "r") as f:
                for raw_line in f:
                    code = re.split(r"[;#(]", raw_line.strip())[0].strip().upper()
                    if not code:
                        continue
                    match = re.search(r"\bM0?6\b[^;#(]*?\bT\s*(\d+)\b", code)
                    if match is None:
                        match = re.search(r"\bT\s*(\d+)\b[^;#(]*?\bM0?6\b", code)
                    if match:
                        return "T" + match.group(1)
        except Exception as e:
            self.write_status("first tool read error: " + str(e), constants.ALARM_LEVEL_LOW)
        return ""

    def get_loaded_tool(self, ui):
        try:
            tool_number = int(getattr(ui.status, "tool_in_spindle", 0))
            if tool_number > 0:
                return "T%d" % tool_number
        except Exception:
            pass
        return "Tool unknown"

    def update_tool_change_wait_state(self):
        if not self.cycle_time_tab_waiting_for_tool_change:
            return
        try:
            if self.cycle_time_tab_waiting_since is not None:
                if (time.time() - self.cycle_time_tab_waiting_since) < 2.0:
                    return
            ui = self.ui or getattr(singletons, "g_Machine", None)
            if not ui:
                return
            loaded_tool = self.get_loaded_tool(ui)
            if (
                self.cycle_time_tab_waiting_tool_text and
                loaded_tool == self.cycle_time_tab_waiting_tool_text
            ):
                self.cycle_time_tab_waiting_for_tool_change = False
                self.cycle_time_tab_waiting_tool_text = ""
                self.cycle_time_tab_waiting_since = None
                self.publish_cycle_time_tab_snapshot()
        except Exception as e:
            self.write_status("tool-change wait-state update error: " + str(e), constants.ALARM_LEVEL_QUIET)

    def get_initial_segment_tool(self, ui, program_path):
        tool_text = self.get_first_program_tool(program_path)
        if tool_text:
            return tool_text
        return self.get_loaded_tool(ui)

    def get_program_display_name(self, program_path):
        if program_path:
            return os.path.basename(program_path)
        return "Unknown program"

    def write_file_loaded_log(self, program_path):
        old_program_path = self.active_program_path
        self.active_program_path = program_path
        try:
            self.write_segment_log("File loaded", 0, "", "", program_path)
        finally:
            self.active_program_path = old_program_path

    def ensure_segment_log_header(self):
        try:
            if os.path.exists(SEGMENT_LOG) and os.path.getsize(SEGMENT_LOG) > 0:
                return
            log_dir = os.path.dirname(SEGMENT_LOG)
            if log_dir and os.path.isdir(log_dir) == False:
                os.makedirs(log_dir)
            with open(SEGMENT_LOG, "a") as f:
                f.write("%-19s | %-*s | %-*s | %-*s | %-*s | %-8s | %s\n" % (
                    "Timestamp",
                    LOG_FILENAME_WIDTH, "Program",
                    LOG_EVENT_WIDTH, "Event",
                    LOG_SEGMENT_WIDTH, "Segment",
                    LOG_TOOL_WIDTH, "Tool",
                    "Elapsed",
                    "Detail"))
                f.write("%s | %s | %s | %s | %s | %s | %s\n" % (
                    "-" * 19,
                    "-" * LOG_FILENAME_WIDTH,
                    "-" * LOG_EVENT_WIDTH,
                    "-" * LOG_SEGMENT_WIDTH,
                    "-" * LOG_TOOL_WIDTH,
                    "-" * 8,
                    "-" * 40))
        except Exception as e:
            self.write_status("segment log header error: " + str(e), constants.ALARM_LEVEL_LOW)

    def write_segment_log(self, event_text, segment_number=0, tool_text="", elapsed_text="", detail_text=""):
        try:
            timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
            program_path = self.active_program_path
            if not program_path and self.ui:
                program_path = self.get_current_program_path(self.ui)
            filename = self.get_program_display_name(program_path)
            if segment_number > 0:
                segment_text = "Segment %d" % segment_number
            else:
                segment_text = ""
            self.ensure_segment_log_header()            
            with open(SEGMENT_LOG, "a") as f:
                f.write("%s | %-*s | %-*.*s | %-*.*s | %-*.*s | %-8s | %s\n" % (
                    timestamp,
                    LOG_FILENAME_WIDTH, filename,
                    LOG_EVENT_WIDTH, LOG_EVENT_WIDTH, event_text,
                    LOG_SEGMENT_WIDTH, LOG_SEGMENT_WIDTH, segment_text,
                    LOG_TOOL_WIDTH, LOG_TOOL_WIDTH, tool_text,
                    elapsed_text,
                    detail_text))
        except Exception as e:
            self.write_status("segment log write error: " + str(e), constants.ALARM_LEVEL_LOW)

    def clean_log_field(self, value):
        # Keep fixed-width pipe logs one-line and visually aligned.
        if value is None:
            return ""
        text = str(value)
        text = text.replace("|", "/")
        text = text.replace("\t", " ")
        text = text.replace("\r", " ")
        text = text.replace("\n", " ")
        return text.strip()

    def make_run_id(self, start_time):
        # Human-readable enough for troubleshooting, unique enough for repeated runs.
        millis = int((start_time - int(start_time)) * 1000)
        return time.strftime("%Y%m%d-%H%M%S", time.localtime(start_time)) + "-%03d" % millis

    def get_program_signature(self, program_path):
        # Used to make sure the in-memory estimate still belongs to the exact loaded file.
        try:
            if program_path and os.path.isfile(program_path):
                return int(os.path.getmtime(program_path)), int(os.path.getsize(program_path))
        except Exception as e:
            self.write_status("program signature error: " + str(e), constants.ALARM_LEVEL_LOW)
        return 0, 0

    def format_signed_seconds(self, seconds):
        try:
            seconds = int(round(seconds))
        except Exception:
            seconds = 0
        if seconds > 0:
            return "+" + self.format_elapsed_seconds(seconds)
        if seconds < 0:
            return "-" + self.format_elapsed_seconds(abs(seconds))
        return "0s"

    def parse_elapsed_text_to_seconds(self, text):
        try:
            text = str(text).strip().lower()
            if not text:
                return None
            total = 0
            matched = False
            for value, unit in re.findall(r'(\d+)\s*([dhms])', text):
                matched = True
                value = int(value)
                if unit == "d":
                    total += value * 86400
                elif unit == "h":
                    total += value * 3600
                elif unit == "m":
                    total += value * 60
                elif unit == "s":
                    total += value
            if matched:
                return total
            if text.endswith("sec"):
                return int(float(text.replace("sec", "").strip()))
            return int(float(text))
        except Exception:
            return None

    def split_pipe_log_line(self, line):
        try:
            parts = [p.strip() for p in line.rstrip("\r\n").split("|")]
            if len(parts) < 2:
                return []
            if parts[0] == "Timestamp" or parts[0].startswith("-"):
                return []
            return parts
        except Exception:
            return []

    def median_seconds(self, values):
        try:
            clean_values = sorted([float(v) for v in values])
            count = len(clean_values)
            if count == 0:
                return None
            middle = count // 2
            if count % 2:
                return clean_values[middle]
            return (clean_values[middle - 1] + clean_values[middle]) / 2.0
        except Exception:
            return None

    def get_history_averages_for_program(self, program_path):
        history = {
            "run_count": 0,
            "cycle_avg_seconds": None,
            "segment_avg_seconds": {}}
        try:
            program_name = self.get_program_display_name(program_path)
            cycle_values = []
            if os.path.isfile(CYCLE_RUN_TIMING_LOG):
                with open(CYCLE_RUN_TIMING_LOG, "r") as f:
                    for raw in f:
                        parts = self.split_pipe_log_line(raw)
                        if len(parts) < 7:
                            continue
                        logged_program = parts[1]
                        actual_text = parts[3]
                        result_text = parts[6].strip().lower().replace("[", "").replace("]", "")
                        if logged_program != program_name:
                            continue
                        if result_text != "completed":
                            continue
                        seconds = self.parse_elapsed_text_to_seconds(actual_text)
                        if seconds is not None:
                            cycle_values.append(seconds)
            if len(cycle_values) > 0:
                history["run_count"] = len(cycle_values)
                history["cycle_avg_seconds"] = self.median_seconds(cycle_values)
            segment_values = {}
            if os.path.isfile(TOOL_SEGMENT_TIMING_LOG):
                with open(TOOL_SEGMENT_TIMING_LOG, "r") as f:
                    for raw in f:
                        parts = self.split_pipe_log_line(raw)
                        if len(parts) < 9:
                            continue
                        logged_program = parts[1]
                        segment_text = parts[2]
                        actual_text = parts[5]
                        result_text = parts[8].strip().lower().replace("[", "").replace("]", "")
                        if logged_program != program_name:
                            continue
                        if result_text != "completed":
                            continue
                        match = re.search(r'(\d+)', segment_text)
                        if not match:
                            continue
                        segment_number = int(match.group(1))
                        seconds = self.parse_elapsed_text_to_seconds(actual_text)
                        if seconds is None:
                            continue
                        if segment_number not in segment_values:
                            segment_values[segment_number] = []
                        segment_values[segment_number].append(seconds)
            for segment_number in segment_values:
                values = segment_values[segment_number]
                if len(values) > 0:
                    history["segment_avg_seconds"][segment_number] = self.median_seconds(values)
        except Exception as e:
            self.write_status("history average read error: " + str(e), constants.ALARM_LEVEL_LOW)
        return history

    def apply_history_avg_to_row(self, row, average_seconds):
        try:
            if row is not None and average_seconds is not None:
                row["history_avg"] = self.format_elapsed_seconds(average_seconds)
        except Exception:
            pass
        return row

    def make_segment_timing_row_with_history(self, segment_number, segment_name, estimated_seconds, actual_seconds, result_text, running, history):
        row = self.make_timing_row(
            segment_name,
            estimated_seconds,
            actual_seconds,
            result_text,
            running)
        try:
            segment_averages = history.get("segment_avg_seconds", {})
            if segment_number in segment_averages:
                row["history_avg"] = self.format_elapsed_seconds(segment_averages[segment_number])
        except Exception:
            pass
        return row

    def make_timing_row(self, label_text, estimated_seconds, actual_seconds=None, result_text="", running=False):
        if estimated_seconds is None:
            estimated_text = "unknown"
        else:
            estimated_text = self.format_elapsed_seconds(estimated_seconds)
        history_avg_text = ""            
        if running:
            if actual_seconds is None:
                actual_text = "running"
                remaining_text = "unknown"
            else:
                actual_text = self.format_elapsed_seconds(actual_seconds)
                remaining_seconds = self.get_remaining_seconds(estimated_seconds, actual_seconds)
                remaining_text = self.format_elapsed_seconds(remaining_seconds) if remaining_seconds is not None else "unknown"
            return {
                "label": label_text,
                "actual": actual_text,
                "estimated": estimated_text,
                "amount": remaining_text,
                "timing": "remaining",
                "percent": "",
                "speed": "",
                "result": "%s" % result_text,
                "history_avg": history_avg_text,                }
        if actual_seconds is None:
            return {
                "label": label_text,
                "actual": "pending",
                "estimated": estimated_text,
                "amount": "",
                "timing": "estimated",
                "percent": "",
                "speed": "",
                "result": "pending",
                "history_avg": history_avg_text,                }
        actual_text = self.format_elapsed_seconds(actual_seconds)
        if estimated_seconds is None:
            return {
                "label": label_text,
                "actual": actual_text,
                "estimated": "unknown",
                "amount": "",
                "timing": "actual",
                "percent": "",
                "speed": "",
                "result": "%s" % result_text,
                "history_avg": history_avg_text}
        delta_seconds = actual_seconds - estimated_seconds
        abs_delta_text = self.format_elapsed_seconds(abs(delta_seconds))
        percent_text = self.format_delta_percent(estimated_seconds, actual_seconds).replace("+", "")
        if delta_seconds < 0:
            timing_text = "under estimate"
            speed_text = "faster than expected"
        elif delta_seconds > 0:
            timing_text = "over estimate"
            speed_text = "slower than expected"
        else:
            timing_text = "on estimate"
            speed_text = "as expected"
        return {
            "label": label_text,
            "actual": actual_text,
            "estimated": estimated_text,
            "amount": abs_delta_text,
            "timing": timing_text,
            "percent": percent_text,
            "speed": speed_text,
            "result": "%s" % result_text,
            "history_avg": history_avg_text,            }

    def format_timing_rows(self, rows):
        if len(rows) == 0:
            return []
        label_width = max([len(row["label"]) for row in rows])
        actual_width = max([len(row["actual"]) for row in rows])
        estimate_width = max([len(row["estimated"]) for row in rows])
        amount_width = max([len(row["amount"]) for row in rows] + [1])
        timing_width = max([len(row["timing"]) for row in rows] + [1])
        result_width = max([len(row["result"]) for row in rows])
        history_width = max([len(row.get("history_avg", "")) for row in rows] + [1])
        output = []
        for row in rows:
            base = "%-*s  %*s of %*s | %*s %-*s | %-*s" % (
                label_width,
                row["label"],
                actual_width,
                row["actual"],
                estimate_width,
                row["estimated"],
                amount_width,
                row["amount"],
                timing_width,
                row["timing"],
                result_width,
                row["result"])
            history_avg = row.get("history_avg", "")
            if history_avg:
                base = base + " | Hx Median: %*s" % (history_width, history_avg)
            output.append(base)
        return output

    def format_delta_percent(self, estimated_seconds, actual_seconds):
        if estimated_seconds is not None and estimated_seconds > 0:
            delta_seconds = actual_seconds - estimated_seconds
            return "%+.1f%%" % ((delta_seconds / estimated_seconds) * 100.0)
        return ""

    def ensure_cycle_run_timing_log_header(self):
        try:
            if os.path.exists(CYCLE_RUN_TIMING_LOG) and os.path.getsize(CYCLE_RUN_TIMING_LOG) > 0:
                return
            log_dir = os.path.dirname(CYCLE_RUN_TIMING_LOG)
            if log_dir and os.path.isdir(log_dir) == False:
                os.makedirs(log_dir)
            with open(CYCLE_RUN_TIMING_LOG, "a") as f:
                f.write("%-19s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %s\n" % (
                    "Timestamp",
                    TOOL_LOG_PROGRAM_WIDTH, "Program",
                    TOOL_LOG_TIME_WIDTH, "Estimated",
                    TOOL_LOG_TIME_WIDTH, "Actual",
                    TOOL_LOG_DELTA_WIDTH, "Delta",
                    TOOL_LOG_PERCENT_WIDTH, "Delta %",
                    TOOL_LOG_RESULT_WIDTH, "Result",
                    TOOL_LOG_RUN_WIDTH, "Run ID",
                    "Detail"))
                f.write("%s | %s | %s | %s | %s | %s | %s | %s | %s\n" % (
                    "-" * 19,
                    "-" * TOOL_LOG_PROGRAM_WIDTH,
                    "-" * TOOL_LOG_TIME_WIDTH,
                    "-" * TOOL_LOG_TIME_WIDTH,
                    "-" * TOOL_LOG_DELTA_WIDTH,
                    "-" * TOOL_LOG_PERCENT_WIDTH,
                    "-" * TOOL_LOG_RESULT_WIDTH,
                    "-" * TOOL_LOG_RUN_WIDTH,
                    "-" * 40))
        except Exception as e:
            self.write_status("cycle run timing header error: " + str(e), constants.ALARM_LEVEL_LOW)

    def write_cycle_run_timing_log(self, actual_seconds, result_text, detail_text):
        try:
            program_path = self.active_program_path
            if not program_path and self.ui:
                program_path = self.get_current_program_path(self.ui)
            filename = self.get_program_display_name(program_path)
            estimated_seconds = self.active_total_estimate_seconds
            estimated_text = ""
            delta_text = ""
            delta_percent_text = ""
            if estimated_seconds is not None:
                estimated_text = self.format_elapsed_seconds(estimated_seconds)
                delta_text = self.format_signed_seconds(actual_seconds - estimated_seconds)
                delta_percent_text = self.format_delta_percent(estimated_seconds, actual_seconds)
            timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
            self.ensure_cycle_run_timing_log_header()
            with open(CYCLE_RUN_TIMING_LOG, "a") as f:                                   
                f.write("%s | %-*s | %-*.*s | %-*.*s | %-*.*s | %-*.*s | %-*.*s | %-*.*s | %s\n" % (
                    timestamp,
                    TOOL_LOG_PROGRAM_WIDTH, self.clean_log_field(filename),
                    TOOL_LOG_TIME_WIDTH, TOOL_LOG_TIME_WIDTH, self.clean_log_field(estimated_text),
                    TOOL_LOG_TIME_WIDTH, TOOL_LOG_TIME_WIDTH, self.clean_log_field(self.format_elapsed_seconds(actual_seconds)),
                    TOOL_LOG_DELTA_WIDTH, TOOL_LOG_DELTA_WIDTH, self.clean_log_field(delta_text),
                    TOOL_LOG_PERCENT_WIDTH, TOOL_LOG_PERCENT_WIDTH, self.clean_log_field(delta_percent_text),
                    TOOL_LOG_RESULT_WIDTH, TOOL_LOG_RESULT_WIDTH, self.clean_log_field(result_text),
                    TOOL_LOG_RUN_WIDTH, TOOL_LOG_RUN_WIDTH, self.clean_log_field(self.active_run_id),
                    self.clean_log_field(detail_text)))
        except Exception as e:
            self.write_status("cycle run timing log write error: " + str(e), constants.ALARM_LEVEL_LOW)

    def ensure_tool_segment_timing_log_header(self):
        try:
            if os.path.exists(TOOL_SEGMENT_TIMING_LOG) and os.path.getsize(TOOL_SEGMENT_TIMING_LOG) > 0:
                return
            log_dir = os.path.dirname(TOOL_SEGMENT_TIMING_LOG)
            if log_dir and os.path.isdir(log_dir) == False:
                os.makedirs(log_dir)
            with open(TOOL_SEGMENT_TIMING_LOG, "a") as f:
                f.write("%-19s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %s\n" % (
                    "Timestamp",
                    TOOL_LOG_PROGRAM_WIDTH, "Program",
                    TOOL_LOG_SEGMENT_WIDTH, "Segment",
                    TOOL_LOG_TOOL_WIDTH, "Tool",
                    TOOL_LOG_TIME_WIDTH, "Estimated",
                    TOOL_LOG_TIME_WIDTH, "Actual",
                    TOOL_LOG_DELTA_WIDTH, "Delta",
                    TOOL_LOG_PERCENT_WIDTH, "Delta %",
                    TOOL_LOG_RESULT_WIDTH, "Result",
                    TOOL_LOG_RUN_WIDTH, "Run ID",
                    "Detail"))
                f.write("%s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s\n" % (
                    "-" * 19,
                    "-" * TOOL_LOG_PROGRAM_WIDTH,
                    "-" * TOOL_LOG_SEGMENT_WIDTH,
                    "-" * TOOL_LOG_TOOL_WIDTH,
                    "-" * TOOL_LOG_TIME_WIDTH,
                    "-" * TOOL_LOG_TIME_WIDTH,
                    "-" * TOOL_LOG_DELTA_WIDTH,
                    "-" * TOOL_LOG_PERCENT_WIDTH,
                    "-" * TOOL_LOG_RESULT_WIDTH,
                    "-" * TOOL_LOG_RUN_WIDTH,
                    "-" * 40))
        except Exception as e:
            self.write_status("tool segment timing header error: " + str(e), constants.ALARM_LEVEL_LOW)

    def write_tool_segment_timing_log(self, segment_number, tool_text, actual_seconds, result_text, detail_text):
        try:
            program_path = self.active_program_path
            if not program_path and self.ui:
                program_path = self.get_current_program_path(self.ui)
            filename = self.get_program_display_name(program_path)
            estimated_seconds = self.segment_estimates.get(segment_number)
            estimated_text = ""
            delta_text = ""
            delta_percent_text = ""
            if estimated_seconds is not None:
                estimated_text = self.format_elapsed_seconds(estimated_seconds)
                delta_text = self.format_signed_seconds(actual_seconds - estimated_seconds)
                delta_percent_text = self.format_delta_percent(estimated_seconds, actual_seconds)
            segment_text = "Segment %d" % segment_number
            timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
            self.ensure_tool_segment_timing_log_header()
            with open(TOOL_SEGMENT_TIMING_LOG, "a") as f:
                f.write("%s | %-*s | %-*.*s | %-*.*s | %-*.*s | %-*.*s | %-*.*s | %-*.*s | %-*.*s | %-*.*s | %s\n" % (
                    timestamp,
                    TOOL_LOG_PROGRAM_WIDTH, self.clean_log_field(filename),
                    TOOL_LOG_SEGMENT_WIDTH, TOOL_LOG_SEGMENT_WIDTH, segment_text,
                    TOOL_LOG_TOOL_WIDTH, TOOL_LOG_TOOL_WIDTH, self.clean_log_field(tool_text),
                    TOOL_LOG_TIME_WIDTH, TOOL_LOG_TIME_WIDTH, self.clean_log_field(estimated_text),
                    TOOL_LOG_TIME_WIDTH, TOOL_LOG_TIME_WIDTH, self.clean_log_field(self.format_elapsed_seconds(actual_seconds)),
                    TOOL_LOG_DELTA_WIDTH, TOOL_LOG_DELTA_WIDTH, self.clean_log_field(delta_text),
                    TOOL_LOG_PERCENT_WIDTH, TOOL_LOG_PERCENT_WIDTH, self.clean_log_field(delta_percent_text),
                    TOOL_LOG_RESULT_WIDTH, TOOL_LOG_RESULT_WIDTH, self.clean_log_field(result_text),
                    TOOL_LOG_RUN_WIDTH, TOOL_LOG_RUN_WIDTH, self.clean_log_field(self.active_run_id),
                    self.clean_log_field(detail_text)))
        except Exception as e:
            self.write_status("tool segment timing log write error: " + str(e), constants.ALARM_LEVEL_LOW)

    def get_actual_log_estimate_rows(self):
        # The live logger starts Segment 1 at Cycle Start using the first program tool.
        # Time before the first M6 is folded into Segment 1 so estimates and actuals line up.
        rows = []
        pre_tool_seconds = 0.0
        segment_number = 1
        for tool_num, segment_seconds in self.cycle_time.tool_segments:
            if tool_num is None:
                pre_tool_seconds += segment_seconds
            else:
                row_seconds = pre_tool_seconds + segment_seconds
                rows.append((segment_number, "T%d" % tool_num, row_seconds))
                pre_tool_seconds = 0.0
                segment_number += 1
        if len(rows) == 0 and pre_tool_seconds > 0.0:
            rows.append((1, "", pre_tool_seconds))
        return rows

    def set_loaded_estimates_from_current_file(self, program_path):
        self.loaded_estimate_rows = self.get_actual_log_estimate_rows()
        self.loaded_total_estimate_seconds = 0.0
        for segment_number, tool_text, estimated_seconds in self.loaded_estimate_rows:
            try:
                self.loaded_total_estimate_seconds += float(estimated_seconds)
            except Exception:
                pass
        self.loaded_estimate_program_path = os.path.normpath(program_path)
        self.loaded_estimate_program_signature = self.get_program_signature(program_path)

    def loaded_estimates_match_program(self, program_path):
        if program_path and self.loaded_estimate_program_path:
            normalized_program_path = os.path.normpath(program_path)
            if normalized_program_path == os.path.normpath(self.loaded_estimate_program_path):
                return self.get_program_signature(program_path) == self.loaded_estimate_program_signature
        return False

    def ensure_estimates_for_program(self, program_path):
        # Normal path: estimates were calculated on file load.
        # Fallback path: recalculate at cycle start if the load callback was missed.
        if self.loaded_estimates_match_program(program_path):
            return
        if program_path and os.path.isfile(program_path):
            self.cycle_time.estimate_runtime(program_path)
            self.set_loaded_estimates_from_current_file(program_path)

    def get_loaded_estimate_map(self):
        estimate_map = {}
        for segment_number, tool_text, estimated_seconds in self.loaded_estimate_rows:
            estimate_map[segment_number] = estimated_seconds
        return estimate_map

    def _get_last_non_whitespace_line(self, full_path):
        last_line = None
        with open(full_path, 'r') as f:
            for raw in f:
                stripped = raw.strip()
                if stripped:
                    last_line = stripped
        return last_line

    def _last_line_has_end_code(self, full_path):
        try:
            last_line = self._get_last_non_whitespace_line(full_path)
            if last_line:
                return re.search(r'^\s*(?:M30|M2)\s*(?:\(.*\)|;.*)?\s*$', last_line, re.IGNORECASE) is not None
            return False
        except Exception as e:
            self.write_status("M30 check error: " + str(e), constants.ALARM_LEVEL_LOW)
            return False

    def _is_nc_file(self, full_path):
        return os.path.splitext(full_path)[1].lower() == '.nc'

    def _show_m30_popup_async(self, full_path):
        if M30_CHECKER:
            pass
        else:
            return
        if self._is_nc_file(full_path):
            pass
        else:
            return
        with self._m30_prompt_lock:
            if self._m30_prompt_active.get(full_path):
                return
            self._m30_prompt_active[full_path] = True

        def _run_popup():
            try:
                time.sleep(M30_POPUP_DELAY)
                if os.path.isfile(full_path):
                    pass
                else:
                    return
                if self._last_line_has_end_code(full_path):
                    return
                subprocess.Popen([sys.executable, os.path.abspath(__file__), '--m30-popup', full_path], close_fds=True)
            except Exception as e:
                self.write_status("M30 popup launch error: " + str(e), constants.ALARM_LEVEL_LOW)
            finally:
                with self._m30_prompt_lock:
                    if full_path in self._m30_prompt_active:
                        del self._m30_prompt_active[full_path]

        t = threading.Thread(target=_run_popup)
        t.daemon = True
        t.start()

    def write_cycle_time_tab_snapshot_file(self, lines):
        try:
            directory = os.path.dirname(CYCLE_TIME_TAB_STATUS)
            if directory and not os.path.isdir(directory):
                os.makedirs(directory)
            tmp_path = CYCLE_TIME_TAB_STATUS + ".tmp"
            f = open(tmp_path, "w")
            try:
                f.write("\n".join(lines))
                f.write("\n")
            finally:
                f.close()
            os.rename(tmp_path, CYCLE_TIME_TAB_STATUS)
        except Exception as e:
            self.write_status("cycle time tab snapshot write error: " + str(e), constants.ALARM_LEVEL_LOW)

    def get_running_elapsed_seconds(self, start_time):
        if start_time is None:
            return None
        try:
            return max(0.0, time.time() - start_time)
        except Exception:
            return None

    def get_remaining_seconds(self, estimated_seconds, elapsed_seconds):
        if estimated_seconds is None or elapsed_seconds is None:
            return None
        try:
            return max(0.0, float(estimated_seconds) - float(elapsed_seconds))
        except Exception:
            return None

    def program_is_paused(self):
        try:
            ui = self.ui or getattr(singletons, "g_Machine", None)
            if not ui:
                return False
            if getattr(ui.status, "interp_state", None) == linuxcnc.INTERP_PAUSED:
                return True
            if getattr(ui.status, "paused", False):
                return True
        except Exception:
            pass
        return False

    def get_live_status_text(self):
        if self.program_is_paused():
            return "paused"
        if self.cycle_time_tab_waiting_for_tool_change:
            return "waiting"
        return "running"

    def refresh_cycle_time_tab_snapshot(self):
        try:
            if self.active_start_time is not None:
                self.update_tool_change_wait_state()
                self.publish_cycle_time_tab_snapshot()
        except Exception as e:
            self.write_status("cycle time tab live refresh error: " + str(e), constants.ALARM_LEVEL_LOW)
        return True

    def publish_cycle_time_tab_snapshot(self, program_path=""):
        try:
            if not program_path:
                program_path = self.active_program_path or self.loaded_estimate_program_path
            program_display = program_path if program_path else self.get_program_display_name(program_path)
            history = self.get_history_averages_for_program(program_path)
            header_label_width = len("Program")
            header_right_column = 72
            program_line = "%*s: %s" % (
                header_label_width,
                "Program",
                program_display)
            updated_text = "%*s: %s" % (
                header_label_width,
                "Updated",
                time.strftime("%Y-%m-%d %H:%M:%S"))
            run_count = history.get("run_count", 0)
            run_word = "run." if run_count == 1 else "runs."
            hx_text = "Median times built from %s completed %s" % (run_count, run_word)
            if len(updated_text) < header_right_column:
                updated_line = updated_text + (" " * (header_right_column - len(updated_text))) + hx_text
            else:
                updated_line = updated_text + "    " + hx_text
            lines = []
            lines.append(program_line)
            lines.append(updated_line)
            lines.append("")
            total_estimate_seconds = None
            if self.loaded_total_estimate_seconds is not None:
                total_estimate_seconds = self.loaded_total_estimate_seconds
            elif self.active_total_estimate_seconds is not None:
                total_estimate_seconds = self.active_total_estimate_seconds
            total_row = None
            if self.cycle_time_tab_cycle_actual_seconds is not None:
                total_row = self.make_timing_row(
                    "Total cycle:",
                    total_estimate_seconds,
                    self.cycle_time_tab_cycle_actual_seconds,
                    self.cycle_time_tab_cycle_result,
                    False)
            elif self.active_start_time is not None:
                elapsed_seconds = self.get_running_elapsed_seconds(self.active_start_time)
                total_status_text = self.get_live_status_text()
                total_row = self.make_timing_row(
                    "Total cycle:",
                    total_estimate_seconds,
                    elapsed_seconds,
                    total_status_text,
                    True)
            else:
                total_row = self.make_timing_row(
                    "Total cycle:",
                    total_estimate_seconds,
                    None,
                    "",
                    False)
            total_row = self.apply_history_avg_to_row(
                total_row,
                history.get("cycle_avg_seconds"))
            segment_rows = []
            if len(self.loaded_estimate_rows) > 0:
                for segment_number, tool_text, estimated_seconds in self.loaded_estimate_rows:
                    if tool_text:
                        segment_name = "Segment %d / %s:" % (segment_number, tool_text)
                    else:
                        segment_name = "Segment %d:" % segment_number
                    actual_data = self.cycle_time_tab_actual_segments.get(segment_number)
                    if actual_data:
                        actual_tool, actual_seconds, result_text = actual_data
                        row = self.make_segment_timing_row_with_history(
                            segment_number,
                            segment_name,
                            estimated_seconds,
                            actual_seconds,
                            result_text,
                            False,
                            history)
                        segment_rows.append(row)
                    else:
                        if (
                            self.active_start_time is not None and
                            self.active_segment_start_time is not None and
                            segment_number == self.active_segment_number
                        ):
                            elapsed_seconds = self.get_running_elapsed_seconds(self.active_segment_start_time)
                            segment_status_text = self.get_live_status_text()
                            row = self.make_segment_timing_row_with_history(
                                segment_number,
                                segment_name,
                                estimated_seconds,
                                elapsed_seconds,
                                segment_status_text,
                                True,
                                history)
                            segment_rows.append(row)
                        else:
                            row = self.make_segment_timing_row_with_history(
                                segment_number,
                                segment_name,
                                estimated_seconds,
                                None,
                                "",
                                False,
                                history)
                            segment_rows.append(row)
            all_rows = []
            if total_row is not None:
                all_rows.append(total_row)
            all_rows.extend(segment_rows)
            formatted_rows = self.format_timing_rows(all_rows)
            lines.append("Total Cycle Run")
            lines.append("----------------")
            if len(formatted_rows) > 0:
                lines.append(formatted_rows[0])
            else:
                lines.append("No total cycle estimate is available.")
            lines.append("")
            lines.append("Tool Segments")
            lines.append("-------------")
            if len(segment_rows) == 0:
                lines.append("No tool segment estimate is available.")
            else:
                for row_text in formatted_rows[1:]:
                    lines.append(row_text)
            self.write_cycle_time_tab_snapshot_file(lines)
        except Exception as e:
            self.write_status("cycle time tab snapshot error: " + str(e), constants.ALARM_LEVEL_LOW)

    def write_estimate_status_block(self, full_path, runtime_min, units):
        try:
            formatted = format_minutes_and_seconds(runtime_min)
            run_count, last_run_dt, last_run_type = self.last_run.get_run_history(full_path)
            output_lines = []
            output_lines.append("Cycle Estimation:")
            output_lines.append("  Estimated run time: %s" % formatted)
            if len(self.loaded_estimate_rows) > 0:
                output_lines.append(" ")
                output_lines.append("  Estimated time by tool segment:")
                for segment_number, tool_text, segment_seconds in self.loaded_estimate_rows:
                    if tool_text:
                        segment_name = "Segment %d / %s" % (segment_number, tool_text)
                    else:
                        segment_name = "Segment %d" % segment_number
                    output_lines.append("    %-20s %s" % (segment_name + ":", format_minutes_and_seconds(segment_seconds / 60.0)))
                output_lines.append(" ")
            output_lines.append("  Run history:        %s total runs (partial + complete)" % run_count)
            if last_run_dt is None:
                output_lines.append("  Last run:           no run history found in archive log.")
            else:
                now = datetime.now()
                elapsed = now - last_run_dt
                run_type_text = last_run_type if last_run_type else "unknown"
                output_lines.append("  Last run:           %s (%s) [%s]" % (last_run_dt.strftime('%Y-%m-%d %H:%M:%S'), self.last_run.format_elapsed_since(elapsed), run_type_text))
            self._write_status_block(output_lines)
        except Exception as e:
            self.write_status("estimate status block error: " + str(e), constants.ALARM_LEVEL_LOW)

    def _write_status_block(self, lines):
        try:
            block_lines = []
            block_lines.append("#######################################")
            block_lines.extend(lines)
            block_lines.append("#######################################")
            block_lines.append(" ")
            for line in reversed(block_lines):
                self.error_handler.write(line, constants.ALARM_LEVEL_QUIET)
        except Exception as e:
            self.write_status("Cycle Time status write error: " + str(e), constants.ALARM_LEVEL_LOW)

    def handle_file_loaded(self, loaded_path):
        # One plugin owns the file-load event, so estimates cannot get out of sync with actual tracking.
        loaded_path = os.path.normpath(loaded_path)
        self.write_file_loaded_log(loaded_path)
        self.reset_program_tracking("new file loaded", loaded_path)
        if os.path.isfile(loaded_path):
            pass
        else:
            self.write_status("Cycle Time file not found: " + loaded_path, constants.ALARM_LEVEL_LOW)
            return
        self._show_m30_popup_async(loaded_path)
        runtime_min, units = self.cycle_time.estimate_runtime(loaded_path)
        self.set_loaded_estimates_from_current_file(loaded_path)
        self.write_estimate_status_block(loaded_path, runtime_min, units)
        self.publish_cycle_time_tab_snapshot(loaded_path)        

    def write_stopped_segment_time(self, end_time):
        if self.active_segment_tool and self.active_segment_start_time is not None:
            elapsed_seconds = end_time - self.active_segment_start_time
            elapsed_text = self.format_elapsed_seconds(elapsed_seconds)
            self.write_segment_log("Segment stopped prematurely", self.active_segment_number, self.active_segment_tool, elapsed_text, "Program stopped before normal completion")
            self.write_tool_segment_timing_log(self.active_segment_number, self.active_segment_tool, elapsed_seconds, "stopped", "Program stopped before normal completion")
            self.cycle_time_tab_actual_segments[self.active_segment_number] = (self.active_segment_tool, elapsed_seconds, "stopped")
            self.publish_cycle_time_tab_snapshot()
            self.write_status(" ")
            self.write_status("========================================")
            self.write_status("Segment %d / %s was stopped after %s." % (self.active_segment_number, self.active_segment_tool, elapsed_text), constants.ALARM_LEVEL_QUIET)
            self.write_status("========================================")
            self.write_status(" ")

    def write_completed_segment_time(self, end_time):
        if self.active_segment_tool and self.active_segment_start_time is not None:
            elapsed_seconds = end_time - self.active_segment_start_time
            elapsed_text = self.format_elapsed_seconds(elapsed_seconds)
            self.write_segment_log("Segment completed", self.active_segment_number, self.active_segment_tool, elapsed_text, "Tool segment ended normally")
            self.write_tool_segment_timing_log(self.active_segment_number, self.active_segment_tool, elapsed_seconds, "completed", "Tool segment ended normally")
            self.cycle_time_tab_actual_segments[self.active_segment_number] = (self.active_segment_tool, elapsed_seconds, "completed")
            self.publish_cycle_time_tab_snapshot()            
            self.write_status(" ")
            self.write_status("========================================")
            self.write_status("Segment %d / %s took %s to complete." % (self.active_segment_number, self.active_segment_tool, elapsed_text), constants.ALARM_LEVEL_QUIET)
            self.write_status("========================================")
            self.write_status(" ")

    def reset_program_tracking(self, reason="", program_path=""):
        # Clears incomplete run state. This is intentionally not a completed segment log.
        self.active_program_path = program_path if program_path else None
        self.active_start_time = None
        self.active_segment_tool = ""
        self.active_segment_start_time = None
        self.active_segment_number = 0
        self.active_run_id = ""
        self.active_total_estimate_seconds = None
        self.segment_estimates = {}
        self.cycle_time_tab_actual_segments = {}
        self.cycle_time_tab_cycle_actual_seconds = None
        self.cycle_time_tab_cycle_result = ""
        self.cycle_time_tab_waiting_for_tool_change = False
        self.cycle_time_tab_waiting_tool_text = ""
        self.cycle_time_tab_waiting_since = None
        self.last_tool_change_key = ""
        self.last_tool_change_time = 0.0
        if reason:
            self.write_status("segment timing reset: " + reason, constants.ALARM_LEVEL_QUIET)

    def format_elapsed_seconds(self, elapsed_seconds):
        try:
            elapsed_seconds = int(round(elapsed_seconds))
        except Exception:
            elapsed_seconds = 0
        if elapsed_seconds < 0:
            elapsed_seconds = 0
        hours = elapsed_seconds // 3600
        minutes = (elapsed_seconds % 3600) // 60
        seconds = elapsed_seconds % 60
        if hours > 0:
            return "%dh %02dm %02ds" % (hours, minutes, seconds)
        if minutes > 0:
            return "%dm %02ds" % (minutes, seconds)
        return "%ds" % seconds

    def apply_email_tokens(self, text, event_text, program_path, elapsed_text, tool_text, message_text):
        filename = self.get_program_display_name(program_path)
        text = text.replace("{event}", event_text)
        text = text.replace("{filename}", filename)
        text = text.replace("{path}", program_path if program_path else "Unknown path")
        text = text.replace("{elapsed}", elapsed_text)
        text = text.replace("{tool}", tool_text if tool_text else "N/A")
        text = text.replace("{message}", message_text if message_text else "")
        return text

    def send_email_async(self, event_text, program_path="", elapsed_seconds=None, tool_text="", message_text=""):
        t = threading.Thread(
            target=self.send_email,
            args=(event_text, program_path, elapsed_seconds, tool_text, message_text)        )
        t.daemon = True
        t.start()

    def send_operation_complete_email_async(self, program_path="", elapsed_seconds=None):
        self.send_email_async(
            "Operation Complete",
            program_path,
            elapsed_seconds,
            "",
            ""        )

    def send_tool_change_email_async(self, tool_text, message_text=""):
        program_path = ""
        try:
            ui = self.ui or getattr(singletons, "g_Machine", None)
            if ui:
                program_path = self.get_current_program_path(ui)
        except Exception:
            pass
        self.send_email_async(
            "Tool Change Requested",
            program_path,
            None,
            tool_text,
            message_text        )

    def send_email(self, event_text, program_path="", elapsed_seconds=None, tool_text="", message_text=""):
        try:
            config = self._load_config()
            sender = config.get("email", "sender")
            recipient = config.get("email", "recipient")
            subject = config.get("email", "subject")
            body = config.get("email", "body").replace("\\n", "\n")
            smtp_server = config.get("email", "smtp_server")
            smtp_port = config.getint("email", "smtp_port")
            smtp_username = config.get("email", "smtp_username")
            smtp_password = config.get("email", "smtp_password")
            if elapsed_seconds is None:
                elapsed_text = "Unknown"
            else:
                elapsed_text = self.format_elapsed_seconds(elapsed_seconds)
            body_had_tokens = (
                "{event}" in body or
                "{filename}" in body or
                "{path}" in body or
                "{elapsed}" in body or
                "{tool}" in body or
                "{message}" in body            )
            subject = self.apply_email_tokens(subject, event_text, program_path, elapsed_text, tool_text, message_text)
            body = self.apply_email_tokens(body, event_text, program_path, elapsed_text, tool_text, message_text)
            if body_had_tokens == False:
                body = (
                    body +
                    "\n\nEvent: " + event_text +
                    "\nProgram: " + self.get_program_display_name(program_path) +
                    "\nElapsed: " + elapsed_text +
                    "\nTool: " + (tool_text if tool_text else "N/A")                )
                if message_text:
                    body = body + "\nMessage: " + message_text
            msg = MIMEText(body)
            msg["From"] = sender
            msg["To"] = recipient
            msg["Subject"] = subject
            smtp = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=15)
            smtp.login(smtp_username, smtp_password)
            smtp.sendmail(sender, [recipient], msg.as_string())
            smtp.quit()
            if tool_text:
                self.write_status("email sent for " + event_text + " " + tool_text)
            else:
                self.write_status("email sent for " + self.get_program_display_name(program_path))
            self.write_status(" ")
        except Exception as e:
            self.write_status("email send failed: " + str(e), constants.ALARM_LEVEL_QUIET)

    def setup_key_handler(self):
        main_ui = getattr(singletons, "g_Machine", None)
        if main_ui and hasattr(main_ui, "window"):
            main_ui.window.add_events(gtk.gdk.KEY_PRESS_MASK)
            main_ui.window.connect("key-press-event", self.on_global_key_press)
            self.write_status("Ctrl+H bound to mode cycle")
        return False

    def on_global_key_press(self, widget, event):
        if (event.state & gtk.gdk.CONTROL_MASK and event.keyval == gtk.keysyms.h):
            self.cycle_notify_mode()
            return True
        return False

    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 err_handler:
                        self.error_handler = err_handler
                    else:
                        raise Exception("No error_handler found")                    
                    self.cycle_time.error_handler = self.error_handler
                    self.last_run.error_handler = self.error_handler
                    if self.original_interp_task_status_change is None:
                        self.original_interp_task_status_change = self.ui.handle_interp_task_status_change
                        self.ui.handle_interp_task_status_change = types.MethodType(
                            self.handle_interp_task_status_change_wrapper,
                            self.ui                        )
                        self.write_status("completion hook installed")
                    if self.original_set_message_line_text is None and hasattr(self.ui, "set_message_line_text"):
                        self.original_set_message_line_text = self.ui.set_message_line_text
                        self.ui.set_message_line_text = types.MethodType(
                            self.set_message_line_text_wrapper,
                            self.ui                        )
                        self.write_status("tool-change message hook installed")
                    if self.original_load_gcode_file is None and hasattr(self.ui, "load_gcode_file"):
                        if getattr(self.ui, "_cycle_time_monitor_load_gcode_wrapped", False) == False:
                            self.original_load_gcode_file = self.ui.load_gcode_file
                            self.ui.load_gcode_file = types.MethodType(
                                self.load_gcode_file_wrapper,
                                self.ui                        )
                            self.ui._cycle_time_monitor_load_gcode_wrapped = True
                            self.write_status("file-load segment reset hook installed")
                    return
            except Exception as e:
                self.write_status("hook setup error: " + str(e), constants.ALARM_LEVEL_QUIET)
            time.sleep(0.5)

    def load_gcode_file_wrapper(self, ui, path, *args, **kwargs):
        result = self.original_load_gcode_file(path, *args, **kwargs)
        try:
            loaded_path = self.get_current_program_path(ui)
            if loaded_path and path:
                if os.path.normpath(loaded_path) == os.path.normpath(path):
                    self.handle_file_loaded(loaded_path)
        except Exception as e:
            self.write_status("file-load monitor error: " + str(e), constants.ALARM_LEVEL_QUIET)
        return result

    def handle_interp_task_status_change_wrapper(self, ui):
        prev_interp = ui.prev_lcnc_interp_state
        if self.original_interp_task_status_change:
            self.original_interp_task_status_change()
        if self.program_just_completed(ui, prev_interp):
            if self.program_ended_cleanly(ui):
                self.handle_program_completed(ui)
            else:
                self.handle_program_stopped(ui)
            return
        if self.program_just_started(ui, prev_interp):
            self.begin_new_program_run(ui)
            return
        if self.program_is_running(ui):
            self.update_tool_change_wait_state()
            self.capture_program_start(ui)
            return

    def set_message_line_text_wrapper(self, ui, message):
        try:
            self.handle_possible_tool_change_message(message)
        except Exception as e:
            self.write_status("tool-change detect error: " + str(e), constants.ALARM_LEVEL_QUIET)
        return self.original_set_message_line_text(message)

    def normalize_prompt_message(self, message):
        try:
            if message is None:
                return ""
            text = str(message)
            text = text.replace("*", " ")
            text = text.replace("$$REPLY_TEXT$$", "")
            text = re.sub(r"\s+", " ", text)
            return text.strip()
        except Exception:
            return ""

    def extract_tool_from_message(self, message):
        text = self.normalize_prompt_message(message)
        patterns = [
            r"\bInsert\s+T\s*([0-9]+)\b",
            r"\bInsert\s+tool\s*([0-9]+)\b",
            r"\bT\s*([0-9]+)\b"        ]
        for pattern in patterns:
            match = re.search(pattern, text, re.IGNORECASE)
            if match:
                return "T" + match.group(1), text
        return "", text

    def handle_possible_tool_change_message(self, message):
        tool_text, clean_message = self.extract_tool_from_message(message)
        if not tool_text:
            return
        now = time.time()
        dedupe_key = tool_text + "|" + clean_message
        if (
            dedupe_key == self.last_tool_change_key and
            (now - self.last_tool_change_time) < TOOL_CHANGE_SECS
        ):
            return
        self.last_tool_change_key = dedupe_key
        self.last_tool_change_time = now
        if self.active_segment_start_time is None:
            self.active_segment_number = 1
            self.active_segment_tool = tool_text
            self.active_segment_start_time = now
        elif tool_text != self.active_segment_tool:
            self.write_completed_segment_time(now)
            self.active_segment_number += 1
            self.active_segment_tool = tool_text
            self.active_segment_start_time = now
        self.cycle_time_tab_waiting_for_tool_change = True
        self.cycle_time_tab_waiting_tool_text = tool_text
        self.cycle_time_tab_waiting_since = now
        self.publish_cycle_time_tab_snapshot()
        self.write_status("detected tool change request: " + tool_text)
        self.write_segment_log("Tool change requested", self.active_segment_number, tool_text, "", clean_message)
        if self.notify_tool_change_enabled():
            self.send_tool_change_email_async(tool_text, clean_message)

    def program_just_started(self, ui, prev_interp):
        return (
            self.program_is_running(ui) and
            prev_interp == linuxcnc.INTERP_SEEKING        )

    def program_is_running(self, ui):
        return (
            ui.status.interp_state not in (
                linuxcnc.INTERP_IDLE,
                linuxcnc.INTERP_SEEKING
            ) and
            ui.status.task_mode == linuxcnc.MODE_AUTO and
            ui.status.task_state == linuxcnc.STATE_ON        )

    def program_ended_cleanly(self, ui):
        return (
            getattr(ui.status, "program_ended", False) or
            getattr(ui.status, "program_ended_and_reset", False)        )

    def program_just_completed(self, ui, prev_interp):
        # ESC can switch task_mode back to MODE_MANUAL before interp_state reaches IDLE.
        # Use the interpreter transition as the source of truth for end/stop detection.
        return (
            ui.status.interp_state == linuxcnc.INTERP_IDLE and
            prev_interp not in (
                linuxcnc.INTERP_IDLE,
                linuxcnc.INTERP_SEEKING
            ) and
            ui.status.task_state == linuxcnc.STATE_ON        )

    def begin_new_program_run(self, ui):
        # New cycle starts must not inherit segment timing from an aborted prior run.
        now = time.time()
        program_path = self.get_current_program_path(ui)
        self.ensure_estimates_for_program(program_path)
        self.active_program_path = program_path
        self.active_start_time = now
        self.active_run_id = self.make_run_id(now)
        self.segment_estimates = self.get_loaded_estimate_map()
        self.active_total_estimate_seconds = self.loaded_total_estimate_seconds
        self.cycle_time_tab_actual_segments = {}
        self.cycle_time_tab_cycle_actual_seconds = None
        self.cycle_time_tab_cycle_result = ""
        self.cycle_time_tab_waiting_for_tool_change = False
        self.cycle_time_tab_waiting_tool_text = ""
        self.cycle_time_tab_waiting_since = None
        self.active_total_estimate_seconds = self.loaded_total_estimate_seconds
        self.active_segment_number = 1
        self.active_segment_tool = self.get_initial_segment_tool(ui, program_path)
        self.active_segment_start_time = now
        self.last_tool_change_key = ""
        self.last_tool_change_time = 0.0
        self.cycle_time_tab_actual_segments = {}
        self.cycle_time_tab_cycle_actual_seconds = None
        self.cycle_time_tab_cycle_result = ""
        self.publish_cycle_time_tab_snapshot(program_path)        
        self.write_segment_log("Cycle started", self.active_segment_number, self.active_segment_tool, "", "New Cycle Start detected")
        self.publish_cycle_time_tab_snapshot(program_path)

    def capture_program_start(self, ui):
        program_path = self.get_current_program_path(ui)
        if self.active_start_time is None:
            self.begin_new_program_run(ui)
            return
        if program_path and self.active_program_path:
            if os.path.normpath(program_path) != os.path.normpath(self.active_program_path):
                self.begin_new_program_run(ui)
                return

    def handle_program_stopped(self, ui):
        program_path = self.active_program_path
        if not program_path:
            program_path = self.get_current_program_path(ui)
        filename = self.get_program_display_name(program_path)
        end_time = time.time()
        self.write_stopped_segment_time(end_time)
        if self.active_start_time is not None:
            elapsed_seconds = end_time - self.active_start_time
            self.write_cycle_run_timing_log(elapsed_seconds, "stopped", "Program stopped before normal completion")
            self.cycle_time_tab_cycle_actual_seconds = elapsed_seconds
            self.cycle_time_tab_cycle_result = "stopped"
            self.publish_cycle_time_tab_snapshot(program_path)
        self.write_status(" ")
        self.write_status("detected program stop: " + filename)
        self.write_segment_log("Program stopped", 0, "", "", filename)
        self.reset_program_tracking("program stopped", program_path)

    def handle_program_completed(self, ui):
        program_path = self.active_program_path
        if not program_path:
            program_path = self.get_current_program_path(ui)
        end_time = time.time()
        if self.active_start_time is None:
            elapsed_seconds = None
        else:
            elapsed_seconds = end_time - self.active_start_time
        filename = self.get_program_display_name(program_path)
        self.write_completed_segment_time(end_time)
        if elapsed_seconds is not None:
            self.write_cycle_run_timing_log(elapsed_seconds, "completed", "Program completed normally")
            self.cycle_time_tab_cycle_actual_seconds = elapsed_seconds
            self.cycle_time_tab_cycle_result = "completed"
            self.publish_cycle_time_tab_snapshot(program_path)
        self.write_status(" ")
        self.write_status("detected program end: " + filename)
        self.write_segment_log("Program completed", 0, "", self.format_elapsed_seconds(elapsed_seconds) if elapsed_seconds is not None else "Unknown", filename)        
        if self.notify_completion_enabled():
            self.send_operation_complete_email_async(program_path, elapsed_seconds)
        self.active_program_path = None
        self.active_start_time = None
        self.active_segment_tool = ""
        self.active_segment_start_time = None
        self.active_segment_number = 0
        self.active_run_id = ""
        self.active_total_estimate_seconds = None
        self.segment_estimates = {}

class FakeConfigFile(object):
    def __init__(self, text):
        self.lines = text.splitlines(True)

    def readline(self):
        if self.lines:
            return self.lines.pop(0)
        return ""

if __name__ == '__main__':
    if len(sys.argv) >= 3 and sys.argv[1] == '--m30-popup':
        sys.exit(M30PopupProcess(sys.argv[2]).run())

DESCRIPTION_LONG = """Cycle Time Monitor estimates cycle time when a program is loaded,
    logs actual elapsed time by tool segment while the program runs, and can
    optionally send email notifications for tool-change prompts and program
    completion.</font></p>"""