# 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

#############################################
##                                         ##
##        End Of Day Summary v0.95         ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.95 - public beta - 4/2/26

import os
import re
import datetime
from collections import defaultdict
import Tkinter as tk
import ttk
import tkMessageBox

CURRENT_VER       = "0.95"
SCRIPT_NAME       = "Hobbs End of Day Summary"
DESCRIPTION       = "Shows an end-of-day summary for your tools, spindle and run files."
HOBBS_DIR         = "/home/operator/gcode/python/hobbs"
OEM_GCODE_LOG     = "/home/operator/gcode/logfiles/gcode_log.txt"
ARCHIVE_GCODE_LOG = "/home/operator/gcode/python/tormachtips_gcode_log.txt"
GCODE_BASE_DIR    = "/home/operator/gcode"
LINE_RE           = re.compile(r"""\(\+(\d+)s\).*?tool\s+(\d+)""", re.IGNORECASE)
BAR_WIDTH         = 60
SEC_PER_BLOCK     = 60
BLOCK             = u"\u2588"
EXCLUDE_FILE      = "/home/operator/gcode/python/load_history_excluder.txt"

def parse_hobbs_file(path):
    tool_seconds = defaultdict(int)
    try:
        with open(path, "r") as f:
            for line in f:
                m = LINE_RE.search(line)
                if not m:
                    continue
                delta_seconds = int(m.group(1))
                tool_num = int(m.group(2))
                tool_seconds[tool_num] += delta_seconds
    except:
        pass
    return dict(tool_seconds)

def format_hms(seconds):   
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    secs = seconds % 60
    if hours > 0:
        return "%dh %dm %ds" % (hours, minutes, secs)
    elif minutes > 0:
        return "%dm %ds" % (minutes, secs)
    else:
        return "%ds" % secs

def extract_data(line):
    match = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) .* total = (\d+) seconds', line)
    if match:
        timestamp_str = match.group(1)
        total_sec = int(match.group(2))
        try:
            dt = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
            return dt, total_sec
        except ValueError:
            return None, None
    return None, None

def analyze_hourly(filename):
    if not os.path.exists(filename):
        return {}
    hourly = dict((h, 0) for h in range(24))
    prev_total = None
    try:
        with open(filename, 'r') as f:
            for raw_line in f:
                line = raw_line.strip()
                dt, total = extract_data(line)
                if dt is None or total is None:
                    continue
                hour = dt.hour
                if prev_total is not None:
                    seconds_this_line = total - prev_total
                    if 0 < seconds_this_line <= 2:
                        hourly[hour] += seconds_this_line
                prev_total = total
    except:
        pass
    return hourly

def make_bar(seconds):
    num_blocks = int(round(float(seconds) / SEC_PER_BLOCK))
    if num_blocks > BAR_WIDTH:
        num_blocks = BAR_WIDTH
    if num_blocks < 0:
        num_blocks = 0
    bar = BLOCK * num_blocks + u" " * (BAR_WIDTH - num_blocks)
    return u"[%s]" % bar

def format_hourly_display(hourly_seconds):
    total_seconds = sum(hourly_seconds.values())
    def fmt_hour_runtime(seconds):
        minutes = seconds // 60
        secs = seconds % 60
        return u"%dm %ds" % (minutes, secs)
    def fmt_total_runtime(seconds):
        minutes = seconds // 60
        secs = seconds % 60
        hours = minutes // 60
        minutes = minutes % 60
        days = hours // 24
        hours = hours % 24
        base_text = u"%dh %dm %ds" % (days * 24 + hours, minutes, secs)
        if days > 0:
            return base_text, u"(%dd %dh %dm %ds)" % (days, hours, minutes, secs)
        return base_text, u""
    hour_w = 5
    time_w = 8
    lines = []
    lines.append(u"Hourly Spindle Runtime for Today")
    lines.append(u"")
    lines.append(u"=" * 87)
    lines.append(u"%-*s  %*s  %s" % (hour_w, u"Hour", time_w, u"Runtime", u"Hour Bar"))
    lines.append(u"-" * 87)
    for h in range(24):
        secs = hourly_seconds.get(h, 0)
        bar = make_bar(secs)
        hour_text = u"%02d:00" % h
        if secs > 0:
            time_text = u"%*s" % (time_w, fmt_hour_runtime(secs))
        else:
            time_text = u"%*s" % (time_w, u"-")
        line = u"%-*s  %s  %s" % (hour_w, hour_text, time_text, bar)
        lines.append(line)
    lines.append(u"-" * 87)
    total_text, total_extra = fmt_total_runtime(total_seconds)
    lines.append(u"TOTAL   %s" % total_text)
    if total_extra:
        lines.append(u"        %s" % total_extra)
    lines.append(u"")
    return u"\n".join(lines)

def read_today_history_rows(archive_path, today):
    results = []
    if not os.path.isfile(archive_path):
        return results
    active_runs = {}
    try:
        with open(archive_path, 'r') as f:
            for raw_line in f:
                line = raw_line.strip()
                if not line:
                    continue
                parts = [p.strip() for p in line.split('|', 3)]
                if len(parts) != 4:
                    continue
                timestamp_str, machine_name, file_path, event_text = parts
                try:
                    event_time = datetime.datetime.strptime(timestamp_str.split('.')[0], '%Y-%m-%d %H:%M:%S')
                except:
                    continue
                event_lower = event_text.lower()
                if event_lower.startswith('cycle start'):
                    active_runs[file_path] = event_time
                    continue
                if not event_lower.startswith('program stopped'):
                    continue
                start_time = active_runs.pop(file_path, None)
                if start_time is None:
                    continue
                elapsed_seconds = int((event_time - start_time).total_seconds())
                if elapsed_seconds < 0:
                    continue
                if event_time.date() == today:
                    results.append((start_time, event_time, file_path, elapsed_seconds, event_text))
    except:
        pass
    return results

def get_status_from_event(event_text):
    event_lower = event_text.lower()
    if 'complete run' in event_lower:
        return "complete"
    if 'partial run' in event_lower:
        return "partial"
    return ''

def format_elapsed(total_seconds):
    total_seconds = max(0, int(total_seconds))
    hours = total_seconds // 3600
    minutes = (total_seconds % 3600) // 60
    seconds = total_seconds % 60
    parts = []
    if hours > 0:
        parts.append('%dh' % hours)
    if minutes > 0 or hours > 0:
        parts.append('%dm' % minutes)
    parts.append('%ds' % seconds)
    return ' '.join(parts)

def read_grand_total_seconds():
    HOBBS_FILE = "/home/operator/gcode/python/hobbs.txt"
    if not os.path.isfile(HOBBS_FILE):
        return 0
    try:
        with open(HOBBS_FILE, "r") as f:
            content = f.read()
        match = re.search(r'^\s*total_seconds\s*=\s*(\d+)\s*$', content, re.MULTILINE)
        if match:
            return int(match.group(1))
    except:
        pass
    return 0

def get_today_total_spindle_seconds(hobbs_path):
    if not hobbs_path or not os.path.exists(hobbs_path):
        return 0
    hourly = analyze_hourly(hobbs_path)   
    return sum(hourly.values())

def format_xh_xm(seconds):
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    return "{}h {}m".format(hours, minutes)

class EndOfDaySummary(object):
    def __init__(self, root):
        self.root = root
        self.today = datetime.date.today()
        self.current_date = self.today
        self.hobbs_path = None
        self.excluded_paths = {}
        self._load_excluded_paths()
        self.available_dates = self._get_available_dates()
        self.root.title("End Of Day Summary - {}".format(self.current_date.strftime("%Y-%m-%d")))
        self.root.configure(bg="#f0f0f0")
        self._build_ui()
        self._sync_log()
        self.available_dates = self._get_available_dates()
        if self.available_dates and self.current_date not in self.available_dates:
            self.current_date = self.available_dates[-1]
        self._load_and_display_data()
        self.root.bind("<Escape>", lambda e: self.root.destroy())
        self.root.bind("<Return>", lambda e: self.root.destroy())
        self.root.bind("<KP_Enter>", lambda e: self.root.destroy())
        self.root.bind("<Left>", self._on_prev_day)
        self.root.bind("<Right>", self._on_next_day)
    
    def _normalize_path(self, raw_path):
        if not raw_path:
            return None
        raw_path = raw_path.strip()
        if not raw_path:
            return None
        if os.path.isabs(raw_path):
            full_path = raw_path
        else:
            full_path = os.path.join(GCODE_BASE_DIR, raw_path.lstrip('/'))
        full_path = os.path.normpath(full_path)
        if not full_path.startswith(GCODE_BASE_DIR):
            return None
        return full_path
    
    def _load_excluded_paths(self):
        excluded = {}
        try:
            f = open(EXCLUDE_FILE, 'r')
            try:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    excluded[os.path.normpath(line)] = True
            finally:
                f.close()
        except:
            pass
        self.excluded_paths = excluded
    
    def _is_excluded_path(self, raw_path):
        norm = self._normalize_path(raw_path)
        if not norm:
            return False
        return norm in self.excluded_paths
    
    def _sync_log(self):
        if not os.path.isfile(OEM_GCODE_LOG):
            return
        try:
            with open(OEM_GCODE_LOG, 'r') as f_live:
                live_lines = f_live.readlines()
            if not os.path.isfile(ARCHIVE_GCODE_LOG):
                with open(ARCHIVE_GCODE_LOG, 'w') as f_archive:
                    f_archive.writelines(live_lines)
                return
            archive_seen = {}
            with open(ARCHIVE_GCODE_LOG, 'r') as f_archive:
                for line in f_archive:
                    archive_seen[line] = True
            new_lines = [line for line in live_lines if line not in archive_seen]
            if new_lines:
                with open(ARCHIVE_GCODE_LOG, 'a') as f_archive:
                    f_archive.writelines(new_lines)
        except:
            pass
    
    def _get_available_dates(self):
        dates = []
        try:
            names = os.listdir(HOBBS_DIR)
        except:
            return dates
        for name in names:
            m = re.match(r'^hobbs_(\d{4}-\d{2}-\d{2})\.txt$', name, re.IGNORECASE)
            if not m:
                continue
            try:
                file_date = datetime.datetime.strptime(m.group(1), "%Y-%m-%d").date()
                dates.append(file_date)
            except:
                pass
        dates.sort()
        return dates
    
    def _get_current_index(self):
        try:
            return self.available_dates.index(self.current_date)
        except ValueError:
            return -1
    
    def _navigate_day(self, step):
        if not self.available_dates:
            return "break"
        idx = self._get_current_index()
        if idx < 0:
            return "break"
        new_idx = idx + step
        if new_idx < 0 or new_idx >= len(self.available_dates):
            return "break"
        self.current_date = self.available_dates[new_idx]
        self._load_and_display_data()
        return "break"
    
    def _on_prev_day(self, event=None):
        return self._navigate_day(-1)
    
    def _on_next_day(self, event=None):
        return self._navigate_day(1)
    
    def _get_hobbs_path_for_date(self, file_date):
        filename = "hobbs_{}.txt".format(file_date.strftime("%Y-%m-%d"))
        path = os.path.join(HOBBS_DIR, filename)
        return path if os.path.exists(path) else None
    
    def _build_ui(self):
        self.notebook = ttk.Notebook(self.root)
        self.notebook.pack(fill="both", expand=True, padx=12, pady=12)
        self.history_frame = tk.Frame(self.notebook)
        self.notebook.add(self.history_frame, text="File Run History")
        self._build_history_tab()
        self.hourly_frame = tk.Frame(self.notebook)
        self.notebook.add(self.hourly_frame, text="Hourly Spindle Runtime")
        self._build_hourly_tab()
        self.tool_frame = tk.Frame(self.notebook)
        self.notebook.add(self.tool_frame, text="Tool Usage Summary")
        self._build_tool_tab()
        btn_frame = tk.Frame(self.root, bg="#f0f0f0")
        btn_frame.pack(fill="x", pady=8)
        tk.Button(btn_frame, text="Close", width=12, command=self.root.destroy).pack(side="right", padx=20)
    
    def _build_history_tab(self):
        frame = self.history_frame
        grand_frame = tk.Frame(frame, bg="#f0f0f0")
        grand_frame.pack(fill="x", padx=10, pady=(10, 0))
        tk.Label(
            grand_frame,
            text="Today's Total Spindle Time (includes manual (non-file) cutting):",
            font=("Helvetica", 11, "bold")
        ).pack(side="left")
        self.history_grand_total_label = tk.Label(
            grand_frame,
            text="0h 0m",
            font=("Helvetica", 14, "bold"),
            fg="#0066cc"
        )
        self.history_grand_total_label.pack(side="left", padx=(8, 0))
        today_frame = tk.Frame(frame, bg="#f0f0f0")
        today_frame.pack(fill="x", padx=10, pady=(8, 5))
        tk.Label(
            today_frame,
            text="Today's File Runs Total (may include probe moves, M01 waits, etc, so won't match spindle time):",
            font=("Helvetica", 11, "bold")
        ).pack(side="left")
        self.history_today_total_label = tk.Label(
            today_frame,
            text="0h 0m",
            font=("Helvetica", 14, "bold"),
            fg="#0066cc"
        )
        self.history_today_total_label.pack(side="left", padx=(8, 0))
        tk.Label(
            frame,
            text="Files Run Today (earliest first)",
            font=("Helvetica", 14, "bold")
        ).pack(anchor="w", padx=10, pady=(10, 5))
        style = ttk.Style()
        style.configure('Treeview', rowheight=24, font=('Courier', 10))
        style.configure('Treeview.Heading', font=('Helvetica', 10, 'bold'))
        columns = ('start', 'stop', 'elapsed', 'status', 'file')
        tree_wrap = tk.Frame(frame, bg="#f0f0f0")
        tree_wrap.pack(fill="both", expand=True, padx=10, pady=5)
        self.history_tree = ttk.Treeview(tree_wrap, columns=columns, show='headings')
        self.history_tree.pack(side="left", fill="both", expand=True)
        self.history_tree.heading('start', text='Start')
        self.history_tree.heading('stop', text='Stop')
        self.history_tree.heading('elapsed', text='Elapsed')
        self.history_tree.heading('status', text='Status')
        self.history_tree.heading('file', text='File', anchor='w')
        self.history_tree.column('start', width=100, anchor='center')
        self.history_tree.column('stop', width=100, anchor='center')
        self.history_tree.column('elapsed', width=110, anchor='center')
        self.history_tree.column('status', width=100, anchor='center')
        self.history_tree.column('file', width=520, anchor='w')
        yscroll = tk.Scrollbar(tree_wrap, orient="vertical", command=self.history_tree.yview)
        yscroll.pack(side="right", fill="y")
        self.history_tree.configure(yscrollcommand=yscroll.set)
        self.history_msg = tk.Label(frame, text="", fg="#0066cc", font=("Helvetica", 11))
        self.history_msg.pack(anchor="w", padx=10, pady=(0, 8))
    
    def _build_hourly_tab(self):
        tk.Label(self.hourly_frame, text="Spindle Runtime by Hour", font=("Helvetica", 14, "bold")).pack(anchor="w", padx=10, pady=(10, 5))
        self.hourly_text = tk.Text(
            self.hourly_frame,
            wrap="none",
            font=("Courier", 10),
            bg="#f8f8f8",
            height=28,
            relief="solid",
            bd=1        )
        self.hourly_text.pack(fill="both", expand=True, padx=10, pady=5)
        yscroll = tk.Scrollbar(self.hourly_frame, orient="vertical", command=self.hourly_text.yview)
        yscroll.pack(side="right", fill="y")
        self.hourly_text.configure(yscrollcommand=yscroll.set)
        self.hourly_msg = tk.Label(self.hourly_frame, text="", fg="#0066cc", font=("Helvetica", 11))
        self.hourly_msg.pack(pady=8)
    
    def _build_tool_tab(self):
        tk.Label(self.tool_frame, text="Tool Usage Today", font=("Helvetica", 14, "bold")).pack(anchor="w", padx=10, pady=(10, 5))
        self.tool_outer = tk.Frame(self.tool_frame, bg="#ffffff", bd=1, relief="solid")
        self.tool_outer.pack(fill="both", expand=True, padx=10, pady=5)
        self.tool_canvas = tk.Canvas(self.tool_outer, bg="#ffffff", highlightthickness=0, bd=0)
        self.tool_canvas.pack(side="left", fill="both", expand=True)
        scrollbar = tk.Scrollbar(self.tool_outer, orient="vertical", command=self.tool_canvas.yview)
        scrollbar.pack(side="right", fill="y")
        self.tool_canvas.configure(yscrollcommand=scrollbar.set)
        self.tool_table_frame = tk.Frame(self.tool_canvas, bg="#ffffff")
        self.tool_window = self.tool_canvas.create_window((0, 0), window=self.tool_table_frame, anchor="nw")
        self.tool_table_frame.bind("<Configure>", self._sync_tool_scroll)
        self.tool_canvas.bind("<Configure>", lambda e: self.tool_canvas.itemconfigure(self.tool_window, width=e.width))
        self.tool_msg = tk.Label(self.tool_frame, text="", fg="#0066cc", font=("Helvetica", 11))
        self.tool_msg.pack(pady=8)
    
    def _sync_tool_scroll(self, event=None):
        self.tool_canvas.configure(scrollregion=self.tool_canvas.bbox("all"))
    
    def _load_and_display_data(self):
        self.hobbs_path = self._get_hobbs_path_for_date(self.current_date)
        self.root.title("End Of Day Summary - {}".format(self.current_date.strftime("%Y-%m-%d")))
        history_rows = read_today_history_rows(ARCHIVE_GCODE_LOG, self.current_date)
        history_rows = [
            row for row in history_rows
            if not self._is_excluded_path(row[2])        ]
        history_rows.sort(key=lambda r: r[0])
        today_file_seconds = sum(row[3] for row in history_rows)
        today_spindle_seconds = get_today_total_spindle_seconds(self.hobbs_path)
        self.history_grand_total_label.config(text=format_xh_xm(today_spindle_seconds))
        self.history_today_total_label.config(text=format_xh_xm(today_file_seconds))
        for item in self.history_tree.get_children():
            self.history_tree.delete(item)
        if not history_rows:
            self.history_msg.config(text="No file runs recorded for {}.".format(self.current_date.strftime("%Y-%m-%d")))
        else:
            self.history_msg.config(text="{} run{} on {}".format(
                len(history_rows),
                "" if len(history_rows) == 1 else "s",
                self.current_date.strftime("%Y-%m-%d")))
            for start_time, stop_time, full_path, elapsed_seconds, event_text in history_rows:
                status = get_status_from_event(event_text)
                filename = os.path.basename(full_path) if full_path else "(unknown)"
                values = (start_time.strftime('%H:%M:%S'),stop_time.strftime('%H:%M:%S'),format_elapsed(elapsed_seconds),status, filename)
                self.history_tree.insert('', 'end', values=values)
        if self.hobbs_path:
            hourly = analyze_hourly(self.hobbs_path)
            display_text = format_hourly_display(hourly)
            self.hourly_text.config(state="normal")
            self.hourly_text.delete("1.0", "end")
            self.hourly_text.insert("1.0", display_text)
            self.hourly_text.config(state="disabled")
            self.hourly_msg.config(text="")
        else:
            self.hourly_msg.config(text="No hobbs log file for {}.".format(self.current_date.strftime("%Y-%m-%d")))
            self.hourly_text.config(state="normal")
            self.hourly_text.delete("1.0", "end")
            self.hourly_text.insert("1.0", "No spindle runtime data available for {}.".format(
                self.current_date.strftime("%Y-%m-%d")))
            self.hourly_text.config(state="disabled")
        for widget in self.tool_table_frame.winfo_children():
            widget.destroy()
        if self.hobbs_path:
            tool_seconds = parse_hobbs_file(self.hobbs_path)
            self._build_tool_table(tool_seconds)
            self.tool_msg.config(text="")
        else:
            self.tool_msg.config(text="No hobbs log file for {}.".format(self.current_date.strftime("%Y-%m-%d")))
            tk.Label(self.tool_table_frame,
                     text="No tool usage data for {}.".format(self.current_date.strftime("%Y-%m-%d")),
                     font=("Helvetica", 11),
                     bg="#ffffff",
                     padx=20, pady=20).grid(row=0, column=0, columnspan=2)
    
    def _build_tool_table(self, tool_seconds):
        if not tool_seconds:
            tk.Label(self.tool_table_frame,
                     text="No tool usage recorded today.",
                     font=("Helvetica", 11),
                     bg="#ffffff",
                     padx=12, pady=12).grid(row=0, column=0, columnspan=2, sticky="nsew")
            return
        header_bg = "#cfd6de"
        headings = [("Tool", 10, "w"), ("Time", 18, "e")]
        for col, (text, width, anchor) in enumerate(headings):
            lbl = tk.Label(self.tool_table_frame, text=text, width=width,
                           font=("Helvetica", 11, "bold"), bg=header_bg,
                           fg="#000000", anchor=anchor, padx=12, pady=8)
            lbl.grid(row=0, column=col, sticky="ew")
        sorted_tools = sorted(tool_seconds.items())
        total_seconds = 0
        row_idx = 1
        row_a = "#ffffff"
        row_b = "#f3f6f9"
        for tool_num, seconds in sorted_tools:
            bg = row_a if (row_idx % 2 == 1) else row_b
            total_seconds += seconds
            values = [("T%d" % tool_num, 10, "w"),(format_hms(seconds), 18, "e")]
            for col, (value, width, anchor) in enumerate(values):
                lbl = tk.Label(self.tool_table_frame, text=value, width=width,
                               font=("Helvetica", 11), bg=bg, fg="#222222",
                               anchor=anchor, padx=12, pady=7)
                lbl.grid(row=row_idx, column=col, sticky="ew")
            row_idx += 1
        footer_row = row_idx
        footer_bg = "#e4e9ee"
        footer_vals = [("Total", 10, "w"), (format_hms(total_seconds), 18, "e")]
        for col, (value, width, anchor) in enumerate(footer_vals):
            lbl = tk.Label(self.tool_table_frame, text=value, width=width,
                           font=("Helvetica", 11, "bold"), bg=footer_bg,
                           fg="#000000", anchor=anchor, padx=12, pady=8)
            lbl.grid(row=footer_row, column=col, sticky="ew")
        self.tool_table_frame.grid_columnconfigure(0, weight=1, minsize=90)
        self.tool_table_frame.grid_columnconfigure(1, weight=1, minsize=180)
        self._sync_tool_scroll()

def main():
    root = tk.Tk()
    try:
        root.wm_attributes('-zoomed', 1)
    except:
        root.geometry("1280x820")
    EndOfDaySummary(root)
    root.mainloop()

if __name__ == "__main__":
    main()
