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

#############################################
##                                         ##
##        Tool Life Manager 1.53           ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 1.53 - Adds a cutter-diameter callout to the left of the Entire Tool Diagram cutter and makes TLM metadata comparison unit-invariant so G20/G21 display changes do not create Update? states or metadata events. Numeric Description/Diameter/Length baselines are stored canonically in inches while the UI continues following PathPilot units.          - 9/24/2026
# 1.52 - Cleans the Entire Tool Diagram layout: hides the secondary dimensions card behind a code toggle, extends reference/interface guides with labels moved to the right, uses explicit opposing dimension arrowheads, makes OAL/Stickout labels horizontal, and removes the footer/explanation/Open-Print controls.                                      - 9/24/2026
# 1.51 - Adds View Entire Tool Diagram beside holder editing. Generates a dimensioned SVG of the assigned holder/cutter using the same holder reference and cutter-envelope math as Gremlin; OAL is spindle-reference-to-tip, stickout is holder/collet-interface-to-tip, and dimensions follow PathPilot inch/mm units. Includes Save SVG and Open / Print. - 9/24/2026
# 1.50 - Adds Tool Diameter entry resolvers. On PathPilot 2.9.x, #1-#80 and A-Z drill sizes
#        resolve to their standard diameters; on modern PathPilot, the OEM gauge resolver is
#        retained. Both layouts accept an explicit mm suffix such as 4mm/3.3 mm. Existing
#        numeric/expression entry (including 1/4 -> 0.2500 in inch mode) remains OEM. - 9/23/2026
# 1.49 - Adds a visible border to the Commission post-it and temporarily highlights the
#        Commission Tool button in matching pale yellow while the note is displayed. - 9/21/2026
# 1.48 - Adds a pale-yellow first-use Commission Tool post-it beside the button, explaining that
#        commissioning starts Installed/Age/Usage tracking for an uncommissioned tool. - 9/21/2026
# 1.47 - Removes obsolete burst-drop/suppression constants and state left behind after 1.42's
#        coalesced/debounced event logging; no behavior change. - 9/21/2026
# 1.46 - Rewrites Tool Life Manager Help and Tool Life Popup Help for the current feature set,
#        including Wide/Go To, assignments, duplication, reminders, events, dates, usage views, and T99. - 9/21/2026
# 1.45 - Protects PathPilot's reserved T99 probe description from TLM duplicate/clear paths;
#        T99 always keeps the OEM Mill Probe description while diameter/length operations remain allowed. - 9/21/2026
# 1.44 - Reminder Age now means cutter age since Installed, not time since the reminder was created;
#        reminder countdown/status views use the same cutter-age basis. - 9/21/2026
# 1.43 - Expands Event Log coverage to Commission/Replace, Notes, Reminder add/delete/trigger,
#        and History deletion while retaining the 1.42 event-driven OEM metadata reconciliation. - 9/21/2026
# 1.42 - Hardens PathPilot metadata event capture: reconciles Description/Diameter/Length on Offsets entry,
#        reconciles after structural table changes, and coalesces bulk/rebuild changes instead of dropping bursts. - 9/21/2026
# 1.41 - Date entry also accepts US slash dates and 2-digit years (for example 7/6/25, 7/6/2025,
#        07/06/2025); date-only entries still default to noon and normalize to canonical storage. - 9/21/2026
# 1.40 - Installed, Replaced, and Reminder date entry accepts MM-DD-YYYY or YYYY-MM-DD,
#        optional 12/24-hour time, defaults date-only entries to noon, and stores/displays canonical YYYY-MM-DD HH:MM:SS. - 9/21/2026
# 1.39 - Age display now shows the three largest useful units.                                                                                                                                                                                                                                                                                               - 9/21/2026
# 1.38 - Age display now rolls months into years and shows only the two most useful largest units.                                                                                                                                                                                                                                                           - 9/21/2026
# 1.37 - Hardens Wide mode: starts Wide every launch, re-enters Wide whenever the main Offsets tab is entered,
#        and only stays Normal after the user explicitly clicks Normal during that Offsets visit. - 9/21/2026
# 1.35 - Fixes Remove so PathPilot Description/Diameter/Length are visibly and persistently cleared on legacy and modern tables.                                                                                                                                                                                                                             - 9/20/2026
# 1.34 - Adds right-click Duplicate Tool with exact target T-number, overwrite confirmation, and Basic/ALL copy scopes.                                                                                                                                                                                                                                      - 9/20/2026
# 1.36 - Adds configurable default-Wide behavior whenever the main Offsets page is entered.                                                                                                                                                                                                                                                                  - 9/20/2026
# 1.27 - Adds Holder, Tool Model, and Flutes combo columns directly to the existing PathPilot tool table.
#        Columns use TLM SQLite-backed assignment caches and do not alter the OEM ListStore schema. - 9/20/2026
# 1.30 - Restores Holder in the wide tool table, forces automatic horizontal scrolling when needed,
#        and adds sortable headers for the live tool table where the PathPilot model supports GtkTreeSortable. - 9/20/2026
# 1.33 - Auto-sizes Holder/Cutter/Flutes from values actually displayed in the live table, not the full dropdown catalog; columns remain manually resizable.                                                                                                                                                                                                 - 9/20/2026
# 1.32 - Auto-sizes Holder/Cutter/Flutes columns to the widest current choice/header; removes hard minimum-width lock.                                                                                                                                                                                                                                       - 9/20/2026
# 1.31 - Removes the "Default:" prefix from holder display text to save table width.                                                                                                                                                                                                                                                                         - 9/20/2026
# 1.29 - Reorders expanded tool columns to Manage/Usage/Cutter/Flutes, removes Holder from the table view,
#        and adds an exact Go To Tool pseudo-tab for direct T-number navigation. - 9/20/2026
# 1.28 - Adds Wide/Normal pseudo-tab beside Tool/Work to expand the existing OEM tool table across the Tool page.                                                                                                                                                                                                                                            - 9/20/2026
# 1.26 - Adds Drill Bit tool model with dedicated thumbnail.                                                                                                                                                                                                                                                                                                 - 9/20/2026
# 1.25 - Adds startup suite dependency warning when Gremlin Holder Plugin or Builder is missing.                                                                                                                                                                                                                                                             - 9/20/2026
# 1.24 - Adds two smaller Thread Mill presets for 1/4 and 3/16 shanks with matching thumbnails.                                                                                                                                                                                                                                                              - 9/20/2026
# 0.97 - added usage by month/hour                                                                                                                                                                                                                                                                                                                           - 6/14/2026
# 0.96 - Public release.                                                                                                                                                                                                                                                                                                                                     - 6/13/2026
# Adds Tool Life Manager columns and menus to the PathPilot tool table.
# Tracks per-tool install state, runtime, history, reminders, and PathPilot metadata changes.
# Uses the Hobbs spindle timer database as its runtime source and stores tool-life state in SQLite.

import os
import gtk
import glib
import pango
import sqlite3
import json
import calendar
import datetime
import subprocess
import sys
import glob
import shutil
import re
import math
import linuxcnc
import constants
import singletons
from ui_hooks import plugin

CURRENT_VER                       = "1.53"
SCRIPT_NAME                       = "Tool Life Manager"
DESCRIPTION                       = "Full featured in-PathPilot Tool Life Manager"
HOBBS_DIR                         = "/home/operator/gcode/python/hobbs"
DB_FILE                           = os.path.join(HOBBS_DIR, "hobbs_tool_life.db")
GREMLIN_CONFIG_PATH               = "/home/operator/gcode/python/gremlin_tool_holder_config.json"
HOLDER_BUILDER_PATH               = "/home/operator/gcode/python/gremlin_tool_holder_builder.py"
SUITE_DIR                         = os.path.dirname(os.path.abspath(__file__))
GREMLIN_PLUGIN_PATH               = os.path.join(SUITE_DIR, "gremlin_tool_holder_plugin.py")
ENABLED                           = 1
DEV_MACHINE                       = 1
DEV_MACHINE_FLAG                  = "/home/operator/gcode/python/dev_machine.txt"
WIDE_TABLE_DEFAULT                = 1
HOBBS_PLUGIN_PATH                 = "/home/operator/gcode/python/hobbs_plugin.py"
MENU_HELP_TITLE                   = "Help"
TOOL_TITLE_FORMAT                 = "Remove T{}"
TOOL_HISTORY_TITLE                = "View T{} History"
ALL_HISTORY_TITLE                 = "View All History"
TRIGGERED_REMINDER_TITLE          = "Delete Triggered Reminder"
REMINDERS_TITLE_FORMAT            = "View T{} Reminders"
ALL_REMINDERS_TITLE               = "View All Reminders"
EVENT_LOG_BUTTON_TITLE            = "Show Event Log"
USAGE_BY_DAY_BUTTON               = "View Usage By Day"
EVENT_LOG_FLUSH_DELAY_MS          = 750
EVENT_LOG_RECONCILE_DELAY_MS      = 750
DELETE_ALL_TOOLS_TITLE            = "Delete ENTIRE Tool Table"
TOOL_TABLE_BACKUP_ROOT            = "/home/operator/gcode/python/tool_table_backups"
TOOL_TABLE_FILE_PATTERNS          = ("/home/operator/mill_data/tool*.tbl", "/mill_data/tool*.tbl")
TOOL_MODEL_ALIASES                = {"standard": "","chamfer_drill": "chamfer_90","reamer": "reamer_straight",}
TOOL_DIAGRAM_SHOW_DIMENSIONS_CARD = 0

NUMBER_DRILL_DIAMETERS_IN = {
    1: 0.2280, 2: 0.2210, 3: 0.2130, 4: 0.2090, 5: 0.2055,
    6: 0.2040, 7: 0.2010, 8: 0.1990, 9: 0.1960, 10: 0.1935,
    11: 0.1910, 12: 0.1890, 13: 0.1850, 14: 0.1820, 15: 0.1800,
    16: 0.1770, 17: 0.1730, 18: 0.1695, 19: 0.1660, 20: 0.1610,
    21: 0.1590, 22: 0.1570, 23: 0.1540, 24: 0.1520, 25: 0.1495,
    26: 0.1470, 27: 0.1440, 28: 0.1405, 29: 0.1360, 30: 0.1285,
    31: 0.1200, 32: 0.1160, 33: 0.1130, 34: 0.1110, 35: 0.1100,
    36: 0.1065, 37: 0.1040, 38: 0.1015, 39: 0.0995, 40: 0.0980,
    41: 0.0960, 42: 0.0935, 43: 0.0890, 44: 0.0860, 45: 0.0820,
    46: 0.0810, 47: 0.0785, 48: 0.0760, 49: 0.0730, 50: 0.0700,
    51: 0.0670, 52: 0.0635, 53: 0.0595, 54: 0.0550, 55: 0.0520,
    56: 0.0465, 57: 0.0430, 58: 0.0420, 59: 0.0410, 60: 0.0400,
    61: 0.0390, 62: 0.0380, 63: 0.0370, 64: 0.0360, 65: 0.0350,
    66: 0.0330, 67: 0.0320, 68: 0.0310, 69: 0.0292, 70: 0.0280,
    71: 0.0260, 72: 0.0250, 73: 0.0240, 74: 0.0225, 75: 0.0210,
    76: 0.0200, 77: 0.0180, 78: 0.0160, 79: 0.0145, 80: 0.0135,}

LETTER_DRILL_DIAMETERS_IN = {
    "A": 0.2340, "B": 0.2380, "C": 0.2420, "D": 0.2460,
    "E": 0.2500, "F": 0.2570, "G": 0.2610, "H": 0.2660,
    "I": 0.2720, "J": 0.2770, "K": 0.2810, "L": 0.2900,
    "M": 0.2950, "N": 0.3020, "O": 0.3160, "P": 0.3230,
    "Q": 0.3320, "R": 0.3390, "S": 0.3480, "T": 0.3580,
    "U": 0.3680, "V": 0.3770, "W": 0.3860, "X": 0.3970,
    "Y": 0.4040, "Z": 0.4130,}

TOOL_DIAMETER_MM_RE               = re.compile(r"^\s*((?:\d+(?:\.\d*)?)|(?:\.\d+))\s*mm\s*$", re.IGNORECASE)
TOOL_DIAMETER_NUMBER_DRILL_RE     = re.compile(r"^\s*#\s*(\d{1,3})\s*$")
TOOL_DIAMETER_LETTER_DRILL_RE     = re.compile(r"^\s*([A-Za-z])\s*$")

TOOL_MODEL_CHOICES       = (
    ("", "End Mill"),
    ("ball_mill", "Ball Mill"),
    ("bull_nose_end_mill", "Rounded Edge End Mill"),
    ("reamer_straight", "Reamer - Straight Flute"),
    ("reamer_spiral", "Reamer - Spiral Flute"),
    ("chamfer_90", "Chamfer Mill - 90 Deg"),
    ("chamfer_45", "Chamfer Mill - 45 Deg"),
    ("drill_bit", "Drill Bit"),
    ("key_cutter", "Key Seat Cutter"),
    ("key_cutter_rounded", "Rounded Key Seat Cutter"),
    ("dovetail", "Dovetail Cutter"),
    ("thread_mill", "Thread Mill - 3/8 Shank"),
    ("thread_mill_0250", "Thread Mill - 1/4 Shank"),
    ("thread_mill_01875", "Thread Mill - 3/16 Shank"),)

TOOL_THUMBNAIL_EXAMPLES = {
    "standard": [
        {"shape": "rect", "x": 27.0, "y": 10.0, "width": 18.0, "height": 110.0},],
    "ball_mill": [
        {"shape": "rect", "x": 27.0, "y": 10.0, "width": 18.0, "height": 101.0},
        {"shape": "semicircle_bottom", "x": 27.0, "y": 111.0, "width": 18.0, "height": 9.0},],
    "bull_nose_end_mill": [
        {"shape": "rect", "x": 27.0, "y": 10.0, "width": 18.0, "height": 105.0},
        {"shape": "bull_nose_bottom", "x": 27.0, "y": 115.0, "width": 18.0, "height": 5.0, "radius": 5.0},],
    "reamer_straight": [
        {"shape": "rect", "x": 27.0, "y": 10.0, "width": 18.0, "height": 40.0},
        {"shape": "rect", "x": 30.0, "y": 50.0, "width": 12.0, "height": 20.0},
        {"shape": "rect", "x": 27.0, "y": 70.0, "width": 18.0, "height": 50.0},
        {"shape": "line", "x1": 31.0, "y1": 75.0, "x2": 31.0, "y2": 116.0},
        {"shape": "line", "x1": 36.0, "y1": 75.0, "x2": 36.0, "y2": 116.0},
        {"shape": "line", "x1": 41.0, "y1": 75.0, "x2": 41.0, "y2": 116.0},],
    "reamer_spiral": [
        {"shape": "rect", "x": 27.0, "y": 10.0, "width": 18.0, "height": 40.0},
        {"shape": "rect", "x": 30.0, "y": 50.0, "width": 12.0, "height": 20.0},
        {"shape": "rect", "x": 27.0, "y": 70.0, "width": 18.0, "height": 50.0},
        {"shape": "line", "x1": 28.0, "y1": 113.0, "x2": 36.0, "y2": 75.0},
        {"shape": "line", "x1": 33.0, "y1": 116.0, "x2": 41.0, "y2": 78.0},
        {"shape": "line", "x1": 38.0, "y1": 116.0, "x2": 45.0, "y2": 83.0},],
    "chamfer_90": [
        {"shape": "rect", "x": 27.0, "y": 10.0, "width": 18.0, "height": 95.0},
        {"shape": "triangle_down", "x": 27.0, "y": 105.0, "width": 18.0, "height": 15.0},],
    "chamfer_45": [
        {"shape": "rect", "x": 27.0, "y": 10.0, "width": 18.0, "height": 80.0},
        {"shape": "triangle_down", "x": 27.0, "y": 90.0, "width": 18.0, "height": 30.0},],
    "drill_bit": [
        {"shape": "rect", "x": 27.0, "y": 10.0, "width": 18.0, "height": 100.0},
        {"shape": "triangle_down", "x": 27.0, "y": 110.0, "width": 18.0, "height": 10.0},],
    "key_cutter": [
        {"shape": "rect", "x": 28.0, "y": 10.0, "width": 16.0, "height": 42.0},
        {"shape": "rect", "x": 32.0, "y": 52.0, "width": 8.0, "height": 56.0},
        {"shape": "rect", "x": 18.0, "y": 108.0, "width": 36.0, "height": 12.0},],
    "key_cutter_rounded": [
        {"shape": "rect", "x": 28.0, "y": 10.0, "width": 16.0, "height": 42.0},
        {"shape": "rect", "x": 32.0, "y": 52.0, "width": 8.0, "height": 56.0},
        {"shape": "rounded_rect", "x": 18.0, "y": 108.0, "width": 36.0, "height": 12.0, "radius": 4.0},],
    "dovetail": [
        {"shape": "rect", "x": 28.0, "y": 10.0, "width": 16.0, "height": 42.0},
        {"shape": "rect", "x": 32.0, "y": 52.0, "width": 8.0, "height": 45.0},
        {"shape": "trapezoid", "x": 18.0, "y": 97.0, "width_top": 8.0, "width_bottom": 36.0, "height": 23.0},],
    "thread_mill": [
        {"shape": "rect", "x": 28.0, "y": 10.0, "width": 16.0, "height": 42.0},
        {"shape": "rect", "x": 32.0, "y": 52.0, "width": 8.0, "height": 52.0},
        {"shape": "trapezoid", "x": 18.0, "y": 104.0, "width_top": 8.0, "width_bottom": 36.0, "height": 6.0},
        {"shape": "trapezoid", "x": 18.0, "y": 110.0, "width_top": 36.0, "width_bottom": 8.0, "height": 6.0},],
    "thread_mill_0250": [
        {"shape": "rect", "x": 30.0, "y": 10.0, "width": 12.0, "height": 42.0},
        {"shape": "rect", "x": 33.0, "y": 52.0, "width": 6.0, "height": 52.0},
        {"shape": "trapezoid", "x": 20.0, "y": 104.0, "width_top": 6.0, "width_bottom": 32.0, "height": 6.0},
        {"shape": "trapezoid", "x": 20.0, "y": 110.0, "width_top": 32.0, "width_bottom": 6.0, "height": 6.0},],
    "thread_mill_01875": [
        {"shape": "rect", "x": 31.5, "y": 10.0, "width": 9.0, "height": 42.0},
        {"shape": "rect", "x": 33.5, "y": 52.0, "width": 5.0, "height": 52.0},
        {"shape": "trapezoid", "x": 22.0, "y": 104.0, "width_top": 5.0, "width_bottom": 28.0, "height": 6.0},
        {"shape": "trapezoid", "x": 22.0, "y": 110.0, "width_top": 28.0, "width_bottom": 5.0, "height": 6.0},],}

class ToolReminderManager(object):
    def __init__(self, plugin):
        self.plugin = plugin

    def get_reminders_for_tool(self, tool_num):
        rows = []
        conn = self.plugin._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT r.id, r.tool, r.message, r.due_datetime,
                          r.due_age_seconds, r.due_usage_seconds,
                          r.created_at, r.triggered_at,
                          u.installed_date, u.total_seconds
                   FROM tool_reminders r
                   LEFT JOIN tool_usage u ON u.tool = r.tool
                   WHERE r.tool = ?
                   ORDER BY r.id ASC""",
                (tool_num,))
            for row in c.fetchall():
                rows.append(row)
        finally:
            conn.close()
        return rows

    def add_reminder(self, tool_num, message, due_datetime, due_age_seconds, due_usage_seconds):
        now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        conn = self.plugin._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """INSERT INTO tool_reminders
                   (tool, message, due_datetime, due_age_seconds,
                    due_usage_seconds, created_at, triggered_at)
                   VALUES (?, ?, ?, ?, ?, ?, NULL)""",
                (
                    tool_num,
                    message,
                    due_datetime,
                    int(due_age_seconds or 0),
                    int(due_usage_seconds or 0),
                    now_text))
            trigger_summary = self.plugin._format_reminder_trigger(due_datetime, due_age_seconds, due_usage_seconds)
            self.plugin._append_named_tool_event(
                c, tool_num,
                "Reminder added: %s" % self.plugin._normalize_text(message),
                "reminder_add", "", trigger_summary)
            conn.commit()
        finally:
            conn.close()

    def delete_reminder(self, reminder_id):
        conn = self.plugin._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT tool, message, due_datetime, due_age_seconds, due_usage_seconds
                   FROM tool_reminders WHERE id = ?""",
                (int(reminder_id),))
            old_row = c.fetchone()
            c.execute("DELETE FROM tool_reminders WHERE id = ?", (int(reminder_id),))
            if old_row:
                tool_num, message, due_datetime, due_age_seconds, due_usage_seconds = old_row
                self.plugin._append_named_tool_event(
                    c, tool_num,
                    "Reminder deleted: %s" % self.plugin._normalize_text(message),
                    "reminder_delete",
                    self.plugin._format_reminder_trigger(
                        due_datetime, due_age_seconds, due_usage_seconds),
                    "")
            conn.commit()
        finally:
            conn.close()

    def delete_all_reminders_for_tool(self, tool_num):
        conn = self.plugin._db_connect()
        try:
            c = conn.cursor()
            c.execute("SELECT COUNT(*) FROM tool_reminders WHERE tool = ?", (int(tool_num),))
            count_row = c.fetchone()
            delete_count = int(count_row[0] or 0) if count_row else 0
            c.execute("DELETE FROM tool_reminders WHERE tool = ?", (int(tool_num),))
            if delete_count > 0:
                self.plugin._append_named_tool_event(
                    c, tool_num,
                    "All reminders deleted (%d)" % delete_count,
                    "reminder_delete_all", str(delete_count), "0")
            conn.commit()
        finally:
            conn.close()

    def poll_reminders(self):
        now_dt = datetime.datetime.now()
        conn = self.plugin._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT r.id, r.tool, r.message, r.due_datetime,
                          r.due_age_seconds, r.due_usage_seconds,
                          r.created_at, u.installed_date, u.total_seconds
                   FROM tool_reminders r
                   LEFT JOIN tool_usage u ON u.tool = r.tool
                   WHERE r.triggered_at IS NULL""")
            rows = c.fetchall()
            for row in rows:
                reminder_id, tool_num, message, due_datetime, due_age_seconds, due_usage_seconds, created_at, installed_date, total_seconds = row
                condition_text = self._get_due_condition_text(
                    now_dt,
                    due_datetime,
                    due_age_seconds,
                    due_usage_seconds,
                    installed_date,
                    total_seconds)
                if condition_text:
                    self._fire_reminder(reminder_id, tool_num, message, condition_text)
        finally:
            conn.close()

    def _seconds_since_text_datetime(self, now_dt, date_text):
        date_dt = self.plugin._parse_datetime(date_text)
        if date_dt:
            elapsed = now_dt - date_dt
            return int(elapsed.days * 86400 + elapsed.seconds)
        return 0

    def _get_due_condition_text(self, now_dt, due_datetime, due_age_seconds, due_usage_seconds, installed_date, total_seconds):
        # Return the first trigger that crossed its limit. Order matches the existing due-check priority.
        # Age means cutter age: calendar time since the current cutter's Installed date.
        if due_datetime:
            due_dt = self.plugin._parse_datetime(due_datetime)
            if due_dt and now_dt >= due_dt:
                return "date '%s' condition met" % due_datetime
        try:
            due_age_seconds = int(due_age_seconds or 0)
        except:
            due_age_seconds = 0
        if due_age_seconds > 0:
            cutter_age_seconds = self._seconds_since_text_datetime(now_dt, installed_date)
            if cutter_age_seconds >= due_age_seconds:
                return "age '%s' condition met" % self.plugin._format_age_seconds(due_age_seconds)
        try:
            due_usage_seconds = int(due_usage_seconds or 0)
        except:
            due_usage_seconds = 0
        if due_usage_seconds > 0:
            if int(total_seconds or 0) >= due_usage_seconds:
                return "usage '%s' condition met" % self.plugin._format_seconds(due_usage_seconds)
        return ""

    def post_triggered_reminders_to_status(self):
        rows = []
        now_dt = datetime.datetime.now()
        conn = self.plugin._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT r.tool, r.message, r.due_datetime,
                          r.due_age_seconds, r.due_usage_seconds,
                          r.created_at, r.triggered_at,
                          u.installed_date, u.total_seconds
                   FROM tool_reminders r
                   LEFT JOIN tool_usage u ON u.tool = r.tool
                   WHERE r.triggered_at IS NOT NULL
                     AND r.triggered_at != ''
                   ORDER BY r.triggered_at ASC, r.tool ASC, r.id ASC""")
            rows = c.fetchall()
        finally:
            conn.close()
        if len(rows) <= 0:
            return
        self.plugin._write_high_status(" ")
        self.plugin._write_high_status("============================")
        for row in rows:
            tool_num, message, due_datetime, due_age_seconds, due_usage_seconds, created_at, triggered_at, installed_date, total_seconds = row
            if not message:
                message = "Reminder due"
            condition_text = self._get_due_condition_text(
                now_dt,
                due_datetime,
                due_age_seconds,
                due_usage_seconds,
                installed_date,
                total_seconds)
            if condition_text:
                self.plugin._write_high_status("Reminder: T%d flagged for %s (%s)" % (int(tool_num), message, condition_text))
            else:
                self.plugin._write_high_status("Reminder: T%d flagged for %s (triggered %s)" % (int(tool_num), message, triggered_at))
        self.plugin._write_high_status("============================")
        self.plugin._write_high_status(" ")

    def _fire_reminder(self, reminder_id, tool_num, message, condition_text):
        if not message:
            message = "Reminder due"
        now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        conn = self.plugin._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """UPDATE tool_reminders
                   SET triggered_at = ?
                   WHERE id = ?""",
                (now_text, int(reminder_id)))
            self.plugin._append_named_tool_event(
                c, tool_num,
                "Reminder triggered: %s (%s)" % (
                    self.plugin._normalize_text(message),
                    self.plugin._normalize_text(condition_text)),
                "reminder_triggered", "", condition_text)
            conn.commit()
        finally:
            conn.close()
        self.plugin._write_high_status(" ")
        self.plugin._write_high_status("============================")
        self.plugin._write_high_status("Reminder: T%d flagged for %s (%s)" % (int(tool_num), message, condition_text))
        self.plugin._write_high_status("============================")
        self.plugin._write_high_status(" ")
        self.plugin._refresh_triggered_reminder_cache()
        try:
            self.plugin.ui.tool_treeview.queue_draw()
        except:
            pass

class ToolUsageByDayViewer(object):
    # GTK month/hour viewer for Hobbs DB tool_runtime_segments.
    # This does not parse legacy Hobbs text files; it reads completed DB runtime segments only.

    def __init__(self, plugin, tool_num, parent_window):
        self.plugin = plugin
        self.tool_num = int(tool_num)
        today = datetime.date.today()
        self.current_year = today.year
        self.current_month = today.month
        self.window = None
        self.parent_window = parent_window
        self.month_label = None
        self.grid_box = None
        self.total_label = None

    def show(self):
        parent_window = self.parent_window
        self.window = gtk.Dialog(
            "T%d Usage By Day" % self.tool_num,
            parent_window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            ())
        self.window.set_default_size(760, 560)
        try:
            self.window.set_position(gtk.WIN_POS_CENTER_ON_PARENT)
        except:
            pass
        outer = gtk.VBox(False, 8)
        self._ensure_segment_table()
        outer.set_border_width(10)
        self.window.vbox.pack_start(outer, True, True, 0)
        nav = gtk.HBox(False, 6)
        prev_button = gtk.Button("<")
        next_button = gtk.Button(">")
        today_button = gtk.Button("Today")
        close_button = gtk.Button("Close")
        self.month_label = gtk.Label()
        self.month_label.set_alignment(0.5, 0.5)
        prev_button.connect("clicked", self._on_prev_month)
        next_button.connect("clicked", self._on_next_month)
        today_button.connect("clicked", self._on_today)
        close_button.connect("clicked", self._on_close)
        nav.pack_start(prev_button, False, False, 0)
        nav.pack_start(self.month_label, True, True, 0)
        nav.pack_start(next_button, False, False, 0)
        nav.pack_start(today_button, False, False, 0)
        nav.pack_start(close_button, False, False, 0)
        outer.pack_start(nav, False, False, 0)
        self.grid_box = gtk.Table(7, 7, True)
        outer.pack_start(self.grid_box, True, True, 0)
        self.total_label = gtk.Label()
        self.total_label.set_alignment(0.0, 0.5)
        outer.pack_start(self.total_label, False, False, 0)
        self._refresh_month()
        self.plugin._prepare_maximized_window(self.window)
        self.window.show_all()
        try:
            self.window.present()
        except:
            pass
        glib.idle_add(self.plugin._maximize_window_once, self.window)
        glib.timeout_add(250, self.plugin._maximize_window_once, self.window)
        try:
            self.window.run()
        finally:
            self.window.destroy()
            self.window = None

    def _ensure_segment_table(self):
        # Keeps the usage viewer harmless if updated Hobbs has not created the segment table yet.
        try:
            conn = self._db_connect()
            try:
                c = conn.cursor()
                c.execute("""CREATE TABLE IF NOT EXISTS tool_runtime_segments (
                                id INTEGER PRIMARY KEY AUTOINCREMENT,
                                tool INTEGER NOT NULL,
                                start_time TEXT NOT NULL,
                                end_time TEXT NOT NULL,
                                seconds INTEGER NOT NULL,
                                start_rpm REAL DEFAULT 0,
                                end_rpm REAL DEFAULT 0,
                                max_rpm REAL DEFAULT 0,
                                source TEXT DEFAULT 'hobbs',
                                run_source TEXT DEFAULT '',
                                program_path TEXT DEFAULT '')""")
                try:
                    c.execute("ALTER TABLE tool_runtime_segments ADD COLUMN run_source TEXT DEFAULT ''")
                except:
                    pass
                try:
                    c.execute("ALTER TABLE tool_runtime_segments ADD COLUMN program_path TEXT DEFAULT ''")
                except:
                    pass                                
                c.execute("CREATE INDEX IF NOT EXISTS idx_tool_runtime_segments_tool_start ON tool_runtime_segments (tool, start_time)")
                c.execute("CREATE INDEX IF NOT EXISTS idx_tool_runtime_segments_start ON tool_runtime_segments (start_time)")
                conn.commit()
            finally:
                conn.close()
        except Exception as e:
            self.plugin._write_status("[Tool Life Column] Usage segment table check failed: {}".format(str(e)))

    def _db_connect(self):
        return self.plugin._db_connect()

    def _parse_datetime(self, text):
        try:
            return datetime.datetime.strptime(str(text), "%Y-%m-%d %H:%M:%S")
        except:
            return None

    def _seconds_between(self, start_dt, end_dt):
        delta = end_dt - start_dt
        return int(delta.days * 86400 + delta.seconds)

    def _format_seconds(self, seconds):
        seconds = int(seconds or 0)
        hours = seconds // 3600
        minutes = (seconds % 3600) // 60
        secs = seconds % 60
        if hours > 0:
            return "%dh %02dm %02ds" % (hours, minutes, secs)
        if minutes > 0:
            return "%dm %02ds" % (minutes, secs)
        if seconds > 0:
            return "%ds" % seconds
        return "-"

    def _get_month_bounds(self):
        start_dt = datetime.datetime(self.current_year, self.current_month, 1, 0, 0, 0)
        if self.current_month == 12:
            end_dt = datetime.datetime(self.current_year + 1, 1, 1, 0, 0, 0)
        else:
            end_dt = datetime.datetime(self.current_year, self.current_month + 1, 1, 0, 0, 0)
        return start_dt, end_dt

    def _get_day_bounds(self, day):
        start_dt = datetime.datetime(self.current_year, self.current_month, int(day), 0, 0, 0)
        end_dt = start_dt + datetime.timedelta(days=1)
        return start_dt, end_dt

    def _fetch_segments(self, start_dt, end_dt):
        rows = []
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute("SELECT start_time, end_time, seconds, run_source, program_path FROM tool_runtime_segments WHERE tool = ? AND start_time < ? AND end_time >= ? ORDER BY start_time ASC", (int(self.tool_num), end_dt.strftime("%Y-%m-%d %H:%M:%S"), start_dt.strftime("%Y-%m-%d %H:%M:%S")))
            rows = c.fetchall()
        finally:
            conn.close()
        return rows

    def _add_segment_to_day_totals(self, day_totals, segment_start, segment_seconds, range_start, range_end):
        segment_end = segment_start + datetime.timedelta(seconds=int(segment_seconds))
        current = max(segment_start, range_start)
        stop_dt = min(segment_end, range_end)
        while current < stop_dt:
            next_day = datetime.datetime(current.year, current.month, current.day, 0, 0, 0) + datetime.timedelta(days=1)
            chunk_end = min(next_day, stop_dt)
            day_totals[current.day] = day_totals.get(current.day, 0) + self._seconds_between(current, chunk_end)
            current = chunk_end

    def _get_month_day_totals(self):
        day_totals = {}
        start_dt, end_dt = self._get_month_bounds()
        try:
            rows = self._fetch_segments(start_dt, end_dt)
            for start_text, end_text, seconds, run_source, program_path in rows:
                segment_start = self._parse_datetime(start_text)
                if segment_start and int(seconds or 0) > 0:
                    self._add_segment_to_day_totals(day_totals, segment_start, int(seconds), start_dt, end_dt)
        except Exception as e:
            self.plugin._write_status("[Tool Life Column] Usage by day query failed: {}".format(str(e)))
        return day_totals

    def _add_segment_to_hour_totals(self, hour_totals, segment_start, segment_seconds, range_start, range_end):
        segment_end = segment_start + datetime.timedelta(seconds=int(segment_seconds))
        current = max(segment_start, range_start)
        stop_dt = min(segment_end, range_end)
        while current < stop_dt:
            next_hour = datetime.datetime(current.year, current.month, current.day, current.hour, 0, 0) + datetime.timedelta(hours=1)
            chunk_end = min(next_hour, stop_dt)
            hour_totals[current.hour] = hour_totals.get(current.hour, 0) + self._seconds_between(current, chunk_end)
            current = chunk_end

    def _get_day_hour_totals_and_segments(self, day):
        hour_totals = dict((hour, 0) for hour in range(24))
        segment_rows = []
        start_dt, end_dt = self._get_day_bounds(day)
        try:
            rows = self._fetch_segments(start_dt, end_dt)
            for start_text, end_text, seconds, run_source, program_path in rows:
                segment_start = self._parse_datetime(start_text)
                if segment_start and int(seconds or 0) > 0:
                    self._add_segment_to_hour_totals(hour_totals, segment_start, int(seconds), start_dt, end_dt)
                    segment_rows.append((start_text, end_text, int(seconds), run_source, program_path))
        except Exception as e:
            self.plugin._write_status("[Tool Life Column] Usage by hour query failed: {}".format(str(e)))
        return hour_totals, segment_rows

    def _clear_grid(self):
        for child in self.grid_box.get_children():
            self.grid_box.remove(child)

    def _refresh_month(self):
        self._clear_grid()
        month_name = datetime.date(self.current_year, self.current_month, 1).strftime("%B %Y")
        self.month_label.set_markup('<span weight="bold" size="large">T%d Usage - %s</span>' % (self.tool_num, month_name))
        headers = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
        for col in xrange(0, len(headers)):
            label = gtk.Label()
            label.set_markup("<b>%s</b>" % headers[col])
            self.grid_box.attach(label, col, col + 1, 0, 1)
        day_totals = self._get_month_day_totals()
        weeks = calendar.Calendar(firstweekday=6).monthdayscalendar(self.current_year, self.current_month)
        month_total = 0
        for row_index in xrange(0, len(weeks)):
            week = weeks[row_index]
            for col in xrange(0, len(week)):
                day = week[col]
                if day <= 0:
                    filler = gtk.Label("")
                    self.grid_box.attach(filler, col, col + 1, row_index + 1, row_index + 2)
                else:
                    seconds = int(day_totals.get(day, 0) or 0)
                    month_total += seconds
                    button = gtk.Button()
                    cell_box = gtk.VBox(False, 2)
                    day_label = gtk.Label()
                    day_label.set_markup("<b>%d</b>" % day)
                    day_label.set_alignment(0.5, 0.5)
                    day_label.set_justify(gtk.JUSTIFY_CENTER)
                    usage_label = gtk.Label(self._format_seconds(seconds))
                    usage_label.set_alignment(0.5, 0.5)
                    usage_label.set_justify(gtk.JUSTIFY_CENTER)
                    today = datetime.date.today()
                    if today.year == self.current_year and today.month == self.current_month and today.day == day:
                        # GTK2 themes do not reliably support a true outline on buttons.
                        # Use a blue-tinted cell and blue bold day number so today is obvious.
                        day_label.set_markup('<span foreground="#0057d9" weight="bold" size="large">%d</span>' % day)
                        try:
                            button.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#d9ecff"))
                            button.modify_bg(gtk.STATE_PRELIGHT, gtk.gdk.color_parse("#c6e2ff"))
                            button.modify_bg(gtk.STATE_ACTIVE, gtk.gdk.color_parse("#aacfff"))
                        except:
                            pass
                    cell_box.pack_start(day_label, True, True, 0)
                    cell_box.pack_start(usage_label, True, True, 0)
                    button.add(cell_box)
                    button.connect("clicked", self._on_day_clicked, day)
                    self.grid_box.attach(button, col, col + 1, row_index + 1, row_index + 2)
        self.total_label.set_text("Month total for T%d: %s" % (self.tool_num, self._format_seconds(month_total)))
        self.window.show_all()

    def _make_hour_bar(self, seconds):
        # Always scale bars against a full 60-minute hour, not the busiest hour of the day.
        bar_width = 50
        full_hour_seconds = 3600
        if seconds <= 0:
            blocks = 0
        else:
            blocks = int(round((float(seconds) / float(full_hour_seconds)) * bar_width))
            if blocks < 1:
                blocks = 1
            if blocks > bar_width:
                blocks = bar_width
        return u"[%s%s]" % (u"\u2588" * blocks, u" " * (bar_width - blocks))

    def _on_day_clicked(self, button, day):
        self._show_hour_window(day)

    def _format_time_only(self, datetime_text):
        dt = self._parse_datetime(datetime_text)
        if dt:
            return dt.strftime("%H:%M:%S")
        text = str(datetime_text or "").strip()
        if len(text) >= 8:
            return text[-8:]
        return text

    def _format_runtime_source(self, run_source, program_path):
        source_text = str(run_source or "").strip()
        if source_text:
            return source_text
        path_text = str(program_path or "").strip()
        if path_text:
            return os.path.basename(path_text)
        return "MDI / jog shuttle"

    def _show_hour_window(self, day):
        hour_totals, segment_rows = self._get_day_hour_totals_and_segments(day)
        date_text = "%04d-%02d-%02d" % (self.current_year, self.current_month, int(day))
        total_seconds = sum(hour_totals.values())
        window = gtk.Dialog(
            "T%d Usage By Hour - %s" % (self.tool_num, date_text),
            self.window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            ())
        window.set_default_size(860, 620)
        outer = gtk.VBox(False, 8)
        outer.set_border_width(10)
        window.vbox.pack_start(outer, True, True, 0)
        title = gtk.Label()
        title.set_alignment(0.0, 0.5)
        title.set_markup('<span weight="bold" size="large">T%d Usage By Hour - %s</span>' % (self.tool_num, date_text))
        outer.pack_start(title, False, False, 0)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        text_view = gtk.TextView()
        text_view.set_editable(False)
        text_view.set_cursor_visible(False)
        try:
            text_view.modify_font(pango.FontDescription("monospace 10"))
        except:
            pass
        lines = []
        lines.append("Tool: T%d" % self.tool_num)
        lines.append("Date: %s" % date_text)
        lines.append("Total: %s" % self._format_seconds(total_seconds))
        lines.append("")
        lines.append("Hour   Runtime     60-Min Hour Bar")
        lines.append("-" * 78)
        for hour in xrange(0, 24):
            seconds = int(hour_totals.get(hour, 0) or 0)
            lines.append("%02d:00  %-10s  %s" % (hour, self._format_seconds(seconds), self._make_hour_bar(seconds)))
        lines.append("-" * 78)
        lines.append("")
        lines.append("Runtime windows")
        lines.append("-" * 78)
        if len(segment_rows) > 0:
            for start_text, end_text, seconds, run_source, program_path in segment_rows:
                lines.append("%s  to  %s  (%s)  (%s)" % (
                    start_text,
                    self._format_time_only(end_text),
                    self._format_seconds(seconds),
                    self._format_runtime_source(run_source, program_path)))
        else:
            lines.append("No DB runtime segments found for this tool on this day.")
        text_buffer = text_view.get_buffer()
        text_buffer.set_text("\n".join(lines))
        scroller.add(text_view)
        outer.pack_start(scroller, True, True, 0)
        close_button = gtk.Button("Close")
        close_button.connect("clicked", lambda b: window.response(gtk.RESPONSE_CLOSE))
        outer.pack_start(close_button, False, False, 0)
        self.plugin._prepare_maximized_window(window)
        window.show_all()
        try:
            window.present()
        except:
            pass
        glib.idle_add(self.plugin._maximize_window_once, window)
        glib.timeout_add(250, self.plugin._maximize_window_once, window)
        try:
            window.run()
        finally:
            window.destroy()

    def _on_prev_month(self, button):
        if self.current_month == 1:
            self.current_month = 12
            self.current_year -= 1
        else:
            self.current_month -= 1
        self._refresh_month()

    def _on_next_month(self, button):
        if self.current_month == 12:
            self.current_month = 1
            self.current_year += 1
        else:
            self.current_month += 1
        self._refresh_month()

    def _on_today(self, button):
        today = datetime.date.today()
        self.current_year = today.year
        self.current_month = today.month
        self._refresh_month()

    def _on_close(self, button):
        if self.window:
            self.window.response(gtk.RESPONSE_CLOSE)

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 not plugin_enabled:
            return
        self.reminder_manager = None            
        self.ui = None
        self.life_column = None
        self.usage_column = None
        self.holder_column = None
        self.tool_model_column = None
        self.flutes_column = None
        self.holder_combo_store = None
        self.tool_model_combo_store = None
        self.flutes_combo_store = None
        self.holder_catalog_default = "TTS_ER32_GENERIC"
        self.holder_catalog_rows = []
        self.tool_holder_assignment_cache = {}
        self.tool_model_assignment_cache = {}
        self.tool_flute_count_cache = {}
        self.installed = False
        self.tool_usage_cache = set()
        self.tool_usage_seconds_cache = {}        
        self.triggered_reminder_tools_cache = set()
        self.reminder_tools_cache = set()
        self.tool_life_state_cache = {}
        self.pathpilot_metadata_cache = {}        
        self.pathpilot_event_logging_enabled = False        
        self.pathpilot_event_log_queue = []
        self.pathpilot_event_log_flush_pending = False
        self.pathpilot_event_log_flush_generation = 0
        self.pathpilot_structure_change_generation = 0
        self.tool_model_signal_ids = []
        self.modern_tool_edit_signal_ids = []
        self.refresh_pending = False
        self.modern_tool_table = False
        self._wide_tool_table_installed = False
        self._wide_tool_table_enabled = False
        self._wide_tool_table_normal_requested = False
        self._wide_tool_table_notebook = None
        self._wide_tool_table_page = None
        self._wide_tool_table_label = None
        self._wide_tool_table_page_num = -1
        self._wide_tool_table_switch_handler_id = None
        self._wide_tool_table_outer_notebook = None
        self._wide_tool_table_outer_page = None
        self._wide_tool_table_outer_switch_handler_id = None
        self._tool_table_sort_installed = False
        self._tool_table_sort_ids = {}
        self._wide_tool_table_real_page_num = 0
        self._wide_tool_table_geometry = {}
        self._wide_tool_table_child_visibility = {}
        self._goto_tool_page = None
        self._goto_tool_label = None
        self._goto_tool_page_num = -1
        self._goto_tool_pending_number = 0
        self._diameter_resolver_installed = False
        self._diameter_resolver_signal_ids = []
        glib.timeout_add(1000, self._install_when_ready)

    def _check_suite_dependencies_once(self):
        missing = []
        for label, path in (
                ("Gremlin Tool Holder Plugin", GREMLIN_PLUGIN_PATH),
                ("Gremlin Tool Holder Builder", HOLDER_BUILDER_PATH)):
            if not os.path.isfile(path):
                missing.append("%s\n%s" % (label, path))
        if not missing:
            return
        message = (
            "This component is part of the Gremlin/TLM tool-visualization suite "
            "and expects the other suite components to be installed.\n\n"
            "Missing:\n\n%s\n\n"
            "The plugin will continue loading, but related holder/model features "
            "may not work correctly until the missing file(s) are installed."
            % "\n\n".join(missing))
        try:
            dialog = gtk.MessageDialog(
                None,
                gtk.DIALOG_MODAL,
                gtk.MESSAGE_WARNING,
                gtk.BUTTONS_OK,
                "Gremlin/TLM Suite - Missing Component")
            dialog.format_secondary_text(message)
            dialog.run()
            dialog.destroy()
        except Exception:
            try:
                self._write_high_status("[Tool Life Column] " + message.replace("\n", " "))
            except Exception:
                pass

    def _install_when_ready(self):
        try:
            ui = singletons.g_Machine
            if not ui:
                return True
            if not hasattr(ui, "tool_treeview"):
                return True
            if not hasattr(ui, "tool_liststore"):
                return True
            self.ui = ui
            self.modern_tool_table = callable(getattr(ui, "tool_table_get_tool_number", None))
            self.ui.tool_life_manager_plugin = self
            self.ui.tormach_tips_tool_life_manager_plugin = self
            self._check_suite_dependencies_once()
            self._warn_if_hobbs_plugin_missing()            
            self._ensure_db()
            self._migrate_removal_reason_semantics_once()
            self.reminder_manager = ToolReminderManager(self)            
            self._ensure_initial_tool_life_baselines()
            self._refresh_state_cache()
            self._install_life_column()
            self._install_wide_tool_table_toggle()
            self._install_tool_diameter_resolver()
            self._refresh_pathpilot_metadata_cache()
            if not self._uses_modern_tool_table():
                self._connect_tool_model_refresh_signals()
                glib.timeout_add(15000, self._enable_pathpilot_event_logging_once)
            else:
                # PathPilot 2.10+/2.14 rebuilds the 1000-row tool model in idle
                # chunks. Never attach row-changed/insert/delete observers to that
                # model; they amplify OEM rebuild activity on the GTK main thread.
                #
                # Instead, observe only the OEM cell renderers' explicit "edited"
                # signals. Those occur only when the user actually commits an edit.
                self.pathpilot_event_logging_enabled = True
                self._connect_modern_tool_edit_signals()
                self._write_status("[Tool Life Column] Modern quiet-table mode enabled.")
            glib.timeout_add(30000 if self._uses_modern_tool_table() else 10000, self._periodic_refresh)
            glib.timeout_add(60000, self._periodic_reminder_check)            
            glib.timeout_add(600000, self._periodic_triggered_reminder_status_check)            
            glib.idle_add(self._post_triggered_reminders_once_after_install)            
            return False
        except Exception as e:
            self._write_status("[Tool Life Column] Install failed: {}".format(str(e)))
            return False

    def _periodic_reminder_check(self):
        try:
            if self.reminder_manager is not None:
                self.reminder_manager.poll_reminders()
        except Exception as e:
            self._write_status("[Tool Life Column] Reminder check failed: {}".format(str(e)))
        return True

    def _post_triggered_reminders_once_after_install(self):
        try:
            if self.reminder_manager is not None:
                self.reminder_manager.post_triggered_reminders_to_status()
        except Exception as e:
            self._write_status("[Tool Life Column] Initial triggered reminder status post failed: {}".format(str(e)))
        return False

    def _periodic_triggered_reminder_status_check(self):
        try:
            if self.reminder_manager is not None:
                self.reminder_manager.post_triggered_reminders_to_status()
        except Exception as e:
            self._write_status("[Tool Life Column] Triggered reminder status loop failed: {}".format(str(e)))
        return True

    def _write_high_status(self, message):
        try:
            high_level = getattr(constants, "ALARM_LEVEL_HIGH", constants.ALARM_LEVEL_MEDIUM)
            self.error_handler.write(message, high_level)
        except:
            pass        

    def _ensure_db(self):
        if not os.path.isdir(HOBBS_DIR):
            os.makedirs(HOBBS_DIR)
        conn = self._db_connect()
        c = conn.cursor()
        c.execute('''CREATE TABLE IF NOT EXISTS tool_usage (
                        tool INTEGER PRIMARY KEY,
                        installed_date TEXT,
                        total_seconds INTEGER DEFAULT 0
                     )''')
        try:
            c.execute("ALTER TABLE tool_usage ADD COLUMN install_note TEXT")
        except:
            pass
        c.execute('''CREATE TABLE IF NOT EXISTS tool_history (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        tool INTEGER NOT NULL,
                        installed_date TEXT,
                        reset_date TEXT NOT NULL,
                        elapsed_seconds INTEGER DEFAULT 0,
                        total_seconds INTEGER DEFAULT 0,
                        note TEXT
                     )''')
        try:
            c.execute("ALTER TABLE tool_history ADD COLUMN note TEXT")
        except:
            pass
        c.execute('''CREATE TABLE IF NOT EXISTS tool_life_state (
                        tool INTEGER PRIMARY KEY,
                        last_description TEXT,
                        last_diameter TEXT,
                        last_length TEXT,
                        synced_at TEXT
                     )''')
        c.execute('''CREATE TABLE IF NOT EXISTS tool_event_log (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        tool INTEGER NOT NULL,
                        logged_at TEXT NOT NULL,
                        event_text TEXT NOT NULL,
                        field_name TEXT,
                        old_value TEXT,
                        new_value TEXT
                     )''')
        c.execute('''CREATE TABLE IF NOT EXISTS tool_reminders (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        tool INTEGER NOT NULL,
                        message TEXT,
                        due_datetime TEXT,
                        due_age_seconds INTEGER DEFAULT 0,
                        due_usage_seconds INTEGER DEFAULT 0,
                        created_at TEXT,
                        triggered_at TEXT
                     )''')                     
        c.execute('''CREATE TABLE IF NOT EXISTS tool_holder_assignment (
                        tool INTEGER PRIMARY KEY,
                        holder_type TEXT NOT NULL,
                        updated_at TEXT
                     )''')
        c.execute('''CREATE TABLE IF NOT EXISTS tool_model_assignment (
                        tool INTEGER PRIMARY KEY,
                        model_type TEXT NOT NULL,
                        flute_count INTEGER DEFAULT 4,
                        updated_at TEXT
                     )''')
        c.execute("""CREATE TABLE IF NOT EXISTS tool_notes (
                        tool INTEGER PRIMARY KEY,
                        notes TEXT NOT NULL DEFAULT '',
                        updated_at TEXT
                     )""")
        try:
            c.execute("ALTER TABLE tool_model_assignment ADD COLUMN flute_count INTEGER DEFAULT 4")
        except:
            pass
        c.execute('''CREATE TABLE IF NOT EXISTS tlm_meta (
                        key TEXT PRIMARY KEY,
                        value TEXT
                     )''')
        try:
            c.execute("ALTER TABLE tool_reminders ADD COLUMN message TEXT")
        except:
            pass
        try:
            c.execute("ALTER TABLE tool_reminders ADD COLUMN due_datetime TEXT")
        except:
            pass
        try:
            c.execute("ALTER TABLE tool_reminders ADD COLUMN due_age_seconds INTEGER DEFAULT 0")
        except:
            pass
        try:
            c.execute("ALTER TABLE tool_reminders ADD COLUMN due_usage_seconds INTEGER DEFAULT 0")
        except:
            pass
        try:
            c.execute("ALTER TABLE tool_reminders ADD COLUMN created_at TEXT")
        except:
            pass
        try:
            c.execute("ALTER TABLE tool_reminders ADD COLUMN triggered_at TEXT")
        except:
            pass                     
        conn.commit()
        conn.close()

    def _db_connect(self):
        return sqlite3.connect(DB_FILE)

    def _get_tool_notes(self, tool_num):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute("SELECT notes FROM tool_notes WHERE tool = ?", (int(tool_num),))
            row = c.fetchone()
            if row and row[0] is not None:
                return row[0]
        except Exception as e:
            self._write_status("[Tool Life Column] T%d notes read failed: %s" % (int(tool_num), str(e)))
        finally:
            conn.close()
        return ""

    def _set_tool_notes(self, tool_num, notes):
        now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        if notes is None:
            notes = ""
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute("SELECT notes FROM tool_notes WHERE tool = ?", (int(tool_num),))
            old_row = c.fetchone()
            old_notes = old_row[0] if old_row and old_row[0] is not None else ""
            c.execute(
                """INSERT OR REPLACE INTO tool_notes
                   (tool, notes, updated_at)
                   VALUES (?, ?, ?)""",
                (int(tool_num), notes, now_text))
            if self._normalize_text(old_notes) != self._normalize_text(notes):
                self._append_named_tool_event(c, tool_num, "Persistent tool notes changed", "notes", old_notes, notes)
            conn.commit()
        except Exception as e:
            try:
                conn.rollback()
            except:
                pass
            self._write_status("[Tool Life Column] T%d notes save failed: %s" % (int(tool_num), str(e)))
            raise
        finally:
            conn.close()

    def _show_tool_notes_dialog(self, tool_num, parent_window=None):
        # Plain persistent per-T-number scratchpad. Notes intentionally belong
        # to the tool number, not to one cutter-history generation.
        dialog = gtk.Dialog(
            "Notes - T%d" % int(tool_num),
            parent_window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
             gtk.STOCK_SAVE, gtk.RESPONSE_OK))
        dialog.set_default_size(650, 420)
        dialog.set_border_width(8)
        outer = gtk.VBox(False, 6)
        outer.set_border_width(6)
        dialog.vbox.pack_start(outer, True, True, 0)
        hint = gtk.Label(
            "Persistent notes for T%d. These notes stay with the tool number "
            "and are not changed by cutter replacement history." % int(tool_num))
        hint.set_alignment(0.0, 0.5)
        hint.set_line_wrap(True)
        outer.pack_start(hint, False, False, 0)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.set_shadow_type(gtk.SHADOW_IN)
        text_view = gtk.TextView()
        text_view.set_wrap_mode(gtk.WRAP_WORD_CHAR)
        text_view.set_accepts_tab(True)
        try:
            text_view.modify_font(pango.FontDescription("monospace 10"))
        except:
            pass
        text_buffer = text_view.get_buffer()
        try:
            existing_notes = self._get_tool_notes(tool_num)
        except:
            existing_notes = ""
        if existing_notes is None:
            existing_notes = ""
        text_buffer.set_text(existing_notes)
        scroller.add(text_view)
        outer.pack_start(scroller, True, True, 0)
        dialog.set_default_response(gtk.RESPONSE_OK)
        dialog.show_all()
        try:
            text_view.grab_focus()
        except:
            pass
        response = dialog.run()
        if response == gtk.RESPONSE_OK:
            start_iter = text_buffer.get_start_iter()
            end_iter = text_buffer.get_end_iter()
            notes = text_buffer.get_text(start_iter, end_iter, True)
            try:
                self._set_tool_notes(tool_num, notes)
                self._write_status("[Tool Life Column] T%d notes saved." % int(tool_num))
            except Exception:
                pass
        dialog.destroy()

    def _migrate_removal_reason_semantics_once(self):
        migration_key = "removal_reason_semantics_v1"
        conn = self._db_connect()
        shifted_rows = 0
        affected_tools = 0
        try:
            c = conn.cursor()
            c.execute("SELECT value FROM tlm_meta WHERE key = ?", (migration_key,))
            marker = c.fetchone()
            if marker and self._normalize_text(marker[0]) == "done":
                return
            c.execute(
                """SELECT tool FROM tool_usage
                   UNION
                   SELECT tool FROM tool_history
                   ORDER BY tool ASC""")
            tool_numbers = [int(row[0]) for row in c.fetchall()]
            for tool_num in tool_numbers:
                c.execute(
                    """SELECT install_note
                       FROM tool_usage
                       WHERE tool = ?""",
                    (tool_num,))
                current_row = c.fetchone()
                current_note = self._normalize_text(current_row[0]) if current_row else ""
                c.execute(
                    """SELECT id, note
                       FROM tool_history
                       WHERE tool = ?
                       ORDER BY id DESC""",
                    (tool_num,))
                history_rows = c.fetchall()
                # Old behavior attached each replacement reason to the incoming
                # cutter. Shift every note one generation downward so each
                # archived row owns the reason that cutter was removed.
                carry_note = current_note
                tool_changed = False
                for history_id, old_note in history_rows:
                    old_note = self._normalize_text(old_note)
                    c.execute(
                        """UPDATE tool_history
                           SET note = ?
                           WHERE id = ? AND tool = ?""",
                        (carry_note, int(history_id), tool_num))
                    carry_note = old_note
                    shifted_rows += 1
                    tool_changed = True
                # Current cutter has not been removed yet. This also drops the
                # old "auto-commissioned during plugin install" note.
                if current_row:
                    c.execute(
                        """UPDATE tool_usage
                           SET install_note = ''
                           WHERE tool = ?""",
                        (tool_num,))
                    if current_note:
                        tool_changed = True
                if tool_changed:
                    affected_tools += 1
            c.execute(
                """INSERT OR REPLACE INTO tlm_meta (key, value)
                   VALUES (?, ?)""",
                (migration_key, "done"))
            conn.commit()
            self._write_status(
                "[Tool Life Column] Removal Reason migration complete: "
                "%d history rows across %d tools." %
                (shifted_rows, affected_tools))
        except Exception as e:
            try:
                conn.rollback()
            except:
                pass
            self._write_status("[Tool Life Column] Removal Reason migration failed: %s" % str(e))
        finally:
            conn.close()

    def _refresh_triggered_reminder_cache(self):
        triggered_tools = set()
        reminder_tools = set()
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT DISTINCT tool
                   FROM tool_reminders""")
            for row in c.fetchall():
                try:
                    reminder_tools.add(int(row[0]))
                except:
                    pass
            c.execute(
                """SELECT DISTINCT tool
                   FROM tool_reminders
                   WHERE triggered_at IS NOT NULL
                     AND triggered_at != ''""")
            for row in c.fetchall():
                try:
                    triggered_tools.add(int(row[0]))
                except:
                    pass
        except:
            pass
        finally:
            conn.close()
        self.triggered_reminder_tools_cache = triggered_tools
        self.reminder_tools_cache = reminder_tools

    def _refresh_state_cache(self):
        usage = set()
        usage_seconds = {}
        state = {}
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute("SELECT tool, total_seconds FROM tool_usage")
            for row in c.fetchall():
                tool_num = int(row[0])
                usage.add(tool_num)
                usage_seconds[tool_num] = int(row[1] or 0)
            c.execute("SELECT tool, last_description, last_diameter, last_length FROM tool_life_state")
            for row in c.fetchall():
                state[int(row[0])] = (
                    self._normalize_text(row[1]),
                    self._normalize_text(row[2]),
                    self._normalize_text(row[3]))
            holder_assignments = {}
            model_assignments = {}
            flute_counts = {}
            c.execute("SELECT tool, holder_type FROM tool_holder_assignment")
            for row in c.fetchall():
                holder_assignments[int(row[0])] = self._normalize_text(row[1])
            c.execute("SELECT tool, model_type, flute_count FROM tool_model_assignment")
            for row in c.fetchall():
                tool_num = int(row[0])
                model_type = self._normalize_text(row[1]).lower()
                model_assignments[tool_num] = TOOL_MODEL_ALIASES.get(model_type, model_type)
                try:
                    count = int(row[2])
                except:
                    count = 4
                flute_counts[tool_num] = max(1, min(6, count))
        finally:
            conn.close()
        self.tool_usage_cache = usage
        self.tool_usage_seconds_cache = usage_seconds
        self.tool_life_state_cache = state
        self.tool_holder_assignment_cache = holder_assignments
        self.tool_model_assignment_cache = model_assignments
        self.tool_flute_count_cache = flute_counts
        self._refresh_triggered_reminder_cache()

    def _metadata_canonical_number_from_display(self, value):
        """Normalize a displayed PathPilot linear value to inches.
        PathPilot's legacy tool-table ListStore is display-unit based, so G20/G21
        can rewrite every Diameter/Length cell even though no physical tool data
        changed.  TLM keeps comparison/event baselines in inches so unit display
        changes remain presentation-only.
        """
        text = self._normalize_text(value)
        if not text:
            return ""
        try:
            numeric = float(text)
        except Exception:
            return text
        if self._tool_diameter_is_metric():
            numeric /= 25.4
        if abs(numeric) < 0.00000005:
            numeric = 0.0
        # Legacy PathPilot displays inch tool values to four decimals.  Using the
        # same canonical precision also absorbs the harmless round-trip from a
        # three-decimal metric display back to inches.
        return "%.4f" % numeric

    def _metadata_tuple_from_row(self, row):
        return (
            self._normalize_text(row["description"]),
            self._metadata_canonical_number_from_display(row["diameter"]),
            self._metadata_canonical_number_from_display(row["length"]))

    def _saved_metadata_number_matches_display(self, saved_value, display_value):
        """Compare one stored value with the current PathPilot display value.
        New 1.53 baselines are canonical inches. Older databases may contain
        whichever units were displayed when a cutter was commissioned/synced,
        so accept either legacy inches or legacy millimeters when they represent
        the same physical value.
        """
        saved_text = self._normalize_text(saved_value)
        display_text = self._normalize_text(display_value)
        if saved_text == display_text:
            return True
        if not saved_text or not display_text:
            return False
        try:
            saved = float(saved_text)
            displayed = float(display_text)
        except Exception:
            return False
        current_inches = displayed / 25.4 if self._tool_diameter_is_metric() else displayed
        tolerance = 0.00011
        # saved may already be canonical/inch, or may be a pre-1.53 metric value.
        if abs(saved - current_inches) <= tolerance:
            return True
        if abs((saved / 25.4) - current_inches) <= tolerance:
            return True
        return False

    def _saved_metadata_state_matches_row(self, saved_state, row):
        if saved_state is None:
            return False
        try:
            if self._normalize_text(saved_state[0]) != self._normalize_text(row["description"]):
                return False
            if not self._saved_metadata_number_matches_display(saved_state[1], row["diameter"]):
                return False
            if not self._saved_metadata_number_matches_display(saved_state[2], row["length"]):
                return False
            return True
        except Exception:
            return False

    def _tool_diameter_is_metric(self):
        # Prefer the UI linear scale because the legacy tool ListStore is rebuilt
        # from that display scale during G20/G21 changes.  Falling back to the
        # interpreter modal state keeps compatibility with layouts that do not
        # expose get_linear_scale().
        try:
            scale = float(self.ui.get_linear_scale())
            if abs(scale - 25.4) < 0.001:
                return True
            if abs(scale - 1.0) < 0.001:
                return False
        except Exception:
            pass
        try:
            return (self.ui.status.gcodes[linuxcnc.G_CODE_UNITS] == 210)
        except Exception:
            return False

    def _format_tool_diameter_resolved_value(self, value):
        # Give the OEM validator enough precision for explicit mm conversions;
        # the normal PathPilot column formatter controls what the user finally
        # sees in the table.
        text = "%.10f" % float(value)
        text = text.rstrip("0").rstrip(".")
        if not text:
            text = "0"
        return text

    def _resolve_tool_diameter_entry_text(self, raw_text):
        """Return replacement text, or None to leave OEM entry untouched.
        The important compatibility rule is that ordinary PathPilot numeric
        expressions are never parsed here.  Inputs such as 1/4, 3/16, .125+.125,
        etc. fall straight through to PathPilot's existing expression parser.
        """
        try:
            raw_text = str(raw_text)
        except Exception:
            return None
        stripped = raw_text.strip()
        if not stripped:
            return None
        # Explicit metric suffix is supported on both legacy and modern
        # PathPilot regardless of the currently displayed units.
        match = TOOL_DIAMETER_MM_RE.match(stripped)
        if match:
            try:
                mm_value = float(match.group(1))
            except Exception:
                return None
            if self._tool_diameter_is_metric():
                value = mm_value
            else:
                value = mm_value / 25.4
            return self._format_tool_diameter_resolved_value(value)
        # PathPilot 2.10+/2.14 already has an OEM CSV-backed gauge resolver.
        # Preserve it.  We only normalize lowercase letters and optional space
        # after '#', then let the OEM edited handler do the lookup.
        if self._uses_modern_tool_table():
            letter_match = TOOL_DIAMETER_LETTER_DRILL_RE.match(stripped)
            if letter_match:
                return letter_match.group(1).upper()
            number_match = TOOL_DIAMETER_NUMBER_DRILL_RE.match(stripped)
            if number_match:
                return "#%d" % int(number_match.group(1))
            return None
        # Legacy 2.9.x has no validate_tool_gauge(), so resolve the standard
        # number/letter drill table here and hand a plain number to the exact
        # OEM validation/save path.
        letter_match = TOOL_DIAMETER_LETTER_DRILL_RE.match(stripped)
        if letter_match:
            letter = letter_match.group(1).upper()
            diameter_in = LETTER_DRILL_DIAMETERS_IN.get(letter)
            if diameter_in is None:
                return None
            value = (diameter_in * 25.4 if self._tool_diameter_is_metric() else diameter_in)
            return self._format_tool_diameter_resolved_value(value)
        number_match = TOOL_DIAMETER_NUMBER_DRILL_RE.match(stripped)
        if number_match:
            try:
                number = int(number_match.group(1))
            except Exception:
                return None
            diameter_in = NUMBER_DRILL_DIAMETERS_IN.get(number)
            if diameter_in is None:
                return None
            value = (diameter_in * 25.4 if self._tool_diameter_is_metric() else diameter_in)
            return self._format_tool_diameter_resolved_value(value)
        return None

    def _rewrite_tool_diameter_editable(self, editable):
        try:
            if editable is None:
                return False
            if not hasattr(editable, "get_text"):
                return False
            if not hasattr(editable, "set_text"):
                return False
            raw_text = editable.get_text()
            replacement = self._resolve_tool_diameter_entry_text(raw_text)
            if replacement is None:
                return False
            if str(replacement) == str(raw_text):
                return False
            editable.set_text(str(replacement))
            try:
                editable.set_position(-1)
            except Exception:
                pass
            return True
        except Exception as e:
            self._write_status("[Tool Life Column] Diameter resolver entry rewrite failed: %s" % str(e))
            return False

    def _on_tool_diameter_resolver_key_press(self, editable, event):
        try:
            if event.keyval in (
                    gtk.keysyms.Return,
                    gtk.keysyms.KP_Enter,
                    gtk.keysyms.Tab,
                    gtk.keysyms.ISO_Left_Tab):
                self._rewrite_tool_diameter_editable(editable)
        except Exception:
            pass
        return False

    def _on_tool_diameter_resolver_focus_out(self, editable, event):
        self._rewrite_tool_diameter_editable(editable)
        return False

    def _on_tool_diameter_resolver_editing_started(self, renderer, editable, path):
        try:
            if editable is None:
                return
            # Resolve before the OEM CellRendererText emits "edited".  That keeps
            # the original PathPilot validation, tool-table write, observer/log,
            # and display-format paths authoritative.
            editable.connect("key-press-event", self._on_tool_diameter_resolver_key_press)
            editable.connect("focus-out-event", self._on_tool_diameter_resolver_focus_out)
        except Exception as e:
            self._write_status("[Tool Life Column] Diameter resolver editor hook failed: %s" % str(e))

    def _install_tool_diameter_resolver(self):
        if self._diameter_resolver_installed:
            return True
        try:
            if self.ui is None:
                return False
            column = getattr(self.ui, "tool_diameter_column", None)
            if column is None:
                return False
            renderers = column.get_cell_renderers()
            if not renderers:
                return False
            renderer = renderers[0]
            # Avoid duplicate runtime hooks if the plugin is reloaded in-place.
            if bool(getattr(
                    renderer,
                    "_tormachtips_diameter_resolver_installed",
                    False)):
                self._diameter_resolver_installed = True
                return True
            signal_id = renderer.connect("editing-started", self._on_tool_diameter_resolver_editing_started)
            self._diameter_resolver_signal_ids.append((renderer, signal_id))
            try:
                renderer._tormachtips_diameter_resolver_installed = True
            except Exception:
                pass
            self._diameter_resolver_installed = True
            if self._uses_modern_tool_table():
                self._write_status(
                    "[Tool Life Column] Diameter resolver enabled: explicit mm "
                    "suffix added; OEM PathPilot number/letter drill resolver retained.")
            else:
                self._write_status(
                    "[Tool Life Column] Diameter resolver enabled: #1-#80, A-Z, "
                    "and explicit mm suffix. OEM fractions/expressions are unchanged.")
            return True
        except Exception as e:
            self._write_status("[Tool Life Column] Diameter resolver install failed: %s" % str(e))
            return False

    def _uses_modern_tool_table(self):
        # This is set once during install. Avoid repeated getattr/callable work
        # inside GTK cell-data callbacks, which can execute very frequently.
        return bool(self.modern_tool_table)

    def _get_modern_hidden_tool_number_from_iter(self, model, tree_iter):
        # PathPilot 2.14.x mill tool rows contain the actual tool number in a
        # hidden integer model column (column 7). Column 0 is only display text
        # and may contain decorations such as "12!".
        #
        # Reading the hidden model value is dramatically cheaper than calling
        # tool_table_get_tool_number(), which reaches through LinuxCNC status.
        # Older 2.10+ models that do not expose this column fall back cleanly.
        try:
            if int(model.get_n_columns()) > 7:
                tool_num = int(model.get_value(tree_iter, 7))
                if tool_num > 0:
                    return tool_num
        except:
            pass
        return 0

    def _get_tool_number_for_row_index(self, model, row_index):
        if self._uses_modern_tool_table():
            try:
                tree_iter = model.get_iter(row_index)
                tool_num = self._get_modern_hidden_tool_number_from_iter(model, tree_iter)
                if tool_num > 0:
                    return tool_num
            except:
                pass
            # Compatibility fallback for earlier 2.10+ layouts.
            return int(self.ui.tool_table_get_tool_number(model, row_index))
        try:
            tree_iter = model.iter_nth_child(None, int(row_index))
        except:
            tree_iter = model.get_iter(row_index)
        return int(model.get_value(tree_iter, 0))

    def _get_tool_number_for_iter(self, model, tree_iter):
        if self._uses_modern_tool_table():
            tool_num = self._get_modern_hidden_tool_number_from_iter(model, tree_iter)
            if tool_num > 0:
                return tool_num
            return int(self.ui.tool_table_get_tool_number(model, model.get_path(tree_iter)))
        return int(model.get_value(tree_iter, 0))

    def _get_tool_row_for_iter(self, model, tree_iter):
        try:
            tool_num = self._get_tool_number_for_iter(model, tree_iter)
        except:
            return None
        if tool_num <= 0:
            return None
        return {
            "tool": tool_num,
            "description": self._normalize_text(model.get_value(tree_iter, 1)),
            "diameter": self._normalize_text(model.get_value(tree_iter, 2)),
            "length": self._normalize_text(model.get_value(tree_iter, 3))}

    def _get_tool_row_for_model_row(self, model, model_row, row_index):
        try:
            if self._uses_modern_tool_table():
                try:
                    tool_num = int(model_row[7])
                except:
                    tool_num = 0
                if tool_num <= 0:
                    tool_num = self._get_tool_number_for_row_index(model, row_index)
            else:
                tool_num = int(model_row[0])
        except:
            return None
        if tool_num <= 0:
            return None
        return {
            "tool": tool_num,
            "description": self._normalize_text(model_row[1]),
            "diameter": self._normalize_text(model_row[2]),
            "length": self._normalize_text(model_row[3])}

    def _tool_table_edit_is_active(self):
        # Newer PathPilot sets this while an OEM offset cell is being edited.
        # Legacy versions do not expose it, in which case it simply reads False.
        try:
            return bool(self._uses_modern_tool_table() and getattr(self.ui, "entry_editing", False))
        except:
            return False

    def _get_tool_table_insert_index_before_diameter(self):
        treeview = self.ui.tool_treeview
        columns = treeview.get_columns()
        index = 0
        for column in columns:
            try:
                if column.get_title() == "Diameter":
                    return index
            except:
                pass
            index += 1
        return len(columns)

    def _get_model_row_from_iter(self, model, tree_iter):
        return self._get_tool_row_for_iter(model, tree_iter)

    def _snapshot_pathpilot_metadata(self):
        cache = {}
        model = self.ui.tool_treeview.get_model()
        if model is None:
            return cache
        row_index = 0
        for model_row in model:
            row = self._get_tool_row_for_model_row(model, model_row, row_index)
            row_index += 1
            if row is None:
                continue
            cache[int(row["tool"])] = self._metadata_tuple_from_row(row)
        return cache

    def _refresh_pathpilot_metadata_cache(self):
        try:
            self.pathpilot_metadata_cache = self._snapshot_pathpilot_metadata()
        except Exception as e:
            self._write_status("[Tool Life Column] PathPilot metadata cache refresh failed: {}".format(str(e)))

    def _reconcile_pathpilot_metadata_cache(self, reason="table reconciliation"):
        # Safety net for metadata changes that do not pass through a GTK cell
        # renderer edit signal: OEM touch-off/Move & Set Tool Length, imported
        # tool tables, G10/MDI, other plugins, and structural OEM rebuilds.
        try:
            current_cache = self._snapshot_pathpilot_metadata()
            if not self.pathpilot_event_logging_enabled:
                self.pathpilot_metadata_cache = current_cache
                return False
            field_names = ("description", "diameter", "length")
            changes_found = 0
            for tool_num, current_state in current_cache.items():
                previous_state = self.pathpilot_metadata_cache.get(tool_num)
                if previous_state is None:
                    continue
                if current_state == previous_state:
                    continue
                for index in xrange(0, len(field_names)):
                    old_value = previous_state[index]
                    new_value = current_state[index]
                    if old_value != new_value:
                        self._append_tool_event_log(int(tool_num), field_names[index], old_value, new_value)
                        changes_found += 1
            # Always replace the snapshot after comparison. New/deleted rows are
            # intentionally treated as table structure, while metadata changes
            # on existing T-numbers are the event-log concern.
            self.pathpilot_metadata_cache = current_cache
            if changes_found > 0:
                self._write_status(
                    "[Tool Life Column] %d PathPilot metadata change(s) captured by %s." %
                    (changes_found, reason))
        except Exception as e:
            self._write_status("[Tool Life Column] PathPilot metadata reconciliation failed: %s" % str(e))
        return False

    def _format_event_value(self, value):
        value = self._normalize_text(value)
        if value:
            return value
        return "<blank>"

    def _append_tool_editor_event_log(self, cursor, tool_num, field_name, old_value, new_value):
        # Direct DB logger for edits made inside the Tool Life Manager editor.
        # This bypasses the PathPilot table-change queue because these edits do not fire tool-table model signals.
        old_value = self._normalize_text(old_value)
        new_value = self._normalize_text(new_value)
        if old_value == new_value:
            return
        field_label = {
            "installed": "installed date",
            "replaced": "replacement date",
            "age": "age",
            "usage": "usage",
            "note": "note"}.get(field_name, field_name)
        event_text = "%s edited from '%s' to '%s' in TLM editor" % (
            field_label,
            self._format_event_value(old_value),
            self._format_event_value(new_value))
        logged_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        cursor.execute(
            """INSERT INTO tool_event_log
               (tool, logged_at, event_text, field_name, old_value, new_value)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (int(tool_num), logged_at, event_text, self._normalize_text(field_name), old_value, new_value))

    def _append_named_tool_event(self, cursor, tool_num, event_text, field_name, old_value="", new_value=""):
        # Generic event logger for deliberate TLM actions. Use the caller's
        # transaction so the event and the action commit/rollback together.
        cursor.execute(
            """INSERT INTO tool_event_log
               (tool, logged_at, event_text, field_name, old_value, new_value)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (
                int(tool_num),
                datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                self._normalize_text(event_text),
                self._normalize_text(field_name),
                self._normalize_text(old_value),
                self._normalize_text(new_value)))

    def _append_tool_event_log(self, tool_num, field_name, old_value, new_value):
        old_value = self._normalize_text(old_value)
        new_value = self._normalize_text(new_value)
        if old_value == new_value:
            return
        field_label = {
            "description": "description",
            "diameter": "diameter",
            "length": "length"}.get(field_name, field_name)
        # Coalesce repeated changes to the same field during one OEM rebuild or
        # bulk edit. Keep the first observed old value and the final new value.
        # If the field returns to its starting value, remove the pending event.
        tool_num = int(tool_num)
        field_name = self._normalize_text(field_name)
        existing_index = -1
        for index in xrange(0, len(self.pathpilot_event_log_queue)):
            row = self.pathpilot_event_log_queue[index]
            if int(row[0]) == tool_num and self._normalize_text(row[3]) == field_name:
                existing_index = index
                break
        if existing_index >= 0:
            existing = self.pathpilot_event_log_queue[existing_index]
            first_old = self._normalize_text(existing[4])
            if first_old == new_value:
                del self.pathpilot_event_log_queue[existing_index]
            else:
                event_text = "%s changed from '%s' to '%s'" % (
                    field_label,
                    self._format_event_value(first_old),
                    self._format_event_value(new_value))
                self.pathpilot_event_log_queue[existing_index] = (
                    tool_num,
                    datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                    event_text,
                    field_name,
                    first_old,
                    new_value)
        else:
            event_text = "%s changed from '%s' to '%s'" % (
                field_label,
                self._format_event_value(old_value),
                self._format_event_value(new_value))
            self.pathpilot_event_log_queue.append((
                tool_num,
                datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                event_text,
                field_name,
                old_value,
                new_value))
        # Debounce instead of dropping bursts. Every new change extends the
        # quiet period so an OEM rebuild/import settles before one batch write.
        self.pathpilot_event_log_flush_generation += 1
        generation = int(self.pathpilot_event_log_flush_generation)
        self.pathpilot_event_log_flush_pending = True
        glib.timeout_add(EVENT_LOG_FLUSH_DELAY_MS, self._flush_pathpilot_event_log_queue, generation)

    def _flush_pathpilot_event_log_queue(self, generation=None):
        if generation is not None:
            try:
                if int(generation) != int(self.pathpilot_event_log_flush_generation):
                    return False
            except:
                pass
        rows = self.pathpilot_event_log_queue
        self.pathpilot_event_log_queue = []
        self.pathpilot_event_log_flush_pending = False
        if len(rows) <= 0:
            return False
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.executemany(
                """INSERT INTO tool_event_log
                   (tool, logged_at, event_text, field_name, old_value, new_value)
                   VALUES (?, ?, ?, ?, ?, ?)""",
                rows)
            conn.commit()
        except Exception as e:
            try:
                conn.rollback()
            except:
                pass
            # Restore rows so a later metadata event can retry the batch.
            self.pathpilot_event_log_queue = rows + self.pathpilot_event_log_queue
            self._write_status("[Tool Life Column] Event log flush failed: {}".format(str(e)))
        finally:
            conn.close()
        return False

    def _log_pathpilot_table_change_if_needed(self, model, tree_iter):
        row = self._get_model_row_from_iter(model, tree_iter)
        if row is None:
            return False
        tool_num = int(row["tool"])
        current_state = self._metadata_tuple_from_row(row)
        previous_state = self.pathpilot_metadata_cache.get(tool_num)
        if previous_state is None:
            self.pathpilot_metadata_cache[tool_num] = current_state
            return True
        if current_state == previous_state:
            return False
        if self.pathpilot_event_logging_enabled:
            field_names = ["description", "diameter", "length"]
            for index in xrange(0, len(field_names)):
                old_value = previous_state[index]
                new_value = current_state[index]
                if old_value != new_value:
                    self._append_tool_event_log(tool_num, field_names[index], old_value, new_value)
        self.pathpilot_metadata_cache[tool_num] = current_state
        return True

    def _enable_pathpilot_event_logging_once(self):
        try:
            self._refresh_pathpilot_metadata_cache()
            self.pathpilot_event_logging_enabled = True
            self._write_status("[Tool Life Column] PathPilot event logging enabled.")
        except Exception as e:
            self._write_status("[Tool Life Column] Event logging enable failed: {}".format(str(e)))
        return False

    def _connect_modern_tool_edit_signals(self):
        # PathPilot 2.10+/2.14: listen only for explicit user edits to the three
        # metadata cells TLM cares about. This avoids observing the OEM liststore
        # while PathPilot is rebuilding/highlighting/filtering it.
        column_fields = (
            ("tool_description_column", "description"),
            ("tool_diameter_column", "diameter"),
            ("tool_length_column", "length"),)
        for column_attr, field_name in column_fields:
            try:
                column = getattr(self.ui, column_attr, None)
                if column is None:
                    continue
                renderers = column.get_cell_renderers()
                if not renderers:
                    continue
                # OEM handlers were connected during PathPilot UI construction,
                # before plugins load. Our callback therefore runs later; use idle
                # anyway so the model definitely contains the committed OEM value.
                renderer = renderers[0]
                signal_id = renderer.connect("edited", self._on_modern_tool_cell_edited, field_name)
                self.modern_tool_edit_signal_ids.append((renderer, signal_id))
            except Exception as e:
                self._write_status("[Tool Life Column] Modern %s edit hook failed: %s" % (field_name, str(e)))

    def _on_modern_tool_cell_edited(self, renderer, path, new_text, field_name):
        try:
            glib.idle_add(self._capture_modern_tool_edit_after_oem, str(path), str(field_name))
        except Exception as e:
            self._write_status("[Tool Life Column] Modern edit capture schedule failed: %s" % str(e))

    def _capture_modern_tool_edit_after_oem(self, path, field_name):
        try:
            model = self.ui.tool_treeview.get_model()
            if model is None:
                return False
            tree_iter = model.get_iter(path)
            if tree_iter is None:
                return False
            changed = self._log_pathpilot_table_change_if_needed(model, tree_iter)
            # No queue_draw here. The OEM edit already changed the model and GTK
            # redraws that row. TLM's Manage renderer will naturally see Update?.
            if changed:
                try:
                    tool_num = self._get_tool_number_for_iter(model, tree_iter)
                    self._write_status("[Tool Life Column] T%d %s edit observed." % (int(tool_num), field_name))
                except:
                    pass
        except Exception as e:
            self._write_status("[Tool Life Column] Modern edit capture failed: %s" % str(e))
        return False

    def _connect_tool_model_refresh_signals(self):
        try:
            model = self.ui.tool_treeview.get_model()
            if model is None:
                return
            self.tool_model_signal_ids.append(model.connect("row-changed", self._on_tool_model_row_changed))
            self.tool_model_signal_ids.append(model.connect("row-inserted", self._on_tool_model_structure_changed))
            self.tool_model_signal_ids.append(model.connect("row-deleted", self._on_tool_model_structure_changed))
        except Exception as e:
            self._write_status("[Tool Life Column] Model refresh signal setup failed: {}".format(str(e)))

    def _on_tool_model_row_changed(self, model, path, tree_iter):
        # GTK already redraws a row when its model emits row-changed. Do not
        # queue another full-tree redraw here. On 2.14.x the OEM model can emit
        # many row-changed signals during normal internal refreshes; the old
        # handler turned each one into SQLite reads plus queue_draw(), creating
        # a costly feedback/churn loop.
        try:
            self._log_pathpilot_table_change_if_needed(model, tree_iter)
        except Exception as e:
            self._write_status("[Tool Life Column] PathPilot table event log failed: {}".format(str(e)))

    def _on_tool_model_structure_changed(self, model, path, tree_iter=None):
        # Debounce structural rebuilds. Unlike the old code, do not blindly
        # refresh the cache and erase evidence of imported/bulk metadata changes.
        self.pathpilot_structure_change_generation += 1
        generation = int(self.pathpilot_structure_change_generation)
        glib.timeout_add(EVENT_LOG_RECONCILE_DELAY_MS, self._reconcile_after_tool_model_structure_change, generation)

    def _reconcile_after_tool_model_structure_change(self, generation):
        try:
            if int(generation) != int(self.pathpilot_structure_change_generation):
                return False
        except:
            pass
        if self._tool_table_edit_is_active():
            glib.timeout_add(250, self._reconcile_after_tool_model_structure_change, generation)
            return False
        return self._reconcile_pathpilot_metadata_cache("tool-table rebuild")

    def _tool_table_is_visible(self):
        try:
            return getattr(self.ui, "current_notebook_page_id", "") == "notebook_offsets_fixed"
        except:
            return False

    def _periodic_refresh(self):
        try:
            if self._tool_table_edit_is_active():
                return True
            # The SQLite cache is cheap and independent of GTK.
            self._refresh_state_cache()
            if self.ui and hasattr(self.ui, "tool_treeview"):
                if not self._uses_modern_tool_table():
                    self.ui.tool_treeview.queue_draw()
                # Modern PathPilot intentionally gets no periodic full-tree redraw.
                # Its own model updates redraw changed rows, and the table is rebuilt
                # when the Offsets page is entered.
        except Exception as e:
            self._write_status("[Tool Life Column] Refresh failed: {}".format(str(e)))
        return True

    def _make_all_tool_columns_resizable(self):
        try:
            if self._uses_modern_tool_table():
                # 2.10+ OEM columns are ToolTableColumn instances that cache model
                # positions. Do not restyle/rebuild them; only touch our columns.
                columns = (self.holder_column, self.tool_model_column, self.flutes_column, self.life_column, self.usage_column)
            else:
                # 2.9.x uses ordinary TreeViewColumns and historically allowed all
                # columns to be made resizable by this plugin.
                columns = self.ui.tool_treeview.get_columns()
            for column in columns:
                if column is None:
                    continue
                column.set_resizable(True)
                try:
                    column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
                except:
                    pass
                try:
                    column.set_expand(False)
                except:
                    pass
        except Exception as e:
            self._write_status("[Tool Life Column] Resize setup failed: {}".format(str(e)))

    def _recenter_tool_column_headers_once(self):
        self._center_all_tool_column_headers()
        return False

    def _center_all_tool_column_headers(self):
        try:
            if self._uses_modern_tool_table():
                columns = (self.holder_column, self.tool_model_column, self.flutes_column, self.life_column, self.usage_column)
            else:
                columns = self.ui.tool_treeview.get_columns()
            for column in columns:
                if column is None:
                    continue
                title = column.get_title()
                label = gtk.Label(title)
                label.set_alignment(0.5, 0.5)
                label.show()
                column.set_widget(label)
                try:
                    column.set_alignment(0.5)
                except:
                    pass
        except Exception as e:
            self._write_status("[Tool Life Column] Header center failed: {}".format(str(e)))

    def _refresh_tool_table_choice_models(self):
        # The dropdown models are tiny and shared by every visible row. Rebuild
        # them only when explicitly requested; cell-data callbacks stay cache-only.
        default_holder_type, holder_rows = self._load_holder_catalog()
        self.holder_catalog_default = default_holder_type
        self.holder_catalog_rows = holder_rows
        holder_store = gtk.ListStore(str, str)
        holder_store.append(["", self._holder_display_name("", default_holder_type, holder_rows)])
        for holder_type, display_name, image_path in holder_rows:
            holder_store.append([holder_type, display_name])
        self.holder_combo_store = holder_store
        model_store = gtk.ListStore(str, str)
        for model_type, display_name in TOOL_MODEL_CHOICES:
            model_store.append([model_type, display_name])
        self.tool_model_combo_store = model_store
        flute_store = gtk.ListStore(int, str)
        for count in range(1, 7):
            flute_store.append([count, str(count)])
        self.flutes_combo_store = flute_store
        # Existing CellRendererCombo instances keep their own model property.
        # Refresh those properties when the holder Builder has changed the catalog.
        for column, store in (
                (self.holder_column, self.holder_combo_store),
                (self.tool_model_column, self.tool_model_combo_store),
                (self.flutes_column, self.flutes_combo_store)):
            if column is None:
                continue
            try:
                renderers = column.get_cell_renderers()
                if renderers:
                    renderers[0].set_property("model", store)
            except:
                pass
        try:
            if self.ui is not None and hasattr(self.ui, "tool_treeview"):
                self._autosize_visual_assignment_columns()
                self.ui.tool_treeview.queue_draw()
        except:
            pass

    def _tool_num_from_table_path(self, path):
        try:
            model = self.ui.tool_treeview.get_model()
            if model is None:
                return 0
            tree_iter = model.get_iter(path)
            if tree_iter is None:
                return 0
            row = self._get_tool_row_for_iter(model, tree_iter)
            if row is None or not self._is_real_tool_row(row):
                return 0
            return int(row["tool"])
        except:
            return 0

    def _holder_table_cell_data_func(self, column, renderer, model, tree_iter):
        try:
            row = self._get_tool_row_for_iter(model, tree_iter)
            if row is None or not self._is_real_tool_row(row):
                renderer.set_property("text", "")
                renderer.set_property("editable", False)
                return
            tool_num = int(row["tool"])
            holder_type = self.tool_holder_assignment_cache.get(tool_num, "")
            renderer.set_property(
                "text",
                self._holder_display_name(
                    holder_type,
                    self.holder_catalog_default,
                    self.holder_catalog_rows))
            renderer.set_property("editable", True)
        except:
            renderer.set_property("text", "")
            renderer.set_property("editable", False)

    def _tool_model_table_cell_data_func(self, column, renderer, model, tree_iter):
        try:
            row = self._get_tool_row_for_iter(model, tree_iter)
            if row is None or not self._is_real_tool_row(row):
                renderer.set_property("text", "")
                renderer.set_property("editable", False)
                return
            tool_num = int(row["tool"])
            if tool_num == 99:
                renderer.set_property("text", "Probe")
                renderer.set_property("editable", False)
                return
            model_type = self.tool_model_assignment_cache.get(tool_num, "")
            renderer.set_property("text", self._tool_model_display_name(model_type))
            renderer.set_property("editable", True)
        except:
            renderer.set_property("text", "")
            renderer.set_property("editable", False)

    def _flutes_table_cell_data_func(self, column, renderer, model, tree_iter):
        try:
            row = self._get_tool_row_for_iter(model, tree_iter)
            if row is None or not self._is_real_tool_row(row):
                renderer.set_property("text", "")
                renderer.set_property("editable", False)
                return
            tool_num = int(row["tool"])
            if tool_num == 99:
                renderer.set_property("text", "")
                renderer.set_property("editable", False)
                return
            count = int(self.tool_flute_count_cache.get(tool_num, 4) or 4)
            renderer.set_property("text", str(max(1, min(6, count))))
            renderer.set_property("editable", True)
        except:
            renderer.set_property("text", "")
            renderer.set_property("editable", False)

    def _on_holder_table_combo_changed(self, renderer, path, new_iter):
        try:
            tool_num = self._tool_num_from_table_path(path)
            if tool_num <= 0 or new_iter is None:
                return
            holder_type = self._normalize_text(self.holder_combo_store.get_value(new_iter, 0))
            self._set_tool_holder_assignment(tool_num, holder_type)
            self.tool_holder_assignment_cache[tool_num] = holder_type
            self._autosize_visual_assignment_columns()
            self.ui.tool_treeview.queue_draw()
            self._write_status("[Tool Life Column] T%d holder changed from tool table." % tool_num)
        except Exception as e:
            self._write_status("[Tool Life Column] Holder table edit failed: %s" % str(e))

    def _on_tool_model_table_combo_changed(self, renderer, path, new_iter):
        try:
            tool_num = self._tool_num_from_table_path(path)
            if tool_num <= 0 or tool_num == 99 or new_iter is None:
                return
            model_type = self._normalize_text(self.tool_model_combo_store.get_value(new_iter, 0)).lower()
            self._set_tool_model_assignment(tool_num, model_type)
            model_type = TOOL_MODEL_ALIASES.get(model_type, model_type)
            self.tool_model_assignment_cache[tool_num] = model_type
            if tool_num not in self.tool_flute_count_cache:
                self.tool_flute_count_cache[tool_num] = 4
            self._autosize_visual_assignment_columns()
            self.ui.tool_treeview.queue_draw()
            self._write_status("[Tool Life Column] T%d tool model changed from tool table." % tool_num)
        except Exception as e:
            self._write_status("[Tool Life Column] Tool Model table edit failed: %s" % str(e))

    def _on_flutes_table_combo_changed(self, renderer, path, new_iter):
        try:
            tool_num = self._tool_num_from_table_path(path)
            if tool_num <= 0 or tool_num == 99 or new_iter is None:
                return
            count = int(self.flutes_combo_store.get_value(new_iter, 0))
            self._set_tool_flute_count(tool_num, count)
            self.tool_flute_count_cache[tool_num] = max(1, min(6, count))
            if tool_num not in self.tool_model_assignment_cache:
                self.tool_model_assignment_cache[tool_num] = ""
            self._autosize_visual_assignment_columns()
            self.ui.tool_treeview.queue_draw()
            self._write_status("[Tool Life Column] T%d flute count changed from tool table." % tool_num)
        except Exception as e:
            self._write_status("[Tool Life Column] Flutes table edit failed: %s" % str(e))

    def _visual_column_text_width(self, text, font_desc="Roboto Condensed 9"):
        try:
            layout = self.ui.tool_treeview.create_pango_layout(str(text or ""))
            try:
                layout.set_font_description(pango.FontDescription(font_desc))
            except:
                pass
            width, height = layout.get_pixel_size()
            return int(width or 0)
        except:
            return max(0, len(str(text or "")) * 7)

    def _visual_assignment_display_texts(self, kind):
        # Return the values that are actually represented by rows in the live
        # PathPilot TreeView model.  Do not use the full combo/dropdown catalog:
        # a long available choice should not permanently widen a column when no
        # tool is currently displaying that value.
        values = []
        seen = set()
        try:
            if self.ui is None or not hasattr(self.ui, "tool_treeview"):
                return values
            model = self.ui.tool_treeview.get_model()
            if model is None:
                return values
            tree_iter = model.get_iter_first()
            while tree_iter is not None:
                try:
                    row = self._get_tool_row_for_iter(model, tree_iter)
                    if row is not None and self._is_real_tool_row(row):
                        tool_num = int(row["tool"])
                        if kind == "holder":
                            holder_type = self.tool_holder_assignment_cache.get(tool_num, "")
                            value = self._holder_display_name(
                                holder_type,
                                self.holder_catalog_default,
                                self.holder_catalog_rows)
                        elif kind == "cutter":
                            if tool_num == 99:
                                value = "Probe"
                            else:
                                value = self._tool_model_display_name(
                                    self.tool_model_assignment_cache.get(tool_num, ""))
                        elif kind == "flutes":
                            if tool_num == 99:
                                value = ""
                            else:
                                count = int(self.tool_flute_count_cache.get(tool_num, 4) or 4)
                                value = str(max(1, min(6, count)))
                        else:
                            value = ""
                        value = str(value or "")
                        if value and value not in seen:
                            seen.add(value)
                            values.append(value)
                except:
                    pass
                tree_iter = model.iter_next(tree_iter)
        except:
            pass
        return values

    def _autosize_visual_assignment_columns(self):
        # Standard table behavior for these TLM columns: fit the header and the
        # widest value CURRENTLY DISPLAYED by the live table.  Combo choices may
        # be wider than the column; GTK's popup is allowed to show those choices
        # without reserving that width all the time.
        specs = (
            (self.holder_column, "Holder", "holder", 26),
            (self.tool_model_column, "Cutter", "cutter", 26),
            (self.flutes_column, "Flutes", "flutes", 16),)
        for column, title, kind, chrome in specs:
            if column is None:
                continue
            texts = [title]
            texts.extend(self._visual_assignment_display_texts(kind))
            widest = 0
            for value in texts:
                widest = max(widest, self._visual_column_text_width(value))
            width = max(34, int(widest + chrome))
            try:
                column.set_min_width(1)
            except:
                pass
            try:
                column.set_max_width(-1)
            except:
                pass
            try:
                column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
                column.set_fixed_width(width)
            except:
                pass
            try:
                column.set_resizable(True)
            except:
                pass
        return False

    def _make_visual_combo_column(self, title, store, data_func, changed_handler, min_width=None):
        renderer = gtk.CellRendererCombo()
        renderer.set_property("editable", True)
        renderer.set_property("model", store)
        renderer.set_property("text-column", 1)
        renderer.set_property("has-entry", False)
        renderer.set_property("xalign", 0.0 if title != "Flutes" else 0.5)
        renderer.set_property("ypad", 4)
        try:
            renderer.set_property("font", "Roboto Condensed 9")
        except:
            pass
        renderer.connect("changed", changed_handler)
        column = gtk.TreeViewColumn(title)
        column.pack_start(renderer, True)
        column.set_cell_data_func(renderer, data_func)
        # Width is computed from the values currently displayed in the live
        # table by _autosize_visual_assignment_columns().  Keep normal GTK
        # resizing available as a fallback.
        column.set_resizable(True)
        try:
            column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
        except:
            pass
        column.set_clickable(False)
        return column

    def _position_visual_columns(self):
        # Visible wide-table order:
        # Tool | Description | Diameter | Length | Manage | Usage | Holder | Cutter | Flutes
        try:
            treeview = self.ui.tool_treeview
            length_column = None
            for column in treeview.get_columns():
                try:
                    if column.get_title() == "Length":
                        length_column = column
                        break
                except:
                    pass
            previous = length_column
            for column in (self.life_column, self.usage_column, self.holder_column, self.tool_model_column, self.flutes_column):
                if column is None:
                    continue
                try:
                    treeview.move_column_after(column, previous)
                except:
                    pass
                previous = column
        except Exception as e:
            self._write_status("[Tool Life Column] Column positioning failed: %s" % str(e))

    def _install_life_column(self):
        if self.installed:
            return
        treeview = self.ui.tool_treeview
        for column in treeview.get_columns():
            try:
                title = column.get_title()
                if title == "Manage":
                    self.life_column = column
                elif title == "Usage":
                    self.usage_column = column
                elif title == "Holder":
                    self.holder_column = column
                elif title in ("Tool Model", "Cutter"):
                    self.tool_model_column = column
                elif title == "Flutes":
                    self.flutes_column = column
            except:
                pass
        self._refresh_tool_table_choice_models()
        if self.holder_column is None:
            self.holder_column = self._make_visual_combo_column(
                "Holder",
                self.holder_combo_store,
                self._holder_table_cell_data_func,
                self._on_holder_table_combo_changed,
                135)
            treeview.append_column(self.holder_column)
        if self.tool_model_column is None:
            self.tool_model_column = self._make_visual_combo_column(
                "Cutter",
                self.tool_model_combo_store,
                self._tool_model_table_cell_data_func,
                self._on_tool_model_table_combo_changed,
                150)
            treeview.append_column(self.tool_model_column)
        if self.tool_model_column is not None:
            try:
                self.tool_model_column.set_title("Cutter")
            except:
                pass
        if self.flutes_column is None:
            self.flutes_column = self._make_visual_combo_column(
                "Flutes",
                self.flutes_combo_store,
                self._flutes_table_cell_data_func,
                self._on_flutes_table_combo_changed,
                52)
            treeview.append_column(self.flutes_column)
        if self.life_column is None:
            renderer = gtk.CellRendererText()
            renderer.set_property("xalign", 0.5)
            renderer.set_property("ypad", 4)
            renderer.set_property("font", "Roboto Condensed 10")
            self.life_column = gtk.TreeViewColumn("Manage")
            self.life_column.pack_start(renderer, True)
            self.life_column.set_cell_data_func(renderer, self._life_cell_data_func)
            self.life_column.set_resizable(True)
            self.life_column.set_min_width(60)
            try:
                self.life_column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
            except:
                pass
            self.life_column.set_clickable(False)
            if self._uses_modern_tool_table():
                treeview.insert_column(self.life_column, self._get_tool_table_insert_index_before_diameter())
            else:
                treeview.append_column(self.life_column)
        if self.usage_column is None:
            renderer = gtk.CellRendererText()
            renderer.set_property("xalign", 0.5)
            renderer.set_property("ypad", 4)
            renderer.set_property("font", "Roboto Condensed 10")
            self.usage_column = gtk.TreeViewColumn("Usage")
            self.usage_column.pack_start(renderer, True)
            self.usage_column.set_cell_data_func(renderer, self._usage_cell_data_func)
            self.usage_column.set_resizable(True)
            self.usage_column.set_min_width(80)
            try:
                self.usage_column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
            except:
                pass
            self.usage_column.set_clickable(False)
            if self._uses_modern_tool_table():
                treeview.insert_column(self.usage_column, self._get_tool_table_insert_index_before_diameter())
            else:
                treeview.append_column(self.usage_column)
        self._position_visual_columns()
        self._make_all_tool_columns_resizable()
        self._autosize_visual_assignment_columns()
        self._ensure_tool_table_scrollbars()
        self._install_tool_table_sorting()
        self._center_all_tool_column_headers()
        glib.idle_add(self._recenter_tool_column_headers_once)
        glib.timeout_add(500, self._recenter_tool_column_headers_once)
        treeview.connect("button-release-event", self._on_tool_table_button_release)
        treeview.connect("button-press-event", self._on_tool_table_button_press)
        self.installed = True
        treeview.show_all()
        self._write_status("[Tool Life Column] Manage, Usage, Holder, Cutter, and Flutes columns installed.")

    def _ensure_tool_table_scrollbars(self):
        try:
            scroller = getattr(self.ui, "scrolled_window_tool_table", None)
            if scroller is not None:
                scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        except Exception as e:
            self._write_status("[Tool Life Column] Tool table scrollbar setup failed: %s" % str(e))

    def _tool_table_sort_value(self, key, model, tree_iter):
        row = self._get_tool_row_for_iter(model, tree_iter)
        if row is None:
            return None
        try:
            tool_num = int(row["tool"])
        except:
            tool_num = 0
        if key == "tool":
            return tool_num
        if key == "description":
            return self._normalize_text(row["description"]).lower()
        if key == "diameter":
            try:
                return float(self._normalize_text(row["diameter"]) or 0.0)
            except:
                return 0.0
        if key == "length":
            try:
                return float(self._normalize_text(row["length"]) or 0.0)
            except:
                return 0.0
        if key == "manage":
            try:
                return self._get_life_status_for_row(tool_num, row).lower()
            except:
                return ""
        if key == "usage":
            return int(self.tool_usage_seconds_cache.get(tool_num, 0) or 0)
        if key == "holder":
            holder_type = self.tool_holder_assignment_cache.get(tool_num, "")
            try:
                return self._holder_display_name(
                    holder_type,
                    self.holder_catalog_default,
                    self.holder_catalog_rows).lower()
            except:
                return self._normalize_text(holder_type).lower()
        if key == "cutter":
            if tool_num == 99:
                return "probe"
            return self._tool_model_display_name(self.tool_model_assignment_cache.get(tool_num, "")).lower()
        if key == "flutes":
            if tool_num == 99:
                return 0
            return int(self.tool_flute_count_cache.get(tool_num, 4) or 4)
        return ""

    def _tool_table_sort_compare(self, model, iter_a, iter_b, key):
        try:
            a = self._tool_table_sort_value(key, model, iter_a)
            b = self._tool_table_sort_value(key, model, iter_b)
            if a is None and b is None:
                return 0
            if a is None:
                return 1
            if b is None:
                return -1
            if a < b:
                return -1
            if a > b:
                return 1
            # Stable-looking tie breaker by actual T number.
            try:
                ta = int(self._get_tool_number_for_iter(model, iter_a))
                tb = int(self._get_tool_number_for_iter(model, iter_b))
                if ta < tb:
                    return -1
                if ta > tb:
                    return 1
            except:
                pass
            return 0
        except:
            return 0

    def _install_tool_table_sorting(self):
        if self._tool_table_sort_installed:
            return True
        try:
            treeview = self.ui.tool_treeview
            model = treeview.get_model()
            if model is None or not hasattr(model, "set_sort_func") or not hasattr(model, "set_sort_column_id"):
                self._write_status("[Tool Life Column] Tool table model does not support safe GTK sorting; headers left unsorted.")
                return False
            title_keys = (
                ("Tool", "tool"),
                ("Description", "description"),
                ("Diameter", "diameter"),
                ("Length", "length"),
                ("Manage", "manage"),
                ("Usage", "usage"),
                ("Holder", "holder"),
                ("Cutter", "cutter"),
                ("Flutes", "flutes"),)
            sort_base = 100
            installed = 0
            for offset, pair in enumerate(title_keys):
                title, key = pair
                column = None
                for candidate in treeview.get_columns():
                    try:
                        if candidate.get_title() == title:
                            column = candidate
                            break
                    except:
                        pass
                if column is None:
                    continue
                sort_id = sort_base + offset
                try:
                    model.set_sort_func(sort_id, self._tool_table_sort_compare, key)
                    column.set_sort_column_id(sort_id)
                    column.set_clickable(True)
                    self._tool_table_sort_ids[key] = sort_id
                    installed += 1
                except Exception as e:
                    self._write_status("[Tool Life Column] Sort setup failed for %s: %s" % (title, str(e)))
            self._tool_table_sort_installed = installed > 0
            if self._tool_table_sort_installed:
                self._write_status("[Tool Life Column] Sortable tool-table headers enabled.")
            return self._tool_table_sort_installed
        except Exception as e:
            self._write_status("[Tool Life Column] Tool table sorting setup failed: %s" % str(e))
            return False

    def _wide_tool_table_get_builder_object(self, name):
        try:
            if self.ui is not None and hasattr(self.ui, "builder") and self.ui.builder is not None:
                return self.ui.builder.get_object(name)
        except:
            pass
        return None

    def _wide_tool_table_fixed_xy(self, fixed, child):
        try:
            return (int(fixed.child_get_property(child, "x")), int(fixed.child_get_property(child, "y")))
        except:
            return (0, 0)

    def _wide_tool_table_capture_widget(self, key, fixed, widget):
        if widget is None:
            return
        try:
            x, y = self._wide_tool_table_fixed_xy(fixed, widget)
            width, height = widget.get_size_request()
            self._wide_tool_table_geometry[key] = (widget, x, y, int(width), int(height))
        except:
            pass

    def _wide_tool_table_restore_widget(self, key, fixed):
        row = self._wide_tool_table_geometry.get(key)
        if not row:
            return
        widget, x, y, width, height = row
        try:
            fixed.move(widget, x, y)
        except:
            pass
        try:
            widget.set_size_request(width, height)
        except:
            pass

    def _wide_tool_table_copy_tab_style(self, source_label, target_label):
        if source_label is None or target_label is None:
            return False
        copied = False
        try:
            attributes = source_label.get_layout().get_attributes()
            if attributes:
                try:
                    attributes = attributes.copy()
                except:
                    pass
                target_label.set_attributes(attributes)
                copied = True
        except:
            pass
        try:
            target_label.set_style(source_label.get_style().copy())
            copied = True
        except:
            pass
        try:
            xalign, yalign = source_label.get_alignment()
            target_label.set_alignment(xalign, yalign)
        except:
            pass
        try:
            xpad, ypad = source_label.get_padding()
            target_label.set_padding(xpad, ypad)
        except:
            pass
        return copied

    def _wide_tool_table_update_label(self):
        if self._wide_tool_table_label is None:
            return
        text = "Normal" if self._wide_tool_table_enabled else "Wide"
        try:
            self._wide_tool_table_label.set_text(text)
        except:
            try:
                self._wide_tool_table_label.set_markup(
                    '<span weight="regular" font_desc="Roboto Condensed 10" foreground="black">%s</span>' % text)
            except:
                pass

    def _wide_tool_table_capture_layout(self):
        fixed = self._wide_tool_table_get_builder_object("tool_offsets_fixed")
        if fixed is None:
            return False
        table = getattr(self.ui, "scrolled_window_tool_table", None)
        search = self._wide_tool_table_get_builder_object("tool_search_entry")
        filter_label = self._wide_tool_table_get_builder_object("filter_tool_table_text")
        filter_combo = self._wide_tool_table_get_builder_object("filter_tool_table_combobox")
        if table is None:
            return False
        self._wide_tool_table_geometry = {}
        self._wide_tool_table_capture_widget("table", fixed, table)
        self._wide_tool_table_capture_widget("search", fixed, search)
        self._wide_tool_table_capture_widget("filter_label", fixed, filter_label)
        self._wide_tool_table_capture_widget("filter_combo", fixed, filter_combo)
        keep = set([w for w in (table, search, filter_label, filter_combo) if w is not None])
        background = self._wide_tool_table_get_builder_object("tool_tab_background")
        if background is not None:
            keep.add(background)
        self._wide_tool_table_child_visibility = {}
        try:
            for child in fixed.get_children():
                try:
                    self._wide_tool_table_child_visibility[child] = bool(child.get_property("visible"))
                except:
                    self._wide_tool_table_child_visibility[child] = True
        except:
            pass
        self._wide_tool_table_keep_widgets = keep
        return True

    def _set_wide_tool_table_mode(self, enabled):
        enabled = bool(enabled)
        if enabled == bool(self._wide_tool_table_enabled):
            self._wide_tool_table_update_label()
            return False
        fixed = self._wide_tool_table_get_builder_object("tool_offsets_fixed")
        table = getattr(self.ui, "scrolled_window_tool_table", None)
        if fixed is None or table is None:
            return False
        self._ensure_tool_table_scrollbars()
        if enabled:
            if not self._wide_tool_table_geometry:
                if not self._wide_tool_table_capture_layout():
                    return False
            keep = getattr(self, "_wide_tool_table_keep_widgets", set())
            try:
                for child in fixed.get_children():
                    if child in keep:
                        child.show()
                    else:
                        child.hide()
            except:
                pass
            try:
                fixed.move(table, 8, 35)
                table.set_size_request(962, 320)
            except:
                pass
            search = self._wide_tool_table_get_builder_object("tool_search_entry")
            filter_label = self._wide_tool_table_get_builder_object("filter_tool_table_text")
            filter_combo = self._wide_tool_table_get_builder_object("filter_tool_table_combobox")
            try:
                if search is not None:
                    fixed.move(search, 8, 5)
                    search.set_size_request(500, 25)
                if filter_label is not None:
                    fixed.move(filter_label, 530, 6)
                    filter_label.set_size_request(210, 25)
                if filter_combo is not None:
                    fixed.move(filter_combo, 750, 4)
                    filter_combo.set_size_request(220, 27)
            except:
                pass
            self._wide_tool_table_enabled = True
        else:
            for key in ("table", "search", "filter_label", "filter_combo"):
                self._wide_tool_table_restore_widget(key, fixed)
            try:
                for child, was_visible in self._wide_tool_table_child_visibility.items():
                    if was_visible:
                        child.show()
                    else:
                        child.hide()
            except:
                pass
            self._wide_tool_table_enabled = False
        self._wide_tool_table_update_label()
        try:
            self.ui.tool_treeview.queue_draw()
        except:
            pass
        return False

    def _toggle_wide_tool_table_mode(self):
        # This method is called only by the Wide/Normal pseudo-tab, so a move
        # to Normal here is an explicit user request. It lasts only for the
        # current visit to the main Offsets page.
        if self._wide_tool_table_enabled:
            self._wide_tool_table_normal_requested = True
            return self._set_wide_tool_table_mode(False)
        self._wide_tool_table_normal_requested = False
        return self._set_wide_tool_table_mode(True)

    def _goto_tool_set_all_tools_filter(self):
        # Exact Go To should not be defeated by the OEM fuzzy description search.
        try:
            search = self._wide_tool_table_get_builder_object("tool_search_entry")
            if search is not None:
                search.set_text("")
        except:
            pass
        try:
            combo = self._wide_tool_table_get_builder_object("filter_tool_table_combobox")
            if combo is not None:
                model = combo.get_model()
                if model is not None:
                    tree_iter = model.get_iter_first()
                    row_index = 0
                    selected = -1
                    while tree_iter is not None:
                        try:
                            values = []
                            for col in range(model.get_n_columns()):
                                values.append(self._normalize_text(model.get_value(tree_iter, col)))
                            if any(value.strip().lower() == "all tools" for value in values):
                                selected = row_index
                                break
                        except:
                            pass
                        row_index += 1
                        tree_iter = model.iter_next(tree_iter)
                    if selected >= 0:
                        combo.set_active(selected)
        except:
            pass

    def _goto_tool_exact_retry(self, tool_num, attempts_left=8):
        try:
            tool_num = int(tool_num)
            treeview = self.ui.tool_treeview
            model = treeview.get_model()
            if model is None:
                return False
            tree_iter = model.get_iter_first()
            while tree_iter is not None:
                try:
                    if int(self._get_tool_number_for_iter(model, tree_iter)) == tool_num:
                        path = model.get_path(tree_iter)
                        try:
                            treeview.get_selection().select_path(path)
                        except:
                            pass
                        try:
                            treeview.set_cursor(path)
                        except:
                            pass
                        try:
                            treeview.scroll_to_cell(path, None, True, 0.35, 0.0)
                        except:
                            pass
                        try:
                            treeview.grab_focus()
                        except:
                            pass
                        self._write_status("[Tool Life Column] Go To Tool: T%d" % tool_num)
                        return False
                except:
                    pass
                tree_iter = model.iter_next(tree_iter)
        except Exception as e:
            self._write_status("[Tool Life Column] Go To Tool failed: %s" % str(e))
            return False
        if int(attempts_left) > 0:
            glib.timeout_add(100, self._goto_tool_exact_retry, int(tool_num), int(attempts_left) - 1)
        else:
            self._show_reminder_info_dialog("Tool T%d was not found in the current PathPilot tool table." % int(tool_num))
        return False

    def _show_go_to_tool_dialog(self):
        dialog = None
        try:
            dialog = gtk.Dialog(
                "Go To Tool Number",
                None,
                gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
                (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OK, gtk.RESPONSE_OK))
            dialog.set_default_size(360, 120)
            box = gtk.VBox(False, 8)
            box.set_border_width(12)
            dialog.vbox.pack_start(box, True, True, 0)
            label = gtk.Label("Enter the exact PathPilot tool number:")
            label.set_alignment(0.0, 0.5)
            box.pack_start(label, False, False, 0)
            entry = gtk.Entry()
            entry.set_max_length(6)
            try:
                entry.set_width_chars(12)
            except:
                pass
            box.pack_start(entry, False, False, 0)
            entry.connect("activate", lambda widget: dialog.response(gtk.RESPONSE_OK))
            dialog.show_all()
            entry.grab_focus()
            response = dialog.run()
            raw = self._normalize_text(entry.get_text()).strip()
            dialog.destroy()
            dialog = None
            if response != gtk.RESPONSE_OK:
                return False
            try:
                tool_num = int(raw)
            except:
                self._show_reminder_info_dialog("Enter a whole-number tool number, for example 665.")
                return False
            if tool_num <= 0:
                self._show_reminder_info_dialog("Tool number must be greater than zero.")
                return False
            self._goto_tool_set_all_tools_filter()
            glib.timeout_add(100, self._goto_tool_exact_retry, tool_num, 8)
        except Exception as e:
            try:
                if dialog is not None:
                    dialog.destroy()
            except:
                pass
            self._write_status("[Tool Life Column] Go To Tool dialog failed: %s" % str(e))
        return False

    def _find_outer_offsets_notebook_for_wide(self, offsets_notebook):
        # Walk upward from the inner Tool/Work notebook until we find the
        # enclosing main PathPilot notebook. Return both that notebook and the
        # direct page child that contains Offsets. This avoids hard-coding the
        # OEM page index and is resilient to nearby PathPilot versions.
        try:
            child = offsets_notebook
            parent = child.get_parent()
            while parent is not None:
                if isinstance(parent, gtk.Notebook) and parent is not offsets_notebook:
                    return parent, child
                child = parent
                parent = child.get_parent()
        except:
            pass
        return None, None

    def _apply_default_wide_on_offsets_entry(self):
        try:
            if not WIDE_TABLE_DEFAULT:
                return False
            notebook = self._wide_tool_table_notebook
            if notebook is None:
                return False
            # Every entry into the main Offsets page is a new visit. Normal is
            # intentionally temporary and is forgotten as soon as Offsets is
            # entered again (including the first visit after startup).
            self._wide_tool_table_normal_requested = False
            # The Tool page is the real page 0; Wide/Go To are pseudo-pages.
            if notebook.get_current_page() != 0:
                notebook.set_current_page(0)
            self._set_wide_tool_table_mode(True)
        except Exception as e:
            self._write_status("[Tool Life Column] Default Wide-on-Offsets failed: %s" % str(e))
        return False

    def _on_main_notebook_switch_page_for_wide(self, notebook, page_ptr, page_num):
        try:
            offsets_page = self._wide_tool_table_outer_page
            if offsets_page is not None and page_ptr is offsets_page:
                # Let GTK/OEM table updates settle, then reconcile metadata that
                # may have changed without a direct cell-edit signal.
                glib.timeout_add(
                    EVENT_LOG_RECONCILE_DELAY_MS,
                    self._reconcile_pathpilot_metadata_cache,
                    "Offsets entry")
                if WIDE_TABLE_DEFAULT:
                    # Let GTK finish switching the outer page before moving the
                    # inner Tool/Work notebook and resizing its widgets.
                    glib.idle_add(self._apply_default_wide_on_offsets_entry)
        except Exception as e:
            self._write_status("[Tool Life Column] Offsets-entry handling failed: %s" % str(e))

    def _on_offsets_notebook_switch_page_for_wide(self, notebook, page_ptr_unused, page_num):
        try:
            if int(page_num) == int(self._wide_tool_table_page_num):
                self._toggle_wide_tool_table_mode()
                glib.idle_add(notebook.set_current_page, 0)
            elif int(page_num) == int(self._goto_tool_page_num):
                # Go To is also a pseudo-tab/button. Return to Tool immediately,
                # then ask for the exact T-number without invoking fuzzy search.
                glib.idle_add(notebook.set_current_page, 0)
                glib.idle_add(self._show_go_to_tool_dialog)
            else:
                self._wide_tool_table_real_page_num = int(page_num)
        except Exception as e:
            self._write_status("[Tool Life Column] Tool-table pseudo-tab action failed: %s" % str(e))

    def _install_wide_tool_table_toggle(self):
        if self._wide_tool_table_installed:
            return True
        try:
            notebook = self._wide_tool_table_get_builder_object("offsets_notebook")
            fixed = self._wide_tool_table_get_builder_object("tool_offsets_fixed")
            table = getattr(self.ui, "scrolled_window_tool_table", None)
            if notebook is None or fixed is None or table is None:
                self._write_status("[Tool Life Column] Wide table toggle unavailable: Tool notebook widgets were not found.")
                return False
            # Do not duplicate the pseudo-page if this plugin is initialized twice.
            existing_page = None
            existing_num = -1
            try:
                for page_num in range(notebook.get_n_pages()):
                    page = notebook.get_nth_page(page_num)
                    if page is not None and page.get_name() == "tt_tlm_wide_tool_table_page":
                        existing_page = page
                        existing_num = page_num
                        break
            except:
                pass
            if existing_page is None:
                page = gtk.Fixed()
                page.set_name("tt_tlm_wide_tool_table_page")
                label = gtk.Label("Wide")
                label.set_name("tt_tlm_wide_tool_table_tab")
                try:
                    tool_page = notebook.get_nth_page(0)
                    tool_label = notebook.get_tab_label(tool_page)
                    if not self._wide_tool_table_copy_tab_style(tool_label, label):
                        label.set_markup('<span weight="regular" font_desc="Roboto Condensed 10" foreground="black">Wide</span>')
                except:
                    pass
                label.show()
                existing_num = notebook.append_page(page, label)
                page.show()
                existing_page = page
                self._wide_tool_table_label = label
            else:
                self._wide_tool_table_label = notebook.get_tab_label(existing_page)
            # Add a second pseudo-tab/button for exact T-number navigation.
            goto_page = None
            goto_num = -1
            try:
                for page_num in range(notebook.get_n_pages()):
                    candidate = notebook.get_nth_page(page_num)
                    if candidate is not None and candidate.get_name() == "tt_tlm_goto_tool_page":
                        goto_page = candidate
                        goto_num = page_num
                        break
            except:
                pass
            if goto_page is None:
                goto_page = gtk.Fixed()
                goto_page.set_name("tt_tlm_goto_tool_page")
                goto_label = gtk.Label("Go To")
                goto_label.set_name("tt_tlm_goto_tool_tab")
                try:
                    tool_page = notebook.get_nth_page(0)
                    tool_label = notebook.get_tab_label(tool_page)
                    if not self._wide_tool_table_copy_tab_style(tool_label, goto_label):
                        goto_label.set_markup('<span weight="regular" font_desc="Roboto Condensed 10" foreground="black">Go To</span>')
                except:
                    pass
                goto_label.show()
                goto_num = notebook.append_page(goto_page, goto_label)
                goto_page.show()
                self._goto_tool_label = goto_label
            else:
                self._goto_tool_label = notebook.get_tab_label(goto_page)
            self._goto_tool_page = goto_page
            self._goto_tool_page_num = goto_num
            self._wide_tool_table_notebook = notebook
            self._wide_tool_table_page = existing_page
            self._wide_tool_table_page_num = existing_num
            self._wide_tool_table_real_page_num = notebook.get_current_page()
            self._wide_tool_table_capture_layout()
            if self._wide_tool_table_switch_handler_id is None:
                self._wide_tool_table_switch_handler_id = notebook.connect(
                    "switch-page", self._on_offsets_notebook_switch_page_for_wide)
            # Main Offsets-page entry drives two event-based behaviors:
            # metadata reconciliation on every PathPilot version, and optional
            # default-Wide display handling.
            outer_notebook, outer_page = self._find_outer_offsets_notebook_for_wide(notebook)
            self._wide_tool_table_outer_notebook = outer_notebook
            self._wide_tool_table_outer_page = outer_page
            if (outer_notebook is not None and
                    self._wide_tool_table_outer_switch_handler_id is None):
                self._wide_tool_table_outer_switch_handler_id = outer_notebook.connect(
                    "switch-page", self._on_main_notebook_switch_page_for_wide)
            self._wide_tool_table_installed = True
            self._wide_tool_table_update_label()
            # Harden startup/default behavior. The outer notebook may already
            # be sitting on Offsets when this plugin finishes installing, in
            # which case no switch-page signal will arrive. Apply Wide once
            # immediately so startup/reboot state is deterministic.
            if WIDE_TABLE_DEFAULT:
                glib.idle_add(self._apply_default_wide_on_offsets_entry)
            glib.timeout_add(EVENT_LOG_RECONCILE_DELAY_MS, self._reconcile_pathpilot_metadata_cache, "startup settle")
            self._write_status("[Tool Life Column] Wide/Normal and Go To tool-table pseudo-tabs installed.")
            return True
        except Exception as e:
            self._write_status("[Tool Life Column] Wide table install failed: %s" % str(e))
            return False

    def _usage_cell_data_func(self, column, renderer, model, tree_iter):
        try:
            row = self._get_tool_row_for_iter(model, tree_iter)
            renderer.set_property("foreground-set", False)
            renderer.set_property("background-set", False)
            if row is None:
                renderer.set_property("text", "")
                return
            tool_num = int(row["tool"])
            if not self._is_real_tool_row(row):
                renderer.set_property("text", "")
                return
            usage_seconds = int(self.tool_usage_seconds_cache.get(tool_num, 0) or 0)
            if usage_seconds <= 0 and tool_num not in self.tool_usage_cache:
                renderer.set_property("text", "")
                return
            renderer.set_property("text", self._format_seconds(usage_seconds))
        except:
            renderer.set_property("text", "")
            renderer.set_property("foreground-set", False)
            renderer.set_property("background-set", False)

    def _life_cell_data_func(self, column, renderer, model, tree_iter):
        try:
            row = self._get_tool_row_for_iter(model, tree_iter)
            renderer.set_property("foreground-set", False)
            renderer.set_property("background-set", False)
            if row is None:
                renderer.set_property("text", "")
                return
            tool_num = int(row["tool"])
            if not self._is_real_tool_row(row):
                renderer.set_property("text", "")
                return
            status = self._get_life_status_for_row(tool_num, row)
            if tool_num in self.triggered_reminder_tools_cache:
                renderer.set_property("text", status + " !")
                renderer.set_property("foreground", "white")
                renderer.set_property("background", "red")
            elif tool_num in self.reminder_tools_cache:
                renderer.set_property("text", status)
                renderer.set_property("foreground-set", False)
                renderer.set_property("background", "#ffd6d6")
            elif status == "Synced":
                renderer.set_property("text", status)
                renderer.set_property("foreground-set", False)
                renderer.set_property("background-set", False)
            else:
                renderer.set_property("text", status)
                renderer.set_property("foreground-set", False)
                renderer.set_property("background", "#fff7bf")
        except:
            renderer.set_property("text", "")
            renderer.set_property("foreground-set", False)
            renderer.set_property("background-set", False)

    def _get_life_status_for_row(self, tool_num, row):
        saved_state = self.tool_life_state_cache.get(tool_num)
        if saved_state is not None and not self._saved_metadata_state_matches_row(saved_state, row):
            return "Update?"
        if tool_num not in self.tool_usage_cache:
            return "Install?"
        if saved_state is None:
            return "Update?"
        return "Synced"

    def _get_life_status_for_iter(self, model, tree_iter):
        row = self._get_tool_row_for_iter(model, tree_iter)
        if row is None:
            return ""
        tool_num = int(row["tool"])
        saved_state = self.tool_life_state_cache.get(tool_num)
        if saved_state is not None and not self._saved_metadata_state_matches_row(saved_state, row):
            return "Update?"
        if tool_num not in self.tool_usage_cache:
            return "Install?"
        if saved_state is None:
            return "Update?"
        return "Synced"

    def _get_current_row_state_from_iter(self, model, tree_iter):
        return (
            self._normalize_text(model.get_value(tree_iter, 1)),
            self._normalize_text(model.get_value(tree_iter, 2)),
            self._normalize_text(model.get_value(tree_iter, 3)))

    def _tool_row_has_nonzero_value(self, value):
        text = self._normalize_text(value)
        if not text:
            return False
        try:
            return abs(float(text)) > 0.0000001
        except:
            return False

    def _is_real_tool_row(self, row):
        description = self._normalize_text(row["description"])
        diameter_is_nonzero = self._tool_row_has_nonzero_value(row["diameter"])
        length_is_nonzero = self._tool_row_has_nonzero_value(row["length"])
        if description:
            return True
        if diameter_is_nonzero and length_is_nonzero:
            return True
        return False

    def _is_commissionable_tool_row(self, row):
        diameter_is_nonzero = self._tool_row_has_nonzero_value(row["diameter"])
        length_is_nonzero = self._tool_row_has_nonzero_value(row["length"])
        if diameter_is_nonzero and length_is_nonzero:
            return True
        return False

    def _get_current_tool_row(self, tool_num):
        model = self.ui.tool_treeview.get_model()
        row_index = 0
        for model_row in model:
            row = self._get_tool_row_for_model_row(model, model_row, row_index)
            row_index += 1
            if row is not None and int(row["tool"]) == int(tool_num):
                return row
        return {"tool": int(tool_num), "description": "", "diameter": "", "length": ""}

    def _reserved_probe_description(self):
        try:
            return self._normalize_text(constants.MILL_PROBE_TOOL_DESCRIPTION)
        except:
            return "Mill Probe (diameter is effective, not actual)"

    def _is_reserved_probe_tool(self, tool_num):
        try:
            reserved_tool_num = int(constants.MILL_PROBE_TOOL_NUM)
        except:
            reserved_tool_num = 99
        try:
            return int(tool_num) == reserved_tool_num
        except:
            return False

    def _normalize_text(self, value):
        if value is None:
            return ""
        try:
            return str(value).strip()
        except:
            try:
                return unicode(value).encode("utf-8").strip()
            except:
                return ""

    def _on_tool_table_button_press(self, treeview, event):
        try:
            if event.button != 3:
                return False
            result = treeview.get_path_at_pos(int(event.x), int(event.y))
            if result is None:
                return False
            path, column, cell_x, cell_y = result
            model = treeview.get_model()
            tree_iter = model.get_iter(path)
            row = self._get_tool_row_for_iter(model, tree_iter)
            if row is None:
                return False
            tool_num = int(row["tool"])
            if tool_num <= 0:
                return False
            treeview.set_cursor(path, column, False)
            manage_status = self._get_life_status_for_row(tool_num, row)
            action_item = None
            clear_update_item = None
            if manage_status == "Install?":
                action_item = gtk.MenuItem("Install T{}".format(tool_num))
            elif manage_status == "Update?":
                action_item = gtk.MenuItem("Update T{}".format(tool_num))
                clear_update_item = gtk.MenuItem("Clear Update Flag (No Log)")
            menu = gtk.Menu()
            help_item = gtk.MenuItem(MENU_HELP_TITLE)
            duplicate_tool_item = gtk.MenuItem("Duplicate T{}...".format(tool_num))
            remove_item = gtk.MenuItem(TOOL_TITLE_FORMAT.format(tool_num))
            delete_all_tools_item = None
            if self._uses_modern_tool_table():
                delete_all_tools_item = gtk.MenuItem(DELETE_ALL_TOOLS_TITLE)
            view_tool_history_item = gtk.MenuItem(TOOL_HISTORY_TITLE.format(tool_num))
            view_all_history_item = gtk.MenuItem(ALL_HISTORY_TITLE)
            delete_triggered_reminder_item = gtk.MenuItem(TRIGGERED_REMINDER_TITLE)
            view_tool_reminders_item = gtk.MenuItem(REMINDERS_TITLE_FORMAT.format(tool_num))
            view_all_reminders_item = gtk.MenuItem(ALL_REMINDERS_TITLE)
            separator_1 = gtk.SeparatorMenuItem()
            separator_2 = gtk.SeparatorMenuItem()
            separator_3 = gtk.SeparatorMenuItem()
            help_item.connect("activate", self._on_tool_table_help_menu, tool_num)
            if action_item is not None:
                action_item.connect("activate", self._on_tool_life_menu, tool_num)
            if clear_update_item is not None:
                clear_update_item.connect("activate", self._on_clear_update_flag_menu, tool_num, row)
            duplicate_tool_item.connect("activate", self._on_duplicate_tool_menu, treeview, tool_num)
            remove_item.connect("activate", self._on_delete_tool_table_entry_menu, treeview, tool_num)
            if delete_all_tools_item is not None:
                delete_all_tools_item.connect("activate", self._on_delete_entire_tool_table_menu, treeview)
            view_tool_history_item.connect("activate", self._on_tool_life_menu, tool_num)
            view_all_history_item.connect("activate", self._on_view_all_history_menu)
            delete_triggered_reminder_item.connect("activate", self._on_delete_triggered_reminder_menu, tool_num)
            view_tool_reminders_item.connect("activate", self._on_tool_life_menu, tool_num)
            view_all_reminders_item.connect("activate", self._on_view_all_reminders_menu)
            menu.append(help_item)
            menu.append(separator_1)
            if action_item is not None:
                menu.append(action_item)
            if clear_update_item is not None:
                menu.append(clear_update_item)
            menu.append(duplicate_tool_item)
            menu.append(remove_item)
            if delete_all_tools_item is not None:
                menu.append(delete_all_tools_item)
            menu.append(separator_2)
            menu.append(view_tool_history_item)
            menu.append(view_all_history_item)
            menu.append(separator_3)
            if tool_num in self.triggered_reminder_tools_cache:
                menu.append(delete_triggered_reminder_item)
            menu.append(view_tool_reminders_item)
            menu.append(view_all_reminders_item)
            help_item.show()
            separator_1.show()
            if action_item is not None:
                action_item.show()
            if clear_update_item is not None:
                clear_update_item.show()
            duplicate_tool_item.show()
            remove_item.show()
            if delete_all_tools_item is not None:
                delete_all_tools_item.show()
            separator_2.show()
            view_tool_history_item.show()
            view_all_history_item.show()
            separator_3.show()
            if tool_num in self.triggered_reminder_tools_cache:
                delete_triggered_reminder_item.show()
            view_tool_reminders_item.show()
            view_all_reminders_item.show()
            menu.popup(None, None, None, event.button, event.time)
            return True
        except Exception as e:
            self._write_status("[Tool Life Column] Right-click menu failed: {}".format(str(e)))
        return False

    def _ask_duplicate_target_tool(self, source_tool):
        dialog = gtk.Dialog(
            "Duplicate T%d" % int(source_tool),
            None,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            ())
        dialog.set_default_size(430, 150)
        outer = gtk.VBox(False, 8)
        outer.set_border_width(12)
        dialog.vbox.pack_start(outer, True, True, 0)
        label = gtk.Label(
            "Duplicate T%d into which tool number?\n\n"
            "Enter the exact destination T-number." % int(source_tool))
        label.set_alignment(0.0, 0.5)
        outer.pack_start(label, False, False, 0)
        entry = gtk.Entry()
        entry.set_max_length(6)
        entry.set_activates_default(True)
        outer.pack_start(entry, False, False, 0)
        dialog.add_button("Cancel", gtk.RESPONSE_CANCEL)
        dialog.add_button("Continue", gtk.RESPONSE_OK)
        dialog.set_default_response(gtk.RESPONSE_OK)
        dialog.show_all()
        try:
            entry.grab_focus()
        except:
            pass
        response = dialog.run()
        value = self._normalize_text(entry.get_text())
        dialog.destroy()
        if response != gtk.RESPONSE_OK:
            return 0
        try:
            target_tool = int(value)
        except:
            self._show_reminder_info_dialog("Destination tool number must be a whole number.")
            return 0
        if target_tool <= 0:
            self._show_reminder_info_dialog("Destination tool number must be greater than zero.")
            return 0
        if target_tool == int(source_tool):
            self._show_reminder_info_dialog("Source and destination cannot be the same tool number.")
            return 0
        return target_tool

    def _tool_has_tlm_data(self, tool_num):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            tables = (
                "tool_usage",
                "tool_history",
                "tool_life_state",
                "tool_event_log",
                "tool_reminders",
                "tool_holder_assignment",
                "tool_model_assignment",
                "tool_notes",
                "tool_runtime_segments",)
            for table_name in tables:
                try:
                    c.execute("SELECT 1 FROM %s WHERE tool = ? LIMIT 1" % table_name, (int(tool_num),))
                    if c.fetchone() is not None:
                        return True
                except:
                    pass
        finally:
            conn.close()
        return False

    def _tool_has_any_duplicate_destination_data(self, tool_num):
        row = self._get_current_tool_row(tool_num)
        if self._normalize_text(row.get("description")):
            return True
        if self._tool_row_has_nonzero_value(row.get("diameter")):
            return True
        if self._tool_row_has_nonzero_value(row.get("length")):
            return True
        return self._tool_has_tlm_data(tool_num)

    def _confirm_duplicate_overwrite(self, source_tool, target_tool):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_WARNING,
            gtk.BUTTONS_YES_NO,
            "Overwrite T%d?" % int(target_tool))
        dialog.format_secondary_text(
            "T%d already contains PathPilot and/or Tool Life Manager data.\n\n"
            "Duplicating T%d into it will replace its Description, Diameter, Length, "
            "Holder, Cutter, and Flutes. Existing Tool Life Manager records for T%d "
            "will also be cleared before the selected copy scope is applied.\n\n"
            "Note: T99's PathPilot-reserved Mill Probe description is always preserved.\n\n"
            "Continue?" % (int(target_tool), int(source_tool), int(target_tool)))
        response = dialog.run()
        dialog.destroy()
        return response == gtk.RESPONSE_YES

    def _ask_duplicate_copy_scope(self, source_tool, target_tool):
        dialog = gtk.Dialog(
            "Duplicate T%d -> T%d" % (int(source_tool), int(target_tool)),
            None,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            ())
        dialog.set_default_size(560, 245)
        label = gtk.Label(
            "Choose how much data to copy.\n\n"
            "BASIC TOOL DATA\n"
            "Description, Diameter, Length, Holder, Cutter, and Flutes only.\n"
            "The destination starts with fresh Tool Life Manager history/usage.\n\n"
            "ALL TOOL + TLM DATA\n"
            "Everything above plus current life/usage, cutter replacement history, "
            "reminders, notes, event log, and recorded runtime segments.")
        label.set_alignment(0.0, 0.0)
        label.set_line_wrap(True)
        label.set_selectable(False)
        dialog.vbox.pack_start(label, True, True, 12)
        dialog.add_button("Cancel", 0)
        dialog.add_button("Basic Tool Data", 1)
        dialog.add_button("ALL Tool + TLM Data", 2)
        dialog.show_all()
        response = dialog.run()
        dialog.destroy()
        if response == 1:
            return "basic"
        if response == 2:
            return "all"
        return ""

    def _set_pathpilot_basic_tool_data(self, tool_num, source_row):
        model = self.ui.tool_treeview.get_model()
        row_index = self._find_tool_model_row_index(model, int(tool_num))
        if row_index is None:
            raise RuntimeError("T%d was not found in the current PathPilot tool table." % int(tool_num))
        description = self._normalize_text(source_row.get("description"))
        if self._is_reserved_probe_tool(tool_num):
            description = self._reserved_probe_description()
        diameter_text = self._normalize_text(source_row.get("diameter")) or "0.0000"
        length_text = self._normalize_text(source_row.get("length")) or "0.0000"
        try:
            diameter_value = float(diameter_text)
        except:
            diameter_value = 0.0
        try:
            length_value = float(length_text)
        except:
            length_value = 0.0
        if not self._is_reserved_probe_tool(tool_num):
            self.ui.set_tool_description(int(tool_num), description)
        self.ui.set_tool_diameter(int(row_index), str(diameter_value), model)
        self.ui.issue_tool_offset_command("Z", int(tool_num), float(length_value))
        # Keep the visible model in sync immediately. OEM callbacks will persist
        # the same values through PathPilot's normal tool-table machinery.
        try:
            model[row_index][1] = description
        except:
            pass
        try:
            self.ui.tool_diameter_column.format_cell(model, row_index, str(diameter_value))
        except:
            try:
                model[row_index][2] = "%.4f" % diameter_value
            except:
                pass
        try:
            self.ui.tool_length_column.format_cell(model, row_index, str(length_value))
        except:
            try:
                model[row_index][3] = "%.4f" % length_value
            except:
                pass
        try:
            tool_in_spindle = int(self.ui.status.tool_in_spindle)
        except:
            tool_in_spindle = 0
        if int(tool_num) == tool_in_spindle:
            try:
                self.ui.command.wait_complete()
                self.ui.issue_mdi("G43")
            except:
                pass
        try:
            if self._uses_modern_tool_table():
                self.ui.finish_tool_table_column_edit(row_index)
            else:
                glib.idle_add(self.ui.tool_table_update_observer, row_index)
        except:
            pass

    def _clear_duplicate_destination_tlm(self, cursor, tool_num):
        # Keep this list explicit so global/meta tables can never be affected.
        for table_name in (
                "tool_history",
                "tool_usage",
                "tool_life_state",
                "tool_reminders",
                "tool_notes",
                "tool_event_log",
                "tool_holder_assignment",
                "tool_model_assignment",
                "tool_runtime_segments"):
            try:
                cursor.execute("DELETE FROM %s WHERE tool = ?" % table_name, (int(tool_num),))
            except:
                pass

    def _copy_sqlite_tool_rows(self, cursor, table_name, source_tool, target_tool):
        # Generic per-tool copier that adapts to added columns. Integer primary-key
        # identity columns such as id are intentionally omitted so SQLite allocates
        # fresh destination IDs.
        try:
            cursor.execute("PRAGMA table_info(%s)" % table_name)
            info = cursor.fetchall()
        except:
            return
        if not info:
            return
        columns = []
        for col in info:
            name = str(col[1])
            col_type = self._normalize_text(col[2]).upper()
            is_pk = int(col[5] or 0) != 0
            if is_pk and name != "tool" and ("INT" in col_type or name.lower() == "id"):
                continue
            columns.append(name)
        if "tool" not in columns:
            return
        select_columns = ", ".join(['"%s"' % name for name in columns])
        cursor.execute("SELECT %s FROM %s WHERE tool = ?" % (select_columns, table_name), (int(source_tool),))
        rows = cursor.fetchall()
        if not rows:
            return
        tool_index = columns.index("tool")
        placeholders = ", ".join(["?"] * len(columns))
        insert_columns = ", ".join(['"%s"' % name for name in columns])
        sql = "INSERT OR REPLACE INTO %s (%s) VALUES (%s)" % (
            table_name, insert_columns, placeholders)
        for row in rows:
            values = list(row)
            values[tool_index] = int(target_tool)
            cursor.execute(sql, tuple(values))

    def _copy_duplicate_tlm_data(self, source_tool, target_tool, copy_all):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            self._clear_duplicate_destination_tlm(c, target_tool)
            # Holder/Cutter/Flutes are part of both Basic and All modes.
            for table_name in ("tool_holder_assignment", "tool_model_assignment"):
                self._copy_sqlite_tool_rows(c, table_name, source_tool, target_tool)
            if copy_all:
                for table_name in (
                        "tool_usage",
                        "tool_history",
                        "tool_life_state",
                        "tool_reminders",
                        "tool_notes",
                        "tool_event_log",
                        "tool_runtime_segments"):
                    self._copy_sqlite_tool_rows(c, table_name, source_tool, target_tool)
            # Basic mode is intentionally literal: only PathPilot Description/OD/Length
            # plus Holder/Cutter/Flutes are copied. No life/usage/history/reminder/note/event
            # records are created for the destination. It will appear as a fresh/uncommissioned
            # TLM tool until the normal Install/Commission workflow is used.
            if copy_all:
                now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                c.execute(
                    """INSERT INTO tool_event_log
                       (tool, logged_at, event_text, field_name, old_value, new_value)
                       VALUES (?, ?, ?, ?, ?, ?)""",
                    (int(target_tool), now_text,
                     "Tool duplicated from T%d (ALL data)" % int(source_tool),
                     "duplicate_tool", str(source_tool), str(target_tool)))
            conn.commit()
        except Exception:
            try:
                conn.rollback()
            except:
                pass
            raise
        finally:
            conn.close()

    def _on_duplicate_tool_menu(self, menu_item, treeview, source_tool):
        try:
            source_tool = int(source_tool)
            target_tool = self._ask_duplicate_target_tool(source_tool)
            if target_tool <= 0:
                return
            # Verify the destination exists in this PathPilot tool table before
            # asking destructive questions.
            model = treeview.get_model()
            if self._find_tool_model_row_index(model, target_tool) is None:
                self._show_reminder_info_dialog(
                    "T%d was not found in the current PathPilot tool table." % int(target_tool))
                return
            if self._tool_has_any_duplicate_destination_data(target_tool):
                if not self._confirm_duplicate_overwrite(source_tool, target_tool):
                    return
            scope = self._ask_duplicate_copy_scope(source_tool, target_tool)
            if not scope:
                return
            source_row = self._get_current_tool_row(source_tool)
            if source_row is None:
                self._show_reminder_info_dialog("Could not read the PathPilot data for T%d." % int(source_tool))
                return
            self._set_pathpilot_basic_tool_data(target_tool, source_row)
            self._copy_duplicate_tlm_data(source_tool, target_tool, scope == "all")
            self._refresh_state_cache()
            self._refresh_pathpilot_metadata_cache()
            try:
                self.ui.tool_treeview.queue_draw()
            except:
                pass
            try:
                self.ui.gremlin.cached_tool = None
                self.ui.gremlin.queue_draw()
            except:
                pass
            try:
                self._schedule_visual_column_autosize()
            except:
                pass
            self._write_status(
                "[Tool Life Column] Duplicated T%d -> T%d (%s)." %
                (source_tool, target_tool, "ALL data" if scope == "all" else "Basic data"))
        except Exception as e:
            self._write_status("[Tool Life Column] Duplicate Tool failed: %s" % str(e))
            try:
                self._show_reminder_info_dialog("Duplicate Tool failed:\n\n%s" % str(e))
            except:
                pass

    def _ask_clear_tool_table_scope(self, tool_num):
        dialog = gtk.Dialog("Remove T{}".format(tool_num), None, gtk.DIALOG_DESTROY_WITH_PARENT, ())
        dialog.set_modal(True)
        dialog.set_default_size(480, 180)
        message = gtk.Label(
            "Clear PathPilot description, diameter, and length for T{}?\n\n"
            "You can optionally also remove all Tool Life Manager records for this tool.".format(tool_num))
        message.set_alignment(0.0, 0.0)
        message.set_line_wrap(True)
        dialog.vbox.pack_start(message, True, True, 12)
        dialog.add_button("Cancel", 0)
        dialog.add_button("Clear PathPilot Only", 1)
        dialog.add_button("Clear PathPilot + TLM", 2)
        dialog.show_all()
        response = dialog.run()
        dialog.destroy()
        if response == 1:
            return 1
        if response == 2:
            return 2
        return 0

    def _clear_tlm_entries_for_tool(self, tool_num):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute("DELETE FROM tool_history WHERE tool = ?", (tool_num,))
            c.execute("DELETE FROM tool_usage WHERE tool = ?", (tool_num,))
            c.execute("DELETE FROM tool_life_state WHERE tool = ?", (tool_num,))
            try:
                c.execute("DELETE FROM tool_reminders WHERE tool = ?", (tool_num,))
            except:
                pass
            try:
                c.execute("DELETE FROM tool_notes WHERE tool = ?", (tool_num,))
            except:
                pass
            c.execute("DELETE FROM tool_event_log WHERE tool = ?", (tool_num,))
            c.execute("DELETE FROM tool_holder_assignment WHERE tool = ?", (tool_num,))
            c.execute("DELETE FROM tool_model_assignment WHERE tool = ?", (tool_num,))
            conn.commit()
        except Exception as e:
            conn.rollback()
            self._write_status("[Tool Life Column] T{} TLM clear failed: {}".format(tool_num, str(e)))
        finally:
            conn.close()

    def _on_all_history_row_double_clicked(self, treeview, event):
        try:
            if event.button != 1:
                return False
            if event.type != gtk.gdk._2BUTTON_PRESS:
                return False
            result = treeview.get_path_at_pos(int(event.x), int(event.y))
            if result is None:
                return False
            path, column, cell_x, cell_y = result
            model = treeview.get_model()
            tree_iter = model.get_iter(path)
            tool_num = int(model.get_value(tree_iter, 0))
            if tool_num <= 0:
                return False
            row = self._get_current_tool_row(tool_num)
            status = self._get_life_status_for_row(tool_num, row)
            self._show_life_dialog(tool_num, status)
            return True
        except Exception as e:
            self._write_status("[Tool Life Column] All history row double-click failed: {}".format(str(e)))
        return False

    def _on_all_history_window_key_press(self, window, event):
        try:
            if event.keyval == gtk.keysyms.Escape:
                window.destroy()
                return True
        except:
            pass
        return False

    def _prepare_maximized_window(self, window):
        # Keep normal window decorations so PathPilot shows the titlebar and window buttons.
        # Setting the type hint to NORMAL helps GTK dialogs behave more like regular windows.
        try:
            window.set_resizable(True)
        except:
            pass
        try:
            window.set_decorated(True)
        except:
            pass
        try:
            window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_NORMAL)
        except:
            pass

    def _maximize_window_once(self, window):
        # Do not use fullscreen here; fullscreen removes normal titlebar/window controls.
        try:
            window.unfullscreen()
        except:
            pass
        try:
            window.present()
        except:
            pass
        try:
            window.maximize()
        except:
            pass
        return False

    def _on_all_reminders_row_double_clicked(self, treeview, event):
        try:
            if event.button != 1:
                return False
            if event.type != gtk.gdk._2BUTTON_PRESS:
                return False
            result = treeview.get_path_at_pos(int(event.x), int(event.y))
            if result is None:
                return False
            path, column, cell_x, cell_y = result
            model = treeview.get_model()
            tree_iter = model.get_iter(path)
            tool_num = int(model.get_value(tree_iter, 0))
            if tool_num <= 0:
                return False
            row = self._get_current_tool_row(tool_num)
            status = self._get_life_status_for_row(tool_num, row)
            self._show_life_dialog(tool_num, status)
            return True
        except Exception as e:
            self._write_status("[Tool Life Column] All reminders row double-click failed: {}".format(str(e)))
        return False

    def _show_all_reminders_window(self):
        window = gtk.Window(gtk.WINDOW_TOPLEVEL)
        window.set_title("Tool Life Manager - All Reminders")
        window.set_default_size(980, 520)
        window.connect("key-press-event", self._on_all_history_window_key_press)
        vbox = gtk.VBox(False, 6)
        window.add(vbox)
        title = gtk.Label()
        title.set_markup('<span weight="bold" size="large">Tool Life Manager - All Reminders</span>')
        title.set_alignment(0.0, 0.5)
        vbox.pack_start(title, False, False, 8)
        store = gtk.ListStore(int, str, str, str, str)
        treeview = gtk.TreeView(store)
        treeview.set_headers_visible(True)
        treeview.connect("button-press-event", self._on_all_reminders_row_double_clicked)
        self._add_all_reminders_column(treeview, "Tool", 0, 60)
        self._add_all_reminders_column(treeview, "Message", 1, 280)
        self._add_all_reminders_column(treeview, "Trigger", 2, 320)
        self._add_all_reminders_column(treeview, "Time Remaining", 3, 180)
        self._add_all_reminders_column(treeview, "Created", 4, 150)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.add(treeview)
        vbox.pack_start(scroller, True, True, 0)
        button_row = gtk.HBox(False, 6)
        refresh_button = gtk.Button("Refresh")
        close_button = gtk.Button("Close")
        button_row.pack_end(close_button, False, False, 8)
        button_row.pack_end(refresh_button, False, False, 0)
        vbox.pack_start(button_row, False, False, 8)

        def refresh_clicked(button):
            self._populate_all_reminders_store(store)

        def close_clicked(button):
            window.destroy()

        refresh_button.connect("clicked", refresh_clicked)
        close_button.connect("clicked", close_clicked)
        self._populate_all_reminders_store(store)
        window.show_all()
        glib.idle_add(self._maximize_window_once, window)

    def _add_all_reminders_column(self, treeview, title, model_index, width):
        renderer = gtk.CellRendererText()
        renderer.set_property("font", "Roboto Condensed 10")
        renderer.set_property("xalign", 0.5)
        column = gtk.TreeViewColumn(title, renderer, text=model_index)
        column.set_resizable(True)
        column.set_min_width(width)
        column.set_sort_column_id(model_index)
        try:
            column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
        except:
            pass
        try:
            column.set_alignment(0.5)
        except:
            pass
        treeview.append_column(column)

    def _populate_all_reminders_store(self, store):
        store.clear()
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT r.id, r.tool, r.message, r.due_datetime,
                          r.due_age_seconds, r.due_usage_seconds,
                          r.created_at, r.triggered_at,
                          u.installed_date, u.total_seconds
                   FROM tool_reminders r
                   LEFT JOIN tool_usage u ON u.tool = r.tool
                   ORDER BY r.tool ASC, r.triggered_at ASC, r.id ASC""")
            for row in c.fetchall():
                reminder_id, tool_num, message, due_datetime, due_age_seconds, due_usage_seconds, created_at, triggered_at, installed_date, total_seconds = row
                store.append((
                    int(tool_num),
                    self._normalize_text(message),
                    self._format_reminder_trigger(due_datetime, due_age_seconds, due_usage_seconds),
                    self._format_reminder_time_remaining(
                        due_datetime,
                        due_age_seconds,
                        due_usage_seconds,
                        installed_date,
                        total_seconds,
                        triggered_at),
                    self._normalize_text(created_at)))
        finally:
            conn.close()

    def _show_all_history_window(self):
        window = gtk.Window(gtk.WINDOW_TOPLEVEL)
        window.set_title("Tool Life Manager - All Current Tools")
        window.set_default_size(980, 620)
        window.connect("key-press-event", self._on_all_history_window_key_press)
        vbox = gtk.VBox(False, 6)
        window.add(vbox)
        title_box = gtk.HBox(False, 0)
        title_box.set_border_width(8)
        title = gtk.Label()
        title.set_markup('<span weight="bold" size="large">Tool Life Manager - All Current Tools</span>')
        title.set_alignment(0.0, 0.5)
        title_box.pack_start(title, True, True, 0)
        vbox.pack_start(title_box, False, False, 0)
        store = gtk.ListStore(int, str, str, str, str, str, int, int)
        treeview = gtk.TreeView(store)
        treeview.set_headers_visible(True)
        treeview.connect("button-press-event", self._on_all_history_row_double_clicked)
        self._add_all_history_column(treeview, "Tool", 0, 60, 0)
        self._add_all_history_column(treeview, "Description", 1, 320, 1)
        self._add_all_history_column(treeview, "Installed", 2, 145, 2)
        self._add_all_history_column(treeview, "Age", 3, 95, 6)
        self._add_all_history_column(treeview, "Usage", 4, 95, 7)
        self._add_all_history_column(treeview, "Note", 5, 300, 5)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.add(treeview)
        vbox.pack_start(scroller, True, True, 0)
        button_row = gtk.HBox(False, 6)
        refresh_button = gtk.Button("Refresh")
        close_button = gtk.Button("Close")
        button_row.pack_end(close_button, False, False, 8)
        button_row.pack_end(refresh_button, False, False, 0)
        vbox.pack_start(button_row, False, False, 8)

        def refresh_clicked(button):
            self._populate_all_history_store(store)

        def close_clicked(button):
            window.destroy()

        refresh_button.connect("clicked", refresh_clicked)
        close_button.connect("clicked", close_clicked)
        self._populate_all_history_store(store)
        self._prepare_maximized_window(window)
        window.show_all()
        glib.idle_add(self._maximize_window_once, window)
        glib.timeout_add(250, self._maximize_window_once, window)

    def _add_all_history_column(self, treeview, title, model_index, width, sort_index):
        renderer = gtk.CellRendererText()
        renderer.set_property("font", "Roboto Condensed 10")
        if model_index == 0:
            renderer.set_property("xalign", 0.5)
        column = gtk.TreeViewColumn(title, renderer, text=model_index)
        column.set_resizable(True)
        column.set_min_width(width)
        column.set_sort_column_id(sort_index)
        try:
            column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
        except:
            pass
        try:
            column.set_alignment(0.5)
        except:
            pass
        treeview.append_column(column)

    def _populate_all_history_store(self, store):
        store.clear()
        self._refresh_state_cache()
        tool_rows = self._get_tool_table_rows_by_tool()
        current_rows = self._get_all_current_life_rows()
        known_tools = set(tool_rows.keys())
        for tool_num in current_rows.keys():
            known_tools.add(tool_num)
        for tool_num in self.tool_life_state_cache.keys():
            known_tools.add(tool_num)
        tools = list(known_tools)
        tools.sort()
        for tool_num in tools:
            row = tool_rows.get(tool_num)
            if row and self._is_real_tool_row(row):
                description = row["description"]
            else:
                continue
            current = current_rows.get(tool_num)
            installed_date = ""
            usage_seconds = 0
            note_text = ""
            if current:
                installed_date = self._normalize_text(current["installed_date"])
                usage_seconds = int(current["total_seconds"] or 0)
                note_text = self._normalize_text(current["install_note"])
            age_seconds = self._age_seconds_from_installed(installed_date)
            store.append((
                int(tool_num),
                description,
                installed_date,
                self._format_age(installed_date),
                self._format_seconds(usage_seconds),
                note_text,
                int(age_seconds),
                int(usage_seconds)))

    def _get_tool_table_rows_by_tool(self):
        rows = {}
        try:
            model = self.ui.tool_treeview.get_model()
            row_index = 0
            for model_row in model:
                row = self._get_tool_row_for_model_row(model, model_row, row_index)
                row_index += 1
                if row is None:
                    continue
                rows[int(row["tool"])] = row
        except Exception as e:
            self._write_status("[Tool Life Column] Could not read PathPilot tool table rows: {}".format(str(e)))
        return rows

    def _get_all_current_life_rows(self):
        rows = {}
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT tool, installed_date, total_seconds, install_note
                   FROM tool_usage""")
            for tool_num, installed_date, total_seconds, install_note in c.fetchall():
                rows[int(tool_num)] = {
                    "installed_date": installed_date,
                    "total_seconds": int(total_seconds or 0),
                    "install_note": install_note}
        finally:
            conn.close()
        return rows

    def _age_seconds_from_installed(self, installed_date_text):
        installed_dt = self._parse_datetime(installed_date_text)
        if not installed_dt:
            return 0
        elapsed = datetime.datetime.now() - installed_dt
        return int(elapsed.days * 86400 + elapsed.seconds)

    def _on_delete_triggered_reminder_menu(self, menu_item, tool_num):
        try:
            if not self._confirm_reminder_delete("Delete triggered reminder(s) for T{}?".format(tool_num)):
                return
            conn = self._db_connect()
            try:
                c = conn.cursor()
                c.execute(
                    """SELECT COUNT(*) FROM tool_reminders
                       WHERE tool = ?
                         AND triggered_at IS NOT NULL
                         AND triggered_at != ''""",
                    (int(tool_num),))
                count_row = c.fetchone()
                delete_count = int(count_row[0] or 0) if count_row else 0
                c.execute(
                    """DELETE FROM tool_reminders
                       WHERE tool = ?
                         AND triggered_at IS NOT NULL
                         AND triggered_at != ''""",
                    (int(tool_num),))
                if delete_count > 0:
                    self._append_named_tool_event(
                        c, tool_num,
                        "Triggered reminders deleted (%d)" % delete_count,
                        "reminder_delete_triggered", str(delete_count), "0")
                conn.commit()
            finally:
                conn.close()
            self._refresh_triggered_reminder_cache()
            try:
                self.ui.tool_treeview.queue_draw()
            except:
                pass
            self._write_status("[Tool Life Column] T{} triggered reminder(s) deleted.".format(tool_num))
        except Exception as e:
            self._write_status("[Tool Life Column] Delete triggered reminder failed: {}".format(str(e)))

    def _on_tool_life_menu(self, menu_item, tool_num):
        try:
            row = self._get_current_tool_row(tool_num)
            if hasattr(self, "_is_real_tool_row"):
                if not self._is_real_tool_row(row):
                    self._write_status("[Tool Life Column] T{} is an empty PathPilot tool row.".format(tool_num))
                    return
            status = self._get_life_status_for_row(tool_num, row)
            self._show_life_dialog(tool_num, status)
        except Exception as e:
            self._write_status("[Tool Life Column] Tool Life menu failed: {}".format(str(e)))

    def _on_view_all_reminders_menu(self, menu_item):
        self._show_all_reminders_window()

    def _on_view_all_history_menu(self, menu_item):
        self._show_all_history_window()

    def _show_scrollable_help_dialog(self, title_text, message):
        dialog = gtk.Dialog(title_text, None, gtk.DIALOG_DESTROY_WITH_PARENT, ("Close", gtk.RESPONSE_CLOSE))
        dialog.set_modal(True)
        dialog.set_default_size(760, 560)
        try:
            dialog.set_position(gtk.WIN_POS_CENTER)
        except:
            pass
        outer = gtk.VBox(False, 8)
        outer.set_border_width(12)
        header_row = gtk.HBox(False, 8)
        info_icon = gtk.Image()
        info_icon.set_from_stock(gtk.STOCK_DIALOG_INFO, gtk.ICON_SIZE_DIALOG)
        title = gtk.Label()
        title.set_markup('<span weight="bold" size="large">%s</span>' % title_text)
        title.set_alignment(0.0, 0.5)
        header_row.pack_start(info_icon, False, False, 0)
        header_row.pack_start(title, True, True, 0)
        outer.pack_start(header_row, False, False, 0)
        body_label = gtk.Label()
        body_label.set_use_markup(True)
        body_label.set_markup(message)
        body_label.set_alignment(0.0, 0.0)
        body_label.set_line_wrap(True)
        try:
            body_label.set_width_chars(92)
        except:
            pass
        viewport = gtk.Viewport()
        viewport.add(body_label)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
        scroller.set_shadow_type(gtk.SHADOW_IN)
        scroller.set_size_request(720, 460)
        scroller.add(viewport)
        outer.pack_start(scroller, True, True, 0)
        dialog.vbox.pack_start(outer, True, True, 0)
        dialog.show_all()
        try:
            dialog.run()
        finally:
            dialog.destroy()

    def _on_clear_update_flag_menu(self, menu_item, tool_num, row):
        # Accept the current PathPilot description/diameter/length as the new
        # TLM metadata baseline without cutter replacement, history, or event log.
        # This is intentionally lighter-weight than "Sync Metadata Only".
        try:
            tool_num = int(tool_num)
            if tool_num not in self.tool_usage_cache:
                self._show_reminder_info_dialog("T%d has not been commissioned yet." % tool_num)
                return
            current_state = self._metadata_tuple_from_row(row)
            self._save_tool_life_state(tool_num, row)
            # Update in-memory caches immediately so the Manage cell becomes
            # Synced without waiting for the periodic DB refresh.
            self.tool_life_state_cache[tool_num] = current_state
            self.pathpilot_metadata_cache[tool_num] = current_state
            try:
                if (not self._uses_modern_tool_table()) or self._tool_table_is_visible():
                    self.ui.tool_treeview.queue_draw()
            except:
                pass
            self._write_status(
                "[Tool Life Column] T%d Update flag cleared; current metadata accepted without log." %
                tool_num)
        except Exception as e:
            self._write_status("[Tool Life Column] T%d Clear Update failed: %s" % (int(tool_num), str(e)))

    def _on_tool_table_help_menu(self, menu_item, tool_num):
        message = (
            "<b>Tool Life Manager Overview</b>\n\n"
            "TLM extends the existing PathPilot tool table. It tracks the current cutter installed in each "
            "tool holder, spindle usage, cutter history, reminders, notes, holder/cutter visualization assignments, "
            "and metadata changes. Runtime comes from the TormachTips Spindle Hobbs Timer.\n\n"
            "<b>Tool vs Cutter</b>\n\n"
            "TLM uses normal shop vocabulary: the Tool is the holder/tool-number assembly. The Cutter is the "
            "end mill, drill, reamer, thread mill, etc. installed in that holder. One Tool can use many cutters "
            "over its lifetime. Replacing a cutter closes the old cutter record into History and starts a new one.\n\n"
            "<b>Manage Column: Install? / Synced / Update?</b>\n\n"
            "<b>Install?</b> means this T-number is not yet commissioned in TLM. Commissioning creates the current "
            "cutter record and establishes the PathPilot Description, Diameter, and Length baseline.\n\n"
            "<b>Synced</b> means the current PathPilot Description, Diameter, and Length match TLM's saved baseline.\n\n"
            "<b>Update?</b> means one or more of those PathPilot metadata values changed after the last commission, "
            "replacement, or sync. Open the Tool Life popup to decide whether this was a real cutter replacement "
            "or only a metadata correction.\n\n"
            "<b>Clear Update Flag (No Log)</b>\n\n"
            "Accepts the current Description, Diameter, and Length as the new TLM baseline without creating a "
            "replacement-history row or metadata-sync event. Use this for a harmless correction that should not "
            "represent a cutter change.\n\n"
            "<b>Wide / Normal</b>\n\n"
            "The Wide pseudo-tab expands the PathPilot Tool table across the Offsets area so the extra TLM columns "
            "fit comfortably. TLM starts Wide and returns to Wide whenever you leave and re-enter Offsets. Choosing "
            "Normal applies only to the current Offsets visit.\n\n"
            "<b>Go To Tool</b>\n\n"
            "The Go To pseudo-tab lets you enter an exact T-number and jumps the live PathPilot table directly to "
            "that tool.\n\n"
            "<b>Holder / Cutter / Flutes Columns</b>\n\n"
            "Holder selects the Gremlin holder model assigned to the T-number. Cutter selects the visual cutter model. "
            "Flutes selects the visual flute count. These assignments are stored by TLM and are used by the Gremlin "
            "Tool Holder suite; they do not change LinuxCNC tool offsets. Columns are sortable where PathPilot's model "
            "supports sorting, and can be manually resized.\n\n"
            "<b>Supported Cutter Models</b>\n\n"
            "End Mill, Ball Mill, Rounded Edge End Mill, Straight and Spiral Reamers, 90 and 45 degree Chamfer Mills, "
            "Drill Bit, Key Seat Cutter, Rounded Key Seat Cutter, Dovetail Cutter, and Thread Mill presets for "
            "3/8, 1/4, and 3/16 inch shanks.\n\n"
            "<b>Duplicate Tool</b>\n\n"
            "Right-click a tool and choose Duplicate to copy it into an exact destination T-number. "
            "<b>Basic Tool Data</b> copies Description, Diameter, Length, Holder, Cutter, and Flutes and starts the "
            "destination with fresh TLM life/history data. <b>ALL Tool + TLM Data</b> also copies current life/usage, "
            "replacement history, reminders, notes, event log, and recorded runtime segments. Existing destination "
            "data is cleared after confirmation before the selected copy scope is applied.\n\n"
            "<b>Tool Life Popup</b>\n\n"
            "Click the Manage or Usage cell, or use the right-click History/Reminders items, to open the per-tool "
            "Tool Life popup. It contains current and historical cutter rows, Notes, reminders, usage-by-day/hour, "
            "metadata actions, and the Event Log. Use the Help button in that popup for field-by-field details.\n\n"
            "<b>Dates</b>\n\n"
            "Installed, Replaced, and Reminder dates accept common U.S. entry such as 7/6/25, 7/6/2025, "
            "07/06/2025, hyphen versions such as 7-6-2025, or ISO YYYY-MM-DD. Time is optional and can be entered "
            "in 12-hour or 24-hour form. A date with no time defaults to 12:00 noon. After entry, TLM stores/displays "
            "the canonical YYYY-MM-DD HH:MM:SS form.\n\n"
            "<b>Age and Usage</b>\n\n"
            "Age is calendar time since Installed for the current cutter, or Installed-to-Replaced for a historical "
            "cutter. The table displays the three largest useful age units. Usage is actual tracked spindle runtime "
            "for that cutter, supplied by Hobbs. SIM-mode spindle time is excluded by the current Hobbs plugin.\n\n"
            "<b>Reminders</b>\n\n"
            "Reminders can trigger by exact date/time, cutter Age, spindle Usage, or any combination. If several "
            "triggers are set, the first one reached fires the reminder. Age means cutter age since Installed. "
            "Triggered reminders are marked in the table and are also posted to the Status Window.\n\n"
            "<b>Event Log</b>\n\n"
            "The Event Log records TLM lifecycle actions and PathPilot Description/Diameter/Length changes. "
            "Modern PathPilot direct edits are captured immediately, while Offsets-entry and table-rebuild "
            "reconciliation catches OEM touch-off/Move &amp; Set Tool Length, imported tables, G10/MDI changes, "
            "and other changes that bypass a cell edit signal. Bulk rebuild changes are coalesced rather than dropped.\n\n"
            "<b>Usage By Day / Hour</b>\n\n"
            "Usage By Day shows completed Hobbs runtime segments on a monthly calendar. Click a day to see hourly "
            "runtime and the individual runtime windows/source information recorded for that tool.\n\n"
            "<b>Remove T#</b>\n\n"
            "Clear PathPilot Only clears that tool's Description, Diameter, and Length while retaining TLM records. "
            "Clear PathPilot + TLM also removes the tool's TLM life/history/reminder/assignment data. "
            "PathPilot reserves T99 as the Mill Probe; TLM always preserves T99's OEM probe description.\n\n"
            "<b>Delete ENTIRE Tool Table</b>\n\n"
            "On modern PathPilot, this right-click action clears all non-empty/non-zero tool rows and TLM data after "
            "two confirmations. TLM creates a timestamped backup under "
            "<tt>/home/operator/gcode/python/tool_table_backups</tt>, but you should still use PathPilot's Export "
            "button before a full reset. Watch the Status Window until completion is reported.\n\n"
            "<b>T99 Mill Probe</b>\n\n"
            "PathPilot reserves T99 for the mill probe. Its canonical OEM description is "
            "<b>Mill Probe (diameter is effective, not actual)</b>. TLM will not blank or replace that reserved "
            "description, including during Duplicate and Remove operations. Diameter and Length remain usable as "
            "PathPilot intends.\n\n"
            "<b>Gremlin / Builder Suite</b>\n\n"
            "Holder, Cutter, and Flutes assignments are visualization data consumed by the Gremlin Tool Holder plugin "
            "and Holder Builder. View Entire Tool Diagram generates a larger dimensioned SVG from the same assignments and holder reference geometry; its OAL runs from the spindle/tool-length reference plane to the cutting-edge tip, while Stickout runs from the holder/collet interface to the cutting-edge tip. Diagram dimensions follow PathPilot inch/mm display units. "
            "These are visual aids only: they do not provide geometric collision detection and do "
            "not alter machine/tool offsets.")
        self._show_scrollable_help_dialog("Tool Life Manager Help", message)

    def _on_delete_tool_table_entry_menu(self, menu_item, treeview, tool_num):
        try:
            clear_mode = self._ask_clear_tool_table_scope(tool_num)
            if clear_mode == 0:
                return
            model = treeview.get_model()
            row_index = self._find_tool_model_row_index(model, tool_num)
            if row_index is None:
                self._write_status("[Tool Life Column] Could not find T{} in tool table.".format(tool_num))
                return
            self._clear_tool_table_entry(model, row_index, tool_num)
            if clear_mode == 1:
                self._save_tool_life_state(tool_num, {
                    "tool": tool_num,
                    "description": "",
                    "diameter": "0.0000",
                    "length": "0.0000"})
                self._write_status("[Tool Life Column] T{} PathPilot tool table entry cleared.".format(tool_num))
            elif clear_mode == 2:
                self._clear_tlm_entries_for_tool(tool_num)
                self._write_status("[Tool Life Column] T{} PathPilot entry and Tool Life Manager entries cleared.".format(tool_num))
            self._refresh_state_cache()
            treeview.queue_draw()
        except Exception as e:
            self._write_status("[Tool Life Column] Tool table clear failed: {}".format(str(e)))

    def _tool_table_value_has_content(self, value):
        text = self._normalize_text(value)
        if text:
            return True
        return False

    def _tool_table_numeric_has_nonzero_value(self, value):
        text = self._normalize_text(value)
        if not text:
            return False
        try:
            return abs(float(text)) > 0.0000001
        except:
            return True

    def _tool_table_max_rpm_has_value(self, value):
        text = self._normalize_text(value)
        if not text:
            return False
        try:
            rpm = float(text)
            if rpm > 0.0000001:
                return True
            return False
        except:
            return True

    def _tool_table_row_has_any_content(self, model_row):
        if not self._uses_modern_tool_table():
            return False
        # Modern PathPilot mill tool-table columns:
        # 1 description, 2 diameter, 3 length, 4 diameter wear, 5 length wear, 6 max RPM.
        if self._tool_table_value_has_content(model_row[1]):
            return True
        if self._tool_table_numeric_has_nonzero_value(model_row[2]):
            return True
        if self._tool_table_numeric_has_nonzero_value(model_row[3]):
            return True
        if self._tool_table_numeric_has_nonzero_value(model_row[4]):
            return True
        if self._tool_table_numeric_has_nonzero_value(model_row[5]):
            return True
        if self._tool_table_max_rpm_has_value(model_row[6]):
            return True
        return False

    def _make_tool_table_backup_dir(self):
        stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        backup_dir = os.path.join(TOOL_TABLE_BACKUP_ROOT, stamp)
        if not os.path.isdir(TOOL_TABLE_BACKUP_ROOT):
            os.makedirs(TOOL_TABLE_BACKUP_ROOT)
        os.makedirs(backup_dir)
        return backup_dir

    def _backup_live_tool_table_snapshot(self, backup_dir):
        snapshot_path = os.path.join(backup_dir, "live_tool_table_snapshot.txt")
        model = self.ui.tool_treeview.get_model()
        with open(snapshot_path, "w") as f:
            f.write("tool\tdescription\tdiameter\tlength\tdia_wear\tlen_wear\tmax_rpm\n")
            row_index = 0
            for model_row in model:
                try:
                    tool_num = self._get_tool_number_for_row_index(model, row_index)
                    description = self._normalize_text(model_row[1])
                    diameter = self._normalize_text(model_row[2])
                    length = self._normalize_text(model_row[3])
                    dia_wear = self._normalize_text(model_row[4])
                    len_wear = self._normalize_text(model_row[5])
                    max_rpm = self._normalize_text(model_row[6])
                    f.write("%s\t%s\t%s\t%s\t%s\t%s\t%s\n" % (
                        str(tool_num),
                        description,
                        diameter,
                        length,
                        dia_wear,
                        len_wear,
                        max_rpm))
                except:
                    pass
                row_index += 1
        return snapshot_path

    def _backup_tool_table_files(self, backup_dir):
        copied = []
        seen = set()
        for pattern in TOOL_TABLE_FILE_PATTERNS:
            for path in glob.glob(pattern):
                try:
                    real_path = os.path.realpath(path)
                    if real_path in seen:
                        continue
                    seen.add(real_path)
                    if not os.path.isfile(path):
                        continue
                    target = os.path.join(backup_dir, os.path.basename(path))
                    shutil.copy2(path, target)
                    copied.append(target)
                except Exception as e:
                    self._write_status("[Tool Life Column] Tool table file backup failed for {}: {}".format(path, str(e)))
        return copied

    def _backup_tlm_database(self, backup_dir):
        if not os.path.isfile(DB_FILE):
            return ""
        target = os.path.join(backup_dir, os.path.basename(DB_FILE))
        shutil.copy2(DB_FILE, target)
        return target

    def _backup_before_entire_tool_table_clear(self):
        backup_dir = self._make_tool_table_backup_dir()
        self._backup_tool_table_files(backup_dir)
        self._backup_tlm_database(backup_dir)
        self._backup_live_tool_table_snapshot(backup_dir)
        return backup_dir

    def _confirm_delete_entire_tool_table(self, title, message_text):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_WARNING,
            gtk.BUTTONS_OK_CANCEL,
            title)
        dialog.set_modal(True)
        dialog.format_secondary_text(message_text)
        response = dialog.run()
        dialog.destroy()
        return response == gtk.RESPONSE_OK

    def _clear_all_pathpilot_tool_rows(self, treeview):
        model = treeview.get_model()
        rows_to_clear = []
        row_index = 0
        for model_row in model:
            try:
                tool_num = self._get_tool_number_for_row_index(model, row_index)
                if int(tool_num) > 0 and self._tool_table_row_has_any_content(model_row):
                    rows_to_clear.append((row_index, int(tool_num)))
            except:
                pass
            row_index += 1
        if len(rows_to_clear) <= 0:
            self._write_status("[Tool Life Column] No non-empty tool table rows found.")
            return 0
        # Avoid turning a whole-table reset into hundreds of misleading per-cell history records.
        cleared_count = 0
        for row_index, tool_num in rows_to_clear:
            self._clear_tool_table_entry(model, row_index, tool_num)
            cleared_count += 1
            if cleared_count % 10 == 0:
                self._write_status("[Tool Life Column] Cleared {} of {} non-empty tool table rows...".format(cleared_count, len(rows_to_clear)))
                while gtk.events_pending():
                    gtk.main_iteration(False)
        self._write_status("[Tool Life Column] Cleared {} non-empty tool table rows. Empty rows were skipped.".format(cleared_count))
        return cleared_count

    def _clear_all_tlm_entries(self):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute("DELETE FROM tool_history")
            c.execute("DELETE FROM tool_usage")
            c.execute("DELETE FROM tool_life_state")
            try:
                c.execute("DELETE FROM tool_reminders")
            except:
                pass
            try:
                c.execute("DELETE FROM tool_event_log")
            except:
                pass
            try:
                c.execute("DELETE FROM tool_holder_assignment")
                c.execute("DELETE FROM tool_model_assignment")
            except:
                pass
            try:
                c.execute("DELETE FROM tool_notes")
            except:
                pass
            conn.commit()
        except Exception as e:
            conn.rollback()
            self._write_status("[Tool Life Column] Full TLM clear failed: {}".format(str(e)))
            raise
        finally:
            conn.close()

    def _on_delete_entire_tool_table_menu(self, menu_item, treeview):
        try:
            if not self._confirm_delete_entire_tool_table(
                    "Delete ENTIRE Tool Table?",
                    "Before continuing, you should export your tool table using the Export button "
                    "to the left of the PathPilot tool table.\n\n"
                    "This reset only clears non-empty / non-zero tool rows, but it may still take "
                    "a minute on large tool tables.\n\n"
                    "Watch the Status Window for progress and completion messages.\n\n"
                    "Backup folder:\n"
                    "{}\n\n"
                    "Click OK to continue to the final warning.".format(TOOL_TABLE_BACKUP_ROOT)):
                return
            if not self._confirm_delete_entire_tool_table(
                    "FINAL WARNING",
                    "You are about to clear the entire PathPilot tool table.\n\n"
                    "This includes non-empty descriptions, diameters, lengths, wear values, max RPM values, "
                    "Tool Life Manager usage, history, reminders, and event log records.\n\n"
                    "This can take a minute. Watch the Status Window until completion is reported.\n\n"
                    "Click OK only if you are absolutely sure."):
                return
            backup_dir = self._backup_before_entire_tool_table_clear()
            self._write_status("[Tool Life Column] Entire tool table clear started. Watch this Status Window for completion.")
            cleared_count = self._clear_all_pathpilot_tool_rows(treeview)
            self._clear_all_tlm_entries()
            self._refresh_state_cache()
            self._refresh_pathpilot_metadata_cache()
            treeview.queue_draw()
            self._write_status("[Tool Life Column] Entire tool table clear complete. Non-empty rows cleared: {}. Backup: {}".format(cleared_count, backup_dir))
        except Exception as e:
            self._write_status("[Tool Life Column] Entire tool table clear failed: {}".format(str(e)))

    def _find_tool_model_row_index(self, model, tool_num):
        row_index = 0
        for row in model:
            try:
                if self._get_tool_number_for_row_index(model, row_index) == int(tool_num):
                    return row_index
            except:
                pass
            row_index += 1
        return None

    def _clear_tool_table_entry(self, model, row_index, tool_num):
        # Persist the clear through PathPilot's own setters/MDI path first.
        # T99's description is OEM-reserved for the mill probe and must never
        # be cleared or repurposed by TLM.
        if self._is_reserved_probe_tool(tool_num):
            cleared_description = self._reserved_probe_description()
        else:
            cleared_description = ""
            self.ui.set_tool_description(tool_num, cleared_description)
        self.ui.set_tool_diameter(row_index, "0.0000", model)
        self.ui.issue_tool_offset_command("Z", tool_num, 0.0)
        # Update the live GTK model immediately on ALL supported PathPilot
        # versions. 2.9.x does not always repaint Description/Length promptly
        # after the OEM persistence callbacks, which made Remove look partial.
        try:
            model[row_index][1] = cleared_description
        except:
            pass
        try:
            self.ui.tool_diameter_column.format_cell(model, row_index, "0.0000")
        except:
            try:
                model[row_index][2] = "0.0000"
            except:
                pass
        try:
            self.ui.tool_length_column.format_cell(model, row_index, "0.0000")
        except:
            try:
                model[row_index][3] = "0.0000"
            except:
                pass
        if self._uses_modern_tool_table():
            # 2.10+ added wear and max-RPM fields. Clear them too when removing a
            # tool row, but keep every call guarded so minor OEM changes are safe.
            try:
                self.ui.set_tool_wear_diameter(row_index, "0.0000", model)
            except:
                try:
                    self.ui.issue_tool_offset_command("I", tool_num, 0.0)
                except:
                    pass
            try:
                self.ui.issue_tool_offset_command("W", tool_num, 0.0)
            except:
                pass
            try:
                self.ui.issue_tool_offset_command("J", tool_num, -1.0)
            except:
                pass
            try:
                self.ui.tool_diameter_wear_column.format_cell(model, row_index, "0.0000")
            except:
                try:
                    model[row_index][4] = "0.0000"
                except:
                    pass
            try:
                self.ui.tool_length_wear_column.format_cell(model, row_index, "0.0000")
            except:
                try:
                    model[row_index][5] = "0.0000"
                except:
                    pass
            try:
                model[row_index][6] = ""
            except:
                pass
        try:
            tool_in_spindle = int(self.ui.status.tool_in_spindle)
        except:
            tool_in_spindle = 0
        if int(tool_num) == tool_in_spindle:
            self.ui.command.wait_complete()
            self.ui.issue_mdi("G43")
        # Let the OEM observer persist/reconcile the edited row after our
        # immediate visual clear. Do not return before scheduling this on 2.9.x.
        if self._uses_modern_tool_table():
            try:
                self.ui.finish_tool_table_column_edit(row_index)
                return
            except:
                pass
        glib.idle_add(self.ui.tool_table_update_observer, row_index)

    def _on_tool_table_button_release(self, treeview, event):
        try:
            result = treeview.get_path_at_pos(int(event.x), int(event.y))
            if not result:
                return False
            path, column, cell_x, cell_y = result
            if column != self.life_column and column != self.usage_column:
                return False
            model = treeview.get_model()
            tree_iter = model.get_iter(path)
            tool_num = self._get_tool_number_for_iter(model, tree_iter)
            if tool_num <= 0:
                return False
            status = self._get_life_status_for_iter(model, tree_iter)
            self._show_life_dialog(tool_num, status)
        except Exception as e:
            self._write_status("[Tool Life Column] Click handler failed: {}".format(str(e)))
        return False

    def _on_life_dialog_help_button(self, button, tool_num):
        message = (
            "<b>Current &amp; Historical Cutter Data</b>\n\n"
            "The first row is the current cutter installed in this T-number. Older rows are archived cutter "
            "generations created by Replace Cutter.\n\n"
            "<b>Installed</b> is when that cutter generation began.\n"
            "<b>Replaced</b> is when a historical cutter generation ended. The current cutter has no Replaced date.\n"
            "<b>Age</b> is calendar time from Installed to now for the current cutter, or Installed to Replaced for history. "
            "The display shows the three largest useful units.\n"
            "<b>Usage</b> is tracked spindle runtime for that cutter from Hobbs.\n"
            "<b>Removal Reason</b> belongs to the cutter on that row and explains why it left service. The current "
            "cutter's Removal Reason remains blank until that cutter is replaced.\n\n"
            "<b>Editing Dates and Times</b>\n\n"
            "Installed and historical Replaced dates accept MM/DD/YYYY, MM-DD-YYYY, or YYYY-MM-DD. One-digit month/day "
            "and 2-digit years are accepted, for example 7/6/25. Time is optional; date-only entries default to noon. "
            "12-hour times such as 3:30 PM and 24-hour times such as 15:30 are accepted. TLM normalizes the saved/displayed "
            "value to YYYY-MM-DD HH:MM:SS.\n\n"
            "Editable Age/Usage time values accept normal duration text such as 12m 30s, 1h 5m, or raw seconds.\n\n"
            "<b>Notes</b>\n\n"
            "Notes are a persistent scratchpad attached to the T-number itself. They remain with the Tool number across "
            "cutter replacements and are not part of one historical cutter generation. Note changes are recorded in the Event Log.\n\n"
            "<b>Tool Metadata</b>\n\n"
            "TLM tracks PathPilot Description, Diameter, and Length as the cutter metadata baseline. If one changes, "
            "the Manage column becomes Update?.\n\n"
            "<b>Sync Metadata Only</b>\n\n"
            "Use this when PathPilot metadata changed but the physical cutter did not. It accepts the current Description, "
            "Diameter, and Length as the TLM baseline without resetting Usage or creating a replacement-history row. "
            "The sync is traceable in the Event Log.\n\n"
            "<b>Clear Update (No Log)</b>\n\n"
            "Accepts the same current metadata baseline but intentionally does not create a cutter replacement, history row, "
            "or metadata-sync event. Use it for a correction you do not want represented as a formal lifecycle action.\n\n"
            "<b>Replace Cutter</b>\n\n"
            "Use this only when the physical cutter was actually replaced. The outgoing current record is archived, its "
            "final Usage/Age are retained, the Replacement Reason becomes that outgoing cutter's Removal Reason, and a new "
            "current cutter record starts with a fresh Installed date and Usage. The replacement itself is recorded in the Event Log.\n\n"
            "<b>Delete Row / Delete All History</b>\n\n"
            "Delete Row removes the selected archived cutter generation. Delete All removes every archived cutter row for "
            "this T-number. Neither action removes the current cutter. History deletions are recorded in the Event Log.\n\n"
            "<b>Usage By Day</b>\n\n"
            "Opens a monthly calendar built from completed Hobbs runtime segments. Click a day for hourly totals and individual "
            "runtime windows/source information. This detailed view depends on runtime segments recorded by the current Hobbs plugin.\n\n"
            "<b>Reminders</b>\n\n"
            "A reminder can trigger by exact Date/Time, cutter Age, spindle Usage, or several triggers at once. When multiple "
            "triggers are set, the first one reached fires the reminder.\n\n"
            "<b>Age means cutter age since Installed</b>, not time since the reminder was created. If a cutter is already "
            "20 days old and you add a 30-day Age reminder, about 10 days remain.\n\n"
            "Reminder Date/Time accepts the same smart date formats as Installed/Replaced; date-only defaults to noon. "
            "Triggered reminders are marked in the tool table and periodically repeated in the Status Window until cleared. "
            "Reminder add/delete/trigger actions are recorded in the Event Log.\n\n"
            "<b>Show Event Log</b>\n\n"
            "Shows recorded PathPilot metadata changes and TLM lifecycle/actions for this T-number. This includes direct and "
            "reconciled Description/Diameter/Length changes, Commission, Replace Cutter, Notes changes, reminder actions, and "
            "history deletions. The log is informational and does not itself change tool-life data.\n\n"
            "<b>T99 Mill Probe</b>\n\n"
            "PathPilot reserves T99's description as <b>Mill Probe (diameter is effective, not actual)</b>. TLM preserves "
            "that OEM description. Diameter and Length remain usable as PathPilot intends.\n\n"
            "<b>Close</b>\n\n"
            "Closes this popup. Edits/actions already committed remain saved.")
        self._show_scrollable_help_dialog("Tool Life Popup Help", message)

    def _resolve_holder_image_path(self, holder_data):
        # The Builder owns SVG generation. TLM only consumes the image path
        # written into each holder definition and never rewrites that SVG.
        if not isinstance(holder_data, dict):
            return ""
        image_path = self._normalize_text(holder_data.get("image", ""))
        if not image_path:
            return ""
        if os.path.isabs(image_path):
            return image_path
        config_dir = os.path.dirname(os.path.abspath(GREMLIN_CONFIG_PATH))
        return os.path.normpath(os.path.join(config_dir, image_path))

    def _load_holder_catalog(self):
        default_holder_type = "TTS_ER32_GENERIC"
        holder_rows = []
        try:
            handle = open(GREMLIN_CONFIG_PATH, "r")
            try:
                config = json.load(handle)
            finally:
                handle.close()
            if isinstance(config, dict):
                default_holder_type = self._normalize_text(
                    config.get("default_holder_type", config.get("holder_model", default_holder_type)))
                holder_types = config.get("holder_types")
                if isinstance(holder_types, dict):
                    for holder_type, holder_data in holder_types.items():
                        if not isinstance(holder_data, dict):
                            continue
                        holder_type = self._normalize_text(holder_type)
                        if not holder_type:
                            continue
                        image_path = self._resolve_holder_image_path(holder_data)
                        holder_rows.append((
                            holder_type,
                            self._normalize_text(holder_data.get("display_name", holder_type)),
                            image_path))
                elif isinstance(config.get("sections"), list):
                    # Keep TLM's old config-reading tolerance for now; there is
                    # intentionally no SVG generation fallback here.
                    holder_type = self._normalize_text(
                        config.get("holder_model", default_holder_type)) or default_holder_type
                    holder_rows.append((holder_type, holder_type.replace("_", " "), ""))
                    default_holder_type = holder_type
        except Exception as e:
            self._write_status("[Tool Life Column] Holder catalog read failed: {}".format(str(e)))
        holder_rows.sort(key=lambda row: row[1].lower())
        return default_holder_type, holder_rows

    def _get_tool_holder_assignment(self, tool_num):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute("SELECT holder_type FROM tool_holder_assignment WHERE tool = ?", (int(tool_num),))
            row = c.fetchone()
            return "" if row is None else self._normalize_text(row[0])
        finally:
            conn.close()

    def _set_tool_holder_assignment(self, tool_num, holder_type):
        holder_type = self._normalize_text(holder_type)
        old_holder_type = self._get_tool_holder_assignment(tool_num)
        if old_holder_type == holder_type:
            return
        conn = self._db_connect()
        try:
            c = conn.cursor()
            now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            if holder_type:
                c.execute("INSERT OR REPLACE INTO tool_holder_assignment (tool, holder_type, updated_at) VALUES (?, ?, ?)", (int(tool_num), holder_type, now_text))
            else:
                c.execute("DELETE FROM tool_holder_assignment WHERE tool = ?", (int(tool_num),))
            c.execute("INSERT INTO tool_event_log (tool, logged_at, event_text, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)", (int(tool_num), now_text, "Tool holder assignment changed", "holder_type", old_holder_type, holder_type))
            conn.commit()
        finally:
            conn.close()
        self.tool_holder_assignment_cache[int(tool_num)] = holder_type
        try:
            self.ui.tool_treeview.queue_draw()
        except:
            pass
        try:
            self.ui.gremlin.cached_tool = None
            self.ui.gremlin.queue_draw()
        except:
            pass

    def _get_tool_model_assignment(self, tool_num):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute("SELECT model_type FROM tool_model_assignment WHERE tool = ?", (int(tool_num),))
            row = c.fetchone()
            model_type = "" if row is None else self._normalize_text(row[0]).lower()
            return TOOL_MODEL_ALIASES.get(model_type, model_type)
        finally:
            conn.close()

    def _get_tool_flute_count(self, tool_num):
        count = 4
        conn = self._db_connect()
        try:
            c = conn.cursor()
            try:
                c.execute("SELECT flute_count FROM tool_model_assignment WHERE tool = ?", (int(tool_num),))
                row = c.fetchone()
                if row is not None and row[0] is not None:
                    count = int(row[0])
            except:
                count = 4
        finally:
            conn.close()
        return max(1, min(6, int(count)))

    def _set_tool_model_assignment(self, tool_num, model_type):
        model_type = self._normalize_text(model_type).lower()
        model_type = TOOL_MODEL_ALIASES.get(model_type, model_type)
        allowed = set([item[0] for item in TOOL_MODEL_CHOICES])
        if model_type not in allowed:
            model_type = ""
        old_model_type = self._get_tool_model_assignment(tool_num)
        if old_model_type == model_type:
            return
        flute_count = self._get_tool_flute_count(tool_num)
        conn = self._db_connect()
        try:
            c = conn.cursor()
            now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            c.execute(
                "INSERT OR REPLACE INTO tool_model_assignment (tool, model_type, flute_count, updated_at) VALUES (?, ?, ?, ?)",
                (int(tool_num), model_type, int(flute_count), now_text))
            c.execute(
                "INSERT INTO tool_event_log (tool, logged_at, event_text, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)",
                (int(tool_num), now_text, "Gremlin tool model assignment changed", "tool_model", old_model_type, model_type))
            conn.commit()
        finally:
            conn.close()
        self.tool_model_assignment_cache[int(tool_num)] = model_type
        self.tool_flute_count_cache[int(tool_num)] = int(flute_count)
        try:
            self.ui.tool_treeview.queue_draw()
        except:
            pass
        try:
            self.ui.gremlin.cached_tool = None
            self.ui.gremlin.queue_draw()
        except:
            pass

    def _set_tool_flute_count(self, tool_num, flute_count):
        try:
            flute_count = int(flute_count)
        except:
            flute_count = 4
        flute_count = max(1, min(6, flute_count))
        old_count = self._get_tool_flute_count(tool_num)
        if old_count == flute_count:
            return
        model_type = self._get_tool_model_assignment(tool_num)
        conn = self._db_connect()
        try:
            c = conn.cursor()
            now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            c.execute(
                "INSERT OR REPLACE INTO tool_model_assignment (tool, model_type, flute_count, updated_at) VALUES (?, ?, ?, ?)",
                (int(tool_num), model_type, int(flute_count), now_text))
            c.execute(
                "INSERT INTO tool_event_log (tool, logged_at, event_text, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)",
                (int(tool_num), now_text, "Gremlin flute count changed", "flute_count", str(old_count), str(flute_count)))
            conn.commit()
        finally:
            conn.close()
        self.tool_flute_count_cache[int(tool_num)] = int(flute_count)
        self.tool_model_assignment_cache[int(tool_num)] = model_type
        try:
            self.ui.tool_treeview.queue_draw()
        except:
            pass
        try:
            self.ui.gremlin.cached_tool = None
            self.ui.gremlin.queue_draw()
        except:
            pass

    def _tool_model_display_name(self, model_type):
        model_type = self._normalize_text(model_type).lower()
        model_type = TOOL_MODEL_ALIASES.get(model_type, model_type)
        for item_type, display_name in TOOL_MODEL_CHOICES:
            if item_type == model_type:
                return display_name
        return "End Mill"

    def _holder_display_name(self, holder_type, default_holder_type, holder_rows):
        holder_type = self._normalize_text(holder_type)
        effective = holder_type or default_holder_type
        for item_type, display_name, image_path in holder_rows:
            if item_type == effective:
                return display_name
        return effective

    def _holder_visual_warning_text(self):
        return (
            "<b>Holder Visualization Warning</b>\n\n"
            "Holder models and thumbnails are visual aids only. They are generated from user-entered dimensions and may be incomplete, inaccurate, or different from the physical holder installed in the machine.\n\n"
            "<b>These visuals do not change PathPilot or LinuxCNC tool length, diameter, work offsets, or any other machine-control offset.</b> Assigning or editing a holder only changes the visualization used by Gremlin and Tool Life Manager.\n\n"
            "Do not use the rendered holder as a collision-checking, clearance, setup, or safety authority. Always verify the actual holder, cutter, stickout, offsets, workholding, machine travel, and clearances before machining.\n\n"
            "Use of this visualization is at your own risk. The author is not responsible for crashes, collisions, damaged tools, damaged work, machine damage, injury, or other losses caused by relying on the visualization.")

    def _show_holder_visual_warning(self, button=None):
        self._show_scrollable_help_dialog("Holder Visualization - Help / Warning", self._holder_visual_warning_text())

    def _show_large_holder_preview(self, widget, event, image_path, holder_name, parent_window=None):
        if not image_path or not os.path.isfile(image_path):
            self._show_reminder_info_dialog("No generated holder image is available.")
            return False
        try:
            dialog = gtk.Dialog("Holder Preview - %s" % holder_name, parent_window, gtk.DIALOG_DESTROY_WITH_PARENT, ())
            dialog.set_modal(False)
            dialog.set_default_size(360, 500)
            box = gtk.VBox(False, 8)
            box.set_border_width(12)
            dialog.vbox.pack_start(box, True, True, 0)
            image = gtk.Image()
            pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(image_path, 300, 400)
            image.set_from_pixbuf(pixbuf)
            box.pack_start(image, True, True, 0)
            note = gtk.Label("Generated from the configured holder dimensions.")
            note.set_alignment(0.5, 0.5)
            box.pack_start(note, False, False, 0)
            close_button = gtk.Button("Close")
            close_button.connect("clicked", lambda button: dialog.destroy())
            button_row = gtk.HBox(False, 6)
            button_row.pack_end(close_button, False, False, 0)
            box.pack_start(button_row, False, False, 0)
            dialog.show_all()
            try:
                dialog.present()
                dialog.window.raise_()
            except:
                pass
        except Exception as e:
            self._write_status("[Tool Life Column] Holder preview failed: {}".format(str(e)))
        return False

    def _launch_holder_builder(self, button=None, on_exit=None):
        if not os.path.isfile(HOLDER_BUILDER_PATH):
            self._show_reminder_info_dialog("Holder Builder was not found at:\n\n%s" % HOLDER_BUILDER_PATH)
            return
        try:
            process = subprocess.Popen([sys.executable, HOLDER_BUILDER_PATH, GREMLIN_CONFIG_PATH])
            self._write_status("[Tool Life Column] Holder Builder launched.")
            # Do not block PathPilot's GTK loop waiting for the standalone Tk builder.
            # Poll the child cheaply; once it exits, let the still-open TLM dialog
            # rebuild only its holder/tool-model card from the just-saved config.
            if callable(on_exit):

                def poll_holder_builder():
                    try:
                        if process.poll() is None:
                            return True
                    except Exception as e:
                        self._write_status("[Tool Life Column] Holder Builder process check failed: {}".format(str(e)))
                        return False
                    try:
                        self._refresh_tool_table_choice_models()
                        on_exit()
                        self._write_status("[Tool Life Column] Holder Builder closed; holder catalog refreshed.")
                    except Exception as e:
                        self._write_status(
                            "[Tool Life Column] Holder catalog refresh after Builder failed: {}".format(str(e)))
                    return False

                glib.timeout_add(250, poll_holder_builder)
        except Exception as e:
            self._show_reminder_info_dialog("Could not launch Holder Builder:\n\n%s" % str(e))

    def _get_tool_thumbnail_cache_path(self, tool_num, model_type="standard"):
        # Do not reuse one filename while the ComboBox is being changed. Some
        # GTK2/gdk-pixbuf SVG stacks can hand back stale pixels when a pathname
        # is rewritten repeatedly in a short interval. A per-render serial makes
        # every load unambiguously new; the caller removes the file after load.
        try:
            tool_num = int(tool_num)
        except:
            tool_num = 0
        model_type = self._normalize_text(model_type).lower() or "standard"
        safe_model = "".join([ch if ch.isalnum() else "_" for ch in model_type])
        serial = int(getattr(self, "_tool_thumbnail_render_serial", 0) or 0) + 1
        self._tool_thumbnail_render_serial = serial
        return os.path.join(
            "/tmp",
            "tlm_tool_model_thumb_T%s_%s_%s_%06d.svg" % (
                tool_num, os.getpid(), safe_model, serial))

    def _write_tool_model_thumbnail_svg(self, tool_num, model_type, flute_count):
        model_type = self._normalize_text(model_type).lower()
        model_type = TOOL_MODEL_ALIASES.get(model_type, model_type)
        if int(tool_num) == 99:
            model_type = "probe"
        if not model_type:
            model_type = "standard"
        width = 72
        height = 135
        fill = "#c7c7c7"
        stroke = "#202020"
        stroke_width = 1.0
        pieces = [
            '<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">' % (width, height, width, height),
            '<rect x="0.5" y="0.5" width="%d" height="%d" fill="#ffffff" stroke="#d0d0d0" stroke-width="1"/>' % (width - 1, height - 1),
            '<g fill="%s" stroke="%s" stroke-width="%.1f" stroke-linejoin="round">' % (fill, stroke, stroke_width),]

        def num(shape, key, default=0.0):
            try:
                return float(shape.get(key, default))
            except Exception:
                return float(default)

        def add_rect(shape):
            x = num(shape, 'x')
            y = num(shape, 'y')
            w = max(0.0, num(shape, 'width'))
            h = max(0.0, num(shape, 'height'))
            pieces.append('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f"/>' % (x, y, w, h))

        def add_triangle_down(shape):
            x = num(shape, 'x')
            y = num(shape, 'y')
            w = max(0.0, num(shape, 'width'))
            h = max(0.0, num(shape, 'height'))
            pieces.append('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"/>' % (x, y, x + w, y, x + (w / 2.0), y + h))

        def add_semicircle_bottom(shape):
            x = num(shape, 'x')
            y = num(shape, 'y')
            w = max(0.0, num(shape, 'width'))
            h = max(0.0, num(shape, 'height'))
            if w <= 0.0 or h <= 0.0:
                return
            # Two cubic quarter-arcs form a deterministic lower half ellipse.
            # This avoids the old SVG arc whose start/end geometry collapsed.
            rx = w / 2.0
            ry = h
            k = 0.5522847498
            cx = x + rx
            x2 = x + w
            yb = y + ry
            pieces.append(
                '<path d="M %.3f %.3f '
                'C %.3f %.3f %.3f %.3f %.3f %.3f '
                'C %.3f %.3f %.3f %.3f %.3f %.3f Z"/>' % (
                    x, y,
                    x, y + k * ry, cx - k * rx, yb, cx, yb,
                    cx + k * rx, yb, x2, y + k * ry, x2, y))

        def add_rounded_rect(shape):
            x = num(shape, 'x')
            y = num(shape, 'y')
            w = max(0.0, num(shape, 'width'))
            h = max(0.0, num(shape, 'height'))
            r = max(0.0, min(num(shape, 'radius', 3.0), w / 2.0, h / 2.0))
            pieces.append('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="%.2f" ry="%.2f"/>' % (x, y, w, h, r, r))

        def add_bull_nose_bottom(shape):
            x = num(shape, 'x')
            y = num(shape, 'y')
            w = max(0.0, num(shape, 'width'))
            h = max(0.0, num(shape, 'height'))
            r = max(0.0, min(num(shape, 'radius', h), h, w / 2.0))
            if w <= 0.0 or h <= 0.0:
                return
            k = 0.5522847498
            pieces.append(
                '<path d="M %.3f %.3f L %.3f %.3f '
                'C %.3f %.3f %.3f %.3f %.3f %.3f '
                'L %.3f %.3f '
                'C %.3f %.3f %.3f %.3f %.3f %.3f Z"/>' % (
                    x, y, x + w, y,
                    x + w, y + (k * r), x + w - (k * r), y + r, x + w - r, y + r,
                    x + r, y + r,
                    x + (k * r), y + r, x, y + (k * r), x, y))

        def add_trapezoid(shape):
            x = num(shape, 'x')
            y = num(shape, 'y')
            wt = max(0.0, num(shape, 'width_top'))
            wb = max(0.0, num(shape, 'width_bottom'))
            h = max(0.0, num(shape, 'height'))
            maxw = max(wt, wb)
            cx = x + (maxw / 2.0)
            pieces.append('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f %.2f,%.2f"/>' % (
                cx - wt / 2.0, y, cx + wt / 2.0, y,
                cx + wb / 2.0, y + h, cx - wb / 2.0, y + h))

        def add_line(shape):
            pieces.append('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" fill="none" stroke="#505050" stroke-width="1.0"/>' % (
                num(shape, 'x1'), num(shape, 'y1'), num(shape, 'x2'), num(shape, 'y2')))

        if model_type == "probe":
            pieces.append('<rect x="31.0" y="10.0" width="10.0" height="76.0"/>')
            pieces.append('</g>')
            pieces.append('<circle cx="36" cy="96" r="10" fill="#b02040" stroke="#202020" stroke-width="1"/>')
            pieces.append('<circle cx="36" cy="96" r="6" fill="#d86484" stroke="#202020" stroke-width="1"/>')
        else:
            shapes = TOOL_THUMBNAIL_EXAMPLES.get(model_type) or TOOL_THUMBNAIL_EXAMPLES.get('standard') or []
            rendered = 0
            for shape in shapes:
                if not isinstance(shape, dict):
                    continue
                shape_type = self._normalize_text(shape.get('shape', '')).lower()
                if shape_type == 'rect':
                    add_rect(shape)
                    rendered += 1
                elif shape_type == 'triangle_down':
                    add_triangle_down(shape)
                    rendered += 1
                elif shape_type == 'semicircle_bottom':
                    add_semicircle_bottom(shape)
                    rendered += 1
                elif shape_type == 'rounded_rect':
                    add_rounded_rect(shape)
                    rendered += 1
                elif shape_type == 'bull_nose_bottom':
                    add_bull_nose_bottom(shape)
                    rendered += 1
                elif shape_type == 'trapezoid':
                    add_trapezoid(shape)
                    rendered += 1
                elif shape_type == 'line':
                    add_line(shape)
                    rendered += 1
            if rendered <= 0:
                fallback_shapes = TOOL_THUMBNAIL_EXAMPLES.get(model_type) or TOOL_THUMBNAIL_EXAMPLES['standard']
                for shape in fallback_shapes:
                    shape_type = self._normalize_text(shape.get('shape', '')).lower()
                    if shape_type == 'rect':
                        add_rect(shape)
                    elif shape_type == 'triangle_down':
                        add_triangle_down(shape)
                    elif shape_type == 'semicircle_bottom':
                        add_semicircle_bottom(shape)
                    elif shape_type == 'rounded_rect':
                        add_rounded_rect(shape)
                    elif shape_type == 'bull_nose_bottom':
                        add_bull_nose_bottom(shape)
                    elif shape_type == 'trapezoid':
                        add_trapezoid(shape)
                    elif shape_type == 'line':
                        add_line(shape)
            pieces.append('</g>')
        pieces.append('</svg>')
        thumb_path = self._get_tool_thumbnail_cache_path(tool_num, model_type)
        handle = open(thumb_path, 'w')
        try:
            handle.write(''.join(pieces))
        finally:
            handle.close()
        return thumb_path

    def _tool_diagram_svg_escape(self, value):
        try:
            text = unicode(value)
        except Exception:
            try:
                text = str(value)
            except Exception:
                text = ""
        return (text.replace(u"&", u"&amp;").replace(u"<", u"&lt;").replace(u">", u"&gt;").replace(u'"', u"&quot;"))

    def _tool_diagram_units(self):
        metric = bool(self._tool_diameter_is_metric())
        return metric, ("mm" if metric else "in")

    def _tool_diagram_format_length(self, inches, metric=None):
        if metric is None:
            metric = bool(self._tool_diameter_is_metric())
        value = float(inches) * 25.4 if metric else float(inches)
        return ("%.3f mm" % value) if metric else ("%.4f in" % value)

    def _tool_diagram_row_value_inches(self, raw_value, metric):
        try:
            value = float(raw_value)
        except Exception:
            return 0.0
        return (value / 25.4) if metric else value

    def _tool_diagram_load_config(self):
        handle = open(GREMLIN_CONFIG_PATH, "r")
        try:
            config = json.load(handle)
        finally:
            handle.close()
        if not isinstance(config, dict):
            raise ValueError("Gremlin holder configuration root is not a JSON object.")
        holder_types = config.get("holder_types")
        if not isinstance(holder_types, dict) or not holder_types:
            raise ValueError("Gremlin holder configuration has no holder_types catalog.")
        return config

    def _tool_diagram_holder(self, config, tool_num, holder_type_override=None):
        holder_types = config.get("holder_types", {})
        default_holder_type = self._normalize_text(config.get("default_holder_type", ""))
        holder_type = self._normalize_text(holder_type_override)
        if not holder_type:
            holder_type = self._get_tool_holder_assignment(tool_num)
        effective = holder_type or default_holder_type
        holder = holder_types.get(effective)
        if not isinstance(holder, dict):
            holder = holder_types.get(default_holder_type)
            effective = default_holder_type
        if not isinstance(holder, dict):
            raise ValueError("Assigned holder '%s' is not present in the holder configuration." % effective)
        sections = holder.get("sections")
        if not isinstance(sections, list) or not sections:
            raise ValueError("Holder '%s' has no section geometry." % effective)
        return effective, holder

    def _tool_diagram_model_params(self, config, tool_num, model_type):
        params = {}
        dimensions = config.get("tool_model_dimensions", {})
        if isinstance(dimensions, dict):
            defaults = dimensions.get(model_type, {})
            if isinstance(defaults, dict):
                params.update(defaults)
        overrides = config.get("tool_model_overrides", {})
        if isinstance(overrides, dict):
            key = str(int(tool_num))
            override = overrides.get(key)
            if override is None:
                override = overrides.get("T%s" % key)
            if isinstance(override, dict):
                params.update(override)
        return params

    def _tool_diagram_geometry(self, tool_num, holder_type_override=None,
                               model_type_override=None, flute_count_override=None):
        config = self._tool_diagram_load_config()
        holder_type, holder = self._tool_diagram_holder(config, tool_num, holder_type_override)
        sections = holder.get("sections", [])
        reference_index = None
        fixed_holder_length = 0.0
        total_holder_height = 0.0
        max_holder_diameter = 0.0
        for index, section in enumerate(sections):
            if not isinstance(section, dict):
                continue
            try:
                height = max(0.0, float(section.get("height_inches", 0.0)))
            except Exception:
                height = 0.0
            try:
                bottom_d = abs(float(section.get("bottom_diameter_inches", 0.0)))
            except Exception:
                bottom_d = 0.0
            try:
                top_d = abs(float(section.get("top_diameter_inches", bottom_d)))
            except Exception:
                top_d = bottom_d
            total_holder_height += height
            max_holder_diameter = max(max_holder_diameter, bottom_d, top_d)
            if reference_index is None:
                fixed_holder_length += height
                if bool(section.get("tool_length_reference", False)):
                    reference_index = index
        if reference_index is None:
            raise ValueError(
                "Holder '%s' has no tool-length reference section.\n\n"
                "Open Add / Edit Holders and mark exactly one section as the spindle-face reference."
                % self._normalize_text(holder.get("display_name", holder_type)))
        row = self._get_current_tool_row(tool_num)
        metric, unit_text = self._tool_diagram_units()
        diameter_inches = abs(self._tool_diagram_row_value_inches(row.get("diameter", 0.0), metric))
        tool_length_inches = abs(self._tool_diagram_row_value_inches(row.get("length", 0.0), metric))
        minimum_stickout = 0.05
        default_stickout = 1.50
        try:
            minimum_stickout = float(config.get("minimum_stickout_inches", minimum_stickout))
        except Exception:
            minimum_stickout = 0.05
        try:
            default_stickout = float(config.get("default_stickout_inches", default_stickout))
        except Exception:
            default_stickout = 1.50
        if minimum_stickout <= 0.0:
            minimum_stickout = 0.05
        if default_stickout <= 0.0:
            default_stickout = 1.50
        stickout = default_stickout
        stickout_source = "Configured default"
        mode = self._normalize_text(config.get("stickout_mode", "tool_table_length"))
        if mode == "tool_table_length" and tool_length_inches > 0.0 and fixed_holder_length > 0.0:
            derived = tool_length_inches - fixed_holder_length
            if derived >= minimum_stickout:
                stickout = derived
                stickout_source = "PathPilot tool length"
        overrides = config.get("tool_stickout_inches", {})
        if isinstance(overrides, dict):
            tool_key = str(int(tool_num))
            override_value = None
            if tool_key in overrides:
                override_value = overrides.get(tool_key)
            elif ("T%s" % tool_key) in overrides:
                override_value = overrides.get("T%s" % tool_key)
            if override_value is not None:
                try:
                    stickout = float(override_value)
                    stickout_source = "T%s stickout override" % tool_key
                except Exception:
                    pass
        if stickout < minimum_stickout:
            stickout = default_stickout
            stickout_source = "Configured default"
        modeled_oal = fixed_holder_length + stickout
        model_type = self._normalize_text(model_type_override).lower()
        if not model_type:
            model_type = self._get_tool_model_assignment(tool_num)
        model_type = TOOL_MODEL_ALIASES.get(model_type, model_type)
        if not model_type:
            model_type = "standard"
        allowed_models = set([item[0] or "standard" for item in TOOL_MODEL_CHOICES])
        if model_type not in allowed_models and model_type != "probe":
            model_type = "standard"
        if int(tool_num) == 99:
            model_type = "probe"
        if flute_count_override is None:
            flute_count = self._get_tool_flute_count(tool_num)
        else:
            try:
                flute_count = int(flute_count_override)
            except Exception:
                flute_count = self._get_tool_flute_count(tool_num)
        flute_count = max(1, min(6, int(flute_count)))
        params = self._tool_diagram_model_params(config, tool_num, model_type)
        display_model_type = "" if model_type == "standard" else model_type
        return {
            "config": config,
            "row": row,
            "metric": metric,
            "unit_text": unit_text,
            "holder_type": holder_type,
            "holder": holder,
            "sections": sections,
            "reference_index": reference_index,
            "fixed_holder_length_inches": fixed_holder_length,
            "total_holder_height_inches": total_holder_height,
            "max_holder_diameter_inches": max_holder_diameter,
            "diameter_inches": diameter_inches,
            "pathpilot_length_inches": tool_length_inches,
            "stickout_inches": stickout,
            "stickout_source": stickout_source,
            "modeled_oal_inches": modeled_oal,
            "model_type": model_type,
            "model_display_name": ("Probe" if model_type == "probe" else self._tool_model_display_name(display_model_type)),
            "flute_count": flute_count,
            "model_params": params,}

    def _tool_diagram_profile_points(self, model_type, diameter, stickout, params):
        """Return the Gremlin cutter side profile as (z_inches, radius_inches).
        The formulas intentionally mirror the corresponding Gremlin model
        builders so the 2D diagram and the 3D visualization describe the same
        cutter envelope.
        """
        radius = max(0.0001, abs(float(diameter)) * 0.5)
        stickout = max(0.0001, float(stickout))
        model_type = self._normalize_text(model_type).lower() or "standard"
        points = []

        def add(z_value, r_value):
            z_value = max(0.0, min(stickout, float(z_value)))
            r_value = max(0.0, abs(float(r_value)))
            if points and abs(points[-1][0] - z_value) < 0.0000001 and abs(points[-1][1] - r_value) < 0.0000001:
                return
            points.append((z_value, r_value))

        if model_type == "probe":
            ball_r = radius
            ball_h = min(stickout, 2.0 * ball_r)
            segments = 16
            for index in range(segments + 1):
                z = ball_h * (float(index) / float(segments))
                if z <= 2.0 * ball_r:
                    rr = math.sqrt(max(0.0, (ball_r * ball_r) - ((z - ball_r) * (z - ball_r))))
                else:
                    rr = ball_r * 0.66
                add(z, max(rr, ball_r * 0.08))
            if stickout > ball_h:
                add(ball_h, ball_r * 0.66)
                add(stickout, ball_r * 0.66)
            return points
        if model_type == "ball_mill":
            ball_height = min(radius, stickout)
            segments = 16
            for index in range(segments + 1):
                z = ball_height * (float(index) / float(segments))
                rr = math.sqrt(max(0.0, (radius * radius) - ((z - radius) * (z - radius))))
                add(z, max(rr, radius * 0.01))
            if stickout > ball_height:
                add(stickout, radius)
            return points
        if model_type == "bull_nose_end_mill":
            try:
                corner_radius = abs(float(params.get("corner_radius_inches", 0.0625)))
            except Exception:
                corner_radius = 0.0625
            max_corner = min(radius * 0.95, stickout * 0.45)
            corner_radius = min(corner_radius, max_corner)
            if corner_radius <= 0.0001:
                return [(0.0, radius), (stickout, radius)]
            inner_radius = max(0.00005, radius - corner_radius)
            segments = 12
            for index in range(segments + 1):
                z = corner_radius * (float(index) / float(segments))
                rr = inner_radius + math.sqrt(max(0.0, (corner_radius * corner_radius) - ((z - corner_radius) * (z - corner_radius))))
                add(z, rr)
            if stickout > corner_radius:
                add(stickout, radius)
            return points
        if model_type in ("chamfer_90", "chamfer_45", "drill_bit"):
            if model_type == "chamfer_90":
                angle = 90.0
            elif model_type == "chamfer_45":
                angle = 45.0
            else:
                try:
                    angle = float(params.get("included_angle_degrees", 118.0))
                except Exception:
                    angle = 118.0
            try:
                tip_diameter = abs(float(params.get("tip_diameter_inches", 0.0)))
            except Exception:
                tip_diameter = 0.0
            angle = max(5.0, min(175.0, angle))
            tip_r = min(radius * 0.95, tip_diameter * 0.5)
            tangent = math.tan(math.radians(angle * 0.5))
            if tangent <= 0.0001:
                tangent = 1.0
            full_cone_height = max(0.001, (radius - tip_r) / tangent)
            cone_height = min(stickout, full_cone_height)
            top_r = tip_r + ((radius - tip_r) * (cone_height / full_cone_height))
            add(0.0, tip_r)
            add(cone_height, top_r)
            if stickout > cone_height:
                add(cone_height, radius)
                add(stickout, radius)
            return points
        if model_type in ("key_cutter", "key_cutter_rounded"):
            try:
                head_thickness = abs(float(params.get("head_thickness_inches", 0.125)))
            except Exception:
                head_thickness = 0.125
            try:
                neck_radius = abs(float(params.get("neck_diameter_inches", 0.250))) * 0.5
            except Exception:
                neck_radius = 0.125
            try:
                neck_length = abs(float(params.get("neck_length_inches", 0.500)))
            except Exception:
                neck_length = 0.500
            try:
                shank_radius = abs(float(params.get("shank_diameter_inches", 0.375))) * 0.5
            except Exception:
                shank_radius = 0.1875
            head_h = min(stickout, max(0.001, head_thickness))
            if model_type == "key_cutter_rounded":
                try:
                    edge_radius = abs(float(params.get("edge_radius_inches", 0.03125)))
                except Exception:
                    edge_radius = 0.03125
                edge_radius = min(edge_radius, radius * 0.45, head_h * 0.45)
                core_radius = max(0.001, radius - edge_radius)
                if edge_radius > 0.0001:
                    segments = 8
                    for index in range(segments + 1):
                        z = edge_radius * (float(index) / float(segments))
                        rr = core_radius + math.sqrt(max(0.0, (edge_radius * edge_radius) - ((z - edge_radius) * (z - edge_radius))))
                        add(z, rr)
                    top_base = head_h - edge_radius
                    if top_base > edge_radius:
                        add(top_base, radius)
                    for index in range(1, segments + 1):
                        t = edge_radius * (float(index) / float(segments))
                        z = top_base + t
                        rr = core_radius + math.sqrt(max(0.0, (edge_radius * edge_radius) - (t * t)))
                        add(z, rr)
                else:
                    add(0.0, radius)
                    add(head_h, radius)
            else:
                add(0.0, radius)
                add(head_h, radius)
            remaining = max(0.0, stickout - head_h)
            neck_h = min(remaining, neck_length)
            if neck_h > 0.0:
                add(head_h, neck_radius)
                add(head_h + neck_h, neck_radius)
            remaining -= neck_h
            if remaining > 0.0:
                add(head_h + neck_h, shank_radius)
                add(stickout, shank_radius)
            elif points[-1][0] < stickout:
                add(stickout, neck_radius)
            return points
        if model_type in ("reamer_straight", "reamer_spiral"):
            try:
                cutting_length = abs(float(params.get("cutting_length_inches", 0.750)))
            except Exception:
                cutting_length = 0.750
            try:
                shank_ratio = abs(float(params.get("shank_diameter_ratio", 0.70)))
            except Exception:
                shank_ratio = 0.70
            cutting_length = max(0.01, cutting_length)
            cutting_h = min(stickout, cutting_length)
            shank_radius = radius * shank_ratio
            add(0.0, radius)
            add(cutting_h, radius)
            if stickout > cutting_h:
                add(cutting_h, shank_radius)
                add(stickout, shank_radius)
            return points
        if model_type in ("thread_mill", "thread_mill_0250", "thread_mill_01875"):
            try:
                angle = float(params.get("included_angle_degrees", 60.0))
            except Exception:
                angle = 60.0
            try:
                root_radius = abs(float(params.get("neck_diameter_inches", 0.250))) * 0.5
            except Exception:
                root_radius = 0.125
            try:
                neck_length = abs(float(params.get("neck_length_inches", 0.500)))
            except Exception:
                neck_length = 0.500
            try:
                shank_radius = abs(float(params.get("shank_diameter_inches", 0.375))) * 0.5
            except Exception:
                shank_radius = 0.1875
            angle = max(10.0, min(170.0, angle))
            radial_depth = max(0.001, radius - root_radius)
            full_head_h = max(0.002, 2.0 * radial_depth * math.tan(math.radians(angle * 0.5)))
            head_h = min(stickout, full_head_h)
            half_h = head_h * 0.5
            add(0.0, root_radius)
            add(half_h, radius)
            add(head_h, root_radius)
            remaining = max(0.0, stickout - head_h)
            neck_h = min(remaining, neck_length)
            if neck_h > 0.0:
                add(head_h + neck_h, root_radius)
            remaining -= neck_h
            if remaining > 0.0:
                add(head_h + neck_h, shank_radius)
                add(stickout, shank_radius)
            elif points[-1][0] < stickout:
                add(stickout, root_radius)
            return points
        if model_type == "dovetail":
            try:
                angle = float(params.get("included_angle_degrees", 60.0))
            except Exception:
                angle = 60.0
            try:
                neck_radius = abs(float(params.get("neck_diameter_inches", 0.250))) * 0.5
            except Exception:
                neck_radius = 0.125
            try:
                neck_length = abs(float(params.get("neck_length_inches", 0.250)))
            except Exception:
                neck_length = 0.250
            try:
                shank_radius = abs(float(params.get("shank_diameter_inches", 0.375))) * 0.5
            except Exception:
                shank_radius = 0.1875
            angle = max(10.0, min(170.0, angle))
            tangent = math.tan(math.radians(angle * 0.5))
            if tangent <= 0.0001:
                tangent = 1.0
            full_head_h = max(0.001, (radius - neck_radius) / tangent)
            head_h = min(stickout, full_head_h)
            top_radius = max(neck_radius, radius - (tangent * head_h))
            add(0.0, radius)
            add(head_h, top_radius)
            remaining = max(0.0, stickout - head_h)
            neck_h = min(remaining, neck_length)
            if neck_h > 0.0:
                add(head_h, neck_radius)
                add(head_h + neck_h, neck_radius)
            remaining -= neck_h
            if remaining > 0.0:
                add(head_h + neck_h, shank_radius)
                add(stickout, shank_radius)
            elif points[-1][0] < stickout:
                add(stickout, neck_radius)
            return points
        return [(0.0, radius), (stickout, radius)]

    def _get_tool_diagram_cache_path(self, tool_num, extension="svg"):
        try:
            tool_num = int(tool_num)
        except Exception:
            tool_num = 0
        serial = int(getattr(self, "_tool_diagram_render_serial", 0) or 0) + 1
        self._tool_diagram_render_serial = serial
        extension = self._normalize_text(extension).lower().lstrip(".") or "svg"
        return os.path.join("/tmp", "tlm_full_tool_diagram_T%d_%d_%06d.%s" % (tool_num, os.getpid(), serial, extension))

    def _write_tool_assembly_diagram_svg(self, tool_num, holder_type_override=None,
                                         model_type_override=None, flute_count_override=None,
                                         output_path=None):
        geo = self._tool_diagram_geometry(tool_num, holder_type_override, model_type_override, flute_count_override)
        if geo["diameter_inches"] <= 0.0000001:
            raise ValueError("T%d has no positive PathPilot tool diameter." % int(tool_num))
        sections = geo["sections"]
        profile = self._tool_diagram_profile_points(
            geo["model_type"],
            geo["diameter_inches"],
            geo["stickout_inches"],
            geo["model_params"])
        if not profile:
            raise ValueError("Could not build cutter profile for T%d." % int(tool_num))
        width = 900.0
        height = 650.0
        center_x = 420.0
        top_margin = 96.0
        bottom_margin = 58.0
        model_height_px = height - top_margin - bottom_margin
        total_assembly_height = geo["stickout_inches"] + geo["total_holder_height_inches"]
        max_tool_diameter = max([2.0 * p[1] for p in profile] or [geo["diameter_inches"]])
        max_diameter = max(geo["max_holder_diameter_inches"], max_tool_diameter, 0.1)
        scale = min(model_height_px / max(total_assembly_height, 0.1), 285.0 / max_diameter)
        baseline_y = height - bottom_margin

        def y_for_z(z_inches):
            return baseline_y - (float(z_inches) * scale)

        def x_left(radius_inches):
            return center_x - (float(radius_inches) * scale)

        def x_right(radius_inches):
            return center_x + (float(radius_inches) * scale)

        metric = geo["metric"]
        unit = geo["unit_text"]
        holder_name = self._normalize_text(geo["holder"].get("display_name", geo["holder_type"]))
        row = geo["row"]
        description = self._normalize_text(row.get("description", "")) or "Tool"
        model_name = geo["model_display_name"]
        title = "T%d - %s" % (int(tool_num), description)
        parts = [
            u'<?xml version="1.0" encoding="UTF-8"?>',
            u'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="650" viewBox="0 0 900 650">',
            u'<defs></defs>',
            u'<rect x="0" y="0" width="900" height="650" fill="#ffffff"/>',
            u'<text x="24" y="31" font-family="Arial,Helvetica,sans-serif" font-size="21" font-weight="bold" fill="#111111">%s</text>' % self._tool_diagram_svg_escape(title),
            u'<text x="24" y="55" font-family="Arial,Helvetica,sans-serif" font-size="13" fill="#333333">Holder: %s   |   Cutter: %s   |   Flutes: %d   |   Units: %s</text>' % (
                self._tool_diagram_svg_escape(holder_name),
                self._tool_diagram_svg_escape(model_name),
                int(geo["flute_count"]),
                self._tool_diagram_svg_escape(unit)),
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#b8b8b8" stroke-width="1" stroke-dasharray="4,4"/>' % (
                center_x, top_margin - 10.0, center_x, baseline_y + 14.0),]
        # Cutter profile, using the same envelope formulas as Gremlin.
        right_points = []
        for z, radius in profile:
            right_points.append((x_right(radius), y_for_z(z)))
        left_points = []
        for z, radius in reversed(profile):
            left_points.append((x_left(radius), y_for_z(z)))
        polygon_points = right_points + left_points
        point_text = u" ".join(u"%.2f,%.2f" % (x, y) for x, y in polygon_points)
        parts.append(
            u'<polygon points="%s" fill="#c6c6c6" stroke="#202020" stroke-width="1.4" stroke-linejoin="round"/>' % point_text)
        # Holder stack starts at the collet / holder interface, i.e. stickout.
        material_fill = {
            "body": "#8a8a8a",
            "nut": "#767676",
            "relief": "#666666",
            "flange": "#909090",
            "shank": "#9a9a9a",}
        z_cursor = geo["stickout_inches"]
        reference_z = None
        widest_section = None
        widest_diameter = -1.0
        for index, section in enumerate(sections):
            if not isinstance(section, dict):
                continue
            try:
                sec_h = max(0.0001, float(section.get("height_inches", 0.0)))
                bottom_d = max(0.0001, abs(float(section.get("bottom_diameter_inches", 0.0))))
                top_d = max(0.0001, abs(float(section.get("top_diameter_inches", bottom_d))))
            except Exception:
                continue
            z0 = z_cursor
            z1 = z_cursor + sec_h
            y0 = y_for_z(z0)
            y1 = y_for_z(z1)
            xb0 = center_x - ((bottom_d * 0.5) * scale)
            xb1 = center_x + ((bottom_d * 0.5) * scale)
            xt0 = center_x - ((top_d * 0.5) * scale)
            xt1 = center_x + ((top_d * 0.5) * scale)
            material = self._normalize_text(section.get("material", "body")).lower()
            fill = material_fill.get(material, material_fill["body"])
            parts.append(
                u'<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="%s" stroke="#171717" stroke-width="1.3" stroke-linejoin="round"/>' %
                (xb0, y0, xb1, y0, xt1, y1, xt0, y1, fill))
            sec_max_d = max(bottom_d, top_d)
            if sec_max_d > widest_diameter:
                widest_diameter = sec_max_d
                widest_section = (z0, z1, sec_max_d)
            if index == geo["reference_index"]:
                reference_z = z1
            z_cursor = z1
        # Nut / holder interface and spindle mating reference. Keep the guide
        # lines long and move their labels well to the right of the assembly so
        # they read as datum annotations instead of crowding the holder profile.
        nut_y = y_for_z(geo["stickout_inches"])
        ref_y = y_for_z(geo["modeled_oal_inches"] if reference_z is None else reference_z)
        model_half_width = (max_diameter * 0.5) * scale
        guide_left_x = max(250.0, center_x - model_half_width - 48.0)
        guide_right_x = 865.0
        guide_label_x = 610.0
        parts.append(
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#2b70b8" stroke-width="1.4" stroke-dasharray="5,4"/>' %
            (guide_left_x, nut_y, guide_right_x, nut_y))
        parts.append(
            u'<text x="%.2f" y="%.2f" font-family="Arial,Helvetica,sans-serif" font-size="11" fill="#2b70b8" text-anchor="start">holder / collet interface</text>' %
            (guide_label_x, nut_y + 15.0))
        parts.append(
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#b00020" stroke-width="1.5" stroke-dasharray="6,4"/>' %
            (guide_left_x, ref_y, guide_right_x, ref_y))
        parts.append(
            u'<text x="%.2f" y="%.2f" font-family="Arial,Helvetica,sans-serif" font-size="11" fill="#b00020" text-anchor="start">spindle mating / tool-length reference plane</text>' %
            (guide_label_x, ref_y - 9.0))
        # Cutter diameter callout. Keep it to the left of the cutter so the main
        # profile stays unobstructed. The label follows PathPilot display units.
        cutter_mid_z = max(0.02, min(geo["stickout_inches"] * 0.42, geo["stickout_inches"] - 0.02))
        cutter_mid_y = y_for_z(cutter_mid_z)
        cutter_radius_at_mid = geo["diameter_inches"] * 0.5
        try:
            # Use the nearest profile point so necked/special cutters get a leader
            # aimed at their actual envelope instead of always at nominal OD.
            nearest = min(profile, key=lambda p: abs(float(p[0]) - cutter_mid_z))
            cutter_radius_at_mid = max(0.0001, float(nearest[1]))
        except Exception:
            pass
        cutter_left_x = x_left(cutter_radius_at_mid)
        diameter_label_x = max(232.0, cutter_left_x - 72.0)
        parts.append(
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#666666" stroke-width="1"/>' %
            (diameter_label_x + 8.0, cutter_mid_y, cutter_left_x - 5.0, cutter_mid_y))
        parts.append(
            u'<text x="%.2f" y="%.2f" text-anchor="end" font-family="Arial,Helvetica,sans-serif" font-size="14" font-weight="bold" fill="#111111">Dia. %s</text>' %
            (diameter_label_x, cutter_mid_y + 5.0,
             self._tool_diagram_svg_escape(self._tool_diagram_format_length(geo["diameter_inches"], metric))))
        # Vertical dimensions. OAL is reference plane to cutting tip. Stickout is
        # holder / collet interface to cutting tip. Explicit triangle glyphs are
        # used instead of SVG marker orientation so both ends always point inward
        # toward one another under PathPilot's older librsvg renderer.
        tip_y = y_for_z(0.0)
        oal_x = 160.0
        stickout_x = 800.0
        arrow_half_w = 5.0
        arrow_h = 9.0
        parts.extend([
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#777777" stroke-width="1"/>' %
            (oal_x + 8.0, tip_y, center_x - model_half_width - 8.0, tip_y),
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#777777" stroke-width="1"/>' %
            (oal_x + 8.0, ref_y, center_x - model_half_width - 8.0, ref_y),
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#333333" stroke-width="1.4"/>' %
            (oal_x, tip_y, oal_x, ref_y),
            # Lower arrow points upward; upper arrow points downward.
            u'<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="#333333"/>' %
            (oal_x, tip_y, oal_x - arrow_half_w, tip_y + arrow_h, oal_x + arrow_half_w, tip_y + arrow_h),
            u'<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="#333333"/>' %
            (oal_x, ref_y, oal_x - arrow_half_w, ref_y - arrow_h, oal_x + arrow_half_w, ref_y - arrow_h),
            u'<text x="%.2f" y="%.2f" text-anchor="end" font-family="Arial,Helvetica,sans-serif" font-size="15" font-weight="bold" fill="#111111">OAL  %s</text>' %
            (oal_x - 12.0, ((tip_y + ref_y) * 0.5) + 5.0,
             self._tool_diagram_svg_escape(self._tool_diagram_format_length(geo["modeled_oal_inches"], metric))),
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#777777" stroke-width="1"/>' %
            (center_x + model_half_width + 8.0, tip_y, stickout_x - 8.0, tip_y),
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#777777" stroke-width="1"/>' %
            (center_x + model_half_width + 8.0, nut_y, stickout_x - 8.0, nut_y),
            u'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#333333" stroke-width="1.4"/>' %
            (stickout_x, tip_y, stickout_x, nut_y),
            u'<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="#333333"/>' %
            (stickout_x, tip_y, stickout_x - arrow_half_w, tip_y + arrow_h, stickout_x + arrow_half_w, tip_y + arrow_h),
            u'<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="#333333"/>' %
            (stickout_x, nut_y, stickout_x - arrow_half_w, nut_y - arrow_h, stickout_x + arrow_half_w, nut_y - arrow_h),
            u'<text x="%.2f" y="%.2f" text-anchor="end" font-family="Arial,Helvetica,sans-serif" font-size="15" font-weight="bold" fill="#111111">Stickout  %s</text>' %
            (stickout_x - 12.0, ((tip_y + nut_y) * 0.5) + 5.0,
             self._tool_diagram_svg_escape(self._tool_diagram_format_length(geo["stickout_inches"], metric))),
        ])
        # Secondary dimensions card retained for future use, but hidden by
        # default to keep the primary drawing clean.
        if TOOL_DIAGRAM_SHOW_DIMENSIONS_CARD:
            data_x = 620.0
            data_y = 92.0
            line_h = 18.0
            data_lines = [
                ("Tool diameter", self._tool_diagram_format_length(geo["diameter_inches"], metric)),
                ("Max holder OD", self._tool_diagram_format_length(geo["max_holder_diameter_inches"], metric)),
                ("Fixed holder length", self._tool_diagram_format_length(geo["fixed_holder_length_inches"], metric)),
                ("Stickout source", geo["stickout_source"]),]
            if geo["pathpilot_length_inches"] > 0.0:
                data_lines.insert(0, ("PathPilot Length", self._tool_diagram_format_length(geo["pathpilot_length_inches"], metric)))
            parts.append(
                u'<rect x="%.2f" y="%.2f" width="245" height="%d" rx="5" ry="5" fill="#f6f6f6" stroke="#c6c6c6" stroke-width="1"/>' %
                (data_x - 12.0, data_y - 21.0, int(16 + (len(data_lines) * line_h))))
            for index, (label, value) in enumerate(data_lines):
                yy = data_y + (index * line_h)
                parts.append(
                    u'<text x="%.2f" y="%.2f" font-family="Arial,Helvetica,sans-serif" font-size="11.5" fill="#333333">%s:</text>' %
                    (data_x, yy, self._tool_diagram_svg_escape(label)))
                parts.append(
                    u'<text x="%.2f" y="%.2f" font-family="Arial,Helvetica,sans-serif" font-size="11.5" font-weight="bold" fill="#111111">%s</text>' %
                    (data_x + 105.0, yy, self._tool_diagram_svg_escape(value)))
        # If an explicit stickout override makes the modeled OAL differ from the
        # PathPilot tool-table length, make that discrepancy visible instead of
        # silently presenting two incompatible numbers.
        if geo["pathpilot_length_inches"] > 0.0 and abs(geo["pathpilot_length_inches"] - geo["modeled_oal_inches"]) > 0.0005:
            warning = "Modeled OAL differs from PathPilot Length because %s is active." % geo["stickout_source"]
            parts.append(
                u'<text x="24" y="632" font-family="Arial,Helvetica,sans-serif" font-size="11" fill="#9a4b00">%s</text>' %
                self._tool_diagram_svg_escape(warning))
        parts.append(u'</svg>')
        if not output_path:
            output_path = self._get_tool_diagram_cache_path(tool_num, "svg")
        handle = open(output_path, "wb")
        try:
            payload = u"\n".join(parts) + u"\n"
            try:
                handle.write(payload.encode("utf-8"))
            except Exception:
                handle.write(str(payload))
        finally:
            handle.close()
        return output_path, geo

    def _save_tool_diagram_svg_as(self, source_path, tool_num, parent_window=None):
        chooser = gtk.FileChooserDialog(
            "Save Tool Diagram SVG",
            parent_window,
            gtk.FILE_CHOOSER_ACTION_SAVE,
            (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
             gtk.STOCK_SAVE, gtk.RESPONSE_OK))
        try:
            chooser.set_do_overwrite_confirmation(True)
        except Exception:
            pass
        try:
            chooser.set_current_name("T%d_entire_tool_diagram.svg" % int(tool_num))
        except Exception:
            pass
        try:
            response = chooser.run()
            if response != gtk.RESPONSE_OK:
                return
            target = chooser.get_filename()
            if not target:
                return
            if not str(target).lower().endswith(".svg"):
                target = str(target) + ".svg"
            shutil.copyfile(source_path, target)
            self._write_status("[Tool Life Column] Saved tool diagram: %s" % target)
        finally:
            chooser.destroy()

    def _open_tool_diagram_for_print(self, svg_path, parent_window=None):
        # Keep printing outside PathPilot's GTK process. xdg-open hands the SVG
        # to the system viewer/browser, where normal Print/Save-PDF controls are
        # available without adding printer-driver dependencies to the plugin.
        commands = (
            ("xdg-open", svg_path),
            ("gio", "open", svg_path),)
        last_error = None
        for command in commands:
            try:
                subprocess.Popen(list(command))
                self._write_status("[Tool Life Column] Opened tool diagram for viewing / printing.")
                return
            except Exception as e:
                last_error = e
        self._show_reminder_info_dialog(
            "Could not open the SVG in a system viewer.\n\n"
            "Use Save SVG... and open the saved file manually.\n\n%s" %
            (str(last_error) if last_error is not None else "No viewer was available."))

    def _show_entire_tool_diagram(self, tool_num, holder_type_override=None,
                                  model_type_override=None, flute_count_override=None,
                                  parent_window=None):
        try:
            svg_path, geo = self._write_tool_assembly_diagram_svg(
                tool_num,
                holder_type_override,
                model_type_override,
                flute_count_override)
            dialog = gtk.Dialog(
                "Entire Tool Diagram - T%d" % int(tool_num),
                parent_window,
                gtk.DIALOG_DESTROY_WITH_PARENT,
                ())
            dialog.set_modal(False)
            dialog.set_default_size(920, 690)
            outer = gtk.VBox(False, 8)
            outer.set_border_width(10)
            dialog.vbox.pack_start(outer, True, True, 0)
            scroller = gtk.ScrolledWindow()
            scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
            image = gtk.Image()
            pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(svg_path, 880, 620)
            image.set_from_pixbuf(pixbuf)
            viewport = gtk.Viewport()
            viewport.add(image)
            scroller.add(viewport)
            outer.pack_start(scroller, True, True, 0)
            button_row = gtk.HBox(False, 6)
            save_button = gtk.Button("Save SVG...")
            close_button = gtk.Button("Close")
            save_button.connect("clicked", lambda button: self._save_tool_diagram_svg_as(svg_path, tool_num, dialog))
            close_button.connect("clicked", lambda button: dialog.destroy())
            button_row.pack_start(save_button, False, False, 0)
            button_row.pack_end(close_button, False, False, 0)
            outer.pack_start(button_row, False, False, 0)
            dialog.show_all()
            try:
                dialog.present()
                dialog.window.raise_()
            except Exception:
                pass
            self._write_status(
                "[Tool Life Column] T%d entire-tool diagram generated (%s OAL, %s stickout)." %
                (int(tool_num),
                 self._tool_diagram_format_length(geo["modeled_oal_inches"], geo["metric"]),
                 self._tool_diagram_format_length(geo["stickout_inches"], geo["metric"])))
        except Exception as e:
            self._write_status("[Tool Life Column] Tool diagram failed: %s" % str(e))
            self._show_reminder_info_dialog("Could not build the tool diagram:\n\n%s" % str(e))

    def _build_holder_assignment_row(self, tool_num, refresh_dialog, rebuild_controls=None):
        default_holder_type, holder_rows = self._load_holder_catalog()
        outer = gtk.HBox(False, 10)
        thumbnail_row = gtk.HBox(False, 6)
        # Thumbnail intentionally starts near the card's top-left corner. Keep all
        # interior padding on the controls side instead of around the whole card.
        thumbnail = gtk.Image()
        thumbnail.set_size_request(84, 135)
        image_event = gtk.EventBox()
        image_event.set_visible_window(True)
        image_event.add(thumbnail)
        # Small left-only inset so the thumbnails do not touch the frame edge.
        thumbnail_alignment = gtk.Alignment(0.0, 0.0, 0.0, 0.0)
        thumbnail_alignment.set_padding(0, 0, 6, 0)
        thumbnail_alignment.add(image_event)
        thumbnail_row.pack_start(thumbnail_alignment, False, False, 0)
        tool_thumbnail = gtk.Image()
        tool_thumbnail.set_size_request(72, 135)
        tool_thumbnail_alignment = gtk.Alignment(0.0, 0.0, 0.0, 0.0)
        tool_thumbnail_alignment.add(tool_thumbnail)
        thumbnail_row.pack_start(tool_thumbnail_alignment, False, False, 0)
        outer.pack_start(thumbnail_row, False, False, 0)
        controls = gtk.VBox(False, 5)
        controls.set_border_width(8)
        outer.pack_start(controls, True, True, 0)
        model = gtk.ListStore(str, str)
        model.append(["", self._holder_display_name("", default_holder_type, holder_rows)])
        known_types = set()
        for holder_type, display_name, image_path in holder_rows:
            known_types.add(holder_type)
            model.append([holder_type, display_name])
        assigned_holder = self._get_tool_holder_assignment(tool_num)
        if assigned_holder and assigned_holder not in known_types:
            model.append([assigned_holder, "%s (missing from config)" % assigned_holder])
        combo = gtk.ComboBox(model)
        renderer = gtk.CellRendererText()
        combo.pack_start(renderer, True)
        combo.add_attribute(renderer, "text", 1)
        combo.set_size_request(255, -1)
        active_index = 0
        tree_iter = model.get_iter_first()
        index = 0
        while tree_iter is not None:
            if self._normalize_text(model.get_value(tree_iter, 0)) == assigned_holder:
                active_index = index
                break
            tree_iter = model.iter_next(tree_iter)
            index += 1
        combo.set_active(active_index)
        holder_title = gtk.Label("Holder model")
        holder_title.set_alignment(0.0, 0.5)
        controls.pack_start(holder_title, False, False, 0)
        controls.pack_start(combo, False, False, 0)
        tool_model_store = gtk.ListStore(str, str)
        for model_type, model_name in TOOL_MODEL_CHOICES:
            tool_model_store.append([model_type, model_name])
        assigned_tool_model = self._get_tool_model_assignment(tool_num)
        if int(tool_num) == 99:
            tool_model_store = gtk.ListStore(str, str)
            tool_model_store.append(["", "Probe (fixed OEM model)"])
            assigned_tool_model = ""
        tool_model_combo = gtk.ComboBox(tool_model_store)
        tool_model_renderer = gtk.CellRendererText()
        tool_model_combo.pack_start(tool_model_renderer, True)
        tool_model_combo.add_attribute(tool_model_renderer, "text", 1)
        tool_model_combo.set_size_request(255, -1)
        model_iter = tool_model_store.get_iter_first()
        model_index = 0
        model_active = 0
        while model_iter is not None:
            if self._normalize_text(tool_model_store.get_value(model_iter, 0)).lower() == assigned_tool_model:
                model_active = model_index
                break
            model_iter = tool_model_store.iter_next(model_iter)
            model_index += 1
        tool_model_combo.set_active(model_active)
        if int(tool_num) == 99:
            tool_model_combo.set_sensitive(False)
        controls.pack_start(tool_model_combo, False, False, 4)
        flute_row = gtk.HBox(False, 6)
        flute_label = gtk.Label("Flutes")
        flute_label.set_alignment(0.0, 0.5)
        flute_row.pack_start(flute_label, False, False, 0)
        flute_store = gtk.ListStore(int, str)
        for flute_value in range(1, 7):
            flute_store.append([flute_value, str(flute_value)])
        flute_combo = gtk.ComboBox(flute_store)
        flute_renderer = gtk.CellRendererText()
        flute_combo.pack_start(flute_renderer, True)
        flute_combo.add_attribute(flute_renderer, "text", 1)
        flute_combo.set_size_request(80, -1)
        assigned_flute_count = self._get_tool_flute_count(tool_num)
        flute_combo.set_active(max(0, min(5, int(assigned_flute_count) - 1)))
        if int(tool_num) == 99:
            flute_combo.set_sensitive(False)
        flute_row.pack_start(flute_combo, False, False, 0)
        holder_help_button = gtk.Button("Warning")
        edit_holders_button = gtk.Button("Add / Edit Holders")
        entire_tool_button = gtk.Button("Tool Diagram")
        entire_tool_button.set_tooltip_text(
            "Open a larger dimensioned SVG of the assigned holder and cutter. "
            "OAL is spindle-reference-to-tip; stickout is holder/collet-interface-to-tip.")
        flute_row.pack_start(holder_help_button, False, False, 2)
        flute_row.pack_start(edit_holders_button, False, False, 0)
        flute_row.pack_start(entire_tool_button, False, False, 0)
        controls.pack_start(flute_row, False, False, 4)
        current_preview = {
            "path": "",
            "name": "",}

        def current_flute_count_from_ui():
            try:
                active_iter = flute_combo.get_active_iter()
                if active_iter is not None:
                    return int(flute_store.get_value(active_iter, 0))
            except:
                pass
            return int(assigned_flute_count)

        def update_tool_thumbnail(model_type):
            # Render first and independently from DB persistence. The temporary
            # SVG uses a unique pathname so a stale gdk-pixbuf/rsvg cache cannot
            # make a legitimate ComboBox change appear unresponsive.
            thumb_path = ""
            try:
                thumb_path = self._write_tool_model_thumbnail_svg(tool_num, model_type, current_flute_count_from_ui())
                if thumb_path and os.path.isfile(thumb_path):
                    pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(thumb_path, 72, 135)
                    tool_thumbnail.set_from_pixbuf(pixbuf)
                    tool_thumbnail.queue_draw()
                else:
                    tool_thumbnail.clear()
            except Exception as e:
                try:
                    tool_thumbnail.clear()
                except:
                    pass
                self._write_status("[Tool Life Column] Tool thumbnail load failed: {}".format(str(e)))
            finally:
                # pixbuf_new_from_file_at_size has fully decoded the image here.
                # Remove the one-shot SVG so /tmp does not collect render files.
                if thumb_path:
                    try:
                        os.remove(thumb_path)
                    except:
                        pass

        def update_details(holder_type):
            effective = holder_type or default_holder_type
            image_path = ""
            display_name = effective
            for item_type, item_name, item_image in holder_rows:
                if item_type == effective:
                    image_path = item_image
                    display_name = item_name
                    break
            current_preview["path"] = image_path
            current_preview["name"] = display_name
            try:
                if image_path and os.path.isfile(image_path):
                    pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(image_path, 100, 135)
                    thumbnail.set_from_pixbuf(pixbuf)
                else:
                    thumbnail.clear()
            except Exception as e:
                try:
                    thumbnail.clear()
                except:
                    pass
                self._write_status("[Tool Life Column] Holder thumbnail load failed: {}".format(str(e)))

        def on_changed(widget):
            active_iter = widget.get_active_iter()
            if active_iter is None:
                return
            holder_type = self._normalize_text(model.get_value(active_iter, 0))
            self._set_tool_holder_assignment(tool_num, holder_type)
            update_details(holder_type)
            refresh_dialog()
            self._write_status("[Tool Life Column] T{} holder: {}".format(
                tool_num,
                self._holder_display_name(holder_type, default_holder_type, holder_rows)))

        def on_thumbnail_clicked(widget, event):
            parent_window = None
            try:
                parent_window = widget.get_toplevel()
            except:
                pass
            return self._show_large_holder_preview(
                widget,
                event,
                current_preview["path"],
                current_preview["name"],
                parent_window)

        tool_model_change_state = {"serial": 0, "model_type": assigned_tool_model}

        def persist_tool_model_change(model_type, serial):
            # Run after the GTK changed callback has returned. If the user moved
            # through several choices quickly, persist only the newest choice.
            if serial != tool_model_change_state["serial"]:
                return False
            try:
                self._set_tool_model_assignment(tool_num, model_type)
            except Exception as e:
                self._write_status("[Tool Life Column] T{} tool model save failed: {}".format(tool_num, str(e)))
            return False

        def on_tool_model_changed(widget):
            if int(tool_num) == 99:
                update_tool_thumbnail("probe")
                return
            active_iter = widget.get_active_iter()
            if active_iter is None:
                return
            model_type = self._normalize_text(tool_model_store.get_value(active_iter, 0)).lower()
            # Make the card follow the ComboBox immediately. Persistence and the
            # Gremlin cache invalidation happen on idle so a DB hiccup cannot stop
            # the thumbnail update or make the selector feel dead.
            update_tool_thumbnail(model_type)
            tool_model_change_state["model_type"] = model_type
            tool_model_change_state["serial"] += 1
            change_serial = tool_model_change_state["serial"]
            glib.idle_add(persist_tool_model_change, model_type, change_serial)
            self._write_status("[Tool Life Column] T{} tool model: {}".format(
                tool_num, self._tool_model_display_name(model_type)))

        def on_flute_count_changed(widget):
            if int(tool_num) == 99:
                update_tool_thumbnail("probe")
                return
            active_iter = widget.get_active_iter()
            if active_iter is None:
                return
            flute_count = int(flute_store.get_value(active_iter, 0))
            self._set_tool_flute_count(tool_num, flute_count)
            update_tool_thumbnail(tool_model_change_state.get("model_type", assigned_tool_model))
            self._write_status("[Tool Life Column] T{} visual flutes: {}".format(tool_num, flute_count))

        def on_edit_holders_clicked(button):
            self._launch_holder_builder(button, rebuild_controls)

        def on_entire_tool_diagram_clicked(button):
            holder_type = assigned_holder
            try:
                active_iter = combo.get_active_iter()
                if active_iter is not None:
                    holder_type = self._normalize_text(model.get_value(active_iter, 0))
            except Exception:
                pass
            model_type = tool_model_change_state.get("model_type", assigned_tool_model)
            flute_count = current_flute_count_from_ui()
            parent_window = None
            try:
                parent_window = button.get_toplevel()
            except Exception:
                pass
            self._show_entire_tool_diagram(tool_num, holder_type, model_type, flute_count, parent_window)

        combo.connect("changed", on_changed)
        tool_model_combo.connect("changed", on_tool_model_changed)
        flute_combo.connect("changed", on_flute_count_changed)
        image_event.connect("button-press-event", on_thumbnail_clicked)
        holder_help_button.connect("clicked", self._show_holder_visual_warning)
        edit_holders_button.connect("clicked", on_edit_holders_clicked)
        entire_tool_button.connect("clicked", on_entire_tool_diagram_clicked)
        update_details(assigned_holder)
        update_tool_thumbnail(assigned_tool_model)
        return outer

    def _refresh_life_dialog(self, tool_num, info, history_store):
        self._refresh_state_cache()
        row = self._get_current_tool_row(tool_num)
        current_status = self._get_life_status_for_row(tool_num, row)
        saved_state = self.tool_life_state_cache.get(tool_num)
        info.set_text(
            "Status: {}\nDescription: {}\nDiameter: {}    Length: {}\n\n{}".format(
                current_status,
                row["description"],
                row["diameter"],
                row["length"],
                self._format_changes(row, saved_state)))
        history_store.clear()
        for display_row in self._get_tool_life_display_rows(tool_num):
            history_store.append(list(display_row))
        try:
            self.ui.tool_treeview.queue_draw()
        except:
            pass

    def _set_selected_history_record(self, history_view, record_id):
        history_view.tlm_selected_history_record_id = int(record_id or 0)

    def _build_history_view(self, history_store, tool_num):
        history_view = gtk.TreeView(history_store)
        history_view.set_headers_visible(True)
        history_view.get_selection().set_mode(gtk.SELECTION_NONE)
        self._set_selected_history_record(history_view, 0)
        editable_history_cells = []

        def on_history_cell_edited(renderer, path_text, new_text, model_index):
            try:
                tree_iter = history_store.get_iter_from_string(path_text)
                row_type = history_store.get_value(tree_iter, 1)
                record_id = int(history_store.get_value(tree_iter, 7))
                # The first visible column is the derived Cutter # sequence.
                # Existing save routines use the original logical field indexes
                # (Installed=1 through Note=5), so translate the shifted model
                # indexes back before saving.
                logical_model_index = int(model_index) - 1
                self._save_life_cell_edit(tool_num, row_type, record_id, logical_model_index, new_text)
                self._refresh_life_dialog_for_tree(tool_num, history_store, history_view)
            except Exception as e:
                self._write_status("[Tool Life Column] Cell edit failed: {}".format(str(e)))

        self._add_history_column(history_view, "Cutter #", 0, 65)
        self._add_history_column(history_view, "Type", 1, 70)
        editable_history_cells.append(self._add_history_column(history_view, "Installed", 2, 135, on_history_cell_edited))
        editable_history_cells.append(self._add_history_column(history_view, "Replaced", 3, 145, on_history_cell_edited))
        editable_history_cells.append(self._add_history_column(history_view, "Age", 4, 70, on_history_cell_edited))
        editable_history_cells.append(self._add_history_column(history_view, "Usage", 5, 80, on_history_cell_edited))
        editable_history_cells.append(self._add_history_column(history_view, "Removal Reason", 6, 220, on_history_cell_edited))
        history_view.connect("button-press-event", self._on_history_view_button_press, editable_history_cells)
        return history_view

    def _refresh_life_dialog_for_tree(self, tool_num, history_store, history_view):
        history_store.clear()
        for display_row in self._get_tool_life_display_rows(tool_num):
            history_store.append(list(display_row))
        try:
            history_view.queue_draw()
        except:
            pass
        try:
            self._refresh_state_cache()
            self.ui.tool_treeview.queue_draw()
        except:
            pass

    def _on_history_view_button_press(self, treeview, event, editable_history_cells):
        if event.button != 1:
            return False
        result = treeview.get_path_at_pos(int(event.x), int(event.y))
        if result is None:
            self._set_selected_history_record(treeview, 0)
            return False
        path, column, cell_x, cell_y = result
        model = treeview.get_model()
        tree_iter = model.get_iter(path)
        row_type = model.get_value(tree_iter, 1)
        record_id = int(model.get_value(tree_iter, 7))
        if row_type == "History" and record_id > 0:
            self._set_selected_history_record(treeview, record_id)
        else:
            self._set_selected_history_record(treeview, 0)
        for editable_column, editable_renderer in editable_history_cells:
            if column == editable_column:
                treeview.set_cursor_on_cell(path, editable_column, editable_renderer, True)
                return True
        return False

    def _build_history_scroller(self, history_view):
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.set_size_request(860, 160)
        scroller.add(history_view)
        return scroller

    def _build_reminder_view(self, reminder_store):
        reminder_view = gtk.TreeView(reminder_store)
        reminder_view.set_headers_visible(True)
        reminder_view.get_selection().set_mode(gtk.SELECTION_SINGLE)
        self._add_reminder_column(reminder_view, "Message", 1, 260)
        self._add_reminder_column(reminder_view, "Trigger", 2, 280)
        self._add_reminder_column(reminder_view, "Time Remaining", 3, 140)
        self._add_reminder_column(reminder_view, "Status", 4, 160)
        return reminder_view

    def _add_reminder_column(self, treeview, title, model_index, width):
        renderer = gtk.CellRendererText()
        renderer.set_property("font", "Roboto Condensed 10")
        renderer.set_property("xalign", 0.5)
        column = gtk.TreeViewColumn(title, renderer, text=model_index)
        column.set_resizable(True)
        column.set_min_width(width)
        try:
            column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
        except:
            pass
        try:
            column.set_alignment(0.5)
        except:
            pass
        treeview.append_column(column)

    def _build_reminder_scroller(self, reminder_view):
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.set_size_request(860, 120)
        scroller.add(reminder_view)
        return scroller

    def _refresh_reminder_store(self, tool_num, reminder_store):
        reminder_store.clear()
        if self.reminder_manager is None:
            return
        for row in self.reminder_manager.get_reminders_for_tool(tool_num):
            reminder_id, tool, message, due_datetime, due_age_seconds, due_usage_seconds, created_at, triggered_at, installed_date, total_seconds = row
            reminder_store.append([
                int(reminder_id),
                self._normalize_text(message),
                self._format_reminder_trigger(due_datetime, due_age_seconds, due_usage_seconds),
                self._format_reminder_time_remaining(
                    due_datetime,
                    due_age_seconds,
                    due_usage_seconds,
                    installed_date,
                    total_seconds,
                    triggered_at),
                self._format_reminder_status(triggered_at)
            ])

    def _age_seconds_from_installed_date(self, installed_date_text):
        installed_dt = self._parse_datetime(installed_date_text)
        if installed_dt:
            elapsed = datetime.datetime.now() - installed_dt
            return int(elapsed.days * 86400 + elapsed.seconds)
        return 0

    def _format_reminder_time_remaining(self, due_datetime, due_age_seconds, due_usage_seconds, installed_date, total_seconds, triggered_at):
        if self._normalize_text(triggered_at):
            return "Triggered"
        remaining_values = []
        due_datetime = self._normalize_text(due_datetime)
        if due_datetime:
            due_dt = self._parse_datetime(due_datetime)
            if due_dt:
                delta = due_dt - datetime.datetime.now()
                remaining_values.append(int(delta.total_seconds()))
        try:
            due_age_seconds = int(due_age_seconds or 0)
        except:
            due_age_seconds = 0
        if due_age_seconds > 0:
            current_age_seconds = self._age_seconds_from_installed_date(installed_date)
            remaining_values.append(int(due_age_seconds - current_age_seconds))
        try:
            due_usage_seconds = int(due_usage_seconds or 0)
        except:
            due_usage_seconds = 0
        if due_usage_seconds > 0:
            remaining_values.append(int(due_usage_seconds - int(total_seconds or 0)))
        if not remaining_values:
            return ""
        soonest_remaining = min(remaining_values)
        if soonest_remaining <= 0:
            return "Due. Pending trigger."
        return self._format_age_seconds(soonest_remaining)

    def _format_reminder_trigger(self, due_datetime, due_age_seconds, due_usage_seconds):
        parts = []
        due_datetime = self._normalize_text(due_datetime)
        if due_datetime:
            parts.append("Date: %s" % due_datetime)
        try:
            due_age_seconds = int(due_age_seconds or 0)
        except:
            due_age_seconds = 0
        try:
            due_usage_seconds = int(due_usage_seconds or 0)
        except:
            due_usage_seconds = 0
        if due_age_seconds > 0:
            parts.append("Age: %s" % self._format_age_seconds(due_age_seconds))
        if due_usage_seconds > 0:
            parts.append("Usage: %s" % self._format_seconds(due_usage_seconds))
        if not parts:
            return "No trigger set"
        return " | ".join(parts)

    def _format_reminder_status(self, triggered_at):
        triggered_at = self._normalize_text(triggered_at)
        if triggered_at:
            return "Triggered: %s" % triggered_at
        return "Active"

    def _build_reminder_buttons(self, tool_num, reminder_view, reminder_store):
        button_row = gtk.HBox(False, 6)
        add_button = gtk.Button("Add Reminder")
        remove_button = gtk.Button("Remove")
        remove_all_button = gtk.Button("Remove All Reminders")
        button_row.pack_start(add_button, False, False, 0)
        button_row.pack_start(remove_button, False, False, 0)
        button_row.pack_start(remove_all_button, False, False, 0)

        def refresh_reminders():
            self._refresh_reminder_store(tool_num, reminder_store)

        def on_add_clicked(button):
            self._show_add_reminder_dialog(tool_num, refresh_reminders)

        def on_remove_clicked(button):
            self._delete_selected_reminder(reminder_view, refresh_reminders)

        def on_remove_all_clicked(button):
            self._delete_all_reminders_for_tool_ui(tool_num, refresh_reminders)

        add_button.connect("clicked", on_add_clicked)
        remove_button.connect("clicked", on_remove_clicked)
        remove_all_button.connect("clicked", on_remove_all_clicked)
        return button_row

    def _build_life_dialog_bottom_buttons(self, dialog, tool_num, reminder_view, reminder_store):
        button_row = gtk.HBox(False, 6)
        reminder_buttons = self._build_reminder_buttons(tool_num, reminder_view, reminder_store)
        event_log_button = gtk.Button(EVENT_LOG_BUTTON_TITLE)
        help_button = gtk.Button("Help")
        close_button = gtk.Button("Close")

        def on_event_log_clicked(button):
            self._show_event_log_window(tool_num, dialog)

        def on_close_clicked(button):
            dialog.destroy()

        event_log_button.connect("clicked", on_event_log_clicked)
        help_button.connect("clicked", self._on_life_dialog_help_button, tool_num)
        close_button.connect("clicked", on_close_clicked)
        button_row.pack_start(reminder_buttons, False, False, 0)
        button_row.pack_end(close_button, False, False, 0)
        button_row.pack_end(help_button, False, False, 0)
        button_row.pack_end(event_log_button, False, False, 0)
        return button_row

    def _get_selected_reminder_id(self, reminder_view):
        selection = reminder_view.get_selection()
        model, tree_iter = selection.get_selected()
        if tree_iter is None:
            return 0
        try:
            return int(model.get_value(tree_iter, 0))
        except:
            return 0

    def _delete_selected_reminder(self, reminder_view, refresh_reminders):
        if self.reminder_manager is None:
            return
        reminder_id = self._get_selected_reminder_id(reminder_view)
        if reminder_id <= 0:
            self._show_reminder_info_dialog("Select a reminder first.")
            return
        if not self._confirm_reminder_delete("Delete selected reminder?"):
            return
        self.reminder_manager.delete_reminder(reminder_id)
        self._refresh_triggered_reminder_cache()
        try:
            self.ui.tool_treeview.queue_draw()
        except:
            pass        
        refresh_reminders()
        self._write_status("[Tool Life Column] Reminder deleted.")

    def _delete_all_reminders_for_tool_ui(self, tool_num, refresh_reminders):
        if self.reminder_manager is None:
            return
        if not self._confirm_reminder_delete("Delete all reminders for T{}?".format(tool_num)):
            return
        self.reminder_manager.delete_all_reminders_for_tool(tool_num)
        self._refresh_triggered_reminder_cache()
        try:
            self.ui.tool_treeview.queue_draw()
        except:
            pass        
        refresh_reminders()
        self._write_status("[Tool Life Column] T{} reminders deleted.".format(tool_num))

    def _show_reminder_info_dialog(self, message_text):
        dialog = gtk.MessageDialog(None, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_INFO, gtk.BUTTONS_OK, message_text)
        dialog.set_modal(True)
        dialog.run()
        dialog.destroy()

    def _confirm_reminder_delete(self, message_text):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_WARNING,
            gtk.BUTTONS_OK_CANCEL,
            message_text)
        dialog.set_modal(True)
        response = dialog.run()
        dialog.destroy()
        return response == gtk.RESPONSE_OK

    def _show_add_reminder_dialog(self, tool_num, refresh_reminders):
        dialog = gtk.Dialog("Add Reminder - T{}".format(tool_num), None, gtk.DIALOG_DESTROY_WITH_PARENT, ())
        dialog.set_modal(True)
        dialog.set_default_size(560, 320)
        box = gtk.VBox(False, 8)
        box.set_border_width(12)
        message_label = gtk.Label("Reminder message:")
        message_label.set_alignment(0.0, 0.5)
        message_entry = gtk.Entry()
        message_entry.set_text("Inspect cutter for wear.")
        datetime_label = gtk.Label("Remind at date/time: MM/DD/YYYY, MM-DD-YYYY, or YYYY-MM-DD. Time optional; date only = noon.")
        datetime_label.set_alignment(0.0, 0.5)
        datetime_entry = gtk.Entry()
        age_label = gtk.Label("Remind at a specific age (age is not usage). Examples: 12 mo, 30 day, 2 hr, 2hr")
        age_label.set_alignment(0.0, 0.5)
        age_entry = gtk.Entry()
        usage_label = gtk.Label("Remind at a specific usage (actual cutting time). Examples: 30 min, 30min, 2 hr, 2hr, 1 hr 30 min")
        usage_label.set_alignment(0.0, 0.5)
        usage_entry = gtk.Entry()
        help_label = gtk.Label("Set at least one trigger. If multiple triggers are set, the reminder fires when the first one becomes due. A bare number means minutes.")
        help_label.set_alignment(0.0, 0.5)
        help_label.set_line_wrap(True)
        box.pack_start(message_label, False, False, 0)
        box.pack_start(message_entry, False, False, 0)
        box.pack_start(datetime_label, False, False, 0)
        box.pack_start(datetime_entry, False, False, 0)
        box.pack_start(age_label, False, False, 0)
        box.pack_start(age_entry, False, False, 0)
        box.pack_start(usage_label, False, False, 0)
        box.pack_start(usage_entry, False, False, 0)
        box.pack_start(help_label, False, False, 8)
        dialog.vbox.pack_start(box, True, True, 0)
        dialog.add_button("Cancel", 0)
        dialog.add_button("Add Reminder", 1)
        dialog.show_all()
        response = dialog.run()
        message_text = self._normalize_text(message_entry.get_text())
        due_datetime = self._normalize_text(datetime_entry.get_text())
        age_text = self._normalize_text(age_entry.get_text())
        usage_text = self._normalize_text(usage_entry.get_text())
        dialog.destroy()
        if response != 1:
            return
        if not message_text:
            message_text = "Reminder due"
        if due_datetime:
            normalized_due_datetime = self._normalize_datetime_input(due_datetime)
            if not normalized_due_datetime:
                self._show_reminder_info_dialog(
                    "Invalid reminder date/time.\n\n"
                    "Use MM/DD/YYYY, MM-DD-YYYY, or YYYY-MM-DD. 2-digit years are okay. Time is optional; "
                    "date-only entries default to 12:00 noon.\n\n"
                    "Examples:\n"
                    "09-21-2026\n"
                    "09-21-2026 3:30 PM\n"
                    "2026-09-21 15:30")
                return
            due_datetime = normalized_due_datetime
        due_age_seconds = 0
        if age_text:
            due_age_seconds = self._parse_duration_to_seconds(age_text)
            if due_age_seconds is None:
                self._show_reminder_info_dialog("Invalid cutter age duration: %s\n\nUse formats like 30 min, 30min, 2 hr, 2hr, 1 hr 30 min, 7 day, or 6 mo." % age_text)
                return
        due_usage_seconds = 0
        if usage_text:
            due_usage_seconds = self._parse_duration_to_seconds(usage_text)
            if due_usage_seconds is None:
                self._show_reminder_info_dialog("Invalid spindle usage duration: %s\n\nUse formats like 30 min, 30min, 2 hr, 2hr, or 1 hr 30 min." % usage_text)
                return
        if not due_datetime and int(due_age_seconds or 0) <= 0 and int(due_usage_seconds or 0) <= 0:
            self._show_reminder_info_dialog("Set at least one trigger: date/time, age, or usage.")
            return
        self.reminder_manager.add_reminder(tool_num, message_text, due_datetime, due_age_seconds, due_usage_seconds)
        self._refresh_triggered_reminder_cache()
        try:
            self.ui.tool_treeview.queue_draw()
        except:
            pass
        refresh_reminders()
        self._write_status("[Tool Life Column] T{} reminder added: {}".format(tool_num, message_text))

    def _build_life_note_entry(self, status):
        if status == "Install?":
            note_label = gtk.Label("Install Reason:")
        else:
            note_label = gtk.Label("Replacement Reason:")
        note_label.set_alignment(0.0, 0.5)
        note_entry = gtk.Entry()
        if status == "Install?":
            note_entry.set_text("First cutter installed")
        elif status == "Update?":
            note_entry.set_text("example: worn cutter replaced")
        else:
            note_entry.set_text("example: worn cutter replaced")
        return {
            "label": note_label,
            "entry": note_entry}

    def _on_view_usage_by_day_clicked(self, button, tool_num):
        try:
            parent_window = None
            try:
                parent_window = button.get_toplevel()
            except:
                parent_window = None
            viewer = ToolUsageByDayViewer(self, tool_num, parent_window)
            viewer.show()
        except Exception as e:
            self._write_status("[Tool Life Column] T{} usage-by-day viewer failed: {}".format(tool_num, str(e)))

    def _sync_tool_metadata_only(self, tool_num, refresh_dialog):
        try:
            self._refresh_state_cache()
            row = self._get_current_tool_row(tool_num)
            status = self._get_life_status_for_row(tool_num, row)
            if status == "Install?":
                self._show_reminder_info_dialog("T{} has not been commissioned yet. Use Commission Tool instead.".format(tool_num))
                return
            saved_state = self.tool_life_state_cache.get(tool_num)
            current_state = self._metadata_tuple_from_row(row)
            old_value = ""
            if saved_state is not None:
                old_value = "Description: %s | Diameter: %s | Length: %s" % (
                    saved_state[0],
                    saved_state[1],
                    saved_state[2])
            new_value = "Description: %s | Diameter: %s | Length: %s" % (
                current_state[0],
                current_state[1],
                current_state[2])
            conn = self._db_connect()
            try:
                c = conn.cursor()
                now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                c.execute(
                    """INSERT OR REPLACE INTO tool_life_state
                       (tool, last_description, last_diameter, last_length, synced_at)
                       VALUES (?, ?, ?, ?, ?)""",
                    (int(tool_num),) + current_state + (now_text,))
                c.execute(
                    """INSERT INTO tool_event_log
                       (tool, logged_at, event_text, field_name, old_value, new_value)
                       VALUES (?, ?, ?, ?, ?, ?)""",
                    (
                        int(tool_num),
                        now_text,
                        "Metadata synced without cutter replacement",
                        "metadata_sync",
                        old_value,
                        new_value))
                conn.commit()
            except Exception as e:
                try:
                    conn.rollback()
                except:
                    pass
                self._write_status("[Tool Life Column] T{} metadata sync failed: {}".format(tool_num, str(e)))
                return
            finally:
                conn.close()
            self._refresh_state_cache()
            try:
                self.ui.tool_treeview.queue_draw()
            except:
                pass
            try:
                refresh_dialog()
            except:
                pass
            self._write_status("[Tool Life Column] T{} metadata synced without cutter replacement.".format(tool_num))
        except Exception as e:
            self._write_status("[Tool Life Column] T{} metadata sync failed: {}".format(tool_num, str(e)))

    def _build_history_delete_buttons(self, tool_num, history_view, refresh_dialog):
        button_row = gtk.HBox(False, 6)
        sync_metadata_button = gtk.Button("Sync Metadata Only")
        usage_by_day_button = gtk.Button(USAGE_BY_DAY_BUTTON)
        delete_row_button = gtk.Button("Delete Row")
        delete_history_button = gtk.Button("Delete All")
        button_row.pack_start(sync_metadata_button, False, False, 0)
        button_row.pack_start(usage_by_day_button, False, False, 0)
        button_row.pack_end(delete_history_button, False, False, 0)
        button_row.pack_end(delete_row_button, False, False, 0)

        def on_sync_metadata_clicked(button):
            self._sync_tool_metadata_only(tool_num, refresh_dialog)

        def on_usage_by_day_clicked(button):
            self._on_view_usage_by_day_clicked(button, tool_num)

        def on_delete_row_clicked(button):
            self._delete_selected_history_row(tool_num, history_view, refresh_dialog)

        def on_delete_history_clicked(button):
            self._delete_all_history_for_tool(tool_num, refresh_dialog)

        sync_metadata_button.connect("clicked", on_sync_metadata_clicked)
        usage_by_day_button.connect("clicked", on_usage_by_day_clicked)
        delete_row_button.connect("clicked", on_delete_row_clicked)
        delete_history_button.connect("clicked", on_delete_history_clicked)
        return button_row

    def _show_history_delete_not_available_dialog(self, message_text):
        dialog = gtk.MessageDialog(None, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_INFO, gtk.BUTTONS_OK, message_text)
        dialog.format_secondary_text("Only archived History rows can be deleted with Delete Row. The Current cutter row stays in use until you replace the cutter.")
        dialog.set_modal(True)
        dialog.run()
        dialog.destroy()

    def _delete_selected_history_row(self, tool_num, history_view, refresh_dialog):
        try:
            record_id = int(getattr(history_view, "tlm_selected_history_record_id", 0) or 0)
            if record_id <= 0:
                self._show_history_delete_not_available_dialog("Select a History row first.")
                return
            if not self._confirm_history_delete("Delete the selected history row for T{}?".format(tool_num)):
                return
            conn = self._db_connect()
            try:
                c = conn.cursor()
                c.execute(
                    """SELECT installed_date, reset_date, total_seconds, note
                       FROM tool_history
                       WHERE id = ? AND tool = ?""",
                    (record_id, tool_num))
                history_row = c.fetchone()
                c.execute("DELETE FROM tool_history WHERE id = ? AND tool = ?", (record_id, tool_num))
                if history_row:
                    installed_date, reset_date, total_seconds, note = history_row
                    summary = "Installed %s | Replaced %s | Usage %s | Reason %s" % (
                        self._normalize_text(installed_date),
                        self._normalize_text(reset_date),
                        self._format_seconds(int(total_seconds or 0)),
                        self._normalize_text(note))
                    self._append_named_tool_event(
                        c, tool_num, "Archived history row deleted",
                        "history_delete", summary, "")
                conn.commit()
            finally:
                conn.close()
            self._set_selected_history_record(history_view, 0)
            refresh_dialog()
            self._write_status("[Tool Life Column] T{} selected history row deleted.".format(tool_num))
        except Exception as e:
            self._write_status("[Tool Life Column] Delete history row failed: {}".format(str(e)))

    def _delete_all_history_for_tool(self, tool_num, refresh_dialog):
        try:
            if not self._confirm_history_delete("Delete all archived history rows for T{}?".format(tool_num)):
                return
            conn = self._db_connect()
            try:
                c = conn.cursor()
                c.execute("SELECT COUNT(*) FROM tool_history WHERE tool = ?", (tool_num,))
                count_row = c.fetchone()
                delete_count = int(count_row[0] or 0) if count_row else 0
                c.execute("DELETE FROM tool_history WHERE tool = ?", (tool_num,))
                if delete_count > 0:
                    self._append_named_tool_event(
                        c, tool_num,
                        "All archived history deleted (%d rows)" % delete_count,
                        "history_delete_all", str(delete_count), "0")
                conn.commit()
            finally:
                conn.close()
            refresh_dialog()
            self._write_status("[Tool Life Column] T{} archived history deleted.".format(tool_num))
        except Exception as e:
            self._write_status("[Tool Life Column] Delete all history failed: {}".format(str(e)))

    def _confirm_history_delete(self, message_text):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_WARNING,
            gtk.BUTTONS_OK_CANCEL,
            message_text)
        dialog.set_modal(True)
        response = dialog.run()
        dialog.destroy()
        return response == gtk.RESPONSE_OK

    def _confirm_replace_cutter(self, tool_num):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_WARNING,
            gtk.BUTTONS_OK_CANCEL,
            "Do you wish to record a cutter replacement for T{}?".format(tool_num))
        dialog.format_secondary_text(
            "The script will now close the current cutter into history using the current date and time. "
            "The Replacement Reason will be stored on that outgoing cutter as its Removal Reason. "
            "The newly installed cutter starts with a blank Removal Reason.\n\n"
            "You can edit the dates, usage, and Removal Reason afterward if needed.\n\n"
            "Cancel this and click 'Sync Metadata Only' if you want to update the saved metadata without performing a cutter replacement.\n\n"
            "(For example, when changing only the tool description.)")
        dialog.set_modal(True)
        response = dialog.run()
        dialog.destroy()
        return response == gtk.RESPONSE_OK

    def _destroy_commission_postit(self, dialog):
        try:
            action_button = getattr(dialog, "tlm_commission_highlight_button", None)
            if action_button is not None:
                for state in (
                        gtk.STATE_NORMAL,
                        gtk.STATE_PRELIGHT,
                        gtk.STATE_ACTIVE):
                    try:
                        action_button.modify_bg(state, None)
                    except:
                        pass
        except:
            pass
        try:
            dialog.tlm_commission_highlight_button = None
        except:
            pass
        try:
            postit = getattr(dialog, "tlm_commission_postit", None)
            if postit is not None:
                postit.destroy()
        except:
            pass
        try:
            dialog.tlm_commission_postit = None
        except:
            pass

    def _show_commission_postit(self, dialog, action_button, tool_num):
        # Show once per Tool Life dialog while Commission Tool is the available
        # action. It is intentionally lightweight and has no timer/watchdog.
        try:
            if getattr(dialog, "tlm_commission_postit_shown", False):
                return False
            if action_button is None or action_button.get_parent() is None:
                return False
            if not action_button.get_realized():
                return False
            dialog.tlm_commission_postit_shown = True
            postit = gtk.Window(gtk.WINDOW_POPUP)
            postit.set_resizable(False)
            postit.set_border_width(2)
            postit.set_size_request(390, -1)
            try:
                postit.set_transient_for(dialog)
            except:
                pass
            # Match the action button to the note while the guidance is visible.
            dialog.tlm_commission_highlight_button = action_button
            try:
                action_button.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#fff4b8"))
                action_button.modify_bg(gtk.STATE_PRELIGHT, gtk.gdk.color_parse("#fff0a0"))
                action_button.modify_bg(gtk.STATE_ACTIVE, gtk.gdk.color_parse("#ffe783"))
            except:
                pass
            border = gtk.Frame()
            border.set_shadow_type(gtk.SHADOW_OUT)
            paper = gtk.EventBox()
            try:
                paper.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#fff4b8"))
            except:
                pass
            note_box = gtk.VBox(False, 7)
            note_box.set_border_width(12)
            paper.add(note_box)
            border.add(paper)
            title_row = gtk.HBox(False, 6)
            title = gtk.Label()
            title.set_alignment(0.0, 0.5)
            title.set_markup('<span weight="bold" size="large">Start here - Commission Tool</span>')
            title_row.pack_start(title, True, True, 0)
            close_button = gtk.Button("x")
            close_button.set_relief(gtk.RELIEF_NONE)
            close_button.set_tooltip_text("Dismiss this note")
            title_row.pack_end(close_button, False, False, 0)
            note_box.pack_start(title_row, False, False, 0)
            body = gtk.Label(
                "This tool is not being tracked by Tool Life Manager yet.\n\n"
                "Click Commission Tool first to register the cutter's Installed "
                "date and begin tracking its Age and spindle Usage.\n\n"
                "If this cutter was already in service, you can edit the Installed "
                "date and starting Usage after commissioning.")
            body.set_alignment(0.0, 0.0)
            body.set_line_wrap(True)
            body.set_size_request(355, -1)
            note_box.pack_start(body, False, False, 0)
            hint = gtk.Label()
            hint.set_alignment(0.0, 0.5)
            hint.set_markup('<span style="italic">Commissioning is the first step for a new TLM tool record.</span>')
            hint.set_line_wrap(True)
            note_box.pack_start(hint, False, False, 0)
            close_button.connect("clicked", lambda button: self._destroy_commission_postit(dialog))
            postit.add(border)
            dialog.tlm_commission_postit = postit
            postit.show_all()
            # Place the note next to the Commission Tool button. Prefer the
            # left side so it does not cover the button; fall back above it.
            try:
                button_window = action_button.window
                origin_x, origin_y = button_window.get_origin()
                alloc = action_button.allocation
                screen_w = gtk.gdk.screen_width()
                screen_h = gtk.gdk.screen_height()
                postit.realize()
                req = postit.size_request()
                note_w = int(req[0] or 390)
                note_h = int(req[1] or 190)
                button_x = int(origin_x + alloc.x)
                button_y = int(origin_y + alloc.y)
                button_w = int(alloc.width)
                button_h = int(alloc.height)
                x = button_x - note_w - 14
                y = button_y + button_h - note_h
                if x < 8:
                    x = min(max(8, button_x + button_w - note_w), max(8, screen_w - note_w - 8))
                    y = button_y - note_h - 12
                if y < 8:
                    y = button_y + button_h + 12
                if y + note_h > screen_h - 8:
                    y = max(8, screen_h - note_h - 8)
                postit.move(int(x), int(y))
                postit.present()
            except:
                try:
                    postit.set_position(gtk.WIN_POS_CENTER_ON_PARENT)
                    postit.present()
                except:
                    pass
        except Exception as e:
            self._write_status("[Tool Life Column] Commission help note failed: %s" % str(e))
        return False

    def _build_life_dialog_buttons(self, dialog, tool_num, status, note_controls, refresh_dialog, rebuild_controls):
        button_row = gtk.HBox(False, 6)
        note_controls["label"].set_alignment(0.0, 0.5)
        button_row.pack_start(note_controls["label"], False, False, 0)
        button_row.pack_start(note_controls["entry"], True, True, 0)
        if status == "Install?":
            action_button = gtk.Button("Commission Tool")
        else:
            action_button = gtk.Button("Replace Cutter")
        button_row.pack_end(action_button, False, False, 0)

        def on_action_clicked(button):
            row = self._get_current_tool_row(tool_num)
            note_text = note_controls["entry"].get_text().strip()
            if status == "Install?":
                self._destroy_commission_postit(dialog)
                self._commission_tool(tool_num, row, note_text)
                refresh_dialog()
                rebuild_controls()
                return
            else:
                if not self._confirm_replace_cutter(tool_num):
                    return
                self._replace_cutter(tool_num, row, note_text)
                note_controls["entry"].set_text("example: worn cutter replaced")
            refresh_dialog()

        action_button.connect("clicked", on_action_clicked)
        button_row.tlm_action_button = action_button
        return button_row

    def _on_life_note_entry_clicked(self, entry, event, history_view):
        self._set_selected_history_record(history_view, 0)
        return False

    def _add_event_log_column(self, treeview, title, model_index, width):
        renderer = gtk.CellRendererText()
        renderer.set_property("font", "Roboto Condensed 10")
        if model_index == 0:
            renderer.set_property("xalign", 0.5)
        else:
            renderer.set_property("xalign", 0.0)
        column = gtk.TreeViewColumn(title, renderer, text=model_index)
        column.set_resizable(True)
        column.set_min_width(width)
        try:
            column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
        except:
            pass
        try:
            column.set_alignment(0.5)
        except:
            pass
        treeview.append_column(column)

    def _populate_event_log_store(self, tool_num, store):
        store.clear()
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT logged_at, event_text
                   FROM tool_event_log
                   WHERE tool = ?
                   ORDER BY logged_at DESC, id DESC""",
                (int(tool_num),))
            for row in c.fetchall():
                logged_at, event_text = row
                store.append([self._normalize_text(logged_at), self._normalize_text(event_text)])
        finally:
            conn.close()

    def _show_event_log_window(self, tool_num, parent_window=None):
        dialog = gtk.Dialog("Event Log - T{}".format(tool_num), parent_window, gtk.DIALOG_DESTROY_WITH_PARENT, ())
        dialog.set_modal(False)
        dialog.set_default_size(780, 420)
        dialog.connect("key-press-event", self._on_all_history_window_key_press)
        try:
            dialog.set_transient_for(parent_window)
            dialog.set_position(gtk.WIN_POS_CENTER_ON_PARENT)
        except:
            pass
        vbox = gtk.VBox(False, 6)
        vbox.set_border_width(8)
        dialog.vbox.pack_start(vbox, True, True, 0)
        title = gtk.Label()
        title.set_markup('<span weight="bold" size="large">T{} Event Log</span>'.format(tool_num))
        title.set_alignment(0.0, 0.5)
        vbox.pack_start(title, False, False, 8)
        store = gtk.ListStore(str, str)
        treeview = gtk.TreeView(store)
        treeview.set_headers_visible(True)
        self._add_event_log_column(treeview, "Logged At", 0, 160)
        self._add_event_log_column(treeview, "Event", 1, 560)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.add(treeview)
        vbox.pack_start(scroller, True, True, 0)
        button_row = gtk.HBox(False, 6)
        refresh_button = gtk.Button("Refresh")
        close_button = gtk.Button("Close")
        button_row.pack_end(close_button, False, False, 0)
        button_row.pack_end(refresh_button, False, False, 0)
        vbox.pack_start(button_row, False, False, 8)

        def refresh_clicked(button):
            self._populate_event_log_store(tool_num, store)

        def close_clicked(button):
            dialog.destroy()

        refresh_button.connect("clicked", refresh_clicked)
        close_button.connect("clicked", close_clicked)
        self._populate_event_log_store(tool_num, store)
        dialog.show_all()
        try:
            dialog.present()
            dialog.window.raise_()
        except:
            pass

    def _show_life_dialog(self, tool_num, status):
        dialog = gtk.Dialog("Tool Life - T{}".format(tool_num), None, gtk.DIALOG_DESTROY_WITH_PARENT, ())
        dialog.set_modal(False)
        try:
            life_dialog_width = max(900, int(gtk.gdk.screen_width()))
        except:
            life_dialog_width = 900
        dialog.set_default_size(life_dialog_width, 620)
        summary_row = gtk.HBox(False, 8)
        summary_row.set_border_width(4)
        dialog.vbox.pack_start(summary_row, False, False, 2)
        metadata_frame = gtk.Frame("Tool Metadata")
        metadata_box = gtk.VBox(False, 2)
        metadata_box.set_border_width(8)
        metadata_frame.add(metadata_box)
        metadata_frame.set_size_request(355, -1)
        summary_row.pack_start(metadata_frame, False, False, 0)
        info = gtk.Label("")
        info.set_alignment(0.0, 0.0)
        info.set_line_wrap(True)
        metadata_box.pack_start(info, True, True, 0)
        metadata_action_row = gtk.HBox(False, 6)
        notes_button = gtk.Button("Notes")
        notes_button.set_tooltip_text("Open persistent free-form notes for this tool number.")
        metadata_action_row.pack_start(notes_button, False, False, 0)
        clear_update_button = gtk.Button("Clear Update (No Log)")
        clear_update_button.set_tooltip_text(
            "Accept the current PathPilot description, diameter, and length as the new TLM baseline "
            "without creating a cutter-replacement or metadata-sync log entry.")
        metadata_action_row.pack_end(clear_update_button, False, False, 0)
        metadata_box.pack_start(metadata_action_row, False, False, 2)
        holder_frame = gtk.Frame("Holder / Tool Model")
        holder_assignment_box = gtk.VBox(False, 0)
        holder_assignment_box.set_border_width(0)
        holder_frame.add(holder_assignment_box)
        summary_row.pack_start(holder_frame, True, True, 0)
        history_header_row = gtk.HBox(False, 6)
        history_label = gtk.Label()
        history_label.set_markup('<span weight="bold">Current &amp; Historical data. All fields are editable.</span>')
        history_label.set_alignment(0.0, 0.5)
        history_header_row.pack_start(history_label, True, True, 0)
        history_header_action_box = gtk.HBox(False, 6)
        history_header_row.pack_end(history_header_action_box, False, False, 0)
        dialog.vbox.pack_start(history_header_row, False, False, 4)
        history_store = gtk.ListStore(int, str, str, str, str, str, str, int)
        history_view = self._build_history_view(history_store, tool_num)
        scroller = self._build_history_scroller(history_view)
        dialog.vbox.pack_start(scroller, False, False, 8)
        controls_box = gtk.VBox(False, 4)
        dialog.vbox.pack_start(controls_box, False, False, 4)
        reminder_label = gtk.Label()
        reminder_label.set_markup('<span weight="bold">Reminders</span>')
        reminder_label.set_alignment(0.0, 0.5)
        dialog.vbox.pack_start(reminder_label, False, False, 4)
        reminder_store = gtk.ListStore(int, str, str, str, str)
        reminder_view = self._build_reminder_view(reminder_store)
        reminder_scroller = self._build_reminder_scroller(reminder_view)
        dialog.vbox.pack_start(reminder_scroller, False, False, 4)
        bottom_button_row = self._build_life_dialog_bottom_buttons(dialog, tool_num, reminder_view, reminder_store)
        dialog.vbox.pack_start(bottom_button_row, False, False, 4)
        dialog.connect("destroy", lambda widget: self._destroy_commission_postit(dialog))

        def refresh_dialog():
            self._refresh_life_dialog(tool_num, info, history_store)
            self._refresh_reminder_store(tool_num, reminder_store)

        def on_clear_update_clicked(button):
            row = self._get_current_tool_row(tool_num)
            if row is None:
                return
            current_status = self._get_life_status_for_row(tool_num, row)
            if current_status != "Update?":
                return
            self._on_clear_update_flag_menu(button, tool_num, row)
            refresh_dialog()
            rebuild_controls()

        clear_update_button.connect("clicked", on_clear_update_clicked)
        notes_button.connect("clicked", lambda button: self._show_tool_notes_dialog(tool_num, dialog))

        def rebuild_controls():
            self._destroy_commission_postit(dialog)
            for child in holder_assignment_box.get_children():
                holder_assignment_box.remove(child)
            holder_assignment_box.pack_start(self._build_holder_assignment_row(tool_num, refresh_dialog, rebuild_controls), False, False, 0)
            holder_assignment_box.show_all()
            for child in controls_box.get_children():
                controls_box.remove(child)
            for child in history_header_action_box.get_children():
                history_header_action_box.remove(child)
            row = self._get_current_tool_row(tool_num)
            current_status = self._get_life_status_for_row(tool_num, row)
            clear_update_button.set_sensitive(current_status == "Update?")
            if current_status != "Install?":
                history_button_row = self._build_history_delete_buttons(tool_num, history_view, refresh_dialog)
                history_header_action_box.pack_start(history_button_row, False, False, 0)
            note_entry = self._build_life_note_entry(current_status)
            note_entry["entry"].connect("button-press-event", self._on_life_note_entry_clicked, history_view)
            button_row = self._build_life_dialog_buttons(
                dialog,
                tool_num,
                current_status,
                note_entry,
                refresh_dialog,
                rebuild_controls)
            controls_box.pack_start(button_row, False, False, 6)
            history_header_action_box.show_all()
            controls_box.show_all()
            if current_status == "Install?":
                glib.timeout_add(
                    900,
                    self._show_commission_postit,
                    dialog,
                    getattr(button_row, "tlm_action_button", None),
                    tool_num)

        refresh_dialog()
        rebuild_controls()
        self._prepare_maximized_window(dialog)
        dialog.show_all()
        try:
            dialog.present()
            dialog.window.raise_()
        except:
            pass
        glib.idle_add(self._maximize_window_once, dialog)
        glib.timeout_add(250, self._maximize_window_once, dialog)
        glib.timeout_add(750, self._maximize_window_once, dialog)

    def _add_history_column(self, treeview, title, model_index, width, edited_callback=None):
        renderer = gtk.CellRendererText()
        renderer.set_property("font", "Roboto Condensed 10")
        renderer.set_property("xalign", 0.5)
        if edited_callback:
            renderer.set_property("editable", True)
            renderer.connect("edited", edited_callback, model_index)
        column = gtk.TreeViewColumn(title, renderer, text=model_index)
        column.set_resizable(True)
        column.set_min_width(width)
        try:
            column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
        except:
            pass
        try:
            column.set_alignment(0.5)
        except:
            pass
        treeview.append_column(column)
        return column, renderer

    def _format_changes(self, row, saved_state):
        if saved_state is None:
            return "This tool has not been commissioned yet. Click Commission Tool to start tracking age and spindle usage. You can edit installed date and usage later if needed."
        lines = []
        if self._normalize_text(row["description"]) != self._normalize_text(saved_state[0]):
            lines.append("Description: {} -> {}".format(saved_state[0], row["description"]))
        if not self._saved_metadata_number_matches_display(saved_state[1], row["diameter"]):
            lines.append("Diameter: {} -> {}".format(saved_state[1], row["diameter"]))
        if not self._saved_metadata_number_matches_display(saved_state[2], row["length"]):
            lines.append("Length: {} -> {}".format(saved_state[2], row["length"]))
        if not lines:
            return "No PathPilot description, diameter, or length changes are pending."
        return "Pending changes:\n" + "\n".join(lines)

    def _save_tool_life_state(self, tool_num, row):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            c.execute(
                """INSERT OR REPLACE INTO tool_life_state
                   (tool, last_description, last_diameter, last_length, synced_at)
                   VALUES (?, ?, ?, ?, ?)""",
                (tool_num,) + self._metadata_tuple_from_row(row) + (now_text,))
            conn.commit()
        finally:
            conn.close()

    def _commission_tool(self, tool_num, row, note_text):
        if not note_text:
            note_text = "First cutter installed"
        conn = self._db_connect()
        try:
            c = conn.cursor()
            now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            c.execute("SELECT tool FROM tool_usage WHERE tool = ?", (tool_num,))
            existing = c.fetchone()
            if existing:
                c.execute(
                    """UPDATE tool_usage
                       SET install_note = ?
                       WHERE tool = ?""",
                    (note_text, tool_num))
            else:
                c.execute(
                    """INSERT INTO tool_usage
                       (tool, installed_date, total_seconds, install_note)
                       VALUES (?, ?, 0, ?)""",
                    (tool_num, now_text, note_text))
            c.execute(
                """INSERT OR REPLACE INTO tool_life_state
                   (tool, last_description, last_diameter, last_length, synced_at)
                   VALUES (?, ?, ?, ?, ?)""",
                (tool_num,) + self._metadata_tuple_from_row(row) + (now_text,))
            self._append_named_tool_event(
                c, tool_num,
                "Tool commissioned: %s" % self._normalize_text(note_text),
                "commission", "", now_text)
            conn.commit()
            self._write_status("[Tool Life Column] T{} commissioned. Reason: {}".format(tool_num, note_text))
        except Exception as e:
            conn.rollback()
            self._write_status("[Tool Life Column] T{} commission failed: {}".format(tool_num, str(e)))
        finally:
            conn.close()

    def _replace_cutter(self, tool_num, row, note_text):
        if not note_text:
            note_text = "Cutter replaced"
        conn = self._db_connect()
        try:
            c = conn.cursor()
            now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            c.execute(
                """SELECT installed_date, total_seconds
                   FROM tool_usage
                   WHERE tool = ?""",
                (tool_num,))
            existing = c.fetchone()
            if existing:
                installed_date, total_seconds = existing
                total_seconds = int(total_seconds or 0)
                elapsed_seconds = self._elapsed_seconds_from_install(installed_date, now_text)
                c.execute(
                    """INSERT INTO tool_history
                       (tool, installed_date, reset_date, elapsed_seconds, total_seconds, note)
                       VALUES (?, ?, ?, ?, ?, ?)""",
                    (tool_num, installed_date, now_text, elapsed_seconds, total_seconds, note_text))
                c.execute(
                    """UPDATE tool_usage
                       SET installed_date = ?,
                           total_seconds = 0,
                           install_note = ''
                       WHERE tool = ?""",
                    (now_text, tool_num))
            else:
                c.execute(
                    """INSERT INTO tool_usage
                       (tool, installed_date, total_seconds, install_note)
                       VALUES (?, ?, 0, '')""",
                    (tool_num, now_text))
            c.execute(
                """INSERT OR REPLACE INTO tool_life_state
                   (tool, last_description, last_diameter, last_length, synced_at)
                   VALUES (?, ?, ?, ?, ?)""",
                (tool_num,) + self._metadata_tuple_from_row(row) + (now_text,))
            self._append_named_tool_event(
                c, tool_num,
                "Cutter replaced: %s" % self._normalize_text(note_text),
                "replace_cutter", "", now_text)
            conn.commit()
            self._write_status(
                "[Tool Life Column] T{} cutter replaced. Outgoing cutter removal reason: {}".format(
                    tool_num, note_text))
        except Exception as e:
            conn.rollback()
            self._write_status("[Tool Life Column] T{} cutter replacement failed: {}".format(tool_num, str(e)))
        finally:
            conn.close()

    def _elapsed_seconds_from_install(self, installed_date_text, reset_date_text):
        installed_dt = self._parse_datetime(installed_date_text)
        reset_dt = self._parse_datetime(reset_date_text)
        if installed_dt and reset_dt:
            elapsed_seconds = int((reset_dt - installed_dt).total_seconds())
            if elapsed_seconds >= 0:
                return elapsed_seconds
        return 0

    def _parse_datetime(self, text_value):
        normalized = self._normalize_datetime_input(text_value)
        if not normalized:
            return None
        try:
            return datetime.datetime.strptime(normalized, "%Y-%m-%d %H:%M:%S")
        except:
            return None

    def _normalize_datetime_input(self, text_value):
        # User-facing date entry accepts either ISO or US month-day-year.
        # Date-only input intentionally defaults to noon, while everything is
        # stored/displayed in one canonical PathPilot/TLM timestamp format.
        text_value = self._normalize_text(text_value)
        if not text_value:
            return ""
        formats = (
            ("%Y-%m-%d %H:%M:%S", False),
            ("%Y-%m-%d %H:%M", False),
            ("%Y-%m-%d %I:%M:%S %p", False),
            ("%Y-%m-%d %I:%M %p", False),
            ("%m-%d-%Y %H:%M:%S", False),
            ("%m-%d-%Y %H:%M", False),
            ("%m-%d-%Y %I:%M:%S %p", False),
            ("%m-%d-%Y %I:%M %p", False),
            ("%m-%d-%y %H:%M:%S", False),
            ("%m-%d-%y %H:%M", False),
            ("%m-%d-%y %I:%M:%S %p", False),
            ("%m-%d-%y %I:%M %p", False),
            ("%m/%d/%Y %H:%M:%S", False),
            ("%m/%d/%Y %H:%M", False),
            ("%m/%d/%Y %I:%M:%S %p", False),
            ("%m/%d/%Y %I:%M %p", False),
            ("%m/%d/%y %H:%M:%S", False),
            ("%m/%d/%y %H:%M", False),
            ("%m/%d/%y %I:%M:%S %p", False),
            ("%m/%d/%y %I:%M %p", False),
            ("%Y-%m-%d", True),
            ("%m-%d-%Y", True),
            ("%m-%d-%y", True),
            ("%m/%d/%Y", True),
            ("%m/%d/%y", True),)
        for fmt, date_only in formats:
            try:
                parsed = datetime.datetime.strptime(text_value, fmt)
                if date_only:
                    parsed = parsed.replace(hour=12, minute=0, second=0)
                return parsed.strftime("%Y-%m-%d %H:%M:%S")
            except:
                pass
        return None

    def _warn_if_hobbs_plugin_missing(self):
        if os.path.isfile(HOBBS_PLUGIN_PATH):
            return
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_WARNING,
            gtk.BUTTONS_OK,
            "Hobbs plugin not found")
        dialog.format_secondary_text(
            "Tool Life Manager will still open, but it will not accumulate spindle usage "
            "unless hobbs_plugin.py is installed at:\n\n%s" % HOBBS_PLUGIN_PATH)
        dialog.set_modal(False)
        dialog.show_all()
        self._write_status("[Tool Life Column] WARNING: hobbs_plugin.py not found. Tool usage will not accumulate.")

    def _write_status(self, message):
        try:
            self.error_handler.write(message, constants.ALARM_LEVEL_QUIET)
        except:
            pass

    def _ensure_initial_tool_life_baselines(self):
        try:
            model = self.ui.tool_treeview.get_model()
            conn = self._db_connect()
            c = conn.cursor()
            now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            try:
                row_index = 0
                for model_row in model:
                    current_row = self._get_tool_row_for_model_row(model, model_row, row_index)
                    row_index += 1
                    if current_row is None:
                        continue
                    tool_num = int(current_row["tool"])
                    if not self._is_commissionable_tool_row(current_row):
                        continue
                    description = current_row["description"]
                    diameter = current_row["diameter"]
                    length = current_row["length"]
                    c.execute("SELECT tool FROM tool_usage WHERE tool = ?", (tool_num,))
                    usage_row = c.fetchone()
                    if not usage_row:
                        c.execute(
                            """INSERT INTO tool_usage
                               (tool, installed_date, total_seconds, install_note)
                               VALUES (?, ?, 0, ?)""",
                            (tool_num, now_text, ""))
                    c.execute("SELECT tool FROM tool_life_state WHERE tool = ?", (tool_num,))
                    state_row = c.fetchone()
                    if not state_row:
                        canonical_state = self._metadata_tuple_from_row(current_row)
                        c.execute(
                            """INSERT INTO tool_life_state
                               (tool, last_description, last_diameter, last_length, synced_at)
                               VALUES (?, ?, ?, ?, ?)""",
                            (tool_num,) + canonical_state + (now_text,))
                conn.commit()
            finally:
                conn.close()
        except Exception as e:
            self._write_status("[Tool Life Column] Initial tool commission failed: {}".format(str(e)))

    def _get_tool_life_display_rows(self, tool_num):
        rows = []
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT installed_date, total_seconds, install_note
                   FROM tool_usage
                   WHERE tool = ?""",
                (tool_num,))
            current = c.fetchone()
            c.execute(
                """SELECT id, installed_date, reset_date, total_seconds, note
                   FROM tool_history
                   WHERE tool = ?
                   ORDER BY reset_date DESC, id DESC""",
                (tool_num,))
            history_rows = c.fetchall()
            history_count = len(history_rows)
            # Cutter # follows actual replacement/insertion order, not the
            # editable Installed/Replaced dates used to sort the visible rows.
            history_ids_in_order = sorted([int(row[0]) for row in history_rows])
            cutter_number_by_history_id = {}
            for sequence_index, history_id in enumerate(history_ids_in_order):
                cutter_number_by_history_id[history_id] = sequence_index + 1
            if current:
                installed_date, total_seconds, install_note = current
                rows.append((
                    history_count + 1,
                    "Current",
                    self._normalize_text(installed_date),
                    "still in use",
                    self._format_age(installed_date),
                    self._format_seconds(total_seconds),
                    "-",
                    0))
            for history_index, history_row in enumerate(history_rows):
                history_id, installed_date, reset_date, total_seconds, note = history_row
                # Visible rows remain date-sorted, but Cutter # is tied to
                # replacement sequence via the immutable history row id.
                cutter_number = cutter_number_by_history_id.get(int(history_id), history_count - history_index)
                rows.append((
                    cutter_number,
                    "History",
                    self._normalize_text(installed_date),
                    self._normalize_text(reset_date),
                    self._format_age_for_history(installed_date, reset_date),
                    self._format_seconds(total_seconds),
                    self._normalize_text(note) or "-",
                    int(history_id)))
        finally:
            conn.close()
        return rows

    def _format_age_seconds(self, total_seconds):
        try:
            total_seconds = int(total_seconds or 0)
        except:
            total_seconds = 0
        if total_seconds < 0:
            total_seconds = 0
        total_minutes = total_seconds / 60
        total_months = total_minutes / (30 * 24 * 60)
        total_minutes = total_minutes % (30 * 24 * 60)
        years = total_months / 12
        months = total_months % 12
        days = total_minutes / (24 * 60)
        total_minutes = total_minutes % (24 * 60)
        hours = total_minutes / 60
        minutes = total_minutes % 60
        # Keep Age compact: show only the three largest useful units.
        parts = []
        if years > 0:
            parts.append("%d yr" % years)
        if months > 0:
            parts.append("%d mo" % months)
        if days > 0:
            parts.append("%d d" % days)
        if hours > 0:
            parts.append("%d hr" % hours)
        if minutes > 0 or not parts:
            parts.append("%d min" % minutes)
        return " ".join(parts[:3])

    def _format_seconds(self, total_seconds):
        try:
            total_seconds = int(total_seconds or 0)
        except:
            total_seconds = 0
        if total_seconds < 0:
            total_seconds = 0
        total_minutes = int(round(total_seconds / 60.0))
        hours = total_minutes / 60
        minutes = total_minutes % 60
        if hours > 0:
            return "%d hr %d min" % (hours, minutes)
        return "%d min" % minutes

    def _format_age(self, installed_date_text):
        installed_dt = self._parse_datetime(installed_date_text)
        if not installed_dt:
            return ""
        elapsed = datetime.datetime.now() - installed_dt
        total_seconds = int(elapsed.days * 86400 + elapsed.seconds)
        return self._format_age_seconds(total_seconds)          

    def _format_age_for_history(self, installed_date_text, reset_date_text):
        installed_dt = self._parse_datetime(installed_date_text)
        reset_dt = self._parse_datetime(reset_date_text)
        if not installed_dt or not reset_dt:
            return ""
        elapsed = reset_dt - installed_dt
        total_seconds = int(elapsed.days * 86400 + elapsed.seconds)
        return self._format_age_seconds(total_seconds)

    def _save_life_cell_edit(self, tool_num, row_type, record_id, model_index, new_text):
        new_text = self._normalize_text(new_text)
        if row_type == "Current":
            self._save_current_life_cell_edit(tool_num, model_index, new_text)
        else:
            self._save_history_life_cell_edit(tool_num, record_id, model_index, new_text)

    def _save_current_life_cell_edit(self, tool_num, model_index, new_text):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT installed_date, total_seconds, install_note
                   FROM tool_usage
                   WHERE tool = ?""",
                (tool_num,))
            row = c.fetchone()
            if row is None:
                return
            installed_date, total_seconds, note_text = row
            installed_date = self._normalize_text(installed_date)
            total_seconds = int(total_seconds or 0)
            note_text = self._normalize_text(note_text)
            edit_field_name = ""
            old_display = ""
            new_display = ""
            if model_index == 1:
                normalized_date = self._normalize_datetime_input(new_text)
                if not normalized_date:
                    self._show_reminder_info_dialog(
                        "Invalid Installed date.\n\n"
                        "Use MM/DD/YYYY, MM-DD-YYYY, or YYYY-MM-DD. 2-digit years are okay. Time is optional; "
                        "date-only entries default to 12:00 noon.")
                    return
                edit_field_name = "installed"
                old_display = installed_date
                new_display = normalized_date
                installed_date = normalized_date
            elif model_index == 2:
                self._write_status("[Tool Life Column] Current cutter replacement date is created when the cutter is replaced.")
                return
            elif model_index == 3:
                age_seconds = self._parse_duration_to_seconds(new_text)
                if age_seconds is None:
                    return
                edit_field_name = "age"
                old_display = self._format_age(installed_date)
                new_display = self._format_age_seconds(age_seconds)
                installed_date = (datetime.datetime.now() - datetime.timedelta(seconds=age_seconds)).strftime("%Y-%m-%d %H:%M:%S")
            elif model_index == 4:
                parsed_seconds = self._parse_duration_to_seconds(new_text)
                if parsed_seconds is None:
                    return
                edit_field_name = "usage"
                old_display = self._format_seconds(total_seconds)
                new_display = self._format_seconds(parsed_seconds)
                total_seconds = parsed_seconds
            elif model_index == 5:
                edit_field_name = "note"
                old_display = note_text
                new_display = new_text
                note_text = new_text
            else:
                return
            c.execute(
                """UPDATE tool_usage
                   SET installed_date = ?,
                       total_seconds = ?,
                       install_note = ?
                   WHERE tool = ?""",
                (installed_date, total_seconds, note_text, tool_num))
            self._append_tool_editor_event_log(c, tool_num, edit_field_name, old_display, new_display)
            conn.commit()
        except Exception as e:
            conn.rollback()
            self._write_status("[Tool Life Column] Current row edit failed: {}".format(str(e)))
        finally:
            conn.close()

    def _save_history_life_cell_edit(self, tool_num, record_id, model_index, new_text):
        conn = self._db_connect()
        try:
            c = conn.cursor()
            c.execute(
                """SELECT tool, installed_date, reset_date, total_seconds, note
                   FROM tool_history
                   WHERE id = ?""",
                (record_id,))
            row = c.fetchone()
            if row is None:
                return
            stored_tool_num, installed_date, reset_date, total_seconds, note_text = row
            tool_num = int(stored_tool_num or tool_num)
            installed_date = self._normalize_text(installed_date)
            reset_date = self._normalize_text(reset_date)
            total_seconds = int(total_seconds or 0)
            note_text = self._normalize_text(note_text)
            edit_field_name = ""
            old_display = ""
            new_display = ""
            if model_index == 1:
                normalized_date = self._normalize_datetime_input(new_text)
                if not normalized_date:
                    self._show_reminder_info_dialog(
                        "Invalid Installed date.\n\n"
                        "Use MM/DD/YYYY, MM-DD-YYYY, or YYYY-MM-DD. 2-digit years are okay. Time is optional; "
                        "date-only entries default to 12:00 noon.")
                    return
                edit_field_name = "installed"
                old_display = installed_date
                new_display = normalized_date
                installed_date = normalized_date
            elif model_index == 2:
                normalized_date = self._normalize_datetime_input(new_text)
                if not normalized_date:
                    self._show_reminder_info_dialog(
                        "Invalid Replaced date.\n\n"
                        "Use MM/DD/YYYY, MM-DD-YYYY, or YYYY-MM-DD. 2-digit years are okay. Time is optional; "
                        "date-only entries default to 12:00 noon.")
                    return
                edit_field_name = "replaced"
                old_display = reset_date
                new_display = normalized_date
                reset_date = normalized_date
            elif model_index == 3:
                age_seconds = self._parse_duration_to_seconds(new_text)
                if age_seconds is None:
                    return
                reset_dt = self._parse_datetime(reset_date)
                if reset_dt is None:
                    reset_dt = datetime.datetime.now()
                edit_field_name = "age"
                old_display = self._format_age_for_history(installed_date, reset_date)
                new_display = self._format_age_seconds(age_seconds)
                installed_date = (reset_dt - datetime.timedelta(seconds=age_seconds)).strftime("%Y-%m-%d %H:%M:%S")
            elif model_index == 4:
                parsed_seconds = self._parse_duration_to_seconds(new_text)
                if parsed_seconds is None:
                    return
                edit_field_name = "usage"
                old_display = self._format_seconds(total_seconds)
                new_display = self._format_seconds(parsed_seconds)
                total_seconds = parsed_seconds
            elif model_index == 5:
                edit_field_name = "note"
                old_display = note_text
                new_display = new_text
                note_text = new_text
            else:
                return
            elapsed_seconds = self._elapsed_seconds_from_install(installed_date, reset_date)
            c.execute(
                """UPDATE tool_history
                   SET installed_date = ?,
                       reset_date = ?,
                       elapsed_seconds = ?,
                       total_seconds = ?,
                       note = ?
                   WHERE id = ?""",
                (installed_date, reset_date, elapsed_seconds, total_seconds, note_text, record_id))
            self._append_tool_editor_event_log(c, tool_num, edit_field_name, old_display, new_display)
            conn.commit()
        except Exception as e:
            conn.rollback()
            self._write_status("[Tool Life Column] History row edit failed: {}".format(str(e)))
        finally:
            conn.close()

    def _parse_duration_to_seconds(self, text):
        # Accepts both spaced and unspaced duration formats:
        # 30 min, 30min, 2 hr, 2hr, 1 hr 30 min, 1hr30min, 7 day, 6 mo.
        # A bare number is treated as minutes for faster data entry.
        text = self._normalize_text(text).lower()
        if not text:
            return 0
        try:
            return int(float(text) * 60)
        except:
            pass
        unit_seconds = {
            "s": 1,
            "sec": 1,
            "secs": 1,
            "second": 1,
            "seconds": 1,
            "m": 60,
            "min": 60,
            "mins": 60,
            "minute": 60,
            "minutes": 60,
            "h": 3600,
            "hr": 3600,
            "hrs": 3600,
            "hour": 3600,
            "hours": 3600,
            "d": 86400,
            "day": 86400,
            "days": 86400,
            "mo": 30 * 86400,
            "mos": 30 * 86400,
            "mon": 30 * 86400,
            "month": 30 * 86400,
            "months": 30 * 86400}
        total = 0
        found = False
        index = 0
        text_length = len(text)
        while index < text_length:
            while index < text_length and text[index] in (" ", ",", "\t"):
                index += 1
            value_text = ""
            while index < text_length and (text[index].isdigit() or text[index] == "."):
                value_text += text[index]
                index += 1
            while index < text_length and text[index] in (" ", "\t"):
                index += 1
            unit_text = ""
            while index < text_length and text[index].isalpha():
                unit_text += text[index]
                index += 1
            if not value_text:
                return None
            if unit_text not in unit_seconds:
                return None
            try:
                total += int(float(value_text) * unit_seconds[unit_text])
                found = True
            except:
                return None
        if found:
            return total
        return None