# 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

#############################################
##                                         ##
##        Cycle Time Trend 1.03            ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 1.03 - reads merged filename history through program aliases stored in the existing edit DB - 9/11/2026
# 1.02 - compact top-row controls; native gray mode buttons - 8/21/2026
# 1.01 - total-job / segment trend toggle - 8/21/2026
# 1.00 - public beta - 8/15/2026

# =============================================================================
# CYCLE TIME TREND
# =============================================================================
# Purpose:
#   Shows how a loaded program's historical cycle-time median and mean evolved
#   after each completed run. The raw G-code estimate is shown alongside the
#   measured actuals so it is easy to see the estimator "learn" the job.
#
# Data sources:
#   cycle_time_cycle_log.txt      - completed total-cycle history and raw estimates
#   cycle_time_tool_log.txt       - completed per-segment history and raw estimates
#   cycle_time_history_edits.db   - edited/excluded run and segment corrections
#   cycle_time_tab_status.txt     - current loaded program/total raw estimate fallback
#   cycle_time_tools_on_deck_status.txt - current segment estimate fallback
#
# History behavior:
#   - Only completed runs contribute to median/mean.
#   - Excluded or hidden runs do not contribute.
#   - Edited actual times replace the logged actual time.
#   - Trend history is reconstructed retroactively from existing logs; no new
#     trend database or trend log is required.
#   - Run 0 represents the raw estimate before any completed history exists.
#
# Display behavior:
#   - Opens maximized on PathPilot.
#   - Raw, Mean, Median, and Actual can be toggled from the legend.
#   - The graph scale stays fixed while series are toggled.
#   - Best, worst, and spread are shown in the coolest graph corner.
#   - The table header stays fixed while the data rows scroll.
#
# Compatibility:
#   Written for PathPilot's Python 2 environment using Tkinter only.
# =============================================================================

import os
import re
import sys
import sqlite3
import Tkinter as tk
import tkFont

CURRENT_VER   = "1.03"
SCRIPT_NAME   = "Cycle Time Trend"
DESCRIPTION   = "Shows how calculated, mean, median, and actual cycle times evolve over completed runs."

LOG_DIR       = "/home/operator/gcode/python"
CYCLE_LOG     = os.path.join(LOG_DIR, "cycle_time_cycle_log.txt")
TOOL_LOG      = os.path.join(LOG_DIR, "cycle_time_tool_log.txt")
TOOLS_STATUS  = os.path.join(LOG_DIR, "cycle_time_tools_on_deck_status.txt")
EDIT_DB       = os.path.join(LOG_DIR, "cycle_time_history_edits.db")
STATUS_FILE   = os.path.join(LOG_DIR, "cycle_time_tab_status.txt")
#
WINDOW_W      = 1180
WINDOW_H      = 760
GRAPH_H       = 430
TABLE_ROWS    = 12
#
COLOR_BG      = "#101418"
COLOR_PANEL   = "#171d23"
COLOR_GRID    = "#313b46"
COLOR_BORDER  = "#5a6878"
COLOR_TEXT    = "#e5edf5"
COLOR_MUTED   = "#8fa1b3"
COLOR_ACTUAL  = "#4FA3FF"
COLOR_MEDIAN  = "#4FA3FF"
COLOR_MEAN    = "#FFD166"
COLOR_RAW     = "#FFD166"
#
LEFT_PAD      = 112
RIGHT_PAD     = 28
TOP_PAD       = 34
BOTTOM_PAD    = 42

def safe_text(value):
    if value is None:
        return ""
    return str(value).strip()

def parse_duration_seconds(text):
    text = safe_text(text).lower()
    if text == "" or text == "unknown":
        return None
    sign = 1
    if text.startswith("-"):
        sign = -1
        text = text[1:].strip()
    total = 0.0
    matched = False
    for value, unit in re.findall(r'([0-9]+(?:\.[0-9]+)?)\s*([dhms])', text):
        matched = True
        value = float(value)
        if unit == "d":
            total += value * 86400.0
        elif unit == "h":
            total += value * 3600.0
        elif unit == "m":
            total += value * 60.0
        elif unit == "s":
            total += value
    if matched:
        return total * sign
    try:
        return float(text) * sign
    except Exception:
        return None

def format_seconds(seconds):
    if seconds is None:
        return "--"
    seconds = int(round(float(seconds)))
    sign = ""
    if seconds < 0:
        sign = "-"
        seconds = abs(seconds)
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    secs = seconds % 60
    if hours > 0:
        return "%s%dh %02dm %02ds" % (sign, hours, minutes, secs)
    if minutes > 0:
        return "%s%dm %02ds" % (sign, minutes, secs)
    return "%s%ds" % (sign, secs)

def median_seconds(values):
    clean = sorted([float(value) for value in values if value is not None])
    count = len(clean)
    if count == 0:
        return None
    middle = count // 2
    if count % 2:
        return clean[middle]
    return (clean[middle - 1] + clean[middle]) / 2.0

def mean_seconds(values):
    clean = [float(value) for value in values if value is not None]
    if len(clean) == 0:
        return None
    return sum(clean) / float(len(clean))

def split_pipe_row(line):
    parts = [part.strip() for part in line.rstrip("\r\n").split("|")]
    if len(parts) < 2:
        return []
    if parts[0] == "Timestamp" or parts[0].startswith("-"):
        return []
    cleaned = line.replace("|", "").replace("-", "").replace(" ", "")
    if cleaned == "":
        return []
    return parts

def get_program_path_from_arg():
    if len(sys.argv) > 1:
        value = safe_text(sys.argv[1])
        if value:
            return os.path.normpath(value)
    return get_program_path_from_status()

def get_program_path_from_status():
    try:
        if os.path.isfile(STATUS_FILE):
            handle = open(STATUS_FILE, "r")
            try:
                for raw_line in handle:
                    line = raw_line.strip()
                    if line.startswith("/") and os.path.splitext(line)[1].lower() == ".nc":
                        return os.path.normpath(line)
            finally:
                handle.close()
    except Exception:
        pass
    return ""

def get_current_raw_from_status(program_path):
    try:
        if not os.path.isfile(STATUS_FILE):
            return None
        status_program = ""
        raw_seconds = None
        handle = open(STATUS_FILE, "r")
        try:
            for raw_line in handle:
                line = raw_line.strip()
                if line.startswith("/") and os.path.splitext(line)[1].lower() == ".nc":
                    status_program = os.path.normpath(line)
                if "Raw:" in line:
                    match = re.search(r'Raw:\s*([^|]+)', line, re.IGNORECASE)
                    if match:
                        raw_seconds = parse_duration_seconds(match.group(1))
        finally:
            handle.close()
        if status_program == os.path.normpath(program_path):
            return raw_seconds
    except Exception:
        pass
    return None

def load_program_aliases():
    aliases = {}
    try:
        connection = sqlite3.connect(EDIT_DB)
        try:
            cursor = connection.cursor()
            cursor.execute(
                "create table if not exists program_history_aliases "
                "(alias_path text primary key, canonical_path text not null, created_at text)")
            connection.commit()
            cursor.execute("select alias_path, canonical_path from program_history_aliases")
            for alias_path, canonical_path in cursor.fetchall():
                alias_path = os.path.normpath(str(alias_path or ""))
                canonical_path = os.path.normpath(str(canonical_path or ""))
                if alias_path and canonical_path and alias_path != canonical_path:
                    aliases[alias_path] = canonical_path
        finally:
            connection.close()
    except Exception:
        pass
    return aliases

def resolve_program_path(program_path, aliases):
    current = os.path.normpath(str(program_path or ""))
    visited = set()
    while current in aliases and current not in visited:
        visited.add(current)
        current = os.path.normpath(aliases[current])
    return current

def get_program_history_keys(program_path):
    aliases = load_program_aliases()
    canonical_path = resolve_program_path(program_path, aliases)
    keys = set([canonical_path, os.path.normpath(str(program_path or ""))])
    for alias_path in aliases:
        if resolve_program_path(alias_path, aliases) == canonical_path:
            keys.add(alias_path)
    return keys

class HistoryEdits(object):
    def __init__(self, db_path):
        self.db_path = db_path
        self.rows = {}
        self.segment_rows = {}
        self.load()

    def load(self):
        self.rows = {}
        self.segment_rows = {}
        if not os.path.isfile(self.db_path):
            return
        connection = sqlite3.connect(self.db_path)
        try:
            cursor = connection.cursor()
            try:
                cursor.execute("select run_id, actual_seconds, excluded, hidden from cycle_history_edits")
                for run_id, actual_seconds, excluded, hidden in cursor.fetchall():
                    self.rows[str(run_id)] = {
                        "actual_seconds": actual_seconds,
                        "excluded": int(excluded or 0),
                        "hidden": int(hidden or 0)}
            except sqlite3.OperationalError:
                try:
                    cursor.execute("select run_id, actual_seconds, excluded from cycle_history_edits")
                    for run_id, actual_seconds, excluded in cursor.fetchall():
                        self.rows[str(run_id)] = {
                            "actual_seconds": actual_seconds,
                            "excluded": int(excluded or 0),
                            "hidden": 0}
                except sqlite3.OperationalError:
                    pass
            try:
                cursor.execute("select run_id, segment_number, actual_seconds, excluded from segment_history_edits")
                for run_id, segment_number, actual_seconds, excluded in cursor.fetchall():
                    self.segment_rows[(str(run_id), int(segment_number))] = {
                        "actual_seconds": actual_seconds,
                        "excluded": int(excluded or 0)}
            except sqlite3.OperationalError:
                pass
        finally:
            connection.close()

    def apply(self, run_id, actual_seconds):
        edit = self.rows.get(str(run_id), {})
        if edit.get("excluded") or edit.get("hidden"):
            return None, True
        if edit.get("actual_seconds") is not None:
            return float(edit.get("actual_seconds")), False
        return actual_seconds, False

    def apply_segment(self, run_id, segment_number, actual_seconds):
        edit = self.segment_rows.get((str(run_id), int(segment_number)), {})
        if edit.get("excluded"):
            return None, True
        if edit.get("actual_seconds") is not None:
            return float(edit.get("actual_seconds")), False
        return actual_seconds, False

class TrendData(object):
    def __init__(self, program_path):
        self.program_path = os.path.normpath(program_path) if program_path else ""
        self.history_program_paths = get_program_history_keys(self.program_path) if self.program_path else set()
        self.edits = HistoryEdits(EDIT_DB)
        self.mode = "total"
        self.segment_number = None
        self.segment_choices = []
        self.segment_tool_text = ""
        self.runs = []
        self.points = []
        self.current_raw_seconds = None
        self.reload()

    def reload(self):
        self.edits.load()
        self.history_program_paths = get_program_history_keys(self.program_path) if self.program_path else set()
        self.runs = []
        self.points = []
        self.segment_choices = self.scan_segment_choices()
        if self.mode == "segment":
            available_numbers = [item[0] for item in self.segment_choices]
            if self.segment_number not in available_numbers:
                self.segment_number = available_numbers[0] if available_numbers else None
            self.current_raw_seconds = self.get_current_segment_estimate(self.segment_number)
            self.read_segment_log()
        else:
            self.current_raw_seconds = get_current_raw_from_status(self.program_path)
            self.read_cycle_log()
        self.build_points()

    def scan_segment_choices(self):
        choices = {}
        if self.program_path and os.path.isfile(TOOL_LOG):
            handle = open(TOOL_LOG, "r")
            try:
                for raw_line in handle:
                    parts = split_pipe_row(raw_line)
                    if len(parts) < 9:
                        continue
                    program = os.path.normpath(parts[1])
                    if program not in self.history_program_paths:
                        continue
                    match = re.search(r'(\d+)', parts[2])
                    if not match:
                        continue
                    segment_number = int(match.group(1))
                    tool_text = parts[3] if len(parts) > 3 else ""
                    if segment_number not in choices or not choices[segment_number]:
                        choices[segment_number] = tool_text
            finally:
                handle.close()
        # The live Tools On Deck status can expose segments before the first
        # completed segment has ever been logged. Its tool rows are in segment order.
        if self.program_path and os.path.isfile(TOOLS_STATUS):
            status_program = ""
            status_tools = []
            handle = open(TOOLS_STATUS, "r")
            try:
                for raw_line in handle:
                    line = raw_line.strip()
                    if line.startswith("program="):
                        status_program = os.path.normpath(line.split("=", 1)[1].strip())
                    elif line and "|" in line and not line.startswith("summary|"):
                        parts = line.split("|")
                        if len(parts) >= 5:
                            status_tools.append(parts[0].strip())
            finally:
                handle.close()
            if status_program == self.program_path:
                for index, tool_text in enumerate(status_tools):
                    segment_number = index + 1
                    if segment_number not in choices:
                        choices[segment_number] = tool_text
        return [(number, choices[number]) for number in sorted(choices.keys())]

    def get_segment_label(self, segment_number):
        for number, tool_text in self.segment_choices:
            if number == segment_number:
                if tool_text:
                    return "Segment %d - %s" % (number, tool_text)
                return "Segment %d" % number
        if segment_number is not None:
            return "Segment %d" % segment_number
        return "No segments"

    def get_current_segment_estimate(self, segment_number):
        if segment_number is None or not self.program_path or not os.path.isfile(TOOLS_STATUS):
            return None
        status_program = ""
        tool_rows = []
        handle = open(TOOLS_STATUS, "r")
        try:
            for raw_line in handle:
                line = raw_line.strip()
                if line.startswith("program="):
                    status_program = os.path.normpath(line.split("=", 1)[1].strip())
                elif line and "|" in line and not line.startswith("summary|"):
                    parts = line.split("|")
                    if len(parts) >= 5:
                        tool_rows.append(parts)
        finally:
            handle.close()
        if status_program != self.program_path or segment_number < 1 or segment_number > len(tool_rows):
            return None
        # This status field is the current expected segment duration. It is only
        # a fallback for Run 0 when no logged calculated segment estimate exists.
        return parse_duration_seconds(tool_rows[segment_number - 1][4])

    def read_cycle_log(self):
        if self.program_path == "" or not os.path.isfile(CYCLE_LOG):
            return
        handle = open(CYCLE_LOG, "r")
        try:
            for raw_line in handle:
                parts = split_pipe_row(raw_line)
                if len(parts) < 8:
                    continue
                if len(parts) >= 10:
                    timestamp = parts[0]
                    program = os.path.normpath(parts[1])
                    estimated_text = parts[2]
                    actual_text = parts[4]
                    result_text = parts[7].strip().lower().replace("[", "").replace("]", "")
                    run_id = parts[8]
                else:
                    timestamp = parts[0]
                    program = os.path.normpath(parts[1])
                    estimated_text = parts[2]
                    actual_text = parts[3]
                    result_text = parts[6].strip().lower().replace("[", "").replace("]", "")
                    run_id = parts[7]
                if program not in self.history_program_paths or result_text != "completed":
                    continue
                raw_seconds = parse_duration_seconds(estimated_text)
                actual_seconds = parse_duration_seconds(actual_text)
                if actual_seconds is None:
                    continue
                actual_seconds, excluded = self.edits.apply(run_id, actual_seconds)
                if excluded or actual_seconds is None:
                    continue
                self.runs.append({
                    "timestamp": timestamp,
                    "run_id": run_id,
                    "raw_seconds": raw_seconds,
                    "actual_seconds": actual_seconds})
        finally:
            handle.close()

    def read_segment_log(self):
        if self.program_path == "" or self.segment_number is None or not os.path.isfile(TOOL_LOG):
            return
        handle = open(TOOL_LOG, "r")
        try:
            for raw_line in handle:
                parts = split_pipe_row(raw_line)
                if len(parts) < 10:
                    continue
                program = os.path.normpath(parts[1])
                match = re.search(r'(\d+)', parts[2])
                if program not in self.history_program_paths or not match or int(match.group(1)) != self.segment_number:
                    continue
                if len(parts) >= 11:
                    timestamp = parts[0]
                    tool_text = parts[3]
                    estimated_text = parts[4]
                    actual_text = parts[6]
                    result_text = parts[9].strip().lower().replace("[", "").replace("]", "")
                    run_id = parts[10]
                else:
                    # Compatibility with older segment logs that did not include Expected.
                    timestamp = parts[0]
                    tool_text = parts[3]
                    estimated_text = parts[4]
                    actual_text = parts[5]
                    result_text = parts[8].strip().lower().replace("[", "").replace("]", "")
                    run_id = parts[9] if len(parts) > 9 else ""
                if result_text != "completed":
                    continue
                raw_seconds = parse_duration_seconds(estimated_text)
                actual_seconds = parse_duration_seconds(actual_text)
                if actual_seconds is None:
                    continue
                actual_seconds, excluded = self.edits.apply_segment(run_id, self.segment_number, actual_seconds)
                if excluded or actual_seconds is None:
                    continue
                if tool_text:
                    self.segment_tool_text = tool_text
                self.runs.append({
                    "timestamp": timestamp,
                    "run_id": run_id,
                    "raw_seconds": raw_seconds,
                    "actual_seconds": actual_seconds})
        finally:
            handle.close()

    def build_points(self):
        actual_values = []
        first_raw = self.current_raw_seconds
        if len(self.runs) > 0 and self.runs[0].get("raw_seconds") is not None:
            first_raw = self.runs[0].get("raw_seconds")
        self.points.append({
            "run_number": 0,
            "timestamp": "Before runs",
            "actual_seconds": None,
            "median_seconds": None,
            "mean_seconds": None,
            "raw_seconds": first_raw})
        for index, run in enumerate(self.runs):
            actual_values.append(run.get("actual_seconds"))
            raw_seconds = run.get("raw_seconds")
            if raw_seconds is None:
                raw_seconds = first_raw
            self.points.append({
                "run_number": index + 1,
                "timestamp": run.get("timestamp", ""),
                "actual_seconds": run.get("actual_seconds"),
                "median_seconds": median_seconds(actual_values),
                "mean_seconds": mean_seconds(actual_values),
                "raw_seconds": raw_seconds})

    def get_value_range(self):
        values = []
        for point in self.points:
            for key in ("actual_seconds", "median_seconds", "mean_seconds", "raw_seconds"):
                value = point.get(key)
                if value is not None:
                    values.append(float(value))
        if len(values) == 0:
            return 0.0, 60.0
        low = min(values)
        high = max(values)
        spread = high - low
        if spread < 30.0:
            spread = 30.0
        pad = max(5.0, spread * 0.10)
        return max(0.0, low - pad), high + pad

class CycleTimeTrend(object):
    def __init__(self, root):
        self.root = root
        self.program_path = get_program_path_from_arg()
        self.data = TrendData(self.program_path)
        self.view_mode = tk.StringVar()
        self.view_mode.set("total")
        self.segment_choice = tk.StringVar()
        self.segment_choice.set("")
        self.font_title = tkFont.Font(family="Helvetica", size=12, weight="bold")
        self.font_normal = tkFont.Font(family="Helvetica", size=9)
        self.font_small = tkFont.Font(family="Helvetica", size=8)
        self.font_mono = tkFont.Font(family="DejaVu Sans Mono", size=9)
        # Series visibility controls. These are owned by the main Tk root and
        # are wired directly to the checkboxes in the legend.
        self.show_actual = tk.IntVar()
        self.show_median = tk.IntVar()
        self.show_mean = tk.IntVar()
        self.show_raw = tk.IntVar()
        self.show_actual.set(1)
        self.show_median.set(1)
        self.show_mean.set(1)
        self.show_raw.set(1)
        # Y-axis bounds are captured on data refresh and remain fixed while
        # series are toggled. Hiding a line must never move the remaining lines.
        self.graph_low = 0.0
        self.graph_high = 60.0
        self.root.title("%s - %s" % (
            SCRIPT_NAME,
            os.path.basename(self.program_path) if self.program_path else "No loaded program"))
        self.root.configure(bg=COLOR_BG)
        self.set_initial_geometry()
        self.build_ui()
        self.root.bind("<Configure>", self.on_window_configure)
        self.refresh_data()

    def set_initial_geometry(self):
        # PathPilot normally runs X11/Tk where -zoomed is the cleanest way to
        # request a maximized top-level window. Fall back to full-screen-sized
        # geometry if that attribute is unavailable on a particular build.
        try:
            self.root.attributes("-zoomed", True)
            self.root.minsize(800, 600)
            return
        except Exception:
            pass
        try:
            screen_w = int(self.root.winfo_screenwidth())
            screen_h = int(self.root.winfo_screenheight())
        except Exception:
            screen_w = WINDOW_W
            screen_h = WINDOW_H
        self.root.geometry("%dx%d+0+0" % (screen_w, screen_h))
        self.root.minsize(800, 600)

    def build_ui(self):
        header = tk.Frame(self.root, bg=COLOR_BG)
        header.pack(fill="x", padx=12, pady=(8, 4))

        # All primary controls live on one compact top row.  Mode selectors are
        # ordinary Tk Buttons so their native gray face matches Close/Help/Refresh.
        tk.Label(header, text="Trend:", bg=COLOR_BG, fg=COLOR_MUTED, font=self.font_small).pack(side="left", padx=(0, 4))
        self.total_job_button = tk.Button(
            header, text="Total job", width=10, command=lambda: self.set_view_mode("total"))
        self.total_job_button.pack(side="left", padx=(0, 4))
        self.segment_button = tk.Button(
            header, text="Segment", width=10, command=lambda: self.set_view_mode("segment"))
        self.segment_button.pack(side="left", padx=(0, 8))
        self.segment_menu = tk.OptionMenu(header, self.segment_choice, "")
        self.segment_menu.configure(width=22, highlightthickness=0)
        self.segment_menu.pack(side="left")

        tk.Button(header, text="Refresh", width=10, command=self.refresh_data).pack(side="right", padx=(4, 0))
        tk.Button(header, text="Help", width=10, command=self.show_help).pack(side="right", padx=(4, 0))
        tk.Button(header, text="Close", width=10, command=self.root.destroy).pack(side="right", padx=(4, 0))
        self.summary_label = tk.Label(self.root, text="", bg=COLOR_BG, fg=COLOR_MUTED, font=self.font_small, anchor="w")
        self.summary_label.pack(fill="x", padx=12, pady=(0, 4))
        graph_frame = tk.Frame(self.root, bg=COLOR_PANEL, bd=1, relief="solid")
        graph_frame.pack(fill="both", expand=True, padx=10, pady=(0, 6))
        self.canvas = tk.Canvas(graph_frame, bg=COLOR_BG, highlightthickness=0, height=GRAPH_H)
        self.canvas.pack(fill="both", expand=True)
        # Keep the legend completely outside the plotting canvas so it can never
        # collide with X-axis labels regardless of screen/window size.
        legend_frame = tk.Frame(graph_frame, bg=COLOR_BG, height=28)
        legend_frame.pack(fill="x", side="bottom")
        legend_frame.pack_propagate(False)
        self.build_legend(legend_frame)
        self.x_axis_label = tk.Label(
            legend_frame,
            text="completed run number",
            bg=COLOR_BG,
            fg=COLOR_MUTED,
            font=self.font_small)
        self.x_axis_label.pack(side="right", padx=(0, 16), pady=4)
        table_frame = tk.Frame(self.root, bg=COLOR_PANEL, bd=1, relief="solid")
        table_frame.pack(fill="x", padx=10, pady=(0, 10))
        # Fixed header: this widget never scrolls vertically with the data rows.
        self.table_header = tk.Label(
            table_frame,
            text=self.get_table_header_text(),
            bg=COLOR_PANEL,
            fg=COLOR_TEXT,
            font=self.font_mono,
            anchor="w",
            justify="left")
        self.table_header.pack(fill="x", side="top", padx=(3, 18), pady=(2, 0))
        separator = tk.Frame(table_frame, bg=COLOR_BORDER, height=1)
        separator.pack(fill="x", side="top")
        table_body = tk.Frame(table_frame, bg=COLOR_PANEL)
        table_body.pack(fill="x", side="top")
        self.table_text = tk.Text(
            table_body,
            height=TABLE_ROWS,
            bg=COLOR_PANEL,
            fg=COLOR_TEXT,
            insertbackground=COLOR_TEXT,
            relief="flat",
            wrap="none",
            font=self.font_mono)
        yscroll = tk.Scrollbar(table_body, orient="vertical", command=self.table_text.yview)
        self.table_text.configure(yscrollcommand=yscroll.set)
        yscroll.pack(side="right", fill="y")
        self.table_text.pack(side="left", fill="both", expand=True)
        self.table_text.configure(state="disabled")

    def build_legend(self, parent):
        # Checkbox + line sample + label are kept together as one legend item.
        # Toggling any box redraws immediately and rescales the Y axis using
        # only the currently visible series.
        items = [
            ("Raw estimate", COLOR_RAW, False, self.show_raw),
            ("Mean", COLOR_MEAN, True, self.show_mean),
            ("Median", COLOR_MEDIAN, True, self.show_median),
            ("Actual", COLOR_ACTUAL, False, self.show_actual)]
        for label_text, color, dashed, variable in items:
            item = tk.Frame(parent, bg=COLOR_BG)
            item.pack(side="left", padx=(10, 12), pady=2)
            checkbox = tk.Checkbutton(
                item,
                variable=variable,
                command=self.draw_graph,
                bg=COLOR_BG,
                activebackground=COLOR_BG,
                activeforeground=COLOR_TEXT,
                selectcolor="#FFD166",
                highlightthickness=0,
                bd=0)
            checkbox.pack(side="left", padx=(0, 2))
            swatch = tk.Canvas(item, width=26, height=12, bg=COLOR_BG, highlightthickness=0)
            swatch.pack(side="left")
            if dashed:
                swatch.create_line(1, 6, 25, 6, fill=color, width=2, dash=(5, 3))
            else:
                swatch.create_line(1, 6, 25, 6, fill=color, width=2)
            tk.Label(
                item,
                text=label_text,
                bg=COLOR_BG,
                fg=COLOR_TEXT,
                font=self.font_small).pack(side="left", padx=(4, 0))

    def show_help(self):
        # Standalone Tk equivalent of the information window used by other
        # TormachTips plugins. It is non-modal and stays above the trend window.
        window = tk.Toplevel(self.root)
        window.title("%s - Help" % SCRIPT_NAME)
        window.configure(bg=COLOR_BG)

        try:
            window.transient(self.root)
        except Exception:
            pass

        try:
            screen_w = int(window.winfo_screenwidth())
            screen_h = int(window.winfo_screenheight())
            window_w = int(screen_w * 0.90)
            window_h = int(screen_h * 0.90)
            window.geometry("%dx%d+%d+%d" % (
                window_w,
                window_h,
                int((screen_w - window_w) / 2),
                int((screen_h - window_h) / 2)))
        except Exception:
            window.geometry("850x620")

        title = tk.Label(
            window,
            text="%s %s" % (SCRIPT_NAME, CURRENT_VER),
            bg=COLOR_BG,
            fg=COLOR_TEXT,
            font=self.font_title,
            anchor="w")
        title.pack(fill="x", padx=12, pady=(10, 4))

        scroller = tk.Frame(window, bg=COLOR_PANEL)
        scroller.pack(fill="both", expand=True, padx=12, pady=(0, 8))

        help_text = tk.Text(
            scroller,
            bg=COLOR_PANEL,
            fg=COLOR_TEXT,
            wrap="word",
            relief="flat",
            font=self.font_normal,
            padx=10,
            pady=10)

        scrollbar = tk.Scrollbar(
            scroller,
            orient="vertical",
            command=help_text.yview)

        help_text.configure(yscrollcommand=scrollbar.set)
        scrollbar.pack(side="right", fill="y")
        help_text.pack(side="left", fill="both", expand=True)

        help_text.insert("1.0", self.get_help_text())
        help_text.configure(state="disabled")

        button_row = tk.Frame(window, bg=COLOR_BG)
        button_row.pack(fill="x", padx=12, pady=(0, 10))

        tk.Button(
            button_row,
            text="Close",
            width=12,
            command=window.destroy).pack(side="right")

        def on_key_press(event):
            if event.keysym in ("Escape", "Return", "KP_Enter"):
                window.destroy()
                return "break"
            return None

        window.bind("<KeyPress>", on_key_press)

        try:
            window.lift()
            window.focus_force()
        except Exception:
            pass

    def get_help_text(self):
        return """Cycle Time Trend shows how Cycle Time Monitor's estimates change as completed history accumulates. Use the Total job / Segment control to switch between full-cycle timing and one tool segment.

What the lines mean:

Raw estimate
  The calculated G-code estimate. This normally stays unchanged until the NC file itself changes.

Mean
  The arithmetic average of completed actual cycle times.

Median
  The middle completed actual cycle time. Median is less sensitive to unusual long or short runs and is the primary historical estimate used by Cycle Time Monitor.

Actual
  The measured completed cycle time for each run.

Run 0:
  Run 0 represents the estimate before any completed history exists. In Segment mode, the logged calculated segment estimate is used when available; the live expected segment value is only a fallback before history exists.

Legend checkboxes:
  Use the checkboxes to hide or show Raw, Mean, Median, and Actual.
  Hiding a series does not change the graph scale, so the remaining lines stay in exactly the same positions.

BEST / WORST / SPREAD:
  The larger rings identify the fastest and slowest Actual runs.
  The summary box is automatically placed in a relatively empty area of the graph.
  Spread is the time difference between the fastest and slowest completed Actual runs.

History edits:
  Changes made in Cycle Time History Editor are reflected here.
  Excluded or hidden total runs do not contribute to Mean or Median.
  Segment exclusions and edited segment Actual times are also honored in Segment mode.
  Edited Actual times replace the original logged value.

Table:
  The table shows Raw, Mean, Median, and Actual values after each completed run or completed segment sample.
  Its header remains fixed while the rows scroll.

Refresh:
  Reloads cycle history and History Editor changes from disk.

This display is reconstructed from existing Cycle Time Monitor history, so old completed runs can be graphed retroactively.
"""

    def on_window_configure(self, event):
        # Coalesce resize redraws; the actual plot always reads live canvas size.
        try:
            if event.widget is self.root:
                self.root.after_cancel(getattr(self, "_resize_after_id", ""))
        except Exception:
            pass
        try:
            self._resize_after_id = self.root.after(80, self.draw_graph)
        except Exception:
            pass

    def update_segment_menu(self):
        menu = self.segment_menu["menu"]
        menu.delete(0, "end")
        choices = self.data.segment_choices
        for segment_number, tool_text in choices:
            label = self.data.get_segment_label(segment_number)
            menu.add_command(label=label, command=lambda value=label: self.on_segment_selected(value))
        if choices:
            valid_labels = [self.data.get_segment_label(item[0]) for item in choices]
            if self.segment_choice.get() not in valid_labels:
                selected = self.data.segment_number if self.data.segment_number is not None else choices[0][0]
                self.segment_choice.set(self.data.get_segment_label(selected))
            self.segment_menu.configure(state="normal" if self.view_mode.get() == "segment" else "disabled")
        else:
            self.segment_choice.set("No segments")
            self.segment_menu.configure(state="disabled")

    def set_view_mode(self, mode):
        self.view_mode.set(mode)
        self.on_view_mode_changed()

    def update_mode_button_state(self):
        # Keep both controls the same native gray as the standard buttons; only
        # relief distinguishes the selected mode.
        if self.view_mode.get() == "segment":
            self.total_job_button.configure(relief="raised")
            self.segment_button.configure(relief="sunken")
        else:
            self.total_job_button.configure(relief="sunken")
            self.segment_button.configure(relief="raised")

    def on_view_mode_changed(self):
        self.data.mode = self.view_mode.get()
        if self.data.mode == "segment" and self.data.segment_number is None and self.data.segment_choices:
            self.data.segment_number = self.data.segment_choices[0][0]
        self.update_mode_button_state()
        self.refresh_data()

    def on_segment_selected(self, label):
        self.segment_choice.set(label)
        match = re.match(r'^Segment\s+(\d+)', label)
        if match:
            self.data.segment_number = int(match.group(1))
            self.data.mode = "segment"
            self.view_mode.set("segment")
            self.refresh_data()

    def refresh_data(self):
        self.data.mode = self.view_mode.get()
        self.update_mode_button_state()
        self.data.reload()
        self.update_segment_menu()
        self.graph_low, self.graph_high = self.data.get_value_range()
        if self.data.mode == "segment":
            self.x_axis_label.configure(text="completed segment sample number")
        else:
            self.x_axis_label.configure(text="completed run number")
        self.root.update_idletasks()
        self.draw_graph()
        self.draw_table()
        self.update_summary()

    def update_summary(self):
        filename = os.path.basename(self.program_path) if self.program_path else "No loaded program"
        completed_count = len(self.data.runs)
        if self.data.mode == "segment":
            context = self.data.get_segment_label(self.data.segment_number)
            count_label = "Completed samples"
        else:
            context = "Total job"
            count_label = "Completed runs"
        if completed_count > 0:
            latest = self.data.points[-1]
            actual_values = [
                point.get("actual_seconds")
                for point in self.data.points
                if point.get("actual_seconds") is not None]
            if actual_values:
                best_actual = min(actual_values)
                worst_actual = max(actual_values)
                actual_spread = worst_actual - best_actual
                range_text = "%s-%s (%s)" % (
                    format_seconds(best_actual),
                    format_seconds(worst_actual),
                    format_seconds(actual_spread))
            else:
                range_text = "--"
            text = "%s   |   %s   |   %s: %d   |   Median: %s   |   Mean: %s   |   Estimate: %s   |   Actual range: %s" % (
                filename,
                context,
                count_label,
                completed_count,
                format_seconds(latest.get("median_seconds")),
                format_seconds(latest.get("mean_seconds")),
                format_seconds(self.data.current_raw_seconds if self.data.current_raw_seconds is not None else latest.get("raw_seconds")),
                range_text)
        else:
            text = "%s   |   %s   |   No completed samples yet   |   Estimate: %s" % (
                filename,
                context,
                format_seconds(self.data.current_raw_seconds))
        self.summary_label.configure(text=text)

    def get_visible_series_keys(self):
        keys = []
        if self.show_actual.get():
            keys.append("actual_seconds")
        if self.show_median.get():
            keys.append("median_seconds")
        if self.show_mean.get():
            keys.append("mean_seconds")
        if self.show_raw.get():
            keys.append("raw_seconds")
        return keys

    def get_visible_value_range(self):
        values = []
        visible_keys = self.get_visible_series_keys()
        for point in self.data.points:
            for key in visible_keys:
                value = point.get(key)
                if value is not None:
                    values.append(float(value))
        if len(values) == 0:
            return 0.0, 60.0
        low = min(values)
        high = max(values)
        spread = high - low
        if spread < 30.0:
            spread = 30.0
        # Keep only a small amount of breathing room above/below the data.
        # Five percent makes better use of the available graph height.
        pad = max(5.0, spread * 0.10)
        return max(0.0, low - pad), high + pad

    def draw_graph(self):
        self.canvas.delete("all")
        # Critical: use only the canvas's REAL allocated size. Do not compare
        # against WINDOW_W or any configured width; PathPilot may constrain the
        # window to a smaller physical display, which would otherwise draw the
        # right side of the graph off-canvas.
        self.canvas.update_idletasks()
        width = int(self.canvas.winfo_width())
        height = int(self.canvas.winfo_height())
        if width < 200 or height < 160:
            return
        self.canvas.create_rectangle(0, 0, width, height, fill=COLOR_BG, outline=COLOR_BG)
        if self.program_path == "":
            self.canvas.create_text(20, 24, anchor="w", fill=COLOR_TEXT, font=self.font_title, text="No loaded program found.")
            return
        points = self.data.points
        if len(points) == 0 or all(point.get("raw_seconds") is None for point in points):
            self.canvas.create_text(20, 24, anchor="w", fill=COLOR_TEXT, font=self.font_title, text="No cycle-time data found for this program.")
            return
        left = LEFT_PAD
        right = max(left + 100, width - RIGHT_PAD)
        top = TOP_PAD
        bottom = max(top + 100, height - BOTTOM_PAD)
        plot_w = right - left
        plot_h = bottom - top
        low = self.graph_low
        high = self.graph_high
        self.draw_grid(left, top, plot_w, plot_h, low, high, len(points))
        if self.show_raw.get():
            self.draw_series(points, "raw_seconds", COLOR_RAW, left, top, plot_w, plot_h, low, high, 2, None)
        if self.show_mean.get():
            self.draw_series(points, "mean_seconds", COLOR_MEAN, left, top, plot_w, plot_h, low, high, 2, (2, 4))
        if self.show_median.get():
            self.draw_series(points, "median_seconds", COLOR_MEDIAN, left, top, plot_w, plot_h, low, high, 2, (2, 4))
        if self.show_actual.get():
            self.draw_actual_points(points, left, top, plot_w, plot_h, low, high)
            self.draw_actual_range(points, left, top, plot_w, plot_h, low, high)

    def draw_grid(self, left, top, width, height, low, high, point_count):
        self.canvas.create_rectangle(left, top, left + width, top + height, outline=COLOR_BORDER)
        for index in range(0, 6):
            fraction = index / 5.0
            y = top + height - int(height * fraction)
            value = low + ((high - low) * fraction)
            self.canvas.create_line(left, y, left + width, y, fill=COLOR_GRID)
            self.canvas.create_text(left - 8, y, anchor="e", fill=COLOR_MUTED, font=self.font_small, text=format_seconds(value))
        max_run = max(1, point_count - 1)
        # Keep X ticks readable on both small and large PathPilot displays.
        if max_run <= 12:
            tick_step = 1
        elif max_run <= 30:
            tick_step = 2
        elif max_run <= 60:
            tick_step = 5
        else:
            tick_step = 10
        run_number = 0
        while run_number <= max_run:
            x = left + int((run_number / float(max_run)) * width)
            self.canvas.create_line(x, top, x, top + height, fill=COLOR_GRID)
            self.canvas.create_text(x, top + height + 10, anchor="n", fill=COLOR_MUTED, font=self.font_small, text=str(run_number))
            run_number += tick_step
        if (max_run % tick_step) != 0:
            x = left + width
            self.canvas.create_line(x, top, x, top + height, fill=COLOR_GRID)
            self.canvas.create_text(x, top + height + 10, anchor="n", fill=COLOR_MUTED, font=self.font_small, text=str(max_run))
        # Vertical Y-axis title stays left of the numeric tick labels.
        self.canvas.create_text(
            18,
            top + (height // 2),
            anchor="center",
            fill=COLOR_MUTED,
            font=self.font_small,
            text="segment time" if self.data.mode == "segment" else "cycle time",
            angle=90)

    def x_for_run(self, run_number, left, width, max_run):
        if max_run <= 0:
            return left
        return left + int((float(run_number) / float(max_run)) * width)

    def y_for_seconds(self, seconds, top, height, low, high):
        if high <= low:
            return top + height
        fraction = (float(seconds) - low) / float(high - low)
        return top + height - int(fraction * height)

    def draw_series(self, points, key, color, left, top, width, height, low, high, line_width, dash):
        max_run = max(1, len(points) - 1)
        coords = []
        markers = []
        for point in points:
            value = point.get(key)
            if value is None:
                continue
            x = self.x_for_run(point.get("run_number"), left, width, max_run)
            y = self.y_for_seconds(value, top, height, low, high)
            coords.append(x)
            coords.append(y)
            markers.append((x, y))
        if len(coords) >= 4:
            self.canvas.create_line(coords, fill=color, width=line_width, dash=dash, smooth=False)
        # Every series gets a visible point marker at every plotted run.
        for x, y in markers:
            self.canvas.create_oval(x - 3, y - 3, x + 3, y + 3, fill=color, outline=color)

    def draw_actual_points(self, points, left, top, width, height, low, high):
        max_run = max(1, len(points) - 1)
        previous = None
        for point in points:
            value = point.get("actual_seconds")
            if value is None:
                continue
            x = self.x_for_run(point.get("run_number"), left, width, max_run)
            y = self.y_for_seconds(value, top, height, low, high)
            if previous is not None:
                self.canvas.create_line(previous[0], previous[1], x, y, fill=COLOR_ACTUAL, width=1)
            self.canvas.create_oval(x - 3, y - 3, x + 3, y + 3, fill=COLOR_ACTUAL, outline=COLOR_ACTUAL)
            previous = (x, y)

    def draw_actual_range(self, points, left, top, width, height, low, high):
        actual_points = []
        for point in points:
            value = point.get("actual_seconds")
            if value is not None:
                actual_points.append((point, float(value)))
        if len(actual_points) == 0:
            return
        best_point, best_value = min(actual_points, key=lambda item: item[1])
        worst_point, worst_value = max(actual_points, key=lambda item: item[1])
        max_run = max(1, len(points) - 1)
        best_x = self.x_for_run(best_point.get("run_number"), left, width, max_run)
        best_y = self.y_for_seconds(best_value, top, height, low, high)
        worst_x = self.x_for_run(worst_point.get("run_number"), left, width, max_run)
        worst_y = self.y_for_seconds(worst_value, top, height, low, high)
        marker_radius = 5
        # Keep larger rings on the actual best/worst points. Their text values
        # are shown in the cool-zone summary box instead of beside the points.
        self.canvas.create_oval(
            best_x - marker_radius,
            best_y - marker_radius,
            best_x + marker_radius,
            best_y + marker_radius,
            outline=COLOR_ACTUAL,
            width=2)
        self.canvas.create_oval(
            worst_x - marker_radius,
            worst_y - marker_radius,
            worst_x + marker_radius,
            worst_y + marker_radius,
            outline=COLOR_ACTUAL,
            width=2)
        spread = abs(worst_value - best_value)
        # Score four candidate corner boxes by how many plotted points fall in
        # or near each box. The least-occupied corner is the "coolest" location.
        box_w = 150
        box_h = 58
        inset = 10
        candidates = [
            ("top_left", left + inset, top + inset),
            ("top_right", left + width - box_w - inset, top + inset),
            ("bottom_left", left + inset, top + height - box_h - inset),
            ("bottom_right", left + width - box_w - inset, top + height - box_h - inset)]
        plotted_xy = []
        for point in points:
            for key in ("raw_seconds", "mean_seconds", "median_seconds", "actual_seconds"):
                value = point.get(key)
                if value is None:
                    continue
                x = self.x_for_run(point.get("run_number"), left, width, max_run)
                y = self.y_for_seconds(value, top, height, low, high)
                plotted_xy.append((x, y))
        best_candidate = None
        best_score = None
        for name, box_x, box_y in candidates:
            score = 0
            hot_left = box_x - 20
            hot_right = box_x + box_w + 20
            hot_top = box_y - 20
            hot_bottom = box_y + box_h + 20
            for x, y in plotted_xy:
                if hot_left <= x <= hot_right and hot_top <= y <= hot_bottom:
                    score += 1
            if best_score is None or score < best_score:
                best_score = score
                best_candidate = (box_x, box_y)
        if best_candidate is None:
            return
        box_x, box_y = best_candidate
        self.canvas.create_rectangle(box_x, box_y, box_x + box_w, box_y + box_h, fill=COLOR_PANEL, outline=COLOR_BORDER)
        text_x = box_x + 8
        text_y = box_y + 8
        self.canvas.create_text(
            text_x,
            text_y,
            anchor="nw",
            fill=COLOR_ACTUAL,
            font=self.font_small,
            text="BEST   %s" % format_seconds(best_value))
        self.canvas.create_text(
            text_x,
            text_y + 17,
            anchor="nw",
            fill=COLOR_ACTUAL,
            font=self.font_small,
            text="WORST  %s" % format_seconds(worst_value))
        self.canvas.create_text(
            text_x,
            text_y + 34,
            anchor="nw",
            fill=COLOR_ACTUAL,
            font=self.font_small,
            text="SPREAD %s" % format_seconds(spread))

    def get_table_header_text(self):
        # Display estimate progression left-to-right from raw calculation
        # through statistics to the directly measured actual runtime.
        return "%-5s  %-16s  %-12s  %-12s  %-12s  %-12s" % (
            "Run", "Date", "Raw", "Mean", "Median", "Actual")

    def draw_table(self):
        self.table_header.configure(text=self.get_table_header_text())
        lines = []
        for point in self.data.points:
            timestamp = point.get("timestamp", "")
            if point.get("run_number") == 0:
                date_text = "Before runs"
            else:
                date_text = timestamp[:16]
            lines.append("%-5d  %-16s  %-12s  %-12s  %-12s  %-12s" % (
                int(point.get("run_number", 0)),
                date_text,
                format_seconds(point.get("raw_seconds")),
                format_seconds(point.get("mean_seconds")),
                format_seconds(point.get("median_seconds")),
                format_seconds(point.get("actual_seconds"))))
        self.table_text.configure(state="normal")
        self.table_text.delete("1.0", tk.END)
        self.table_text.insert("1.0", "\n".join(lines))
        self.table_text.configure(state="disabled")

def main():
    root = tk.Tk()
    CycleTimeTrend(root)
    root.mainloop()

if __name__ == "__main__":
    main()