# coding: utf-8
# python 2 only

# Copyright (c) 2026 TormachTips.com. All rights reserved.
# Licensed under the TormachTips Personal Use License.
# Permission is granted only for private personal use and private personal modification.
# No sharing, publication, distribution, resale, sublicensing, screenshots, code excerpts,
# benchmarks, or videos are permitted without prior written permission.
# Requests:         tormach.1100m@gmail.com
# Information page: https://tormachtips.com/plugins.htm

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

# 1.09 - Blank Removal Reason cells display a visible dash instead of appearing accidentally empty. - 9/17/2026
# 1.08 - Removal Reason belongs to the cutter that was removed; existing reasons migrate down one cutter generation once. - 9/17/2026
# 1.07 - Tool Life history adds Cutter # sequence: original cutter = 1, each replacement increments it. - 9/17/2026
# 1.06 - Clear Update (No Log) button added directly to Tool Life dialog metadata card. - 9/17/2026
# 1.05 - permanent quiet modern-table path; direct user-edit logging only; right-click Clear Update Flag. - 9/17/2026
# 1.04 - 2.14 quiet-table mode: no live model observers; no hidden-tab redraws; modern table refresh isolated. - 9/17/2026
# 1.03 - 2.14 performance: fast hidden tool-id lookup, no model-signal redraw loop, fewer resolver calls. - 9/17/2026
# 1.02 - single compatibility-aware build for PathPilot 2.9.x and 2.10+/2.14.x tool-table APIs. - 9/17/2026
# 1.01 - larger clickable holder preview, holder warning/help, builder launcher, and consolidated dialog chrome. - 9/17/2026
# 1.00 - compact side-by-side metadata/holder cards so the Tool Life dialog fits vertically with thumbnails. - 9/17/2026
# 0.99 - dimension-generated SVG holder thumbnails in the Tool Life dialog. - 9/17/2026
# 0.98 - per-tool Gremlin holder assignments and image-ready holder catalog. - 9/17/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 constants
import singletons
from ui_hooks import plugin

CURRENT_VER              = "1.09"
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"
GREMLIN_HOLDER_DIR       = "/home/operator/gcode/python/gremlin_tool_holders"
HOLDER_BUILDER_PATH      = "/home/operator/gcode/python/gremlin_tool_holder_builder.py"
ENABLED                  = 1
DEV_MACHINE              = 1
DEV_MACHINE_FLAG         = "/home/operator/gcode/python/dev_machine.txt"
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_BURST_LIMIT    = 6
EVENT_LOG_BURST_QUIET_MS = 3000
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")

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.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))
            conn.commit()
        finally:
            conn.close()

    def delete_reminder(self, reminder_id):
        conn = self.plugin._db_connect()
        try:
            c = conn.cursor()
            c.execute("DELETE FROM tool_reminders WHERE id = ?", (int(reminder_id),))
            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("DELETE FROM tool_reminders WHERE tool = ?", (int(tool_num),))
            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.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, total_seconds = row
                condition_text = self._get_due_condition_text(
                    now_dt,
                    due_datetime,
                    due_age_seconds,
                    due_usage_seconds,
                    created_at,
                    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, created_at, total_seconds):
        # Return the first trigger that crossed its limit. Order matches the existing due-check priority.
        # Age means time since this reminder was created, not cutter age since install.
        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:
            reminder_age_seconds = self._seconds_since_text_datetime(now_dt, created_at)
            if reminder_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.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, 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,
                created_at,
                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)))
            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.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_suppressed_until = None        
        self.tool_model_signal_ids = []
        self.modern_tool_edit_signal_ids = []
        self.refresh_pending = False
        self.modern_tool_table = False
        glib.timeout_add(1000, self._install_when_ready)

    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._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._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 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 _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]))
        finally:
            conn.close()
        self.tool_usage_cache = usage
        self.tool_usage_seconds_cache = usage_seconds
        self.tool_life_state_cache = state
        self._refresh_triggered_reminder_cache()

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

    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 _refresh_pathpilot_metadata_cache(self):
        cache = {}
        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
                cache[int(row["tool"])] = self._metadata_tuple_from_row(row)
        except Exception as e:
            self._write_status("[Tool Life Column] PathPilot metadata cache refresh failed: {}".format(str(e)))
        self.pathpilot_metadata_cache = cache

    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_tool_event_log(self, tool_num, field_name, old_value, new_value):
        if self._pathpilot_event_log_is_suppressed():
            return
        field_label = {
            "description": "description",
            "diameter": "diameter",
            "length": "length"}.get(field_name, field_name)
        event_text = "%s changed from '%s' to '%s'" % (
            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")
        self.pathpilot_event_log_queue.append((
            int(tool_num),
            logged_at,
            event_text,
            self._normalize_text(field_name),
            self._normalize_text(old_value),
            self._normalize_text(new_value)))
        if len(self.pathpilot_event_log_queue) > EVENT_LOG_BURST_LIMIT:
            self._drop_pathpilot_event_log_burst()
            return
        if self.pathpilot_event_log_flush_pending:
            return
        self.pathpilot_event_log_flush_pending = True
        glib.timeout_add(EVENT_LOG_FLUSH_DELAY_MS, self._flush_pathpilot_event_log_queue)

    def _pathpilot_event_log_is_suppressed(self):
        if self.pathpilot_event_log_suppressed_until is None:
            return False
        return datetime.datetime.now() <= self.pathpilot_event_log_suppressed_until

    def _drop_pathpilot_event_log_burst(self):
        # PathPilot can rebuild/reload the whole tool table during boot or NC program load.
        # A user can only edit one cell at a time, so a burst is internal churn, not useful history.
        self.pathpilot_event_log_queue = []
        self.pathpilot_event_log_flush_pending = False
        self.pathpilot_event_log_suppressed_until = (
            datetime.datetime.now() + datetime.timedelta(milliseconds=EVENT_LOG_BURST_QUIET_MS))

    def _flush_pathpilot_event_log_queue(self):
        rows = self.pathpilot_event_log_queue
        self.pathpilot_event_log_queue = []
        self.pathpilot_event_log_flush_pending = False
        if len(rows) <= 0:
            return False
        if len(rows) > EVENT_LOG_BURST_LIMIT:
            self._drop_pathpilot_event_log_burst()
            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
            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):
        # Insert/delete/rebuild events can invalidate the metadata cache. Coalesce
        # an entire OEM table rebuild into one idle refresh.
        if self.refresh_pending:
            return
        self.refresh_pending = True
        glib.idle_add(self._refresh_after_tool_model_structure_change)

    def _refresh_after_tool_model_structure_change(self):
        if self._tool_table_edit_is_active():
            glib.timeout_add(250, self._refresh_after_tool_model_structure_change)
            return False
        self.refresh_pending = False
        try:
            self._refresh_pathpilot_metadata_cache()
        except Exception as e:
            self._write_status("[Tool Life Column] Model structure refresh failed: {}".format(str(e)))
        return False

    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.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.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 _install_life_column(self):
        if self.installed:
            return
        treeview = self.ui.tool_treeview
        for column in treeview.get_columns():
            try:
                if column.get_title() == "Manage":
                    self.life_column = column
                elif column.get_title() == "Usage":
                    self.usage_column = column
            except:
                pass
        if self.life_column is not None and self.usage_column is not None:
            self.installed = True
            self._make_all_tool_columns_resizable()
            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)
            self._write_status("[Tool Life Column] Manage and Usage columns already exist.")
            return
        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._make_all_tool_columns_resizable()
        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 and Usage columns installed.")

    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):
        current_state = (
            self._normalize_text(row["description"]),
            self._normalize_text(row["diameter"]),
            self._normalize_text(row["length"]))
        saved_state = self.tool_life_state_cache.get(tool_num)
        if saved_state is not None and current_state != saved_state:
            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"])
        current_state = self._get_current_row_state_from_iter(model, tree_iter)
        saved_state = self.tool_life_state_cache.get(tool_num)
        if saved_state is not None and current_state != saved_state:
            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 _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)
            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)
            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(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()
            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_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
            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,
                        created_at,
                        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(
                    """DELETE FROM tool_reminders
                       WHERE tool = ?
                         AND triggered_at IS NOT NULL
                         AND triggered_at != ''""",
                    (int(tool_num),))
                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 vs Cutter</b>\n\n"
            "The TLM assumes normal machine shop vocabulary: The Tool is the holder. "
            "The Cutter is the end mill, drill, etc, that is installed into the Tool Holder. "
            "A Tool will house many cutters over its lifetime.\n\n"
            "<b>Install?</b>\n\n"
            "Tool has not been registered into the Tool Life Manager yet. Click to formally log Install date "
            "(adjust as necessary) and edit any accrued usage.\n\n"
            "The first time this script is loaded, all current tools are auto-commissioned, so Install? will be shown on any subsequent tools. Note that a tool must have a set diameter and length to be considered installable.\n\n"
            "<b>Synced</b>\n\n"
            "The tool is being tracked and PathPilot metadata (diameter and length) matches the TLM.\n\n"
            "<b>Update?</b>\n\n"
            "PathPilot metadata no longer matches the TLM. This usually means the user has adjusted diameter "
            "and/or length, suggesting a new cutter has been installed.\n\n"
            "Clicking update allows you to formally log the cutter swap.\n\n"
            "<b>Clear Update (No Log)</b>\n\n"
            "Available in the Tool Metadata card whenever this tool shows Update?, and also from the tool-table right-click menu. "
            "It accepts the current PathPilot description, diameter, and length as the new TLM baseline "
            "without recording a cutter replacement, history row, or metadata-sync event. Use this for "
            "minor edits that should not represent a formal cutter change.\n\n"
            "<b>Remove</b>\n\n"
            "Allows you to delete the PathPilot Tool Table entry and, optionally, the TLM entries.")
        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.
        self.pathpilot_event_log_suppressed_until = (
            datetime.datetime.now() + datetime.timedelta(seconds=30))
        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")
            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):
        self.ui.set_tool_description(tool_num, "")
        self.ui.set_tool_diameter(row_index, "0.0000", model)
        self.ui.issue_tool_offset_command("Z", tool_num, 0.0)

        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:
                model[row_index][1] = ""
            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
            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")

        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 Data</b>\n\n"
            "The table shows the current cutter record and prior cutter history for this tool holder.\n\n"
            "Installed is when that cutter record began.\n"
            "Replaced is when that cutter record ended.\n"
            "Age is calendar time between installed and replaced, or installed and now for the current cutter.\n"
            "Usage is spindle/runtime tracked for that cutter.\n"
            "Removal Reason belongs to the cutter on that row and explains why that cutter left service. The Current cutter stays blank until it is replaced.\n\n"
            "All visible fields in this table are editable. Click a cell to select it, then click/edit the cell value. "
            "Usage and age fields accept normal time text such as 12m 30s, 1h 5m, or seconds.\n\n"
            "<b>Sync Metadata Only</b>\n\n"
            "Use this when you changed PathPilot metadata but did not replace the cutter. "
            "The most common example is changing only the description.\n\n"
            "This updates the saved Tool Life Manager description, diameter, and length baseline so Update? becomes Synced.\n\n"
            "It does not reset usage.\n"
            "It does not replace the cutter.\n"
            "It does not create a cutter history row.\n"
            "It may create an event log entry so the sync is traceable.\n\n"
            "<b>Replace Cutter</b>\n\n"
            "Use this when the cutter in the holder was actually replaced. "
            "The current cutter record is closed into history, a new current cutter record is started, and usage tracking begins again for the new cutter.\n\n"
            "The Replacement Reason field is saved on the outgoing cutter\'s history row as its Removal Reason. The newly installed cutter starts with a blank Removal Reason.\n\n"
            "<b>Delete Row</b>\n\n"
            "Deletes the selected historical row. Select a History row in the table first. "
            "This is for cleanup of a bad or unwanted historical cutter record.\n\n"
            "<b>Delete All</b>\n\n"
            "Deletes all historical cutter rows for this tool. This is cleanup only. "
            "Use carefully, because it removes the history records shown in this popup.\n\n"
            "<b>Reminders</b>\n\n"
            "Reminders are per-tool notes that can trigger later by date/time, by age, by usage, or by a combination of those conditions.\n\n"
            "Add Reminder creates a new reminder for this tool.\n"
            "Remove deletes the selected reminder.\n"
            "Remove all Reminders deletes every reminder for this tool.\n\n"
            "Triggered reminders are shown in the tool table with a warning marker and can also be cleared from the right-click menu.\n\n"
            "<b>Show Event Log</b>\n\n"
            "Shows recorded metadata edits and Tool Life Manager actions for this tool. "
            "This is informational and does not change tool life data.\n\n"
            "<b>Close</b>\n\n"
            "Closes this popup. Any edits already saved remain saved.")
        self._show_scrollable_help_dialog("Tool Life Popup Help", message)

    def _build_life_dialog_title(self, tool_num):
        title_row = gtk.HBox(False, 6)
        title = gtk.Label()
        title.set_markup('<span weight="bold" size="large">T{} Tool Life</span>'.format(tool_num))
        title.set_alignment(0.0, 0.5)
        help_button = gtk.Button("Help")
        help_button.set_size_request(70, 28)
        help_button.connect("clicked", self._on_life_dialog_help_button, tool_num)
        title_row.pack_start(title, True, True, 0)
        title_row.pack_end(help_button, False, False, 0)
        return title_row

    def _holder_svg_filename(self, holder_type):
        safe = []
        for char in self._normalize_text(holder_type).lower():
            if char.isalnum() or char in ("_", "-"):
                safe.append(char)
            else:
                safe.append("_")
        name = "".join(safe).strip("_") or "holder"
        return os.path.join(GREMLIN_HOLDER_DIR, "%s.svg" % name)

    def _generate_holder_svg(self, holder_type, holder_data, edge_enabled, edge_width):
        sections = holder_data.get("sections")
        if not isinstance(sections, list) or not sections:
            return ""

        try:
            total_h = sum(max(0.001, float(sec.get("height_inches", 0.001))) for sec in sections if isinstance(sec, dict))
            max_d = max(
                max(float(sec.get("bottom_diameter_inches", 0.001)),
                    float(sec.get("top_diameter_inches", sec.get("bottom_diameter_inches", 0.001))))
                for sec in sections if isinstance(sec, dict))
        except Exception:
            return ""

        if total_h <= 0 or max_d <= 0:
            return ""

        width = 180.0
        height = 240.0
        margin = 14.0
        scale = min((height - 2.0 * margin) / total_h,
                    (width - 2.0 * margin) / max_d)
        cx = width / 2.0
        y = height - margin

        try:
            edge_width = float(edge_width)
        except Exception:
            edge_width = 1.5
        edge_width = max(0.5, min(10.0, edge_width))
        stroke = "#000000" if edge_enabled else "none"

        parts = [
            '<?xml version="1.0" encoding="UTF-8"?>',
            '<svg xmlns="http://www.w3.org/2000/svg" width="180" height="240" viewBox="0 0 180 240">',
            '<rect x="0" y="0" width="180" height="240" fill="#ffffff"/>',
            '<g stroke="%s" stroke-width="%.3f" stroke-linejoin="round" fill="#c8c8c8">' % (stroke, edge_width),
        ]

        for sec in sections:
            if not isinstance(sec, dict):
                continue
            try:
                h_in = max(0.001, float(sec.get("height_inches", 0.001)))
                bd = max(0.001, float(sec.get("bottom_diameter_inches", 0.001)))
                td = max(0.001, float(sec.get("top_diameter_inches", bd)))
                facets = max(3, int(float(sec.get("facets", 32))))
            except Exception:
                continue

            y2 = y - h_in * scale
            xb_l = cx - bd * scale / 2.0
            xb_r = cx + bd * scale / 2.0
            xt_l = cx - td * scale / 2.0
            xt_r = cx + td * scale / 2.0

            if facets == 6:
                chamfer = min((y - y2) * 0.22, min(bd, td) * scale * 0.10)
                points = [
                    (xb_l + chamfer, y), (xb_r - chamfer, y),
                    (xb_r, y - chamfer), (xt_r, y2 + chamfer),
                    (xt_r - chamfer, y2), (xt_l + chamfer, y2),
                    (xt_l, y2 + chamfer), (xb_l, y - chamfer),
                ]
            else:
                points = [(xb_l, y), (xb_r, y), (xt_r, y2), (xt_l, y2)]

            parts.append('<polygon points="%s"/>' %
                         " ".join("%.3f,%.3f" % (x, py) for x, py in points))
            y = y2

        parts.append("</g>")
        parts.append("</svg>")

        try:
            if not os.path.isdir(GREMLIN_HOLDER_DIR):
                os.makedirs(GREMLIN_HOLDER_DIR)
            path = self._holder_svg_filename(holder_type)
            handle = open(path, "wb")
            try:
                handle.write(("\n".join(parts) + "\n").encode("utf-8"))
            finally:
                handle.close()
            return path
        except Exception as e:
            self._write_status("[Tool Life Column] Holder SVG write failed: {}".format(str(e)))
            return ""

    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)))
                edge_enabled = bool(config.get("edge_enabled", True))
                try:
                    edge_width = float(config.get("edge_width", 1.5))
                except Exception:
                    edge_width = 1.5

                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._generate_holder_svg(holder_type, holder_data, edge_enabled, edge_width)
                        holder_rows.append((
                            holder_type,
                            self._normalize_text(holder_data.get("display_name", holder_type)),
                            image_path))
                elif isinstance(config.get("sections"), list):
                    holder_type = self._normalize_text(config.get("holder_model", default_holder_type)) or default_holder_type
                    holder_data = {"sections": config.get("sections", [])}
                    image_path = self._generate_holder_svg(holder_type, holder_data, edge_enabled, edge_width)
                    holder_rows.append((holder_type, holder_type.replace("_", " "), image_path))
                    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()
        try:
            self.ui.gremlin.cached_tool = None
            self.ui.gremlin.queue_draw()
        except:
            pass

    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 if holder_type else "Default: %s" % display_name
        return effective if holder_type else "Default: %s" % 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):
        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:
            subprocess.Popen([
                sys.executable,
                HOLDER_BUILDER_PATH,
                GREMLIN_CONFIG_PATH])
            self._write_status("[Tool Life Column] Holder Builder launched.")
        except Exception as e:
            self._show_reminder_info_dialog(
                "Could not launch Holder Builder:\n\n%s" % str(e))

    def _build_holder_assignment_row(self, tool_num, refresh_dialog):
        default_holder_type, holder_rows = self._load_holder_catalog()

        outer = gtk.HBox(False, 10)

        thumbnail = gtk.Image()
        thumbnail.set_size_request(100, 135)

        image_event = gtk.EventBox()
        image_event.set_visible_window(True)
        image_event.add(thumbnail)
        outer.pack_start(image_event, False, False, 0)

        controls = gtk.VBox(False, 5)
        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(300, -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)

        details = gtk.Label("")
        details.set_alignment(0.0, 0.5)
        details.set_ellipsize(pango.ELLIPSIZE_END)

        controls.pack_start(combo, False, False, 0)
        controls.pack_start(details, False, False, 0)

        holder_button_row = gtk.HBox(False, 6)
        holder_help_button = gtk.Button("Help / Warning")
        edit_holders_button = gtk.Button("Add / Edit Holders")
        holder_button_row.pack_start(holder_help_button, False, False, 0)
        holder_button_row.pack_start(edit_holders_button, False, False, 0)
        controls.pack_start(holder_button_row, False, False, 4)

        current_preview = {
            "path": "",
            "name": "",
        }

        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

            details.set_text("%s  [%s]" % (display_name, effective))
            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)

        combo.connect("changed", on_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", self._launch_holder_builder)

        update_details(assigned_holder)
        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, 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,
                    created_at,
                    total_seconds,
                    triggered_at),
                self._format_reminder_status(triggered_at)
            ])

    def _age_seconds_from_reminder_created(self, created_at_text):
        created_dt = self._parse_datetime(created_at_text)
        if created_dt:
            elapsed = datetime.datetime.now() - created_dt
            return int(elapsed.days * 86400 + elapsed.seconds)
        return 0

    def _format_reminder_time_remaining(self, due_datetime, due_age_seconds, due_usage_seconds, created_at, 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_reminder_created(created_at)
            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 a specific date/time. Format: YYYY-MM-DD HH:MM:SS")
        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:
            if not self._parse_datetime(due_datetime):
                self._show_reminder_info_dialog("Invalid date/time. Use YYYY-MM-DD HH:MM:SS.")
                return
        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),
                        row["description"],
                        row["diameter"],
                        row["length"],
                        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("DELETE FROM tool_history WHERE id = ? AND tool = ?", (record_id, tool_num))
                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("DELETE FROM tool_history WHERE tool = ?", (tool_num,))
                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 _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._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)
        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)
        dialog.set_default_size(900, 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)
        summary_row.pack_start(metadata_frame, True, True, 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)
        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")
        holder_assignment_box = gtk.VBox(False, 2)
        holder_assignment_box.set_border_width(8)
        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)

        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)

        def rebuild_controls():
            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), 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()

        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)

    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."
        labels = ["Description", "Diameter", "Length"]
        current = (row["description"], row["diameter"], row["length"])
        lines = []
        for index in xrange(0, len(labels)):
            if current[index] != saved_state[index]:
                lines.append("{}: {} -> {}".format(labels[index], saved_state[index], current[index]))
        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, row["description"], row["diameter"], row["length"], 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, row["description"], row["diameter"], row["length"], 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, row["description"], row["diameter"], row["length"], 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):
        if not text_value:
            return None
        for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
            try:
                return datetime.datetime.strptime(text_value.strip(), fmt)
            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:
                        c.execute(
                            """INSERT INTO tool_life_state
                               (tool, last_description, last_diameter, last_length, synced_at)
                               VALUES (?, ?, ?, ?, ?)""",
                            (tool_num, description, diameter, length, 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
        months = total_minutes / (30 * 24 * 60)
        total_minutes = total_minutes % (30 * 24 * 60)
        days = total_minutes / (24 * 60)
        total_minutes = total_minutes % (24 * 60)
        hours = total_minutes / 60
        minutes = total_minutes % 60
        parts = []
        if months > 0:
            parts.append("%d mo" % months)
            parts.append("%d d" % days)
            if hours > 0:
                parts.append("%d hr" % hours)
            return " ".join(parts)
        if days > 0:
            parts.append("%d d" % days)
            if hours > 0:
                parts.append("%d hr" % hours)
            if minutes > 0:
                parts.append("%d min" % minutes)
            return " ".join(parts)
        if hours > 0:
            parts.append("%d hr" % hours)
            if minutes > 0:
                parts.append("%d min" % minutes)
            return " ".join(parts)
        return "%d min" % minutes

    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:
                edit_field_name = "installed"
                old_display = installed_date
                new_display = new_text
                installed_date = new_text
            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:
                edit_field_name = "installed"
                old_display = installed_date
                new_display = new_text
                installed_date = new_text
            elif model_index == 2:
                edit_field_name = "replaced"
                old_display = reset_date
                new_display = new_text
                reset_date = new_text
            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