# coding: utf-8
# python 2 compatible

# 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:         tormachtips@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#############################################
##                                         ##
##     Gremlin Rapid Color Plugin 1.09     ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 1.09 - public release - 9/27/2026

import os
import re
import time
import shutil
import fcntl
import glib
import gtk
import constants
from ui_hooks import plugin

CURRENT_VER                    = "1.09"
SCRIPT_NAME                    = "Gremlin Rapid Color"
DESCRIPTION                    = "Customizes Gremlin backplot rapid-move colors and transparency for improved visibility in the PathPilot 3D viewer."
ENABLED                        = 1
DEV_MACHINE                    = 1
DEV_MACHINE_FLAG               = "/home/operator/gcode/python/dev_machine.txt"
PATCH_DELAY_MS                 = 3000
PATCH_LOCK_PATH                = "/tmp/tt_pathpilot_file_patch.lock"
PATCH_LOCK_TIMEOUT_SECONDS     = 30.0
TMC_ROOT                       = "/home/operator/tmc"
GREMLIN_RELATIVE_PATH          = os.path.join("lib", "python", "gremlin", "gremlin_draw.py")
GLCANON_RELATIVE_PATH          = os.path.join("lib", "python", "rs274", "glcanon.py")
PATCH_MARKER                   = "BEAGLE_GREMLIN_RAPID_YELLOW_V4"
GLCANON_PATCH_MARKER           = "BEAGLE_GREMLIN_RAPID_WIDTH_V1"
OLD_PATCH_MARKERS              = ("BEAGLE_GREMLIN_RAPID_YELLOW_V1", "BEAGLE_GREMLIN_RAPID_YELLOW_V2")
OEM_ALPHA_LINE                 = "            'backplottraverse': 0.25,"
OEM_SKIPPED_ALPHA_LINE         = "            'skipped_segments': 0.25,"
OEM_TRAVERSE_LINE              = "            'traverse': (0.30, 0.50, 0.50),"
OEM_TRAVERSE_XY_LINE           = "            'traverse_xy': (0.30, 0.50, 0.50),"
OEM_TRAVERSE_UV_LINE           = "            'traverse_uv': (0.30, 0.50, 0.50),"
OEM_BACKPLOT_TRAVERSE_LINE     = "            'backplottraverse': (0.30, 0.50, 0.50),"
OEM_SKIPPED_COLOR_LINE         = "            'skipped_segments': (0.35, 0.35, 0.25),"
PATCHED_ALPHA_LINE             = "            'backplottraverse': 0.75,  # %s" % PATCH_MARKER
PATCHED_SKIPPED_ALPHA_LINE     = "            'skipped_segments': 0.75,  # %s" % PATCH_MARKER
PATCHED_TRAVERSE_LINE          = "            'traverse': (1.00, 1.00, 0.00),  # %s" % PATCH_MARKER
PATCHED_TRAVERSE_XY_LINE       = "            'traverse_xy': (1.00, 1.00, 0.00),  # %s" % PATCH_MARKER
PATCHED_TRAVERSE_UV_LINE       = "            'traverse_uv': (1.00, 1.00, 0.00),  # %s" % PATCH_MARKER
PATCHED_BACKPLOT_TRAVERSE_LINE = "            'backplottraverse': (1.00, 1.00, 0.00),  # %s" % PATCH_MARKER
PATCHED_SKIPPED_COLOR_LINE     = "            'skipped_segments': (1.00, 1.00, 0.00),  # %s" % PATCH_MARKER
OEM_FILES_MODIFIED             = ('/home/operator/tmc/lib/python/gremlin/gremlin_draw.py','/home/operator/tmc/lib/python/rs274/glcanon.py',)

GLCANON_OEM_DRAW_RAPIDS_29 = """    def draw_rapids(self, for_selection):
        glEnable(GL_LINE_STIPPLE)
        for k in self.tool_motion_types:
            if k.motion_type != 'traverse':
                continue

            if self.visible_tools is None or k.tool_no in self.visible_tools:
                color_base_name = self.color_config.exec_mode_colormap.get(k.exec_mode, k.motion_type)
                self.colored_lines_by_type(color_base_name, k, for_selection)
        glDisable(GL_LINE_STIPPLE)
"""

GLCANON_PATCHED_DRAW_RAPIDS_29 = """    def draw_rapids(self, for_selection):
        glLineWidth(2)  # %s
        glEnable(GL_LINE_STIPPLE)
        for k in self.tool_motion_types:
            if k.motion_type != 'traverse':
                continue

            if self.visible_tools is None or k.tool_no in self.visible_tools:
                color_base_name = self.color_config.exec_mode_colormap.get(k.exec_mode, k.motion_type)
                self.colored_lines_by_type(color_base_name, k, for_selection)
        glDisable(GL_LINE_STIPPLE)
        glLineWidth(1)  # %s
""" % (GLCANON_PATCH_MARKER, GLCANON_PATCH_MARKER)

GLCANON_OEM_DRAW_RAPIDS_214 = """    def draw_rapids(self, for_selection):
        glEnable(GL_LINE_STIPPLE)
        for k in self.tool_motion_types:
            if k.motion_type != 'traverse':
                continue

            if k.tool_no not in self.hidden_tools:
                color_base_name = self.color_config.exec_mode_colormap.get(k.exec_mode, k.motion_type)
                self.colored_lines_by_type(color_base_name, k, for_selection)
        glDisable(GL_LINE_STIPPLE)
"""

GLCANON_PATCHED_DRAW_RAPIDS_214 = """    def draw_rapids(self, for_selection):
        glLineWidth(2)  # %s
        glEnable(GL_LINE_STIPPLE)
        for k in self.tool_motion_types:
            if k.motion_type != 'traverse':
                continue

            if k.tool_no not in self.hidden_tools:
                color_base_name = self.color_config.exec_mode_colormap.get(k.exec_mode, k.motion_type)
                self.colored_lines_by_type(color_base_name, k, for_selection)
        glDisable(GL_LINE_STIPPLE)
        glLineWidth(1)  # %s
""" % (GLCANON_PATCH_MARKER, GLCANON_PATCH_MARKER)

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, "%s %s" % (SCRIPT_NAME, CURRENT_VER))
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = DEV_MACHINE
        else:
            plugin_enabled = ENABLED
        if plugin_enabled == 0:
            self.error_handler.write("[%s] Plugin loaded, but disabled." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
            return
        glib.timeout_add(PATCH_DELAY_MS, self.try_patch)

    def debug(self, message):
        try:
            self.error_handler.write("[%s] %s" % (SCRIPT_NAME, message), constants.ALARM_LEVEL_QUIET)
        except Exception:
            pass

    def safe_script_name(self):
        safe_name = SCRIPT_NAME.lower()
        safe_name = safe_name.replace(" ", "_")
        safe_name = re.sub(r"[^a-z0-9_]+", "_", safe_name)
        return safe_name

    def get_active_pathpilot_root(self):
        if os.path.exists(TMC_ROOT) == False:
            raise RuntimeError("Active PathPilot link/path was not found: %s" % TMC_ROOT)
        active_root = os.path.realpath(TMC_ROOT)
        if os.path.isdir(active_root) == False:
            raise RuntimeError("Active PathPilot root is not a directory: %s" % active_root)
        return active_root

    def get_active_pathpilot_version(self, active_root):
        version_name = os.path.basename(active_root.rstrip(os.sep))
        if version_name == "":
            return "unknown"
        return version_name

    def get_target_path(self, active_root):
        target_path = os.path.join(active_root, GREMLIN_RELATIVE_PATH)
        if os.path.isfile(target_path) == False:
            raise RuntimeError("Gremlin source file was not found: %s" % target_path)
        return target_path

    def get_glcanon_target_path(self, active_root):
        target_path = os.path.join(active_root, GLCANON_RELATIVE_PATH)
        if os.path.isfile(target_path) == False:
            raise RuntimeError("GLCanon source file was not found: %s" % target_path)
        return target_path

    def read_file(self, path):
        handle = open(path, "r")
        try:
            return handle.read()
        finally:
            handle.close()

    def write_file(self, path, content):
        handle = open(path, "w")
        try:
            handle.write(content)
            handle.flush()
            os.fsync(handle.fileno())
        finally:
            handle.close()

    def make_chronological_backup(self, target_path):
        timestamp = time.strftime("%Y%m%d_%H%M%S")
        backup_path = "%s.%s.%s.bak" % (target_path, timestamp, self.safe_script_name())
        counter = 1
        final_path = backup_path
        while os.path.exists(final_path):
            final_path = "%s.%03d" % (backup_path, counter)
            counter += 1
        shutil.copy2(target_path, final_path)
        self.debug("Backup created: %s" % final_path)
        return final_path

    def staged_new_path(self, target_path):
        return "%s.%s.%d.new" % (target_path, self.safe_script_name(), os.getpid())

    def staged_old_path(self, target_path):
        return "%s.%s.%d.old" % (target_path, self.safe_script_name(), os.getpid())

    def cleanup_staged_files(self, target_path):
        for path in (self.staged_new_path(target_path), self.staged_old_path(target_path)):
            try:
                if os.path.exists(path):
                    os.remove(path)
            except Exception:
                pass

    def verify_oem_anchors(self, content):
        anchors = (
            OEM_ALPHA_LINE,
            OEM_SKIPPED_ALPHA_LINE,
            OEM_TRAVERSE_LINE,
            OEM_TRAVERSE_XY_LINE,
            OEM_TRAVERSE_UV_LINE,
            OEM_BACKPLOT_TRAVERSE_LINE,
            OEM_SKIPPED_COLOR_LINE,)
        for anchor in anchors:
            count = content.count(anchor)
            if count != 1:
                raise RuntimeError("Expected Gremlin OEM anchor exactly once, found %d: %s" % (count, anchor.strip()))

    def patch_content(self, content):
        if PATCH_MARKER in content:
            return content, False
        old_marker_found = None
        for old_marker in OLD_PATCH_MARKERS:
            if old_marker in content:
                old_marker_found = old_marker
                break
        if old_marker_found is not None:
            patched = content.replace(old_marker_found, PATCH_MARKER)
            if OEM_SKIPPED_ALPHA_LINE not in patched or OEM_SKIPPED_COLOR_LINE not in patched:
                raise RuntimeError("Existing Gremlin rapid-color patch found, but skipped_segments OEM anchors are not available.")
            patched = patched.replace(OEM_SKIPPED_ALPHA_LINE, PATCHED_SKIPPED_ALPHA_LINE, 1)
            patched = patched.replace(OEM_SKIPPED_COLOR_LINE, PATCHED_SKIPPED_COLOR_LINE, 1)
            if patched.count(PATCH_MARKER) != 7:
                raise RuntimeError("Gremlin rapid-color upgrade verification failed; expected 7 markers.")
            return patched, True
        self.verify_oem_anchors(content)
        patched = content.replace(OEM_ALPHA_LINE, PATCHED_ALPHA_LINE, 1)
        patched = patched.replace(OEM_SKIPPED_ALPHA_LINE, PATCHED_SKIPPED_ALPHA_LINE, 1)
        patched = patched.replace(OEM_TRAVERSE_LINE, PATCHED_TRAVERSE_LINE, 1)
        patched = patched.replace(OEM_TRAVERSE_XY_LINE, PATCHED_TRAVERSE_XY_LINE, 1)
        patched = patched.replace(OEM_TRAVERSE_UV_LINE, PATCHED_TRAVERSE_UV_LINE, 1)
        patched = patched.replace(OEM_BACKPLOT_TRAVERSE_LINE, PATCHED_BACKPLOT_TRAVERSE_LINE, 1)
        patched = patched.replace(OEM_SKIPPED_COLOR_LINE, PATCHED_SKIPPED_COLOR_LINE, 1)
        if patched == content:
            raise RuntimeError("Gremlin rapid-color patch produced no changes.")
        if patched.count(PATCH_MARKER) != 7:
            raise RuntimeError("Gremlin rapid-color patch verification failed; expected 7 markers.")
        return patched, True

    def patch_glcanon_content(self, content):
        if GLCANON_PATCH_MARKER in content:
            return content, False

        known_blocks = (
            ("2.9.x", GLCANON_OEM_DRAW_RAPIDS_29, GLCANON_PATCHED_DRAW_RAPIDS_29),
            ("2.14.x", GLCANON_OEM_DRAW_RAPIDS_214, GLCANON_PATCHED_DRAW_RAPIDS_214),)

        matches = []
        for version_family, oem_block, patched_block in known_blocks:
            count = content.count(oem_block)
            if count > 1:
                raise RuntimeError("Expected GLCanon %s draw_rapids OEM block at most once, found %d." % (version_family, count))
            if count == 1:
                matches.append((version_family, oem_block, patched_block))

        if len(matches) != 1:
            raise RuntimeError("Expected exactly one known GLCanon draw_rapids OEM block, found %d." % len(matches))

        version_family, oem_block, patched_block = matches[0]
        self.debug("Matched GLCanon draw_rapids layout: %s" % version_family)
        patched = content.replace(oem_block, patched_block, 1)

        if patched.count(GLCANON_PATCH_MARKER) != 2:
            raise RuntimeError("GLCanon rapid-width patch verification failed; expected 2 markers.")

        return patched, True

    def install_staged_file(self, target_path, staged_path):
        # staged_path is a sibling of target_path, so this is one atomic
        # same-filesystem replacement on Linux.
        os.rename(staged_path, target_path)

    def restore_backup_atomic(self, target_path, backup_path):
        restore_path = "%s.%s.%d.rollback" % (
            target_path, self.safe_script_name(), os.getpid())
        try:
            shutil.copy2(backup_path, restore_path)
            handle = open(restore_path, "r")
            try:
                os.fsync(handle.fileno())
            finally:
                handle.close()
            os.rename(restore_path, target_path)
        finally:
            try:
                if os.path.exists(restore_path):
                    os.remove(restore_path)
            except Exception:
                pass

    def acquire_patch_lock(self):
        lock_handle = open(PATCH_LOCK_PATH, "a+")
        deadline = time.time() + PATCH_LOCK_TIMEOUT_SECONDS
        while True:
            try:
                fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                self.debug("Patch lock acquired: %s" % PATCH_LOCK_PATH)
                return lock_handle
            except IOError:
                if time.time() >= deadline:
                    lock_handle.close()
                    raise RuntimeError("Timed out waiting for PathPilot patch lock.")
                time.sleep(0.10)

    def release_patch_lock(self, lock_handle):
        if lock_handle is None:
            return
        try:
            fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN)
        except Exception:
            pass
        try:
            lock_handle.close()
        except Exception:
            pass
        self.debug("Patch lock released: %s" % PATCH_LOCK_PATH)

    def show_install_dialog(self, success, message):
        message_type = gtk.MESSAGE_INFO if success else gtk.MESSAGE_ERROR
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            message_type,
            gtk.BUTTONS_OK,
            message)
        dialog.set_title(SCRIPT_NAME)
        dialog.set_keep_above(True)
        dialog.set_modal(False)

        def on_response(widget, response_id):
            widget.destroy()

        dialog.connect("response", on_response)
        dialog.show_all()
        return False

    def try_patch(self):
        lock_handle = None
        gremlin_target = None
        glcanon_target = None
        try:
            lock_handle = self.acquire_patch_lock()
            active_root = self.get_active_pathpilot_root()
            pathpilot_version = self.get_active_pathpilot_version(active_root)
            gremlin_target = self.get_target_path(active_root)
            glcanon_target = self.get_glcanon_target_path(active_root)
            self.debug("Active PathPilot root: %s" % active_root)
            self.debug("Detected PathPilot version: %s" % pathpilot_version)
            self.debug("Gremlin source: %s" % gremlin_target)
            self.debug("GLCanon source: %s" % glcanon_target)
            gremlin_original = self.read_file(gremlin_target)
            gremlin_patched, gremlin_changed = self.patch_content(gremlin_original)
            glcanon_original = self.read_file(glcanon_target)
            glcanon_patched, glcanon_changed = self.patch_glcanon_content(glcanon_original)
            if gremlin_changed == False and glcanon_changed == False:
                self.error_handler.write("[%s] Already installed for PathPilot %s. G0 is yellow, dotted, and thicker." % (SCRIPT_NAME, pathpilot_version), constants.ALARM_LEVEL_QUIET)
                return False
            staged_paths = []
            if gremlin_changed:
                gremlin_staged = self.staged_new_path(gremlin_target)
                self.cleanup_staged_files(gremlin_target)
                self.write_file(gremlin_staged, gremlin_patched)
                shutil.copystat(gremlin_target, gremlin_staged)
                os.utime(gremlin_staged, None)
                gremlin_staged_content = self.read_file(gremlin_staged)
                if gremlin_staged_content != gremlin_patched:
                    raise RuntimeError("Staged Gremlin source did not read back exactly.")
                if gremlin_staged_content.count(PATCH_MARKER) != 7:
                    raise RuntimeError("Staged Gremlin source marker verification failed.")
                staged_paths.append((gremlin_target, gremlin_staged, PATCH_MARKER, 7))
            if glcanon_changed:
                glcanon_staged = self.staged_new_path(glcanon_target)
                self.cleanup_staged_files(glcanon_target)
                self.write_file(glcanon_staged, glcanon_patched)
                shutil.copystat(glcanon_target, glcanon_staged)
                os.utime(glcanon_staged, None)
                glcanon_staged_content = self.read_file(glcanon_staged)
                if glcanon_staged_content != glcanon_patched:
                    raise RuntimeError("Staged GLCanon source did not read back exactly.")
                if glcanon_staged_content.count(GLCANON_PATCH_MARKER) != 2:
                    raise RuntimeError("Staged GLCanon source marker verification failed.")
                staged_paths.append((glcanon_target, glcanon_staged, GLCANON_PATCH_MARKER, 2))
            backups = {}
            installed_targets = []
            try:
                # Create every backup before replacing either OEM file. If the
                # second backup fails, neither OEM file has been touched.
                for target_path, staged_path, marker, marker_count in staged_paths:
                    backups[target_path] = self.make_chronological_backup(target_path)

                # Only after all staged files and all backups are ready do we
                # begin the atomic replacements.
                for target_path, staged_path, marker, marker_count in staged_paths:
                    self.install_staged_file(target_path, staged_path)
                    installed_targets.append(target_path)
                    installed = self.read_file(target_path)
                    if installed.count(marker) != marker_count:
                        raise RuntimeError("Installed source marker verification failed after swap: %s" % target_path)

            except Exception:
                # Restore every file already replaced during this transaction,
                # in reverse order, so Gremlin and GLCanon cannot be left
                # half-installed.
                rollback_errors = []
                for target_path in reversed(installed_targets):
                    backup_path = backups.get(target_path)
                    if backup_path is None:
                        continue
                    try:
                        self.restore_backup_atomic(target_path, backup_path)
                        self.debug("Rolled back after failed install: %s" % target_path)
                    except Exception as rollback_error:
                        rollback_errors.append("%s: %s" % (target_path, str(rollback_error)))

                if rollback_errors:
                    raise RuntimeError(
                        "Install failed and rollback also failed: %s" %
                        "; ".join(rollback_errors))
                raise
            self.error_handler.write("[%s] Installed yellow, dotted, thicker G0 rapid preview for PathPilot %s. Restart PathPilot once more to load the updated Gremlin code." % (SCRIPT_NAME, pathpilot_version), constants.ALARM_LEVEL_LOW)
            success_message = (
                "Gremlin Rapid Color was installed successfully for PathPilot %s.\n\n"
                "G0 rapid preview is now yellow, dotted, and thicker.\n\n"
                "Restart PathPilot once more to load the updated Gremlin code."
            ) % pathpilot_version
            glib.idle_add(self.show_install_dialog, True, success_message)
        except Exception as e:
            if gremlin_target is not None:
                self.cleanup_staged_files(gremlin_target)
            if glcanon_target is not None:
                self.cleanup_staged_files(glcanon_target)
            self.error_handler.write("[%s] ERROR: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_HIGH)
            failure_message = (
                "Gremlin Rapid Color installation failed.\n\n"
                "%s"
            ) % str(e)
            glib.idle_add(self.show_install_dialog, False, failure_message)
        finally:
            self.release_patch_lock(lock_handle)
        return False
