"""
GRIFO_M_PBIT_gui.py - Real-Time Test Monitor GUI

This module provides a tkinter-based GUI to monitor test execution in real-time.
Works with both real hardware and simulation mode.

Features:
    - Live test progress visualization
    - Current scenario information
    - Real-time statistics (B6/B8/Serial)
    - Color-coded status indicators
    - Event log with filtering
    - Thread-safe updates via queue

Requirements:
    - Python 3.7+
    - tkinter 8.6+ (included in embedded Python)

Usage:
    # In test script:
    from GRIFO_M_PBIT_gui import TestMonitorGUI
    gui = TestMonitorGUI()
    gui.start()
    
    # Update from test code:
    gui.update_status('run', current=5, total=10)
    gui.update_scenario('processor_fail', 'Processor Failure', {...})
    gui.log_event('info', 'BIT completed in 15.3s')
    
    # Cleanup:
    gui.stop()

Author: Test Automation Team
Date: 2026-01-30
"""

import tkinter as tk
from tkinter import ttk, scrolledtext
import threading
import queue
import time
import gc
import os
from datetime import datetime, timedelta
from typing import Dict, Any, Optional


class ToolTip:
    """
    Creates a tooltip for a given widget.
    Shows the tooltip when mouse enters the widget.
    """
    def __init__(self, widget, text):
        self.widget = widget
        self.text = text
        self.tooltip_window = None
        self.widget.bind("<Enter>", self.show_tooltip)
        self.widget.bind("<Leave>", self.hide_tooltip)
        
    def show_tooltip(self, event=None):
        """Display the tooltip."""
        if self.tooltip_window or not self.text:
            return
        
        # Get widget position
        x = self.widget.winfo_rootx() + 20
        y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
        
        # Create tooltip window
        self.tooltip_window = tw = tk.Toplevel(self.widget)
        tw.wm_overrideredirect(True)  # Remove window decorations
        tw.wm_geometry(f"+{x}+{y}")
        
        # Create label with tooltip text
        label = tk.Label(tw, text=self.text, justify=tk.LEFT,
                        background="#ffffe0", relief=tk.SOLID, borderwidth=1,
                        font=("Arial", 9))
        label.pack(ipadx=5, ipady=3)
        
    def hide_tooltip(self, event=None):
        """Hide the tooltip."""
        if self.tooltip_window:
            self.tooltip_window.destroy()
            self.tooltip_window = None


class TestMonitorGUI:
    """
    Real-time GUI monitor for GRIFO PBIT test execution.
    
    Thread-safe GUI that displays:
    - Test progress (runs, scenarios)
    - Live statistics (B6, B8, Serial)
    - Scenario information
    - Event log
    """
    
    def __init__(self):
        """Initialize GUI components (creates window but doesn't show it yet)."""
        self.root = None
        self.thread = None
        self.running = False
        self.update_queue = queue.Queue()
        
        # Data storage
        self.data = {
            'run_current': 0,
            'run_total': 0,
            'scenario_name': 'Initializing...',
            'scenario_description': '',
            'pbit_time': 0.0,
            'pbit_available': False,
            'power_on': False,
            'b6_total': 0,
            'b6_pass': 0,
            'b6_fail': 0,
            'b6_known': 0,
            'b8_checked': 0,
            'b8_pass': 0,
            'b8_fail': 0,
            'serial_total': 0,
            'serial_errors': 0,
            'serial_fatal': 0,
            'serial_recycles': 0,
            'success_rate': 0.0,
            'expected_failures': [],
            'expected_passes': [],
            'notes': '',
        }
        
        # GUI components (will be created in GUI thread)
        self.widgets = {}
        # Timing/ETA estimation
        self.pbit_samples = []  # list of completed run durations (seconds)
        self.run_start_timestamp = None
        self.last_run_current = 0
        self.default_pbit_sec = 182.0  # fallback per-run estimate
        # Per-run detailed data storage (populated on GUI thread)
        self.runs_data = {}
    
    def start(self):
        """Start GUI in separate thread."""
        if self.running:
            return
        
        self.running = True
        # Start GUI thread as non-daemon so the process remains alive until user closes the GUI
        self.thread = threading.Thread(target=self._run_gui, daemon=False)
        self.thread.start()
        
        # Wait for GUI to initialize
        time.sleep(0.5)
    
    def stop(self):
        """Stop GUI and close window."""
        # Request shutdown from GUI thread to avoid calling tkinter methods
        # from the main thread (which can trigger Tcl_AsyncDelete warnings).
        if not self.running:
            return
        self.update_queue.put(('shutdown', {}))

        # Wait for GUI thread to exit (safe join); allow longer timeout for slow systems
        try:
            if self.thread and self.thread.is_alive():
                self.thread.join(timeout=15.0)
        except Exception:
            pass
        self.running = False
    
    def _run_gui(self):
        """GUI thread main loop."""
        self.root = tk.Tk()
        self.root.title("GRIFO M-PBIT Test Monitor")
        # Increase default window size so log and runs table are visible without manual resize
        self.root.geometry("1100x900")
        self.root.minsize(900, 700)
        self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
        
        # Configure styles
        style = ttk.Style()
        style.theme_use('clam')
        
        # Create GUI layout
        self._create_widgets()
        
        # Start update polling
        self._poll_updates()
        
        # Run main loop
        self.root.mainloop()
    
    def _create_widgets(self):
        """Create all GUI widgets."""
        # Main container
        main_frame = ttk.Frame(self.root, padding="10")
        main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
        self.root.columnconfigure(0, weight=1)
        self.root.rowconfigure(0, weight=1)
        
        # Configure grid weights
        main_frame.columnconfigure(0, weight=1)
        main_frame.columnconfigure(1, weight=1)
        # Give more vertical space to runs table (row 3) and some to log (row 4)
        main_frame.rowconfigure(3, weight=3)
        main_frame.rowconfigure(4, weight=1)
        
        # ===== ROW 0: HEADER =====
        header_frame = ttk.Frame(main_frame)
        header_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
        
        title_label = ttk.Label(header_frame, text="GRIFO M-PBIT Test Monitor", 
                               font=('Arial', 16, 'bold'))
        title_label.pack(side=tk.LEFT)
        
        self.widgets['status_indicator'] = ttk.Label(header_frame, text="● IDLE", 
                                                     font=('Arial', 12, 'bold'),
                                                     foreground='gray')
        self.widgets['status_indicator'].pack(side=tk.RIGHT)
        
        # ===== ROW 1: PROGRESS SECTION =====
        progress_frame = ttk.LabelFrame(main_frame, text="Test Progress", padding="10")
        progress_frame.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
        progress_frame.columnconfigure(0, weight=0)
        progress_frame.columnconfigure(1, weight=1)
        progress_frame.columnconfigure(2, weight=0)
        progress_frame.columnconfigure(3, weight=0)
        
        # Run info
        ttk.Label(progress_frame, text="Current Run:").grid(row=0, column=0, sticky=tk.W)
        self.widgets['run_label'] = ttk.Label(progress_frame, text="0 / 0", 
                                              font=('Arial', 10, 'bold'))
        self.widgets['run_label'].grid(row=0, column=1, sticky=tk.W, padx=(10, 0))
        
        # Progress bar
        self.widgets['progress_bar'] = ttk.Progressbar(progress_frame, mode='determinate')
        self.widgets['progress_bar'].grid(row=1, column=0, columnspan=4, sticky=(tk.W, tk.E), pady=(5, 0))
        
        # PBIT timer
        ttk.Label(progress_frame, text="PBIT Time:").grid(row=2, column=0, sticky=tk.W, pady=(5, 0))
        self.widgets['pbit_label'] = ttk.Label(progress_frame, text="0.0s", 
                               font=('Arial', 10))
        self.widgets['pbit_label'].grid(row=2, column=1, sticky=tk.W, padx=(10, 0), pady=(5, 0))
        # Estimated remaining time (ETA) on the same row, right-justified
        ttk.Label(progress_frame, text="Estimated Remaining:").grid(row=2, column=2, sticky=tk.W, pady=(5, 0))
        self.widgets['eta_label'] = ttk.Label(progress_frame, text="--:--", font=('Arial', 10))
        self.widgets['eta_label'].grid(row=2, column=3, sticky=tk.E, padx=(10, 0), pady=(5, 0))
        
        # ===== ROW 2: SCENARIO INFO =====
        scenario_frame = ttk.LabelFrame(main_frame, text="Current Scenario", padding="10")
        scenario_frame.grid(row=2, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
        scenario_frame.columnconfigure(1, weight=1)
        
        # Scenario name
        ttk.Label(scenario_frame, text="Scenario:").grid(row=0, column=0, sticky=tk.W)
        self.widgets['scenario_label'] = ttk.Label(scenario_frame, text="Initializing...", 
                                                   font=('Arial', 10, 'bold'))
        self.widgets['scenario_label'].grid(row=0, column=1, sticky=tk.W, padx=(10, 0))
        
        # Description
        ttk.Label(scenario_frame, text="Description:").grid(row=1, column=0, sticky=tk.W, pady=(5, 0))
        self.widgets['scenario_desc'] = ttk.Label(scenario_frame, text="", 
                                                  font=('Arial', 9), wraplength=600)
        self.widgets['scenario_desc'].grid(row=1, column=1, sticky=tk.W, padx=(10, 0), pady=(5, 0))
        
        # Expected failures
        ttk.Label(scenario_frame, text="Expected Failures:").grid(row=2, column=0, sticky=(tk.W, tk.N), pady=(5, 0))
        self.widgets['expected_fail'] = tk.Text(scenario_frame, height=2, width=60, 
                                                font=('Courier', 8), wrap=tk.WORD,
                                                background='#fff5f5')
        self.widgets['expected_fail'].grid(row=2, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(5, 0))
        
        # ===== ROW 3: PER-RUN TABLE (moved up for better visibility) =====
        runs_frame = ttk.LabelFrame(main_frame, text="Runs Overview", padding="5")
        runs_frame.grid(row=3, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10))
        runs_frame.columnconfigure(0, weight=1)
        runs_frame.rowconfigure(1, weight=1)  # Row 1 for table (row 0 for custom headers)

        # Create custom header frame with tooltips
        header_frame = ttk.Frame(runs_frame)
        header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
        
        # Define headers with their tooltips
        headers_with_tooltips = [
            ('Run', None, 60),
            ('Status', None, 80),
            ('PBIT Time', None, 80),
            ('B6 Status (P/F/K)', 'P = Pass\nF = Fail\nK = Known Failures', 120),
            ('B8 Checks (P/F/K)', 'P = Pass\nF = Fail\nK = Known Failures', 120),
            ('Serial Messages (E/F/R)', 'E = Errors\nF = Fatal\nR = Recycles', 120),
            ('Target', 'Target distance and cycle\nFormat: XXXXm @ cycle Y', 140),
            ('Target Test', None, 80)
        ]
        
        # Create header labels with tooltips
        for idx, (title, tooltip, width) in enumerate(headers_with_tooltips):
            label = ttk.Label(header_frame, text=title, font=('Arial', 9, 'bold'), 
                            anchor=tk.CENTER, relief=tk.RAISED, borderwidth=1)
            label.grid(row=0, column=idx, sticky=(tk.W, tk.E), ipadx=2, ipady=3)
            header_frame.columnconfigure(idx, weight=1 if idx > 0 else 0, minsize=width)
            
            # Add tooltip if specified
            if tooltip:
                ToolTip(label, tooltip)

        # Columns aligned with PDF report format for consistency
        cols = ('run', 'status', 'pbit_time', 'b6_status', 'b8_checks', 'serial_msgs', 'target', 'target_test')
        # Taller table for better visibility (show='tree' to hide built-in headers)
        self.widgets['runs_table'] = ttk.Treeview(runs_frame, columns=cols, show='tree', height=12)
        self.widgets['runs_table'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
        self.widgets['runs_table'].column('#0', width=0, stretch=tk.NO)  # Hide tree column
        
        # Set column widths to match headers
        column_widths = [60, 80, 80, 120, 120, 120, 140, 80]
        for c, width in zip(cols, column_widths):
            self.widgets['runs_table'].column(c, width=width, anchor=tk.CENTER)
        
        # Add scrollbar
        runs_scroll = ttk.Scrollbar(runs_frame, orient=tk.VERTICAL, command=self.widgets['runs_table'].yview)
        runs_scroll.grid(row=1, column=1, sticky=(tk.N, tk.S))
        self.widgets['runs_table'].configure(yscrollcommand=runs_scroll.set)
        
        # Style tags for visual feedback
        self.widgets['runs_table'].tag_configure('pass', background='#d4edda')
        self.widgets['runs_table'].tag_configure('fail', background='#f8d7da')
        
        # Bind double-click for details
        self.widgets['runs_table'].bind('<Double-Button-1>', self._on_run_double_click)
        
        # ===== ROW 4: EVENT LOG (moved down) =====
        log_frame = ttk.LabelFrame(main_frame, text="Event Log", padding="10")
        log_frame.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S))
        log_frame.columnconfigure(0, weight=1)
        log_frame.rowconfigure(0, weight=1)
        
        self.widgets['log_text'] = scrolledtext.ScrolledText(log_frame, height=15, width=80,
                                                             font=('Courier', 8), wrap=tk.WORD)
        self.widgets['log_text'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
        
        # Configure text tags for color coding
        self.widgets['log_text'].tag_config('info', foreground='black')
        self.widgets['log_text'].tag_config('success', foreground='green')
        self.widgets['log_text'].tag_config('warning', foreground='orange')
        self.widgets['log_text'].tag_config('error', foreground='red')
        self.widgets['log_text'].tag_config('debug', foreground='gray')
    
    def _poll_updates(self):
        """Poll update queue and apply changes to GUI."""
        if not self.running:
            return
        
        try:
            # Process all queued updates
            while not self.update_queue.empty():
                update_type, data = self.update_queue.get_nowait()
                
                if update_type == 'status':
                    self._update_status(data)
                elif update_type == 'scenario':
                    self._update_scenario(data)
                elif update_type == 'statistics':
                    self._update_statistics(data)
                elif update_type == 'run_row':
                    self._update_run_row(data)
                elif update_type == 'show_results':
                    self._show_results_dialog(data)
                elif update_type == 'shutdown':
                    # Perform clean shutdown from the GUI thread
                    try:
                        self.running = False
                        if self.root:
                            try:
                                self.root.quit()
                            except Exception:
                                pass
                            try:
                                self.root.destroy()
                            except Exception:
                                pass
                            # CRITICAL: Clear all Tk references on GUI thread to avoid
                            # Tcl_AsyncDelete warnings when main thread cleans up
                            try:
                                self.root = None
                                self.widgets.clear()
                            except Exception:
                                pass
                    except Exception:
                        pass
                    return
                elif update_type == 'gc_collect':
                    # Run garbage collection on the GUI thread so that any
                    # PhotoImage/ImageTk destructors that call into Tk run
                    # on the correct thread and avoid Tcl_AsyncDelete issues.
                    try:
                        gc.collect()
                    except Exception:
                        pass
                elif update_type == 'log':
                    self._add_log_entry(data)
        except queue.Empty:
            pass
        except Exception as e:
            print(f"[GUI] Error processing update: {e}")
        
        # Schedule next poll
        if self.root:
            self.root.after(100, self._poll_updates)
    
    def _update_status(self, data: Dict[str, Any]):
        """Update test status display."""
        if 'run_current' in data:
            self.data['run_current'] = data['run_current']
        if 'run_total' in data:
            self.data['run_total'] = data['run_total']
        if 'pbit_time' in data:
            self.data['pbit_time'] = data['pbit_time']
        if 'pbit_available' in data:
            self.data['pbit_available'] = data['pbit_available']
        if 'power_on' in data:
            self.data['power_on'] = data['power_on']
        
        # Update labels
        self.widgets['run_label'].config(text=f"{self.data['run_current']} / {self.data['run_total']}")
        self.widgets['pbit_label'].config(text=f"{self.data['pbit_time']:.1f}s")
        
        # Update progress bar
        if self.data['run_total'] > 0:
            progress = (self.data['run_current'] / self.data['run_total']) * 100
            self.widgets['progress_bar']['value'] = progress
        
        # Update status indicator
        if self.data['power_on']:
            status_text = "● RUNNING"
            status_color = 'green'
        else:
            status_text = "● IDLE"
            status_color = 'gray'
        
        self.widgets['status_indicator'].config(text=status_text, foreground=status_color)

        # Initialize runs table when run_total is set
        if self.data['run_total'] and 'runs_table_initialized' not in self.widgets:
            self._init_runs_table(self.data['run_total'])

        # ----- ETA / estimation logic -----
        try:
            now = time.time()
            run_current = int(self.data.get('run_current', 0) or 0)
            run_total = int(self.data.get('run_total', 0) or 0)
            # Initialize run start timestamp when first run is seen
            if run_current and self.run_start_timestamp is None:
                self.run_start_timestamp = now
                self.last_run_current = run_current

            # Detect run increment -> previous run finished
            if run_current and self.last_run_current and run_current > self.last_run_current:
                # previous run finished at 'now'
                try:
                    duration = now - (self.run_start_timestamp or now)
                    if duration > 0:
                        self.pbit_samples.append(duration)
                except Exception:
                    pass
                # start timestamp for the new run
                self.run_start_timestamp = now
                self.last_run_current = run_current

            # Compute average per-run duration
            if self.pbit_samples:
                avg = sum(self.pbit_samples) / len(self.pbit_samples)
            else:
                avg = float(self.default_pbit_sec)

            # Remaining runs (not counting current one)
            remaining_runs = max(0, run_total - run_current)
            current_pbit = float(self.data.get('pbit_time', 0.0) or 0.0)

            # Estimate remaining seconds: remaining full runs + remaining of current
            remaining_of_current = max(0.0, avg - current_pbit)
            est_seconds = remaining_runs * avg + remaining_of_current

            # If finished, show completed
            if run_total and run_current >= run_total:
                eta_text = "Completed"
            else:
                # Human-friendly duration with units
                duration_str = self._format_seconds(est_seconds)
                # Estimated finish clock time
                try:
                    finish_dt = datetime.now() + timedelta(seconds=int(round(est_seconds)))
                    finish_str = finish_dt.strftime("%H:%M")
                    eta_text = f"{duration_str} ({finish_str})"
                except Exception:
                    eta_text = duration_str

            # Update ETA label
            try:
                self.widgets['eta_label'].config(text=eta_text)
            except Exception:
                pass
        except Exception:
            # Keep ETA unchanged on errors
            pass
    
    def _update_scenario(self, data: Dict[str, Any]):
        """Update scenario information display."""
        if 'name' in data:
            self.data['scenario_name'] = data['name']
        if 'description' in data:
            self.data['scenario_description'] = data['description']
        if 'expected_failures' in data:
            self.data['expected_failures'] = data['expected_failures']
        if 'expected_passes' in data:
            self.data['expected_passes'] = data['expected_passes']
        if 'notes' in data:
            self.data['notes'] = data['notes']
        
        # Update labels
        self.widgets['scenario_label'].config(text=self.data['scenario_name'])
        self.widgets['scenario_desc'].config(text=self.data['scenario_description'])
        
        # Update expected failures text box (enable for edits then disable)
        txt = self.widgets['expected_fail']
        try:
            txt.config(state='normal')
        except Exception:
            pass
        txt.delete('1.0', tk.END)
        if self.data['expected_failures']:
            fail_text = '\n'.join([f"✗ {f}" for f in self.data['expected_failures']])
        else:
            fail_text = "(none expected)"
        txt.insert('1.0', fail_text)
        txt.config(state='disabled')
    
    def _update_statistics(self, data: Dict[str, Any]):
        """Update statistics display.
        
        Note: Statistics frame removed from GUI (data visible in runs table).
        This method kept for backward compatibility but does nothing.
        """
        # Update internal data only (no GUI update)
        for key in ['b6_total', 'b6_pass', 'b6_fail', 'b6_known',
                   'b8_checked', 'b8_pass', 'b8_fail',
                   'serial_total', 'serial_errors', 'serial_fatal', 'serial_recycles']:
            if key in data:
                self.data[key] = data[key]
        # GUI widgets removed - data now visible in runs table
    
    def _add_log_entry(self, data: Dict[str, str]):
        """Add entry to event log."""
        level = data.get('level', 'info')
        message = data.get('message', '')
        timestamp = time.strftime('%H:%M:%S')
        
        # Format message with timestamp
        log_line = f"[{timestamp}] {message}\n"
        
        # Insert with appropriate tag
        self.widgets['log_text'].insert(tk.END, log_line, level)
        self.widgets['log_text'].see(tk.END)
        
        # Limit log size (keep last 500 lines)
        line_count = int(self.widgets['log_text'].index('end-1c').split('.')[0])
        if line_count > 500:
            self.widgets['log_text'].delete('1.0', '100.0')

    def _format_seconds(self, seconds: float) -> str:
        """Format seconds into human-friendly string with units.

        Examples:
          - 14     -> "14s"
          - 134    -> "2m 14s"
          - 3670   -> "1h 01m 10s"
        """
        try:
            sec = int(round(seconds))
            if sec <= 0:
                return "0s"
            hours, rem = divmod(sec, 3600)
            minutes, secs = divmod(rem, 60)
            parts = []
            if hours:
                parts.append(f"{hours}h")
            if minutes:
                parts.append(f"{minutes}m")
            if secs or not parts:
                parts.append(f"{secs}s")
            return ' '.join(parts)
        except Exception:
            return "--:--"
    
    def _on_closing(self):
        """Handle window close event."""
        self.running = False
        self.root.destroy()
    
    # ===== PUBLIC API FOR TEST CODE =====
    
    def update_status(self, run_current: Optional[int] = None, run_total: Optional[int] = None,
                     pbit_time: Optional[float] = None, pbit_available: Optional[bool] = None,
                     power_on: Optional[bool] = None):
        """Update test status (thread-safe)."""
        data = {}
        if run_current is not None:
            data['run_current'] = run_current
        if run_total is not None:
            data['run_total'] = run_total
        if pbit_time is not None:
            data['pbit_time'] = pbit_time
        if pbit_available is not None:
            data['pbit_available'] = pbit_available
        if power_on is not None:
            data['power_on'] = power_on
        
        if data:
            self.update_queue.put(('status', data))
    
    def update_scenario(self, name: str, description: str, 
                       expected_failures: list, expected_passes: list, notes: str):
        """Update scenario information (thread-safe)."""
        data = {
            'name': name,
            'description': description,
            'expected_failures': expected_failures,
            'expected_passes': expected_passes,
            'notes': notes,
        }
        self.update_queue.put(('scenario', data))
    
    def update_statistics(self, **kwargs):
        """Update statistics (thread-safe)."""
        self.update_queue.put(('statistics', kwargs))

    def _init_runs_table(self, total_runs: int):
        """Pre-populate runs table with empty rows for visual overview."""
        table = self.widgets.get('runs_table')
        if not table:
            return
        # Clear existing
        for r in table.get_children():
            table.delete(r)
        for i in range(1, total_runs + 1):
            table.insert('', 'end', iid=str(i), values=(f'{i}/{total_runs}', '', '', '', '', '', '', '', '', ''))
            # initialize empty metadata for each run
            try:
                self.runs_data[str(i)] = {}
            except Exception:
                pass
        self.widgets['runs_table_initialized'] = True

    def update_run(self, run: int, **kwargs):
        """Thread-safe request to update one row in runs table.

        Accepts keyword arguments matching PDF report format:
        - result: PASS/FAIL status
        - pbit: PBIT time (seconds)
        - b6_pass, b6_fail, b6_known_fail: B6 test results (formatted as P/F/K)
        - b8_pass, b8_fail, b8_checked: B8 test results (formatted as Pass/Fail or -)
        - serial_errors, serial_fatal, serial_recycles: Serial message stats (formatted as E/F/R)
        - target_simulated: Target simulation data (formatted as distance @ cycle)
        - target_detected: Target test result (PASS/FAIL/-)
        
        Additional metadata fields for detail dialog:
        - start_time, end_time, run_duration
        - failures, serial_details
        - target_test_time, stby_verified, silence_verified, stby_silence_verify_details
        - target_distance_m, target_distance_nm, target_found_iter
        """
        data = {'run': run}
        # Merge all kwargs into data to keep API flexible
        try:
            for k, v in kwargs.items():
                data[k] = v
        except Exception:
            pass
        self.update_queue.put(('run_row', data))

    def _update_run_row(self, data: Dict[str, Any]):
        """Apply an update to the runs table row specified by 'run'."""
        table = self.widgets.get('runs_table')
        if not table:
            return
        run_num = str(data.get('run'))
        if not table.exists(run_num):
            # Insert if missing
            total = self.data.get('run_total', 0)
            table.insert('', 'end', iid=run_num, values=(f'{run_num}/{total}', '', '', '', '', '', '', ''))

        # Read old values
        old = table.item(run_num).get('values', [])
        if not old:
            old = [''] * 8  # Updated to 8 columns (aligned with PDF)
        # Map columns to match PDF format
        new = list(old)
        for k, v in data.items():
            if k == 'run':
                new[0] = f"{v}/{self.data.get('run_total', '')}"
            elif k == 'result':
                # Column 1: Status (PASS/FAIL)
                new[1] = v
            elif k == 'pbit':
                # Column 2: PBIT Time
                new[2] = f"{v:.1f}s" if isinstance(v, (int, float)) else str(v)
            elif k in ('b6_pass', 'b6_fail', 'b6_known_fail'):
                # Column 3: B6 Status (P/F/K) - format like PDF
                b6_pass = data.get('b6_pass', 0)
                b6_fail = data.get('b6_fail', 0)
                b6_known = data.get('b6_known_fail', 0)
                new[3] = f"{b6_pass}/{b6_fail}/{b6_known}"
            elif k in ('b8_pass', 'b8_fail', 'b8_checked', 'b8_known_fail'):
                # Column 4: B8 Checks (Pass/Fail/Known) - format like PDF
                b8_pass = data.get('b8_pass', 0)
                b8_fail = data.get('b8_fail', 0)
                b8_known = data.get('b8_known_fail', 0)
                b8_checked = data.get('b8_checked', 0)
                if b8_checked > 0:
                    new[4] = f"{b8_pass}/{b8_fail}/{b8_known}"
                else:
                    new[4] = "-"
            elif k in ('serial_errors', 'serial_fatal', 'serial_recycles'):
                # Column 5: Serial Messages (E/F/R) - format like PDF
                serial_e = data.get('serial_errors', 0)
                serial_f = data.get('serial_fatal', 0)
                serial_r = data.get('serial_recycles', 0)
                new[5] = f"{serial_e}/{serial_f}/{serial_r}"
            elif k == 'target_simulated':
                # Column 6: Target (distance @ cycle) - format like PDF
                if v and isinstance(v, dict):
                    try:
                        distance = v.get('distance', '?')
                        cycle = v.get('appeared_after_cycles', v.get('found_at_iter', '?'))
                        new[6] = f"{distance}m @ cycle {cycle}"
                    except Exception:
                        new[6] = "-"
                else:
                    new[6] = "-"
            elif k == 'target_detected':
                # Column 7: Target Test (PASS/FAIL/-)
                if v is None:
                    new[7] = "-"
                elif v:
                    new[7] = "PASS"
                    # If target detected but no simulated data yet, keep existing target column
                    if new[6] == "-" or not new[6]:
                        new[6] = "Detected"
                else:
                    new[7] = "FAIL"
                    # If target not detected, clear target column
                    new[6] = "-"

        # Merge/remember detailed data for this run (kept on GUI thread)
        try:
            meta = self.runs_data.get(run_num, {})
            for k, v in data.items():
                meta[k] = v
            # also store the displayed columns for convenience (aligned with PDF)
            meta['display'] = {
                'status': new[1], 'pbit_time': new[2], 'b6_status': new[3], 'b8_checks': new[4],
                'serial_msgs': new[5], 'target': new[6], 'target_test': new[7]
            }
            self.runs_data[run_num] = meta
        except Exception:
            pass

        table.item(run_num, values=new)
        # Apply visual tag based on result for quick scanning
        try:
            res = new[1].strip() if len(new) > 1 and isinstance(new[1], str) else ''
            if res.upper() == 'PASS':
                table.item(run_num, tags=('pass',))
            elif res.upper() == 'FAIL':
                table.item(run_num, tags=('fail',))
            else:
                # clear tags
                table.item(run_num, tags=())
        except Exception:
            pass
        # Ensure the updated row is visible (scroll to it)
        try:
            table.see(run_num)
        except Exception:
            # Fallback: move view to bottom
            try:
                table.yview_moveto(1.0)
            except Exception:
                pass
    
    def log_event(self, level: str, message: str):
        """Add log entry (thread-safe).
        
        Args:
            level: 'info', 'success', 'warning', 'error', 'debug'
            message: Log message text
        """
        data = {'level': level, 'message': message}
        self.update_queue.put(('log', data))

    def _on_run_double_click(self, event=None):
        """Handle double-click on runs table: open detailed run dialog."""
        try:
            table = self.widgets.get('runs_table')
            if not table:
                return

            sel = table.focus()
            if not sel:
                # try selection
                sel_items = table.selection()
                if not sel_items:
                    return
                sel = sel_items[0]

            run_id = str(sel)
            meta = self.runs_data.get(run_id, {})

            # Build dialog
            dlg = tk.Toplevel(self.root)
            dlg.title(f"Run {run_id} Details")
            dlg.transient(self.root)
            frm = ttk.Frame(dlg, padding=12)
            frm.pack(fill=tk.BOTH, expand=True)

            # Header
            hdr = ttk.Label(frm, text=f"Run {run_id}", font=('Arial', 12, 'bold'))
            hdr.pack(anchor=tk.W)

            # Key/value grid
            grid = ttk.Frame(frm)
            grid.pack(fill=tk.X, pady=(6, 6))

            def _kv(row, key, val):
                ttk.Label(grid, text=key+":", font=('Arial', 9, 'bold')).grid(row=row, column=0, sticky=tk.W, padx=(0,6))
                ttk.Label(grid, text=str(val), font=('Arial', 9)).grid(row=row, column=1, sticky=tk.W)

            display = meta.get('display', {})
            _kv(0, 'Status', display.get('status', ''))
            _kv(1, 'PBIT Time', display.get('pbit_time', ''))
            _kv(2, 'B6 Status (P/F/K)', display.get('b6_status', ''))
            _kv(3, 'B8 Checks', display.get('b8_checks', ''))
            _kv(4, 'Serial Msgs (E/F/R)', display.get('serial_msgs', ''))
            _kv(5, 'Target', display.get('target', ''))
            _kv(6, 'Target Test', display.get('target_test', ''))

            # Start / End times and duration (if provided)
            start_iso = meta.get('start_time')
            end_iso = meta.get('end_time')
            run_duration = meta.get('run_duration')
            try:
                start_disp = start_iso
                end_disp = end_iso
                dur_disp = f"{run_duration:.2f}s" if isinstance(run_duration, (int, float)) else str(run_duration)
            except Exception:
                start_disp = start_iso
                end_disp = end_iso
                dur_disp = run_duration
            _kv(7, 'Start Time', start_disp)
            _kv(8, 'End Time', end_disp)
            _kv(9, 'Run Duration', dur_disp)

            # Target info
            tgt_detected = meta.get('target_detected', None)
            tgt_test_time = meta.get('target_test_time', None)
            tgt_dist_m = meta.get('target_distance_m', None)
            tgt_dist_nm = meta.get('target_distance_nm', None)
            tgt_iter = meta.get('target_found_iter', None)

            tgt_status = 'YES' if tgt_detected is True else ('NO' if tgt_detected is False else 'N/A')
            _kv(10, 'Target Detected', tgt_status)
            _kv(11, 'Target Test Time', f"{tgt_test_time:.2f}s" if isinstance(tgt_test_time, (int, float)) else str(tgt_test_time))
            # Distance formatting
            try:
                dist_str = ''
                if isinstance(tgt_dist_m, (int, float)):
                    dist_str = f"{tgt_dist_m:.1f} m"
                    if isinstance(tgt_dist_nm, (int, float)):
                        dist_str += f" ({tgt_dist_nm:.2f} NM)"
                else:
                    dist_str = str(tgt_dist_m)
            except Exception:
                dist_str = str(tgt_dist_m)
            _kv(12, 'Target Distance', dist_str)
            _kv(13, 'Target Found At Iter', tgt_iter)

            # Failures detail (multi-line)
            ttk.Label(frm, text='Failures (detailed):', font=('Arial', 9, 'bold')).pack(anchor=tk.W, pady=(6,0))
            failures_text = tk.Text(frm, height=6, width=80, font=('Courier', 9), wrap=tk.WORD)
            failures = meta.get('failures') or meta.get('fail_summary') or ''
            # If failures stored as list, pretty-format
            if isinstance(failures, (list, tuple)):
                body = '\n'.join([str(x) for x in failures])
            else:
                body = str(failures)
            failures_text.insert('1.0', body)
            failures_text.config(state='disabled')
            failures_text.pack(fill=tk.BOTH, expand=False, pady=(2,6))

            # Serial details if present
            serial = meta.get('serial_details') or meta.get('serial_events') or ''
            ttk.Label(frm, text='Serial events/details:', font=('Arial', 9, 'bold')).pack(anchor=tk.W, pady=(6,0))
            serial_text = tk.Text(frm, height=4, width=80, font=('Courier', 9), wrap=tk.WORD)
            serial_text.insert('1.0', str(serial))
            serial_text.config(state='disabled')
            serial_text.pack(fill=tk.BOTH, expand=False, pady=(2,6))

            # Close button
            btn_frame = ttk.Frame(frm)
            btn_frame.pack(fill=tk.X, pady=(6,0))
            close_btn = ttk.Button(btn_frame, text='Close', command=lambda: dlg.destroy())
            close_btn.pack(side=tk.RIGHT)

            # Center dialog
            dlg.update_idletasks()
            w = dlg.winfo_reqwidth()
            h = dlg.winfo_reqheight()
            try:
                px = self.root.winfo_rootx()
                py = self.root.winfo_rooty()
                pw = self.root.winfo_width() or self.root.winfo_reqwidth()
                ph = self.root.winfo_height() or self.root.winfo_reqheight()
                x = px + max(0, (pw - w)//2)
                y = py + max(0, (ph - h)//2)
                dlg.geometry(f"{w}x{h}+{x}+{y}")
            except Exception:
                pass
            dlg.transient(self.root)
            dlg.grab_set()
        except Exception as e:
            print(f"Error opening run details: {e}")

    def show_results(self, folder: str):
        """Request GUI to show results folder dialog (thread-safe).

        This enqueues a request processed in the GUI thread.
        """
        self.update_queue.put(('show_results', {'folder': folder}))

    def _show_results_dialog(self, data: dict):
        """Create a professional dialog showing test completion and output locations."""
        folder = data.get('folder') if isinstance(data, dict) else data
        if not folder:
            return

        try:
            dlg = tk.Toplevel(self.root)
            dlg.title('GRIFO M-PBIT Test Completed')
            dlg.transient(self.root)
            
            # Main container with padding
            main_frame = ttk.Frame(dlg, padding="20")
            main_frame.pack(fill=tk.BOTH, expand=True)

            # Header with icon and title
            header_frame = ttk.Frame(main_frame)
            header_frame.pack(fill=tk.X, pady=(0, 15))
            
            title_label = ttk.Label(header_frame, text="✓ Test Execution Completed", 
                                   font=('Arial', 14, 'bold'), foreground='green')
            title_label.pack(anchor=tk.W)

            # Separator
            sep1 = ttk.Separator(main_frame, orient=tk.HORIZONTAL)
            sep1.pack(fill=tk.X, pady=(0, 15))

            # Info section
            info_frame = ttk.LabelFrame(main_frame, text="Test Results", padding="15")
            info_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 15))

            msg_text = (
                "The test execution has been completed successfully.\n\n"
                "The following output files have been generated:"
            )
            msg_label = ttk.Label(info_frame, text=msg_text, justify=tk.LEFT)
            msg_label.pack(anchor=tk.W, pady=(0, 10))

            # File types list
            files_frame = ttk.Frame(info_frame)
            files_frame.pack(fill=tk.X, pady=(0, 10))
            
            file_items = [
                ("PDF Report", "Complete test report with analysis"),
                ("CSV Export", "Detailed statistics and per-run data"),
                ("Log Files", "Execution logs and debug information")
            ]
            
            for icon_text, description in file_items:
                item_frame = ttk.Frame(files_frame)
                item_frame.pack(fill=tk.X, pady=2)
                ttk.Label(item_frame, text=f"  {icon_text}:", font=('Arial', 9, 'bold')).pack(side=tk.LEFT)
                ttk.Label(item_frame, text=f" {description}", font=('Arial', 9)).pack(side=tk.LEFT)

            # Location section with folder path
            location_frame = ttk.Frame(info_frame)
            location_frame.pack(fill=tk.X, pady=(10, 0))
            
            ttk.Label(location_frame, text="Location:", font=('Arial', 9, 'bold')).pack(anchor=tk.W)
            
            path_text = tk.Text(location_frame, height=2, wrap=tk.WORD, 
                               font=('Courier', 8), relief=tk.FLAT, 
                               background='#f0f0f0', padx=5, pady=5)
            path_text.insert('1.0', folder)
            path_text.config(state='disabled')
            path_text.pack(fill=tk.X, pady=(5, 0))

            # Separator
            sep2 = ttk.Separator(main_frame, orient=tk.HORIZONTAL)
            sep2.pack(fill=tk.X, pady=(0, 15))

            # Button frame
            btn_frame = ttk.Frame(main_frame)
            btn_frame.pack(fill=tk.X)

            def _open_folder():
                try:
                    if os.name == 'nt':
                        os.startfile(folder)
                    else:
                        import subprocess
                        subprocess.Popen(['xdg-open', folder])
                except Exception:
                    pass

            def _close_all():
                try:
                    dlg.destroy()
                except Exception:
                    pass
                try:
                    self._on_closing()
                except Exception:
                    pass

            # Buttons with better styling
            open_btn = ttk.Button(btn_frame, text='Open folder', command=_open_folder)
            open_btn.pack(side=tk.RIGHT, padx=(10, 0))
            
            close_btn = ttk.Button(btn_frame, text='Close Test', command=_close_all)
            close_btn.pack(side=tk.RIGHT)

            # Update window to calculate final size, then center on parent and show
            dlg.update_idletasks()
            
            # Set minimum size based on content
            dlg_width = max(600, dlg.winfo_reqwidth())
            dlg_height = max(400, dlg.winfo_reqheight())
            
            # Calculate position to center on parent window (use root's screen coordinates)
            try:
                parent_x = self.root.winfo_rootx()
                parent_y = self.root.winfo_rooty()
                parent_width = self.root.winfo_width() or self.root.winfo_reqwidth()
                parent_height = self.root.winfo_height() or self.root.winfo_reqheight()
            except Exception:
                parent_x = 0
                parent_y = 0
                parent_width = self.root.winfo_screenwidth()
                parent_height = self.root.winfo_screenheight()

            x = parent_x + max(0, (parent_width - dlg_width) // 2)
            y = parent_y + max(0, (parent_height - dlg_height) // 2)
            # Clamp to screen bounds
            screen_w = self.root.winfo_screenwidth()
            screen_h = self.root.winfo_screenheight()
            x = max(0, min(x, screen_w - dlg_width))
            y = max(0, min(y, screen_h - dlg_height))
            
            # Apply geometry and make non-resizable
            dlg.geometry(f'{dlg_width}x{dlg_height}+{x}+{y}')
            dlg.resizable(False, False)
            
            # Now grab focus
            dlg.grab_set()

            # Ensure dialog is on top
            dlg.lift()
            dlg.focus_force()
        except Exception as e:
            print(f"Error showing results dialog: {e}")


# ===== SIMPLE TEST/DEMO =====
if __name__ == '__main__':
    print("Starting GUI test...")
    
    gui = TestMonitorGUI()
    gui.start()
    
    # Simulate test execution
    gui.update_status(run_current=0, run_total=10, power_on=True)
    gui.log_event('info', 'Test started')
    
    time.sleep(1)
    
    for i in range(1, 11):
        # Update run
        gui.update_status(run_current=i, run_total=10)
        gui.log_event('info', f'Starting run {i}/10')
        
        # Update scenario
        scenarios = ['normal', 'processor_fail', 'transmitter_fail']
        scenario = scenarios[i % 3]
        gui.update_scenario(
            name=scenario.replace('_', ' ').title(),
            description=f"Test scenario {scenario}",
            expected_failures=['Field A', 'Field B'] if scenario != 'normal' else [],
            expected_passes=['All other fields'],
            notes='This is a test scenario'
        )
        
        # Simulate PBIT execution
        for t in range(15):
            gui.update_status(pbit_time=t + 1.0)
            time.sleep(0.2)
        
        # Update statistics
        gui.update_statistics(
            b6_total=12, b6_pass=10, b6_fail=1, b6_known=1,
            b8_checked=0 if scenario == 'normal' else 50,
            b8_pass=0 if scenario == 'normal' else 48,
            b8_fail=0 if scenario == 'normal' else 2,
            serial_total=i * 5, serial_errors=i, serial_fatal=0, serial_recycles=0
        )
        
        gui.log_event('success' if scenario == 'normal' else 'warning', 
                     f'Run {i} completed')
        
        time.sleep(1)
    
    gui.log_event('success', 'All tests completed!')
    
    # Keep GUI open
    input("Press Enter to close GUI...")
    gui.stop()
