"""CustomTkinter GUI for DslrDirector based on templates/index.html.

This module implements a modern dark desktop GUI using customtkinter.
It mirrors the structure and behavior of the web UI in templates/index.html
without using PySimpleGUI. The communication status is shown in a persistent
top-right panel and is polled frequently for responsive updates.

If customtkinter is unavailable the module will attempt to fall back to the
standard tkinter controls with a simpler visual style; the user is encouraged
to "python -m pip install customtkinter" for the intended appearance.
"""

from __future__ import annotations

import importlib
import sys
import json
import os
import threading
import time
from typing import Optional, Any, Dict
import tkinter as _tk
try:
    # colorchooser is a submodule of tkinter
    from tkinter import colorchooser as _colorchooser
except Exception:
    _colorchooser = None

try:
    import customtkinter as ctk
    CTK_AVAILABLE = True
except Exception:
    import tkinter as ctk  # type: ignore
    from tkinter import ttk  # type: ignore
    CTK_AVAILABLE = False

REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
DATA_DIR = os.path.join(REPO_ROOT, 'data')
os.makedirs(DATA_DIR, exist_ok=True)
GUI_LOG = os.path.join(DATA_DIR, 'gui_start.log')


def _write_gui_log(msg: str) -> None:
    try:
        with open(GUI_LOG, 'a', encoding='utf-8') as f:
            f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
    except Exception:
        pass


# Prefer the app-managed BleComm if available so there is a single running
# BLE manager. Fall back to importing src.BleComm as a module.
appmod = None
BleComm = None
try:
    import src.app as appmod  # type: ignore
    BleComm = getattr(appmod, 'BleComm', None)
except Exception:
    appmod = None
    BleComm = None

if BleComm is None:
    try:
        BleComm = importlib.import_module('src.BleComm')
    except Exception:
        BleComm = None


# Persistence helpers (small, tolerant)
CONFIG_PATH = os.path.join(DATA_DIR, 'config.json')
COMM_PATH = os.path.join(DATA_DIR, 'comm.json')


def load_animation() -> Dict[str, Any]:
    try:
        if not os.path.exists(CONFIG_PATH):
            return {}
        with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
            data = json.load(f) or {}
        return data.get('animation-events', {})
    except Exception:
        return {}


def save_animation(ae: Dict[str, Any]) -> bool:
    try:
        base = {}
        if os.path.exists(CONFIG_PATH):
            try:
                with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
                    base = json.load(f) or {}
            except Exception:
                base = {}
        base['animation-events'] = ae
        with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
            json.dump(base, f, indent=2)
        return True
    except Exception:
        return False


def load_comm() -> Dict[str, Any]:
    try:
        if not os.path.exists(COMM_PATH):
            return {}
        with open(COMM_PATH, 'r', encoding='utf-8') as f:
            return json.load(f) or {}
    except Exception:
        return {}


def save_comm(cfg: Dict[str, Any]) -> bool:
    try:
        with open(COMM_PATH, 'w', encoding='utf-8') as f:
            json.dump(cfg, f, indent=2)
        return True
    except Exception:
        return False


class DSLRGui(ctk.CTk if CTK_AVAILABLE else ctk.Tk):
    def __init__(self) -> None:
        if CTK_AVAILABLE:
            ctk.set_appearance_mode('dark')
            ctk.set_default_color_theme('dark-blue')
            super().__init__()
        else:
            super().__init__()

        self.title('DSLR Director')
        self.geometry('980x640')
        try:
            self.iconbitmap(False)
        except Exception:
            pass

        # top-right connection status overlay
        self._status_frame = None
        self._status_dot = None
        self._status_text = None
        self._status_last = None
        # tray icon state
        self._tray_icon = None
        self._tray_thread = None
        self._tray_image = None

        # central frames
        self._build_ui()

        # internal state
        self._last_status: Optional[Dict[str, Any]] = None
        self._poll_interval_ms = 1000  # frequent polling for responsiveness
        _write_gui_log('GUI: initialized (customtkinter)')
        # start polling
        try:
            self.after(200, self.poll_status)
        except Exception:
            pass

        # Disable maximize button on Windows and hook minimize->tray behavior
        try:
            self._disable_maximize_button()
        except Exception as e:
            _write_gui_log(f'_disable_maximize_button exception: {e}')
        # Bind unmap (minimize) and map (restore) events for tray behavior
        try:
            try:
                self.bind('<Unmap>', self._on_unmap)
            except Exception as e:
                _write_gui_log(f'bind <Unmap> failed: {e}')
            try:
                self.bind('<Map>', self._on_map)
            except Exception as e:
                _write_gui_log(f'bind <Map> failed: {e}')
            # Ensure cleanup on close
            try:
                self.protocol('WM_DELETE_WINDOW', self._on_close)
            except Exception as e:
                _write_gui_log(f'protocol WM_DELETE_WINDOW failed: {e}')
        except Exception as e:
            _write_gui_log(f'Tray binding setup failed: {e}')

    def _build_ui(self) -> None:
        # root grid
        container = ctk.CTkFrame(self) if CTK_AVAILABLE else ctk.Frame(self, bg='#111')

        def _disable_maximize_button(self) -> None:
            """Attempt to disable the maximize box on Windows. Fall back to
            disabling resizing if platform APIs aren't available."""
            try:
                if sys.platform.startswith('win'):
                    import ctypes
                    GWL_STYLE = -16
                    WS_MAXIMIZEBOX = 0x00010000
                    hwnd = self.winfo_id()
                    style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_STYLE)
                    style &= ~WS_MAXIMIZEBOX
                    ctypes.windll.user32.SetWindowLongW(hwnd, GWL_STYLE, style)
                    # refresh window frame
                    SWP_NOSIZE = 0x0001
                    SWP_NOMOVE = 0x0002
                    SWP_NOZORDER = 0x0004
                    SWP_FRAMECHANGED = 0x0020
                    ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED)
                    return
            except Exception:
                pass
            # Generic fallback: make window non-resizable which effectively disables maximize
            try:
                self.resizable(False, False)
            except Exception:
                pass

        def _on_unmap(self, event=None) -> None:
            """Called when the window is minimized (unmap). Attempt to send to tray."""
            try:
                # Attempt to create tray icon and hide the window. Some window managers
                # may not report 'iconic' reliably, so we proactively try to minimize-to-tray.
                _write_gui_log(f'_on_unmap called; state={getattr(self, "state", lambda: "")( )}')
                created = self._create_tray_icon()
                if created:
                    try:
                        self.withdraw()
                    except Exception:
                        pass
            except Exception as e:
                _write_gui_log(f'_on_unmap failed: {e}')

        def _on_map(self, event=None) -> None:
            """Called when the window is mapped (restored). Ensure tray icon removed."""
            try:
                _write_gui_log('_on_map called; restoring window')
                try:
                    # stop tray icon if present
                    self._stop_tray_icon()
                except Exception:
                    pass
                try:
                    # ensure visible
                    self.deiconify()
                    self.lift()
                except Exception:
                    pass
            except Exception as e:
                _write_gui_log(f'_on_map failed: {e}')

        def _create_tray_icon(self) -> bool:
            """Create a system tray icon using pystray if available. Returns True if a tray was created."""
            if self._tray_icon is not None:
                return True
            try:
                import pystray
                from PIL import Image, ImageDraw
            except Exception:
                _write_gui_log('pystray or PIL not available; minimize-to-tray disabled')
                return False

            try:
                # create a simple icon image (transparent background with a small circle)
                img = Image.new('RGBA', (64, 64), (0, 0, 0, 0))
                draw = ImageDraw.Draw(img)
                draw.ellipse((8, 8, 56, 56), fill=(124, 58, 237, 255))
                self._tray_image = img

                def _on_restore(icon, item):
                    try:
                        self.after(0, self._restore_from_tray)
                    except Exception:
                        pass

                def _on_quit(icon, item):
                    try:
                        self.after(0, self._on_close)
                    except Exception:
                        pass

                menu = pystray.Menu(pystray.MenuItem('Restore', _on_restore), pystray.MenuItem('Quit', _on_quit))
                icon = pystray.Icon('dslr_director', img, 'DSLR Director', menu)
                self._tray_icon = icon

                def _run_icon():
                    try:
                        icon.run()
                    except Exception as e:
                        _write_gui_log(f'Tray icon run failed: {e}')

                t = threading.Thread(target=_run_icon, name='TrayThread', daemon=True)
                t.start()
                self._tray_thread = t
                _write_gui_log('Tray icon created')
                return True
            except Exception as e:
                _write_gui_log(f'Failed to create tray icon: {e}')
                self._tray_icon = None
                return False

        def _restore_from_tray(self) -> None:
            try:
                if self._tray_icon is not None:
                    try:
                        self._tray_icon.stop()
                    except Exception:
                        pass
                    self._tray_icon = None
                try:
                    self.deiconify()
                    self.lift()
                except Exception:
                    pass
                _write_gui_log('Restored from tray')
            except Exception as e:
                _write_gui_log(f'Restore from tray failed: {e}')

        def _stop_tray_icon(self) -> None:
            try:
                if self._tray_icon is not None:
                    try:
                        self._tray_icon.stop()
                    except Exception:
                        pass
                    self._tray_icon = None
                if self._tray_thread is not None:
                    try:
                        self._tray_thread.join(timeout=0.1)
                    except Exception:
                        pass
                    self._tray_thread = None
                _write_gui_log('Tray icon stopped')
            except Exception as e:
                _write_gui_log(f'Stop tray failed: {e}')

        def _on_close(self) -> None:
            try:
                # ensure tray is stopped and resources cleaned up
                try:
                    self._stop_tray_icon()
                except Exception:
                    pass
                try:
                    self.destroy()
                except Exception:
                    try:
                        self.quit()
                    except Exception:
                        pass
            except Exception as e:
                _write_gui_log(f'Close handler failed: {e}')
        container.pack(fill='both', expand=True, padx=18, pady=18)

        # Header: logo + title
        header = ctk.CTkFrame(container) if CTK_AVAILABLE else ctk.Frame(container, bg='#101314')
        header.pack(fill='x', padx=6, pady=(4, 12))

        left = ctk.CTkFrame(header) if CTK_AVAILABLE else ctk.Frame(header, bg='#101314')
        left.pack(side='left', anchor='w')

        # Logo image (use processed if present)
        logo_path = os.path.join(DATA_DIR, 'ata_logo_out.png')
        if not os.path.exists(logo_path):
            logo_path = os.path.join(REPO_ROOT, 'static', 'images', 'ata_logo.png')

        # Load logo image and show it to the left of the title. Prefer Pillow for
        # high-quality resizing; fall back to Tk's PhotoImage subsample if needed.
        lbl_logo = None
        self._logo_image = None
        try:
            target_size = (60, 60)
            img_path = logo_path
            # Try Pillow first
            try:
                from PIL import Image, ImageTk
                pil = Image.open(img_path).convert('RGBA')
                # fit and preserve aspect ratio
                pil.thumbnail(target_size, Image.LANCZOS)
                photo = ImageTk.PhotoImage(pil)
                self._logo_image = photo
                if CTK_AVAILABLE and hasattr(ctk, 'CTkImage'):
                    try:
                        # CTkImage accepts a PIL image or PhotoImage in newer CTk versions
                        ctki = ctk.CTkImage(light_image=pil, size=target_size)
                        lbl_logo = ctk.CTkLabel(left, image=ctki, text='')
                        # keep reference to CTkImage as well
                        self._ctk_logo_image = ctki
                    except Exception:
                        lbl_logo = ctk.CTkLabel(left, image=photo, text='')
                else:
                    lbl_logo = ctk.CTkLabel(left, image=photo, text='') if CTK_AVAILABLE else _tk.Label(left, image=photo, bd=0)
            except Exception:
                # Pillow not available or failed: try Tk.PhotoImage directly
                try:
                    tkimg = _tk.PhotoImage(file=img_path)
                    # subsample if image larger than target
                    w = tkimg.width()
                    h = tkimg.height()
                    subs_w = max(1, int(w / target_size[0]))
                    subs_h = max(1, int(h / target_size[1]))
                    subs = max(subs_w, subs_h)
                    if subs > 1:
                        try:
                            tkimg = tkimg.subsample(subs, subs)
                        except Exception:
                            pass
                    self._logo_image = tkimg
                    lbl_logo = ctk.CTkLabel(left, image=tkimg, text='') if CTK_AVAILABLE else _tk.Label(left, image=tkimg, bd=0)
                except Exception:
                    # last resort: text placeholder
                    lbl_logo = ctk.CTkLabel(left, text='') if CTK_AVAILABLE else _tk.Label(left, text='')
        except Exception:
            lbl_logo = ctk.CTkLabel(left, text='') if CTK_AVAILABLE else _tk.Label(left, text='')
        try:
            lbl_logo.pack(side='left')
        except Exception:
            pass

        title_col = ctk.CTkFrame(header) if CTK_AVAILABLE else ctk.Frame(header, bg='#101314')
        title_col.pack(side='left', padx=12)
        title = ctk.CTkLabel(title_col, text='DSLR Director', font=ctk.CTkFont(size=20, weight='bold')) if CTK_AVAILABLE else ctk.Label(title_col, text='DSLR Director', fg='#e6eef6', bg='#101314', font=('Segoe UI', 16, 'bold'))
        title.pack(anchor='w')
        lead = ctk.CTkLabel(title_col, text='Configure LED animations and communication.', text_color='#9aa6b2') if CTK_AVAILABLE else ctk.Label(title_col, text='Configure LED animations and communication.', fg='#9aa6b2', bg='#101314')
        lead.pack(anchor='w', pady=(4, 0))

        # comm status overlay on top-right
        status_frame = ctk.CTkFrame(self, fg_color='transparent') if CTK_AVAILABLE else ctk.Frame(self, bg='#0b0f14')
        status_frame.place(relx=0.98, x=-18, y=18, anchor='ne')
        self._status_frame = status_frame

        dot = ctk.CTkLabel(status_frame, text=' ', width=12, height=12, corner_radius=6, fg_color='#e55353') if CTK_AVAILABLE else ctk.Canvas(status_frame, width=12, height=12, bg='#0b0f14', highlightthickness=0)
        if not CTK_AVAILABLE:
            dot.create_oval(0, 0, 12, 12, fill='#e55353', outline='')
        dot.pack(side='left', padx=(6, 6))
        self._status_dot = dot

        st_lbl = ctk.CTkLabel(status_frame, text='Disconnected', text_color='#9aa6b2') if CTK_AVAILABLE else ctk.Label(status_frame, text='Disconnected', fg='#9aa6b2', bg='#0b0f14')
        st_lbl.pack(side='left')
        self._status_text = st_lbl

        st_last = ctk.CTkLabel(status_frame, text='', text_color='#9aa6b2') if CTK_AVAILABLE else ctk.Label(status_frame, text='', fg='#9aa6b2', bg='#0b0f14')
        st_last.pack(side='left', padx=(8, 6))
        self._status_last = st_last

        # Main card area with tabs
        card = ctk.CTkFrame(container) if CTK_AVAILABLE else ctk.Frame(container, bg='#0f1720')
        card.pack(fill='both', expand=True)

        if CTK_AVAILABLE:
            tabview = ctk.CTkTabview(card)
            tabview.pack(fill='both', expand=True, padx=12, pady=12)
            tabview.add('Animations')
            tabview.add('Communication')
            anim_frame = tabview.tab('Animations')
            comm_frame = tabview.tab('Communication')
        else:
            # simple fallback using ttk.Notebook
            try:
                nb = ttk.Notebook(card)  # type: ignore
                anim_frame = ttk.Frame(nb)
                comm_frame = ttk.Frame(nb)
                nb.add(anim_frame, text='Animations')
                nb.add(comm_frame, text='Communication')
                nb.pack(fill='both', expand=True)
            except Exception:
                anim_frame = ctk.Frame(card)  # type: ignore
                comm_frame = ctk.Frame(card)  # type: ignore

        # Animations tab content
        self._build_animations_tab(anim_frame)

        # Communication tab content
        self._build_comm_tab(comm_frame)

        # Footer note
        note = ctk.CTkLabel(card, text="Tip: Animation IDs are integers. Use 'Solid Color' to send a static color.", text_color='#9aa6b2') if CTK_AVAILABLE else ctk.Label(card, text="Tip: Animation IDs are integers. Use 'Solid Color' to send a static color.", fg='#9aa6b2', bg='#0f1720')
        note.pack(side='bottom', anchor='w', padx=12, pady=10)

    def _build_animations_tab(self, parent) -> None:
        anim = load_animation()
        frm = ctk.CTkFrame(parent) if CTK_AVAILABLE else ctk.Frame(parent)
        frm.pack(fill='both', expand=True, padx=12, pady=8)

        # Home
        row = ctk.CTkFrame(frm) if CTK_AVAILABLE else ctk.Frame(frm)
        row.pack(fill='x', pady=6)
        lbl = ctk.CTkLabel(row, text='Home Screen', width=180) if CTK_AVAILABLE else ctk.Label(row, text='Home Screen', width=18)
        lbl.pack(side='left')
        self.home_mode = ctk.CTkOptionMenu(row, values=['animation', 'solid']) if CTK_AVAILABLE else ctk.Entry(row)
        try:
            self.home_mode.set(anim.get('home-state', 'animation'))
        except Exception:
            pass
        self.home_mode.pack(side='left', padx=8)
        self.home_id = ctk.CTkEntry(row, width=80) if CTK_AVAILABLE else ctk.Entry(row, width=10)
        self.home_id.insert(0, str(anim.get('home-anim', '')))
        self.home_id.pack(side='left', padx=8)
        # color button opens color chooser
        self.home_color_hex = anim.get('home-color', '#ffffff')
        if CTK_AVAILABLE:
            self.home_color = ctk.CTkButton(row, text='', width=44, height=30, fg_color=self.home_color_hex, command=lambda: self._choose_color('home'))
        else:
            self.home_color = ctk.Button(row, text='Color', command=lambda: self._choose_color('home'), bg=self.home_color_hex)
        self.home_color.pack(side='left', padx=8)
        # Test button - send static color command for home color
        try:
            test_home_btn = ctk.CTkButton(row, text='Test', command=lambda: self._test_color('home'), fg_color='#3b82f6') if CTK_AVAILABLE else ctk.Button(row, text='Test', command=lambda: self._test_color('home'))
            test_home_btn.pack(side='left', padx=6)
        except Exception:
            pass

        # Sharing
        row2 = ctk.CTkFrame(frm) if CTK_AVAILABLE else ctk.Frame(frm)
        row2.pack(fill='x', pady=6)
        # Use same fixed width as Home Screen label to align fields
        lbl2 = ctk.CTkLabel(row2, text='Sharing Screen', width=180) if CTK_AVAILABLE else ctk.Label(row2, text='Sharing Screen', width=18)
        lbl2.pack(side='left')
        self.share_mode = ctk.CTkOptionMenu(row2, values=['animation', 'solid']) if CTK_AVAILABLE else ctk.Entry(row2)
        try:
            if CTK_AVAILABLE:
                self.share_mode.set(anim.get('sharing-state', 'animation'))
        except Exception:
            pass
        try:
            self.share_mode.pack(side='left', padx=8)
        except Exception:
            pass
        self.share_id = ctk.CTkEntry(row2, width=80) if CTK_AVAILABLE else ctk.Entry(row2, width=10)
        try:
            self.share_id.insert(0, str(anim.get('sharing-anim', '')))
        except Exception:
            pass
        self.share_id.pack(side='left', padx=8)
        self.share_color_hex = anim.get('sharing-color', '#ffffff')
        if CTK_AVAILABLE:
            self.share_color = ctk.CTkButton(row2, text='', width=44, height=30, fg_color=self.share_color_hex, command=lambda: self._choose_color('share'))
        else:
            self.share_color = ctk.Button(row2, text='Color', command=lambda: self._choose_color('share'), bg=self.share_color_hex)
        self.share_color.pack(side='left', padx=8)
        # Test button - send static color command for sharing color
        try:
            test_share_btn = ctk.CTkButton(row2, text='Test', command=lambda: self._test_color('share'), fg_color='#3b82f6') if CTK_AVAILABLE else ctk.Button(row2, text='Test', command=lambda: self._test_color('share'))
            test_share_btn.pack(side='left', padx=6)
        except Exception:
            pass

        # Save button
        save_btn = ctk.CTkButton(frm, text='Save Animations', command=self._save_animations, fg_color='#7c3aed') if CTK_AVAILABLE else ctk.Button(frm, text='Save Animations', command=self._save_animations)
        save_btn.pack(anchor='e', pady=(12, 0), padx=12)

    def _build_comm_tab(self, parent) -> None:
        # Prefer using the app's loader which normalizes keys; fall back to raw comm.json
        cfg = load_comm()
        try:
            if appmod is not None and hasattr(appmod, 'load_ble_config'):
                try:
                    ble_cfg = appmod.load_ble_config()
                    # ble_cfg is a BLEConfig instance (or similar); convert to simple dict
                    cfg = {
                        'ble': {
                            'address': getattr(ble_cfg, 'address', '') or getattr(ble_cfg, 'addr', ''),
                            'device-name': getattr(ble_cfg, 'device_name', '') or getattr(ble_cfg, 'device-name', '') or getattr(ble_cfg, 'device_name', ''),
                            'filter-name': getattr(ble_cfg, 'filter_name', '') or getattr(ble_cfg, 'filter-name', ''),
                            'auto-connect': getattr(ble_cfg, 'auto_connect', False) or getattr(ble_cfg, 'auto-connect', False)
                        }
                    }
                except Exception:
                    pass
        except Exception:
            pass
        # Debug: write loaded comm config to GUI log to aid diagnostics
        try:
            _write_gui_log(f'Comm config loaded for UI: {cfg}')
        except Exception:
            pass
        frm = ctk.CTkFrame(parent) if CTK_AVAILABLE else ctk.Frame(parent)
        frm.pack(fill='both', expand=True, padx=12, pady=8)

        # ID
        row = ctk.CTkFrame(frm) if CTK_AVAILABLE else ctk.Frame(frm)
        row.pack(fill='x', pady=6)
        lbl = ctk.CTkLabel(row, text='ID', width=180) if CTK_AVAILABLE else ctk.Label(row, text='ID', width=18)
        lbl.pack(side='left')
        self.comm_id = ctk.CTkEntry(row, width=360) if CTK_AVAILABLE else ctk.Entry(row, width=40)
        # support multiple shapes of comm config (top-level or nested under 'ble')
        comm_id = ''
        try:
            if isinstance(cfg, dict):
                # top-level keys
                comm_id = cfg.get('id') or cfg.get('address') or cfg.get('addr') or comm_id
                # ble section (accept snake_case and kebab-case)
                ble = cfg.get('ble') if isinstance(cfg.get('ble'), dict) else {}
                if ble:
                    comm_id = comm_id or ble.get('address') or ble.get('addr') or ble.get('id') or ble.get('device_name') or ble.get('device-name') or ''
        except Exception:
            comm_id = ''
        # If app provides a normalized loader, prefer its values directly
        try:
            if appmod is not None and hasattr(appmod, 'load_ble_config'):
                try:
                    ble_cfg = appmod.load_ble_config()
                    val_addr = getattr(ble_cfg, 'address', None) or getattr(ble_cfg, 'addr', None) or ''
                    if val_addr:
                        self.comm_id.delete(0, 'end')
                        self.comm_id.insert(0, val_addr)
                    else:
                        self.comm_id.insert(0, comm_id or '')
                except Exception:
                    try:
                        self.comm_id.insert(0, comm_id or '')
                    except Exception:
                        pass
            else:
                self.comm_id.insert(0, comm_id or '')
        except Exception:
            pass
        self.comm_id.pack(side='left', padx=8)

        # Device Name
        row2 = ctk.CTkFrame(frm) if CTK_AVAILABLE else ctk.Frame(frm)
        row2.pack(fill='x', pady=6)
        lbl2 = ctk.CTkLabel(row2, text='Device Name', width=180) if CTK_AVAILABLE else ctk.Label(row2, text='Device Name', width=18)
        lbl2.pack(side='left')
        self.comm_name = ctk.CTkEntry(row2, width=360) if CTK_AVAILABLE else ctk.Entry(row2, width=40)
        comm_name = ''
        try:
            if isinstance(cfg, dict):
                comm_name = cfg.get('name') or cfg.get('device_name') or cfg.get('device-name') or comm_name
                ble = cfg.get('ble') if isinstance(cfg.get('ble'), dict) else {}
                if ble:
                    comm_name = comm_name or ble.get('device_name') or ble.get('device-name') or ble.get('name') or ''
        except Exception:
            comm_name = ''
        try:
            if appmod is not None and hasattr(appmod, 'load_ble_config'):
                try:
                    ble_cfg = appmod.load_ble_config()
                    val_name = getattr(ble_cfg, 'device_name', None) or getattr(ble_cfg, 'device-name', None) or getattr(ble_cfg, 'deviceName', None) or ''
                    if val_name:
                        self.comm_name.delete(0, 'end')
                        self.comm_name.insert(0, val_name)
                    else:
                        self.comm_name.insert(0, comm_name or '')
                except Exception:
                    try:
                        self.comm_name.insert(0, comm_name or '')
                    except Exception:
                        pass
            else:
                self.comm_name.insert(0, comm_name or '')
        except Exception:
            pass
        self.comm_name.pack(side='left', padx=8)

        # Filter + Scan
        row3 = ctk.CTkFrame(frm) if CTK_AVAILABLE else ctk.Frame(frm)
        row3.pack(fill='x', pady=6)
        lbl3 = ctk.CTkLabel(row3, text='Filter', width=180) if CTK_AVAILABLE else ctk.Label(row3, text='Filter', width=18)
        lbl3.pack(side='left')
        self.comm_filter = ctk.CTkEntry(row3, width=240) if CTK_AVAILABLE else ctk.Entry(row3, width=24)
        comm_filter = ''
        try:
            if isinstance(cfg, dict):
                comm_filter = cfg.get('filter') or cfg.get('filter_name') or cfg.get('filter-name') or comm_filter
                ble = cfg.get('ble') if isinstance(cfg.get('ble'), dict) else {}
                if ble:
                    comm_filter = comm_filter or ble.get('filter_name') or ble.get('filter-name') or ble.get('filter') or ''
        except Exception:
            comm_filter = ''
        try:
            if appmod is not None and hasattr(appmod, 'load_ble_config'):
                try:
                    ble_cfg = appmod.load_ble_config()
                    val_filter = getattr(ble_cfg, 'filter_name', None) or getattr(ble_cfg, 'filter-name', None) or ''
                    if val_filter:
                        self.comm_filter.delete(0, 'end')
                        self.comm_filter.insert(0, val_filter)
                    else:
                        self.comm_filter.insert(0, comm_filter or '')
                except Exception:
                    try:
                        self.comm_filter.insert(0, comm_filter or '')
                    except Exception:
                        pass
            else:
                self.comm_filter.insert(0, comm_filter or '')
        except Exception:
            pass
        self.comm_filter.pack(side='left', padx=8)
        scan_btn = ctk.CTkButton(row3, text='Scan', command=self._open_scan_modal, fg_color='#7c3aed') if CTK_AVAILABLE else ctk.Button(row3, text='Scan', command=self._open_scan_modal)
        scan_btn.pack(side='left', padx=8)

        # Auto connect
        row4 = ctk.CTkFrame(frm) if CTK_AVAILABLE else ctk.Frame(frm)
        row4.pack(fill='x', pady=6)
        lbl4 = ctk.CTkLabel(row4, text='Auto Connect', width=180) if CTK_AVAILABLE else ctk.Label(row4, text='Auto Connect', width=18)
        lbl4.pack(side='left')
        self.comm_auto = ctk.CTkCheckBox(row4, text='') if CTK_AVAILABLE else ctk.Checkbutton(row4)
        # set auto flag from either top-level or 'ble' section
        try:
            auto_flag = False
            if isinstance(cfg, dict):
                auto_flag = bool(cfg.get('auto') or cfg.get('auto_connect') or cfg.get('auto-connect') or False)
                ble = cfg.get('ble') if isinstance(cfg.get('ble'), dict) else {}
                if ble:
                    auto_flag = auto_flag or bool(ble.get('auto') or ble.get('auto_connect') or ble.get('auto-connect') or False)
            if auto_flag:
                try:
                    # CTk and tk checkbox both support select() in this usage
                    self.comm_auto.select()
                except Exception:
                    pass
        except Exception:
            pass
        self.comm_auto.pack(side='left', padx=8)

        # Connect toggle
        row5 = ctk.CTkFrame(frm) if CTK_AVAILABLE else ctk.Frame(frm)
        row5.pack(fill='x', pady=6)
        lbl5 = ctk.CTkLabel(row5, text='Action', width=180) if CTK_AVAILABLE else ctk.Label(row5, text='Action', width=18)
        lbl5.pack(side='left')
        self.comm_toggle = ctk.CTkButton(row5, text='Connect', command=self._toggle_connect, fg_color='#2f313a') if CTK_AVAILABLE else ctk.Button(row5, text='Connect', command=self._toggle_connect)
        self.comm_toggle.pack(side='left', padx=8)
        # initialize toggle label and status from runtime BLE status if available
        try:
            runtime = self._get_runtime_ble()
            st = {'connected': False}
            if runtime is not None and hasattr(runtime, 'get_status'):
                try:
                    st = runtime.get_status() or {'connected': False}
                except Exception as e:
                    _write_gui_log(f'poll init status error from runtime: {e}')
                    st = {'connected': False}

            # fallback to importing module directly
            if not st.get('connected'):
                try:
                    mod = importlib.import_module('src.BleComm')
                    if hasattr(mod, 'get_status'):
                        st = mod.get_status() or {'connected': False}
                except Exception:
                    pass

            # Always apply status to UI to avoid stale displays
            try:
                if st.get('connected'):
                    try:
                        self.comm_toggle.configure(text='Disconnect')
                    except Exception:
                        try:
                            self.comm_toggle.config(text='Disconnect')
                        except Exception:
                            pass
                    if CTK_AVAILABLE:
                        try:
                            self._status_dot.configure(fg_color='#28a745')
                            self._status_text.configure(text='Connected')
                        except Exception:
                            pass
                    else:
                        try:
                            self._status_text.config(text='Connected')
                        except Exception:
                            pass
                else:
                    try:
                        self.comm_toggle.configure(text='Connect')
                    except Exception:
                        try:
                            self.comm_toggle.config(text='Connect')
                        except Exception:
                            pass
                    if CTK_AVAILABLE:
                        try:
                            self._status_dot.configure(fg_color='#e55353')
                            self._status_text.configure(text='Disconnected')
                        except Exception:
                            pass
                    else:
                        try:
                            self._status_text.config(text='Disconnected')
                        except Exception:
                            pass
            except Exception:
                pass
        except Exception:
            # tolerate any errors while initializing runtime status
            pass

    def _save_animations(self) -> None:
        try:
            ae = {}
            try:
                ae['home-state'] = self.home_mode.get() if hasattr(self.home_mode, 'get') else 'animation'
            except Exception:
                ae['home-state'] = 'animation'
            ae['home-anim'] = int(self.home_id.get()) if self.home_id.get().strip() else 0
            ae['sharing-state'] = self.share_mode.get() if hasattr(self.share_mode, 'get') else 'animation'
            ae['sharing-anim'] = int(self.share_id.get()) if self.share_id.get().strip() else 0
            # include chosen colors if present
            try:
                ae['home-color'] = getattr(self, 'home_color_hex', '')
            except Exception:
                pass
            try:
                ae['sharing-color'] = getattr(self, 'share_color_hex', '')
            except Exception:
                pass
            ok = save_animation(ae)
            _write_gui_log(f'Animations saved: {ok}')
            # Also update the running app's in-memory LEDAnimations so changes
            # take effect immediately without restarting the server/GUI.
            try:
                target_app = appmod
                # If we don't have appmod (maybe import failed earlier), try dynamic import
                if target_app is None:
                    try:
                        target_app = importlib.import_module('src.app')
                    except Exception:
                        target_app = None

                if target_app is not None:
                    # Prefer the app's persistence helper if available (it preserves other keys)
                    try:
                        if hasattr(target_app, 'persist_animation_events'):
                            try:
                                target_app.persist_animation_events(ae)
                            except Exception as e:
                                _write_gui_log(f'app.persist_animation_events failed: {e}')
                    except Exception:
                        pass

                    # Update the in-memory LEDAnimations under its lock so event handlers
                    # use the new values immediately.
                    try:
                        if hasattr(target_app, 'LEDAnimations') and hasattr(target_app, 'LEDAnimations_lock'):
                            try:
                                with target_app.LEDAnimations_lock:
                                    # Home
                                    try:
                                        if hasattr(target_app, 'parse_state'):
                                            target_app.LEDAnimations.home_state = target_app.parse_state(ae.get('home-state'))
                                        else:
                                            # best-effort: accept string 'animation'/'solid'
                                            hs = str(ae.get('home-state') or 'animation').strip().lower()
                                            target_app.LEDAnimations.home_state = getattr(target_app, 'LED_SHOW', None).ANIMATION if hs.startswith('anim') else getattr(target_app, 'LED_SHOW', None).STATIC_COLOR
                                    except Exception:
                                        pass
                                    try:
                                        if ae.get('home-color'):
                                            target_app.LEDAnimations.home_color = str(ae.get('home-color'))
                                    except Exception:
                                        pass
                                    try:
                                        target_app.LEDAnimations.home_anim = int(ae.get('home-anim') or 0)
                                    except Exception:
                                        pass

                                    # Sharing
                                    try:
                                        if hasattr(target_app, 'parse_state'):
                                            target_app.LEDAnimations.sharing_state = target_app.parse_state(ae.get('sharing-state'))
                                        else:
                                            ss = str(ae.get('sharing-state') or 'animation').strip().lower()
                                            target_app.LEDAnimations.sharing_state = getattr(target_app, 'LED_SHOW', None).ANIMATION if ss.startswith('anim') else getattr(target_app, 'LED_SHOW', None).STATIC_COLOR
                                    except Exception:
                                        pass
                                    try:
                                        if ae.get('sharing-color'):
                                            target_app.LEDAnimations.sharing_color = str(ae.get('sharing-color'))
                                    except Exception:
                                        pass
                                    try:
                                        target_app.LEDAnimations.sharing_anim = int(ae.get('sharing-anim') or 0)
                                    except Exception:
                                        pass
                            except Exception as e:
                                _write_gui_log(f'Failed updating in-memory LEDAnimations: {e}')
                            try:
                                _write_gui_log(f'Updated target_app.LEDAnimations id={id(target_app.LEDAnimations)} module={getattr(target_app, "__name__", "<unknown>")}')
                            except Exception:
                                pass
                    except Exception:
                        pass
                else:
                    _write_gui_log('No app module available to update in-memory LEDAnimations')
            except Exception as e:
                _write_gui_log(f'Failed to persist/update animations in app module: {e}')
            # Also attempt to update the module that may be running as __main__
            try:
                import sys as _sys
                main_mod = _sys.modules.get('__main__')
                if main_mod is not None and main_mod is not target_app:
                    try:
                        if hasattr(main_mod, 'LEDAnimations') and hasattr(main_mod, 'LEDAnimations_lock'):
                            try:
                                with main_mod.LEDAnimations_lock:
                                    try:
                                        if hasattr(main_mod, 'parse_state'):
                                            main_mod.LEDAnimations.home_state = main_mod.parse_state(ae.get('home-state'))
                                        else:
                                            hs = str(ae.get('home-state') or 'animation').strip().lower()
                                            main_mod.LEDAnimations.home_state = getattr(main_mod, 'LED_SHOW', None).ANIMATION if hs.startswith('anim') else getattr(main_mod, 'LED_SHOW', None).STATIC_COLOR
                                    except Exception:
                                        pass
                                    try:
                                        if ae.get('home-color'):
                                            main_mod.LEDAnimations.home_color = str(ae.get('home-color'))
                                    except Exception:
                                        pass
                                    try:
                                        main_mod.LEDAnimations.home_anim = int(ae.get('home-anim') or 0)
                                    except Exception:
                                        pass

                                    try:
                                        if hasattr(main_mod, 'parse_state'):
                                            main_mod.LEDAnimations.sharing_state = main_mod.parse_state(ae.get('sharing-state'))
                                        else:
                                            ss = str(ae.get('sharing-state') or 'animation').strip().lower()
                                            main_mod.LEDAnimations.sharing_state = getattr(main_mod, 'LED_SHOW', None).ANIMATION if ss.startswith('anim') else getattr(main_mod, 'LED_SHOW', None).STATIC_COLOR
                                    except Exception:
                                        pass
                                    try:
                                        if ae.get('sharing-color'):
                                            main_mod.LEDAnimations.sharing_color = str(ae.get('sharing-color'))
                                    except Exception:
                                        pass
                                    try:
                                        main_mod.LEDAnimations.sharing_anim = int(ae.get('sharing-anim') or 0)
                                    except Exception:
                                        pass
                            except Exception as e:
                                _write_gui_log(f'Failed updating __main__.LEDAnimations: {e}')
                                try:
                                    _write_gui_log(f'Updated __main__.LEDAnimations id={id(main_mod.LEDAnimations)} module={getattr(main_mod, "__name__", "__main__")}')
                                except Exception:
                                    pass
                    except Exception:
                        pass
            except Exception:
                pass
        except Exception as e:
            _write_gui_log(f'Failed to save animations: {e}')

    def _open_scan_modal(self) -> None:
        # modal Toplevel showing scan results
        try:
            top = ctk.CTkToplevel(self) if CTK_AVAILABLE else ctk.Toplevel(self)
            top.title('Scan Results')
            # desired modal size
            modal_w = 560
            modal_h = 360
            # center the modal relative to the main GUI window
            try:
                # ensure geometry info for parent is up-to-date
                self.update_idletasks()
                root_x = self.winfo_rootx()
                root_y = self.winfo_rooty()
                root_w = self.winfo_width()
                root_h = self.winfo_height()
                x = int(root_x + (root_w - modal_w) / 2)
                y = int(root_y + (root_h - modal_h) / 2)
                top.geometry(f"{modal_w}x{modal_h}+{x}+{y}")
            except Exception:
                try:
                    top.geometry(f"{modal_w}x{modal_h}")
                except Exception:
                    pass
            try:
                # make modal: keep on top, set transient to parent and grab input
                top.transient(self)
            except Exception:
                pass
            try:
                top.grab_set()
            except Exception:
                pass
            try:
                top.attributes('-topmost', True)
            except Exception:
                pass
            frm = ctk.CTkFrame(top) if CTK_AVAILABLE else ctk.Frame(top)
            frm.pack(fill='both', expand=True, padx=12, pady=12)
            filter_entry = ctk.CTkEntry(frm, width=360) if CTK_AVAILABLE else ctk.Entry(frm, width=40)
            filter_entry.pack(fill='x')

            # Use a selectable Listbox for results so the user can pick an item
            # easily. Keep scan_results (list of device dicts) so we can insert
            # the exact address for the selected device instead of parsing text.
            scan_results = []
            listbox_frame = _tk.Frame(frm)
            listbox_frame.pack(fill='both', expand=False, pady=8)
            scrollbar = _tk.Scrollbar(listbox_frame, orient='vertical')
            # Increase font size for readability
            try:
                import tkinter.font as tkfont
                listbox_font = tkfont.Font(size=12)
            except Exception:
                listbox_font = None

            listbox = _tk.Listbox(listbox_frame, width=80, height=12, yscrollcommand=scrollbar.set)
            try:
                if listbox_font is not None:
                    listbox.config(font=listbox_font)
            except Exception:
                pass
            scrollbar.config(command=listbox.yview)
            listbox.pack(side='left', fill='both', expand=True)
            scrollbar.pack(side='right', fill='y')

            def do_refresh():
                try:
                    listbox.delete(0, 'end')
                except Exception:
                    try:
                        listbox.delete('1.0', 'end')
                    except Exception:
                        pass

                def _scan():
                    # Worker thread: collect results and schedule UI update on main thread
                    try:
                        pref = filter_entry.get() if hasattr(filter_entry, 'get') else ''
                        pref_norm = (pref or '').strip().lower()
                        # Let the BleComm implementation do its own scanning; pass pref
                        # if it accepts a filter, but always apply a strict "starts
                        # with" filter in the UI so behavior is predictable.
                        res = BleComm.scan(pref) if BleComm is not None else []
                        devices = []
                        items = []
                        if not res:
                            items = ['No devices found']
                        else:
                            for d in res:
                                # Normalize name and address
                                name = (d.get('name') or d.get('device_name') or d.get('device-name') or '')
                                addr = d.get('address') or d.get('id') or d.get('addr') or ''
                                # If a filter is provided, only include devices whose
                                # name starts with the filter text (case-insensitive).
                                if pref_norm:
                                    if not name:
                                        # skip unnamed devices when a filter is active
                                        continue
                                    if not str(name).strip().lower().startswith(pref_norm):
                                        continue
                                # keep device
                                devices.append(d)
                                display_name = name or '<unknown>'
                                items.append(f"{display_name} — {addr}")
                            if not devices:
                                if pref_norm:
                                    items = [f'No devices found matching "{pref.strip()}"']
                                else:
                                    items = ['No devices found']
                    except Exception as e:
                        devices = []
                        items = [f'Scan failed: {e}']

                    def _update_ui():
                        try:
                            try:
                                listbox.delete(0, 'end')
                            except Exception:
                                try:
                                    listbox.delete('1.0', 'end')
                                except Exception:
                                    pass
                            scan_results.clear()
                            scan_results.extend(devices)
                            for it in items:
                                try:
                                    listbox.insert('end', it)
                                except Exception:
                                    pass
                        except Exception:
                            pass

                    try:
                        top.after(0, _update_ui)
                    except Exception:
                        # fallback: call directly (if top already destroyed this may fail)
                        try:
                            _update_ui()
                        except Exception:
                            pass

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

            def on_select():
                try:
                    # Prefer to read the index from the Listbox and use the
                    # corresponding device dict from scan_results to obtain the
                    # address. This avoids parsing the display text.
                    try:
                        sel = listbox.curselection()
                        if not sel:
                            return
                        idx = int(sel[0])
                    except Exception:
                        return

                    addr = ''
                    name = ''
                    try:
                        if 0 <= idx < len(scan_results):
                            d = scan_results[idx]
                            addr = d.get('address') or d.get('id') or d.get('addr') or ''
                            name = d.get('name') or d.get('device_name') or d.get('device-name') or ''
                    except Exception:
                        addr = ''
                        name = ''

                    if not addr:
                        try:
                            text = listbox.get(idx)
                            # fallback: take RHS of em-dash if present
                            addr = str(text).split('—')[-1].strip() if '—' in str(text) else str(text).strip()
                        except Exception:
                            addr = ''

                    # Populate both ID and Device Name fields when possible
                    try:
                        if hasattr(self.comm_id, 'delete'):
                            self.comm_id.delete(0, 'end')
                        if addr:
                            self.comm_id.insert(0, addr)
                    except Exception:
                        pass
                    try:
                        if hasattr(self.comm_name, 'delete'):
                            self.comm_name.delete(0, 'end')
                        if name:
                            self.comm_name.insert(0, name)
                    except Exception:
                        pass

                    try:
                        top.destroy()
                    except Exception:
                        pass
                except Exception:
                    try:
                        top.destroy()
                    except Exception:
                        pass

            # double-click to select
            try:
                listbox.bind('<Double-1>', lambda e: on_select())
            except Exception:
                pass

            refresh_btn = ctk.CTkButton(frm, text='Refresh', command=do_refresh, fg_color='#7c3aed') if CTK_AVAILABLE else ctk.Button(frm, text='Refresh', command=do_refresh)
            refresh_btn.pack(side='left', padx=6)
            close_btn = ctk.CTkButton(frm, text='Close', command=top.destroy) if CTK_AVAILABLE else ctk.Button(frm, text='Close', command=top.destroy)
            close_btn.pack(side='left', padx=6)
            select_btn = ctk.CTkButton(frm, text='Select', command=on_select) if CTK_AVAILABLE else ctk.Button(frm, text='Select', command=on_select)
            select_btn.pack(side='right', padx=6)

            do_refresh()
        except Exception as e:
            _write_gui_log(f'Open scan modal failed: {e}')

    def _toggle_connect(self) -> None:
        # optimistic UI update and call BleComm.connect/disconnect in background
        try:
            label = getattr(self.comm_toggle, 'cget', lambda k: 'Connect')('text') if CTK_AVAILABLE else self.comm_toggle.cget('text')
            is_connect = (label.lower().startswith('connect'))

            def _do():
                try:
                    address = self.comm_id.get() if hasattr(self.comm_id, 'get') else ''
                    device_name = self.comm_name.get() if hasattr(self.comm_name, 'get') else ''
                    if is_connect:
                        # Persist chosen BLE settings to comm.json so they survive restarts
                        try:
                            # determine auto flag from widget (best effort)
                            auto_flag = False
                            try:
                                if hasattr(self.comm_auto, 'get'):
                                    auto_flag = bool(self.comm_auto.get())
                                else:
                                    # CTk CheckBox may expose get(); otherwise try var
                                    auto_flag = bool(getattr(self.comm_auto, 'var', False))
                            except Exception:
                                auto_flag = False
                            try:
                                base = load_comm() or {}
                                ble = base.get('ble') if isinstance(base.get('ble'), dict) else {}
                                ble['address'] = address or ble.get('address') or ''
                                ble['device-name'] = device_name or ble.get('device-name') or ''
                                # also write legacy keys for robustness
                                #ble['addr'] = addr or ble.get('addr') or ''
                                #ble['device_name'] = name or ble.get('device_name') or ''
                                ble['filter-name'] = (self.comm_filter.get() if hasattr(self.comm_filter, 'get') else '') or ble.get('filter-name') or ble.get('filter_name') or ''
                                ble['auto-connect'] = bool(auto_flag)
                                base['ble'] = ble
                                save_comm(base)
                            except Exception as e:
                                _write_gui_log(f'Failed to persist comm config from GUI: {e}')
                        except Exception:
                            pass
                        if BleComm is not None and hasattr(BleComm, 'connect'):
                            res = BleComm.connect(address or device_name)
                            _write_gui_log(f'Connect result: {res}')
                    else:
                        if BleComm is not None and hasattr(BleComm, 'disconnect'):
                            res = BleComm.disconnect()
                            _write_gui_log(f'Disconnect result: {res}')
                except Exception as e:
                    _write_gui_log(f'Connect action failed: {e}')

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

            # optimistic UI change
            try:
                if is_connect:
                    self.comm_toggle.configure(text='Disconnect')
                    if CTK_AVAILABLE:
                        self._status_dot.configure(fg_color='#28a745')
                        self._status_text.configure(text='Connected')
                    else:
                        pass
                else:
                    self.comm_toggle.configure(text='Connect')
                    if CTK_AVAILABLE:
                        self._status_dot.configure(fg_color='#e55353')
                        self._status_text.configure(text='Disconnected')
            except Exception:
                pass
        except Exception as e:
            _write_gui_log(f'Toggle connect failed: {e}')

    def _choose_color(self, target: str) -> None:
        """Open a color chooser and apply the selected hex to the target button.

        target is 'home' or 'share'. Uses tkinter.colorchooser.askcolor.
        """
        try:
            if _colorchooser is not None and hasattr(_colorchooser, 'askcolor'):
                col = _colorchooser.askcolor(title='Pick a color')
            else:
                col = _tk.colorchooser.askcolor(title='Pick a color') if hasattr(_tk, 'colorchooser') else None
            if not col:
                return
            hexc = None
            try:
                hexc = col[1]
            except Exception:
                hexc = None
            if not hexc:
                return
            if target == 'home':
                self.home_color_hex = hexc
                try:
                    if CTK_AVAILABLE:
                        self.home_color.configure(fg_color=hexc)
                    else:
                        self.home_color.configure(bg=hexc)
                except Exception:
                    pass
            elif target == 'share':
                self.share_color_hex = hexc
                try:
                    if CTK_AVAILABLE:
                        self.share_color.configure(fg_color=hexc)
                    else:
                        self.share_color.configure(bg=hexc)
                except Exception:
                    pass
            _write_gui_log(f'Color chosen for {target}: {hexc}')
        except Exception as e:
            _write_gui_log(f'Color chooser failed: {e}')

    def _test_color(self, target: str) -> None:
        """Send a static-color LED command for the given target ('home' or 'share').

        This converts the selected hex color to RGB bytes and calls the
        app-level send_led_command wrapper when available so the app-managed
        BleComm is used. Falls back to calling BleComm.send_led_command.
        Runs in a background thread to avoid blocking the UI.
        """
        try:
            # pick hex from stored attributes
            hexc = None
            if target == 'home':
                hexc = getattr(self, 'home_color_hex', None)
            elif target == 'share':
                hexc = getattr(self, 'share_color_hex', None)
            if not hexc:
                _write_gui_log(f'Test color: no color set for {target}')
                return

            def _worker():
                try:
                    # convert #RRGGBB -> r,g,b ints
                    s = str(hexc).strip()
                    if s.startswith('#'):
                        s = s[1:]
                    if len(s) == 6:
                        r = int(s[0:2], 16)
                        g = int(s[2:4], 16)
                        b = int(s[4:6], 16)
                    else:
                        # fallback: try parsing shorter forms or raise
                        if len(s) == 3:
                            r = int(s[0]*2, 16)
                            g = int(s[1]*2, 16)
                            b = int(s[2]*2, 16)
                        else:
                            _write_gui_log(f'Test color: unsupported color format: {hexc}')
                            return

                    # Decide whether to send an animation command or a static-color
                    # command based on the UI mode for the target.
                    mode_val = ''
                    anim_id_val = None
                    try:
                        if target == 'home':
                            if hasattr(self.home_mode, 'get'):
                                mode_val = str(self.home_mode.get()).strip().lower()
                            # animation id
                            try:
                                anim_id_val = int(self.home_id.get()) if hasattr(self.home_id, 'get') and str(self.home_id.get()).strip() else None
                            except Exception:
                                anim_id_val = None
                        else:
                            if hasattr(self.share_mode, 'get'):
                                mode_val = str(self.share_mode.get()).strip().lower()
                            try:
                                anim_id_val = int(self.share_id.get()) if hasattr(self.share_id, 'get') and str(self.share_id.get()).strip() else None
                            except Exception:
                                anim_id_val = None
                    except Exception:
                        mode_val = ''

                    # Determine command constants (prefer app-level constants)
                    CMD_SET_STATIC = None
                    CMD_SET_ANIM = None
                    try:
                        if appmod is not None:
                            CMD_SET_STATIC = getattr(appmod, 'CMD_SET_STATIC_COLOR', None)
                            CMD_SET_ANIM = getattr(appmod, 'CMD_SET_ANIMATION', None)
                    except Exception:
                        CMD_SET_STATIC = CMD_SET_STATIC or None
                        CMD_SET_ANIM = CMD_SET_ANIM or None
                    if CMD_SET_STATIC is None or CMD_SET_ANIM is None:
                        try:
                            am = importlib.import_module('src.app')
                            CMD_SET_STATIC = CMD_SET_STATIC or getattr(am, 'CMD_SET_STATIC_COLOR', None)
                            CMD_SET_ANIM = CMD_SET_ANIM or getattr(am, 'CMD_SET_ANIMATION', None)
                        except Exception:
                            pass
                    if CMD_SET_STATIC is None:
                        CMD_SET_STATIC = 0x1E
                    if CMD_SET_ANIM is None:
                        CMD_SET_ANIM = 0x2C

                    # Prepare the actual send arguments depending on mode
                    send_args = None
                    send_cmd = None
                    if mode_val and mode_val.startswith('anim'):
                        # animation mode: need an animation id
                        if anim_id_val is None:
                            _write_gui_log(f'Test color: animation id missing for {target}')
                            return
                        send_args = (int(anim_id_val), 0, 0)
                        send_cmd = CMD_SET_ANIM
                    else:
                        # static color
                        send_args = (r, g, b)
                        send_cmd = CMD_SET_STATIC

                    # prefer using app's send_led_command wrapper for consistent module instance
                    ok = False
                    try:
                        if appmod is not None and hasattr(appmod, 'send_led_command'):
                            ok = appmod.send_led_command(send_args[0], send_args[1], send_args[2], send_cmd)
                        else:
                            try:
                                am = importlib.import_module('src.app')
                                if hasattr(am, 'send_led_command'):
                                    ok = am.send_led_command(send_args[0], send_args[1], send_args[2], send_cmd)
                            except Exception:
                                ok = False
                    except Exception:
                        ok = False

                    # fallback to BleComm directly if app wrapper not available
                    if not ok:
                        try:
                            if BleComm is not None and hasattr(BleComm, 'send_led_command'):
                                ok = BleComm.send_led_command(send_args[0], send_args[1], send_args[2], send_cmd)
                        except Exception:
                            ok = False

                    _write_gui_log(f'Test send for {target}: mode={mode_val} args={send_args} cmd={hex(send_cmd) if isinstance(send_cmd, int) else send_cmd} ok={ok}')
                except Exception as e:
                    _write_gui_log(f'Test color worker failed: {e}')

            threading.Thread(target=_worker, daemon=True).start()
        except Exception as e:
            _write_gui_log(f'Test color failed: {e}')

    def _get_runtime_ble(self):
        try:
            # Prefer the app-managed BleComm. If app is present, ensure its BleComm is started
            if appmod is not None:
                try:
                    if hasattr(appmod, 'ensure_ble_started'):
                        try:
                            appmod.ensure_ble_started()
                        except Exception:
                            pass
                    return getattr(appmod, 'BleComm', None) or BleComm
                except Exception:
                    return BleComm
            # If appmod not set, try importing app dynamically and ensure BleComm started
            try:
                am = importlib.import_module('src.app')
                if hasattr(am, 'ensure_ble_started'):
                    try:
                        am.ensure_ble_started()
                    except Exception:
                        pass
                return getattr(am, 'BleComm', None) or BleComm
            except Exception:
                return BleComm
        except Exception:
            return BleComm

    def poll_status(self) -> None:
        try:
            runtime = self._get_runtime_ble()
            st = {'connected': False}
            # Prefer the app-managed runtime BleComm when possible
            tried = []
            if runtime is not None and hasattr(runtime, 'get_status'):
                try:
                    st = runtime.get_status() or {'connected': False}
                    tried.append('appmod.BleComm')
                except Exception as e:
                    _write_gui_log(f'poll_status error from runtime: {e}')
                    st = {'connected': False}

            # If not connected, try importing the BleComm module directly (fallback to avoid stale refs)
            if not st.get('connected'):
                try:
                    mod = importlib.import_module('src.BleComm')
                    if hasattr(mod, 'get_status'):
                        st_mod = mod.get_status() or {'connected': False}
                        st = st_mod
                        tried.append('imported src.BleComm')
                except Exception as e:
                    _write_gui_log(f'poll_status import fallback failed: {e}')

            # update UI if changed
            if st != self._last_status:
                self._last_status = st
                try:
                    if st.get('connected'):
                        if CTK_AVAILABLE:
                            self._status_dot.configure(fg_color='#28a745')
                            self._status_text.configure(text='Connected')
                        else:
                            self._status_text.config(text='Connected')
                    else:
                        if CTK_AVAILABLE:
                            self._status_dot.configure(fg_color='#e55353')
                            self._status_text.configure(text='Disconnected')
                        else:
                            self._status_text.config(text='Disconnected')

                    if st.get('last_connected_ts'):
                        ts = st.get('last_connected_ts')
                        try:
                            d = None
                            # try parsing ISO string first, then epoch float/int
                            from datetime import datetime
                            if isinstance(ts, (int, float)):
                                d = datetime.fromtimestamp(float(ts))
                            else:
                                try:
                                    d = datetime.fromisoformat(str(ts))
                                except Exception:
                                    try:
                                        d = datetime.fromtimestamp(float(ts))
                                    except Exception:
                                        d = None
                            if d is not None:
                                self._status_last.configure(text='Last: ' + d.strftime('%Y-%m-%d %H:%M:%S'))
                            else:
                                self._status_last.configure(text='')
                        except Exception:
                            try:
                                self._status_last.configure(text='')
                            except Exception:
                                pass
                except Exception:
                    pass

            # schedule next poll
        except Exception as e:
            _write_gui_log(f'poll_status top error: {e}')
        finally:
            try:
                self.after(self._poll_interval_ms, self.poll_status)
            except Exception:
                pass


if __name__ == '__main__':
    _write_gui_log('GUI: starting custom GUI')
    try:
        app = DSLRGui()
        try:
            import signal

            signal.signal(signal.SIGINT, lambda s, f: app.destroy())
        except Exception:
            pass
        app.mainloop()
    except Exception as e:
        _write_gui_log(f'GUI main failed: {e}')
