# 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

##################################################
##                                              ##
##       Allow Rename Allow Deletion 0.95       ##
##              www.tormachtips.com             ##
##                                              ##
##################################################

# 0.95 - Public beta.      - 5/19/2026

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

CURRENT_VER                  = "0.95"
SCRIPT_NAME                  = "Allow Rename Allow Deletion"
DESCRIPTION                  = "Allows rename and delete actions on the currently loaded G-code file in the PathPilot file chooser."
ENABLED                      = 1
DEV_MACHINE                  = 1
DEV_MACHINE_FLAG             = "/home/operator/gcode/python/dev_machine.txt"
UTIL_FILE                    = os.path.join("python", "tormach_file_util.py")
PATCH_LOCK_PATH              = "/tmp/tt_pathpilot_file_patch.lock"
PATCH_LOCK_TIMEOUT_SECONDS   = 30.0
PY_RENAME_GUARD_START        = "# BEAGLE ALLOW RENAMING LOADED FILES START"
PY_RENAME_GUARD_END          = "# BEAGLE ALLOW RENAMING LOADED FILES END"
PY_RENAME_GUARD_MARKER       = PY_RENAME_GUARD_START
PY_DELETE_GUARD_START        = "# BEAGLE ALLOW DELETION OF LOADED FILES START"
PY_DELETE_GUARD_END          = "# BEAGLE ALLOW DELETION OF LOADED FILES END"
PY_DELETE_GUARD_MARKER       = PY_DELETE_GUARD_START

class TTFilePatchLock(object):
    def __init__(self, owner, error_handler, lock_path, timeout_seconds):
        self.owner = owner
        self.error_handler = error_handler
        self.lock_path = lock_path
        self.timeout_seconds = timeout_seconds
        self.fp = None

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

    def __enter__(self):
        start_time = time.time()
        self.write("Opening patch lock file: %s" % self.lock_path)
        self.fp = open(self.lock_path, "a+")
        self.write("Waiting for patch lock: %s" % self.lock_path)
        while True:
            try:
                fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                self.write("Patch lock acquired: %s" % self.lock_path)
                return self
            except IOError:
                elapsed = time.time() - start_time
                if elapsed >= self.timeout_seconds:
                    self.write("Patch lock timeout after %.1f seconds: %s" % (elapsed, self.lock_path))
                    raise RuntimeError("Timed out waiting for patch lock: %s" % self.lock_path)
                time.sleep(0.1)

    def __exit__(self, exc_type, exc_value, traceback_obj):
        try:
            if self.fp:
                self.write("Releasing patch lock: %s" % self.lock_path)
                fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
                self.write("Patch lock released: %s" % self.lock_path)
        finally:
            try:
                if self.fp:
                    self.fp.close()
                    self.write("Closed patch lock file: %s" % self.lock_path)
            except:
                pass
        return False

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:
            glib.timeout_add(3000, self.try_patch)
            return
        if dev_machine_found:
            self.error_handler.write("[%s] Dev machine found. Plugin loaded, but disabled by DEV_MACHINE." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
        else:
            self.error_handler.write("[%s] Plugin loaded, but is disabled." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
            self.error_handler.write("[%s] To enable it, open the script, find ENABLED = 0, and change it to ENABLED = 1." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)

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

    def show_success_dialog(self):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_INFO,
            gtk.BUTTONS_OK,
            "Loaded-file rename/delete guard patch successfully applied.\n\n"
            "Please reboot or restart PathPilot for the change to take effect.")
        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()

    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_paths(self):
        version_path = "v%d.%d.%d" % (version_list[0], version_list[1], version_list[2])
        version_root = os.path.join("/home/operator", version_path)
        python_dir = os.path.join(version_root, "python")
        return {
            "version_root": version_root,
            "python_dir": python_dir,
            "util": os.path.join(version_root, UTIL_FILE)}

    def read_file(self, path):
        self.debug("Reading file: %s" % path)
        with open(path, "r") as f:
            content = f.read()
        self.debug("Read %d bytes from file: %s" % (len(content), path))
        return content

    def make_chronological_backup(self, target_path):
        timestamp = time.strftime("%Y%m%d_%H%M%S")
        safe_name = self.safe_script_name()
        backup_path = "%s.%s.%s.bak" % (target_path, timestamp, safe_name)
        counter = 1
        final_path = backup_path
        while os.path.exists(final_path):
            final_path = "%s.%03d" % (backup_path, counter)
            counter += 1
        self.debug("Creating chronological backup.")
        self.debug("Backup source: %s" % target_path)
        self.debug("Backup target: %s" % final_path)
        shutil.copy2(target_path, final_path)
        self.debug("Backup complete: %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 write_staged_file(self, target_path, content):
        new_path = self.staged_new_path(target_path)
        self.debug("Writing staged patched file: %s" % new_path)
        with open(new_path, "w") as f:
            f.write(content)
        self.debug("Staged patched file written: %s" % new_path)
        return new_path

    def verify_staged_file(self, staged_path):
        if not os.path.isfile(staged_path):
            raise RuntimeError("Staged file was not created: %s" % staged_path)
        content = self.read_file(staged_path)
        if PY_RENAME_GUARD_MARKER not in content:
            raise RuntimeError("Staged file does not contain rename guard marker: %s" % staged_path)
        if PY_DELETE_GUARD_MARKER not in content:
            raise RuntimeError("Staged file does not contain delete guard marker: %s" % staged_path)
        self.debug("Verified staged file markers: %s" % staged_path)

    def cleanup_staged_files(self, target_paths):
        for path in target_paths:
            for staged_path in (self.staged_new_path(path), self.staged_old_path(path)):
                try:
                    if os.path.exists(staged_path):
                        self.debug("Removing leftover staged file: %s" % staged_path)
                        os.remove(staged_path)
                except Exception as e:
                    self.debug("Could not remove staged file %s: %s" % (staged_path, str(e)))

    def restore_from_old_files(self, target_paths):
        for path in target_paths:
            old_path = self.staged_old_path(path)
            try:
                if os.path.exists(old_path):
                    self.debug("Restoring rollback file.")
                    self.debug("Restore source: %s" % old_path)
                    self.debug("Restore target: %s" % path)
                    if os.path.exists(path):
                        os.remove(path)
                    os.rename(old_path, path)
                    self.debug("Restore complete: %s" % path)
            except Exception as e:
                self.error_handler.write("[%s] ERROR: Rollback restore failed for %s: %s" % (SCRIPT_NAME, path, str(e)), constants.ALARM_LEVEL_HIGH)

    def swap_staged_files_into_place(self, target_paths, staged_paths):
        try:
            for path in target_paths:
                new_path = staged_paths[path]
                old_path = self.staged_old_path(path)
                self.debug("Moving current target to rollback path.")
                self.debug("Rollback source: %s" % path)
                self.debug("Rollback target: %s" % old_path)
                os.rename(path, old_path)
                self.debug("Moving staged patched file into target path.")
                self.debug("Patched source: %s" % new_path)
                self.debug("Patched target: %s" % path)
                os.rename(new_path, path)
                self.debug("Swap complete: %s" % path)
        except Exception:
            self.error_handler.write("[%s] ERROR: Swap failed. Attempting rollback." % SCRIPT_NAME, constants.ALARM_LEVEL_HIGH)
            self.restore_from_old_files(target_paths)
            raise
        for path in target_paths:
            old_path = self.staged_old_path(path)
            try:
                if os.path.exists(old_path):
                    self.debug("Removing rollback file after successful swap: %s" % old_path)
                    os.remove(old_path)
            except Exception as e:
                self.debug("Could not remove rollback file %s: %s" % (old_path, str(e)))

    def patch_tormach_file_util_content(self, content):
        original_content = content
        if PY_RENAME_GUARD_MARKER not in content:
            rename_pattern = re.compile(
                r'(?P<indent>^[ \t]*)if old_path == self\.get_current_gcode_path\(\):\n'
                r'(?P=indent)[ \t]+res_dir = self\.restricted_directory\n'
                r'(?P=indent)[ \t]+if res_dir\[-1\] != [\'"]/[\'"]:\n'
                r'(?P=indent)[ \t]+[ \t]+res_dir \+= [\'"]/[\'"]\n'
                r'(?P=indent)[ \t]+file_name_for_display = old_path\.replace\(res_dir, [\'"][\'"]\)\n'
                r'(?P=indent)[ \t]+self\.error_handler\.write\("Cannot rename currently loaded gcode program: %s" % file_name_for_display, const\.ALARM_LEVEL_LOW\)\n'
                r'(?P=indent)[ \t]+return\n',
                re.MULTILINE)

            def replace_rename_guard(match):
                indent = match.group("indent")
                return (
                    indent + PY_RENAME_GUARD_MARKER + "\n"
                    + indent + PY_RENAME_GUARD_START + "\n"
                    + indent + "# if old_path == self.get_current_gcode_path():\n"
                    + indent + "    # res_dir = self.restricted_directory\n"
                    + indent + "    # if res_dir[-1] != '/':\n"
                    + indent + "        # res_dir += '/'\n"
                    + indent + "    # file_name_for_display = old_path.replace(res_dir, '')\n"
                    + indent + "    # self.error_handler.write(\"Cannot rename currently loaded gcode program: %s\" % file_name_for_display, const.ALARM_LEVEL_LOW)\n"
                    + indent + "    # return\n"
                    + indent + PY_RENAME_GUARD_END + "\n")

            content, rename_count = rename_pattern.subn(replace_rename_guard, content, count=1)
            self.debug("Rename loaded-file guard replacement count: %d" % rename_count)
            if rename_count < 1:
                raise RuntimeError("Rename guard was not found in tormach_file_util.py")
        else:
            self.debug("Rename loaded-file guard patch already present.")
        if PY_DELETE_GUARD_MARKER not in content:
            delete_pattern = re.compile(
                r'(?P<indent>^[ \t]*)if path == self\.get_current_gcode_path\(\):\n'
                r'(?P=indent)[ \t]+self\.error_handler\.write\("Cannot delete currently loaded gcode program: %s" % file_name_for_display, const\.ALARM_LEVEL_LOW\)\n'
                r'(?P=indent)[ \t]+return\n',
                re.MULTILINE)

            def replace_delete_guard(match):
                indent = match.group("indent")
                return (
                    indent + PY_DELETE_GUARD_MARKER + "\n"
                    + indent + PY_DELETE_GUARD_START + "\n"
                    + indent + "# if path == self.get_current_gcode_path():\n"
                    + indent + "    # self.error_handler.write(\"Cannot delete currently loaded gcode program: %s\" % file_name_for_display, const.ALARM_LEVEL_LOW)\n"
                    + indent + "    # return\n"
                    + indent + PY_DELETE_GUARD_END + "\n")

            content, delete_count = delete_pattern.subn(replace_delete_guard, content, count=1)
            self.debug("Delete loaded-file guard replacement count: %d" % delete_count)
            if delete_count < 1:
                raise RuntimeError("Delete guard was not found in tormach_file_util.py")
        else:
            self.debug("Delete loaded-file guard patch already present.")
        if content == original_content:
            return content, False
        return content, True

    def try_patch(self):
        backup_paths = []
        try:
            paths = self.get_paths()
            util_path = paths["util"]
            self.debug("Python directory: %s" % paths["python_dir"])
            self.debug("tormach_file_util.py: %s" % util_path)
            if not os.path.isfile(util_path):
                raise RuntimeError("tormach_file_util.py not found: %s" % util_path)
            with TTFilePatchLock(SCRIPT_NAME, self.error_handler, PATCH_LOCK_PATH, PATCH_LOCK_TIMEOUT_SECONDS):
                self.cleanup_staged_files([util_path])
                util_content = self.read_file(util_path)
                patched_content, changed = self.patch_tormach_file_util_content(util_content)
                if not changed:
                    self.debug("Loaded-file rename/delete guard patch is already applied. No file changes needed.")
                    return False
                backup_paths.append(self.make_chronological_backup(util_path))
                staged_paths = {}
                staged_path = self.write_staged_file(util_path, patched_content)
                staged_paths[util_path] = staged_path
                self.verify_staged_file(staged_path)
                self.swap_staged_files_into_place([util_path], staged_paths)
            self.debug("Patch completed successfully.")
            self.show_success_dialog()
            return False
        except Exception as e:
            self.error_handler.write("[%s] ERROR: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_HIGH)
            if backup_paths:
                for backup_path in backup_paths:
                    self.debug("Backup available: %s" % backup_path)
            return False