#!/usr/bin/env python3
"""Ventana del agente del equipo de VIXORA.

Doble clic (o «Abrir agente.bat») abre una ventana que hace lo mismo que
`vixora_agente.py` en la consola —es el mismo programa, usado como librería— y
además deja configurarlo sin tocar archivos.

    python vixora_agente_app.py                abre la ventana y arranca el agente
    python vixora_agente_app.py --minimizado   igual, pero guardada en la bandeja (arranque con Windows)
    python vixora_agente_app.py --tk           fuerza la ventana sencilla (tkinter)
    python vixora_agente_app.py --seccion=configuracion   abre en esa sección

La ventana buena es la de HTML (`interfaz/`), que necesita `pywebview`
(`pip install pywebview`) y, en Windows, WebView2 (viene con Windows 10/11). Si
falta cualquiera de los dos se usa la de tkinter (`vixora_agente_tk.py`), que
enseña lo mismo.

En Windows la ventana tiene su icono en la bandeja del sistema, junto al reloj
(`vixora_agente_bandeja.py`): al cerrarla con el agente trabajando se guarda
ahí. Abrirla por segunda vez no abre otra: trae la que ya está.

Privacidad: aquí tampoco se ve ni se guarda el contenido de los trabajos; solo
el tipo, la duración y si salió bien.
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import threading
import time
import webbrowser
from collections import deque
from pathlib import Path
from typing import Any

import vixora_agente as agente
import vixora_agente_bandeja as bandeja

AQUI = Path(__file__).resolve().parent
TITULO = "Agente del equipo · VIXORA"
PANEL_URL = "https://admin.vixoraai.com/agente-equipo"
ACCESO = "Agente del equipo VIXORA"
SECCIONES = ("inicio", "configuracion", "registro", "ayuda")

FUNCIONES = {
    "agent": "Agente de WhatsApp",
    "copilot": "Copiloto",
    "design": "Diseño con IA",
    "describe": "Visión de fotos",
    "seo": "Descripción SEO",
    "suggest": "Sugerencia de bandeja",
    "test": "Prueba",
    "prospect_web": "Agente de ventas · búsqueda web",
    "prospect_qualify": "Agente de ventas · calificación",
    "prospect_copy": "Agente de ventas · texto",
}
CLAVES = ("VIXORA_API", "VIXORA_RUNNER_TOKEN", "CONCURRENCY", "MODEL", "CLAUDE_BIN", "JOB_TIMEOUT_MAX")


# ------------------------------------------------------------------ utilidades


def mascara(token: str) -> str:
    """El token nunca se enseña entero: prefijo y las cuatro últimas."""
    token = (token or "").strip()
    if not token:
        return ""
    return (token[:4] if token.startswith("vxr_") else "") + "••••••••" + token[-4:]


def al_portapapeles(texto: str) -> bool:
    """Copia por la API de Windows (UTF-16, sin comerse los acentos)."""
    if os.name != "nt":
        for cmd in (["pbcopy"], ["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "-ib"]):
            if shutil.which(cmd[0]):
                try:
                    subprocess.run(cmd, input=texto.encode("utf-8"), check=False, timeout=10)  # noqa: S603
                except (OSError, subprocess.SubprocessError):
                    continue
                return True
        return False
    import ctypes
    from ctypes import wintypes

    # Instancias propias de las DLL: los tipos de aquí no pisan los de nadie.
    u32 = ctypes.WinDLL("user32", use_last_error=True)
    k32 = ctypes.WinDLL("kernel32", use_last_error=True)
    k32.GlobalAlloc.restype, k32.GlobalAlloc.argtypes = ctypes.c_void_p, [wintypes.UINT, ctypes.c_size_t]
    k32.GlobalLock.restype, k32.GlobalLock.argtypes = ctypes.c_void_p, [ctypes.c_void_p]
    k32.GlobalUnlock.argtypes = [ctypes.c_void_p]
    k32.GlobalFree.argtypes = [ctypes.c_void_p]
    u32.OpenClipboard.argtypes = [wintypes.HWND]
    u32.SetClipboardData.restype, u32.SetClipboardData.argtypes = ctypes.c_void_p, [wintypes.UINT, ctypes.c_void_p]
    datos = ctypes.create_unicode_buffer(texto.replace("\0", ""))
    tam = ctypes.sizeof(datos)  # en bytes, con el cero final (un emoji ocupa dos unidades UTF-16)
    for _ in range(12):  # otro programa puede tener el portapapeles abierto un instante
        if u32.OpenClipboard(None):
            break
        time.sleep(0.05)
    else:
        return False
    try:
        u32.EmptyClipboard()
        bloque = k32.GlobalAlloc(0x0002, tam)  # GMEM_MOVEABLE
        if not bloque:
            return False
        destino = k32.GlobalLock(bloque)
        if not destino:
            k32.GlobalFree(bloque)
            return False
        ctypes.memmove(destino, datos, tam)
        k32.GlobalUnlock(bloque)
        if not u32.SetClipboardData(13, bloque):  # CF_UNICODETEXT; si sale bien, el bloque ya es del portapapeles
            k32.GlobalFree(bloque)
            return False
    finally:
        u32.CloseClipboard()
    return True


def avisar_nativo(titulo: str, mensaje: str) -> None:
    """Un aviso sin depender de ninguna de las dos ventanas."""
    if os.name == "nt":
        import ctypes

        ctypes.windll.user32.MessageBoxW(None, mensaje, titulo, 0x40)
        return
    try:
        import tkinter
        from tkinter import messagebox

        raiz = tkinter.Tk()
        raiz.withdraw()
        messagebox.showinfo(titulo, mensaje)
        raiz.destroy()
    except Exception:  # noqa: BLE001
        print(f"{titulo}: {mensaje}")


# ------------------------------------------------------------------ arranque con Windows


def carpeta_inicio() -> Path:
    """La carpeta «Inicio» del usuario (o la de pruebas: VIXORA_AGENTE_STARTUP_DIR)."""
    forzada = os.environ.get("VIXORA_AGENTE_STARTUP_DIR")
    if forzada:
        return Path(forzada)
    base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
    return Path(base) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"


def _accesos() -> tuple[Path, Path]:
    carpeta = carpeta_inicio()
    return carpeta / f"{ACCESO}.lnk", carpeta / f"{ACCESO}.cmd"


def pythonw() -> str:
    """El mismo Python, sin la consola negra detrás."""
    exe = Path(sys.executable)
    w = exe.with_name("pythonw.exe")
    return str(w if w.exists() else exe)


def arranque_activo() -> bool:
    return any(p.exists() for p in _accesos())


def fijar_arranque(activo: bool) -> tuple[bool, str]:
    """Crea o quita el acceso directo de la carpeta Inicio (ventana minimizada)."""
    if os.name != "nt":
        return False, "El arranque automático desde la ventana solo está en Windows (mira el README)."
    lnk, cmd = _accesos()
    for p in (lnk, cmd):
        try:
            p.unlink(missing_ok=True)
        except OSError as exc:
            return False, f"No se pudo quitar {p.name}: {exc}"
    if not activo:
        return True, "Ya no arranca con Windows."
    guion = str(AQUI / "vixora_agente_app.py")
    try:
        lnk.parent.mkdir(parents=True, exist_ok=True)
        # Los valores viajan por variables de entorno: sin líos de comillas.
        entorno = {
            **os.environ, "VX_LNK": str(lnk), "VX_DESTINO": pythonw(),
            "VX_ARGS": f'"{guion}" --minimizado', "VX_DIR": str(AQUI),
            "VX_ICONO": f"{bandeja.ICONO},0" if bandeja.ICONO.is_file() else "",
        }
        orden = (
            "$s=(New-Object -ComObject WScript.Shell).CreateShortcut($env:VX_LNK);"
            "$s.TargetPath=$env:VX_DESTINO;$s.Arguments=$env:VX_ARGS;$s.WorkingDirectory=$env:VX_DIR;"
            "$s.WindowStyle=7;$s.Description='Agente del equipo de VIXORA';"
            "if($env:VX_ICONO){$s.IconLocation=$env:VX_ICONO};$s.Save()"
        )
        subprocess.run(  # noqa: S603
            ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", orden],
            env=entorno, capture_output=True, timeout=40, creationflags=agente.NO_WINDOW, check=False,
        )
    except (OSError, subprocess.SubprocessError):
        pass
    if not lnk.exists():
        # Sin PowerShell/COM: un .cmd en la misma carpeta hace lo mismo.
        try:
            cmd.write_text(
                f'@echo off\r\ncd /d "{AQUI}"\r\nstart "" /min "{pythonw()}" "{guion}" --minimizado\r\n',
                encoding="utf-8",
            )
        except OSError as exc:
            return False, f"No se pudo crear el acceso de arranque: {exc}"
    return True, "Arrancará con Windows, guardado junto al reloj."


# ------------------------------------------------------------------ el control (lo que enseñan las dos ventanas)


class Control:
    """Escucha al agente y guarda lo que la ventana enseña. Sin nada de dibujo:
    la ventana HTML y la de tkinter leen la misma `instantanea()`."""

    def __init__(self) -> None:
        self._cerrojo = threading.RLock()
        self._registro: deque[str] = deque(maxlen=800)
        self._seq = 0
        self._trabajos: deque[dict[str, Any]] = deque(maxlen=60)
        self._estado = "stopped"
        self._motivo = ""
        self._nombre = ""
        self._senal: float | None = None
        self._ocupado = ""  # «arrancando» / «deteniendo» mientras dura
        self._claude = {"ruta": agente.CLAUDE_BIN, "version": "", "encontrado": None}
        self._hoy = self._leer_hoy()
        agente.on_event(self._evento)
        threading.Thread(target=self.mirar_claude, daemon=True).start()

    # --- lo que cuenta el agente -------------------------------------------
    def _evento(self, tipo: str, d: dict[str, Any]) -> None:
        with self._cerrojo:
            if tipo == "log":
                self._registro.append(str(d.get("line", "")))
                self._seq += 1
            elif tipo == "state":
                self._estado = d.get("state", "stopped")
                self._motivo = d.get("reason") or ""
                if self._estado == "stopped":
                    self._senal = None
            elif tipo == "signal":
                self._senal = time.monotonic()
                if d.get("name"):
                    self._nombre = str(d["name"])
            elif tipo == "job_start":
                self._trabajos.appendleft({
                    "id": str(d.get("id", ""))[:8], "clave": str(d.get("id", "")),
                    "funcion": FUNCIONES.get(d.get("feature", ""), d.get("feature") or "Trabajo"),
                    "estado": "en_curso", "hora": time.strftime("%H:%M:%S"), "t0": time.monotonic(), "segundos": 0.0,
                })
            elif tipo == "job_end":
                ok = bool(d.get("ok"))
                for t in self._trabajos:
                    if t["clave"] == d.get("id") and t["estado"] == "en_curso":
                        t["estado"] = "listo" if ok else "error"
                        t["segundos"] = float(d.get("secs") or 0)
                        break
                self._sumar(ok, float(d.get("secs") or 0))

    # --- contadores del día (sobreviven a cerrar la ventana) ---------------
    def _archivo_hoy(self) -> Path:
        return agente.config_path().parent / "agente_hoy.json"

    def _leer_hoy(self) -> dict[str, Any]:
        vacio = {"fecha": time.strftime("%Y-%m-%d"), "atendidos": 0, "fallidos": 0, "segundos": 0.0}
        try:
            datos = json.loads(self._archivo_hoy().read_text(encoding="utf-8"))
            if isinstance(datos, dict) and datos.get("fecha") == vacio["fecha"]:
                return {**vacio, **{k: datos[k] for k in vacio if k in datos}}
        except (OSError, ValueError):
            pass
        return vacio

    def _sumar(self, ok: bool, segundos: float) -> None:
        if self._hoy["fecha"] != time.strftime("%Y-%m-%d"):
            self._hoy = {"fecha": time.strftime("%Y-%m-%d"), "atendidos": 0, "fallidos": 0, "segundos": 0.0}
        if ok:
            self._hoy["atendidos"] += 1
            self._hoy["segundos"] += segundos
        else:
            self._hoy["fallidos"] += 1
        try:
            self._archivo_hoy().write_text(json.dumps(self._hoy), encoding="utf-8")
        except OSError:
            pass

    # --- Claude Code --------------------------------------------------------
    def mirar_claude(self) -> None:
        ok = agente.detect_claude()
        ruta, version = agente.claude_info()
        with self._cerrojo:
            self._claude = {"ruta": ruta, "version": version if ok else "", "encontrado": ok}

    def detectar_claude(self) -> dict[str, Any]:
        """Busca Claude Code sin hacer caso de la ruta escrita."""
        ruta = agente.find_claude(auto=True)
        encontrado = bool(Path(ruta).is_file() or shutil.which(ruta))
        return {"ok": encontrado, "ruta": ruta if encontrado else "",
                "mensaje": f"Encontrado: {ruta}" if encontrado else
                "No se encontró Claude Code. Instálalo (https://claude.com/claude-code) o escribe su ruta."}

    def probar(self) -> dict[str, Any]:
        """Lo mismo que `--probar`: ¿responde Claude Code aquí? No toca VIXORA."""
        if not agente.running():
            agente.reload_config()
        if not agente.detect_claude():
            self.mirar_claude()
            return {"ok": False, "mensaje": f"No se encontró Claude Code ({agente.CLAUDE_BIN})."}
        self.mirar_claude()
        r = agente.probar_claude()
        if r["ok"]:
            return {"ok": True, "mensaje": f"Claude Code funciona: respondió «{r['text']}» en {r['secs']:.1f} s"
                                           f" (modelo {r.get('model') or '¿?'}, {r.get('version') or 'versión ¿?'})."}
        return {"ok": False, "mensaje": f"Error: {r.get('error')}\n¿Iniciaste sesión? Abre una terminal, "
                                        "ejecuta «claude» y entra con la cuenta del equipo."}

    # --- arrancar y detener -------------------------------------------------
    def iniciar(self) -> dict[str, Any]:
        with self._cerrojo:
            if self._ocupado:
                return {"ok": False, "mensaje": "Un momento…"}
            self._ocupado = "arrancando"
            self._motivo = ""
        try:
            ok, mensaje = agente.start()
        except Exception as exc:  # noqa: BLE001
            ok, mensaje = False, f"No se pudo arrancar: {exc}"
        finally:
            with self._cerrojo:
                self._ocupado = ""
        if not ok:
            with self._cerrojo:
                self._motivo = mensaje
        threading.Thread(target=self.mirar_claude, daemon=True).start()
        return {"ok": ok, "mensaje": mensaje}

    def detener(self) -> dict[str, Any]:
        with self._cerrojo:
            if self._ocupado:
                return {"ok": False, "mensaje": "Un momento…"}
            self._ocupado = "deteniendo"
        try:
            agente.stop()
        finally:
            with self._cerrojo:
                self._ocupado = ""
                self._motivo = ""
                for t in self._trabajos:  # lo que se cortó a medias
                    if t["estado"] == "en_curso":
                        t["estado"] = "error"
                        t["segundos"] = time.monotonic() - t["t0"]
        return {"ok": True, "mensaje": "Agente detenido."}

    def activos(self) -> int:
        with self._cerrojo:
            return sum(1 for t in self._trabajos if t["estado"] == "en_curso")

    # --- configuración ------------------------------------------------------
    def leer_config(self) -> dict[str, Any]:
        try:
            datos = json.loads(agente.config_path().read_text(encoding="utf-8-sig"))
            return datos if isinstance(datos, dict) else {}
        except (OSError, ValueError):
            return {}

    def guardar_config(self, cambios: dict[str, Any]) -> dict[str, Any]:
        """Guarda en config.json. El token solo cambia si llega uno nuevo."""
        datos = self.leer_config()
        if "api" in cambios:
            url = str(cambios["api"] or "").strip().rstrip("/") or "https://api.vixoraai.com"
            if not url.lower().startswith(("http://", "https://")):
                return {"ok": False, "mensaje": "La dirección del API debe empezar por https://"}
            datos["VIXORA_API"] = url
        token = str(cambios.get("token") or "").strip()
        if token:
            datos["VIXORA_RUNNER_TOKEN"] = token
        if "concurrencia" in cambios:
            try:
                datos["CONCURRENCY"] = max(1, min(8, int(cambios["concurrencia"])))
            except (TypeError, ValueError):
                return {"ok": False, "mensaje": "«Trabajos a la vez» debe ser un número del 1 al 8."}
        if "modelo" in cambios:
            datos["MODEL"] = str(cambios["modelo"] or "").strip()
        if "claude_bin" in cambios:
            datos["CLAUDE_BIN"] = str(cambios["claude_bin"] or "").strip().strip('"')
        return self._escribir(datos)

    def importar_config(self, texto: str) -> dict[str, Any]:
        """Un config.json pegado (o solo el token, que es lo que más se pega)."""
        texto = (texto or "").strip().lstrip("﻿")
        if not texto:
            return {"ok": False, "mensaje": "No hay nada que pegar."}
        if texto.startswith("vxr_") and " " not in texto and "\n" not in texto:
            nuevo: dict[str, Any] = {"VIXORA_RUNNER_TOKEN": texto}
        else:
            try:
                nuevo = json.loads(texto)
            except ValueError:
                return {"ok": False, "mensaje": "Eso no es un config.json válido (ni un token vxr_…)."}
            if not isinstance(nuevo, dict) or not any(k in nuevo for k in CLAVES):
                return {"ok": False, "mensaje": "Ese JSON no tiene las claves del agente (VIXORA_API, VIXORA_RUNNER_TOKEN…)."}
        datos = self.leer_config()
        for k in CLAVES:
            if k in nuevo and nuevo[k] not in (None, ""):
                datos[k] = nuevo[k]
            elif k in nuevo and k in ("MODEL", "CLAUDE_BIN"):
                datos[k] = ""
        return self._escribir(datos)

    def importar_archivo(self, ruta: str) -> dict[str, Any]:
        try:
            texto = Path(ruta).read_text(encoding="utf-8-sig")
        except OSError as exc:
            return {"ok": False, "mensaje": f"No se pudo leer el archivo: {exc}"}
        return self.importar_config(texto)

    def _escribir(self, datos: dict[str, Any]) -> dict[str, Any]:
        destino = agente.config_path()
        try:
            destino.write_text(json.dumps(datos, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        except OSError as exc:
            return {"ok": False, "mensaje": f"No se pudo guardar {destino.name}: {exc}"}
        if agente.running():
            if self.activos():
                return {"ok": True, "mensaje": "Guardado. Hay trabajos en curso: se aplicará cuando detengas y "
                                               "vuelvas a iniciar el agente."}
            self.detener()
            r = self.iniciar()
            return {"ok": r["ok"], "mensaje": "Guardado. El agente se reinició con la configuración nueva."
                    if r["ok"] else f"Guardado, pero el agente no arrancó: {r['mensaje']}"}
        agente.reload_config()
        threading.Thread(target=self.mirar_claude, daemon=True).start()
        return {"ok": True, "mensaje": "Configuración guardada."}

    def cambiar_arranque(self, activo: bool) -> dict[str, Any]:
        ok, mensaje = fijar_arranque(bool(activo))
        return {"ok": ok, "mensaje": mensaje, "arranque": arranque_activo()}

    # --- lo que se enseña ---------------------------------------------------
    def texto_registro(self) -> str:
        with self._cerrojo:
            return "\n".join(self._registro)

    def copiar_registro(self) -> dict[str, Any]:
        texto = self.texto_registro()
        if not texto:
            return {"ok": False, "mensaje": "El registro está vacío."}
        ok = al_portapapeles(texto)
        return {"ok": ok, "mensaje": "Registro copiado." if ok else "No se pudo copiar al portapapeles."}

    def _situacion(self) -> tuple[str, str, bool, int]:
        """(clave, texto, corriendo, activos). Con el cerrojo ya cogido."""
        corriendo = agente.running()
        activos = sum(1 for t in self._trabajos if t["estado"] == "en_curso")
        estado = self._estado if corriendo or self._ocupado == "arrancando" else "stopped"
        if self._ocupado == "arrancando":
            clave, texto = "conectando", "Conectando…"
        elif self._ocupado == "deteniendo":
            clave, texto = "conectando", "Deteniendo…"
        elif estado == "offline":
            clave, texto = "sin_conexion", "Sin conexión"
        elif estado == "online" and activos:
            clave, texto = "trabajando", f"Trabajando ({activos})"
        elif estado == "online":
            clave, texto = "en_linea", "En línea, esperando trabajo"
        elif estado == "connecting":
            clave, texto = "conectando", "Conectando…"
        else:
            clave, texto = "detenido", "Detenido"
        return clave, texto, corriendo, activos

    def resumen(self) -> dict[str, Any]:
        """Lo mínimo para la bandeja: el estado, sin leer archivos."""
        with self._cerrojo:
            clave, texto, corriendo, activos = self._situacion()
            return {"estado": clave, "texto": texto, "corriendo": corriendo, "ocupado": self._ocupado,
                    "activos": activos}

    def instantanea(self, desde: int = 0) -> dict[str, Any]:
        ahora = time.monotonic()
        cfg = self.leer_config()
        with self._cerrojo:
            clave, texto, corriendo, activos = self._situacion()
            nuevas = max(0, min(self._seq - int(desde or 0), len(self._registro)))
            hoy = self._hoy if self._hoy["fecha"] == time.strftime("%Y-%m-%d") else {
                "atendidos": 0, "fallidos": 0, "segundos": 0.0}
            token = str(cfg.get("VIXORA_RUNNER_TOKEN") or "")
            return {
                "version": agente.VERSION,
                "estado": clave, "texto": texto, "corriendo": corriendo, "ocupado": self._ocupado,
                "motivo": self._motivo if clave == "detenido" else "",
                "activos": activos,
                "senal_s": int(ahora - self._senal) if (self._senal is not None and corriendo) else None,
                "nombre": self._nombre,
                "claude": dict(self._claude),
                "concurrencia": agente.CONCURRENCY, "modelo": agente.MODEL, "api": agente.API_URL,
                "hoy": {
                    "atendidos": hoy["atendidos"], "fallidos": hoy["fallidos"],
                    "medio_s": round(hoy["segundos"] / hoy["atendidos"], 1) if hoy["atendidos"] else None,
                },
                "trabajos": [
                    {"id": t["id"], "funcion": t["funcion"], "estado": t["estado"], "hora": t["hora"],
                     "segundos": round(ahora - t["t0"] if t["estado"] == "en_curso" else t["segundos"], 1)}
                    for t in self._trabajos
                ],
                "seq": self._seq,
                "registro_nuevo": list(self._registro)[len(self._registro) - nuevas:] if nuevas else [],
                "config": {
                    "api": str(cfg.get("VIXORA_API") or "https://api.vixoraai.com"),
                    "token": mascara(token), "hay_token": bool(token),
                    "concurrencia": cfg.get("CONCURRENCY") or 2, "modelo": str(cfg.get("MODEL") or ""),
                    "claude_bin": str(cfg.get("CLAUDE_BIN") or ""), "archivo": str(agente.config_path()),
                },
                "arranque": arranque_activo(), "windows": os.name == "nt", "panel": PANEL_URL,
            }


# ------------------------------------------------------------------ segundo plano: bandeja y segunda instancia

M_ABRIR, M_INICIAR, M_DETENER, M_PANEL, M_SALIR = 1, 2, 3, 4, 5
AVISO_BANDEJA = "El agente sigue trabajando aquí. Haz clic en este icono para volver a abrir la ventana."


def archivo_llamada() -> Path:
    """Si aparece este archivo, alguien abrió el agente por segunda vez."""
    return agente.lock_path().with_name("agente.mostrar")


def llamar_a_la_otra(espera: float = 4.0) -> bool:
    """Desde la segunda instancia: pide a la ventana que ya está abierta que se
    enseñe. True si contestó (recogió la llamada)."""
    quien = agente.lock_owner()
    if quien.get("mode") != "ventana":
        return False
    try:
        bandeja.dejar_pasar_delante(int(quien.get("pid") or 0))
        llamada = archivo_llamada()
        llamada.write_text(str(os.getpid()), encoding="utf-8")
    except (OSError, ValueError):
        return False
    limite = time.monotonic() + espera
    while time.monotonic() < limite:
        if not llamada.exists():
            return True
        time.sleep(0.1)
    try:
        llamada.unlink(missing_ok=True)  # nadie contestó (una versión anterior, o colgada)
    except OSError:
        pass
    return False


class SegundoPlano:
    """Lo que acompaña a la ventana, sea la de HTML o la de tkinter: el icono
    de la bandeja (solo Windows) y la escucha de una segunda instancia.

    `mostrar` y `salir` los da la ventana. Aquí se llaman desde hilos sueltos:
    cada ventana se encarga de pasarlos a su hilo.
    """

    def __init__(self, control: Control, mostrar: Any, salir: Any) -> None:
        self._c, self._mostrar, self._salir = control, mostrar, salir
        self._fin = threading.Event()
        self._hilo: threading.Thread | None = None
        self._avisado = False
        self.bandeja: bandeja.Bandeja | None = None

    @property
    def hay_bandeja(self) -> bool:
        return bool(self.bandeja and self.bandeja.activa)

    def arrancar(self) -> bool:
        """Pone el icono y empieza a escuchar. True si hay bandeja donde guardarse."""
        try:
            archivo_llamada().unlink(missing_ok=True)  # una llamada vieja no cuenta
        except OSError:
            pass
        if bandeja.ES_WINDOWS and self.bandeja is None:
            b = bandeja.Bandeja(TITULO, lambda: self._aparte(self._mostrar), self._opciones, self._elegir)
            b.texto(self._tip())
            b.arrancar()
            self.bandeja = b  # aunque ahora no esté: reaparece si el Explorador se reinicia
        if self._hilo is None:
            self._hilo = threading.Thread(target=self._vigilar, name="vixora-segundo-plano", daemon=True)
            self._hilo.start()
        return self.hay_bandeja

    def aviso_oculto(self) -> None:
        """La primera vez que la ventana se guarda en la bandeja, se dice dónde quedó."""
        if self.hay_bandeja and not self._avisado:
            self._avisado = True
            self.bandeja.globo("Agente del equipo · VIXORA", AVISO_BANDEJA)

    def parar(self) -> None:
        self._fin.set()
        if self.bandeja is not None:
            self.bandeja.parar()

    # --- por dentro ---------------------------------------------------------
    @staticmethod
    def _aparte(hacer: Any) -> None:
        threading.Thread(target=hacer, daemon=True).start()

    def _tip(self) -> str:
        return "VIXORA · " + self._c.resumen()["texto"]

    def _opciones(self) -> list[tuple[int, str] | None]:
        r = self._c.resumen()
        opciones: list[tuple[int, str] | None] = [(M_ABRIR, "Abrir")]
        if not r["ocupado"]:
            opciones.append((M_DETENER, "Detener") if r["corriendo"] else (M_INICIAR, "Iniciar"))
        return [*opciones, (M_PANEL, "Abrir el panel"), None, (M_SALIR, "Salir")]

    def _elegir(self, que: int) -> None:
        hacer = {
            M_ABRIR: self._mostrar, M_INICIAR: self._c.iniciar, M_DETENER: self._c.detener,
            M_PANEL: lambda: webbrowser.open(PANEL_URL), M_SALIR: self._salir,
        }.get(que)
        if hacer:
            self._aparte(hacer)

    def _vigilar(self) -> None:
        llamada, tip = archivo_llamada(), ""
        while not self._fin.wait(0.3):
            try:
                if llamada.exists():
                    llamada.unlink(missing_ok=True)
                    self._aparte(self._mostrar)
                nuevo = self._tip()
                if nuevo != tip and self.bandeja is not None:
                    tip = nuevo
                    self.bandeja.texto(tip)
            except Exception:  # noqa: BLE001 — esto no puede tumbar nada
                pass


# ------------------------------------------------------------------ ventana HTML (pywebview)

MARCO = (0x00140D0B, 0x00F2EAE6)  # barra de título (BGR): fondo y letra


def _pintar_marco(ventana: Any) -> None:
    """Barra de título oscura, como el resto de la ventana (Windows 11)."""
    import ctypes

    try:
        mango = ctypes.c_void_p(int(ventana.native.Handle))
        dwm = ctypes.windll.dwmapi.DwmSetWindowAttribute
    except Exception:  # noqa: BLE001
        return
    for atributo, valor in ((20, 1), (35, MARCO[0]), (36, MARCO[1]), (34, MARCO[0])):
        try:
            dwm(mango, ctypes.c_int(atributo), ctypes.byref(ctypes.c_int(valor)), ctypes.sizeof(ctypes.c_int))
        except Exception:  # noqa: BLE001
            pass


def _preparar_pantalla() -> None:
    """pywebview dibuja sobre WinForms, que no se escala, con un navegador que
    sí: en pantallas al 125 % la página quedaba recortada. Con el proceso como
    «no consciente del escalado», Windows escala la ventana entera."""
    import ctypes

    try:
        ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-1))
    except Exception:  # noqa: BLE001
        pass


class Puente:
    """Lo único que el HTML puede llamar. Todo lo demás, con guion bajo:
    pywebview publica los atributos públicos de este objeto."""

    def __init__(self, control: Control) -> None:
        self._c = control
        self._ventana: Any = None
        self._salir = False
        self._segundo: SegundoPlano | None = None
        self._mango = 0  # el HWND de la ventana (Windows)

    def estado(self, desde: int = 0) -> dict[str, Any]:
        e = self._c.instantanea(desde)
        e["bandeja"] = bool(self._segundo is not None and self._segundo.hay_bandeja)  # ¿hay icono junto al reloj?
        return e

    def iniciar(self) -> dict[str, Any]:
        return self._c.iniciar()

    def detener(self) -> dict[str, Any]:
        return self._c.detener()

    def guardar_config(self, cambios: dict[str, Any]) -> dict[str, Any]:
        return self._c.guardar_config(cambios or {})

    def importar_config(self, texto: str) -> dict[str, Any]:
        return self._c.importar_config(texto)

    def cargar_archivo(self) -> dict[str, Any]:
        import webview

        try:
            tipo = webview.FileDialog.OPEN
        except AttributeError:
            tipo = webview.OPEN_DIALOG
        elegido = self._ventana.create_file_dialog(tipo, file_types=("config.json (*.json)", "Todos (*.*)"))
        if not elegido:
            return {"ok": False, "mensaje": ""}
        return self._c.importar_archivo(elegido[0] if isinstance(elegido, (list, tuple)) else elegido)

    def detectar_claude(self) -> dict[str, Any]:
        return self._c.detectar_claude()

    def probar(self) -> dict[str, Any]:
        return self._c.probar()

    def arranque(self, activo: bool) -> dict[str, Any]:
        return self._c.cambiar_arranque(activo)

    def copiar_registro(self) -> dict[str, Any]:
        return self._c.copiar_registro()

    def abrir_panel(self) -> None:
        webbrowser.open(PANEL_URL)

    def cierre(self, que: str) -> None:
        """Respuesta a «¿seguir en segundo plano?»: minimizar / salir / nada."""
        if que == "minimizar":
            self._guardar()
        elif que == "salir":
            self._salir_del_todo()

    # --- lo que no es del HTML ---------------------------------------------
    def _guardar(self) -> None:
        """A la bandeja, junto al reloj; sin bandeja, minimizada como siempre."""
        if self._segundo is not None and self._segundo.hay_bandeja:
            self._ventana.hide()
            self._segundo.aviso_oculto()
        else:
            self._ventana.minimize()

    def _mostrar(self) -> None:
        """Vuelve a enseñar la ventana (clic en la bandeja, o la abrieron otra vez)."""
        try:
            self._ventana.show()
            if self._mango:
                bandeja.icono_ventana(self._mango)
                bandeja.traer_al_frente(self._mango)
            else:
                self._ventana.restore()
        except Exception:  # noqa: BLE001 — la ventana ya no está
            pass

    def _salir_del_todo(self) -> None:
        self._c.detener()
        self._salir = True
        try:
            self._ventana.destroy()
        except Exception:  # noqa: BLE001
            pass

    def _al_cerrar(self) -> bool:
        if self._salir or not agente.running():
            return True
        # Preguntar desde otro hilo: este es el que dibuja y no puede esperar.
        threading.Thread(target=self._js, args=("preguntarCierre",), daemon=True).start()
        return False

    def _js(self, funcion: str, *args: Any) -> None:
        carga = ", ".join(json.dumps(a, ensure_ascii=False) for a in args)
        try:
            self._ventana.evaluate_js(f"window.vixoraAgente && window.vixoraAgente.{funcion}({carga})")
        except Exception:  # noqa: BLE001 — la ventana ya no está
            pass


def abrir_web(control: Control, minimizado: bool, seccion: str, auto: bool) -> None:
    import webview

    if os.name == "nt":
        _preparar_pantalla()
    puente = Puente(control)
    segundo = SegundoPlano(control, puente._mostrar, puente._salir_del_todo)
    puente._segundo = segundo
    try:
        # «Minimizado» es guardado en la bandeja; sin bandeja, en la barra de tareas.
        oculto = segundo.arrancar() and minimizado
        pagina = (AQUI / "interfaz" / "index.html").as_uri() + (f"#{seccion}" if seccion else "")
        ventana = webview.create_window(
            TITULO, pagina, js_api=puente, width=1080, height=760, min_size=(820, 560),
            background_color="#0B0D14", minimized=minimizado and not oculto, hidden=oculto, text_select=True,
        )
        if ventana is None:
            raise RuntimeError("pywebview no pudo crear la ventana")
        puente._ventana = ventana
        ventana.events.closing += puente._al_cerrar

        def al_abrir() -> None:
            if os.name == "nt":
                ventana.events.shown.wait(15)
                _pintar_marco(ventana)
                try:
                    puente._mango = int(ventana.native.Handle.ToInt64())
                except Exception:  # noqa: BLE001
                    puente._mango = 0
                bandeja.icono_ventana(puente._mango)
            if auto:
                control.iniciar()

        import inspect

        extra: dict[str, Any] = {}
        if bandeja.ICONO.is_file() and "icon" in inspect.signature(webview.start).parameters:
            extra["icon"] = str(bandeja.ICONO)  # pywebview nuevo lo pone él; el viejo, `icono_ventana`
        webview.start(al_abrir, **extra)
    finally:
        segundo.parar()


# ------------------------------------------------------------------ arranque


def main() -> int:
    args = sys.argv[1:]
    minimizado = "--minimizado" in args
    seccion = next((a.split("=", 1)[1] for a in args if a.startswith("--seccion=")), "")
    seccion = seccion if seccion in SECCIONES else ""
    bandeja.identidad_app()
    if not agente.acquire_lock("ventana"):
        quien = agente.lock_owner()
        if quien.get("mode") == "ventana" and minimizado:
            return 0  # el arranque con Windows, y ya estaba abierto: nada que hacer
        if llamar_a_la_otra():
            return 0  # la ventana que ya estaba se puso delante
        donde = "en una consola (iniciar.bat)" if quien.get("mode") == "consola" else "en otra ventana"
        avisar_nativo(
            TITULO,
            f"El agente del equipo ya está abierto {donde} con esta misma carpeta"
            + (f" (proceso {quien['pid']})" if quien.get("pid") else "")
            + ".\n\nNo se abre un segundo: usa el que ya está, o ciérralo primero.",
        )
        return 3
    control = Control()
    auto = "--no-iniciar" not in args and bool(control.leer_config().get("VIXORA_RUNNER_TOKEN") or agente.TOKEN)
    try:
        abierta = False
        if "--tk" not in args:
            try:
                import webview  # noqa: F401
            except ImportError:
                pass
            else:
                try:
                    abrir_web(control, minimizado, seccion, auto)
                    abierta = True
                except Exception:  # noqa: BLE001 — falta WebView2 o falló: la de tkinter enseña lo mismo
                    abierta = False
        if not abierta:
            from vixora_agente_tk import abrir_tk

            abrir_tk(control, minimizado=minimizado, seccion=seccion, auto=auto, segundo=SegundoPlano)
    finally:
        try:
            agente.stop()
        finally:
            agente.release_lock()
    return 0


if __name__ == "__main__":
    sys.exit(main())
