#!/usr/bin/env python3
"""
sipi_world_ventana.py - La parte visual (Tkinter) de SiPi World.

Se separa de editor_sipi.py (que ya es un archivo grande) para que
SiPi World sea facil de encontrar y de mantener aparte. La ventana la
abre editor_sipi.py con 'VentanaSiPiWorld(editor)' -- ver el metodo
'abrir_sipi_world' agregado ahi.

Que hace y que NO hace (honesto sobre el alcance):
- SI: cuentas con clave, chat global en vivo, crear/unirse a
  comunidades por codigo de invitacion, chat por comunidad, y
  proyectos dentro de cada comunidad (nombre + descripcion + estado:
  propuesto / en_progreso / terminado) que ademas pueden guardar el
  codigo SiPi real del programa -- un amigo con el ID del proyecto y su
  contraseña (si tiene una) puede traerse ese codigo con el boton
  "Abrir código", editarlo en su editor local, y subirlo de vuelta con
  Herramientas -> "Guardar en SiPi World".
- NO: no incluye un servidor publico ya hosteado -- alguien (vos, un
  amigo, o quien administre el proyecto SiPi) tiene que correr
  'sipi_world_servidor.py' en alguna maquina alcanzable por los demas.
  Para "cerca" (misma red) alcanza con la IP local de esa maquina; para
  "lejos" (otro pais, intercontinental) hace falta que ese servidor
  este en una maquina con IP publica o dominio (un VPS barato, por
  ejemplo) -- el protocolo en si funciona igual sea cual sea la
  distancia, es la misma internet.

--------------------------------------------------------------------
Notas de la version con UI mejorada (rediseño visual, sin tocar el
protocolo ni sipi_world_cliente.py/sipi_world_servidor.py):
- Pantalla de login: tarjeta centrada con borde, avatar con inicial
  en vivo, spinner animado mientras conecta, mostrar/ocultar clave.
- Barra superior: avatar circular con inicial + punto "en línea".
- Chat (global y comunidad): burbujas de mensaje (Canvas con
  esquinas redondeadas), alineación distinta para "mis mensajes" vs
  "de otros", separadores de fecha, indicador local de "escribiendo…",
  auto-scroll inteligente (no interrumpe si estás mirando el
  historial de arriba) y contador de caracteres.
- Lista de comunidades: tarjetas con avatar/iniciales, badge de
  miembros y buscador en vivo (en vez de un Listbox plano).
- Tarjetas de proyecto: badges de estado más visuales y con más
  jerarquía, iconos consistentes.
- Barra de estado de conexión persistente (punto verde/rojo/amarillo
  + latencia aproximada) al pie de la ventana.
Todo esto es cosmético: los metodos publicos que usa editor_sipi.py
(VentanaSiPiWorld(editor), .ventana, .subir_codigo_pestana_actual)
siguen funcionando igual que antes.
"""
import queue
import threading
import tkinter as tk
from tkinter import ttk, messagebox
import time

from sipi_world_cliente import SiPiWorldCliente, ErrorConexionSiPiWorld, ErrorSiPiWorld


LIMITE_CARACTERES_CHAT = 500
ANCHO_MAX_BURBUJA = 380


# ==================================================================
# Helpers visuales genericos (canvas: rectangulos redondeados,
# avatares circulares con inicial, medicion de texto para las
# burbujas de chat). No dependen del estado de la ventana, asi que
# viven como funciones sueltas para poder reutilizarlas en varias
# pantallas.
# ==================================================================

def _dibujar_rect_redondeado(canvas, x1, y1, x2, y2, radio=10, **kwargs):
    """Dibuja (y devuelve el id de) un rectangulo de esquinas
    redondeadas en un Canvas usando un poligono suavizado -- Tkinter
    no trae esta forma de fabrica."""
    radio = min(radio, (x2 - x1) / 2, (y2 - y1) / 2)
    puntos = [
        x1 + radio, y1,
        x2 - radio, y1,
        x2, y1,
        x2, y1 + radio,
        x2, y2 - radio,
        x2, y2,
        x2 - radio, y2,
        x1 + radio, y2,
        x1, y2,
        x1, y2 - radio,
        x1, y1 + radio,
        x1, y1,
    ]
    return canvas.create_polygon(puntos, smooth=True, **kwargs)


def _medir_texto(parent, texto, font, wraplength):
    """Usa un Label descartable para saber cuanto espacio necesita un
    texto ya envuelto a 'wraplength' -- asi la burbuja de chat se
    dibuja del tamaño justo (ni recortada ni con aire de mas)."""
    tmp = tk.Label(parent, text=texto, font=font, wraplength=wraplength, justify="left")
    tmp.update_idletasks()
    ancho = tmp.winfo_reqwidth()
    alto = tmp.winfo_reqheight()
    tmp.destroy()
    return max(ancho, 10), max(alto, 10)


def crear_avatar_circulo(parent, letra, bg_circulo, fg_letra, diametro=34, bg_fondo=None,
                          punto_estado=None, font_tam=None):
    """Circulo con la inicial de un usuario/comunidad. Si se pasa
    'punto_estado' (un color), agrega un puntito pequeño abajo a la
    derecha (usado para el indicador de "en línea")."""
    bg_fondo = bg_fondo or parent["bg"]
    canvas = tk.Canvas(parent, width=diametro, height=diametro, bg=bg_fondo,
                        highlightthickness=0, bd=0)
    _redibujar_avatar_circulo(canvas, letra, bg_circulo, fg_letra, diametro, punto_estado, font_tam)
    return canvas


def _redibujar_avatar_circulo(canvas, letra, bg_circulo, fg_letra, diametro, punto_estado=None, font_tam=None):
    canvas.delete("all")
    canvas.configure(width=diametro, height=diametro)
    canvas.create_oval(1, 1, diametro - 1, diametro - 1, fill=bg_circulo, outline="")
    canvas.create_text(diametro / 2, diametro / 2, text=(letra or "?")[:1].upper(),
                        fill=fg_letra, font=("Segoe UI", font_tam or max(9, diametro // 3), "bold"))
    if punto_estado:
        r = max(4, diametro // 6)
        cx, cy = diametro - r, diametro - r
        bg_fondo = canvas["bg"]
        canvas.create_oval(cx - r, cy - r, cx + r, cy + r, fill=punto_estado, outline=bg_fondo, width=2)


class _BotonSuave(tk.Button):
    """Boton con un pequeño hover (cambia de color al pasar el mouse)
    -- Tkinter no trae estados ':hover' como el CSS, asi que se simula
    a mano con bind."""
    def __init__(self, parent, bg, bg_hover, **kwargs):
        super().__init__(parent, bg=bg, activebackground=bg_hover, bd=0, cursor="hand2", **kwargs)
        self._bg = bg
        self._bg_hover = bg_hover
        self.bind("<Enter>", self._entrar)
        self.bind("<Leave>", self._salir)

    def _entrar(self, _e=None):
        if self["state"] != "disabled":
            self.configure(bg=self._bg_hover)

    def _salir(self, _e=None):
        if self["state"] != "disabled":
            self.configure(bg=self._bg)


class VentanaSiPiWorld:
    def __init__(self, editor):
        self.editor = editor
        self.root = editor.root
        self.cliente = SiPiWorldCliente()
        self.comunidad_actual = None  # dict de la comunidad seleccionada
        self.comunidades = {}  # id -> dict

        colores = editor._colores()
        self.colores = colores

        # Estado de la barra de conexion persistente y del spinner de login.
        self._estado_conexion = "desconectado"  # desconectado | conectando | conectado
        self._angulo_spinner = 0
        self._job_spinner = None
        self._job_latencia = None
        self._ultima_latencia_ms = None

        self.ventana = tk.Toplevel(self.root)
        self.ventana.title("SiPi World")
        self.ventana.geometry("920x640")
        self.ventana.minsize(700, 480)
        self.ventana.configure(bg=colores["fondo"])
        self.ventana.protocol("WM_DELETE_WINDOW", self._cerrar)

        # --- barra de estado de conexion persistente (pie de ventana) ---
        self._barra_estado = tk.Frame(self.ventana, bg=colores["barra"])
        self._barra_estado.pack(side=tk.BOTTOM, fill=tk.X)
        self._punto_estado = tk.Canvas(self._barra_estado, width=10, height=10, bg=colores["barra"],
                                        highlightthickness=0, bd=0)
        self._punto_estado.pack(side=tk.LEFT, padx=(10, 4), pady=4)
        self._id_punto_estado = self._punto_estado.create_oval(1, 1, 9, 9, fill="#f38ba8", outline="")
        self._etiqueta_estado_barra = tk.Label(self._barra_estado, text="Sin conectar",
                                                bg=colores["barra"], fg=colores["texto"],
                                                font=("Segoe UI", 8), anchor="w")
        self._etiqueta_estado_barra.pack(side=tk.LEFT, pady=4)
        self._etiqueta_latencia = tk.Label(self._barra_estado, text="", bg=colores["barra"],
                                            fg="#6c7086", font=("Segoe UI", 8))
        self._etiqueta_latencia.pack(side=tk.RIGHT, padx=10, pady=4)

        self._contenedor = tk.Frame(self.ventana, bg=colores["fondo"])
        self._contenedor.pack(fill=tk.BOTH, expand=True)

        self._mostrar_pantalla_conexion()
        self._sondear_eventos()

    # ---------------------------------------------------------- barra de estado persistente
    def _actualizar_barra_estado(self, estado, mensaje=None, latencia_ms=None):
        """'estado' es 'desconectado' | 'conectando' | 'conectado'."""
        self._estado_conexion = estado
        colores_punto = {"desconectado": "#f38ba8", "conectando": "#f9c74f", "conectado": "#a6e3a1"}
        textos = {"desconectado": "Sin conectar", "conectando": "Conectando…",
                  "conectado": f"Conectado a {self.cliente.usuario or '...'}"}
        self._punto_estado.itemconfigure(self._id_punto_estado, fill=colores_punto.get(estado, "#f38ba8"))
        self._etiqueta_estado_barra.configure(text=mensaje or textos.get(estado, estado))
        if latencia_ms is not None:
            self._ultima_latencia_ms = latencia_ms
            self._etiqueta_latencia.configure(text=f"~{latencia_ms} ms")
        elif estado != "conectado":
            self._etiqueta_latencia.configure(text="")

    def _iniciar_sondeo_latencia(self):
        """Cada 12s, mientras haya sesion, mide cuanto tarda un pedido
        liviano ya existente (listar comunidades) para mostrar una
        latencia aproximada en la barra de estado -- no agrega ningun
        pedido nuevo al protocolo del servidor."""
        def _tick():
            if not (self.ventana.winfo_exists() and self.cliente.conectado):
                return

            def _trabajo():
                inicio = time.monotonic()
                try:
                    self.cliente.comunidad_listar()
                except (ErrorConexionSiPiWorld, ErrorSiPiWorld):
                    return
                ms = int((time.monotonic() - inicio) * 1000)
                if self.ventana.winfo_exists() and self.cliente.conectado:
                    self.root.after(0, lambda: self._actualizar_barra_estado("conectado", latencia_ms=ms))

            threading.Thread(target=_trabajo, daemon=True).start()
            if self.ventana.winfo_exists():
                self._job_latencia = self.root.after(12000, _tick)

        self._job_latencia = self.root.after(4000, _tick)

    # ---------------------------------------------------------- conexion / login
    def _mostrar_pantalla_conexion(self):
        if self._job_latencia:
            try:
                self.root.after_cancel(self._job_latencia)
            except Exception:
                pass
            self._job_latencia = None
        self._detener_spinner()
        self._actualizar_barra_estado("desconectado")

        for w in self._contenedor.winfo_children():
            w.destroy()
        colores = self.colores
        cfg = self.editor.config.get("sipi_world", {})

        # --- "tarjeta" centrada: un marco con borde propio, distinto
        # del fondo de la ventana, para separarse visualmente ---
        exterior = tk.Frame(self._contenedor, bg=colores["fondo"])
        exterior.place(relx=0.5, rely=0.5, anchor="center")

        sombra = tk.Frame(exterior, bg=colores.get("linea_actual", "#2a2a3a"))
        sombra.pack(padx=(4, 0), pady=(4, 0))
        tarjeta = tk.Frame(sombra, bg=colores["barra"], highlightthickness=1,
                            highlightbackground=colores.get("acento", "#007acc"), padx=30, pady=26)
        tarjeta.pack(padx=(0, 4), pady=(0, 4))

        var_usuario = tk.StringVar(value=cfg.get("usuario", ""))

        avatar_login = crear_avatar_circulo(
            tarjeta, var_usuario.get() or "?", colores.get("acento", "#007acc"), "white",
            diametro=52, bg_fondo=colores["barra"], font_tam=20)
        avatar_login.pack(pady=(0, 10))

        def _al_cambiar_usuario(*_a):
            _redibujar_avatar_circulo(avatar_login, var_usuario.get().strip() or "?",
                                       colores.get("acento", "#007acc"), "white", 52, font_tam=20)

        var_usuario.trace_add("write", _al_cambiar_usuario)

        tk.Label(tarjeta, text="🌐 SiPi World", bg=colores["barra"], fg=colores.get("acento", colores["texto"]),
                 font=("Segoe UI", 20, "bold")).pack(pady=(0, 2))
        tk.Label(tarjeta, text="Chat global, comunidades y proyectos hechos en SiPi",
                 bg=colores["barra"], fg="#6c7086", font=("Segoe UI", 9)).pack(pady=(0, 18))

        campos = tk.Frame(tarjeta, bg=colores["barra"])
        campos.pack(fill=tk.X)

        def _fila(icono, var, oculto=False):
            f = tk.Frame(campos, bg=colores["barra"])
            f.pack(fill=tk.X, pady=4)
            tk.Label(f, text=icono, bg=colores["barra"], fg=colores["texto"], font=("Segoe UI", 10),
                     width=2).pack(side=tk.LEFT)
            marco_entrada = tk.Frame(f, bg=colores.get("linea_actual", "#2a2a3a"))
            marco_entrada.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 0))
            e = tk.Entry(marco_entrada, textvariable=var, font=("Segoe UI", 10),
                         show="*" if oculto else "", bg=colores.get("linea_actual", "#2a2a3a"),
                         fg=colores["texto"], insertbackground=colores["texto"], relief="flat",
                         bd=0, highlightthickness=0)
            e.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=5, padx=6)
            return e, marco_entrada

        var_host = tk.StringVar(value=cfg.get("host", "127.0.0.1"))
        var_puerto = tk.StringVar(value=str(cfg.get("puerto", 8765)))
        var_clave = tk.StringVar(value="")

        entrada_host, _ = _fila("🖧", var_host)
        entrada_puerto, _ = _fila("🔌", var_puerto)
        entrada_usuario, _ = _fila("👤", var_usuario)

        # Fila de clave con boton de mostrar/ocultar (icono ojo).
        f_clave = tk.Frame(campos, bg=colores["barra"])
        f_clave.pack(fill=tk.X, pady=4)
        tk.Label(f_clave, text="🔑", bg=colores["barra"], fg=colores["texto"], font=("Segoe UI", 10),
                 width=2).pack(side=tk.LEFT)
        marco_clave = tk.Frame(f_clave, bg=colores.get("linea_actual", "#2a2a3a"))
        marco_clave.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 0))
        entrada_clave = tk.Entry(marco_clave, textvariable=var_clave, font=("Segoe UI", 10), show="•",
                                  bg=colores.get("linea_actual", "#2a2a3a"), fg=colores["texto"],
                                  insertbackground=colores["texto"], relief="flat", bd=0, highlightthickness=0)
        entrada_clave.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=5, padx=(6, 0))

        clave_visible = tk.BooleanVar(value=False)

        def _alternar_clave():
            clave_visible.set(not clave_visible.get())
            entrada_clave.configure(show="" if clave_visible.get() else "•")
            boton_ojo.configure(text="🙈" if clave_visible.get() else "👁")

        boton_ojo = tk.Button(marco_clave, text="👁", bg=colores.get("linea_actual", "#2a2a3a"),
                               fg=colores["texto"], bd=0, cursor="hand2", font=("Segoe UI", 9),
                               command=_alternar_clave)
        boton_ojo.pack(side=tk.RIGHT, padx=4)

        # --- fila de estado: spinner animado + texto (en vez de solo texto) ---
        marco_estado = tk.Frame(tarjeta, bg=colores["barra"])
        marco_estado.pack(fill=tk.X, pady=(12, 4))
        spinner = tk.Canvas(marco_estado, width=16, height=16, bg=colores["barra"], highlightthickness=0, bd=0)
        etiqueta_estado = tk.Label(marco_estado, text="", bg=colores["barra"], fg="#f38ba8", font=("Segoe UI", 9),
                                    wraplength=300, justify="left", anchor="w")
        etiqueta_estado.pack(side=tk.LEFT, fill=tk.X, expand=True)

        marco_botones = tk.Frame(tarjeta, bg=colores["barra"])
        marco_botones.pack(pady=(8, 0), fill=tk.X)

        campos_a_bloquear = [entrada_host, entrada_puerto, entrada_usuario, entrada_clave, boton_ojo]

        def _intentar(modo):
            host = var_host.get().strip() or "127.0.0.1"
            try:
                puerto = int(var_puerto.get().strip() or "8765")
            except ValueError:
                etiqueta_estado.configure(fg="#f38ba8", text="El puerto tiene que ser un numero.")
                return
            usuario = var_usuario.get().strip()
            clave = var_clave.get()
            if not usuario or not clave:
                etiqueta_estado.configure(fg="#f38ba8", text="Completá usuario y clave.")
                return

            for b in (boton_entrar, boton_crear_cuenta):
                b.configure(state="disabled")
            for c in campos_a_bloquear:
                c.configure(state="disabled")
            etiqueta_estado.configure(fg=colores["texto"], text="Conectando...")
            spinner.pack(side=tk.LEFT, padx=(0, 6))
            self._actualizar_barra_estado("conectando")
            self._iniciar_spinner(spinner)
            self.root.update_idletasks()

            def _trabajo():
                try:
                    self.cliente.conectar(host, puerto)
                    if modo == "registrar":
                        self.cliente.registrar(usuario, clave)
                    else:
                        self.cliente.iniciar_sesion(usuario, clave)
                    self.editor.config["sipi_world"] = {"host": host, "puerto": puerto, "usuario": usuario}
                    self.editor._guardar_config()
                    self.root.after(0, self._mostrar_pantalla_principal)
                except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                    def _mostrar_error(msg=str(e)):
                        self._detener_spinner()
                        spinner.pack_forget()
                        self._actualizar_barra_estado("desconectado")
                        etiqueta_estado.configure(fg="#f38ba8", text=msg)
                        for b in (boton_entrar, boton_crear_cuenta):
                            b.configure(state="normal")
                        for c in campos_a_bloquear:
                            c.configure(state="normal")
                    self.root.after(0, _mostrar_error)

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

        boton_entrar = _BotonSuave(marco_botones, bg=colores.get("acento", "#007acc"), bg_hover="#3a9bdb",
                                    text="Iniciar sesión", font=("Segoe UI", 10, "bold"), fg="white",
                                    padx=14, pady=7, command=lambda: _intentar("login"))
        boton_entrar.pack(side=tk.LEFT, padx=(0, 6), fill=tk.X, expand=True)
        boton_crear_cuenta = _BotonSuave(marco_botones, bg=colores.get("linea_actual", "#2a2a3a"),
                                          bg_hover=colores.get("seleccion", "#264f78"),
                                          text="Crear cuenta", font=("Segoe UI", 10), fg=colores["texto"],
                                          padx=14, pady=7, command=lambda: _intentar("registrar"))
        boton_crear_cuenta.pack(side=tk.LEFT, fill=tk.X, expand=True)

        tk.Label(tarjeta, text="Necesitás que alguien tenga corriendo 'sipi_world_servidor.py'\n"
                                "(vos mismo, un amigo, o un servidor compartido con la comunidad).",
                 bg=colores["barra"], fg="#6c7086", font=("Segoe UI", 8), justify="center").pack(pady=(16, 0))

        entrada_usuario.bind("<Return>", lambda e: entrada_clave.focus_set())
        entrada_clave.bind("<Return>", lambda e: _intentar("login"))
        if cfg.get("usuario", ""):
            entrada_clave.focus_set()
        else:
            entrada_usuario.focus_set()

    def _iniciar_spinner(self, canvas):
        self._detener_spinner()

        def _girar():
            if not canvas.winfo_exists():
                return
            canvas.delete("all")
            self._angulo_spinner = (self._angulo_spinner + 30) % 360
            canvas.create_arc(2, 2, 14, 14, start=self._angulo_spinner, extent=300,
                               style="arc", outline=self.colores.get("acento", "#007acc"), width=2)
            self._job_spinner = self.root.after(80, _girar)

        _girar()

    def _detener_spinner(self):
        if self._job_spinner:
            try:
                self.root.after_cancel(self._job_spinner)
            except Exception:
                pass
            self._job_spinner = None

    # ---------------------------------------------------------- pantalla principal
    def _mostrar_pantalla_principal(self):
        self._detener_spinner()
        self._actualizar_barra_estado("conectado")
        self._iniciar_sondeo_latencia()

        for w in self._contenedor.winfo_children():
            w.destroy()
        colores = self.colores

        barra_superior = tk.Frame(self._contenedor, bg=colores["barra"])
        barra_superior.pack(fill=tk.X)

        marco_identidad = tk.Frame(barra_superior, bg=colores["barra"])
        marco_identidad.pack(side=tk.LEFT, padx=8, pady=6)
        crear_avatar_circulo(marco_identidad, self.cliente.usuario, colores.get("acento", "#007acc"),
                              "white", diametro=32, bg_fondo=colores["barra"],
                              punto_estado="#a6e3a1").pack(side=tk.LEFT, padx=(0, 8))
        marco_texto_identidad = tk.Frame(marco_identidad, bg=colores["barra"])
        marco_texto_identidad.pack(side=tk.LEFT)
        tk.Label(marco_texto_identidad, text=f"🌐 {self.cliente.usuario}",
                 bg=colores["barra"], fg=colores["texto"], font=("Segoe UI", 10, "bold"),
                 anchor="w").pack(anchor="w")
        tk.Label(marco_texto_identidad, text="● en línea", bg=colores["barra"], fg="#a6e3a1",
                 font=("Segoe UI", 8), anchor="w").pack(anchor="w")

        _BotonSuave(barra_superior, bg=colores["barra"], bg_hover=colores.get("linea_actual", "#2a2a3a"),
                    text="Desconectar", fg="#f38ba8", font=("Segoe UI", 9),
                    command=self._desconectar).pack(side=tk.RIGHT, padx=10)

        self.notebook = ttk.Notebook(self._contenedor)
        self.notebook.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)

        self._pestana_chat_global()
        self._pestana_comunidades()

        self.notebook.select(0)

    # ==============================================================
    # Panel de chat reutilizable (burbujas + fecha + auto-scroll
    # inteligente + contador de caracteres + "escribiendo…").
    # Se usa tanto para el chat global como para el de cada comunidad;
    # el estado de cada instancia vive en un dict simple para no tener
    # que crear una clase Tkinter aparte.
    # ==============================================================
    def _crear_panel_chat(self, parent, al_enviar, placeholder_deshabilitado=None):
        colores = self.colores
        panel = {"ultima_fecha": None, "pegado_abajo": True, "job_escribiendo": None}

        marco_lista = tk.Frame(parent, bg=colores["fondo"])
        marco_lista.pack(fill=tk.BOTH, expand=True, padx=6, pady=(6, 0))

        canvas = tk.Canvas(marco_lista, bg=colores["fondo"], highlightthickness=0, bd=0)
        scroll = ttk.Scrollbar(marco_lista, orient=tk.VERTICAL, command=canvas.yview)
        marco_mensajes = tk.Frame(canvas, bg=colores["fondo"])

        def _al_cambiar_tamano(_e=None):
            canvas.configure(scrollregion=canvas.bbox("all"))
            if panel["pegado_abajo"]:
                canvas.yview_moveto(1.0)

        marco_mensajes.bind("<Configure>", _al_cambiar_tamano)
        id_ventana = canvas.create_window((0, 0), window=marco_mensajes, anchor="nw")
        canvas.bind("<Configure>", lambda e: canvas.itemconfigure(id_ventana, width=e.width))
        canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)

        def _al_moverse(a, b):
            # "Auto-scroll inteligente": si el usuario esta mirando el
            # historial de mas arriba, no lo interrumpimos con cada
            # mensaje nuevo -- solo seguimos pegados abajo si ya
            # estaba cerca del final.
            scroll.set(a, b)
            panel["pegado_abajo"] = float(b) >= 0.985

        canvas.configure(yscrollcommand=_al_moverse)

        def _rueda(evento):
            canvas.yview_scroll(int(-1 * (evento.delta / 120)), "units")
        canvas.bind("<MouseWheel>", _rueda)

        # -- indicador de "escribiendo…" (local: se prende mientras el
        # propio usuario tipea, se apaga solo tras una pausa) --
        etiqueta_escribiendo = tk.Label(parent, text="", bg=colores["fondo"], fg="#6c7086",
                                         font=("Segoe UI", 8, "italic"), anchor="w")
        etiqueta_escribiendo.pack(fill=tk.X, padx=10)

        marco_envio = tk.Frame(parent, bg=colores["fondo"])
        marco_envio.pack(fill=tk.X, padx=6, pady=6)

        marco_entrada = tk.Frame(marco_envio, bg=colores.get("linea_actual", "#2a2a3a"))
        marco_entrada.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6))
        entrada = tk.Entry(marco_entrada, font=("Segoe UI", 10), bg=colores.get("linea_actual"),
                            fg=colores["texto"], insertbackground=colores["texto"], relief="flat", bd=0)
        entrada.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=6, padx=(8, 2))
        contador = tk.Label(marco_entrada, text=f"0/{LIMITE_CARACTERES_CHAT}", bg=colores.get("linea_actual"),
                             fg="#6c7086", font=("Segoe UI", 8))
        contador.pack(side=tk.RIGHT, padx=6)

        boton_enviar = _BotonSuave(marco_envio, bg=colores.get("acento", "#007acc"), bg_hover="#3a9bdb",
                                    text="Enviar  ➤", fg="white", font=("Segoe UI", 9, "bold"),
                                    padx=14, pady=6, command=lambda: al_enviar())
        boton_enviar.pack(side=tk.LEFT)

        def _al_escribir(_e=None):
            largo = len(entrada.get())
            contador.configure(text=f"{largo}/{LIMITE_CARACTERES_CHAT}",
                                fg="#f38ba8" if largo > LIMITE_CARACTERES_CHAT else "#6c7086")
            if largo:
                etiqueta_escribiendo.configure(text="Escribiendo…")
                if panel["job_escribiendo"]:
                    self.root.after_cancel(panel["job_escribiendo"])
                panel["job_escribiendo"] = self.root.after(
                    1500, lambda: etiqueta_escribiendo.configure(text=""))
            else:
                etiqueta_escribiendo.configure(text="")

        entrada.bind("<KeyRelease>", _al_escribir)
        entrada.bind("<Return>", lambda e: al_enviar())

        panel.update({
            "canvas": canvas, "marco_mensajes": marco_mensajes, "entrada": entrada,
            "contador": contador, "boton_enviar": boton_enviar,
            "etiqueta_escribiendo": etiqueta_escribiendo, "marco_envio": marco_envio,
            "marco_entrada": marco_entrada,
        })
        if placeholder_deshabilitado:
            self._fijar_estado_panel_chat(panel, habilitado=False)
        return panel

    def _fijar_estado_panel_chat(self, panel, habilitado):
        estado = "normal" if habilitado else "disabled"
        panel["entrada"].configure(state=estado)
        panel["boton_enviar"].configure(state=estado)

    def _limpiar_panel_chat(self, panel):
        for w in panel["marco_mensajes"].winfo_children():
            w.destroy()
        panel["ultima_fecha"] = None

    def _agregar_linea_chat(self, panel, mensaje):
        colores = self.colores
        marco_mensajes = panel["marco_mensajes"]
        cuando = mensaje.get("enviado_en", time.time())
        es_propio = mensaje.get("autor") == self.cliente.usuario

        # --- separador de fecha, solo cuando cambia el dia ---
        fecha_txt = time.strftime("%d/%m/%Y", time.localtime(cuando))
        if panel["ultima_fecha"] != fecha_txt:
            panel["ultima_fecha"] = fecha_txt
            hoy = time.strftime("%d/%m/%Y")
            ayer = time.strftime("%d/%m/%Y", time.localtime(time.time() - 86400))
            etiqueta_fecha = "Hoy" if fecha_txt == hoy else "Ayer" if fecha_txt == ayer else fecha_txt
            fila_sep = tk.Frame(marco_mensajes, bg=colores["fondo"])
            fila_sep.pack(fill=tk.X, pady=(10, 4))
            tk.Frame(fila_sep, bg=colores.get("linea_actual", "#444"), height=1).pack(
                side=tk.LEFT, fill=tk.X, expand=True, padx=(20, 8), pady=8)
            tk.Label(fila_sep, text=etiqueta_fecha, bg=colores["fondo"], fg="#6c7086",
                     font=("Segoe UI", 8, "bold")).pack(side=tk.LEFT)
            tk.Frame(fila_sep, bg=colores.get("linea_actual", "#444"), height=1).pack(
                side=tk.LEFT, fill=tk.X, expand=True, padx=(8, 20), pady=8)

        fila = tk.Frame(marco_mensajes, bg=colores["fondo"])
        fila.pack(fill=tk.X, padx=10, pady=2, anchor="e" if es_propio else "w")

        columna = tk.Frame(fila, bg=colores["fondo"])
        columna.pack(anchor="e" if es_propio else "w")

        if not es_propio:
            tk.Label(columna, text=mensaje.get("autor", "???"), bg=colores["fondo"],
                     fg=colores.get("acento", "#7ec4ff"), font=("Segoe UI", 8, "bold"),
                     anchor="w").pack(anchor="w", padx=(4, 0))

        texto = mensaje.get("texto", "")
        color_burbuja = colores.get("acento", "#007acc") if es_propio else colores["barra"]
        color_letra = "white" if es_propio else colores["texto"]
        ancho_txt, alto_txt = _medir_texto(marco_mensajes, texto, ("Segoe UI", 10), ANCHO_MAX_BURBUJA - 24)
        ancho_burbuja = ancho_txt + 24
        alto_burbuja = alto_txt + 16

        burbuja = tk.Canvas(columna, width=ancho_burbuja, height=alto_burbuja, bg=colores["fondo"],
                             highlightthickness=0, bd=0)
        burbuja.pack(anchor="e" if es_propio else "w")
        _dibujar_rect_redondeado(burbuja, 1, 1, ancho_burbuja - 1, alto_burbuja - 1, radio=12,
                                  fill=color_burbuja, outline=color_burbuja)
        burbuja.create_text(12, 8, text=texto, anchor="nw", font=("Segoe UI", 10),
                             width=ANCHO_MAX_BURBUJA - 24, fill=color_letra)

        hora = time.strftime("%H:%M", time.localtime(cuando))
        tk.Label(columna, text=hora, bg=colores["fondo"], fg="#6c7086",
                 font=("Segoe UI", 7)).pack(anchor="e" if es_propio else "w", padx=4)

    # ---------------------------------------------------------- Chat Global
    def _pestana_chat_global(self):
        colores = self.colores
        marco = tk.Frame(self.notebook, bg=colores["fondo"])
        self.notebook.add(marco, text="  💬 Chat global  ")

        self.panel_chat_global = self._crear_panel_chat(marco, self._enviar_chat_global)

        def _cargar():
            try:
                mensajes = self.cliente.chat_global_historial()
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld):
                return
            self.root.after(0, lambda: [self._agregar_linea_chat(self.panel_chat_global, m) for m in mensajes])

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

    def _enviar_chat_global(self):
        entrada = self.panel_chat_global["entrada"]
        texto = entrada.get().strip()
        if not texto or len(texto) > LIMITE_CARACTERES_CHAT:
            return
        entrada.delete(0, tk.END)
        self.panel_chat_global["contador"].configure(text=f"0/{LIMITE_CARACTERES_CHAT}", fg="#6c7086")
        self.panel_chat_global["pegado_abajo"] = True
        self._agregar_linea_chat(self.panel_chat_global, {"autor": self.cliente.usuario, "texto": texto,
                                                            "enviado_en": time.time()})

        def _trabajo():
            try:
                self.cliente.chat_global_enviar(texto)
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                self.root.after(0, lambda: messagebox.showerror("SiPi World", str(e), parent=self.ventana))

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

    # ---------------------------------------------------------- Comunidades
    def _pestana_comunidades(self):
        colores = self.colores
        marco = tk.Frame(self.notebook, bg=colores["fondo"])
        self.notebook.add(marco, text="  👥 Comunidades  ")

        panel = tk.PanedWindow(marco, orient=tk.HORIZONTAL, bg=colores["fondo"], sashwidth=4, bd=0,
                                sashrelief="flat", showhandle=False)
        panel.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # --- columna izquierda: mis comunidades, ahora como tarjetas ---
        marco_izq = tk.Frame(panel, bg=colores["barra"])
        panel.add(marco_izq, minsize=230, width=250)

        tk.Label(marco_izq, text="Mis comunidades", bg=colores["barra"], fg=colores["texto"],
                 font=("Segoe UI", 10, "bold"), anchor="w", padx=8, pady=6).pack(fill=tk.X)

        marco_buscador = tk.Frame(marco_izq, bg=colores.get("linea_actual", "#2a2a3a"))
        marco_buscador.pack(fill=tk.X, padx=8, pady=(0, 6))
        tk.Label(marco_buscador, text="🔎", bg=colores.get("linea_actual", "#2a2a3a"),
                 fg="#6c7086", font=("Segoe UI", 9)).pack(side=tk.LEFT, padx=(6, 0))
        self._var_busqueda_comunidad = tk.StringVar()
        entrada_buscar = tk.Entry(marco_buscador, textvariable=self._var_busqueda_comunidad,
                                   bg=colores.get("linea_actual", "#2a2a3a"), fg=colores["texto"],
                                   insertbackground=colores["texto"], relief="flat", bd=0,
                                   font=("Segoe UI", 9))
        entrada_buscar.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=4, padx=4)
        self._var_busqueda_comunidad.trace_add("write", lambda *_a: self._dibujar_tarjetas_comunidades())

        marco_lista_com = tk.Frame(marco_izq, bg=colores["barra"])
        marco_lista_com.pack(fill=tk.BOTH, expand=True, padx=6)
        self._canvas_comunidades = tk.Canvas(marco_lista_com, bg=colores["barra"], highlightthickness=0, bd=0)
        scroll_com = ttk.Scrollbar(marco_lista_com, orient=tk.VERTICAL, command=self._canvas_comunidades.yview)
        self._marco_tarjetas_comunidades = tk.Frame(self._canvas_comunidades, bg=colores["barra"])
        self._marco_tarjetas_comunidades.bind(
            "<Configure>",
            lambda e: self._canvas_comunidades.configure(scrollregion=self._canvas_comunidades.bbox("all")))
        id_ventana_com = self._canvas_comunidades.create_window(
            (0, 0), window=self._marco_tarjetas_comunidades, anchor="nw")
        self._canvas_comunidades.bind(
            "<Configure>", lambda e: self._canvas_comunidades.itemconfigure(id_ventana_com, width=e.width))
        self._canvas_comunidades.configure(yscrollcommand=scroll_com.set)
        self._canvas_comunidades.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll_com.pack(side=tk.RIGHT, fill=tk.Y)

        marco_acciones_com = tk.Frame(marco_izq, bg=colores["barra"])
        marco_acciones_com.pack(fill=tk.X, padx=8, pady=8)
        _BotonSuave(marco_acciones_com, bg=colores.get("acento", "#007acc"), bg_hover="#3a9bdb",
                    text="+ Crear comunidad", fg="white", font=("Segoe UI", 9, "bold"), pady=6,
                    command=self._dialogo_crear_comunidad).pack(fill=tk.X, pady=(0, 4))
        _BotonSuave(marco_acciones_com, bg=colores["fondo"], bg_hover=colores.get("linea_actual", "#2a2a3a"),
                    text="Unirme con código", fg=colores["texto"], font=("Segoe UI", 9), pady=6,
                    command=self._dialogo_unirse_comunidad).pack(fill=tk.X)

        # --- columna derecha: chat + proyectos de la comunidad elegida ---
        marco_der = tk.Frame(panel, bg=colores["fondo"])
        panel.add(marco_der, minsize=380)

        marco_encabezado_com = tk.Frame(marco_der, bg=colores["fondo"])
        marco_encabezado_com.pack(fill=tk.X)
        self._avatar_comunidad_actual = crear_avatar_circulo(
            marco_encabezado_com, "?", colores.get("acento", "#007acc"), "white",
            diametro=30, bg_fondo=colores["fondo"])
        self._avatar_comunidad_actual.pack(side=tk.LEFT, padx=(4, 8), pady=6)
        self.etiqueta_comunidad = tk.Label(marco_encabezado_com, text="Elegí o creá una comunidad para empezar",
                                            bg=colores["fondo"], fg=colores["texto"],
                                            font=("Segoe UI", 11, "bold"), anchor="w", pady=6)
        self.etiqueta_comunidad.pack(side=tk.LEFT, fill=tk.X, expand=True)

        sub = ttk.Notebook(marco_der)
        sub.pack(fill=tk.BOTH, expand=True)
        self._sub_notebook_comunidad = sub

        # -- sub-pestaña: chat de la comunidad --
        marco_chat_com = tk.Frame(sub, bg=colores["fondo"])
        sub.add(marco_chat_com, text="  💬 Chat  ")
        self.panel_chat_comunidad = self._crear_panel_chat(
            marco_chat_com, self._enviar_chat_comunidad, placeholder_deshabilitado=True)

        # -- sub-pestaña: proyectos de la comunidad --
        marco_proyectos = tk.Frame(sub, bg=colores["fondo"])
        sub.add(marco_proyectos, text="  📦 Proyectos  ")

        marco_nuevo_proyecto = tk.Frame(marco_proyectos, bg=colores["fondo"])
        marco_nuevo_proyecto.pack(fill=tk.X, padx=6, pady=6)
        self.boton_nuevo_proyecto = _BotonSuave(
            marco_nuevo_proyecto, bg=colores.get("acento", "#007acc"), bg_hover="#3a9bdb",
            text="+ Proponer un proyecto", fg="white", font=("Segoe UI", 9, "bold"), pady=6,
            state="disabled", command=self._dialogo_crear_proyecto)
        self.boton_nuevo_proyecto.pack(side=tk.LEFT)

        marco_lista_proyectos = tk.Frame(marco_proyectos, bg=colores["fondo"])
        marco_lista_proyectos.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
        self.canvas_proyectos = tk.Canvas(marco_lista_proyectos, bg=colores["fondo"], highlightthickness=0)
        scroll_proyectos = ttk.Scrollbar(marco_lista_proyectos, orient=tk.VERTICAL,
                                          command=self.canvas_proyectos.yview)
        self.marco_tarjetas_proyectos = tk.Frame(self.canvas_proyectos, bg=colores["fondo"])
        self.marco_tarjetas_proyectos.bind(
            "<Configure>", lambda e: self.canvas_proyectos.configure(scrollregion=self.canvas_proyectos.bbox("all")))
        id_ventana_proy = self.canvas_proyectos.create_window(
            (0, 0), window=self.marco_tarjetas_proyectos, anchor="nw")
        self.canvas_proyectos.bind(
            "<Configure>", lambda e: self.canvas_proyectos.itemconfigure(id_ventana_proy, width=e.width))
        self.canvas_proyectos.configure(yscrollcommand=scroll_proyectos.set)
        self.canvas_proyectos.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll_proyectos.pack(side=tk.RIGHT, fill=tk.Y)

        self._refrescar_lista_comunidades()

    def _refrescar_lista_comunidades(self):
        def _trabajo():
            try:
                comunidades = self.cliente.comunidad_listar()
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld):
                return

            def _actualizar():
                self.comunidades = {c["id"]: c for c in comunidades}
                self._dibujar_tarjetas_comunidades()
            self.root.after(0, _actualizar)

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

    def _dibujar_tarjetas_comunidades(self):
        """Tarjetas con avatar/inicial + badge de miembros, en vez del
        Listbox plano de antes. Respeta el filtro del buscador."""
        colores = self.colores
        for w in self._marco_tarjetas_comunidades.winfo_children():
            w.destroy()

        filtro = getattr(self, "_var_busqueda_comunidad", tk.StringVar()).get().strip().lower()
        comunidades = [c for c in self.comunidades.values() if filtro in c["nombre"].lower()]

        if not comunidades:
            texto_vacio = "No hay comunidades que coincidan." if filtro else \
                "Todavía no sos parte de ninguna comunidad."
            tk.Label(self._marco_tarjetas_comunidades, text=texto_vacio, bg=colores["barra"], fg="#6c7086",
                     font=("Segoe UI", 9), wraplength=200, justify="center").pack(pady=20, padx=10)
            return

        for c in comunidades:
            es_actual = self.comunidad_actual is not None and self.comunidad_actual["id"] == c["id"]
            bg_normal = colores.get("linea_actual", "#2a2a3a")
            bg_tarjeta = colores.get("seleccion", "#264f78") if es_actual else bg_normal
            tarjeta = tk.Frame(self._marco_tarjetas_comunidades, bg=bg_tarjeta, cursor="hand2")
            tarjeta.pack(fill=tk.X, padx=6, pady=3)

            interior = tk.Frame(tarjeta, bg=bg_tarjeta)
            interior.pack(fill=tk.X, padx=8, pady=7)
            crear_avatar_circulo(interior, c["nombre"], colores.get("acento", "#007acc"), "white",
                                  diametro=30, bg_fondo=bg_tarjeta).pack(side=tk.LEFT, padx=(0, 8))

            marco_texto = tk.Frame(interior, bg=bg_tarjeta)
            marco_texto.pack(side=tk.LEFT, fill=tk.X, expand=True)
            tk.Label(marco_texto, text=c["nombre"], bg=bg_tarjeta, fg=colores["texto"],
                     font=("Segoe UI", 9, "bold"), anchor="w").pack(fill=tk.X)
            tk.Label(marco_texto, text=f"👤 {c['miembros']} miembro{'s' if c['miembros'] != 1 else ''}",
                     bg=bg_tarjeta, fg="#6c7086", font=("Segoe UI", 8), anchor="w").pack(fill=tk.X)

            def _elegir(_e=None, comunidad_id=c["id"]):
                self._elegir_comunidad(comunidad_id)

            todos_los_widgets = [tarjeta, interior, marco_texto] + list(marco_texto.winfo_children())
            for widget in todos_los_widgets:
                widget.bind("<Button-1>", _elegir)

            if not es_actual:
                def _entrar(_e=None, t=tarjeta, hijos=todos_los_widgets, bg=colores.get("seleccion", "#264f78")):
                    for w in [t] + hijos:
                        try:
                            w.configure(bg=bg)
                        except tk.TclError:
                            pass

                def _salir(_e=None, t=tarjeta, hijos=todos_los_widgets, bg=bg_normal):
                    for w in [t] + hijos:
                        try:
                            w.configure(bg=bg)
                        except tk.TclError:
                            pass

                tarjeta.bind("<Enter>", _entrar)
                tarjeta.bind("<Leave>", _salir)

    def _al_elegir_comunidad(self, evento=None):
        # Se mantiene por compatibilidad conceptual con la version
        # anterior (Listbox); ahora la seleccion la hacen las tarjetas
        # via '_elegir_comunidad'.
        pass

    def _elegir_comunidad(self, comunidad_id):
        comunidad = self.comunidades.get(comunidad_id)
        if comunidad is None:
            return
        colores = self.colores
        self.comunidad_actual = comunidad
        self._dibujar_tarjetas_comunidades()
        _redibujar_avatar_circulo(self._avatar_comunidad_actual, comunidad["nombre"],
                                   colores.get("acento", "#007acc"), "white", 30)

        self.etiqueta_comunidad.configure(
            text=f"{comunidad['nombre']}  —  código de invitación: {comunidad['codigo_invitacion']}")
        self._fijar_estado_panel_chat(self.panel_chat_comunidad, habilitado=True)
        self.boton_nuevo_proyecto.configure(state="normal")

        self._limpiar_panel_chat(self.panel_chat_comunidad)

        def _trabajo():
            try:
                mensajes = self.cliente.comunidad_chat_historial(comunidad["id"])
                proyectos = self.cliente.proyecto_listar(comunidad["id"])
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld):
                return

            def _actualizar():
                for m in mensajes:
                    self._agregar_linea_chat(self.panel_chat_comunidad, m)
                self._dibujar_proyectos(proyectos)
            self.root.after(0, _actualizar)

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

    def _enviar_chat_comunidad(self):
        if self.comunidad_actual is None:
            return
        entrada = self.panel_chat_comunidad["entrada"]
        texto = entrada.get().strip()
        if not texto or len(texto) > LIMITE_CARACTERES_CHAT:
            return
        entrada.delete(0, tk.END)
        self.panel_chat_comunidad["contador"].configure(text=f"0/{LIMITE_CARACTERES_CHAT}", fg="#6c7086")
        self.panel_chat_comunidad["pegado_abajo"] = True
        self._agregar_linea_chat(self.panel_chat_comunidad,
                                  {"autor": self.cliente.usuario, "texto": texto, "enviado_en": time.time()})
        comunidad_id = self.comunidad_actual["id"]

        def _trabajo():
            try:
                self.cliente.comunidad_chat_enviar(comunidad_id, texto)
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                self.root.after(0, lambda: messagebox.showerror("SiPi World", str(e), parent=self.ventana))

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

    # ---------------------------------------------------------- tarjetas de proyecto
    def _dibujar_proyectos(self, proyectos):
        colores = self.colores
        for w in self.marco_tarjetas_proyectos.winfo_children():
            w.destroy()
        if not proyectos:
            tk.Label(self.marco_tarjetas_proyectos, text="📦 Todavía no hay proyectos propuestos en esta comunidad.",
                     bg=colores["fondo"], fg="#6c7086", font=("Segoe UI", 9)).pack(pady=20)
            return

        etiquetas_estado = {
            "propuesto": ("🟡 Propuesto", "#f9c74f"),
            "en_progreso": ("🔵 En progreso", "#7ec4ff"),
            "terminado": ("✅ Terminado", "#a6e3a1"),
        }
        for p in proyectos:
            tarjeta = tk.Frame(self.marco_tarjetas_proyectos, bg=colores["barra"], highlightthickness=1,
                                highlightbackground=colores.get("linea_actual", "#3a3a4a"))
            tarjeta.pack(fill=tk.X, padx=4, pady=5)

            cab = tk.Frame(tarjeta, bg=colores["barra"])
            cab.pack(fill=tk.X, padx=10, pady=(8, 0))
            tk.Label(cab, text=p["nombre"], bg=colores["barra"], fg=colores["texto"],
                     font=("Segoe UI", 10, "bold"), anchor="w").pack(side=tk.LEFT)

            texto_estado, color_estado = etiquetas_estado.get(p["estado"], (p["estado"], colores["texto"]))
            badge = tk.Frame(cab, bg=color_estado)
            badge.pack(side=tk.RIGHT)
            tk.Label(badge, text=texto_estado, bg=color_estado, fg="#1b1b2b",
                     font=("Segoe UI", 8, "bold"), padx=8, pady=2).pack()

            if p.get("descripcion"):
                tk.Label(tarjeta, text=p["descripcion"], bg=colores["barra"], fg=colores["texto"],
                         font=("Segoe UI", 9), anchor="w", justify="left", wraplength=520
                         ).pack(fill=tk.X, padx=10, pady=(4, 0))

            info_extra = f"👤 {p['creador']}  ·  🆔 {p['id']}"
            if p.get("tiene_clave"):
                info_extra += "  ·  🔒 con contraseña"
            elif p.get("tiene_codigo"):
                info_extra += "  ·  🔓 sin contraseña"
            tk.Label(tarjeta, text=info_extra, bg=colores["barra"], fg="#6c7086",
                     font=("Segoe UI", 8), anchor="w").pack(fill=tk.X, padx=10, pady=(4, 8))

            separador = tk.Frame(tarjeta, bg=colores.get("linea_actual", "#3a3a4a"), height=1)
            separador.pack(fill=tk.X, padx=10)

            marco_estados = tk.Frame(tarjeta, bg=colores["barra"])
            marco_estados.pack(fill=tk.X, padx=10, pady=8)
            _BotonSuave(marco_estados, bg=colores["barra"], bg_hover=colores.get("linea_actual", "#2a2a3a"),
                        text="📄 Abrir código", fg=colores.get("acento", "#7ec4ff"),
                        font=("Segoe UI", 8, "bold"),
                        command=lambda pid=p["id"], n=p["nombre"], c=p.get("tiene_clave", False):
                            self._abrir_codigo_proyecto(pid, n, c)
                        ).pack(side=tk.LEFT, padx=(0, 10))
            for estado_id, (texto_boton, color) in etiquetas_estado.items():
                if estado_id == p["estado"]:
                    continue
                _BotonSuave(marco_estados, bg=colores["barra"], bg_hover=colores.get("linea_actual", "#2a2a3a"),
                            text=f"→ {texto_boton}", fg=color, font=("Segoe UI", 8),
                            command=lambda pid=p["id"], e=estado_id: self._cambiar_estado_proyecto(pid, e)
                            ).pack(side=tk.LEFT, padx=(0, 6))

    def _cambiar_estado_proyecto(self, proyecto_id, nuevo_estado):
        if self.comunidad_actual is None:
            return
        comunidad_id = self.comunidad_actual["id"]

        def _trabajo():
            try:
                self.cliente.proyecto_actualizar_estado(comunidad_id, proyecto_id, nuevo_estado)
                proyectos = self.cliente.proyecto_listar(comunidad_id)
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                self.root.after(0, lambda: messagebox.showerror("SiPi World", str(e), parent=self.ventana))
                return
            self.root.after(0, lambda: self._dibujar_proyectos(proyectos))

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

    # ---------------------------------------------------------- diálogos
    def _dialogo_crear_comunidad(self):
        self._dialogo_formulario(
            "Crear comunidad",
            [("Nombre", "nombre", False), ("Descripción (opcional)", "descripcion", False)],
            lambda datos: self._crear_comunidad(datos["nombre"], datos.get("descripcion", "")),
        )

    def _crear_comunidad(self, nombre, descripcion):
        if not nombre.strip():
            messagebox.showwarning("SiPi World", "Ponele un nombre a la comunidad.", parent=self.ventana)
            return

        def _trabajo():
            try:
                r = self.cliente.comunidad_crear(nombre, descripcion)
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                self.root.after(0, lambda: messagebox.showerror("SiPi World", str(e), parent=self.ventana))
                return

            def _listo():
                self._refrescar_lista_comunidades()
                messagebox.showinfo(
                    "Comunidad creada",
                    f"'{nombre}' está lista. Compartí este código para que se sumen:\n\n{r['codigo_invitacion']}",
                    parent=self.ventana)
            self.root.after(0, _listo)

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

    def _dialogo_unirse_comunidad(self):
        self._dialogo_formulario(
            "Unirme a una comunidad",
            [("Código de invitación", "codigo", False)],
            lambda datos: self._unirse_comunidad(datos["codigo"]),
        )

    def _unirse_comunidad(self, codigo):
        if not codigo.strip():
            return

        def _trabajo():
            try:
                self.cliente.comunidad_unirse(codigo)
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                self.root.after(0, lambda: messagebox.showerror("SiPi World", str(e), parent=self.ventana))
                return
            self.root.after(0, self._refrescar_lista_comunidades)

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

    def _dialogo_crear_proyecto(self):
        if self.comunidad_actual is None:
            return
        self._dialogo_formulario(
            "Proponer un proyecto",
            [("Nombre del proyecto", "nombre", False), ("Descripción (opcional)", "descripcion", True),
             ("Contraseña del código (opcional -- para compartir con un amigo por ID + clave; "
              "vacío = cualquiera en la comunidad puede editarlo)", "clave_acceso", False)],
            lambda datos: self._crear_proyecto(
                datos["nombre"], datos.get("descripcion", ""), datos.get("clave_acceso", "")),
        )

    def _crear_proyecto(self, nombre, descripcion, clave_acceso=""):
        if not nombre.strip() or self.comunidad_actual is None:
            return
        comunidad_id = self.comunidad_actual["id"]

        def _trabajo():
            try:
                r = self.cliente.proyecto_crear(comunidad_id, nombre, descripcion, clave_acceso=clave_acceso)
                proyectos = self.cliente.proyecto_listar(comunidad_id)
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                self.root.after(0, lambda: messagebox.showerror("SiPi World", str(e), parent=self.ventana))
                return

            def _listo():
                self._dibujar_proyectos(proyectos)
                if clave_acceso.strip():
                    messagebox.showinfo(
                        "Proyecto creado",
                        f"'{nombre}' está listo (ID {r['id']}).\n\n"
                        f"Para que un amigo pueda traerse el código, pasale:\n"
                        f"  - El código de invitación de esta comunidad: {self.comunidad_actual['codigo_invitacion']}\n"
                        f"  - El ID del proyecto: {r['id']}\n"
                        f"  - La contraseña que elegiste\n\n"
                        f"Con esos tres datos puede abrir 'SiPi World' → unirse a la "
                        f"comunidad → pestaña Proyectos → 'Abrir código'.",
                        parent=self.ventana)
            self.root.after(0, _listo)

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

    def _dialogo_pedir_clave_proyecto(self, titulo, mensaje, al_confirmar):
        """Dialogo chico para pedir solo una contraseña (usado para abrir o
        guardar el código de un proyecto)."""
        colores = self.colores
        dialogo = tk.Toplevel(self.ventana)
        dialogo.title(titulo)
        dialogo.configure(bg=colores["fondo"])
        dialogo.transient(self.ventana)
        dialogo.grab_set()
        dialogo.resizable(False, False)

        tk.Label(dialogo, text=mensaje, bg=colores["fondo"], fg=colores["texto"], font=("Segoe UI", 9),
                 anchor="w", justify="left", wraplength=340).pack(fill=tk.X, padx=14, pady=(12, 6))
        var = tk.StringVar()
        entrada = tk.Entry(dialogo, textvariable=var, show="•", bg=colores.get("linea_actual"),
                            fg=colores["texto"], insertbackground=colores["texto"], relief="flat",
                            font=("Segoe UI", 10))
        entrada.pack(padx=14, fill=tk.X, ipady=4)
        entrada.focus_set()

        def _confirmar(evento=None):
            dialogo.destroy()
            al_confirmar(var.get())

        entrada.bind("<Return>", _confirmar)
        marco_botones = tk.Frame(dialogo, bg=colores["fondo"])
        marco_botones.pack(pady=14)
        _BotonSuave(marco_botones, bg=colores.get("acento", "#007acc"), bg_hover="#3a9bdb", text="Confirmar",
                    fg="white", padx=16, pady=5, command=_confirmar).pack(side=tk.LEFT, padx=4)
        _BotonSuave(marco_botones, bg=colores["barra"], bg_hover=colores.get("linea_actual", "#2a2a3a"),
                    text="Cancelar", fg=colores["texto"], padx=16, pady=5,
                    command=dialogo.destroy).pack(side=tk.LEFT, padx=4)

    def _abrir_codigo_proyecto(self, proyecto_id, nombre_proyecto, tiene_clave):
        if self.comunidad_actual is None:
            return
        comunidad_id = self.comunidad_actual["id"]

        def _pedir(clave_acceso):
            def _trabajo():
                try:
                    r = self.cliente.proyecto_obtener_codigo(comunidad_id, proyecto_id, clave_acceso)
                except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                    self.root.after(0, lambda: messagebox.showerror("SiPi World", str(e), parent=self.ventana))
                    return

                def _listo():
                    if not r["codigo_sipi"]:
                        messagebox.showinfo(
                            "SiPi World",
                            f"'{nombre_proyecto}' todavía no tiene código guardado. Escribí algo en el "
                            f"editor y usá Herramientas → 'Guardar en SiPi World' para subir el primero.",
                            parent=self.ventana)
                    self.editor.abrir_pestana_con_contenido(
                        r["codigo_sipi"], nombre_sugerido=nombre_proyecto,
                        sipi_world={"comunidad_id": comunidad_id, "proyecto_id": proyecto_id,
                                    "clave_acceso": clave_acceso, "nombre": nombre_proyecto})
                    self.editor.root.deiconify()
                    self.editor.root.lift()
                self.root.after(0, _listo)

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

        if tiene_clave:
            self._dialogo_pedir_clave_proyecto(
                "Abrir código",
                f"'{nombre_proyecto}' está protegido con contraseña. Pedísela a quien te compartió "
                f"el ID {proyecto_id}.",
                _pedir)
        else:
            _pedir("")

    def subir_codigo_pestana_actual(self, origen, codigo_sipi):
        """Llamado desde 'guardar_en_sipi_world' en el editor. 'origen' es
        el dict {comunidad_id, proyecto_id, clave_acceso, nombre} que quedó
        guardado en la pestaña al abrirla con '_abrir_codigo_proyecto'."""
        def _trabajo():
            try:
                self.cliente.proyecto_guardar_codigo(
                    origen["comunidad_id"], origen["proyecto_id"], codigo_sipi,
                    clave_acceso=origen.get("clave_acceso", ""))
            except (ErrorConexionSiPiWorld, ErrorSiPiWorld) as e:
                self.root.after(0, lambda: messagebox.showerror("SiPi World", str(e), parent=self.ventana))
                return
            self.root.after(0, lambda: messagebox.showinfo(
                "SiPi World", f"Código de '{origen['nombre']}' actualizado.", parent=self.ventana))

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

    def _dialogo_formulario(self, titulo, campos, al_confirmar):
        """Dialogo generico chico: 'campos' es [(etiqueta, clave, multilinea), ...]."""
        colores = self.colores
        dialogo = tk.Toplevel(self.ventana)
        dialogo.title(titulo)
        dialogo.configure(bg=colores["fondo"])
        dialogo.transient(self.ventana)
        dialogo.grab_set()
        dialogo.resizable(False, False)

        variables = {}
        widgets_multilinea = {}
        for etiqueta, clave, multilinea in campos:
            tk.Label(dialogo, text=etiqueta, bg=colores["fondo"], fg=colores["texto"],
                      font=("Segoe UI", 9), anchor="w").pack(fill=tk.X, padx=14, pady=(10, 2))
            if multilinea:
                w = tk.Text(dialogo, height=4, width=40, bg=colores.get("linea_actual"), fg=colores["texto"],
                            insertbackground=colores["texto"], relief="flat", font=("Segoe UI", 10))
                w.pack(padx=14, fill=tk.X)
                widgets_multilinea[clave] = w
            else:
                var = tk.StringVar()
                w = tk.Entry(dialogo, textvariable=var, bg=colores.get("linea_actual"), fg=colores["texto"],
                             insertbackground=colores["texto"], relief="flat", font=("Segoe UI", 10))
                w.pack(padx=14, fill=tk.X, ipady=4)
                variables[clave] = var
                if not widgets_multilinea and clave == campos[0][1]:
                    w.focus_set()

        def _confirmar():
            datos = {clave: var.get() for clave, var in variables.items()}
            datos.update({clave: w.get("1.0", tk.END).strip() for clave, w in widgets_multilinea.items()})
            dialogo.destroy()
            al_confirmar(datos)

        marco_botones = tk.Frame(dialogo, bg=colores["fondo"])
        marco_botones.pack(pady=14)
        _BotonSuave(marco_botones, bg=colores.get("acento", "#007acc"), bg_hover="#3a9bdb", text="Confirmar",
                    fg="white", padx=16, pady=5, command=_confirmar).pack(side=tk.LEFT, padx=4)
        _BotonSuave(marco_botones, bg=colores["barra"], bg_hover=colores.get("linea_actual", "#2a2a3a"),
                    text="Cancelar", fg=colores["texto"], padx=16, pady=5,
                    command=dialogo.destroy).pack(side=tk.LEFT, padx=4)

    # ---------------------------------------------------------- eventos en vivo
    def _sondear_eventos(self):
        """Se reinvoca solo cada 300ms via 'root.after' -- drena la cola de
        eventos que llegaron del servidor sin haber sido pedidos (mensajes
        nuevos de otra gente, proyectos nuevos) y actualiza la interfaz."""
        try:
            while True:
                evento = self.cliente.cola_eventos.get_nowait()
                self._procesar_evento(evento)
        except queue.Empty:
            pass
        if self.ventana.winfo_exists():
            self.root.after(300, self._sondear_eventos)

    def _procesar_evento(self, evento):
        tipo = evento.get("evento")
        if tipo == "mensaje_global" and hasattr(self, "panel_chat_global"):
            self._agregar_linea_chat(self.panel_chat_global, evento)
        elif tipo == "mensaje_comunidad" and self.comunidad_actual and \
                evento.get("comunidad_id") == self.comunidad_actual.get("id"):
            self._agregar_linea_chat(self.panel_chat_comunidad, evento)
        elif tipo in ("proyecto_nuevo", "proyecto_actualizado", "proyecto_codigo_actualizado") and \
                self.comunidad_actual and evento.get("comunidad_id") == self.comunidad_actual.get("id"):
            comunidad_id = self.comunidad_actual["id"]

            def _trabajo():
                try:
                    proyectos = self.cliente.proyecto_listar(comunidad_id)
                except (ErrorConexionSiPiWorld, ErrorSiPiWorld):
                    return
                self.root.after(0, lambda: self._dibujar_proyectos(proyectos))

            threading.Thread(target=_trabajo, daemon=True).start()
        elif tipo == "desconectado":
            if self._job_latencia:
                try:
                    self.root.after_cancel(self._job_latencia)
                except Exception:
                    pass
                self._job_latencia = None
            if self.ventana.winfo_exists():
                self._actualizar_barra_estado("desconectado", mensaje="Conexión perdida")
                messagebox.showwarning("SiPi World", "Se perdió la conexión con el servidor.", parent=self.ventana)
                self._mostrar_pantalla_conexion()

    # ---------------------------------------------------------- cierre
    def _desconectar(self):
        self.cliente.desconectar()
        self.cliente = SiPiWorldCliente()
        self.comunidad_actual = None
        self._mostrar_pantalla_conexion()

    def _cerrar(self):
        if self._job_latencia:
            try:
                self.root.after_cancel(self._job_latencia)
            except Exception:
                pass
        self._detener_spinner()
        self.cliente.desconectar()
        if getattr(self.editor, "_ventana_sipi_world", None) is self:
            self.editor._ventana_sipi_world = None
        self.ventana.destroy()
