"""Professional PID Controller GUI — Temperature control via SCPI.

Provides a comprehensive tkinter interface for interacting with the
TMP117 PID temperature controller over USB serial or WiFi/TCP.

Features:
- Live PID telemetry (temperature, setpoint, error, output)
- Real-time temperature & output plots with matplotlib
- Temperature ramp control with progress bar
- PID tuning (Kp, Ki, Kd) with apply/query
- TEC direct output control
- WiFi status panel
- SCPI command console
- Auto-connect with discover WiFi IP via USB
- Dark / Light theme toggle

(c) 2026 Prof. Flavio ABREU ARAUJO. All rights reserved.
"""

from __future__ import annotations

import re
import threading
import time
import tkinter as tk
from collections import deque
from tkinter import ttk

import matplotlib

matplotlib.use("TkAgg")
import contextlib

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

from .connection_panel import ConnectionPanel
from .scpi_client import format_response
from .tools.myserial.scpi_universal import SCPIUniversal


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",
        "warning": "#ff8c00",
        "heat": "#e74c3c",
        "cool": "#3498db",
        "muted": "#888888",
        "value_fg": "#00ccff",
        "grid": "#555555",
        "spine": "#555555",
        "btn_bg": "#3c3c3c",
        "btn_active": "#505050",
        "green_btn": "#107c10",
        "green_active": "#0e6b0e",
        "red_btn": "#d83b01",
        "red_active": "#b83200",
        "accent_btn": "#0078d4",
        "accent_active": "#005fa3",
        "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",
        "warning": "#c87d0a",
        "heat": "#c0392b",
        "cool": "#2980b9",
        "muted": "#666666",
        "value_fg": "#005a9e",
        "grid": "#cccccc",
        "spine": "#aaaaaa",
        "btn_bg": "#dcdcdc",
        "btn_active": "#c0c0c0",
        "green_btn": "#0b7a0b",
        "green_active": "#096609",
        "red_btn": "#c50f1f",
        "red_active": "#a00d19",
        "accent_btn": "#0063b1",
        "accent_active": "#004e8c",
        "legend_face": "#e8e8e8",
        "legend_edge": "#aaaaaa",
    },
}

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


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


BG_COLOR = THEMES["dark"]["bg"]
FG_COLOR = THEMES["dark"]["fg"]
PANEL_BG = THEMES["dark"]["panel_bg"]
ACCENT_COLOR = THEMES["dark"]["accent"]
SUCCESS_COLOR = THEMES["dark"]["success"]
ERROR_COLOR = THEMES["dark"]["error"]
WARNING_COLOR = THEMES["dark"]["warning"]
HEAT_COLOR = THEMES["dark"]["heat"]
COOL_COLOR = THEMES["dark"]["cool"]
MUTED_COLOR = THEMES["dark"]["muted"]

# Max history for the live plots
MAX_PLOT_POINTS = 600


class PIDControllerGUI:
    """Main PID Controller GUI application."""

    # ------------------------------------------------------------------ init
    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.root.title("PID Temperature Controller (© 2026 Prof. Flavio ABREU ARAUJO)")
        self.root.geometry("1400x900")
        self.root.resizable(True, True)
        self.root.configure(bg=_t()["bg"])

        # State
        self.instr: SCPIUniversal | None = None
        self.connected = False
        self.polling = False
        self._poll_thread: threading.Thread | None = None
        self._stop_event = threading.Event()
        self._sensor_count: int = 2  # default, updated on connect
        self._plot_timer_id: str | None = None
        self._scpi_lock = threading.Lock()  # Serialize all SCPI I/O

        # Data history for plots
        self._times: deque[float] = deque(maxlen=MAX_PLOT_POINTS)
        self._temps: deque[float] = deque(maxlen=MAX_PLOT_POINTS)
        self._setpoints: deque[float] = deque(maxlen=MAX_PLOT_POINTS)
        self._outputs: deque[float] = deque(maxlen=MAX_PLOT_POINTS)
        self._t0: float | None = None

        # SCPI console history
        self._scpi_history: list[str] = []
        self._scpi_history_idx = 0

        # ----- ttk theme -----
        self._apply_ttk_theme()

        # ----- bottom bar: status + theme toggle (pack FIRST to reserve space) -----
        bottom_bar = ttk.Frame(root)
        bottom_bar.pack(side=tk.BOTTOM, fill=tk.X, padx=6, pady=(0, 4))

        self._status_var = tk.StringVar(value="Disconnected")
        ttk.Label(
            bottom_bar,
            textvariable=self._status_var,
            anchor=tk.W,
            font=("Arial", 9),
            foreground=_t()["muted"],
        ).pack(side=tk.LEFT, fill=tk.X, expand=True)

        self.theme_btn = ttk.Button(bottom_bar, text="☀ Light", command=self._toggle_theme, width=9)
        self.theme_btn.pack(side=tk.RIGHT)

        # ----- layout: left panel | right panel (plots + console) -----
        content = ttk.Frame(root)
        content.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        left_panel = ttk.Frame(content, width=380)
        left_panel.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 6))
        left_panel.pack_propagate(False)

        right_panel = ttk.Frame(content)
        right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

        # ===== LEFT PANEL (controls only — no console) =====
        self._build_connection_panel(left_panel)
        self._build_status_panel(left_panel)
        self._build_pid_control_panel(left_panel)
        self._build_ramp_panel(left_panel)
        self._build_tuning_panel(left_panel)
        self._build_tec_panel(left_panel)

        # ===== RIGHT PANEL: SCPI console at bottom, plots fill the rest =====
        self._build_scpi_console(right_panel)  # pack BOTTOM first
        self._build_plots(right_panel)  # fills remaining space

        # protocol handler
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

    # ----------------------------------------------------------------
    #  TTK theme helpers
    # ----------------------------------------------------------------
    def _apply_ttk_theme(self) -> None:
        """Apply current theme colours to ttk styles."""
        t = _t()
        style = ttk.Style()
        style.theme_use("clam")
        style.configure(".", background=t["bg"], foreground=t["fg"], fieldbackground=t["field_bg"])
        style.configure("TLabel", background=t["bg"], foreground=t["fg"])
        style.configure("TFrame", background=t["bg"])
        style.configure("TLabelframe", background=t["bg"], foreground=t["fg"])
        style.configure("TLabelframe.Label", background=t["bg"], foreground=t["accent"])
        style.configure("TButton", background=t["btn_bg"], foreground=t["fg"], padding=4)
        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.map("TButton", background=[("active", t["btn_active"])])
        style.configure("Green.TButton", background=t["green_btn"], foreground="#ffffff")
        style.map("Green.TButton", background=[("active", t["green_active"])])
        style.configure("Red.TButton", background=t["red_btn"], foreground="#ffffff")
        style.map("Red.TButton", background=[("active", t["red_active"])])
        style.configure("Accent.TButton", background=t["accent_btn"], foreground="#ffffff")
        style.map("Accent.TButton", background=[("active", t["accent_active"])])
        style.configure(
            "Control.TLabel", background=t["bg"], foreground=t["fg"], font=("Arial", 10)
        )
        style.configure(
            "Header.TLabel", background=t["bg"], foreground=t["accent"], font=("Arial", 12, "bold")
        )
        style.configure(
            "Value.TLabel",
            background=t["bg"],
            foreground=t["value_fg"],
            font=("Courier", 14, "bold"),
        )
        style.configure(
            "Small.TLabel", background=t["bg"], foreground=t["muted"], font=("Arial", 8)
        )
        # Blue progress bar
        style.configure(
            "Blue.Horizontal.TProgressbar",
            troughcolor=t["field_bg"],
            background=t["accent"],
            darkcolor=t["accent"],
            lightcolor=t["accent"],
            bordercolor=t["field_bg"],
        )

    # ================================================================
    #  UI builders
    # ================================================================
    def _build_connection_panel(self, parent: ttk.Frame) -> None:
        self.conn_panel = ConnectionPanel(
            parent,
            default="WiFi",
            label="Mode:",
            on_connected=self._on_panel_connected,
            on_disconnected=self._on_panel_disconnected,
            on_log=self._console_log,
        )
        self.conn_panel.pack(fill=tk.X, pady=(0, 6))

    # ----- live status -----
    def _build_status_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="Live Status", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

        labels = [
            ("Temperature:", "temp_val", "---"),
            ("Setpoint:", "sp_val", "---"),
            ("Error:", "err_val", "---"),
            ("Output:", "out_val", "---"),
            ("Sensor:", "sens_val", "---"),
            # ("Loop #:",      "loop_val",  "---"),  # Hidden (debug only)
        ]
        self._status_labels: dict[str, tk.StringVar] = {}
        for i, (lbl, key, default) in enumerate(labels):
            ttk.Label(frm, text=lbl, style="Control.TLabel").grid(
                row=i, column=0, sticky=tk.W, pady=1
            )
            var = tk.StringVar(value=default)
            self._status_labels[key] = var
            ttk.Label(frm, textvariable=var, style="Value.TLabel").grid(
                row=i, column=1, sticky=tk.E, padx=4
            )
        frm.columnconfigure(1, weight=1)

    # ----- PID on/off + setpoint -----
    def _build_pid_control_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="PID Control", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

        # Enable / Disable
        btn_row = ttk.Frame(frm)
        btn_row.pack(fill=tk.X, pady=(0, 4))
        self.pid_enable_btn = ttk.Button(
            btn_row, text="▶ Enable PID", command=self._pid_enable, style="Green.TButton"
        )
        self.pid_enable_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 2))
        self.pid_disable_btn = ttk.Button(
            btn_row, text="⏹ Disable PID", command=self._pid_disable, style="Red.TButton"
        )
        self.pid_disable_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 0))

        # Setpoint
        sp_row = ttk.Frame(frm)
        sp_row.pack(fill=tk.X, pady=2)
        ttk.Label(sp_row, text="Setpoint (°C):", style="Control.TLabel").pack(side=tk.LEFT)
        self.setpoint_var = tk.StringVar(value="25.0")
        ttk.Entry(sp_row, textvariable=self.setpoint_var, width=8).pack(side=tk.LEFT, padx=4)
        ttk.Button(sp_row, text="Apply", command=self._apply_setpoint, style="Accent.TButton").pack(
            side=tk.LEFT
        )

        # Sensor select (1-based; populated on connect via :PID:SENS:COUNT?)
        sens_row = ttk.Frame(frm)
        sens_row.pack(fill=tk.X, pady=2)
        ttk.Label(sens_row, text="Sensor:", style="Control.TLabel").pack(side=tk.LEFT)
        self.sensor_var = tk.StringVar(value="1")
        self.sensor_combo = ttk.Combobox(
            sens_row, textvariable=self.sensor_var, values=["1", "2"], state="readonly", width=4
        )
        self.sensor_combo.pack(side=tk.LEFT, padx=4)
        ttk.Button(
            sens_row, text="Select", command=self._select_sensor, style="Accent.TButton"
        ).pack(side=tk.LEFT)

    # ----- Temperature Ramp -----
    def _build_ramp_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="Temperature Ramp", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

        # Target / Rate
        r0 = ttk.Frame(frm)
        r0.pack(fill=tk.X, pady=2)
        ttk.Label(r0, text="Target (°C):", style="Control.TLabel").pack(side=tk.LEFT)
        self.ramp_target_var = tk.StringVar(value="30.0")
        ttk.Entry(r0, textvariable=self.ramp_target_var, width=8).pack(side=tk.LEFT, padx=4)
        ttk.Label(r0, text="Rate:", style="Control.TLabel").pack(side=tk.LEFT, padx=(8, 0))
        self.ramp_rate_var = tk.StringVar(value="1.0")
        ttk.Entry(r0, textvariable=self.ramp_rate_var, width=6).pack(side=tk.LEFT, padx=4)

        r1 = ttk.Frame(frm)
        r1.pack(fill=tk.X, pady=2)
        ttk.Label(r1, text="Unit:", style="Control.TLabel").pack(side=tk.LEFT)
        self.ramp_unit_var = tk.StringVar(value="°C/min")
        ttk.Combobox(
            r1,
            textvariable=self.ramp_unit_var,
            values=["°C/min", "°C/sec"],
            state="readonly",
            width=8,
        ).pack(side=tk.LEFT, padx=4)
        ttk.Label(r1, text="Hold (s):", style="Control.TLabel").pack(side=tk.LEFT, padx=(8, 0))
        self.ramp_hold_var = tk.StringVar(value="0")
        ttk.Entry(r1, textvariable=self.ramp_hold_var, width=6).pack(side=tk.LEFT, padx=4)

        # Start / Stop
        btn_row = ttk.Frame(frm)
        btn_row.pack(fill=tk.X, pady=(4, 2))
        self.ramp_start_btn = ttk.Button(
            btn_row, text="🔥 Start Ramp", command=self._ramp_start, style="Green.TButton"
        )
        self.ramp_start_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 2))
        self.ramp_stop_btn = ttk.Button(
            btn_row, text="⏹ Stop Ramp", command=self._ramp_stop, style="Red.TButton"
        )
        self.ramp_stop_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 0))

        # Progress
        self.ramp_status_var = tk.StringVar(value="Idle")
        ttk.Label(frm, textvariable=self.ramp_status_var, style="Small.TLabel").pack(anchor=tk.W)

        # Canvas-based progress bar with percentage overlay
        self.ramp_progress_canvas = tk.Canvas(
            frm,
            height=24,
            bg=_t()["bg"],
            highlightthickness=0,
            relief=tk.FLAT,
        )
        self.ramp_progress_canvas.pack(fill=tk.X, pady=(2, 0))
        self.ramp_progress_canvas.bind("<Configure>", lambda _: self._draw_ramp_progress())

        self.ramp_progress_value = 0

        self.ramp_time_var = tk.StringVar(value="")
        ttk.Label(frm, textvariable=self.ramp_time_var, style="Small.TLabel").pack(anchor=tk.W)

    # ----- PID Tuning -----
    def _build_tuning_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="PID Tuning", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

        self.kp_var = tk.StringVar(value="1.0")
        self.ki_var = tk.StringVar(value="0.1")
        self.kd_var = tk.StringVar(value="0.01")

        for i, (name, var) in enumerate(
            [("Kp:", self.kp_var), ("Ki:", self.ki_var), ("Kd:", self.kd_var)]
        ):
            ttk.Label(frm, text=name, style="Control.TLabel").grid(row=0, column=i * 2, padx=2)
            ttk.Entry(frm, textvariable=var, width=8).grid(row=0, column=i * 2 + 1, padx=2)

        btn_row = ttk.Frame(frm)
        btn_row.grid(row=1, column=0, columnspan=6, pady=(4, 0), sticky=tk.EW)
        ttk.Button(btn_row, text="📥 Read", command=self._read_tuning, style="Accent.TButton").pack(
            side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 2)
        )
        ttk.Button(
            btn_row, text="📤 Apply", command=self._apply_tuning, style="Green.TButton"
        ).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 2))
        ttk.Button(btn_row, text="↺ Reset PID", command=self._reset_pid, style="Red.TButton").pack(
            side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 0)
        )

        # Configure columns to expand
        for col in (1, 3, 5):
            frm.columnconfigure(col, weight=1)

    # ----- TEC direct control -----
    def _build_tec_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="TEC Direct Output", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

        r0 = ttk.Frame(frm)
        r0.pack(fill=tk.X)
        ttk.Label(r0, text="Output (−1…+1):", style="Control.TLabel").pack(side=tk.LEFT)
        self.tec_var = tk.StringVar(value="0.0")
        ttk.Entry(r0, textvariable=self.tec_var, width=6).pack(side=tk.LEFT, padx=4)

        btn_row = ttk.Frame(frm)
        btn_row.pack(fill=tk.X, pady=(4, 0))
        ttk.Button(btn_row, text="⚡ Set", command=self._tec_set, style="Accent.TButton").pack(
            side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 2)
        )
        ttk.Button(btn_row, text="🛑 STOP", command=self._tec_stop, style="Red.TButton").pack(
            side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 0)
        )

    # ----- SCPI console -----
    def _build_scpi_console(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="SCPI Console", padding=4)
        frm.pack(side=tk.BOTTOM, fill=tk.X, pady=(4, 0))

        self.console_text = tk.Text(
            frm,
            height=8,
            bg=_t()["console_bg"],
            fg=_t()["fg"],
            font=("Courier", 9),
            state=tk.DISABLED,
            wrap=tk.WORD,
        )
        self.console_text.pack(fill=tk.X, pady=(0, 4))

        entry_row = ttk.Frame(frm)
        entry_row.pack(fill=tk.X)
        self.scpi_entry = ttk.Entry(entry_row, width=30)
        self.scpi_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4))
        self.scpi_entry.bind("<Return>", self._send_scpi)
        self.scpi_entry.bind("<Up>", self._scpi_hist_prev)
        self.scpi_entry.bind("<Down>", self._scpi_hist_next)
        ttk.Button(entry_row, text="Send", command=self._send_scpi, style="Accent.TButton").pack(
            side=tk.LEFT
        )

    # ----- plots -----
    def _build_plots(self, parent: ttk.Frame) -> None:
        t = _t()
        self.fig = Figure(figsize=(9, 6.2), dpi=100, facecolor=t["bg"])
        self.fig.subplots_adjust(left=0.10, right=0.98, top=0.97, bottom=0.08, hspace=0.08)

        gs = self.fig.add_gridspec(2, 1, height_ratios=[3, 1])

        # top: temperature + setpoint
        self.ax_temp = self.fig.add_subplot(gs[0])
        self.ax_temp.set_facecolor(t["plot_bg"])
        self.ax_temp.set_ylabel("Temperature (°C)", color=t["fg"], fontsize=10)
        self.ax_temp.tick_params(colors=t["fg"], labelsize=9, labelbottom=False)
        self.ax_temp.grid(True, alpha=0.2, color=t["grid"])
        (self.line_temp,) = self.ax_temp.plot(
            [], [], color="#00ccff", linewidth=1.8, label="Temperature"
        )
        (self.line_sp,) = self.ax_temp.plot(
            [], [], color=t["warning"], linewidth=1.4, linestyle="--", label="Setpoint"
        )
        self.ax_temp.legend(
            loc="upper left",
            fontsize=9,
            facecolor=t["legend_face"],
            edgecolor=t["legend_edge"],
            labelcolor=t["fg"],
        )

        # bottom: TEC output (independent x-axis — synced manually in _do_plot_update)
        self.ax_out = self.fig.add_subplot(gs[1])
        self.ax_out.set_facecolor(t["plot_bg"])
        self.ax_out.set_ylabel("Output", color=t["fg"], fontsize=10)
        self.ax_out.set_xlabel("Elapsed Time (s)", color=t["fg"], fontsize=10)
        self.ax_out.tick_params(colors=t["fg"], labelsize=9, labelbottom=True)
        self.ax_out.grid(True, alpha=0.2, color=t["grid"])
        self.ax_out.set_ylim(-1.1, 1.1)
        (self.line_out,) = self.ax_out.plot(
            [], [], color="#e74c3c", linewidth=1.4, label="TEC Output"
        )
        self.ax_out.axhline(0, color=t["grid"], linewidth=0.5)
        self.ax_out.legend(
            loc="upper left",
            fontsize=9,
            facecolor=t["legend_face"],
            edgecolor=t["legend_edge"],
            labelcolor=t["fg"],
        )

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

        self.canvas = FigureCanvasTkAgg(self.fig, master=parent)
        self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)

        # periodic redraw timer
        self._schedule_plot_update()

    # ================================================================
    #  Connection callbacks (invoked by ConnectionPanel)
    # ================================================================
    def _on_panel_connected(self, instr: SCPIUniversal, idn: str) -> None:
        """Called from worker thread after successful connection."""
        self.instr = instr
        self.connected = True

        # Additional device queries (still on worker thread)
        try:
            cnt = max(1, int(instr.query(":PID:SENS:COUNT?").strip()))
        except Exception:
            cnt = 2
        self._sensor_count = cnt
        sensor_values = [str(i) for i in range(1, cnt + 1)]

        try:
            cur = instr.query(":PID:SENS?").strip()
        except Exception:
            cur = "1"

        conn_type = instr.connection_type.upper()

        def _ui() -> None:
            self._reset_plots()
            self.sensor_combo.config(values=sensor_values)
            self.sensor_var.set(cur)
            self._set_status(f"Connected ({conn_type}) — {idn}")
            self._start_polling()
            self._schedule_plot_update()

        self.root.after(0, _ui)

    def _on_panel_disconnected(self) -> None:
        """Called on main thread after disconnection."""
        self._stop_polling()
        self._stop_plot_timer()
        self.instr = None
        self.connected = False
        self._set_status("Disconnected")

    # ================================================================
    #  Polling loop
    # ================================================================
    def _start_polling(self) -> None:
        if self.polling:
            return
        self.polling = True
        self._stop_event.clear()
        self._t0 = time.time()
        self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
        self._poll_thread.start()

    def _stop_polling(self) -> None:
        self.polling = False
        self._stop_event.set()

    def _poll_loop(self) -> None:
        """Background thread: poll :PID:TELE? and :PID:RAMP:STAT? every ~500 ms."""
        while not self._stop_event.is_set():
            if not self.connected or self.instr is None:
                break
            try:
                # -- actual TEC output (query FIRST, before telemetry) --
                tec_val = None
                tec_raw = self._query(":TEC:OUTP?")
                if tec_raw:
                    try:
                        tec_val = float(tec_raw)
                    except ValueError:
                        tec_val = None

                # -- telemetry (pass TEC value to override PID output) --
                tele = self._query(":PID:TELE?")
                if tele:
                    self._parse_telemetry(tele, tec_output=tec_val)

                # -- ramp status --
                ramp = self._query(":PID:RAMP:STAT?")
                if ramp:
                    self._parse_ramp_status(ramp)

            except Exception:
                pass

            self._stop_event.wait(0.5)

    def _query(self, cmd: str) -> str | None:
        """Thread-safe SCPI query. Returns stripped response or None."""
        try:
            with self._scpi_lock:
                if self.instr:
                    resp = self.instr.query(cmd)
                    return resp.strip() if resp else None
        except Exception:
            return None

    def _write(self, cmd: str) -> None:
        """Thread-safe SCPI write."""
        try:
            with self._scpi_lock:
                if self.instr:
                    self.instr.write(cmd)
        except Exception as e:
            self._console_log(f"Write error: {e}")

    # ================================================================
    #  Telemetry / Ramp parsing
    # ================================================================
    def _parse_telemetry(self, raw: str, tec_output: float | None = None) -> None:
        """Parse :PID:TELE? → RUNNING,SENSOR,TEMP,SETPOINT,OUTPUT,LOOP_COUNT,RAMPING

        If tec_output is provided, it overrides the PID output value (for TEC direct control).
        """
        try:
            parts = raw.split(",")
            if len(parts) < 7:
                return
            _running = int(parts[0])
            sensor = int(parts[1])
            temp = float(parts[2])
            sp = float(parts[3])
            pid_output = float(parts[4])
            loop = int(parts[5])
            _ramping = int(parts[6])
            error = temp - sp

            # Use actual TEC output if provided, otherwise use PID output
            actual_output = tec_output if tec_output is not None else pid_output

            # record for plots
            t = time.time() - (self._t0 or time.time())
            self._times.append(t)
            self._temps.append(temp)
            self._setpoints.append(sp)
            self._outputs.append(actual_output)

            # update labels (schedule on main thread)
            self.root.after(
                0, self._update_status_labels, temp, sp, error, actual_output, sensor, loop
            )
        except (ValueError, IndexError):
            pass

    def _update_status_labels(self, temp, sp, error, output, sensor, loop) -> None:
        self._status_labels["temp_val"].set(f"{temp:.3f} °C")
        self._status_labels["sp_val"].set(f"{sp:.2f} °C")
        ERROR_COLOR if abs(error) > 2 else WARNING_COLOR if abs(error) > 0.5 else SUCCESS_COLOR
        self._status_labels["err_val"].set(f"{error:+.3f} °C")
        self._status_labels["out_val"].set(f"{output:+.3f}")
        self._status_labels["sens_val"].set(f"#{sensor}")
        # self._status_labels["loop_val"].set(str(loop))

    def _parse_ramp_status(self, raw: str) -> None:
        """Parse :PID:RAMP:STAT? → active,ramping,holding,completed,start,target,current_sp,rate,progress%"""
        try:
            parts = raw.split(",")
            if len(parts) < 9:
                return
            active = int(parts[0])
            ramping = int(parts[1])
            holding = int(parts[2])
            completed = int(parts[3])
            start_t = float(parts[4])
            target = float(parts[5])
            float(parts[6])
            rate = float(parts[7])
            progress = float(parts[8])

            if completed:
                phase = "✓ Completed"
            elif holding:
                phase = "⏸ Holding"
            elif ramping:
                phase = f"▶ Ramping → {target:.1f}°C"
            elif active:
                phase = "● Active"
            else:
                phase = "Idle"

            # Elapsed / remaining
            time_str = ""
            if active and rate > 0 and ramping:
                td = abs(target - start_t)
                total = (td / rate) * 60.0
                elapsed = (progress / 100.0) * total
                remaining = max(0, total - elapsed)
                time_str = f"Elapsed: {elapsed:.0f}s  |  Remaining: {remaining:.0f}s"

            self.root.after(0, self._update_ramp_ui, phase, progress, time_str)
        except (ValueError, IndexError):
            pass

    def _update_ramp_ui(self, phase: str, progress: float, time_str: str) -> None:
        self.ramp_status_var.set(phase)
        self.ramp_progress_value = progress
        self._draw_ramp_progress()
        self.ramp_time_var.set(time_str)

    def _draw_ramp_progress(self) -> None:
        """Draw progress bar with percentage text centered on canvas."""
        canvas = self.ramp_progress_canvas
        canvas.delete("all")

        width = canvas.winfo_width()
        height = canvas.winfo_height()

        if width <= 1:  # Canvas not yet rendered
            return

        t = _t()

        # Draw background
        canvas.create_rectangle(0, 0, width, height, fill=t["field_bg"], outline="")

        # Draw progress bar fill
        progress_width = (self.ramp_progress_value / 100.0) * width
        canvas.create_rectangle(0, 0, progress_width, height, fill=t["accent"], outline="")

        # Draw percentage text centered
        text = f"{self.ramp_progress_value:.0f}%"
        canvas.create_text(
            width // 2,
            height // 2,
            text=text,
            fill=t["fg"],
            font=("Arial", 10, "bold"),
        )

    # ================================================================
    #  Plot update
    # ================================================================
    def _schedule_plot_update(self) -> None:
        """Schedule periodic plot updates. Ensures only one timer is active."""
        if self._plot_timer_id is not None:
            return  # Timer already running
        self._do_plot_update()
        self._plot_timer_id = self.root.after(500, self._schedule_plot_update_loop)

    def _schedule_plot_update_loop(self) -> None:
        """Internal loop for plot updates."""
        self._plot_timer_id = None  # Clear the ID before next cycle
        self._do_plot_update()
        self._plot_timer_id = self.root.after(500, self._schedule_plot_update_loop)

    def _do_plot_update(self) -> None:
        if not self._times:
            return
        t = list(self._times)
        temps = list(self._temps)
        sps = list(self._setpoints)
        outs = list(self._outputs)

        self.line_temp.set_data(t, temps)
        self.line_sp.set_data(t, sps)
        self.line_out.set_data(t, outs)

        # Compute x range from data
        xmin, xmax = t[0], t[-1]
        if xmax <= xmin:
            xmax = xmin + 1.0
        margin = (xmax - xmin) * 0.02 or 0.5
        self.ax_temp.set_xlim(xmin - margin, xmax + margin)
        self.ax_out.set_xlim(xmin - margin, xmax + margin)

        # Y auto-range for temperature axis
        if temps or sps:
            all_y = temps + sps
            ymin, ymax = min(all_y), max(all_y)
            pad = (ymax - ymin) * 0.1 or 1.0
            self.ax_temp.set_ylim(ymin - pad, ymax + pad)

        self.ax_out.set_ylim(-1.1, 1.1)

        with contextlib.suppress(Exception):
            self.canvas.draw_idle()

    def _stop_plot_timer(self) -> None:
        """Stop the plot update timer."""
        if self._plot_timer_id is not None:
            self.root.after_cancel(self._plot_timer_id)
            self._plot_timer_id = None

    def _reset_plots(self) -> None:
        """Clear all data and redraw empty plots (called on disconnect)."""
        self._times.clear()
        self._temps.clear()
        self._setpoints.clear()
        self._outputs.clear()
        self._t0 = None

        self.line_temp.set_data([], [])
        self.line_sp.set_data([], [])
        self.line_out.set_data([], [])

        # Reset axes and re-enable autoscaling
        for ax in (self.ax_temp, self.ax_out):
            ax.relim()
            ax.set_autoscalex_on(True)
            ax.set_autoscaley_on(True)
            ax.autoscale_view()
        self.ax_out.set_ylim(-1.1, 1.1)

        # Reset status labels
        for key in self._status_labels:
            self._status_labels[key].set("---")
        self.ramp_status_var.set("Idle")
        self.ramp_progress_value = 0
        self._draw_ramp_progress()
        self.ramp_time_var.set("")

        with contextlib.suppress(Exception):
            self.canvas.draw_idle()

    # ================================================================
    #  PID actions
    # ================================================================
    def _pid_enable(self) -> None:
        self._do_async(":PID:ENAB 1", "PID enabled")

    def _pid_disable(self) -> None:
        self._do_async(":PID:ENAB 0", "PID disabled")

    def _apply_setpoint(self) -> None:
        try:
            sp = float(self.setpoint_var.get())
        except ValueError:
            self._console_log("❌ Invalid setpoint value")
            return
        self._do_async(f":PID:SETP {sp}", f"Setpoint → {sp} °C")

    def _select_sensor(self) -> None:
        try:
            s = int(self.sensor_var.get())
        except ValueError:
            self._console_log("❌ Invalid sensor number")
            return
        self._do_async(f":PID:SENS {s}", f"Sensor → #{s}")

    # ================================================================
    #  Ramp actions
    # ================================================================
    def _ramp_start(self) -> None:
        try:
            tgt = float(self.ramp_target_var.get())
            rate = float(self.ramp_rate_var.get())
        except ValueError:
            self._console_log("❌ Invalid ramp parameters")
            return
        unit = "SEC" if "sec" in self.ramp_unit_var.get().lower() else "MIN"
        hold = self.ramp_hold_var.get().strip()
        cmd = f":PID:RAMP {tgt},{rate},{unit}"
        if hold and hold != "0":
            cmd += f",{hold}"
        self._do_async(cmd, f"Ramp → {tgt}°C at {rate}°C/{unit.lower()}")
        self.ramp_progress_value = 0
        self._draw_ramp_progress()
        self.ramp_status_var.set("Starting…")

    def _ramp_stop(self) -> None:
        self._do_async(":PID:RAMP:STOP", "Ramp stopped")
        self.ramp_progress_value = 0
        self._draw_ramp_progress()
        self.ramp_status_var.set("Idle")
        self.ramp_time_var.set("")

    # ================================================================
    #  Tuning actions
    # ================================================================
    def _read_tuning(self) -> None:
        def _do():
            resp = self._query(":PID:TUNE?")
            if resp:
                try:
                    parts = resp.split(",")
                    if len(parts) >= 3:
                        self.root.after(0, lambda: self.kp_var.set(parts[0].strip()))
                        self.root.after(0, lambda: self.ki_var.set(parts[1].strip()))
                        self.root.after(0, lambda: self.kd_var.set(parts[2].strip()))
                        self._console_log(f"Tuning: Kp={parts[0]} Ki={parts[1]} Kd={parts[2]}")
                except Exception:
                    pass
            else:
                self._console_log("❌ No tuning response")

        threading.Thread(target=_do, daemon=True).start()

    def _apply_tuning(self) -> None:
        try:
            kp = float(self.kp_var.get())
            ki = float(self.ki_var.get())
            kd = float(self.kd_var.get())
        except ValueError:
            self._console_log("❌ Invalid tuning values")
            return

        def _do():
            self._write(f":PID:KP {kp}")
            time.sleep(0.1)
            self._write(f":PID:KI {ki}")
            time.sleep(0.1)
            self._write(f":PID:KD {kd}")
            self._console_log(f"Tuning applied: Kp={kp} Ki={ki} Kd={kd}")

        threading.Thread(target=_do, daemon=True).start()

    def _reset_pid(self) -> None:
        self._do_async(":PID:RES", "PID reset (integral cleared)")

    # ================================================================
    #  TEC actions
    # ================================================================
    def _tec_set(self) -> None:
        try:
            val = float(self.tec_var.get())
            if not -1.0 <= val <= 1.0:
                self._console_log("❌ TEC output must be −1…+1")
                return
        except ValueError:
            self._console_log("❌ Invalid TEC value")
            return
        self._do_async(f":TEC:OUTP {val}", f"TEC → {val:+.2f}")

    def _tec_stop(self) -> None:
        self._do_async(":TEC:STOP", "TEC stopped")

    # ================================================================
    #  SCPI console
    # ================================================================
    def _send_scpi(self, event=None) -> None:
        cmd = self.scpi_entry.get().strip()
        if not cmd:
            return
        self.scpi_entry.delete(0, tk.END)
        self._scpi_history.append(cmd)
        self._scpi_history_idx = len(self._scpi_history)
        self._console_log(f"→ {cmd}")

        def _do():
            is_query = cmd.endswith("?")
            is_mode_cmd = cmd.upper().startswith(":SYST:MODE") and not is_query
            is_reset_cmd = cmd.upper() in (":SYST:RES", "*RST")

            if is_query:
                resp = self._query(cmd)
                if resp:
                    # Format and display pretty response
                    formatted = format_response(cmd, resp, pretty=True)
                    formatted_plain = strip_ansi_codes(formatted)
                    self._console_log(formatted_plain)
                else:
                    self._console_log("← (no response)")
            elif is_reset_cmd:
                # Reset command - device will disconnect
                self._write(cmd)
                self._console_log("✓ Reset command sent — device restarting...")
            elif is_mode_cmd:
                # Mode command - need to read response and check for errors
                self._write(cmd)
                time.sleep(0.1)  # Brief delay for device to process
                opc_resp = self._query("*OPC?")  # Wait for operation complete
                if opc_resp == "1":
                    # Check for errors
                    err_resp = self._query(":SYST:ERR?")
                    if err_resp and not err_resp.startswith("0,"):
                        # Error occurred - format and display
                        formatted = format_response(cmd, err_resp, pretty=True)
                        formatted_plain = strip_ansi_codes(formatted)
                        self._console_log(formatted_plain)
                    else:
                        # Success - query current mode for confirmation
                        mode_resp = self._query(":SYST:MODE?")
                        if mode_resp:
                            formatted = format_response(":SYST:MODE?", mode_resp, pretty=True)
                            formatted_plain = strip_ansi_codes(formatted)
                            self._console_log(formatted_plain)
                        else:
                            self._console_log("✓ Mode command sent")
                else:
                    self._console_log("✓ Mode command sent (no OPC)")
            else:
                # Regular non-query command
                self._write(cmd)
                self._console_log(f"✓ {cmd}")

        threading.Thread(target=_do, daemon=True).start()

    def _scpi_hist_prev(self, event=None) -> None:
        if self._scpi_history and self._scpi_history_idx > 0:
            self._scpi_history_idx -= 1
            self.scpi_entry.delete(0, tk.END)
            self.scpi_entry.insert(0, self._scpi_history[self._scpi_history_idx])

    def _scpi_hist_next(self, event=None) -> None:
        if self._scpi_history_idx < len(self._scpi_history) - 1:
            self._scpi_history_idx += 1
            self.scpi_entry.delete(0, tk.END)
            self.scpi_entry.insert(0, self._scpi_history[self._scpi_history_idx])
        else:
            self._scpi_history_idx = len(self._scpi_history)
            self.scpi_entry.delete(0, tk.END)

    # ================================================================
    #  Helpers
    # ================================================================
    def _do_async(self, cmd: str, success_msg: str) -> None:
        """Fire-and-forget SCPI write in a background thread."""

        def _do():
            self._write(cmd)
            self._console_log(f"✓ {success_msg}")

        threading.Thread(target=_do, daemon=True).start()

    def _console_log(self, msg: str) -> None:
        """Append a line to the SCPI console (thread-safe)."""

        def _add():
            self.console_text.config(state=tk.NORMAL)
            self.console_text.insert(tk.END, msg + "\n")
            self.console_text.see(tk.END)
            self.console_text.config(state=tk.DISABLED)

        self.root.after(0, _add)

    def _set_status(self, msg: str) -> None:
        self._status_var.set(msg)

    # ================================================================
    #  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._apply_ttk_theme()

        # Update root + console colours
        self.root.configure(bg=t["bg"])
        self.console_text.config(bg=t["console_bg"], fg=t["fg"])

        # Update matplotlib plots
        self.fig.set_facecolor(t["bg"])
        for ax in (self.ax_temp, self.ax_out):
            ax.set_facecolor(t["plot_bg"])
            ax.tick_params(colors=t["fg"], labelsize=9)
            ax.yaxis.label.set_color(t["fg"])
            ax.xaxis.label.set_color(t["fg"])
            ax.grid(True, alpha=0.2, 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 _on_close(self) -> None:
        self._stop_polling()
        self._stop_plot_timer()
        if self.instr:
            with contextlib.suppress(Exception):
                self.instr.close()
        self.root.destroy()
