# 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

##############################################
##                                          ##
##     Program Completion Notifier 0.97     ##
##          www.tormachtips.com             ##
##                                          ##
##############################################

# 0.97 - Added Ctrl+H three-state mode cycling and email notifications for tool-change prompts. - 5/08/2026
# 0.96 - Added filename and elapsed time to email.                                              - 5/01/2026
# 0.95 - Public beta.                                                                           - 1/15/2026

# This script sends email notifications when an NC program finishes running,
# and can optionally also send email notifications on manual tool-change prompts.
#
# 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
import os
import types
import ConfigParser
import smtplib
import gtk
import glib
import re
from email.mime.text import MIMEText
from ui_hooks import plugin
import singletons
import constants

CURRENT_VER         = "0.97"
SCRIPT_NAME         = "Program Completion Notifier"
DESCRIPTION         = "Sends email notifications on NC file completion, and optionally on tool-change prompts. Ctrl+H cycles notification mode."
ENABLED             = 1
DEV_MACHINE         = 1
DEV_MACHINE_FLAG    = "/home/operator/gcode/python/dev_machine.txt"
NOTIFIER_CONFIG     = "/home/operator/gcode/python/program_completion_notifier_config.ini"
MODE_DISABLED       = 0
MODE_COMPLETE_ONLY  = 1
MODE_TOOL_AND_DONE  = 2
TOOL_CHANGE_SECS    = 15.0

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 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.last_tool_change_key = ""
            self.last_tool_change_time = 0.0
            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.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):
        self.error_handler.write("[" + SCRIPT_NAME + "] " + msg, level)

    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_LOW)

    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_LOW)

    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 "Script disabled."
        if mode == MODE_COMPLETE_ONLY:
            return "Notify on completion only."
        if mode == MODE_TOOL_AND_DONE:
            return "Notify on tool changes and completion."
        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_MEDIUM)
        except Exception as e:
            self.write_status("toggle error: " + str(e), constants.ALARM_LEVEL_LOW)

    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_program_display_name(self, program_path):
        if program_path:
            return os.path.basename(program_path)
        return "Unknown program"

    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_LOW)

    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
                    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")
                    return
            except Exception as e:
                self.write_status("hook setup error: " + str(e), constants.ALARM_LEVEL_LOW)
            time.sleep(0.5)

    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 not self.notify_completion_enabled():
            return
        if self.program_is_running(ui):
            self.capture_program_start(ui)
            return
        if self.program_just_completed(ui, prev_interp):
            self.handle_program_completed(ui)

    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_LOW)
        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):
        if not self.notify_tool_change_enabled():
            return
        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
        self.write_status("detected tool change request: " + tool_text)
        self.send_tool_change_email_async(tool_text, clean_message)

    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_just_completed(self, ui, prev_interp):
        return (
            ui.status.interp_state == linuxcnc.INTERP_IDLE and
            prev_interp 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 capture_program_start(self, ui):
        if self.active_start_time is None:
            self.active_start_time = time.time()
            self.active_program_path = self.get_current_program_path(ui)

    def handle_program_completed(self, ui):
        program_path = self.active_program_path
        if not program_path:
            program_path = self.get_current_program_path(ui)
        if self.active_start_time is None:
            elapsed_seconds = None
        else:
            elapsed_seconds = time.time() - self.active_start_time
        filename = self.get_program_display_name(program_path)
        self.write_status(" ")
        self.write_status("detected program end: " + filename)
        self.send_operation_complete_email_async(program_path, elapsed_seconds)
        self.active_program_path = None
        self.active_start_time = None

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

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

DESCRIPTION_LONG = """Wrote a 
    PathPilot script today that will send notifications when a 
    file completes, and can optionally notify on tool-change prompts too.</font></p>
    <p><font face="Verdana" size="2">Ctrl+H cycles through disabled, completion 
    only, and tool changes plus completion.</font></p>"""