"""Reusable ConnectionPanel widget for NEnG GUIs.

A self-contained ttk.LabelFrame that provides:
  - USB / WiFi radio-button mode selection
  - WiFi IP entry + "🔍 Discover" button (auto-detects IP via USB or network scan)
  - USB serial-port combobox + refresh button
  - Optional ⚡ Connect / ⏏ Disconnect buttons with full connection lifecycle

Usage — with connect buttons (PID / WiFi-config GUIs):
    panel = ConnectionPanel(
        parent,
        default="WiFi",
        on_connected=self._on_connected,   # called from worker thread: (instr, idn)
        on_disconnected=self._on_disconnected,  # called on main thread
        on_log=self._console_log,
    )
    panel.pack(fill=tk.X)

Usage — mode + entry only, no buttons (Logger GUI):
    panel = ConnectionPanel(
        parent,
        default="USB",
        show_buttons=False,
        on_log=self._add_info,
        label="Connection:",
    )
    panel.pack(fill=tk.X)
    prefer_wifi, wifi_host, port = panel.get_connection_params()
    panel.set_enabled(False)   # disable while logging
    panel.set_enabled(True)    # re-enable when done

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

from __future__ import annotations

import contextlib
import glob
import platform
import threading
import time
import tkinter as tk
from tkinter import ttk
from typing import Callable

from neng_scpi_tools.myserial.scpi_serial import SCPISerial
from neng_scpi_tools.myserial.scpi_universal import SCPIUniversal

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


def _is_valid_ip(s: str) -> bool:
    """Return True if *s* looks like a valid dotted-quad IPv4 address."""
    parts = s.split(".")
    if len(parts) != 4:
        return False
    for p in parts:
        try:
            n = int(p)
            if n < 0 or n > 255:
                return False
        except ValueError:
            return False
    return True


def scan_serial_ports() -> list[str]:
    """Return a list of available USB serial ports, starting with 'Auto-detect'."""
    ports: list[str] = ["Auto-detect"]
    try:
        import serial.tools.list_ports  # type: ignore

        for port in serial.tools.list_ports.comports():
            dev = port.device
            if (
                dev.startswith("/dev/cu.usbmodem")
                or dev.startswith("/dev/cu.usbserial")
                or dev.startswith("/dev/ttyUSB")
                or dev.startswith("/dev/ttyACM")
                or dev.startswith("COM")
            ):
                ports.append(dev)
    except Exception:
        system = platform.system()
        if system == "Darwin":
            ports.extend(glob.glob("/dev/cu.usbmodem*"))
            ports.extend(glob.glob("/dev/cu.usbserial*"))
        elif system == "Linux":
            ports.extend(glob.glob("/dev/ttyUSB*"))
            ports.extend(glob.glob("/dev/ttyACM*"))
        elif system == "Windows":
            ports.extend([f"COM{i}" for i in range(1, 9)])

    # Deduplicate while preserving order
    seen: set[str] = set()
    unique: list[str] = []
    for p in ports:
        if p not in seen:
            seen.add(p)
            unique.append(p)
    return unique


# ---------------------------------------------------------------------------
# ConnectionPanel widget
# ---------------------------------------------------------------------------


class ConnectionPanel(ttk.LabelFrame):
    """Reusable connection panel: mode radio buttons, IP/port entry, optional Connect/Disconnect.

    Parameters
    ----------
    parent
        Parent Tk widget.
    default : {"USB", "WiFi"}
        Which mode is selected on startup.
    label : str
        Text for the mode-row label (e.g. ``"Mode:"`` or ``"Connection:"``).
    show_buttons : bool
        When *True* (default) the panel includes ⚡ Connect / ⏏ Disconnect buttons
        and manages the full connection lifecycle via callbacks.
        When *False* the panel only renders the mode + IP/port rows; the host GUI
        calls :meth:`get_connection_params` and manages the connection itself.
    on_connected : callable(instr, idn) | None
        Invoked **from the worker thread** immediately after a successful connection.
        Receives the live :class:`SCPIUniversal` instance and the ``*IDN?`` response.
        Schedule any Tk updates with ``root.after(0, …)``.
    on_disconnected : callable() | None
        Invoked **on the main thread** after disconnection.
    on_log : callable(str) | None
        Receives human-readable status / error messages.  May be called from any
        thread.
    """

    def __init__(
        self,
        parent,
        *,
        default: str = "USB",
        label: str = "Mode:",
        show_buttons: bool = True,
        on_connected: Callable[[SCPIUniversal, str], None] | None = None,
        on_disconnected: Callable[[], None] | None = None,
        on_log: Callable[[str], None] | None = None,
        **kwargs,
    ) -> None:
        kwargs.setdefault("text", "Connection")
        kwargs.setdefault("padding", 6)
        super().__init__(parent, **kwargs)

        self._on_connected_cb = on_connected
        self._on_disconnected_cb = on_disconnected
        self._on_log_cb = on_log

        self.instr: SCPIUniversal | None = None
        self.connected: bool = False
        self._discovering: bool = False

        self._build(default=default, label=label, show_buttons=show_buttons)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def get_connection_params(self) -> tuple[bool, str | None, str | None]:
        """Return ``(prefer_wifi, wifi_host, usb_port)`` from the current UI state.

        ``wifi_host`` is *None* when USB mode is active or the IP field is empty.
        ``usb_port`` is *None* for "Auto-detect".
        """
        if self.conn_type_var.get() == "WiFi":
            host = self.wifi_ip_var.get().strip() or None
            return True, host, None
        else:
            pv = self.port_var.get()
            return False, None, (None if pv == "Auto-detect" else pv)

    def set_enabled(self, enabled: bool) -> None:
        """Enable or disable all interactive widgets inside the panel."""
        state = tk.NORMAL if enabled else tk.DISABLED
        ro_state = "readonly" if enabled else tk.DISABLED
        for w in (self.usb_radio, self.wifi_radio, self.wifi_entry, self.discover_btn):
            self._safe_config(w, state=state)
        self._safe_config(self.port_combo, state=ro_state)
        self._safe_config(self.refresh_btn, state=state)

    # ------------------------------------------------------------------
    # Build
    # ------------------------------------------------------------------

    def _build(self, default: str, label: str, show_buttons: bool) -> None:
        # Row 0 – mode row: label + USB radio + WiFi radio (packed side by side)
        conn_row = ttk.Frame(self)
        conn_row.grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(4, 2))
        ttk.Label(conn_row, text=label, style="Control.TLabel").pack(side=tk.LEFT, padx=(0, 8))
        self.conn_type_var = tk.StringVar(value=default)
        self.usb_radio = ttk.Radiobutton(
            conn_row,
            text="USB",
            variable=self.conn_type_var,
            value="USB",
            command=self._on_mode_changed,
        )
        self.usb_radio.pack(side=tk.LEFT, padx=(0, 4))
        self.wifi_radio = ttk.Radiobutton(
            conn_row,
            text="WiFi",
            variable=self.conn_type_var,
            value="WiFi",
            command=self._on_mode_changed,
        )
        self.wifi_radio.pack(side=tk.LEFT)

        # Row 1 – detail row: WiFi and USB sub-frames swap in/out via pack
        detail_frame = ttk.Frame(self)
        detail_frame.grid(row=1, column=0, columnspan=3, sticky=tk.EW, pady=(0, 2))

        self.wifi_row = ttk.Frame(detail_frame)
        ttk.Label(self.wifi_row, text="WiFi IP:", style="Control.TLabel").pack(
            side=tk.LEFT, padx=(0, 8)
        )
        self.wifi_ip_var = tk.StringVar(value="")
        self.wifi_entry = ttk.Entry(self.wifi_row, textvariable=self.wifi_ip_var, width=16)
        self.wifi_entry.pack(side=tk.LEFT, padx=(0, 4))
        self.discover_btn = ttk.Button(
            self.wifi_row,
            text="🔍 Discover",
            command=self._discover_wifi_ip,
            width=10,
        )
        self.discover_btn.pack(side=tk.LEFT)

        self.usb_row = ttk.Frame(detail_frame)
        ttk.Label(self.usb_row, text="Port:", style="Control.TLabel").pack(
            side=tk.LEFT, padx=(0, 8)
        )
        self.port_var = tk.StringVar(value="Auto-detect")
        self.port_combo = ttk.Combobox(
            self.usb_row,
            textvariable=self.port_var,
            values=scan_serial_ports(),
            state="readonly",
            width=20,
        )
        self.port_combo.pack(side=tk.LEFT, padx=(0, 4))
        self.refresh_btn = ttk.Button(
            self.usb_row,
            text="↻",
            command=self._refresh_ports,
            width=2,
        )
        self.refresh_btn.pack(side=tk.LEFT)

        # Show the default row
        if default == "WiFi":
            self.wifi_row.pack(fill=tk.X)
        else:
            self.usb_row.pack(fill=tk.X)

        # Row 2 – Connect / Disconnect (optional)
        if show_buttons:
            btn_frm = ttk.Frame(self)
            btn_frm.grid(row=2, column=0, columnspan=3, pady=(6, 0), sticky=tk.EW)
            self.connect_btn = ttk.Button(
                btn_frm,
                text="⚡ Connect",
                command=self._connect,
                style="Green.TButton",
            )
            self.connect_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 2))
            self.disconnect_btn = ttk.Button(
                btn_frm,
                text="⏏ Disconnect",
                command=self._disconnect,
                style="Red.TButton",
                state=tk.DISABLED,
            )
            self.disconnect_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 0))
        else:
            self.connect_btn = None
            self.disconnect_btn = None

        self.columnconfigure(1, weight=1)

    # ------------------------------------------------------------------
    # Mode switch
    # ------------------------------------------------------------------

    def _on_mode_changed(self) -> None:
        if self.conn_type_var.get() == "WiFi":
            self.usb_row.pack_forget()
            self.wifi_row.pack(fill=tk.X)
        else:
            self.wifi_row.pack_forget()
            self.usb_row.pack(fill=tk.X)

    # ------------------------------------------------------------------
    # Port refresh
    # ------------------------------------------------------------------

    def _refresh_ports(self) -> None:
        ports = scan_serial_ports()
        self.port_combo.config(values=ports)
        if self.port_var.get() not in ports:
            self.port_var.set("Auto-detect")

    # ------------------------------------------------------------------
    # WiFi IP discovery
    # ------------------------------------------------------------------

    def _log(self, msg: str) -> None:
        if self._on_log_cb:
            self._on_log_cb(msg)

    def _discover_wifi_ip(self) -> None:
        if self._discovering:
            return
        self._discovering = True
        self._log("🔍 Discovering WiFi IP via USB…")
        self.discover_btn.config(state=tk.DISABLED, text="⏳…")

        def _do() -> None:
            serial_was_opened = False
            try:
                import serial as pyserial  # type: ignore
                import serial.tools.list_ports  # type: ignore

                usb_ports = [
                    p.device
                    for p in serial.tools.list_ports.comports()
                    if "usb" in p.device.lower()
                    or "acm" in p.device.lower()
                    or "usbmodem" in p.device.lower()
                    or "usbserial" in p.device.lower()
                    or "usb" in (p.hwid or "").lower()
                ]
                if not usb_ports:
                    self._log("❌ No USB ports found.")
                    return

                for port in usb_ports:
                    # Quick open/close to drain leftover buffer
                    try:
                        ser_test = pyserial.Serial(port, timeout=0.1)
                        serial_was_opened = True
                        while ser_test.in_waiting:
                            ser_test.read(ser_test.in_waiting)
                            time.sleep(0.05)
                        ser_test.close()
                    except pyserial.SerialException:
                        self._log(f"⚠ {port} busy (another app?). Skipping.")
                        continue

                    found = False
                    for attempt in range(3):
                        try:
                            with SCPISerial(port=port, timeout=1.5) as ser:
                                serial_was_opened = True
                                raw_ser = getattr(ser, "serial", None)
                                if raw_ser:
                                    time.sleep(0.1)
                                    while raw_ser.in_waiting:
                                        raw_ser.read(raw_ser.in_waiting)
                                        time.sleep(0.02)
                                resp = ser.query(":WIFI:CONNECTED?").strip()
                                if resp.upper() in ("1", "TRUE", "YES", "CONNECTED"):
                                    ip = ser.query(":WIFI:IP?").strip()
                                    if ip and ip != "0.0.0.0" and _is_valid_ip(ip):
                                        self.wifi_ip_var.set(ip)
                                        self._log(f"✓ WiFi IP: {ip}")
                                        found = True
                                        break
                                    elif ip:
                                        self._log(f"⚠ Attempt {attempt + 1}: garbled ({ip[:30]})")
                                else:
                                    self._log(f"⚠ {port}: WiFi not connected ({resp})")
                                    break  # no need to retry this port
                        except Exception as exc:
                            self._log(f"⚠ {port} attempt {attempt + 1}: {exc}")
                    if found:
                        return

                # USB discovery failed → try network scan.
                # If a serial port was opened the microcontroller may need a
                # moment before its TCP server is ready for new connections.
                if serial_was_opened:
                    time.sleep(1.0)

                self._log("🔍 USB discovery failed. Scanning network…")
                self._scan_network_for_device()

            finally:
                self._discovering = False
                self.after(0, lambda: self.discover_btn.config(state=tk.NORMAL, text="🔍 Discover"))

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

    # ------------------------------------------------------------------
    # Network scan helper (with retry)
    # ------------------------------------------------------------------

    def _scan_network_for_device(self, retries: int = 2, delay: float = 2.0) -> None:
        """Scan local networks for a NEnG device, retrying on failure.

        Parameters
        ----------
        retries : int
            Number of additional scan attempts if the first one finds nothing.
        delay : float
            Seconds to wait between retries.
        """
        try:
            import importlib

            # device_scanner is maintained exclusively in neng_wifi_tools.
            _mod = importlib.import_module("neng_wifi_tools.device_scanner")
            get_all_local_networks = getattr(_mod, "get_all_local_networks", None)
            scan_network_fn = getattr(_mod, "scan_network", None)

            if get_all_local_networks is None or scan_network_fn is None:
                raise ImportError("device_scanner not available in neng_wifi_tools")

            networks = get_all_local_networks()
            if not networks:
                self._log("⚠ No local networks detected.")
                self._log("❌ WiFi IP not found. Enter IP manually.")
                return

            for attempt in range(1 + retries):
                if attempt > 0:
                    self._log(f"   Retry {attempt}/{retries} in {delay:.0f}s…")
                    time.sleep(delay)

                self._log(f"   Scanning {len(networks)} network(s)…")
                devices: list[tuple[str, str]] = []
                for network in networks:
                    devices.extend(scan_network_fn(network=network, verbose=False))
                if devices:
                    ip = devices[0][0]
                    self.wifi_ip_var.set(ip)
                    if len(devices) == 1:
                        self._log(f"✓ Found device at: {ip}")
                    else:
                        self._log(f"✓ Found {len(devices)} devices; using first: {ip}")
                    return

            self._log("❌ No devices found on network.")
            self._log("❌ WiFi IP not found. Enter IP manually.")
        except Exception as e:
            self._log(f"⚠ Network scan error: {e}")
            self._log("❌ WiFi IP not found. Enter IP manually.")

    # ------------------------------------------------------------------
    # Connect / Disconnect  (only active when show_buttons=True)
    # ------------------------------------------------------------------

    def _connect(self) -> None:
        if self.connected:
            return
        self.connect_btn.config(state=tk.DISABLED, text="⏳ Connecting…")
        self._log("Connecting…")

        def _restore() -> None:
            self.after(0, lambda: self.connect_btn.config(state=tk.NORMAL, text="⚡ Connect"))

        def _do() -> None:
            try:
                prefer_wifi, wifi_host, port = self.get_connection_params()
                if prefer_wifi and not wifi_host:
                    self._log("❌ WiFi IP required")
                    _restore()
                    return

                self.instr = SCPIUniversal(
                    port=port,
                    wifi_host=wifi_host,
                    prefer_wifi=prefer_wifi,
                    timeout=3.0,
                    auto_connect=False,
                )
                self.instr.connect()
                idn = self.instr.query("*IDN?").strip()
                self._log(f"✓ Connected via {self.instr.connection_type.upper()}: {idn}")
                self.connected = True

                # Notify host GUI (still on worker thread — host may do more queries)
                if self._on_connected_cb:
                    self._on_connected_cb(self.instr, idn)

                # Update UI on main thread: lock settings widgets, flip buttons
                def _ui() -> None:
                    self.set_enabled(False)
                    self.connect_btn.config(state=tk.DISABLED, text="⚡ Connect")
                    self.disconnect_btn.config(state=tk.NORMAL)

                self.after(0, _ui)

            except Exception as e:
                self._log(f"❌ Connection failed: {e}")
                _restore()

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

    def _disconnect(self) -> None:
        if self.instr:
            with contextlib.suppress(Exception):
                self.instr.close()
            self.instr = None
        self.connected = False
        self.set_enabled(True)
        self.connect_btn.config(state=tk.NORMAL, text="⚡ Connect")
        self.disconnect_btn.config(state=tk.DISABLED)
        self._log("Disconnected.")
        if self._on_disconnected_cb:
            self._on_disconnected_cb()

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _safe_config(widget, **kwargs) -> None:
        with contextlib.suppress(Exception):
            widget.config(**kwargs)
