"""Icono propio y bandeja del sistema (junto al reloj) para la ventana del agente.

Lo usan las dos ventanas (`vixora_agente_app.py` y `vixora_agente_tk.py`). Va
solo con `ctypes` contra la API de Windows: nada que instalar. En macOS y
Linux todo esto no hace nada (`Bandeja.arrancar()` devuelve False y la ventana
se minimiza como siempre).

    identidad_app()            la barra de tareas enseña nuestro icono, no el de Python
    icono_ventana(hwnd)        icono de la barra de título y de la barra de tareas
    traer_al_frente(hwnd)      restaura la ventana y la pone delante
    Bandeja(...)               el icono junto al reloj, con su menú y sus avisos

La bandeja vive en un hilo propio con una ventana invisible que recibe los
clics. Lo que el usuario pide (`al_pulsar`, `al_elegir`) se llama desde ese
hilo: quien lo reciba debe pasarlo al hilo de su ventana.
"""

from __future__ import annotations

import atexit
import os
import threading
from collections.abc import Callable
from pathlib import Path
from typing import Any

AQUI = Path(__file__).resolve().parent
ICONO = AQUI / "icono.ico"
APP_ID = "VIXORA.AgenteEquipo"
ES_WINDOWS = os.name == "nt"

# Una opción del menú: (número, texto). `None` es una raya de separación.
Opcion = tuple[int, str] | None

if ES_WINDOWS:
    import ctypes
    from ctypes import wintypes

    # Instancias propias de las DLL: así los tipos que se declaran aquí no
    # pisan los de nadie más (pywebview también usa `ctypes.windll.user32`).
    _u32 = ctypes.WinDLL("user32", use_last_error=True)
    _s32 = ctypes.WinDLL("shell32", use_last_error=True)
    _k32 = ctypes.WinDLL("kernel32", use_last_error=True)

    _LRESULT = ctypes.c_ssize_t
    _WNDPROC = ctypes.WINFUNCTYPE(_LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM)

    class _WNDCLASSW(ctypes.Structure):
        _fields_ = [
            ("style", wintypes.UINT), ("lpfnWndProc", _WNDPROC), ("cbClsExtra", ctypes.c_int),
            ("cbWndExtra", ctypes.c_int), ("hInstance", wintypes.HINSTANCE), ("hIcon", wintypes.HICON),
            ("hCursor", wintypes.HANDLE), ("hbrBackground", wintypes.HANDLE),
            ("lpszMenuName", wintypes.LPCWSTR), ("lpszClassName", wintypes.LPCWSTR),
        ]

    class _NOTIFYICONDATAW(ctypes.Structure):
        _fields_ = [
            ("cbSize", wintypes.DWORD), ("hWnd", wintypes.HWND), ("uID", wintypes.UINT), ("uFlags", wintypes.UINT),
            ("uCallbackMessage", wintypes.UINT), ("hIcon", wintypes.HICON), ("szTip", wintypes.WCHAR * 128),
            ("dwState", wintypes.DWORD), ("dwStateMask", wintypes.DWORD), ("szInfo", wintypes.WCHAR * 256),
            ("uVersion", wintypes.UINT), ("szInfoTitle", wintypes.WCHAR * 64), ("dwInfoFlags", wintypes.DWORD),
            ("guidItem", ctypes.c_byte * 16), ("hBalloonIcon", wintypes.HICON),
        ]

    def _tipos(funcion: Any, resultado: Any, *argumentos: Any) -> None:
        funcion.restype = resultado
        funcion.argtypes = list(argumentos)

    _H, _U, _I, _S = wintypes.HWND, wintypes.UINT, ctypes.c_int, wintypes.LPCWSTR
    _tipos(_k32.GetModuleHandleW, wintypes.HMODULE, _S)
    _tipos(_s32.Shell_NotifyIconW, wintypes.BOOL, wintypes.DWORD, ctypes.POINTER(_NOTIFYICONDATAW))
    _tipos(_s32.SetCurrentProcessExplicitAppUserModelID, ctypes.c_long, _S)
    _tipos(_u32.RegisterClassW, wintypes.ATOM, ctypes.POINTER(_WNDCLASSW))
    _tipos(_u32.UnregisterClassW, wintypes.BOOL, _S, wintypes.HINSTANCE)
    _tipos(_u32.CreateWindowExW, _H, wintypes.DWORD, _S, _S, wintypes.DWORD, _I, _I, _I, _I, _H,
           wintypes.HMENU, wintypes.HINSTANCE, wintypes.LPVOID)
    _tipos(_u32.DefWindowProcW, _LRESULT, _H, _U, wintypes.WPARAM, wintypes.LPARAM)
    _tipos(_u32.DestroyWindow, wintypes.BOOL, _H)
    _tipos(_u32.IsWindow, wintypes.BOOL, _H)
    _tipos(_u32.PostQuitMessage, None, _I)
    _tipos(_u32.GetMessageW, _I, ctypes.POINTER(wintypes.MSG), _H, _U, _U)
    _tipos(_u32.TranslateMessage, wintypes.BOOL, ctypes.POINTER(wintypes.MSG))
    _tipos(_u32.DispatchMessageW, _LRESULT, ctypes.POINTER(wintypes.MSG))
    _tipos(_u32.PostMessageW, wintypes.BOOL, _H, _U, wintypes.WPARAM, wintypes.LPARAM)
    _tipos(_u32.SendMessageTimeoutW, _LRESULT, _H, _U, wintypes.WPARAM, wintypes.LPARAM, _U, _U,
           ctypes.POINTER(ctypes.c_size_t))
    _tipos(_u32.LoadImageW, wintypes.HANDLE, wintypes.HINSTANCE, _S, _U, _I, _I, _U)
    _tipos(_u32.LoadIconW, wintypes.HICON, wintypes.HINSTANCE, ctypes.c_void_p)
    _tipos(_u32.RegisterWindowMessageW, _U, _S)
    _tipos(_u32.CreatePopupMenu, wintypes.HMENU)
    _tipos(_u32.AppendMenuW, wintypes.BOOL, wintypes.HMENU, _U, ctypes.c_size_t, _S)
    _tipos(_u32.SetMenuDefaultItem, wintypes.BOOL, wintypes.HMENU, _U, _U)
    _tipos(_u32.TrackPopupMenu, wintypes.BOOL, wintypes.HMENU, _U, _I, _I, _I, _H, ctypes.c_void_p)
    _tipos(_u32.DestroyMenu, wintypes.BOOL, wintypes.HMENU)
    _tipos(_u32.GetCursorPos, wintypes.BOOL, ctypes.POINTER(wintypes.POINT))
    _tipos(_u32.SetForegroundWindow, wintypes.BOOL, _H)
    _tipos(_u32.IsIconic, wintypes.BOOL, _H)
    _tipos(_u32.ShowWindow, wintypes.BOOL, _H, _I)
    _tipos(_u32.GetSystemMetrics, _I, _I)
    _tipos(_u32.AllowSetForegroundWindow, wintypes.BOOL, wintypes.DWORD)

WM_DESTROY, WM_CLOSE, WM_SETICON, WM_COMMAND, WM_NULL = 0x0002, 0x0010, 0x0080, 0x0111, 0x0000
WM_LBUTTONUP, WM_LBUTTONDBLCLK, WM_RBUTTONUP, WM_CONTEXTMENU = 0x0202, 0x0203, 0x0205, 0x007B
WM_BANDEJA = 0x8000 + 1      # WM_APP+1: lo que manda el icono (el clic va en lParam)
WM_PENDIENTE = 0x8000 + 2    # WM_APP+2: hay un texto o un aviso que aplicar
NIM_ADD, NIM_MODIFY, NIM_DELETE = 0, 1, 2
NIF_MESSAGE, NIF_ICON, NIF_TIP, NIF_INFO = 0x01, 0x02, 0x04, 0x10
NIIF_USER, NIIF_NOSOUND = 0x04, 0x10
ID_ICONO = 1

_iconos: dict[int, int] = {}  # tamaño → HICON (se cargan una vez y viven lo que el proceso)
_identidad_puesta = False


# ------------------------------------------------------------------ icono de la ventana


def identidad_app() -> bool:
    """Dice a Windows que esto es una aplicación propia. Sin esto la barra de
    tareas agrupa la ventana con Python y le pone el icono de Python."""
    global _identidad_puesta
    if not ES_WINDOWS:
        return False
    if not _identidad_puesta:
        try:
            _identidad_puesta = _s32.SetCurrentProcessExplicitAppUserModelID(APP_ID) == 0
        except Exception:  # noqa: BLE001
            return False
    return _identidad_puesta


def cargar_icono(tam: int) -> int:
    """El HICON de `icono.ico` a ese tamaño (0 si no se puede)."""
    if not ES_WINDOWS:
        return 0
    if tam not in _iconos:
        mango = 0
        try:
            if ICONO.is_file():
                mango = _u32.LoadImageW(None, str(ICONO), 1, tam, tam, 0x0010) or 0  # IMAGE_ICON, LR_LOADFROMFILE
        except Exception:  # noqa: BLE001
            mango = 0
        if not mango:
            return 0
        _iconos[tam] = int(mango)
    return _iconos[tam]


def icono_ventana(hwnd: int) -> bool:
    """Pone nuestro icono en la barra de título (pequeño) y en la barra de
    tareas y Alt+Tab (grande) de esa ventana."""
    if not ES_WINDOWS or not hwnd:
        return False
    try:
        chico = cargar_icono(_u32.GetSystemMetrics(49) or 16)   # SM_CXSMICON
        grande = cargar_icono(max(_u32.GetSystemMetrics(11) or 32, 48))  # SM_CXICON; 48 se ve mejor escalado
        if not (chico and grande):
            return False
        hecho = ctypes.c_size_t()
        for cual, mango in ((0, chico), (1, grande)):  # ICON_SMALL, ICON_BIG
            _u32.SendMessageTimeoutW(hwnd, WM_SETICON, cual, mango, 0x0002, 2000, ctypes.byref(hecho))
        return True
    except Exception:  # noqa: BLE001
        return False


def traer_al_frente(hwnd: int) -> None:
    """Si está minimizada la restaura, y la pone delante."""
    if not ES_WINDOWS or not hwnd:
        return
    try:
        if _u32.IsIconic(hwnd):
            _u32.ShowWindow(hwnd, 9)  # SW_RESTORE
        _u32.SetForegroundWindow(hwnd)
    except Exception:  # noqa: BLE001
        pass


def dejar_pasar_delante(pid: int) -> None:
    """Permite que ese otro proceso ponga su ventana delante (lo usa la segunda
    instancia antes de pedirle a la primera que se enseñe)."""
    if ES_WINDOWS and pid:
        try:
            _u32.AllowSetForegroundWindow(int(pid))
        except Exception:  # noqa: BLE001
            pass


# ------------------------------------------------------------------ bandeja


class Bandeja:
    """El icono junto al reloj.

    `al_pulsar()`: clic o doble clic con el botón izquierdo.
    `opciones()`: devuelve el menú del botón derecho, [(número, texto) | None];
    la primera opción sale en negrita. `al_elegir(número)`: lo que se eligió.
    Las tres se llaman desde el hilo de la bandeja: que no tarden.
    """

    def __init__(self, titulo: str, al_pulsar: Callable[[], None], opciones: Callable[[], list[Opcion]],
                 al_elegir: Callable[[int], None]) -> None:
        self._titulo = titulo
        self._al_pulsar, self._opciones, self._al_elegir = al_pulsar, opciones, al_elegir
        self._cerrojo = threading.Lock()
        self._lista = threading.Event()
        self._hilo: threading.Thread | None = None
        self._tip = titulo
        self._globo: tuple[str, str] | None = None
        self._nid: Any = None
        self._proc: Any = None
        self._reinicio = 0
        self.hwnd = 0
        self.activa = False                 # ¿está el icono puesto ahora mismo?
        self.resultados: dict[str, int] = {}  # lo último que contestó Shell_NotifyIcon a cada orden
        self.menu: list[Opcion] = []        # el último menú que se enseñó

    # --- desde cualquier hilo ----------------------------------------------
    def arrancar(self, espera: float = 6.0) -> bool:
        """Pone el icono. False si no es Windows o la bandeja no responde."""
        if not ES_WINDOWS:
            return False
        if self._hilo is None:
            self._hilo = threading.Thread(target=self._correr, name="vixora-bandeja", daemon=True)
            self._hilo.start()
            atexit.register(self.parar)
        self._lista.wait(espera)
        return self.activa

    def texto(self, tip: str) -> None:
        """El texto que sale al dejar el ratón encima."""
        with self._cerrojo:
            self._tip = (tip or self._titulo)[:127]
        self._avisar()

    def globo(self, titulo: str, texto: str) -> None:
        """Una notificación de Windows que sale del icono."""
        with self._cerrojo:
            self._globo = (titulo[:63], texto[:255])
        self._avisar()

    def parar(self) -> None:
        """Quita el icono y cierra el hilo. Se puede llamar varias veces."""
        hilo = self._hilo
        if not ES_WINDOWS or hilo is None:
            return
        if self.hwnd and hilo.is_alive():
            _u32.PostMessageW(self.hwnd, WM_CLOSE, 0, 0)
            if threading.current_thread() is not hilo:
                hilo.join(3)
        if self.activa:  # el hilo no llegó a quitarlo (proceso saliendo): se quita desde aquí
            self._quitar()

    def _avisar(self) -> None:
        if ES_WINDOWS and self.hwnd:
            _u32.PostMessageW(self.hwnd, WM_PENDIENTE, 0, 0)

    # --- en el hilo de la bandeja ------------------------------------------
    def _orden(self, nombre: str, orden: int) -> bool:
        ok = int(bool(_s32.Shell_NotifyIconW(orden, ctypes.byref(self._nid))))
        self.resultados[nombre] = ok
        return bool(ok)

    def _poner(self) -> bool:
        self._nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP
        with self._cerrojo:
            self._nid.szTip = self._tip
        self.activa = self._orden("poner", NIM_ADD)
        return self.activa

    def _quitar(self) -> None:
        if self._nid is not None and self.activa:
            self._nid.uFlags = 0
            self._orden("quitar", NIM_DELETE)
        self.activa = False

    def _pendiente(self) -> None:
        if not self.activa:
            return
        with self._cerrojo:
            tip, globo, self._globo = self._tip, self._globo, None
        if tip != self._nid.szTip:
            self._nid.uFlags = NIF_TIP
            self._nid.szTip = tip
            self._orden("texto", NIM_MODIFY)
        if globo:
            self._nid.uFlags = NIF_INFO
            self._nid.szInfoTitle, self._nid.szInfo = globo
            self._nid.dwInfoFlags = NIIF_USER | NIIF_NOSOUND
            self._orden("globo", NIM_MODIFY)

    def _menu(self) -> None:
        try:
            self.menu = list(self._opciones())
        except Exception:  # noqa: BLE001
            self.menu = []
        if not self.menu:
            return
        menu = _u32.CreatePopupMenu()
        try:
            for opcion in self.menu:
                if opcion is None:
                    _u32.AppendMenuW(menu, 0x0800, 0, None)  # MF_SEPARATOR
                else:
                    _u32.AppendMenuW(menu, 0x0000, opcion[0], opcion[1])  # MF_STRING
            primera = next((o for o in self.menu if o), None)
            if primera:
                _u32.SetMenuDefaultItem(menu, primera[0], 0)
            punto = wintypes.POINT()
            _u32.GetCursorPos(ctypes.byref(punto))
            # Sin esto el menú no se cierra al hacer clic fuera.
            _u32.SetForegroundWindow(self.hwnd)
            # La elección llega como WM_COMMAND a nuestra ventana.
            _u32.TrackPopupMenu(menu, 0x0002 | 0x0020, punto.x, punto.y, 0, self.hwnd, None)  # derecha, abajo
            _u32.PostMessageW(self.hwnd, WM_NULL, 0, 0)
        finally:
            _u32.DestroyMenu(menu)

    def _mensaje(self, hwnd: int, msg: int, wparam: int, lparam: int) -> int:
        try:
            if msg == WM_BANDEJA:
                clic = lparam & 0xFFFF
                if clic in (WM_LBUTTONUP, WM_LBUTTONDBLCLK):
                    self._al_pulsar()
                elif clic in (WM_RBUTTONUP, WM_CONTEXTMENU):
                    self._menu()
                return 0
            if msg == WM_COMMAND:
                self._al_elegir(wparam & 0xFFFF)
                return 0
            if msg == WM_PENDIENTE:
                self._pendiente()
                return 0
            if msg == self._reinicio and self._reinicio:  # el Explorador se reinició: volver a ponerlo
                self.activa = False
                self._poner()
                return 0
            if msg == WM_CLOSE:
                self._quitar()
                _u32.DestroyWindow(hwnd)
                return 0
            if msg == WM_DESTROY:
                _u32.PostQuitMessage(0)
                return 0
        except Exception:  # noqa: BLE001 — un fallo aquí no debe tumbar el hilo
            pass
        return _u32.DefWindowProcW(hwnd, msg, wparam, lparam)

    def _correr(self) -> None:
        clase = f"VixoraAgenteBandeja_{os.getpid()}_{id(self):x}"
        modulo = _k32.GetModuleHandleW(None)
        registrada = False
        try:
            self._proc = _WNDPROC(self._mensaje)  # guardado: si se recoge, Windows llamaría a la nada
            wc = _WNDCLASSW()
            wc.lpfnWndProc, wc.hInstance, wc.lpszClassName = self._proc, modulo, clase
            registrada = bool(_u32.RegisterClassW(ctypes.byref(wc)))
            if not registrada:
                return
            self._reinicio = _u32.RegisterWindowMessageW("TaskbarCreated")
            # Una ventana de verdad pero invisible (no «solo mensajes»): así le
            # llega el aviso de que el Explorador se reinició.
            self.hwnd = _u32.CreateWindowExW(0x00000080, clase, self._titulo, 0x80000000, 0, 0, 0, 0,
                                             None, None, modulo, None) or 0  # WS_EX_TOOLWINDOW, WS_POPUP
            if not self.hwnd:
                return
            nid = _NOTIFYICONDATAW()
            nid.cbSize = ctypes.sizeof(_NOTIFYICONDATAW)
            nid.hWnd, nid.uID, nid.uCallbackMessage = self.hwnd, ID_ICONO, WM_BANDEJA
            nid.hIcon = (cargar_icono(_u32.GetSystemMetrics(49) or 16)
                         or _u32.LoadIconW(None, ctypes.c_void_p(32512)))  # o el icono genérico de Windows
            self._nid = nid
            for _ in range(6):  # al encender el equipo la bandeja puede tardar en estar
                if self._poner():
                    break
                if self._lista.wait(0.5):
                    break
            self._lista.set()
            self._pendiente()
            msg = wintypes.MSG()
            while _u32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
                _u32.TranslateMessage(ctypes.byref(msg))
                _u32.DispatchMessageW(ctypes.byref(msg))
        except Exception:  # noqa: BLE001
            pass
        finally:
            try:
                self._quitar()
                if self.hwnd and _u32.IsWindow(self.hwnd):
                    _u32.DestroyWindow(self.hwnd)
                if registrada:
                    _u32.UnregisterClassW(clase, modulo)
            except Exception:  # noqa: BLE001
                pass
            self.hwnd = 0
            self._lista.set()
