# 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 History Editor 0.96      ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.96 - fix false state on initial toggle run exclusion    - 6/18/2026
# 0.95 - public beta                                        - 6/13/2026

import os
import re
import sys
import time
import sqlite3
import Tkinter as tk
import ttk
import tkFont
import tkSimpleDialog
import tkMessageBox

CURRENT_VER = "0.96"
SCRIPT_NAME = "Cycle Time History Editor"
DESCRIPTION = "Allows you to edit completed cycle and tool segment times to fine-tune the Cycle Time Calculator script."
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")
EDIT_DB     = os.path.join(LOG_DIR, "cycle_time_history_edits.db")
REVIEW_FILE = os.path.join(LOG_DIR, "cycle_time_history_edits_review.txt")

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

def get_program_name_from_arg():
    if len(sys.argv) > 1:
        return os.path.basename(sys.argv[1])
    return ""

def is_separator_line(line):
    cleaned = line.replace("|", "").replace("-", "").replace(" ", "")
    return cleaned == ""

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

def parse_elapsed_text_to_seconds(text):
    text = safe_text(text).lower()
    if text == "":
        return None
    total = 0
    matched = False
    for value, unit in re.findall(r'(\d+)\s*([dhms])', text):
        matched = True
        value = int(value)
        if unit == "d":
            total += value * 86400
        elif unit == "h":
            total += value * 3600
        elif unit == "m":
            total += value * 60
        elif unit == "s":
            total += value
    if matched:
        return total
    try:
        return int(float(text))
    except Exception:
        return None

def format_seconds(seconds):
    if seconds is None:
        return ""
    seconds = int(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 %dm %ds" % (sign, hours, minutes, secs)
    if minutes > 0:
        return "%s%dm %ds" % (sign, minutes, secs)
    return "%s%ds" % (sign, secs)

class HistoryEditStore(object):
    def __init__(self, db_path):
        self.db_path = db_path
        self.ensure_schema()

    def connect(self):
        return sqlite3.connect(self.db_path)

    def ensure_schema(self):
        connection = self.connect()
        try:
            cursor = connection.cursor()
            cursor.execute(
                "create table if not exists cycle_history_edits "
                "(run_id text primary key, program text, actual_seconds integer, "
                "excluded integer default 0, note text, updated_at text)")
            cursor.execute(
                "create table if not exists segment_history_edits "
                "(run_id text, segment_number integer, program text, tool text, "
                "actual_seconds integer, excluded integer default 0, note text, "
                "updated_at text, primary key (run_id, segment_number))")
            connection.commit()
        finally:
            connection.close()

    def load_cycle_edits(self):
        rows = {}
        connection = self.connect()
        try:
            cursor = connection.cursor()
            cursor.execute("select run_id, actual_seconds, excluded, note from cycle_history_edits")
            for run_id, actual_seconds, excluded, note in cursor.fetchall():
                rows[str(run_id)] = {
                    "actual_seconds": actual_seconds,
                    "excluded": int(excluded or 0),
                    "note": safe_text(note)}
        finally:
            connection.close()
        return rows

    def load_segment_edits(self):
        rows = {}
        connection = self.connect()
        try:
            cursor = connection.cursor()
            cursor.execute(
                "select run_id, segment_number, actual_seconds, excluded, note "
                "from segment_history_edits")
            for run_id, segment_number, actual_seconds, excluded, note in cursor.fetchall():
                rows[(str(run_id), int(segment_number))] = {
                    "actual_seconds": actual_seconds,
                    "excluded": int(excluded or 0),
                    "note": safe_text(note)}
        finally:
            connection.close()
        return rows

    def touch_database(self):
        # PathPilot monitor watches the DB mtime to know when to refresh the loaded snapshot.
        try:
            os.utime(self.db_path, None)
        except Exception:
            pass

    def save_cycle_edit(self, run_id, program, actual_seconds, excluded, note):
        connection = self.connect()
        try:
            cursor = connection.cursor()
            cursor.execute(
                "insert or replace into cycle_history_edits "
                "(run_id, program, actual_seconds, excluded, note, updated_at) "
                "values (?, ?, ?, ?, ?, ?)",
                (
                    run_id,
                    program,
                    actual_seconds,
                    int(excluded or 0),
                    note,
                    time.strftime("%Y-%m-%d %H:%M:%S")))
            connection.commit()
            self.touch_database()
        finally:
            connection.close()

    def save_segment_edit(self, run_id, segment_number, program, tool, actual_seconds, excluded, note):
        connection = self.connect()
        try:
            cursor = connection.cursor()
            cursor.execute(
                "insert or replace into segment_history_edits "
                "(run_id, segment_number, program, tool, actual_seconds, excluded, note, updated_at) "
                "values (?, ?, ?, ?, ?, ?, ?, ?)",
                (
                    run_id,
                    int(segment_number),
                    program,
                    tool,
                    actual_seconds,
                    int(excluded or 0),
                    note,
                    time.strftime("%Y-%m-%d %H:%M:%S")))
            connection.commit()
            self.touch_database()
        finally:
            connection.close()

    def delete_cycle_edit(self, run_id):
        connection = self.connect()
        try:
            cursor = connection.cursor()
            cursor.execute("delete from cycle_history_edits where run_id = ?", (run_id,))
            connection.commit()
            self.touch_database()
        finally:
            connection.close()

    def delete_segment_edit(self, run_id, segment_number):
        connection = self.connect()
        try:
            cursor = connection.cursor()
            cursor.execute(
                "delete from segment_history_edits where run_id = ? and segment_number = ?",
                (run_id, int(segment_number)))
            connection.commit()
            self.touch_database()
        finally:
            connection.close()

class CycleTimeHistoryEditor(object):
    def __init__(self, root):
        self.root = root
        self.store = HistoryEditStore(EDIT_DB)
        self.program_filter = get_program_name_from_arg()
        self.cycle_rows = []
        self.tool_rows = []
        self.cycle_edits = {}
        self.segment_edits = {}
        if self.program_filter:
            self.root.title("Cycle Time History Editor - %s" % self.program_filter)
        else:
            self.root.title("Cycle Time History Editor")
        self.root.geometry("1280x760")
        self.build_ui()
        self.load_data()

    def build_ui(self):
        run_frame = tk.Frame(self.root)
        run_frame.pack(fill="both", expand=True, padx=10, pady=(8, 4))
        tk.Label(run_frame, text="Cycle Runs", font=("Helvetica", 10, "bold")).pack(anchor="w")
        self.run_tree = self.make_tree(
            run_frame,
            ("Excluded", "Timestamp", "Program", "Run ID", "Raw Estimate", "Actual", "Edited Actual", "Note"))
        self.run_tree.bind("<<TreeviewSelect>>", self.on_run_selected)
        run_buttons = tk.Frame(self.root)
        run_buttons.pack(fill="x", padx=10, pady=(0, 6))
        tk.Button(run_buttons, text="Edit Total Time", width=16, command=self.edit_selected_cycle).pack(side="left", padx=4)
        tk.Button(run_buttons, text="Toggle Exclude Run", width=18, command=self.toggle_selected_cycle_exclude).pack(side="left", padx=4)
        tk.Button(run_buttons, text="Restore Run", width=14, command=self.restore_selected_cycle).pack(side="left", padx=4)
        tk.Button(run_buttons, text="Open Review Text", width=18, command=self.open_review_text).pack(side="right", padx=4)
        segment_frame = tk.Frame(self.root)
        segment_frame.pack(fill="both", expand=True, padx=10, pady=(0, 4))
        tk.Label(segment_frame, text="Tool Segments for Selected Run", font=("Helvetica", 10, "bold")).pack(anchor="w")
        self.segment_tree = self.make_tree(
            segment_frame,
            ("Excluded", "Segment", "Tool", "Raw Estimate", "Actual", "Edited Actual", "Note"))
        bottom_buttons = tk.Frame(self.root)
        bottom_buttons.pack(fill="x", padx=10, pady=(0, 8))
        tk.Button(bottom_buttons, text="Edit Segment Time", width=18, command=self.edit_selected_segment).pack(side="left", padx=4)
        tk.Button(bottom_buttons, text="Toggle Segment Exclude", width=22, command=self.toggle_selected_segment_exclude).pack(side="left", padx=4)
        tk.Button(bottom_buttons, text="Restore Segment", width=16, command=self.restore_selected_segment).pack(side="left", padx=4)
        tk.Button(bottom_buttons, text="Close", width=12, command=self.root.destroy).pack(side="right", padx=4)
        tk.Button(bottom_buttons, text="Refresh", width=12, command=self.load_data).pack(side="right", padx=4)

    def make_tree(self, parent, columns):
        wrap = tk.Frame(parent)
        wrap.pack(fill="both", expand=True)
        tree = ttk.Treeview(wrap, columns=columns, show="headings", height=9)
        yscroll = tk.Scrollbar(wrap, orient="vertical", command=tree.yview)
        xscroll = tk.Scrollbar(wrap, orient="horizontal", command=tree.xview)
        tree.configure(yscrollcommand=yscroll.set, xscrollcommand=xscroll.set)
        tree.grid(row=0, column=0, sticky="nsew")
        yscroll.grid(row=0, column=1, sticky="ns")
        xscroll.grid(row=1, column=0, sticky="ew")
        wrap.grid_rowconfigure(0, weight=1)
        wrap.grid_columnconfigure(0, weight=1)
        for column in columns:
            tree.heading(column, text=column, anchor="center")
            tree.column(
                column,
                width=80,
                minwidth=40,
                anchor="center",
                stretch=False)
        return tree

    def get_tree_font(self):
        try:
            style = ttk.Style()
            font_name = style.lookup("Treeview", "font")
            if font_name:
                return tkFont.nametofont(font_name)
        except Exception:
            pass
        try:
            return tkFont.nametofont("TkDefaultFont")
        except Exception:
            return tkFont.Font(family="Helvetica", size=10)

    def get_tree_heading_font(self):
        try:
            style = ttk.Style()
            font_name = style.lookup("Treeview.Heading", "font")
            if font_name:
                return tkFont.nametofont(font_name)
        except Exception:
            pass
        try:
            return tkFont.nametofont("TkDefaultFont")
        except Exception:
            return tkFont.Font(family="Helvetica", size=10)

    def estimate_tree_text_width(self, text):
        try:
            return self.get_tree_font().measure(safe_text(text))
        except Exception:
            return len(safe_text(text)) * 7

    def auto_fit_tree_columns(self, tree):
        """
        Auto-fit Treeview columns using actual rendered text widths.
        This intentionally does not apply maximum widths. If a value is long,
        the column is made wide enough for that value, and the horizontal
        scrollbar handles the total table width.
        """
        try:
            self.root.update_idletasks()
        except Exception:
            pass
        padding = 22
        minimum_width = 44
        font = self.get_tree_font()
        heading_font = self.get_tree_heading_font()
        for column in tree["columns"]:
            header_width = heading_font.measure(safe_text(column))
            widest_width = header_width
            for item in tree.get_children():
                value = safe_text(tree.set(item, column))
                value_width = font.measure(value)
                if value_width > widest_width:
                    widest_width = value_width
            final_width = widest_width + padding
            if final_width < minimum_width:
                final_width = minimum_width
            tree.column(
                column,
                width=final_width,
                minwidth=final_width,
                anchor="center",
                stretch=False)

    def tighten_known_tree_columns(self, tree):
        """
        Enforce only sensible minimum widths.
        This never shrinks a column below its auto-fitted width.
        """
        minimum_widths = {
            "Excluded": 74,
            "Timestamp": 132,
            "Program": 90,
            "Run ID": 142,
            "Segment": 82,
            "Tool": 58,
            "Raw Estimate": 98,
            "Actual": 74,
            "Edited Actual": 108,
            "Note": 70}
        for column, minimum_width in minimum_widths.items():
            if column in tree["columns"]:
                current_width = int(tree.column(column, "width"))
                final_width = max(current_width, minimum_width)
                tree.column(
                    column,
                    width=final_width,
                    minwidth=final_width,
                    anchor="center",
                    stretch=False)

    def select_run_after_load(self, preferred_run_id, preferred_segment_id):
        # Preserve the user's selection after edits. Fall back to the newest run only on first load.
        children = self.run_tree.get_children()
        if len(children) == 0:
            self.redraw_segments("")
            return ""
        selected_run_id = preferred_run_id
        if selected_run_id not in children:
            selected_run_id = children[0]
        self.run_tree.selection_set(selected_run_id)
        self.run_tree.focus(selected_run_id)
        self.run_tree.see(selected_run_id)
        self.redraw_segments(selected_run_id)
        if preferred_segment_id:
            segment_children = self.segment_tree.get_children()
            if preferred_segment_id in segment_children:
                self.segment_tree.selection_set(preferred_segment_id)
                self.segment_tree.focus(preferred_segment_id)
                self.segment_tree.see(preferred_segment_id)
        return selected_run_id

    def load_data(self):
        preferred_run_id = self.get_selected_run_id()
        preferred_segment_id = ""
        segment_selection = self.segment_tree.selection()
        if len(segment_selection) > 0:
            preferred_segment_id = segment_selection[0]
        self.cycle_edits = self.store.load_cycle_edits()
        self.segment_edits = self.store.load_segment_edits()
        self.cycle_rows = self.read_cycle_log()
        self.tool_rows = self.read_tool_log()
        self.redraw_runs()
        self.select_run_after_load(preferred_run_id, preferred_segment_id)
        self.root.update_idletasks()
        self.auto_fit_tree_columns(self.run_tree)
        self.tighten_known_tree_columns(self.run_tree)
        self.auto_fit_tree_columns(self.segment_tree)
        self.tighten_known_tree_columns(self.segment_tree)
        self.write_review_file()

    def read_cycle_log(self):
        rows = []
        if os.path.isfile(CYCLE_LOG):
            f = open(CYCLE_LOG, "r")
            try:
                for raw in f:
                    parts = split_pipe_row(raw)
                    if len(parts) >= 8 and parts[6].strip().lower().replace("[", "").replace("]", "") == "completed":
                        if self.program_filter == "" or parts[1] == self.program_filter:
                            rows.append({
                                "timestamp": parts[0],
                                "program": parts[1],
                                "raw_estimate": parse_elapsed_text_to_seconds(parts[2]),
                                "raw_actual": parse_elapsed_text_to_seconds(parts[3]),
                                "run_id": parts[7]
                            })
            finally:
                f.close()
        rows.sort(key=lambda row: row.get("timestamp", ""), reverse=True)
        return rows

    def read_tool_log(self):
        rows = []
        if os.path.isfile(TOOL_LOG):
            f = open(TOOL_LOG, "r")
            try:
                for raw in f:
                    parts = split_pipe_row(raw)
                    if len(parts) >= 10:
                        result_text = parts[8].strip().lower().replace("[", "").replace("]", "")
                        if result_text == "completed":
                            if self.program_filter == "" or parts[1] == self.program_filter:
                                match = re.search(r'(\d+)', parts[2])
                                if match:
                                    rows.append({
                                        "timestamp": parts[0],
                                        "program": parts[1],
                                        "segment_number": int(match.group(1)),
                                        "segment": parts[2],
                                        "tool": parts[3],
                                        "raw_estimate": parse_elapsed_text_to_seconds(parts[4]),
                                        "raw_actual": parse_elapsed_text_to_seconds(parts[5]),
                                        "run_id": parts[9]})
            finally:
                f.close()
        rows.sort(key=lambda row: (row.get("timestamp", ""), row.get("segment_number", 0)), reverse=True)
        return rows

    def get_cycle_effective_seconds(self, row):
        edit = self.cycle_edits.get(row["run_id"])
        if edit is not None and edit.get("actual_seconds") is not None:
            return edit.get("actual_seconds")
        return row.get("raw_actual")

    def get_segment_effective_seconds(self, row):
        edit = self.segment_edits.get((row["run_id"], row["segment_number"]))
        if edit is not None and edit.get("actual_seconds") is not None:
            return edit.get("actual_seconds")
        return row.get("raw_actual")

    def redraw_runs(self):
        for item in self.run_tree.get_children():
            self.run_tree.delete(item)
        for row in self.cycle_rows:
            edit = self.cycle_edits.get(row["run_id"], {})
            values = (
                "yes" if edit.get("excluded") else "",
                row["timestamp"],
                row["program"],
                row["run_id"],
                format_seconds(row.get("raw_estimate")),
                format_seconds(row.get("raw_actual")),
                format_seconds(edit.get("actual_seconds")),
                safe_text(edit.get("note")))
            self.run_tree.insert("", "end", iid=row["run_id"], values=values)

    def redraw_segments(self, run_id):
        for item in self.segment_tree.get_children():
            self.segment_tree.delete(item)
        if run_id == "":
            return
        selected_rows = []
        for row in self.tool_rows:
            if row.get("run_id") == run_id:
                selected_rows.append(row)
        selected_rows.sort(key=lambda row: row.get("segment_number", 0))
        for row in selected_rows:
            edit = self.segment_edits.get((run_id, row["segment_number"]), {})
            values = (
                "yes" if edit.get("excluded") else "",
                row["segment"],
                row["tool"],
                format_seconds(row.get("raw_estimate")),
                format_seconds(row.get("raw_actual")),
                format_seconds(edit.get("actual_seconds")),
                safe_text(edit.get("note")))
            self.segment_tree.insert("", "end", iid=str(row["segment_number"]), values=values)

    def get_selected_run_id(self):
        selection = self.run_tree.selection()
        if len(selection) == 0:
            return ""
        return selection[0]

    def get_selected_cycle_row(self):
        run_id = self.get_selected_run_id()
        for row in self.cycle_rows:
            if row.get("run_id") == run_id:
                return row
        return None

    def get_selected_segment_row(self):
        run_id = self.get_selected_run_id()
        selection = self.segment_tree.selection()
        if run_id == "" or len(selection) == 0:
            return None
        segment_number = int(selection[0])
        for row in self.tool_rows:
            if row.get("run_id") == run_id and row.get("segment_number") == segment_number:
                return row
        return None

    def on_run_selected(self, event=None):
        self.redraw_segments(self.get_selected_run_id())
        self.root.update_idletasks()
        self.auto_fit_tree_columns(self.segment_tree)
        self.tighten_known_tree_columns(self.segment_tree)

    def ask_seconds(self, title, initial_seconds):
        initial_text = format_seconds(initial_seconds)
        value = tkSimpleDialog.askstring(
            title,
            "Enter time, e.g. 12m 34s or 754:",
            initialvalue=initial_text,
            parent=self.root)
        if value is None:
            return None
        seconds = parse_elapsed_text_to_seconds(value)
        if seconds is None:
            tkMessageBox.showerror("Invalid Time", "Could not read that time value.")
        return seconds

    def ask_note(self, initial_note):
        value = tkSimpleDialog.askstring(
            "Note",
            "Optional note:",
            initialvalue=safe_text(initial_note),
            parent=self.root)
        if value is None:
            return safe_text(initial_note)
        return value

    def edit_selected_cycle(self):
        row = self.get_selected_cycle_row()
        if row is None:
            return
        edit = self.cycle_edits.get(row["run_id"], {})
        seconds = self.ask_seconds(
            "Edit Total Time",
            self.get_cycle_effective_seconds(row))
        if seconds is None:
            return
        note = self.ask_note(edit.get("note", ""))
        self.store.save_cycle_edit(
            row["run_id"],
            row["program"],
            seconds,
            edit.get("excluded", 0),
            note)
        self.load_data()

    def toggle_selected_cycle_exclude(self):
        row = self.get_selected_cycle_row()
        if row is None:
            return
        edit = self.cycle_edits.get(row["run_id"], {})
        current_excluded = int(edit.get("excluded") or 0)
        excluded = 0
        if current_excluded == 0:
            excluded = 1
        note = self.ask_note(edit.get("note", ""))
        self.store.save_cycle_edit(
            row["run_id"],
            row["program"],
            edit.get("actual_seconds"),
            excluded,
            note)
        self.load_data()

    def restore_selected_cycle(self):
        row = self.get_selected_cycle_row()
        if row is None:
            return
        self.store.delete_cycle_edit(row["run_id"])
        self.load_data()

    def edit_selected_segment(self):
        row = self.get_selected_segment_row()
        if row is None:
            return
        edit = self.segment_edits.get((row["run_id"], row["segment_number"]), {})
        seconds = self.ask_seconds(
            "Edit Segment Time",
            self.get_segment_effective_seconds(row))
        if seconds is None:
            return
        note = self.ask_note(edit.get("note", ""))
        self.store.save_segment_edit(
            row["run_id"],
            row["segment_number"],
            row["program"],
            row["tool"],
            seconds,
            edit.get("excluded", 0),
            note)
        self.load_data()

    def toggle_selected_segment_exclude(self):
        row = self.get_selected_segment_row()
        if row is None:
            return
        edit = self.segment_edits.get((row["run_id"], row["segment_number"]), {})
        current_excluded = int(edit.get("excluded") or 0)
        excluded = 0
        if current_excluded == 0:
            excluded = 1
        note = self.ask_note(edit.get("note", ""))
        self.store.save_segment_edit(
            row["run_id"],
            row["segment_number"],
            row["program"],
            row["tool"],
            edit.get("actual_seconds"),
            excluded,
            note)
        self.load_data()

    def restore_selected_segment(self):
        row = self.get_selected_segment_row()
        if row is None:
            return
        self.store.delete_segment_edit(row["run_id"], row["segment_number"])
        self.load_data()

    def write_review_file(self):
        f = open(REVIEW_FILE, "w")
        try:
            f.write("Cycle Time History Edits\n")
            f.write("Updated: %s\n\n" % time.strftime("%Y-%m-%d %H:%M:%S"))
            f.write("Cycle edits and exclusions\n")
            for row in self.cycle_rows:
                edit = self.cycle_edits.get(row["run_id"])
                if edit is not None:
                    f.write(
                        "%s | %s | raw estimate %s | actual %s | edited %s | excluded %s | %s\n" %
                        (
                            row["run_id"],
                            row["program"],
                            format_seconds(row.get("raw_estimate")),
                            format_seconds(row.get("raw_actual")),
                            format_seconds(edit.get("actual_seconds")),
                            "yes" if edit.get("excluded") else "no",
                            safe_text(edit.get("note"))))
            f.write("\nSegment edits and exclusions\n")
            for row in self.tool_rows:
                edit = self.segment_edits.get((row["run_id"], row["segment_number"]))
                if edit is not None:
                    f.write(
                        "%s | %s | %s | %s | raw estimate %s | actual %s | edited %s | excluded %s | %s\n" %
                        (
                            row["run_id"],
                            row["program"],
                            row["segment"],
                            row["tool"],
                            format_seconds(row.get("raw_estimate")),
                            format_seconds(row.get("raw_actual")),
                            format_seconds(edit.get("actual_seconds")),
                            "yes" if edit.get("excluded") else "no",
                            safe_text(edit.get("note"))))
        finally:
            f.close()

    def open_review_text(self):
        self.write_review_file()
        try:
            os.system("xdg-open '%s' &" % REVIEW_FILE)
        except Exception:
            tkMessageBox.showinfo("Review File", REVIEW_FILE)

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

if __name__ == "__main__":
    main()