# 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

#############################################
##                                         ##
##     M01 Message Font Patcher v0.96      ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.96 - Added shared file lock, chronological backups, atomic writes, and verbose patch debug logging. - 5/02/2026
# 0.95 - Public beta. - 4/2/26

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.96"
SCRIPT_NAME                  = "M01 Blue Bar Font Size Changer"
DESCRIPTION                  = "Changes the size of the font in the blue bar that presents on M01 commands."
ENABLED                      = 1
DEV_MACHINE                  = 0
DEV_MACHINE_FLAG             = "/home/operator/gcode/python/dev_machine.txt"
MARKER                       = "# BEAGLE: M01 FONT SIZE PATCH INSTALLED"
PATCH_LOCK_PATH              = "/tmp/tt_pathpilot_file_patch.lock"
TARGET_FILE                  = os.path.join("python", "ui_common.py")
PATCH_LOCK_TIMEOUT_SECONDS   = 30.0

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
        else:
            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)
            return

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

    def ask_font_size(self):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_QUESTION,
            gtk.BUTTONS_OK_CANCEL,
            "M01 Message Font Size Changer\n\n"
            "What size do you want the blue message box text?\n\n"
            "• 18 = Original (OEM size)\n"
            "• 14 = Good balance (recommended)\n"
            "• 12 = Smaller, more text fits\n"
            "• 10 = Very small, fits a lot of text")
        dialog.set_title("M01 Message Font Patcher")
        dialog.set_keep_above(True)
        adjustment = gtk.Adjustment(value=14, lower=8, upper=22, step_incr=1)
        spin = gtk.SpinButton(adjustment)
        spin.set_digits(0)
        spin.set_value(14)
        box = dialog.get_content_area()
        box.pack_start(spin, False, False, 10)
        spin.show()
        response = dialog.run()
        chosen_size = int(spin.get_value()) if response == gtk.RESPONSE_OK else None
        dialog.destroy()
        return chosen_size

    def show_success_dialog(self):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_INFO,
            gtk.BUTTONS_OK,
            "Blue M01 message font successfully changed.\n\n"
            "Please reboot or restart PathPilot for the change to take effect.")
        dialog.set_title("M01 Message Font Patcher")
        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 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 atomic_write_file(self, target_path, content):
        temp_path = "%s.%s.%d.tmp" % (
            target_path,
            self.safe_script_name(),
            os.getpid()
        )
        self.debug("Writing temporary patched file: %s" % temp_path)
        with open(temp_path, "w") as f:
            f.write(content)
        self.debug("Temporary patched file written: %s" % temp_path)
        self.debug("Renaming temporary file over target.")
        self.debug("Rename source: %s" % temp_path)
        self.debug("Rename target: %s" % target_path)
        os.rename(temp_path, target_path)
        self.debug("Atomic rename complete: %s" % target_path)

    def patch_content(self, content, font_size):
        original_content = content
        font_block_pattern = re.compile(
            r'(?P<indent>^[ \t]*)message_line_font_description = pango\.FontDescription\([\'"]Roboto Condensed \d+[\'"]\)',
            re.MULTILINE)

        def replace_font_block(match):
            indent = match.group('indent')
            return (
                indent + MARKER + " START\n" +
                indent + "# BEAGLE: ORIGINAL M01 MESSAGE FONT SIZE WAS 18\n" +
                indent + "message_line_font_description = pango.FontDescription('Roboto Condensed " + str(font_size) + "')\n" +
                indent + MARKER + " END"
                )
        
        self.debug("Searching for M01 message font OEM block.")
        content, count = font_block_pattern.subn(replace_font_block, content, count=1)
        self.debug("M01 font replacement count: %d" % count)
        if count < 1:
            raise RuntimeError("Target font block not found. Patch skipped.")
        if content == original_content:
            raise RuntimeError("Patch produced no content changes.")
        return content

    def try_patch(self):
        backup_path = ""
        try:
            version_path = "v%d.%d.%d" % (version_list[0], version_list[1], version_list[2])
            target_path = os.path.join("/home/operator", version_path, TARGET_FILE)
            self.debug("Resolved PathPilot version path: %s" % version_path)
            self.debug("Target file: %s" % target_path)
            self.debug("Patch marker: %s" % MARKER)
            if not os.path.exists(target_path):
                self.error_handler.write("[%s] File not found: %s" % (SCRIPT_NAME, target_path), constants.ALARM_LEVEL_LOW)
                return False
            self.debug("Taking patch lock for pre-dialog marker check.")
            with TTFilePatchLock(SCRIPT_NAME, self.error_handler, PATCH_LOCK_PATH, PATCH_LOCK_TIMEOUT_SECONDS):
                self.debug("Inside locked pre-dialog marker check.")
                if not os.path.exists(target_path):
                    self.error_handler.write("[%s] File not found during pre-dialog check: %s" % (SCRIPT_NAME, target_path), constants.ALARM_LEVEL_LOW)
                    return False
                self.debug("Reading target file before asking for font size: %s" % target_path)
                with open(target_path, "r") as f:
                    content = f.read()
                self.debug("Read %d bytes during pre-dialog marker check." % len(content))
                if MARKER in content:
                    self.error_handler.write("[%s] Already patched. No changes made." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
                    self.debug("Marker found before dialog; exiting without asking for font size.")
                    return False
                self.debug("Marker not found during pre-dialog check; releasing lock before asking user.")
            self.debug("Asking user for desired M01 font size after confirming patch is not already installed.")
            font_size = self.ask_font_size()
            if font_size is None:
                self.error_handler.write("[%s] User cancelled." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
                return False
            self.debug("User selected M01 font size: %d" % font_size)
            with TTFilePatchLock(SCRIPT_NAME, self.error_handler, PATCH_LOCK_PATH, PATCH_LOCK_TIMEOUT_SECONDS):
                self.debug("Inside locked patch section.")
                if not os.path.exists(target_path):
                    self.error_handler.write("[%s] File not found after lock: %s" % (SCRIPT_NAME, target_path), constants.ALARM_LEVEL_LOW)
                    return False
                self.debug("Reading target file for final patch pass: %s" % target_path)
                with open(target_path, "r") as f:
                    content = f.read()
                self.debug("Read %d bytes from target file." % len(content))
                if MARKER in content:
                    self.error_handler.write("[%s] Already patched by another pass. No changes made." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
                    self.debug("Marker found during final locked pass; exiting without backup or write.")
                    return False
                self.debug("Marker not found during final locked pass; patch will be attempted.")
                patched_content = self.patch_content(content, font_size)
                self.debug("Patch content generated.")
                self.debug("Original byte count: %d" % len(content))
                self.debug("Patched byte count: %d" % len(patched_content))
                backup_path = self.make_chronological_backup(target_path)
                self.atomic_write_file(target_path, patched_content)
                self.debug("Leaving locked patch section after successful write.")
            self.error_handler.write(" ", constants.ALARM_LEVEL_QUIET)
            self.error_handler.write("[%s] Patch applied to: %s" % (SCRIPT_NAME, target_path), constants.ALARM_LEVEL_MEDIUM)
            self.error_handler.write("[%s] Backup created: %s" % (SCRIPT_NAME, backup_path), constants.ALARM_LEVEL_MEDIUM)
            self.error_handler.write("[%s] Font size set to %d" % (SCRIPT_NAME, font_size), constants.ALARM_LEVEL_MEDIUM)
            self.error_handler.write("[%s] Please reboot or restart PathPilot." % SCRIPT_NAME, constants.ALARM_LEVEL_MEDIUM)
            self.error_handler.write(" ", constants.ALARM_LEVEL_QUIET)
            glib.idle_add(self.show_success_dialog)
        except Exception as e:
            self.error_handler.write("[%s] Error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return False

DESCRIPTION_LONG = """Changes the size of the 
    font in the white-on-blue messages that appear when you call M01 (some 
    message here)</font></p>
    <p><font face="Verdana" size="2"><a href="images/m015.png">
    <img border="2" src="images/m015_small.png" xthumbnail-orig-image="images/m015.png" width="200" height="150"></a>
    <a href="images/m014.png">
    <img border="2" src="images/m014_small.png" xthumbnail-orig-image="images/m014.png" width="200" height="150"></a><br>
    </font><font face="Verdana" size="1">Before</font></p>
    <p><font face="Verdana" size="2"><a href="images/m013.png">
    <img border="2" src="images/m013_small.png" xthumbnail-orig-image="images/m013.png" width="200" height="150"></a><br>
    </font><font face="Verdana" size="1">Install Message</font></p>
    <p><font face="Verdana" size="2"><a href="images/m011.png">
    <img border="2" src="images/m011_small.png" xthumbnail-orig-image="images/m011.png" width="200" height="150"></a>
    <a href="images/m012.png">
    <img border="2" src="images/m012_small.png" xthumbnail-orig-image="images/m012.png" width="200" height="150"></a><br>
    </font><font face="Verdana" size="1">After (this is size 10)"""