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

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

# 1.08 - renamed all installer patch markers from TT_* to BEAGLE_* markers. - 9/14/2026
# 1.07 - show PathPilot popup on successful installation or failed installation;
#        already-patched remains status-window-only and silent. - 9/14/2026
# 1.06 - support both known GLCanon draw_rapids implementations:
#        PathPilot 2.9.x visible_tools and PathPilot 2.14.x hidden_tools. - 9/14/2026
# 1.05 - removed all .pyo/.pyc handling and staged bytecode compilation logic.
#        PathPilot regenerates its compiled Python files on reboot/reload. - 9/14/2026
# 1.04 - keep G0 dotted, but make rapid preview lines thicker by safely patching
#        rs274/glcanon.py draw_rapids() with glLineWidth(2) and restoring width 1
#        immediately after the rapid pass - 9/14/2026
# 1.03 - reserved for runtime-only test version; this installer continues from 1.02
# 1.02 - also patch skipped_segments color/alpha. PathPilot renders SEEK-mode rapid
#        preview segments with skipped_segments instead of traverse, which is why the
#        earlier yellow traverse change did not affect the visible G0 path - 9/14/2026
# 1.01 - earlier cache-handling experiment; superseded by 1.05. - 9/14/2026
# 1.00 - make Gremlin G0 / rapid moves bright yellow and increase the
#        live rapid backplot opacity - 9/14/2026

# This is an installer-style plugin. Gremlin's preview colors are created
# before user plugins are loaded, so the reliable method is to patch the
# active PathPilot gremlin_draw.py and let PathPilot load it on the next start.
#
# Safety:
#   * Resolves the currently active PathPilot tree through /home/operator/tmc.
#   * Patches only the active version's gremlin_draw.py.
#   * Requires exact known OEM color anchors before making any change.
#   * Creates a chronological backup beside the OEM file.
#   * Writes and verifies a staged file before swapping it into place.
#   * Uses an atomic rename with rollback.
#   * Uses the shared TormachTips PathPilot patch lock.
#   * Is idempotent; once installed it makes no further changes.
#   * Automatically patches a newly installed PathPilot version on first boot
#     if that version still contains the expected OEM Gremlin color anchors.
#
# Result:
#   Program-preview G0:
#       traverse / traverse_xy / traverse_uv = yellow
#
#   SEEK/skipped preview G0:
#       skipped_segments = yellow
#       skipped_segments alpha = 0.75
#
#   Live backplot G0:
#       backplottraverse = yellow
#       backplottraverse alpha = 0.75
#
#   Program-preview rapid line style:
#       dotted/stippled = preserved
#       line width = 2
#
# A second PathPilot restart is required after the first installation because
# Gremlin has already imported its color definitions before user plugins load.

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

CURRENT_VER                    = "1.08"
SCRIPT_NAME                    = "Gremlin Rapid Color"
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

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):
        old_path = self.staged_old_path(target_path)
        if os.path.exists(old_path):
            os.remove(old_path)
        try:
            os.rename(target_path, old_path)
            os.rename(staged_path, target_path)
        except Exception:
            try:
                if os.path.exists(target_path):
                    os.remove(target_path)
            except Exception:
                pass
            if os.path.exists(old_path):
                os.rename(old_path, target_path)
            raise
        try:
            os.remove(old_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))
            for target_path, staged_path, marker, marker_count in staged_paths:
                self.make_chronological_backup(target_path)
                self.install_staged_file(target_path, staged_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)
            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