"""Professional OTA Firmware Update Manager GUI.

Manages over-the-air firmware updates for all NEnG CircuitPython instruments
via the SCPI-over-TCP OTA protocol.

Features:
- Network scanner: discovers all NEnG instruments on port 5025 (auto-detects networks)
- Device auto-detection via *IDN? (3Dmag, RTDMax31865, RelayBank16, BTS7960, TMP117)
- Single-file upload (browse + target path)
- Git-aware incremental deployment (TMP117)
- Upload progress with speed display
- Rollback and reboot controls
- Authentication and algorithm selection
- Full OTA log output
- Dark / Light theme toggle

Layout
------
LEFT column (320 px fixed):
  Connection   ← ConnectionPanel widget
  Network Scanner ← port-5025 scan with Listbox
  Device Info
  Authentication

RIGHT column (expands):
  Update Mode  (Single File / Git Deploy radio)
  Progress bar
  [Start Update]
  Device Control (Rollback / Reboot / Auto-reboot)

BOTTOM (full width):
  OTA Log (scrolled text)
  Status bar + theme toggle

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

from __future__ import annotations

import concurrent.futures
import contextlib
import io
import os
import re
import socket
import threading
import tkinter as tk
from ipaddress import IPv4Network
from tkinter import filedialog, messagebox, ttk

from neng_scpi_tools.connection_panel import ConnectionPanel  # type: ignore
from neng_scpi_tools.myserial.scpi_universal import SCPIUniversal

from .ota_client import GitToDeviceMapper, OTAClient, deploy_from_git

# ===================================================================
# Theme / Colours (identical to wifi_config_gui.py)
# ===================================================================
THEMES = {
    "dark": {
        "bg": "#2b2b2b",
        "fg": "#ffffff",
        "panel_bg": "#333333",
        "field_bg": "#3c3c3c",
        "console_bg": "#1e1e1e",
        "accent": "#0078d4",
        "success": "#107c10",
        "error": "#d83b01",
        "warning": "#ff8c00",
        "muted": "#888888",
        "value_fg": "#00ccff",
        "btn_bg": "#3c3c3c",
        "btn_active": "#505050",
        "green_btn": "#107c10",
        "green_active": "#0e6b0e",
        "red_btn": "#d83b01",
        "red_active": "#b83200",
        "accent_btn": "#0078d4",
        "accent_active": "#005fa3",
        "yellow_btn": "#ff8c00",
        "yellow_active": "#d97700",
        "list_bg": "#2e2e2e",
        "list_select": "#0078d4",
    },
    "light": {
        "bg": "#f0f0f0",
        "fg": "#1e1e1e",
        "panel_bg": "#e8e8e8",
        "field_bg": "#ffffff",
        "console_bg": "#ffffff",
        "accent": "#0063b1",
        "success": "#0b7a0b",
        "error": "#c50f1f",
        "warning": "#c87d0a",
        "muted": "#666666",
        "value_fg": "#005a9e",
        "btn_bg": "#dcdcdc",
        "btn_active": "#c0c0c0",
        "green_btn": "#0b7a0b",
        "green_active": "#096609",
        "red_btn": "#c50f1f",
        "red_active": "#a00d19",
        "accent_btn": "#0063b1",
        "accent_active": "#004e8c",
        "yellow_btn": "#c87d0a",
        "yellow_active": "#a66a08",
        "list_bg": "#fafafa",
        "list_select": "#0063b1",
    },
}

_current_theme = "dark"


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


# ===================================================================
# Module-level helpers
# ===================================================================


def _verify_scpi_device(ip: str, port: int = 5025, timeout: float = 1.5) -> str | None:
    """Send ``*IDN?`` to *ip*:*port*; return the response for any SCPI device, or None."""
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.settimeout(timeout)
            sock.connect((ip, port))
            sock.sendall(b"*IDN?\n")
            response = sock.recv(1024).decode().strip()
            if response:
                return response
    except (socket.timeout, OSError, UnicodeDecodeError):
        pass
    return None


class _GUIWriter(io.TextIOBase):
    """Redirect OTAClient print() output to the GUI log widget (thread-safe)."""

    def __init__(self, log_fn, progress_fn=None) -> None:
        self._log_fn = log_fn
        self._progress_fn = progress_fn
        self._buf = ""

    def write(self, s: str) -> int:
        self._buf += s
        while "\n" in self._buf:
            line, self._buf = self._buf.split("\n", 1)
            stripped = line.rstrip()
            if stripped:
                self._log_fn(stripped)
                if self._progress_fn:
                    self._progress_fn(stripped)
        return len(s)

    def flush(self) -> None:
        if self._buf.strip():
            self._log_fn(self._buf.strip())
            self._buf = ""


# ===================================================================
# Main GUI class
# ===================================================================


class OTAManagerGUI:
    """Professional OTA Firmware Update Manager."""

    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.root.title("OTA Firmware Manager (c) 2026 Prof. Flavio ABREU ARAUJO")
        self.root.geometry("1000x860")
        self.root.resizable(True, True)
        self.root.configure(bg=_t()["bg"])

        # Connection state
        self.instr: SCPIUniversal | None = None
        self.connected = False
        self._scpi_lock = threading.Lock()
        self._ota_running = False
        self._scan_running = False
        self._scan_stop_event: threading.Event | None = None
        self._scan_large_var = tk.BooleanVar(value=False)

        # Device info
        self._device_idn = ""
        self._device_host = ""

        # Scan results: list of (ip, idn) pairs in listbox order
        self._scan_results: list[tuple[str, str]] = []

        # Upload mode
        self._upload_mode = tk.StringVar(value="single")

        # Single file vars
        self._local_file_var = tk.StringVar()
        self._target_name_var = tk.StringVar()

        # Git deploy vars
        self._dry_run_var = tk.BooleanVar(value=False)
        self._force_full_var = tk.BooleanVar(value=False)
        self._git_device_var = tk.StringVar(value="TMP117")

        # Auth vars
        self._password_var = tk.StringVar()
        self._algo_var = tk.StringVar(value="crc32")

        # Control
        self._auto_reboot_var = tk.BooleanVar(value=True)

        # Progress
        self._progress_var = tk.DoubleVar(value=0.0)
        self._progress_label_var = tk.StringVar(value="Ready")
        self._speed_var = tk.StringVar(value="")

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

        # ----- bottom bar (pack FIRST → reserves space at bottom) -----
        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="Sun Light", command=self._toggle_theme, width=10
        )
        self.theme_btn.pack(side=tk.RIGHT)

        # ----- log panel (full width, pack from BOTTOM — before content) -----
        self._build_log_panel(root)

        # ----- main content: left column | right column -----
        content = ttk.Frame(root)
        content.pack(fill=tk.BOTH, expand=True, padx=6, pady=(6, 0))

        left_panel = ttk.Frame(content, width=320)
        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.LEFT, fill=tk.BOTH, expand=True)

        # ===== LEFT column =====
        self._build_connection_panel(left_panel)
        self._build_scan_panel(left_panel)
        self._build_device_info_panel(left_panel)
        self._build_auth_panel(left_panel)

        # ===== RIGHT column =====
        self._build_upload_mode_panel(right_panel)
        self._build_progress_panel(right_panel)

        self.start_btn = ttk.Button(
            right_panel,
            text="Start Update",
            command=self._start_update,
            style="Green.TButton",
        )
        self.start_btn.pack(fill=tk.X, pady=(6, 6))

        self._build_control_panel(right_panel)

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

    # ----------------------------------------------------------------
    # TTK theme helpers
    # ----------------------------------------------------------------
    def _apply_ttk_theme(self) -> None:
        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("TCheckbutton", background=t["bg"], foreground=t["fg"])
        style.configure("TRadiobutton", background=t["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("Yellow.TButton", background=t["yellow_btn"], foreground="#ffffff")
        style.map("Yellow.TButton", background=[("active", t["yellow_active"])])
        style.configure(
            "Control.TLabel", background=t["bg"], foreground=t["fg"], font=("Arial", 10)
        )
        style.configure(
            "Value.TLabel",
            background=t["bg"],
            foreground=t["value_fg"],
            font=("Courier", 10, "bold"),
        )
        style.configure(
            "Small.TLabel", background=t["bg"], foreground=t["muted"], font=("Arial", 8)
        )

    # ================================================================
    # UI builders — LEFT column
    # ================================================================

    def _build_connection_panel(self, parent) -> 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._log,
        )
        self.conn_panel.pack(fill=tk.X, pady=(0, 6))

    def _build_scan_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="Network Scanner", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

        self._scan_status_var = tk.StringVar(value="Click 'Scan' to discover devices")
        ttk.Label(frm, textvariable=self._scan_status_var, style="Small.TLabel", anchor=tk.W).pack(
            fill=tk.X, pady=(0, 4)
        )

        # Listbox + scrollbar
        list_frame = ttk.Frame(frm)
        list_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 4))

        scroll = ttk.Scrollbar(list_frame)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.scan_listbox = tk.Listbox(
            list_frame,
            height=5,
            bg=_t()["list_bg"],
            fg=_t()["fg"],
            selectbackground=_t()["list_select"],
            selectforeground="#ffffff",
            font=("Courier", 9),
            relief=tk.FLAT,
            borderwidth=1,
            yscrollcommand=scroll.set,
        )
        self.scan_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll.config(command=self.scan_listbox.yview)
        self.scan_listbox.bind("<Double-Button-1>", lambda _e: self._on_connect_selected())

        # Options row
        opt_row = ttk.Frame(frm)
        opt_row.pack(fill=tk.X, pady=(0, 2))
        ttk.Checkbutton(
            opt_row,
            text="Large networks (>254 hosts)",
            variable=self._scan_large_var,
        ).pack(side=tk.LEFT)

        # Buttons
        btn_row = ttk.Frame(frm)
        btn_row.pack(fill=tk.X)

        self.scan_btn = ttk.Button(
            btn_row, text="Scan", command=self._start_scan, style="Accent.TButton"
        )
        self.scan_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 2))

        self.connect_sel_btn = ttk.Button(
            btn_row, text="Connect Selected", command=self._on_connect_selected
        )
        self.connect_sel_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 0))

    def _build_device_info_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="Device Info", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

        rows = [("Model:", "idn"), ("Git:", "git"), ("Info:", "info"), ("IP:", "ip")]
        self._info_vars: dict[str, tk.StringVar] = {}
        for i, (lbl, key) in enumerate(rows):
            ttk.Label(frm, text=lbl, style="Control.TLabel").grid(
                row=i, column=0, sticky=tk.W, pady=1
            )
            var = tk.StringVar(value="---")
            self._info_vars[key] = var
            ttk.Label(frm, textvariable=var, style="Value.TLabel", wraplength=180).grid(
                row=i, column=1, sticky=tk.EW, padx=4
            )
        frm.columnconfigure(1, weight=1)

        ttk.Button(
            frm, text="Refresh Info", command=self._refresh_device_info, style="Accent.TButton"
        ).grid(row=len(rows), column=0, columnspan=2, sticky=tk.EW, pady=(6, 0))

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

        ttk.Label(frm, text="Password:", style="Control.TLabel").grid(row=0, column=0, sticky=tk.W)
        ttk.Entry(frm, textvariable=self._password_var, show="*", width=18).grid(
            row=0, column=1, sticky=tk.EW, padx=4, pady=2
        )

        ttk.Label(frm, text="Algorithm:", style="Control.TLabel").grid(row=1, column=0, sticky=tk.W)
        algo_box = ttk.Combobox(
            frm,
            textvariable=self._algo_var,
            values=["crc32", "sha256"],
            state="readonly",
            width=8,
        )
        algo_box.grid(row=1, column=1, sticky=tk.W, padx=4, pady=2)
        frm.columnconfigure(1, weight=1)

    # ================================================================
    # UI builders — RIGHT column
    # ================================================================

    def _build_upload_mode_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="Update Mode", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

        mode_row = ttk.Frame(frm)
        mode_row.pack(fill=tk.X, pady=(0, 6))

        ttk.Radiobutton(
            mode_row,
            text="Single File",
            variable=self._upload_mode,
            value="single",
            command=self._on_mode_change,
        ).pack(side=tk.LEFT, padx=(0, 12))
        ttk.Radiobutton(
            mode_row,
            text="Git Deploy",
            variable=self._upload_mode,
            value="git",
            command=self._on_mode_change,
        ).pack(side=tk.LEFT)

        # Single file frame
        self._single_frame = ttk.LabelFrame(frm, text="Single File Upload", padding=6)
        self._single_frame.pack(fill=tk.X, pady=(0, 4))

        ttk.Label(self._single_frame, text="Local file:", style="Control.TLabel").grid(
            row=0, column=0, sticky=tk.W
        )
        file_row = ttk.Frame(self._single_frame)
        file_row.grid(row=0, column=1, sticky=tk.EW, padx=4)
        ttk.Entry(file_row, textvariable=self._local_file_var, width=22).pack(
            side=tk.LEFT, fill=tk.X, expand=True
        )
        ttk.Button(
            file_row,
            text="Browse",
            command=self._browse_local_file,
            style="Accent.TButton",
            width=7,
        ).pack(side=tk.LEFT, padx=(2, 0))

        ttk.Label(self._single_frame, text="Target name:", style="Control.TLabel").grid(
            row=1, column=0, sticky=tk.W, pady=(4, 0)
        )
        ttk.Entry(self._single_frame, textvariable=self._target_name_var, width=28).grid(
            row=1, column=1, sticky=tk.EW, padx=4, pady=(4, 0)
        )
        ttk.Label(
            self._single_frame,
            text="Target: device path, e.g. code.py  or  lib/neng_scpi_base/ota_update.py",
            style="Small.TLabel",
        ).grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=(2, 0))
        self._single_frame.columnconfigure(1, weight=1)

        # Git deploy frame
        self._git_frame = ttk.LabelFrame(frm, text="Git Deploy", padding=6)
        # Initially hidden — shown when user selects Git mode

        # Device type selector
        dev_row = ttk.Frame(self._git_frame)
        dev_row.pack(fill=tk.X, pady=(0, 6))
        ttk.Label(dev_row, text="Device:", style="Control.TLabel").pack(side=tk.LEFT, padx=(0, 6))
        self._git_device_combo = ttk.Combobox(
            dev_row,
            textvariable=self._git_device_var,
            values=sorted(GitToDeviceMapper.DEVICE_PROFILES),
            state="readonly",
            width=16,
        )
        self._git_device_combo.pack(side=tk.LEFT)

        ttk.Label(
            self._git_frame, text="Deploys from project root via git diff.", style="Small.TLabel"
        ).pack(anchor=tk.W, pady=(0, 4))

        opt_row = ttk.Frame(self._git_frame)
        opt_row.pack(fill=tk.X)
        ttk.Checkbutton(opt_row, text="Dry run", variable=self._dry_run_var).pack(
            side=tk.LEFT, padx=(0, 12)
        )
        ttk.Checkbutton(opt_row, text="Force full deploy", variable=self._force_full_var).pack(
            side=tk.LEFT
        )

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

        self.progress_bar = ttk.Progressbar(
            frm, variable=self._progress_var, maximum=100, mode="determinate", length=300
        )
        self.progress_bar.pack(fill=tk.X, pady=(0, 4))

        label_row = ttk.Frame(frm)
        label_row.pack(fill=tk.X)
        ttk.Label(label_row, textvariable=self._progress_label_var, style="Control.TLabel").pack(
            side=tk.LEFT
        )
        ttk.Label(label_row, textvariable=self._speed_var, style="Value.TLabel").pack(side=tk.RIGHT)

    def _build_control_panel(self, parent: ttk.Frame) -> None:
        frm = ttk.LabelFrame(parent, text="Device Control", padding=6)
        frm.pack(fill=tk.X, pady=(0, 6))

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

        self.rollback_btn = ttk.Button(
            btn_row, text="Rollback", command=self._do_rollback, style="Yellow.TButton"
        )
        self.rollback_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 2))

        self.reboot_btn = ttk.Button(
            btn_row, text="Reboot", command=self._do_reboot, style="Red.TButton"
        )
        self.reboot_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 0))

        ttk.Checkbutton(frm, text="Auto-reboot after update", variable=self._auto_reboot_var).pack(
            anchor=tk.W
        )

        ttk.Label(
            frm,
            text="OTA requires WiFi/TCP connection.\nFor USB-only: device must have WiFi active.",
            style="Small.TLabel",
            justify=tk.LEFT,
        ).pack(anchor=tk.W, pady=(4, 0))

    def _build_log_panel(self, parent) -> None:
        frm = ttk.LabelFrame(parent, text="OTA Log", padding=4)
        frm.pack(fill=tk.X, padx=6, pady=(0, 4), side=tk.BOTTOM)

        scroll = ttk.Scrollbar(frm)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.log_text = tk.Text(
            frm,
            height=10,
            bg=_t()["console_bg"],
            fg=_t()["fg"],
            font=("Courier", 9),
            state=tk.DISABLED,
            wrap=tk.WORD,
            yscrollcommand=scroll.set,
        )
        self.log_text.pack(fill=tk.BOTH, expand=True)
        scroll.config(command=self.log_text.yview)

    # ================================================================
    # UI event handlers
    # ================================================================
    def _on_mode_change(self) -> None:
        if self._upload_mode.get() == "single":
            self._git_frame.pack_forget()
            self._single_frame.pack(fill=tk.X, pady=(0, 4))
        else:
            self._single_frame.pack_forget()
            self._git_frame.pack(fill=tk.X, pady=(0, 4))

    def _browse_local_file(self) -> None:
        path = filedialog.askopenfilename(
            title="Select firmware file",
            filetypes=[("Python files", "*.py"), ("All files", "*.*")],
        )
        if path:
            self._local_file_var.set(path)
            if not self._target_name_var.get():
                self._target_name_var.set(os.path.basename(path))

    # ================================================================
    # Network scanner
    # ================================================================

    def _start_scan(self) -> None:
        """Start (or stop) a background network scan for NEnG devices on port 5025."""
        if self._scan_running:
            if self._scan_stop_event:
                self._scan_stop_event.set()
            return
        self._scan_stop_event = threading.Event()
        self._scan_running = True
        self._scan_results.clear()
        self.scan_listbox.delete(0, tk.END)
        self.scan_btn.config(text="Stop", style="Red.TButton")
        self._scan_status_var.set("Detecting networks…")
        threading.Thread(target=self._scan_worker, daemon=True).start()

    def _scan_worker(self) -> None:
        """Background thread: two-stage scan (port check → *IDN? verify)."""
        stop = self._scan_stop_event
        try:
            from .device_scanner import check_port, get_all_local_networks

            networks = get_all_local_networks()
            if not networks:
                self.root.after(
                    0, lambda: self._scan_status_var.set("No networks detected — enter IP manually")
                )
                return

            # Filter large networks unless opted in
            if not self._scan_large_var.get():
                networks = [n for n in networks if IPv4Network(n, strict=False).prefixlen >= 24]
            if not networks:
                self.root.after(
                    0,
                    lambda: self._scan_status_var.set(
                        "Only large networks found — enable 'Large networks' or enter IP manually"
                    ),
                )
                return

            # Smallest network first for quicker first results
            networks.sort(key=lambda n: IPv4Network(n, strict=False).num_addresses)

            # Build flat host list across all networks
            all_hosts: list[str] = []
            for net in networks:
                if stop.is_set():
                    return
                all_hosts.extend(str(ip) for ip in IPv4Network(net, strict=False).hosts())

            total = len(all_hosts)
            n_nets = len(networks)
            self.root.after(
                0,
                lambda n=n_nets, t=total: self._scan_status_var.set(
                    f"Scanning {n} network(s), {t} hosts on port 5025…"
                ),
            )

            # Stage 1 — concurrent port-5025 check
            open_ips: list[str] = []
            checked = [0]

            with concurrent.futures.ThreadPoolExecutor(max_workers=100) as ex:
                future_to_ip = {ex.submit(check_port, ip, 5025, 0.4): ip for ip in all_hosts}
                for fut in concurrent.futures.as_completed(future_to_ip):
                    if stop.is_set():
                        for f in future_to_ip:
                            f.cancel()
                        break
                    ip = future_to_ip[fut]
                    checked[0] += 1
                    if checked[0] % 25 == 0:
                        pct = checked[0] / total * 100
                        self.root.after(
                            0, lambda p=pct: self._scan_status_var.set(f"Port scan: {p:.0f}%…")
                        )
                    with contextlib.suppress(Exception):
                        if fut.result():
                            open_ips.append(ip)

            if stop.is_set():
                count = len(self._scan_results)
                self.root.after(
                    0,
                    lambda n=count: self._scan_status_var.set(
                        f"Scan stopped — {n} device(s) found" if n else "Scan stopped"
                    ),
                )
                return

            if not open_ips:
                self.root.after(
                    0, lambda: self._scan_status_var.set("No devices found on port 5025")
                )
                return

            # Stage 2 — SCPI *IDN? verification
            n_open = len(open_ips)
            self.root.after(
                0,
                lambda n=n_open: self._scan_status_var.set(f"Verifying {n} device(s)…"),
            )
            for ip in open_ips:
                if stop.is_set():
                    break
                idn = _verify_scpi_device(ip)
                if idn:
                    self._scan_results.append((ip, idn))
                    parts = idn.split(",")
                    model = parts[1].strip() if len(parts) > 1 else idn[:18]
                    item = f"{ip:<16} {model}"
                    self.root.after(0, lambda s=item: self.scan_listbox.insert(tk.END, s))

            count = len(self._scan_results)
            if stop.is_set():
                self.root.after(
                    0,
                    lambda n=count: self._scan_status_var.set(
                        f"Scan stopped — {n} device(s) found" if n else "Scan stopped"
                    ),
                )
            else:
                self.root.after(
                    0,
                    lambda n=count: self._scan_status_var.set(
                        f"Found {n} NEnG device(s)" if n else "No NEnG devices responded to *IDN?"
                    ),
                )
        except Exception as e:
            self.root.after(0, lambda err=e: self._scan_status_var.set(f"Scan error: {err}"))
        finally:
            self._scan_running = False
            self.root.after(
                0, lambda: self.scan_btn.config(text="Scan", style="Accent.TButton")
            )

    def _on_connect_selected(self) -> None:
        """Connect to the device selected in the scan listbox."""
        sel = self.scan_listbox.curselection()
        if not sel:
            messagebox.showinfo("No Selection", "Select a device from the scan list first.")
            return
        idx = sel[0]
        if idx >= len(self._scan_results):
            return
        ip, idn = self._scan_results[idx]
        self._log(f"Connecting to scanned device: {ip}  ({idn})")
        # Switch connection panel to WiFi mode with the discovered IP
        self.conn_panel.conn_type_var.set("WiFi")
        self.conn_panel._on_mode_changed()
        self.conn_panel.wifi_ip_var.set(ip)
        self.conn_panel._connect()

    # ================================================================
    # Connection callbacks (called 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
        self._device_idn = idn

        if instr.connection_type == "tcp" and instr.wifi_host:
            self._device_host = instr.wifi_host
        else:
            self._device_host = ""

        conn_type = instr.connection_type.upper()
        self.root.after(0, lambda: self._set_status(f"Connected ({conn_type}) — {idn}"))
        self.root.after(0, lambda: self._info_vars["idn"].set(idn[:40] if idn else "---"))

        # Auto-select Git Deploy device profile from *IDN? model name
        self.root.after(0, lambda: self._auto_select_device(idn))

        threading.Thread(target=self._refresh_device_info, daemon=True).start()

    def _auto_select_device(self, idn: str) -> None:
        """Pre-select the Git Deploy device type based on the *IDN? response."""
        # *IDN? format: "NEnG,<Model>,SN,FW"  — match model against known profiles
        idn_upper = idn.upper()
        for device in GitToDeviceMapper.DEVICE_PROFILES:
            if device.upper() in idn_upper:
                self._git_device_var.set(device)
                self._log(f"Auto-detected device profile: {device}")
                return

    def _on_panel_disconnected(self) -> None:
        """Called on main thread after disconnection."""
        self.instr = None
        self.connected = False
        self._device_host = ""
        self._set_status("Disconnected")
        for var in self._info_vars.values():
            var.set("---")

    # ================================================================
    # Device info refresh
    # ================================================================
    def _refresh_device_info(self) -> None:
        """Query device info fields in a background thread."""
        if not self.connected or not self.instr:
            return

        try:
            with self._scpi_lock:
                ip = self.instr.query(":WIFI:IP?")
            if ip:
                ip = ip.strip()
                if not self._device_host:
                    self._device_host = ip
                self.root.after(0, lambda v=ip: self._info_vars["ip"].set(v))
        except Exception:
            self.root.after(0, lambda: self._info_vars["ip"].set(self._device_host or "---"))

        try:
            with self._scpi_lock:
                git = self.instr.query(":SYST:VERS:GIT?")
            if git:
                git = git.strip()
                self.root.after(0, lambda v=git: self._info_vars["git"].set(v[:20]))
        except Exception:
            self.root.after(0, lambda: self._info_vars["git"].set("not supported"))

        try:
            with self._scpi_lock:
                info = self.instr.query(":SYST:VERS:INFO?")
            if info:
                info = info.strip()
                self.root.after(0, lambda v=info: self._info_vars["info"].set(v[:40]))
        except Exception:
            self.root.after(0, lambda: self._info_vars["info"].set("---"))

        self._log("Device info refreshed")

    # ================================================================
    # OTA operations
    # ================================================================
    def _get_ota_host(self) -> str | None:
        """Return the device IP for OTA, or None with an error dialog."""
        if self._device_host:
            return self._device_host

        if self.connected and self.instr:
            try:
                with self._scpi_lock:
                    ip = self.instr.query(":WIFI:IP?")
                if ip and ip.strip() not in ("", "0.0.0.0", "None"):
                    self._device_host = ip.strip()
                    return self._device_host
            except Exception:
                pass

        messagebox.showerror(
            "No WiFi IP",
            "Could not determine device WiFi IP address.\n\n"
            "OTA requires a WiFi/TCP connection.\n"
            "Connect via WiFi, or connect via USB and ensure the device has WiFi active.",
        )
        return None

    def _start_update(self) -> None:
        if self._ota_running:
            self._log("OTA already in progress")
            return

        if not self.connected or not self.instr:
            messagebox.showerror("Not Connected", "Please connect to a device first.")
            return

        host = self._get_ota_host()
        if not host:
            return

        password = self._password_var.get().strip() or None
        algo = self._algo_var.get()
        auto_reboot = self._auto_reboot_var.get()
        mode = self._upload_mode.get()

        if mode == "single":
            local_path = self._local_file_var.get().strip()
            target = self._target_name_var.get().strip()
            if not local_path:
                messagebox.showerror("No File", "Please select a local file to upload.")
                return
            if not target:
                messagebox.showerror("No Target", "Please enter the target filename on the device.")
                return

        self._ota_running = True
        self.start_btn.config(state=tk.DISABLED)
        self._progress_var.set(0.0)
        self._progress_label_var.set("Starting…")
        self._speed_var.set("")
        self._log("=" * 60)
        self._log(f"OTA update started — mode={mode}, host={host}, algo={algo}")

        def _worker():
            writer = _GUIWriter(self._log, self._parse_progress_line)
            try:
                with contextlib.redirect_stdout(writer):
                    client = OTAClient(host=host, port=5025, password=password)
                    client.connect()  # OTAClient.connect() prints "Connected to …" via stdout

                    if mode == "single":
                        self.root.after(
                            0, lambda: self._progress_label_var.set(f"Uploading {target}…")
                        )
                        success = client.upload_file(
                            target_filename=target,
                            local_path=local_path,
                            algo=algo,
                            auto_reboot=False,
                            verbose=True,
                        )
                    else:
                        device = self._git_device_var.get()
                        self.root.after(
                            0, lambda d=device: self._progress_label_var.set(f"Git deploy ({d})…")
                        )
                        success = deploy_from_git(
                            client=client,
                            force_full=self._force_full_var.get(),
                            dry_run=self._dry_run_var.get(),
                            auto_reboot=False,
                            algo=algo,
                            auto_confirm=True,
                            device=device,
                        )

                    if success and auto_reboot and not self._dry_run_var.get():
                        self._log("Rebooting device…")
                        client.reboot_device()

                    client.close()

                    status = "completed successfully" if success else "failed"
                    self._log(f"OTA {status}")
                    self.root.after(0, lambda s=status: self._progress_label_var.set(f"OTA {s}"))
                    if success:
                        self.root.after(0, lambda: self._progress_var.set(100.0))

            except Exception as e:
                writer.flush()
                self._log(f"OTA error: {e}")
                self.root.after(0, lambda: self._progress_label_var.set("OTA failed"))
            finally:
                writer.flush()
                self.root.after(0, self._on_ota_done)

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

    def _on_ota_done(self) -> None:
        self._ota_running = False
        self.start_btn.config(state=tk.NORMAL)
        self._speed_var.set("")

    def _parse_progress_line(self, line: str) -> None:
        """Parse OTAClient progress output and update the progress bar."""
        m = re.search(r"(\d+)/(\d+)\s*bytes.*?(\d+\.\d+)%.*?@\s*([\d.]+)\s*KB/s", line)
        if m:
            pct = float(m.group(3))
            speed = float(m.group(4))
            self.root.after(0, lambda p=pct, s=speed: self._update_progress(p, s))
            return

        m2 = re.search(r"\[(\d+)/(\d+)\]\s+(\S+)", line)
        if m2:
            idx = int(m2.group(1))
            total = int(m2.group(2))
            fname = m2.group(3)
            pct = (idx / total) * 100.0
            self.root.after(
                0,
                lambda p=pct, i=idx, t=total, f=fname: (
                    self._progress_var.set(p),
                    self._progress_label_var.set(f"File {i}/{t}: {f}"),
                ),
            )

    def _update_progress(self, pct: float, speed_kbs: float) -> None:
        self._progress_var.set(pct)
        self._speed_var.set(f"{speed_kbs:.1f} KB/s")
        self._progress_label_var.set(f"Uploading… {pct:.0f}%")

    def _do_rollback(self) -> None:
        if not self.connected or not self.instr:
            messagebox.showerror("Not Connected", "Please connect to a device first.")
            return
        if self._ota_running:
            self._log("Cannot rollback while OTA is in progress")
            return
        if not messagebox.askyesno(
            "Confirm Rollback",
            "Roll back to the previous firmware version?\n\nThe device will reboot.",
        ):
            return

        def _worker():
            try:
                with self._scpi_lock:
                    resp = self.instr.query(":SYST:UPD:ROLLBACK")
                self._log(f"Rollback: {resp}")
            except Exception as e:
                self._log(f"Rollback error: {e}")

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

    def _do_reboot(self) -> None:
        if not self.connected or not self.instr:
            messagebox.showerror("Not Connected", "Please connect to a device first.")
            return
        if self._ota_running:
            self._log("Cannot reboot while OTA is in progress")
            return

        def _worker():
            try:
                with self._scpi_lock:
                    self.instr.write(":SYST:RES")
                self._log("Reboot command sent — device restarting…")
            except Exception as e:
                self._log(f"Reboot: {e}")

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

    # ================================================================
    # Theme toggle
    # ================================================================
    def _toggle_theme(self) -> None:
        global _current_theme
        _current_theme = "light" if _current_theme == "dark" else "dark"
        t = _t()
        self.theme_btn.config(text="Moon Dark" if _current_theme == "light" else "Sun Light")
        self._apply_ttk_theme()
        self.root.configure(bg=t["bg"])
        self.log_text.config(bg=t["console_bg"], fg=t["fg"])
        self.scan_listbox.config(bg=t["list_bg"], fg=t["fg"], selectbackground=t["list_select"])

    # ================================================================
    # Helpers
    # ================================================================
    def _log(self, msg: str) -> None:
        """Append a line to the log (thread-safe)."""

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

        self.root.after(0, _add)

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

    def _on_close(self) -> None:
        if self._ota_running and not messagebox.askyesno(
            "OTA in Progress", "An OTA update is running. Exit anyway?"
        ):
            return
        if self.instr:
            with contextlib.suppress(Exception):
                self.instr.close()
        self.root.destroy()
