"""Enhanced GUI Application for Real-time Temperature Data Logger with Embedded Plots.

Provides a professional tkinter interface with embedded matplotlib plots for live
temperature visualization.

Features:
- Real-time dual/single sensor temperature monitoring
- Embedded live temperature and difference plots
- Data logging to CSV with configurable sampling rates
- Pre-flight sensor detection and validation
- Dark / Light theme toggle
- Information panel with read-only logging output
- Copy/Clear buttons for log management

Known Refinements/TODOs:
- Info panel currently displays all text in white (monochrome)
  - Colored text support explored with tkinterweb/HtmlFrame but had rendering issues
  - Future: Re-implement color coding (green for success, red for errors, orange for warnings)
- Auto-detect port selection uses filtered USB port list (excludes Bluetooth devices)
- Sensor re-detection monitoring loop removed to prevent parsing interference
- Data validation includes range checking (-50 to +150°C) and sanity checks
- Buffer auto-reset on consecutive read errors (5 threshold) with auto-recovery

(c) 2025-26 Prof. Flavio ABREU ARAUJO. All rights reserved.
"""

from __future__ import annotations

import contextlib
import re
import threading
import time
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

import matplotlib.animation as animation  # type: ignore
import matplotlib.gridspec as gridspec  # type: ignore
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg  # type: ignore
from matplotlib.figure import Figure  # type: ignore

from . import __version__
from .connection_panel import ConnectionPanel, scan_serial_ports
from .pc_realtime_logger import TemperatureLogger
from .scpi_client import format_response


# ===================================================================
# Helper Functions
# ===================================================================
def strip_ansi_codes(text: str) -> str:
    """Remove ANSI color codes from text for GUI display."""
    ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
    return ansi_escape.sub("", text)


# ===================================================================
# Theme / Colours
# ===================================================================
THEMES = {
    "dark": {
        "bg": "#2b2b2b",
        "fg": "#ffffff",
        "panel_bg": "#333333",
        "field_bg": "#3c3c3c",
        "console_bg": "#1e1e1e",
        "plot_bg": "#1e1e1e",
        "accent": "#0078d4",
        "success": "#107c10",
        "error": "#d83b01",
        "muted": "#888888",
        "grid": "#555555",
        "spine": "#555555",
        "btn_bg": "#0078d4",
        "btn_active": "#005a9e",
        "disabled_bg": "#4a4a4a",
        "green_btn": "#556B2F",
        "green_active": "#6B8E23",
        "red_btn": "#8B0000",
        "red_active": "#DC143C",
        "legend_face": "#333333",
        "legend_edge": "#555555",
    },
    "light": {
        "bg": "#f0f0f0",
        "fg": "#1e1e1e",
        "panel_bg": "#e8e8e8",
        "field_bg": "#ffffff",
        "console_bg": "#ffffff",
        "plot_bg": "#fafafa",
        "accent": "#0063b1",
        "success": "#0b7a0b",
        "error": "#c50f1f",
        "muted": "#666666",
        "grid": "#cccccc",
        "spine": "#aaaaaa",
        "btn_bg": "#0063b1",
        "btn_active": "#004e8c",
        "disabled_bg": "#b0b0b0",
        "green_btn": "#6B8E23",
        "green_active": "#556B2F",
        "red_btn": "#DC143C",
        "red_active": "#8B0000",
        "legend_face": "#e8e8e8",
        "legend_edge": "#aaaaaa",
    },
}

# Active theme (default dark)
_current_theme = "dark"


def _t() -> dict:
    """Return current theme dict."""
    return THEMES[_current_theme]


class TemperatureLoggerGUIEnhanced:
    """Enhanced GUI Application with Live Plots."""

    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.root.title("Real-time Temperature Logger (© 2026 Prof. Flavio ABREU ARAUJO)")
        self.root.geometry("1200x800")
        self.root.resizable(True, True)

        self.logger: TemperatureLogger | None = None
        self.logger_thread: threading.Thread | None = None
        self.is_running = False
        self.plot_interval_ms = 200  # Default plot update interval in milliseconds
        self.text_scale = 1.4  # Graph text scaling factor (1.4 = default)

        self.root.configure(bg=_t()["bg"])

        # Configure style
        self._setup_styles()

        # Create GUI layout
        self._create_widgets()

    def _seconds_to_mmss(self, seconds: float) -> str:
        """Convert seconds to mm:ss format."""
        minutes = int(seconds) // 60
        secs = int(seconds) % 60
        return f"{minutes:02d}:{secs:02d}"

    def _get_elapsed_times(self) -> list[str]:
        """Get list of elapsed time strings from start of logging."""
        if not self.logger or not self.logger.timestamps:
            return []

        start_timestamp = self.logger.timestamps[0]
        elapsed_times = []
        for ts in self.logger.timestamps:
            elapsed = ts - start_timestamp
            elapsed_times.append(self._seconds_to_mmss(elapsed))
        return elapsed_times

    def _timestamp_to_hhmmss(self, timestamp: float) -> str:
        """Convert Unix timestamp to HH:MM:SS format."""
        import time

        return time.strftime("%H:%M:%S", time.localtime(timestamp))

    def _get_clock_times(self) -> list[str]:
        """Get list of clock time strings based on actual elapsed time from logging start.

        Uses the elapsed time values stored in logger.timestamps, which are already
        elapsed seconds from when logging started. Converts to compact format:
        - 0:05 for 5 seconds
        - 1:23 for 1 minute 23 seconds
        - 45:30 for 45 minutes 30 seconds

        Time continuously increments as new data arrives, even when data window wraps.
        """
        if not self.logger or not self.logger.timestamps:
            return []

        clock_times = []
        for elapsed_seconds in self.logger.timestamps:
            # elapsed_seconds is already the elapsed time from logging start
            minutes = int(elapsed_seconds) // 60
            seconds = int(elapsed_seconds) % 60
            time_str = f"{minutes}:{seconds:02d}"
            clock_times.append(time_str)

        return clock_times

    def _setup_styles(self) -> None:
        """Configure ttk styles from current theme."""
        t = _t()
        style = ttk.Style()
        style.theme_use("clam")
        style.configure(".", background=t["bg"], foreground=t["fg"], fieldbackground=t["field_bg"])
        style.configure("TFrame", background=t["bg"])
        style.configure("TLabel", background=t["bg"], foreground=t["fg"])
        style.configure("TLabelframe", background=t["bg"], foreground=t["fg"])
        style.configure("TLabelframe.Label", background=t["bg"], foreground=t["accent"])
        style.configure("TEntry", fieldbackground=t["field_bg"], foreground=t["fg"])
        style.configure("TCombobox", fieldbackground=t["field_bg"], foreground=t["fg"])
        style.configure("TSpinbox", fieldbackground=t["field_bg"], foreground=t["fg"])
        style.configure("TButton", background=t["btn_bg"], foreground="#ffffff", padding=4)
        style.map(
            "TButton", background=[("active", t["btn_active"]), ("disabled", t["disabled_bg"])]
        )
        style.configure(
            "Control.TLabel", background=t["bg"], foreground=t["fg"], font=("Arial", 10)
        )
        style.configure("Green.TButton", background=t["green_btn"], foreground="#ffffff")
        style.map(
            "Green.TButton",
            background=[("active", t["green_active"]), ("disabled", t["disabled_bg"])],
        )
        style.configure("Red.TButton", background=t["red_btn"], foreground="#ffffff")
        style.map(
            "Red.TButton", background=[("active", t["red_active"]), ("disabled", t["disabled_bg"])]
        )

    def _create_widgets(self) -> None:
        """Create GUI widgets."""
        # ========== BOTTOM STATUS BAR (pack first so it reserves space) ==========
        status_frame = ttk.Frame(self.root, relief=tk.SUNKEN, borderwidth=1)
        status_frame.pack(fill=tk.X, side=tk.BOTTOM)

        self.status_label = ttk.Label(
            status_frame,
            text="Ready",
            foreground=_t()["success"],
            font=("Arial", 9),
        )
        self.status_label.pack(side=tk.LEFT, padx=10, pady=3)

        self.theme_btn = ttk.Button(
            status_frame, text="☀ Light", command=self._toggle_theme, width=9
        )
        self.theme_btn.pack(side=tk.RIGHT, padx=(0, 10), pady=3)

        self.point_count_label = ttk.Label(
            status_frame,
            text=f"Points: 0  |  Sensors: -  |  Mode: -  |  v{__version__}",
            font=("Arial", 9),
        )
        self.point_count_label.pack(side=tk.RIGHT, padx=10, pady=3)

        # Top frame for main content (config/controls left, plots right)
        content_frame = ttk.Frame(self.root)
        content_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

        # ========== LEFT PANEL: Configuration and Controls ==========
        left_panel = ttk.Frame(content_frame)
        left_panel.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 5))

        # Header
        # header = ttk.Label(
        #     left_panel,
        #     text="Temperature Logger",
        #     font=("Arial", 14, "bold"),
        # )
        # header.pack(pady=(0, 15))

        # Connection Panel
        self.conn_panel = ConnectionPanel(
            left_panel,
            default="USB",
            label="Connection:",
            show_buttons=False,
            on_log=self._add_info,
        )
        self.conn_panel.pack(fill=tk.X, pady=(0, 6))

        # Configuration Panel
        config_frame = ttk.LabelFrame(left_panel, text="Configuration", padding=10)
        config_frame.pack(fill=tk.X, pady=(0, 10))

        # Output file (spans 3 rows)
        ttk.Label(config_frame, text="Output File:", style="Control.TLabel").grid(
            row=0, column=0, sticky=tk.NW, pady=8
        )
        self.output_var = tk.StringVar(value="tmp117_log.csv")
        self.output_entry = tk.Text(
            config_frame,
            height=3,
            width=26,
            wrap=tk.WORD,
            relief=tk.SUNKEN,
            bd=1,
            bg=_t()["field_bg"],
            fg=_t()["fg"],
            font=("Arial", 9),
        )
        self.output_entry.grid(row=0, column=1, rowspan=3, sticky=tk.NSEW, padx=5)
        # Insert default text
        self.output_entry.insert("1.0", "tmp117_log.csv")
        self.browse_btn = ttk.Button(config_frame, text="...", command=self._browse_file, width=3)
        self.browse_btn.grid(row=0, column=2, padx=2)

        # Max data points
        ttk.Label(config_frame, text="Max Points:", style="Control.TLabel").grid(
            row=3, column=0, sticky=tk.W, pady=8
        )
        self.max_points_var = tk.StringVar(value="1501")
        self.max_points_spinbox = ttk.Spinbox(
            config_frame,
            from_=11,
            to=5001,
            textvariable=self.max_points_var,
            width=10,
        )
        self.max_points_spinbox.grid(row=3, column=1, sticky=tk.W, padx=5)

        # AVG level for SENS:SYNC
        ttk.Label(config_frame, text="AVG Level:", style="Control.TLabel").grid(
            row=4, column=0, sticky=tk.W, pady=8
        )
        self.avg_level_var = tk.StringVar(value="8 (Default, ~200ms/pt)")
        self.avg_level_combo = ttk.Combobox(
            config_frame,
            textvariable=self.avg_level_var,
            values=[
                "1 (Fast, ~120ms/pt)",
                "8 (Default, ~200ms/pt)",
                "32 (Precise, ~575ms/pt)",
                "64 (Very Precise, ~1.065s/pt)",
            ],
            state="readonly",
            width=22,
        )
        self.avg_level_combo.grid(row=4, column=1, sticky=tk.W, padx=5)

        # Graph scale factor
        ttk.Label(config_frame, text="Graph Scale:", style="Control.TLabel").grid(
            row=5, column=0, sticky=tk.W, pady=8
        )
        self.scale_var = tk.StringVar(value="1.4")
        self.scale_spinbox = ttk.Spinbox(
            config_frame,
            from_=0.8,
            to=2.0,
            increment=0.1,
            textvariable=self.scale_var,
            width=10,
            command=self._on_scale_changed,
        )
        self.scale_spinbox.grid(row=5, column=1, sticky=tk.W, padx=5)

        # Control Panel
        control_frame = ttk.LabelFrame(left_panel, text="Control", padding=10)
        control_frame.pack(fill=tk.X, pady=(0, 10))

        btn_row = ttk.Frame(control_frame)
        btn_row.pack(fill=tk.X, pady=(0, 4))

        self.start_btn = ttk.Button(
            btn_row,
            text="▶ START",
            command=self._start_logging,
            style="Green.TButton",
        )
        self.start_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 4))

        self.stop_btn = ttk.Button(
            btn_row,
            text="⏹ STOP",
            command=self._stop_logging,
            style="Red.TButton",
            state=tk.DISABLED,
        )
        self.stop_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(4, 0))

        # SCPI Command Panel
        scpi_frame = ttk.LabelFrame(left_panel, text="SCPI Command", padding=10)
        scpi_frame.pack(fill=tk.X, pady=(0, 10))

        btn_row = ttk.Frame(scpi_frame)
        btn_row.pack(fill=tk.X, pady=(0, 0))

        # Command entry with history
        self.scpi_entry = ttk.Entry(btn_row, width=30)
        self.scpi_entry.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 4))
        self.scpi_entry.bind("<Return>", self._send_scpi_command)
        self.scpi_entry.bind("<Up>", self._scpi_history_prev)
        self.scpi_entry.bind("<Down>", self._scpi_history_next)

        # Send button
        self.scpi_send_btn = ttk.Button(
            btn_row,
            text="📤 Send",
            command=self._send_scpi_command,
        )
        self.scpi_send_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(4, 0))

        # SCPI command history
        self._scpi_history: list[str] = []
        self._scpi_history_index = 0

        # Common commands hint
        # hint_label = ttk.Label(
        #     scpi_frame,
        #     text="e.g. *IDN?, :PID:STAT?, :PID:SETP 25",
        #     font=("Arial", 8),
        #     foreground=_t()["muted"],
        # )
        # hint_label.pack(pady=(5, 0))

        # Info Panel
        info_frame = ttk.LabelFrame(left_panel, text="Information", padding=10)
        info_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))

        # Text widget - simple and reliable white-on-black display
        self.info_text = tk.Text(
            info_frame,
            height=12,
            width=35,
            bg=_t()["console_bg"],
            fg=_t()["fg"],
            font=("Courier", 9),
            state=tk.DISABLED,  # Read-only
        )
        self.info_text.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
        self.html_mode = False

        # Initialize with starting messages
        self._add_info("Temperature Logger Ready\n")
        self._add_info("Click START to begin logging\n")
        # self._add_info("=" * 35 + "\n")

        # Button frame for actions
        button_frame = ttk.Frame(info_frame)
        button_frame.pack(fill=tk.X, pady=(5, 0))

        self.copy_btn = ttk.Button(button_frame, text="📋 Copy", command=self._copy_info, width=12)
        self.copy_btn.pack(side=tk.LEFT, padx=2)

        self.clear_btn = ttk.Button(
            button_frame, text="🗑 Clear", command=self._clear_info, width=12
        )
        self.clear_btn.pack(side=tk.LEFT, padx=2)

        # ========== RIGHT PANEL: Plots ==========
        right_panel = ttk.Frame(content_frame)
        right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

        # Create matplotlib figure with 2 subplots
        t = _t()
        self.fig = Figure(figsize=(8, 7), dpi=100, facecolor=t["bg"])
        gs = gridspec.GridSpec(2, 1, figure=self.fig, hspace=0.05)
        self.fig.subplots_adjust(left=0.14, right=0.98, top=0.98, bottom=0.08)

        # Top subplot: Both temperatures
        self.ax_temps = self.fig.add_subplot(gs[0])
        self.ax_temps.set_facecolor(t["plot_bg"])
        self.ax_temps.set_ylabel(
            "Temperature (°C)", fontsize=int(10 * self.text_scale), color=t["fg"]
        )
        self.ax_temps.tick_params(colors=t["fg"], labelsize=int(9 * self.text_scale))
        self.ax_temps.grid(True, alpha=0.3, color=t["grid"])
        (self.line_temp1,) = self.ax_temps.plot([], [], "b-", label="Sensor 1 ($T_1$)", linewidth=2)
        (self.line_temp2,) = self.ax_temps.plot([], [], "r-", label="Sensor 2 ($T_2$)", linewidth=2)
        self.ax_temps.legend(
            loc="best",
            ncol=2,
            fontsize=int(9 * self.text_scale),
            facecolor=t["legend_face"],
            edgecolor=t["legend_edge"],
            labelcolor=t["fg"],
        )
        # Hide x-axis labels and ticks for upper graph
        self.ax_temps.set_xticklabels([])

        # Bottom subplot: Difference
        self.ax_diff = self.fig.add_subplot(gs[1])
        self.ax_diff.set_facecolor(t["plot_bg"])
        self.ax_diff.set_ylabel(
            r"$\Delta T = T_1 - T_2$ (°C)", fontsize=int(10 * self.text_scale), color=t["fg"]
        )
        self.ax_diff.set_xlabel(
            "Elapsed Time (m:ss)", fontsize=int(10 * self.text_scale), color=t["fg"]
        )
        self.ax_diff.tick_params(colors=t["fg"], labelsize=int(9 * self.text_scale))
        self.ax_diff.grid(True, alpha=0.3, color=t["grid"])
        (self.line_diff,) = self.ax_diff.plot([], [], "g-", linewidth=2)

        for spine in (*self.ax_temps.spines.values(), *self.ax_diff.spines.values()):
            spine.set_color(t["spine"])

        # Embed matplotlib in tkinter
        self.canvas = FigureCanvasTkAgg(self.fig, master=right_panel)
        self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)

        # Connect figure close event to stop logging
        self.fig.canvas.mpl_connect("close_event", self._on_figure_close)

        # Initialize plot interval from GUI (will be updated when logging starts)
        self.plot_interval_ms = 200

        # Start animation
        self.ani = animation.FuncAnimation(
            self.fig,
            self._update_plots,
            interval=self.plot_interval_ms,
            blit=False,
            cache_frame_data=False,
            repeat=True,  # Explicitly set repeat to continue animation loop
        )
        # Draw initial frame to prevent "Animation was deleted without rendering" warning
        self.canvas.draw_idle()

    def _browse_file(self) -> None:
        """Open file browser for output file selection."""
        current_text = self.output_entry.get("1.0", tk.END).strip()
        filename = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
            initialfile=current_text,
        )
        if filename:
            self.output_entry.delete("1.0", tk.END)
            self.output_entry.insert("1.0", filename)

    def _add_info(self, text: str, tag: str = "default") -> None:
        """Add text to info display (thread-safe).

        Args:
            text: Text to add
            tag: Ignored - all text displays in white
        """
        print(text, end="")  # Also print to console for debugging

        def _do_add():
            try:
                # Check if widget still exists (may have been destroyed)
                if not self.info_text.winfo_exists():
                    return

                # Temporarily enable editing to insert text
                self.info_text.config(state=tk.NORMAL)
                # Insert text at end
                self.info_text.insert(tk.END, text)
                self.info_text.see(tk.END)  # Auto-scroll to bottom
                # Disable editing again
                self.info_text.config(state=tk.DISABLED)
            except tk.TclError:
                # Widget was destroyed, silently ignore
                pass

        # Schedule GUI update on main thread (thread-safe)
        with contextlib.suppress(Exception):
            self.root.after(0, _do_add)

    def _copy_info(self) -> None:
        """Copy all info text to clipboard."""
        try:
            content = self.info_text.get("1.0", tk.END)
            self.root.clipboard_clear()
            self.root.clipboard_append(content)
            # self._add_info("✓ Info text copied to clipboard!\n")
        except Exception as e:
            self._add_info(f"❌ Failed to copy text: {e}\n")

    def _clear_info(self) -> None:
        """Clear all info text."""
        self.info_text.config(state=tk.NORMAL)
        self.info_text.delete("1.0", tk.END)
        self.info_text.config(state=tk.DISABLED)
        # self._add_info("✓ Information panel cleared\n")

    def _reset_plots(self) -> None:
        """Reset plots and clear all plot data for a fresh start."""
        # Clear line data
        self.line_temp1.set_data([], [])
        self.line_temp2.set_data([], [])
        self.line_diff.set_data([], [])

        # Reset axes limits
        self.ax_temps.set_xlim(0, 1)
        self.ax_temps.set_ylim(0, 1)
        self.ax_diff.set_xlim(0, 1)
        self.ax_diff.set_ylim(-1, 1)

        # Reset status bar
        self.point_count_label.config(
            text=f"Points: 0  |  Sensors: -  |  Mode: -  |  v{__version__}"
        )

        # Restart animation if it was stopped
        try:
            if hasattr(self, "ani") and self.ani and self.ani.event_source:
                self.ani.event_source.start()
        except Exception:
            pass

        # Redraw canvas
        self.canvas.draw_idle()

    def _send_scpi_command(self, event=None) -> None:
        """Send SCPI command through the logger's serial connection."""
        command = self.scpi_entry.get().strip()
        if not command:
            return

        # Add to history
        if not self._scpi_history or self._scpi_history[-1] != command:
            self._scpi_history.append(command)
        self._scpi_history_index = len(self._scpi_history)

        # Clear entry
        self.scpi_entry.delete(0, tk.END)

        # Check if logger is connected
        if not self.logger or not hasattr(self.logger, "instr") or not self.logger.instr:
            self._add_info(f"→ {command}\n")
            self._add_info("❌ Not connected. Start logging first.\n")
            return

        # Send command in a separate thread to avoid blocking GUI
        def send_cmd():
            try:
                self._add_info(f"→ {command}\n")

                # Pause data collection to prevent interference
                self.logger.pause_data_collection = True
                time.sleep(
                    0.6
                )  # Wait long enough for in-flight command to complete (AVG8 = ~200ms + buffer + margin)

                # Use the logger's instrument connection with lock to prevent
                # interference with the data collection thread
                with self.logger.data_lock:
                    instr = self.logger.instr
                    # SCPISerial uses 'serial' attribute for the pyserial object
                    ser = getattr(instr, "serial", None)

                    if ser:
                        # Aggressively drain all pending data from input buffer
                        # to prevent reading stale responses from data collection
                        time.sleep(0.1)  # Brief settle time
                        while ser.in_waiting > 0:
                            ser.read(ser.in_waiting)
                            time.sleep(0.05)  # Wait for any remaining data
                        ser.reset_input_buffer()
                        time.sleep(0.05)  # Final settle time before sending command

                    # Determine if this is a query
                    is_query = command.strip().endswith("?")

                    if is_query:
                        # Direct write + read for queries with proper blocking timeout
                        if ser:
                            # Save old timeout and set longer one for manual commands
                            old_timeout = ser.timeout
                            ser.timeout = 1.0  # 1 second blocking timeout

                            try:
                                # Write command directly
                                ser.write((command + "\n").encode("ascii"))
                                ser.flush()

                                # Small delay to ensure command is processed before reading
                                time.sleep(0.05)

                                # Read response (blocks until newline or timeout)
                                response = ser.readline().decode("ascii", errors="ignore").strip()

                                if response:
                                    # Pretty-format the response
                                    formatted = format_response(command, response, pretty=True)
                                    # Strip ANSI codes for GUI display
                                    formatted_plain = strip_ansi_codes(formatted)
                                    self._add_info(f"← {formatted_plain}\n")
                                else:
                                    self._add_info("← (no response)\n")
                            finally:
                                # Restore original timeout
                                ser.timeout = old_timeout
                        else:
                            # Fallback to query method
                            response = instr.query(command)
                            if response:
                                # Pretty-format the response
                                formatted = format_response(command, response, pretty=True)
                                # Strip ANSI codes for GUI display
                                formatted_plain = strip_ansi_codes(formatted)
                                self._add_info(f"← {formatted_plain}\n")
                            else:
                                self._add_info("← (no response)\n")
                    else:
                        # For non-query commands, we need to handle responses specially
                        # Mode switch commands return errors or log messages
                        if command.upper().startswith(":SYST:MODE"):
                            if ser:
                                # Mode commands need error checking - use query-like handling
                                old_timeout = ser.timeout
                                ser.timeout = 1.5

                                try:
                                    ser.write((command + "\n").encode("ascii"))
                                    ser.flush()
                                    time.sleep(0.05)

                                    # Read response (could be error or log messages)
                                    response = (
                                        ser.readline().decode("ascii", errors="ignore").strip()
                                    )

                                    if response:
                                        formatted = format_response(command, response, pretty=True)
                                        formatted_plain = strip_ansi_codes(formatted)
                                        self._add_info(f"← {formatted_plain}\n")
                                    else:
                                        self._add_info("← ✓ Command sent\n")
                                finally:
                                    ser.timeout = old_timeout
                            else:
                                instr.write(command)
                                self._add_info("← ✓ Command sent\n")
                        else:
                            # Other non-query commands
                            instr.write(command)
                            self._add_info("← ✓ OK\n")

                # Resume data collection
                self.logger.pause_data_collection = False
                # Brief delay to allow data collection thread to re-synchronize
                time.sleep(0.15)

            except Exception as e:
                # Make sure to resume data collection even on error
                if self.logger:
                    self.logger.pause_data_collection = False
                    time.sleep(0.15)  # Brief delay for re-synchronization
                self._add_info(f"❌ Error: {e}\n")

        # Run in thread
        threading.Thread(target=send_cmd, daemon=True).start()

    def _scpi_history_prev(self, event=None) -> None:
        """Navigate to previous command in history."""
        if self._scpi_history and self._scpi_history_index > 0:
            self._scpi_history_index -= 1
            self.scpi_entry.delete(0, tk.END)
            self.scpi_entry.insert(0, self._scpi_history[self._scpi_history_index])

    def _scpi_history_next(self, event=None) -> None:
        """Navigate to next command in history."""
        if self._scpi_history and self._scpi_history_index < len(self._scpi_history) - 1:
            self._scpi_history_index += 1
            self.scpi_entry.delete(0, tk.END)
            self.scpi_entry.insert(0, self._scpi_history[self._scpi_history_index])
        elif self._scpi_history_index >= len(self._scpi_history) - 1:
            self._scpi_history_index = len(self._scpi_history)
            self.scpi_entry.delete(0, tk.END)

    def _update_status(self, text: str, color: str = None) -> None:  # type: ignore
        """Update status label (thread-safe)."""

        def _do_update():
            try:
                # Check if widget still exists (may have been destroyed)
                if not self.status_label.winfo_exists():
                    return

                actual_color = _t()["fg"] if color is None else color
                self.status_label.config(text=text, foreground=actual_color)
            except tk.TclError:
                # Widget was destroyed, silently ignore
                pass

        # Schedule GUI update on main thread (thread-safe)
        with contextlib.suppress(Exception):
            self.root.after(0, _do_update)

    def _on_scale_changed(self) -> None:
        """Handle graph scale factor change."""
        try:
            new_scale = float(self.scale_var.get())
            if 0.8 <= new_scale <= 2.0:
                self.text_scale = new_scale
                # Update all plot labels and tick parameters immediately
                self.ax_temps.set_ylabel("Temperature (°C)", fontsize=int(10 * self.text_scale))
                self.ax_temps.tick_params(labelsize=int(9 * self.text_scale))
                t = _t()
                self.ax_temps.legend(
                    loc="best",
                    ncol=2,
                    fontsize=int(9 * self.text_scale),
                    facecolor=t["legend_face"],
                    edgecolor=t["legend_edge"],
                    labelcolor=t["fg"],
                )
                self.ax_diff.set_ylabel(
                    r"$\Delta T = T_1 - T_2$ (°C)", fontsize=int(10 * self.text_scale)
                )
                self.ax_diff.set_xlabel("Elapsed Time (m:ss)", fontsize=int(10 * self.text_scale))
                self.ax_diff.tick_params(labelsize=int(9 * self.text_scale))
                # Adjust margins to ensure all labels are visible
                self.fig.subplots_adjust(left=0.14, right=0.98)
                self.canvas.draw_idle()
        except (ValueError, AttributeError):
            pass  # Invalid input or scale_spinbox not yet created

    def _set_button_states_idle(self) -> None:
        """Set button states for idle (START enabled, STOP disabled)."""
        self._safe_widget_config(self.start_btn, state=tk.NORMAL)
        self._safe_widget_config(self.stop_btn, state=tk.DISABLED)

    def _set_button_states_running(self) -> None:
        """Set button states for running (START disabled, STOP enabled)."""
        self._safe_widget_config(self.start_btn, state=tk.DISABLED)
        self._safe_widget_config(self.stop_btn, state=tk.NORMAL)

    def _safe_widget_config(self, widget: tk.Widget, **kwargs) -> None:
        """Safely configure a widget, checking if it still exists.

        Args:
            widget: The widget to configure
            **kwargs: Configuration options to apply
        """
        try:
            if widget.winfo_exists():
                widget.config(**kwargs)
        except tk.TclError:
            # Widget was destroyed, silently ignore
            pass

    def _detect_and_verify_sensors(self, port: str | None) -> tuple[bool, str]:
        """Detect and verify sensors are working.

        Returns:
            Tuple of (success: bool, message: str)
        """
        # First check if serial is available
        try:
            import serial  # type: ignore
            import serial.tools.list_ports  # type: ignore
        except ImportError as e:
            return (
                False,
                f"❌ pyserial not installed. Please install with: pip install pyserial (Error: {e})",
            )

        try:
            # Determine port
            if port is None:
                # Auto-detect: use the filtered port list (excludes Bluetooth, etc.)
                filtered_ports = scan_serial_ports()
                # Remove "Auto-detect" from the list
                available_ports = [p for p in filtered_ports if p != "Auto-detect"]
                if not available_ports:
                    return False, "❌ No USB serial ports found. Please connect the device."
                port = available_ports[0]
                self._add_info(f"Auto-detected port: {port}\n")
            else:
                # Verify port exists
                ports = [p.device for p in serial.tools.list_ports.comports()]
                if port not in ports:
                    return (
                        False,
                        f"❌ Port {port} not found. Available ports: {', '.join(ports) if ports else 'None'}",
                    )

            self._add_info(f"✓ Port {port} is available\n")

            # Skip trying to open serial.Serial() directly due to environment issues
            # The actual connection will be tested when logging starts
            self._add_info(
                "✓ Pre-flight checks passed. Sensor verification will occur during logging.\n"
            )
            return True, "✓ Ready to log."

        except Exception as e:
            return False, f"❌ Error during port detection: {e}"

    def _on_figure_close(self, event) -> None:
        """Handle matplotlib figure close event - stop logging."""
        # If root is already destroyed, skip any further work
        try:
            if not self.root.winfo_exists():
                return
        except tk.TclError:
            return

        if self.is_running:
            self._add_info("⏹ Matplotlib window closed. Stopping data collection...\n")
            self._stop_logging()

    def _start_logging(self) -> None:
        """Start data logging in a separate thread."""
        if self.is_running:
            self._add_info("⚠️ Logging is already running\n")
            return

        # Get connection configuration from panel
        prefer_wifi, wifi_host, port = self.conn_panel.get_connection_params()
        if prefer_wifi and not wifi_host:
            self._add_info("❌ WiFi IP address required\n")
            self._update_status("Error", _t()["error"])
            return

        output_file = (
            self.output_entry.get("1.0", tk.END).strip()
            if self.output_entry.get("1.0", tk.END).strip()
            else None
        )

        try:
            max_points = int(self.max_points_var.get())
            # Extract AVG level from combobox value (e.g., "8 (Default)" -> 8)
            avg_level_str = self.avg_level_var.get().split()[0]
            avg_level = int(avg_level_str)
        except ValueError:
            self._add_info("❌ Invalid max points or AVG level\n")
            self._update_status("Error", _t()["error"])
            return

        # Validate output file
        if output_file:
            try:
                output_path = Path(output_file)
                if output_path.exists():
                    response = messagebox.askyesnocancel(
                        "File exists",
                        f"The file already exists:\n{output_path}\n\n"
                        "Yes: choose a new filename\n"
                        "No: overwrite and continue\n"
                        "Cancel: abort logging",
                    )
                    if response is None:
                        self._add_info("⚠️ Logging aborted (file already exists)\n")
                        self._update_status("Aborted", _t()["error"])
                        return
                    if response:
                        new_filename = filedialog.asksaveasfilename(
                            defaultextension=".csv",
                            filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
                            initialfile=output_path.name,
                            initialdir=str(output_path.parent),
                        )
                        if not new_filename:
                            self._add_info("⚠️ Logging aborted (no filename selected)\n")
                            self._update_status("Aborted", _t()["error"])
                            return
                        output_file = new_filename
                        self.output_entry.delete("1.0", tk.END)
                        self.output_entry.insert("1.0", output_file)
                        output_path = Path(output_file)

                output_path.parent.mkdir(parents=True, exist_ok=True)
            except Exception as e:
                self._add_info(f"❌ Invalid output file path: {e}\n")
                self._update_status("Error", _t()["error"])
                return

        # Pre-flight checks
        self._update_status("Pre-flight checks...", _t()["accent"])
        self._add_info("=" * 28 + "\n", "separator")
        self._add_info("Starting pre-flight checks...\n")

        # Connection info
        if wifi_host:
            self._add_info(f"Connection: WiFi ({wifi_host})\n")
        else:
            self._add_info("Connection: USB\n")

        # Reset plots for fresh start
        self._reset_plots()

        # Create logger
        try:
            self.logger = TemperatureLogger(
                port=port,
                wifi_host=wifi_host,
                prefer_wifi=prefer_wifi,
                output_file=output_file,
                show_plot=False,  # GUI handles plotting
                max_points=max_points,
                avg_level=avg_level,
            )
            self.is_running = True

            # Update UI - Disable configuration widgets (safely)
            self._update_status("Connecting to device...", _t()["accent"])
            self.conn_panel.set_enabled(False)
            self._safe_widget_config(self.output_entry, state="disabled")
            self._safe_widget_config(self.browse_btn, state=tk.DISABLED)
            self._safe_widget_config(self.max_points_spinbox, state="disabled")
            self._safe_widget_config(self.avg_level_combo, state="disabled")
            self._set_button_states_running()

            # Run logger in separate thread
            self.logger_thread = threading.Thread(target=self._run_logger, daemon=True)
            self.logger_thread.start()

        except Exception as e:
            error_msg = str(e)
            self._add_info(f"❌ Failed to start logger: {error_msg}\n")
            self._update_status("Error", _t()["error"])
            self.is_running = False
            # Re-enable configuration widgets (safely)
            self.conn_panel.set_enabled(True)
            self._safe_widget_config(self.output_entry, state="normal")
            self._safe_widget_config(self.browse_btn, state=tk.NORMAL)
            self._safe_widget_config(self.max_points_spinbox, state="normal")
            self._safe_widget_config(self.avg_level_combo, state="readonly")
            self._set_button_states_idle()

    def _run_logger(self) -> None:
        """Run the logger (called in separate thread)."""
        if not self.logger:
            return

        try:
            # Connect first to initialize instr attribute (with timeout)
            self._add_info("Connecting to device (timeout: 2s)...\n")

            # Use a wrapper to catch timeout and errors
            connect_error = [None]  # Use list to allow modification in nested function
            connect_done = [False]

            def connect_wrapper():
                try:
                    self.logger.connect()
                    connect_done[0] = True
                except Exception as e:
                    connect_error[0] = e
                    connect_done[0] = True

            connect_thread = threading.Thread(target=connect_wrapper, daemon=True)
            connect_thread.start()
            connect_thread.join(timeout=3)  # Wait max 3 seconds for connection

            # Check if thread is still running or if there was an error
            if connect_thread.is_alive() or not connect_done[0]:
                raise TimeoutError("❌ Connection timed out after 3 seconds")

            if connect_error[0]:
                # Error message from connect() is already formatted, pass it through
                raise RuntimeError(str(connect_error[0]))

            port_name = self.logger.instr.port_name
            self._update_status(f"Connected: {port_name}", _t()["success"])
            self._add_info(f"✓ Connected to: {port_name}\n")

            if self.logger.is_dual_sensor:
                self._add_info("✓ Dual sensor mode detected\n")
            else:
                self._add_info("✓ Single sensor mode detected\n")

            self._add_info("=" * 28 + "\n", "separator")
            self._add_info("Collecting data...\n")

            # Start data collection
            self.logger.open_csv_file()
            self.logger.running = True

            # Start the data collection thread
            self.logger.data_thread = threading.Thread(
                target=self.logger._data_collection_thread,
                daemon=True,
                name="DataCollectionThread",
            )
            self.logger.data_thread.start()

            # Keep thread alive while logging, but don't probe sensors (avoid parsing issues)
            # Sensor configuration is detected once at startup and doesn't change during normal operation
            while self.logger and self.logger.running:
                time.sleep(0.1)

            # After loop exits, data collection has stopped
            # Save point count before logger is cleared
            point_count = self.logger.point_count if self.logger else 0
            self._add_info("=" * 28 + "\n", "separator")
            self._add_info("✓ Data collection stopped\n")
            self._add_info(f"✓ Total points logged: {point_count}\n")

        except KeyboardInterrupt:
            self._add_info("=" * 28 + "\n", "separator")
            self._add_info("⏹ Logging stopped by user\n", "warning")
        except Exception as e:
            self._add_info("=" * 28 + "\n", "separator")
            error_msg = str(e).strip()
            # If error already has ❌ prefix, use it as-is; otherwise add prefix
            if error_msg.startswith("❌"):
                self._add_info(f"{error_msg}\n", "error")
            else:
                self._add_info(f"❌ {error_msg}\n", "error")
            self._update_status("Error during logging", _t()["error"])
        finally:
            self.is_running = False
            # Re-enable configuration widgets (safely)
            self.conn_panel.set_enabled(True)
            self._safe_widget_config(self.output_entry, state="normal")
            self._safe_widget_config(self.browse_btn, state=tk.NORMAL)
            self._set_button_states_idle()
            self._update_status("Ready", _t()["success"])

    def _update_plots(self, frame) -> None:
        """Update plots with current logger data."""
        if not self.logger or not self.logger.running:
            return

        # Update status bar
        mode = "Dual Sync" if self.logger.is_dual_sensor else "Single"
        self.point_count_label.config(
            text=f"Points: {self.logger.point_count}  |  Sensors: {self.logger.sensor_count}  |  Mode: {mode}  |  v{__version__}"
        )

        # Get data from logger
        if len(self.logger.timestamps) > 0:
            temps1 = list(self.logger.temperatures_1)
            temps2 = list(self.logger.temperatures_2)
            diffs = list(self.logger.diff_data)
            clock_times = self._get_clock_times()

            # Create numeric x-axis for plotting (0, 1, 2, ...)
            x_indices = list(range(len(clock_times)))

            # Update both temperatures on same graph
            if temps1:
                self.line_temp1.set_data(x_indices, temps1)
            if temps2:
                self.line_temp2.set_data(x_indices, temps2)

            # Set xlim and ylim for temperature graph
            if temps1 or temps2:
                max_len = len(x_indices)
                self.ax_temps.set_xlim(0, max_len)

                # Calculate ylim from both datasets, filtering out None values
                all_temps = [t for t in (temps1 + temps2) if t is not None]
                if all_temps:
                    y_min, y_max = min(all_temps), max(all_temps)
                    margin = (y_max - y_min) * 0.1 if y_max > y_min else 1
                    self.ax_temps.set_ylim(y_min - margin, y_max + margin)

            # Update difference plot
            if diffs:
                # Filter out None values from diffs
                diffs_clean = [d for d in diffs if d is not None]
                if diffs_clean:
                    self.line_diff.set_data(x_indices, diffs)
                    self.ax_diff.set_xlim(0, len(x_indices))

                    # Set x-axis tick labels to actual clock time
                    if len(x_indices) > 0:
                        step = max(1, len(x_indices) // 10)
                        self.ax_diff.set_xticks(x_indices[::step])
                        self.ax_diff.set_xticklabels(clock_times[::step])

                    y_min, y_max = min(diffs_clean), max(diffs_clean)
                    margin = (y_max - y_min) * 0.1 if y_max > y_min else 1
                    self.ax_diff.set_ylim(y_min - margin, y_max + margin)

        # Ensure y-axis labels remain visible as tick labels grow (increased left margin for larger numbers)
        self.fig.subplots_adjust(left=0.14, right=0.98)

        # Request canvas redraw to update all changes
        self.canvas.draw_idle()

    # ================================================================
    #  Theme toggle
    # ================================================================
    def _toggle_theme(self) -> None:
        """Switch between dark and light theme."""
        global _current_theme
        _current_theme = "light" if _current_theme == "dark" else "dark"
        t = _t()

        # Update button label
        self.theme_btn.config(text="🌙 Dark" if _current_theme == "light" else "☀ Light")

        # Re-apply ttk styles
        self._setup_styles()

        # Update root background
        self.root.configure(bg=t["bg"])

        # Update tk.Text widgets (not managed by ttk styles)
        self.info_text.config(bg=t["console_bg"], fg=t["fg"])
        self.output_entry.config(bg=t["field_bg"], fg=t["fg"])

        # Update matplotlib plots
        self.fig.set_facecolor(t["bg"])
        for ax in (self.ax_temps, self.ax_diff):
            ax.set_facecolor(t["plot_bg"])
            ax.tick_params(colors=t["fg"], labelsize=int(9 * self.text_scale))
            ax.yaxis.label.set_color(t["fg"])
            ax.xaxis.label.set_color(t["fg"])
            ax.grid(True, alpha=0.3, color=t["grid"])
            for spine in ax.spines.values():
                spine.set_color(t["spine"])
            leg = ax.get_legend()
            if leg:
                leg.get_frame().set_facecolor(t["legend_face"])
                leg.get_frame().set_edgecolor(t["legend_edge"])
                for txt in leg.get_texts():
                    txt.set_color(t["fg"])

        # Redraw immediately
        with contextlib.suppress(Exception):
            self.canvas.draw_idle()

    def _stop_logging(self) -> None:
        """Stop data logging."""
        # If UI is already gone, just stop logger state and return
        try:
            if not self.root.winfo_exists():
                if self.logger:
                    self.logger.running = False
                self.is_running = False
                return
        except tk.TclError:
            if self.logger:
                self.logger.running = False
            self.is_running = False
            return

        if self.logger:
            self._add_info("⏹ Stopping data collection...\n", "warning")
            self.logger.running = False
            self._update_status("Stopping...", _t()["accent"])

            if self.logger_thread and self.logger_thread.is_alive():
                self.logger_thread.join(timeout=3.0)

            self.logger = None
            self.is_running = False

        # Stop animation if still active
        try:
            if hasattr(self, "ani") and self.ani and self.ani.event_source:
                self.ani.event_source.stop()
        except Exception:
            pass

        # Re-enable configuration widgets safely
        self.conn_panel.set_enabled(True)
        self._safe_widget_config(self.output_entry, state="normal")
        self._safe_widget_config(self.browse_btn, state=tk.NORMAL)
        self._safe_widget_config(self.max_points_spinbox, state="normal")
        self._safe_widget_config(self.avg_level_combo, state="readonly")
        self._set_button_states_idle()
        self._update_status("Ready", _t()["success"])


def main() -> int:
    """Main entry point."""
    root = tk.Tk()
    app = TemperatureLoggerGUIEnhanced(root)

    try:
        root.mainloop()
    except KeyboardInterrupt:
        if app.logger:
            app.logger.running = False
        root.destroy()

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
