#!/usr/bin/env python3
"""
editor_sipi.py - Editor visual propio para el lenguaje SiPi.
Real y funcional: resalta la sintaxis, muestra una vista previa en vivo del
resultado de tu programa mientras escribis, permite personalizar colores y
tipografia, guarda/abre archivos .sipi de verdad, y ejecuta o compila el
programa con el motor real de SiPi.
"""
import os
import sys
import re
import json
import subprocess
import threading
import importlib.util
import tempfile
import atexit
import shutil
import webbrowser

import sipi_debug

try:
    import sipi_actualizador
except ImportError:
    # Si por algun motivo el archivo no viaja junto al editor, el
    # buscador de actualizaciones simplemente queda deshabilitado -- no
    # tiene sentido que la falta de ESTE modulo opcional le impida a
    # alguien abrir el editor entero.
    sipi_actualizador = None


def _es_exe_empaquetado():
    """True cuando este codigo esta corriendo adentro de un .exe armado
    con PyInstaller (--onefile), en vez de con un 'python editor_sipi.py'
    normal desde el codigo fuente. PyInstaller marca esto seteando
    'sys.frozen = True' en el interprete embebido."""
    return getattr(sys, "frozen", False)


def _bandera_reentrada_motor():
    return "--sipi-motor-interno"


def _intentar_correr_como_motor_interno():
    """BUG DEL .EXE (raiz real, no un sintoma): el boton 'Ejecutar', la
    vista previa en vivo y el diagnostico de SiPi IA armaban un comando
    tipo  python "sipi.py" archivo.sipi  asumiendo (a) que hay un
    interprete 'python' instalable en el PATH de la maquina del usuario
    final, y (b) que 'sipi.py' existe como archivo suelto al lado del
    editor. Ninguna de las dos cosas es cierta una vez que el editor se
    empaqueta en un .exe con PyInstaller: no hay Python instalado en la
    maquina que se bajo el .exe (para eso lo bajo), y el motor de SiPi
    viaja comprimido/protegido DENTRO del propio .exe, no como .py
    suelto. Resultado real, tal como lo reporto el usuario: al tocar
    'Ejecutar' se abria una consola nueva mostrando
    "'python' no se reconoce como un comando interno..." (Windows) o el
    .exe simplemente se relanzaba a si mismo abriendo OTRA ventana del
    editor en vez de correr el programa (Linux/Mac).

    La solucion: el propio .exe puede re-invocarse a si mismo (con
    'sys.executable', que en un .exe empaquetado apunta al .exe mismo,
    no a un interprete de python) pasandole una bandera interna
    '--sipi-motor-interno <archivo.sipi>'. Esta funcion se llama ANTES
    de que arranque cualquier cosa de Tk: si detecta esa bandera,
    importa el motor de SiPi ya embebido como modulo Python normal
    (nunca como archivo suelto) y corre el archivo directo, sin
    necesitar ningun 'python' externo ni ningun .py al lado. Devuelve
    True si efectivamente actuo como motor (el llamador debe salir sin
    levantar la GUI); False en cualquier otro caso (arranque normal)."""
    bandera = _bandera_reentrada_motor()
    if bandera not in sys.argv:
        return False
    try:
        indice = sys.argv.index(bandera)
        ruta_archivo = sys.argv[indice + 1]
    except IndexError:
        print(f"[SiPi] Uso interno: --exe-o-python {bandera} <archivo.sipi>")
        sys.exit(1)
    for _stream in (sys.stdout, sys.stderr):
        try:
            _stream.reconfigure(encoding="utf-8", errors="replace")
        except (AttributeError, ValueError):
            pass
    try:
        try:
            import sipi_protegido as motor  # version empaquetada (protegida)
        except ImportError:
            import sipi as motor  # version de desarrollo (codigo fuente)
    except ImportError as error:
        print(f"[SiPi] ERROR: no se pudo cargar el motor de SiPi embebido: {error}")
        sys.exit(1)
    sys.argv = [sys.argv[0], ruta_archivo]
    motor.main()
    return True


try:
    import tkinter as tk
    from tkinter import filedialog, messagebox, colorchooser, ttk, simpledialog
    try:
        import customtkinter as ctk
        ctk.set_appearance_mode("dark")
        ctk.set_default_color_theme("blue")
        _CTK_DISPONIBLE = True
    except ImportError:
        ctk = None
        _CTK_DISPONIBLE = False
except ImportError:
    print("=" * 60)
    print("[SiPi] No se encontro el modulo 'tkinter' en este Python.")
    print("El editor visual de SiPi necesita tkinter para mostrar su")
    print("interfaz grafica.")
    print()
    print("Como solucionarlo:")
    print(" - Windows: reinstala Python desde python.org y asegurate de")
    print("   marcar la casilla 'tcl/tk and IDLE' durante la instalacion")
    print("   (viene tildada por defecto en el instalador oficial).")
    print(" - Linux (Debian/Ubuntu): sudo apt install python3-tk")
    print(" - Linux (Fedora): sudo dnf install python3-tkinter")
    print(" - macOS (Homebrew): brew install python-tk")
    print("=" * 60)
    sys.exit(1)

PALABRAS_CLAVE_RESPALDO = [
    "programa", "version", "variable", "var", "sumar", "restar", "decir",
    "imprimir", "preguntar", "si", "sino", "fin", "repetir", "veces",
    "mientras", "funcion", "llamar", "llamar_valor", "devolver",
    "crear_archivo", "leer_archivo", "borrar_archivo", "crear_carpeta",
    "copiar_archivo", "ejecutar", "esperar", "ventana", "boton", "etiqueta",
    "entrada", "imagen", "cuadro", "casilla", "lista", "barra_progreso",
    "actualizar_barra", "menu_desplegable", "pestanias", "pestana",
    "crear_juego", "sprite", "sonido", "chocar",
    "velocidad", "puntaje_inicial", "mostrar_puntaje", "generar_app_android",
    "generar_app_windows", "verdadero", "falso", "instalar_paquete",
    "guardar_dato", "obtener_dato", "borrar_dato", "obtener_url", "longitud",
    "mayusculas", "minusculas", "azar_entre", "raiz", "potencia",
    "generar_pagina_web", "iniciar_servidor_web", "lista_crear",
    "lista_agregar", "lista_obtener", "lista_longitud", "lista_eliminar",
    "lista_ordenar", "lista_invertir", "lista_contiene", "suma_lista",
    "promedio_lista", "para_cada", "en", "fecha_hora_actual",
    "listar_archivos", "hash_texto", "elegir_al_azar", "comprimir_carpeta",
    "descomprimir_zip", "y", "o", "no", "intentar", "capturar",
    "diccionario_crear", "diccionario_asignar", "diccionario_obtener",
    "diccionario_tiene", "diccionario_eliminar", "diccionario_claves",
    "texto_dividir", "texto_reemplazar", "texto_contiene", "minimo",
    "maximo", "redondear", "registrar_evento", "PI", "E",
    "importar", "modo_debug", "matriz_crear", "matriz_asignar",
    "matriz_obtener", "matriz_filas", "matriz_columnas", "mover_aleatorio",
    "json_crear", "json_leer", "json_guardar", "json_texto",
    "csv_leer", "csv_guardar", "cada", "segundos", "detener_temporizador",
    "enum", "estructura", "instanciar", "pagina_web", "titulo", "subtitulo",
    "enlace", "lista_web", "separador", "tarjeta", "tema", "color",
    "formulario", "campo",
]


def _cargar_palabras_clave():
    """Obtiene la lista de comandos reales a resaltar directamente del
    motor de SiPi (sipi.py o sipi_protegido.py, el que este disponible en
    esta misma carpeta), en vez de mantener una segunda lista a mano en el
    editor. Antes 'PALABRAS_CLAVE' era una copia fija que se iba
    desactualizando cada vez que se agregaba un comando nuevo al lenguaje
    (llegamos a tener 26 comandos reales -romper, continuar, sqlite_*,
    escuchar_ruta, tipo_de, lanzar_error, etc.- que el resaltado de
    sintaxis nunca coloreaba). Si por algun motivo no se puede cargar el
    motor, se usa la lista de respaldo de arriba para que el editor nunca
    se rompa."""
    try:
        aqui = os.path.dirname(os.path.abspath(__file__))
        motor = _cargar_motor_sipi(aqui)
        comandos = list(getattr(motor, "COMANDOS_CONOCIDOS", []))
        if comandos:
            return sorted(set(comandos) | {"PI", "E", "en", "y", "o", "no", "como", "desde", "con"})
    except Exception:
        pass
    return PALABRAS_CLAVE_RESPALDO


PALABRAS_CLAVE = PALABRAS_CLAVE_RESPALDO  # valor inicial; se actualiza en tiempo real mas abajo

# Fuentes monoespaciadas "profesionales" en orden de preferencia. 'Consolas'
# solo existe en Windows -- en Linux/Mac tkinter la resolvia a una fuente
# generica cualquiera y el editor se veia inconsistente segun el sistema
# operativo. _fuente_mono() detecta, una sola vez al arrancar, cual de estas
# esta realmente instalada (via tkinter.font.families()) y usa la primera
# que exista, con 'Courier New' como ultimo recurso garantizado.
_FUENTES_MONO_PREFERIDAS = [
    "Cascadia Code", "Cascadia Mono", "JetBrains Mono", "Fira Code",
    "Consolas", "DejaVu Sans Mono", "Menlo", "Courier New",
]
_FUENTE_MONO_DETECTADA = None


def _fuente_mono():
    """Devuelve el nombre de la mejor fuente monoespaciada disponible en
    este sistema. Resultado cacheado tras la primera llamada (requiere que
    ya exista una ventana de Tk creada)."""
    global _FUENTE_MONO_DETECTADA
    if _FUENTE_MONO_DETECTADA is not None:
        return _FUENTE_MONO_DETECTADA
    try:
        import tkinter.font as tkfont
        disponibles = set(tkfont.families())
        for candidata in _FUENTES_MONO_PREFERIDAS:
            if candidata in disponibles:
                _FUENTE_MONO_DETECTADA = candidata
                return candidata
    except Exception:
        pass
    _FUENTE_MONO_DETECTADA = "Courier New"
    return _FUENTE_MONO_DETECTADA


TEMAS = {
    "VS Code Oscuro (por defecto)": {
        # Paleta calcada de "Dark+" (el tema por defecto de Visual
        # Studio Code) -- pedido explicito: que SiPi se sienta tan
        # profesional/moderno como los editores actuales en vez del
        # aspecto "viejo" que tenia antes. Mismo criterio de contraste
        # AA que el resto de los temas, pero con los colores exactos
        # que millones de programadores ya reconocen de un vistazo.
        "fondo": "#1e1e1e", "texto": "#d4d4d4", "clave": "#569cd6",
        "cadena": "#ce9178", "comentario": "#6a9955", "numero": "#b5cea8",
        "barra": "#252526", "acento": "#007acc",
        "linea_actual": "#2a2d2e", "seleccion": "#264f78", "cursor": "#aeafad",
    },
    "Cyberpunk": {
        "fondo": "#1e1e2e", "texto": "#e0e0f0", "clave": "#7ec4ff",
        "cadena": "#a6e3a1", "comentario": "#6c7086", "numero": "#f9c74f",
        "barra": "#181825", "acento": "#40a02b",
        "linea_actual": "#282838", "seleccion": "#3b3b55", "cursor": "#7ec4ff",
    },
    "Claro": {
        "fondo": "#fafafa", "texto": "#1e1e1e", "clave": "#0057b7",
        "cadena": "#0a7d34", "comentario": "#9a9a9a", "numero": "#b56900",
        "barra": "#e5e5e5", "acento": "#0a7d34",
        "linea_actual": "#eef2f7", "seleccion": "#cfe3fb", "cursor": "#1e1e1e",
    },
    "Alto contraste": {
        "fondo": "#000000", "texto": "#ffffff", "clave": "#00ffea",
        "cadena": "#00ff00", "comentario": "#888888", "numero": "#ffff00",
        "barra": "#111111", "acento": "#00ff00",
        "linea_actual": "#1a1a1a", "seleccion": "#264f78", "cursor": "#ffffff",
    },
    "Nordico": {
        # Paleta inspirada en Nord (nordtheme.com): azules grisaceos frios,
        # buen contraste sin ser tan duro como el alto-contraste puro.
        "fondo": "#2e3440", "texto": "#eceff4", "clave": "#88c0d0",
        "cadena": "#a3be8c", "comentario": "#616e88", "numero": "#ebcb8b",
        "barra": "#242933", "acento": "#88c0d0",
        "linea_actual": "#363c4a", "seleccion": "#434c5e", "cursor": "#88c0d0",
    },
    "Atardecer": {
        # Paleta calida (tonos naranja/borravino) para quien prefiera un
        # fondo oscuro pero no azulado -- pensada para sesiones largas
        # de noche, con menos luz azul que el tema por defecto.
        "fondo": "#2b1e2b", "texto": "#f2e5d7", "clave": "#e8a87c",
        "cadena": "#c38d9e", "comentario": "#85667a", "numero": "#f4d35e",
        "barra": "#211621", "acento": "#e8a87c",
        "linea_actual": "#352636", "seleccion": "#4a3350", "cursor": "#e8a87c",
    },
    "Menta": {
        # Alternativa clara con acentos verdeazulados, para quien
        # prefiere fondo blanco pero no le convence el 'Claro' por
        # defecto (mas frio/gris) -- mas calido y suave a la vista.
        "fondo": "#f4faf7", "texto": "#1f2d27", "clave": "#0f7864",
        "cadena": "#2a7a3b", "comentario": "#7c9187", "numero": "#a6641a",
        "barra": "#e1f0e9", "acento": "#0f7864",
        "linea_actual": "#e7f2ec", "seleccion": "#bfe3d5", "cursor": "#0f7864",
    },
}


def _colores_con_defecto(colores):
    """Rellena 'linea_actual'/'seleccion'/'cursor' con valores razonables
    si el diccionario de un tema viejo (guardado en editor_config.json
    antes de que existieran estas claves, o un tema personalizado del
    usuario) todavia no los tiene -- para no romper con un KeyError."""
    resultado = dict(colores)
    resultado.setdefault("linea_actual", resultado.get("barra", "#282838"))
    resultado.setdefault("seleccion", resultado.get("acento", "#3b3b55"))
    resultado.setdefault("cursor", resultado.get("acento", resultado.get("texto", "white")))
    return resultado

def _carpeta_config_usuario():
    """Carpeta de configuracion del usuario, siguiendo XDG Base Directory
    en Linux/Mac ($XDG_CONFIG_HOME o ~/.config) y %APPDATA% en Windows.
    Bug real encontrado al empaquetar SiPi como .deb: antes,
    ARCHIVO_CONFIG vivia al lado de editor_sipi.py -- en una instalacion
    de sistema eso cae en /usr/share/sipi/, que un usuario sin privilegios
    de root no puede escribir. El editor tiraba un PermissionError apenas
    intentaba guardar el tema o el tamano de letra. Ahora la config vive
    en la carpeta de config del usuario, que siempre es escribible."""
    if os.name == "nt":
        base = os.environ.get("APPDATA") or os.path.expanduser("~")
    else:
        base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
    carpeta = os.path.join(base, "sipi")
    try:
        os.makedirs(carpeta, exist_ok=True)
    except OSError:
        # Si por algun motivo ni siquiera esto es escribible, se sigue
        # igual -- el guardado de config ya maneja el error mas abajo y
        # simplemente no persiste nada, no crashea el editor.
        pass
    return carpeta


ARCHIVO_CONFIG = os.path.join(_carpeta_config_usuario(), "editor_config.json")

# Curso online oficial de SiPi -- el tutorial integrado del editor cubre
# lo basico, pero para el curso completo con lecciones en video se linkea
# directo a esta pagina desde el menu Ayuda y desde la ventana de tutorial.
URL_CURSO_ONLINE = "https://gregarious-buttercream-e2a637.netlify.app/curso/"
# Item 9 del feedback ("recuperacion automatica"): a diferencia de
# 'editor_config.json' (preferencias, ahora en la carpeta de config del
# usuario) este archivo vive en la carpeta HOME del usuario, porque tiene
# que sobrevivir aunque SiPi se este ejecutando desde una carpeta temporal
# distinta cada vez (ver el aviso de carpeta volatil mas abajo) y porque
# es exactamente el tipo de dato que un cierre inesperado no deberia
# perder.
ARCHIVO_RECUPERACION = os.path.join(os.path.expanduser("~"), ".sipi_editor_recuperacion.json")


def _cargar_motor_sipi(aqui):
    """Importa el motor de SiPi bajo un nombre de modulo consistente, sin
    depender de que el archivo se llame literalmente 'sipi.py'. En la
    carpeta de desarrollo existe 'sipi.py'; en una carpeta ya publicada
    (generada por publicar.py/proteger_codigo.py) solo existe
    'sipi_protegido.py'. Antes el editor hacia 'import sipi' a secas, que
    fallaba con ModuleNotFoundError apenas se abria 'editor_protegido.py'
    en una carpeta publicada, porque ese archivo no existe ahi."""
    ruta_normal = os.path.join(aqui, "sipi.py")
    ruta_protegida = os.path.join(aqui, "sipi_protegido.py")
    if os.path.exists(ruta_normal):
        ruta = ruta_normal
    elif os.path.exists(ruta_protegida):
        ruta = ruta_protegida
    else:
        raise ImportError(
            "No se encontro 'sipi.py' ni 'sipi_protegido.py' en esta carpeta."
        )
    spec = importlib.util.spec_from_file_location("motor_sipi_dinamico", ruta)
    modulo = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(modulo)
    return modulo


PALABRAS_CLAVE = _cargar_palabras_clave()  # ahora si, con el motor ya definido arriba


def _obtener_version_local():
    """Version instalada de SiPi, leida directamente del motor real
    (VERSION en sipi.py o sipi_protegido.py) -- asi el buscador de
    actualizaciones siempre compara contra el numero real y no contra
    una copia a mano en el editor que se puede desactualizar."""
    try:
        aqui = os.path.dirname(os.path.abspath(__file__))
        motor = _cargar_motor_sipi(aqui)
        version = getattr(motor, "VERSION", None)
        if version:
            return str(version)
    except Exception:
        pass
    return "desconocida"


VERSION_SIPI = _obtener_version_local()


class EditorSiPi:
    def __init__(self, root):
        self.root = root
        self.root.title("SiPi - Editor Visual")
        self.root.geometry("1250x700")
        self.root.minsize(900, 560)
        self._aplicar_icono_ventana()
        self.archivo_actual = None
        self.proceso_vista_previa = None
        self.tarea_pendiente = None
        # Item 8 del feedback ("indicador de cambios"): rastrea si hay
        # cambios sin guardar para mostrar "main.sipi *" en titulo/estado,
        # igual que cualquier IDE. Se pone en True en cada tecla y en False
        # justo despues de guardar con exito.
        self.modificado = False
        # Item 6/7 ("ejecutar sin guardar"): carpeta temporal propia de
        # esta sesion del editor, creada una sola vez y NO borrada hasta
        # que el editor se cierra (o se vuelve a ejecutar). Antes no
        # existia ningun archivo temporal para el caso "sin guardar": el
        # boton Ejecutar directamente exigia guardar primero. Ahora, si no
        # hay archivo_actual (o el contenido no coincide con lo guardado),
        # se escribe el contenido actual aca y se ejecuta desde ahi -- el
        # archivo persiste el tiempo suficiente para que el proceso lo
        # lea, a diferencia de un NamedTemporaryFile(delete=True) que se
        # borra apenas se cierra el 'with', antes de que el proceso hijo
        # (que arranca async con Popen/'start cmd') llegue a abrirlo.
        self.carpeta_temporal_ejecucion = tempfile.mkdtemp(prefix="sipi_editor_")
        self._ruta_temporal_actual = None
        # Item 14 del feedback ("pestañas, poder abrir varios .sipi"):
        # cada pestaña guarda su propio archivo/contenido/estado de
        # modificado. Solo el buffer de la pestaña ACTIVA vive en
        # 'self.texto' en cada momento -- al cambiar de pestaña, el
        # contenido actual se guarda en 'self.pestanas[idx]' antes de
        # cargar el de la nueva (ver '_guardar_estado_pestana_actual' /
        # '_cambiar_a_pestana').
        self.pestanas = [{"archivo": None, "contenido": "", "modificado": False}]
        self.indice_pestana_actual = 0

        self.config = self._cargar_config()
        self.tema_actual = self.config.get("tema", "VS Code Oscuro (por defecto)")
        self.tamano_fuente = self.config.get("tamano_fuente", 12)
        self.vista_previa_activa = self.config.get("vista_previa_activa", True)

        self.root.configure(bg=self._colores()["fondo"])

        self._crear_barra_menu()
        self._crear_barra_herramientas()
        self._crear_area_principal()
        self._crear_barra_estado()
        self._crear_panel_terminal()

        self.texto.bind("<KeyRelease>", self._al_escribir)
        # El pegado (Ctrl+V / clic derecho > Pegar) puede insertar muchas
        # lineas de una sola vez -- el resaltado incremental de
        # '_al_escribir' (que solo mira la linea del cursor) no alcanza
        # ahi, asi que <<Paste>> fuerza una pasada completa aparte.
        self.texto.bind("<<Paste>>", lambda e: self.root.after(1, self._resaltar_sintaxis))
        # <<Modified>> se dispara con CUALQUIER cambio al contenido, sea
        # tecleado o programatico (insert/delete desde codigo: cargar un
        # archivo, deshacer hasta vaciar, recuperacion tras un cierre
        # inesperado, etc.) -- a diferencia de <KeyRelease>, que solo
        # avisa de teclas presionadas. Antes la bienvenida solo se
        # ocultaba con <KeyRelease>, asi que cualquier carga de texto
        # por codigo la dejaba pegada arriba del editor para siempre.
        self.texto.bind("<<Modified>>", self._al_modificarse_contenido)
        self.popup_autocompletar = None
        self.lista_autocompletar = None
        self._motor_sipi_cache = None
        self.texto.bind("<Tab>", self._aceptar_autocompletado)
        self.texto.bind("<Return>", self._al_presionar_enter)
        self.texto.bind("<Escape>", self._cerrar_autocompletado)
        self.texto.bind("<Down>", self._mover_autocompletado)
        self.texto.bind("<Up>", self._mover_autocompletado)
        self._aplicar_tema()
        self._configurar_atajos_teclado()
        atexit.register(self._limpiar_carpeta_temporal)
        self.root.protocol("WM_DELETE_WINDOW", self._al_cerrar_ventana)

        plantilla = (
            'programa "Mi primer programa"\n\n'
            'variable nombre = "Mundo"\n'
            'decir "Hola, {nombre}! Esto corre en SiPi de verdad."\n'
        )
        self.texto.insert("1.0", plantilla)
        self._resaltar_sintaxis()
        self._actualizar_numeros_linea()
        self._programar_vista_previa()
        self._actualizar_selector_nivel_desde_codigo()
        self.pestanas[0]["contenido"] = plantilla
        self._redibujar_barra_pestanas()
        # Bug real: insertar la plantilla arriba es programatico (no
        # dispara <KeyRelease>), asi que sin esta llamada la pantalla de
        # bienvenida se quedaba pegada arriba del codigo para siempre
        # -- nunca se enteraba de que la pestaña ya no estaba vacia.
        self._actualizar_visibilidad_bienvenida()
        self._id_recuperacion_pendiente = self.root.after(100, self._ofrecer_recuperacion_si_corresponde)
        self._banner_actualizacion = None
        # Chequeo silencioso de actualizaciones al arrancar -- 4 segundos
        # despues, para no competir por red/CPU con todo lo demas que se
        # esta inicializando (recuperacion, resaltado inicial, etc.) justo
        # al abrir el editor. No molesta a nadie si no hay novedades ni si
        # no hay internet (ver '_buscar_actualizaciones').
        self.root.after(4000, lambda: self._buscar_actualizaciones(manual=False))

    # ------- Configuracion persistente -------
    def _cargar_config(self):
        if os.path.exists(ARCHIVO_CONFIG):
            try:
                with open(ARCHIVO_CONFIG, "r", encoding="utf-8") as f:
                    return json.load(f)
            except (json.JSONDecodeError, OSError):
                return {}
        return {}

    def _guardar_config(self):
        self.config["tema"] = self.tema_actual
        self.config["tamano_fuente"] = self.tamano_fuente
        self.config["vista_previa_activa"] = self.vista_previa_activa
        try:
            with open(ARCHIVO_CONFIG, "w", encoding="utf-8") as f:
                json.dump(self.config, f, ensure_ascii=False, indent=2)
        except OSError:
            pass

    def _colores(self):
        return _colores_con_defecto(TEMAS.get(self.tema_actual, TEMAS["VS Code Oscuro (por defecto)"]))

    # ------- Interfaz -------
    def _aplicar_icono_ventana(self):
        """Icono de la ventana (barra de titulo, taskbar), tomado del
        mismo sistema de iconos incrustados en base64 -- asi tampoco
        depende de que un instalador copie un .ico o .png suelto."""
        try:
            import iconos_datos
            img = tk.PhotoImage(data=iconos_datos.ICONOS_EDITOR_B64["sipi_logo"])
            self.root.iconphoto(True, img)
            self._icono_ventana_ref = img  # evita que el garbage collector se lo lleve
        except Exception:
            pass

    def _icono(self, nombre, tamano=22):
        """Carga (con cache) un icono propio de SiPi, incrustado como
        base64 en iconos_datos.py (no leido de un archivo suelto -- ver
        el docstring de ese modulo para el porque: evita por completo un
        bug real de empaquetado donde un instalador copiaba el .py pero
        no la carpeta de iconos). Si por algun motivo el modulo de datos
        no esta disponible, cae a leer el .png de 'iconos_editor/' como
        respaldo, y si tampoco eso funciona, el boton usa solo texto --
        nunca un emoji, para mantener una interfaz consistente."""
        if not hasattr(self, "_cache_iconos"):
            self._cache_iconos = {}
        clave = (nombre, tamano)
        if clave in self._cache_iconos:
            return self._cache_iconos[clave]

        icono = None
        try:
            import iconos_datos
            crudo = tk.PhotoImage(data=iconos_datos.ICONOS_EDITOR_B64[nombre])
        except Exception:
            crudo = None
            ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "iconos_editor", nombre + ".png")
            try:
                crudo = tk.PhotoImage(file=ruta)
            except Exception:
                crudo = None

        if crudo is not None:
            try:
                factor = max(1, crudo.width() // tamano)
                if factor > 1:
                    crudo = crudo.subsample(factor, factor)
                icono = crudo
            except Exception:
                icono = None

        self._cache_iconos[clave] = icono
        return icono

    def _icono_ctk(self, nombre, photoimage):
        """Version CTkImage del icono cargado por '_icono', para usar en
        CTkButton sin el warning de customtkinter ni perder nitidez en
        pantallas HiDPI. Se arma con Pillow a partir de los mismos bytes
        base64 (no del PhotoImage ya reducido), para no perder calidad
        por un doble escalado. Cachea igual que '_icono'; si Pillow no
        esta instalado devuelve None y el llamador cae al PhotoImage
        crudo (que sigue funcionando, solo sin el escalado fino)."""
        if not _CTK_DISPONIBLE:
            return None
        if not hasattr(self, "_cache_iconos_ctk"):
            self._cache_iconos_ctk = {}
        if nombre in self._cache_iconos_ctk:
            return self._cache_iconos_ctk[nombre]
        resultado = None
        try:
            import base64
            import io
            from PIL import Image
            import iconos_datos
            crudo = base64.b64decode(iconos_datos.ICONOS_EDITOR_B64[nombre])
            imagen_pil = Image.open(io.BytesIO(crudo)).convert("RGBA")
            tamano = (photoimage.width(), photoimage.height())
            resultado = ctk.CTkImage(light_image=imagen_pil, dark_image=imagen_pil, size=tamano)
        except Exception:
            resultado = None
        self._cache_iconos_ctk[nombre] = resultado
        return resultado

    def _agregar_hover(self, widget, color_normal, color_hover, atributo="bg"):
        """Da feedback visual real al pasar el mouse (widgets tk.Label/
        tk.Button clasicos no lo hacen solos -- 'activebackground' de
        Button solo se ve mientras esta apretado, no al pasar por encima,
        y una interfaz sin ese feedback se siente menos terminada). Se
        usa para la barra de pestañas y, cuando CustomTkinter no esta
        instalado, para los botones de la barra de herramientas."""
        def _entrar(evento=None):
            try:
                widget.configure(**{atributo: color_hover})
            except Exception:
                pass

        def _salir(evento=None):
            try:
                widget.configure(**{atributo: color_normal})
            except Exception:
                pass

        widget.bind("<Enter>", _entrar, add="+")
        widget.bind("<Leave>", _salir, add="+")

    def _tooltip(self, widget, texto):
        """Tooltip simple (sin dependencias externas) que muestra el
        nombre de la accion y su atajo de teclado al pasar el mouse."""
        estado = {"ventana": None}

        def _mostrar(evento):
            if estado["ventana"] is not None:
                return
            x = widget.winfo_rootx() + 10
            y = widget.winfo_rooty() + widget.winfo_height() + 6
            ventana = tk.Toplevel(widget)
            ventana.wm_overrideredirect(True)
            ventana.wm_geometry(f"+{x}+{y}")
            tk.Label(ventana, text=texto, bg="#11111b", fg="#cdd6f4", font=("Segoe UI", 9),
                     padx=8, pady=4, bd=1, relief="solid").pack()
            estado["ventana"] = ventana

        def _ocultar(evento):
            if estado["ventana"] is not None:
                estado["ventana"].destroy()
                estado["ventana"] = None

        widget.bind("<Enter>", _mostrar, add="+")
        widget.bind("<Leave>", _ocultar, add="+")

    def _boton_icono(self, contenedor, icono, texto, comando, tooltip=None, bg="#313244",
                      activebackground="#45475a", fg=None, negrita=False, padx=10):
        """Boton profesional con icono vectorial propio (sin emojis) +
        texto corto al lado. Si el icono no carga, muestra solo el texto."""
        colores = self._colores()
        fg = fg or colores["texto"]
        img = self._icono(icono)

        if _CTK_DISPONIBLE:
            fuente = ctk.CTkFont(family="Segoe UI", size=13, weight="bold" if negrita else "normal")
            kwargs = dict(
                fg_color=bg, hover_color=activebackground, text_color=fg,
                font=fuente, corner_radius=6, border_width=0,
                command=comando, height=32, cursor="hand2",
            )
            if img is not None:
                # CTkButton pide un CTkImage (no un tk.PhotoImage crudo)
                # para escalar bien en pantallas HiDPI y para no tirar el
                # UserWarning de customtkinter en cada boton creado. Se
                # arma un CTkImage a partir del mismo icono via Pillow;
                # si Pillow no esta disponible se cae al PhotoImage crudo
                # (funciona igual, solo sin el escalado HiDPI).
                icono_ctk = self._icono_ctk(icono, img)
                b = ctk.CTkButton(contenedor, image=(icono_ctk or img), text=(" " + texto if texto else ""),
                                   compound="left", width=1 if not texto else 110, **kwargs)
                b.imagen = img
            else:
                b = ctk.CTkButton(contenedor, text=texto, width=90, **kwargs)
        else:
            estilo = {"bg": bg, "fg": fg, "bd": 0, "activebackground": activebackground,
                      "activeforeground": "white", "font": ("Segoe UI", 10, "bold" if negrita else "normal"),
                      "padx": padx, "pady": 6, "cursor": "hand2"}
            if img is not None:
                b = tk.Button(contenedor, image=img, text=(" " + texto if texto else ""),
                               compound=tk.LEFT, command=comando, **estilo)
                b.imagen = img  # evita que el garbage collector se lleve la imagen
            else:
                b = tk.Button(contenedor, text=texto, command=comando, **estilo)
            # tk.Button no da feedback visual al pasar el mouse por
            # encima (solo al hacer clic, via 'activebackground') --
            # se agrega hover real para que se sienta tan pulido como
            # la version con CustomTkinter.
            self._agregar_hover(b, bg, activebackground)

        b.pack(side=tk.LEFT, padx=4, pady=4)
        if tooltip:
            self._tooltip(b, tooltip)
        return b

    def _crear_barra_menu(self):
        """Barra de menu clasica (Archivo/Editar/Ver/Ejecutar/Herramientas/
        Ayuda), el elemento mas basico que le faltaba al editor para
        sentirse como un IDE serio -- antes TODO dependia de encontrar el
        boton correcto en una toolbar larga; ahora las mismas acciones
        (mas alguna extra, como 'Acerca de') tambien viven organizadas
        por categoria, con los atajos de teclado documentados al lado de
        cada una (que ya existian, ver '_configurar_atajos_teclado', pero
        no se veian en ningun lado de la interfaz)."""
        colores = self._colores()
        fuente_menu = ("Segoe UI", 10)
        menu = tk.Menu(self.root, tearoff=0, font=fuente_menu,
                        bg=colores["barra"], fg=colores["texto"],
                        activebackground="#45475a", activeforeground="white",
                        bd=0)
        self.root.configure(menu=menu)
        self._menu_principal = menu

        m_archivo = tk.Menu(menu, tearoff=0, font=fuente_menu, bg=colores["barra"], fg=colores["texto"],
                             activebackground="#45475a", activeforeground="white")
        m_archivo.add_command(label="Nuevo", accelerator="Ctrl+N", command=self.nuevo)
        m_archivo.add_command(label="Nueva pestaña", accelerator="Ctrl+T", command=self.nueva_pestana)
        m_archivo.add_separator()
        m_archivo.add_command(label="Abrir...", accelerator="Ctrl+O", command=self.abrir)
        m_archivo.add_command(label="Guardar", accelerator="Ctrl+S", command=self.guardar)
        m_archivo.add_command(label="Guardar como...", accelerator="Ctrl+Shift+S", command=self.guardar_como)
        m_archivo.add_separator()
        m_archivo.add_command(label="Cerrar pestaña", accelerator="Ctrl+W",
                               command=lambda: self._cerrar_pestana(self.indice_pestana_actual))
        m_archivo.add_separator()
        m_archivo.add_command(label="Salir", command=self._al_cerrar_ventana)
        menu.add_cascade(label="Archivo", menu=m_archivo)

        m_editar = tk.Menu(menu, tearoff=0, font=fuente_menu, bg=colores["barra"], fg=colores["texto"],
                            activebackground="#45475a", activeforeground="white")
        m_editar.add_command(label="Deshacer", accelerator="Ctrl+Z",
                              command=lambda: self.texto.edit_undo())
        m_editar.add_command(label="Rehacer", accelerator="Ctrl+Y",
                              command=lambda: self.texto.edit_redo())
        m_editar.add_separator()
        m_editar.add_command(label="Buscar...", accelerator="Ctrl+F", command=self.abrir_buscar)
        m_editar.add_command(label="Reemplazar...", accelerator="Ctrl+H", command=self.abrir_reemplazar)
        m_editar.add_command(label="Ir a línea...", accelerator="Ctrl+G", command=self.abrir_ir_a_linea)
        m_editar.add_separator()
        m_editar.add_command(label="Formatear código", accelerator="Ctrl+Shift+F",
                              command=self.formatear_codigo_actual)
        menu.add_cascade(label="Editar", menu=m_editar)

        m_ver = tk.Menu(menu, tearoff=0, font=fuente_menu, bg=colores["barra"], fg=colores["texto"],
                         activebackground="#45475a", activeforeground="white")
        m_temas = tk.Menu(m_ver, tearoff=0, font=fuente_menu, bg=colores["barra"], fg=colores["texto"],
                           activebackground="#45475a", activeforeground="white")
        for nombre_tema in TEMAS:
            m_temas.add_command(label=nombre_tema, command=lambda t=nombre_tema: self._cambiar_tema(t))
        m_ver.add_cascade(label="Tema", menu=m_temas)
        m_ver.add_command(label="Acercar", accelerator="Ctrl+=",
                           command=lambda: self._acercar_alejar_fuente(1))
        m_ver.add_command(label="Alejar", accelerator="Ctrl+-",
                           command=lambda: self._acercar_alejar_fuente(-1))
        m_ver.add_separator()
        m_ver.add_command(label="Mostrar/ocultar terminal", accelerator="Ctrl+`",
                           command=self._alternar_panel_terminal)
        m_ver.add_command(label="Refrescar explorador de archivos", command=self._refrescar_explorador)
        m_ver.add_separator()
        m_ver.add_command(label="Restablecer orden de la barra de herramientas",
                           command=self._restablecer_orden_barra)
        m_ver.add_separator()
        m_ver.add_command(label="Pantalla completa", accelerator="F11",
                           command=self._alternar_pantalla_completa)
        menu.add_cascade(label="Ver", menu=m_ver)

        m_ejecutar = tk.Menu(menu, tearoff=0, font=fuente_menu, bg=colores["barra"], fg=colores["texto"],
                              activebackground="#45475a", activeforeground="white")
        m_ejecutar.add_command(label="Ejecutar", accelerator="F5 / Ctrl+Enter", command=self.ejecutar)
        m_ejecutar.add_command(label="Depurar paso a paso", accelerator="Ctrl+D", command=self.abrir_depurador)
        m_ejecutar.add_separator()
        m_ejecutar.add_command(label="Compilar a ejecutable...", command=self.compilar_exe)
        menu.add_cascade(label="Ejecutar", menu=m_ejecutar)

        m_herramientas = tk.Menu(menu, tearoff=0, font=fuente_menu, bg=colores["barra"], fg=colores["texto"],
                                  activebackground="#45475a", activeforeground="white")
        m_herramientas.add_command(label="SiPi IA...", accelerator="Ctrl+I", command=self.abrir_sipi_ia)
        m_herramientas.add_command(label="SiPi IA -- Traductor (SPIR)...", command=self.abrir_traductor_spir)
        m_herramientas.add_command(label="Revisar código...", accelerator="Ctrl+Shift+R", command=self.abrir_revision_codigo)
        m_herramientas.add_command(label="Galería de ejemplos...", command=self.abrir_galeria_ejemplos)
        m_herramientas.add_separator()
        m_herramientas.add_command(label="SiPi World (chat y comunidades)...", command=self.abrir_sipi_world)
        m_herramientas.add_command(label="Guardar en SiPi World", command=self.guardar_en_sipi_world)
        m_herramientas.add_separator()
        m_herramientas.add_command(label="Preferencias...", command=self.abrir_configuracion)
        menu.add_cascade(label="Herramientas", menu=m_herramientas)

        m_ayuda = tk.Menu(menu, tearoff=0, font=fuente_menu, bg=colores["barra"], fg=colores["texto"],
                           activebackground="#45475a", activeforeground="white")
        m_ayuda.add_command(label="Tutorial", accelerator="F1", command=self.abrir_tutorial)
        m_ayuda.add_command(label="Curso online (con lecciones en video)", command=self.abrir_curso_online)
        m_ayuda.add_separator()
        m_ayuda.add_command(label="Buscar actualizaciones...", command=lambda: self._buscar_actualizaciones(manual=True))
        m_ayuda.add_separator()
        m_ayuda.add_command(label="Acerca de SiPi", command=self._mostrar_acerca_de)
        menu.add_cascade(label="Ayuda", menu=m_ayuda)

    def abrir_curso_online(self):
        """Abre el curso oficial de SiPi (lecciones en video, mas completo
        que el tutorial rapido integrado) en el navegador del sistema."""
        try:
            webbrowser.open(URL_CURSO_ONLINE)
        except Exception as e:
            messagebox.showerror("SiPi", f"No se pudo abrir el navegador:\n{e}")

    def _mostrar_acerca_de(self):
        """No existia ningun 'Acerca de' -- un editor sin forma de ver su
        propia version/autoria de un vistazo se siente menos terminado."""
        messagebox.showinfo(
            "Acerca de SiPi",
            f"SiPi - Editor Visual (v{VERSION_SIPI})\n\n"
            "Un lenguaje de programación en español pensado para\n"
            "aprender programando cosas reales: interfaces gráficas,\n"
            "juegos, web, audio y más.\n\n"
            "Repositorio y documentación completa en docs/README.md\n"
            "https://github.com/epsiliusoficial/SiPi",
        )

    # ------- Buscador de actualizaciones -------
    def _buscar_actualizaciones(self, manual=False):
        """Pregunta (en un hilo aparte, para no trabar la interfaz ni un
        instante) si hay una version mas nueva de SiPi publicada en el
        repositorio oficial. 'manual=True' es cuando el usuario lo pide
        desde el menu Ayuda: ahi siempre se pregunta a la red y siempre
        se le contesta algo, incluso 'ya estas al dia' o 'no hay
        conexion'. 'manual=False' es el chequeo silencioso automatico al
        arrancar: respeta el limite de una vez por dia y, si no hay
        actualizacion o no hay conexion, no interrumpe a nadie."""
        if sipi_actualizador is None:
            if manual:
                messagebox.showwarning(
                    "Buscar actualizaciones",
                    "El buscador de actualizaciones no esta disponible en esta instalación\n"
                    "(falta el archivo 'sipi_actualizador.py' junto al editor).",
                )
            return

        def _tarea_de_fondo():
            resultado = sipi_actualizador.verificar_actualizacion(VERSION_SIPI, forzar=manual)
            # Tk no es "thread-safe": cualquier cosa que toque widgets
            # tiene que volver al hilo principal via root.after(0, ...),
            # nunca ejecutarse directo desde este hilo de red.
            try:
                self.root.after(0, lambda: self._procesar_resultado_actualizacion(resultado, manual))
            except RuntimeError:
                pass  # la ventana ya se cerro mientras se esperaba la respuesta de red

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

    def _procesar_resultado_actualizacion(self, resultado, manual):
        if manual:
            if not resultado["chequeado"]:
                messagebox.showinfo(
                    "Buscar actualizaciones",
                    "No se pudo conectar con GitHub para verificar si hay una version nueva.\n"
                    "Revisá tu conexión a internet e intentá de nuevo más tarde.",
                )
                return
            if resultado["hay_actualizacion"]:
                self._mostrar_banner_actualizacion(resultado["version_remota"])
                messagebox.showinfo(
                    "Buscar actualizaciones",
                    f"Hay una versión nueva disponible: v{resultado['version_remota']}\n"
                    f"(la instalada es v{resultado['version_actual']}).\n\n"
                    f"Descargala desde:\n{resultado['url']}",
                )
            else:
                messagebox.showinfo(
                    "Buscar actualizaciones",
                    f"Ya tenés la última versión de SiPi (v{resultado['version_actual']}).",
                )
            return

        # Chequeo automatico y silencioso: solo se muestra algo si
        # efectivamente hay una version mas nueva, y solo una vez por
        # version (no en cada arranque del editor).
        if resultado["chequeado"] and resultado["hay_actualizacion"]:
            version_remota = resultado["version_remota"]
            if not sipi_actualizador.ya_se_avisto_esta_version(version_remota):
                self._mostrar_banner_actualizacion(version_remota)
                sipi_actualizador.marcar_version_avisada(version_remota)

    def _mostrar_banner_actualizacion(self, version_remota):
        """Franja discreta arriba de todo el editor (como el aviso de
        'hay una actualizacion' de VS Code) en vez de un popup molesto
        que corta lo que el usuario esta escribiendo. Se puede cerrar
        con la 'x' y no vuelve a aparecer para esa misma version."""
        if getattr(self, "_banner_actualizacion", None) is not None:
            try:
                self._banner_actualizacion.destroy()
            except tk.TclError:
                pass

        banner = tk.Frame(self.root, bg="#1e66f5", height=32)
        banner.pack(side=tk.TOP, fill=tk.X, before=self.root.winfo_children()[0])
        banner.pack_propagate(False)

        tk.Label(
            banner,
            text=f"⬆  Hay una nueva versión de SiPi disponible (v{version_remota}).",
            bg="#1e66f5", fg="white", font=("Segoe UI", 10, "bold"), padx=10,
        ).pack(side=tk.LEFT, pady=4)

        def _abrir_repo():
            try:
                webbrowser.open(sipi_actualizador.URL_REPOSITORIO)
            except Exception:
                pass

        tk.Button(
            banner, text="Ver en GitHub", command=_abrir_repo, bd=0, padx=10,
            bg="white", fg="#1e66f5", activebackground="#e6e6e6", cursor="hand2",
            font=("Segoe UI", 9, "bold"),
        ).pack(side=tk.LEFT, padx=8)

        tk.Button(
            banner, text="✕", command=banner.destroy, bd=0, padx=8,
            bg="#1e66f5", fg="white", activebackground="#3f7ff7", cursor="hand2",
            font=("Segoe UI", 10),
        ).pack(side=tk.RIGHT, padx=6)

        self._banner_actualizacion = banner

    def _boton_split(self, contenedor, icono, texto, comando_principal, opciones,
                      tooltip=None, bg="#40a02b", activebackground="#5fc93f"):
        """Boton con flechita desplegable al lado (un 'boton dentro de
        boton', el patron split-button de VS Code/Office: click en el
        cuerpo principal hace la accion por defecto, click en la
        flechita despliega variantes relacionadas) en vez de una fila
        cada vez mas larga de botones sueltos para acciones parecidas.
        'opciones' es una lista de (etiqueta, comando)."""
        colores = self._colores()
        envoltorio = tk.Frame(contenedor, bg=colores["barra"])
        envoltorio.pack(side=tk.LEFT, padx=(4, 4), pady=4)

        boton_principal = self._boton_icono(envoltorio, icono, texto, comando_principal, tooltip,
                                             bg=bg, activebackground=activebackground, fg="white", negrita=True)
        boton_principal.pack(side=tk.LEFT, padx=(0, 1))

        menu_desplegable = tk.Menu(self.root, tearoff=0, font=("Segoe UI", 10),
                                    bg=colores["barra"], fg=colores["texto"],
                                    activebackground="#45475a", activeforeground="white")
        for etiqueta, funcion in opciones:
            menu_desplegable.add_command(label=etiqueta, command=funcion)

        def _abrir_menu():
            x = boton_flecha.winfo_rootx()
            y = boton_flecha.winfo_rooty() + boton_flecha.winfo_height()
            menu_desplegable.tk_popup(x, y)

        if _CTK_DISPONIBLE:
            boton_flecha = ctk.CTkButton(envoltorio, text="▾", width=22, height=32,
                                          fg_color=bg, hover_color=activebackground, text_color="white",
                                          corner_radius=6, command=_abrir_menu, cursor="hand2")
        else:
            boton_flecha = tk.Button(envoltorio, text="▾", bg=bg, fg="white", bd=0,
                                      activebackground=activebackground, activeforeground="white",
                                      font=("Segoe UI", 9), width=2, cursor="hand2", command=_abrir_menu)
            self._agregar_hover(boton_flecha, bg, activebackground)
        boton_flecha.pack(side=tk.LEFT)
        self._tooltip(boton_flecha, "Más opciones")
        return boton_principal

    def _crear_barra_herramientas(self):
        colores = self._colores()
        altura = 48

        # La barra de herramientas tiene bastantes botones y en ventanas
        # angostas (o en pantallas chicas, notebooks, etc.) no entran
        # todos: antes simplemente se cortaban fuera de la ventana y
        # quedaban invisibles/inaccesibles, sin ningun aviso. Ahora la
        # barra vive dentro de un Canvas con scroll horizontal (rueda del
        # mouse, o Shift+rueda en Windows/Mac): ningun boton se pierde,
        # solo hay que desplazarse para llegar a el. El resto del codigo
        # de este metodo no cambia -- 'self.barra' sigue siendo el
        # contenedor donde se cuelgan los botones, como antes.
        self.barra_exterior = tk.Frame(self.root, bg=colores["barra"], height=altura)
        self.barra_exterior.pack(side=tk.TOP, fill=tk.X)
        self.barra_exterior.pack_propagate(False)

        self.barra_canvas = tk.Canvas(self.barra_exterior, bg=colores["barra"], height=altura,
                                       highlightthickness=0, bd=0)
        self.barra_canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)

        if _CTK_DISPONIBLE:
            self.barra = ctk.CTkFrame(self.barra_canvas, fg_color=colores["barra"], height=48, corner_radius=0)
        else:
            self.barra = tk.Frame(self.barra_canvas, bg=colores["barra"], height=44)

        self._id_ventana_barra = self.barra_canvas.create_window((0, 0), window=self.barra, anchor="nw")

        def _actualizar_scroll_barra(evento=None):
            self.barra_canvas.configure(scrollregion=self.barra_canvas.bbox("all"))
            # La altura real del contenido (con el selector de nivel, el
            # grupo de zoom, etc.) puede ser mayor que el valor inicial
            # de 'altura' -- se ajusta el canvas a la altura real para
            # no recortar la parte de abajo de los botones (un canvas
            # mas bajo que su contenido oculta silenciosamente lo que
            # sobra, en vez de mostrarlo todo como hacia el Frame simple
            # de antes).
            alto_contenido = self.barra.winfo_reqheight()
            self.barra_canvas.configure(height=alto_contenido)
            self.barra_exterior.configure(height=alto_contenido)
            ancho_disponible = self.barra_canvas.winfo_width()
            if self.barra.winfo_reqwidth() < ancho_disponible:
                self.barra_canvas.itemconfig(self._id_ventana_barra, width=ancho_disponible)
            else:
                self.barra_canvas.itemconfig(self._id_ventana_barra, width=self.barra.winfo_reqwidth())

        self.barra.bind("<Configure>", _actualizar_scroll_barra)
        self.barra_canvas.bind("<Configure>", _actualizar_scroll_barra)

        def _scroll_horizontal_barra(evento):
            # Linux manda Button-4/5 con la rueda; Windows/Mac mandan
            # <MouseWheel> con 'delta'. Se soportan ambos para que el
            # scroll de la barra funcione igual en cualquier sistema.
            if getattr(evento, "num", None) == 4:
                direccion = -1
            elif getattr(evento, "num", None) == 5:
                direccion = 1
            else:
                direccion = -1 if evento.delta > 0 else 1
            self.barra_canvas.xview_scroll(direccion * 3, "units")

        self.barra_canvas.bind("<MouseWheel>", _scroll_horizontal_barra)
        self.barra_canvas.bind("<Button-4>", _scroll_horizontal_barra)
        self.barra_canvas.bind("<Button-5>", _scroll_horizontal_barra)

        self.estilo_boton = {"bg": "#313244", "fg": colores["texto"], "bd": 0,
                              "activebackground": "#45475a", "activeforeground": "white",
                              "font": ("Segoe UI", 10), "padx": 10, "pady": 6}

        # --- Grupo archivo (siempre visibles: son las acciones que se
        # usan en CADA sesion de trabajo) ---
        self._boton_icono(self.barra, "nuevo", "Nuevo", self.nuevo, "Nuevo archivo (Ctrl+N)")
        self._boton_icono(self.barra, "abrir", "Abrir", self.abrir, "Abrir archivo (Ctrl+O)")
        self._boton_icono(self.barra, "guardar", "Guardar", self.guardar, "Guardar (Ctrl+S)")

        self._separador_barra()

        # --- Grupo ejecucion ---
        # 'Ejecutar' es un split-button: el cuerpo principal corre el
        # programa como siempre, y la flechita despliega variantes
        # relacionadas (ejecutar sin cache, depurar, compilar) -- esto
        # es lo que permitio sacar los botones sueltos de "Compilar" y
        # "Depurar" que antes vivian aparte en la barra: la accion
        # sigue a un clic de distancia (en la flechita) en vez de
        # perderse, pero ya no ocupa un boton propio todo el tiempo.
        self._boton_split(
            self.barra, "ejecutar", "Ejecutar", self.ejecutar,
            [
                ("▶  Ejecutar", self.ejecutar),
                ("↻  Ejecutar sin caché", self.ejecutar_sin_cache),
                ("🐞  Depurar paso a paso (Ctrl+D)", self.abrir_depurador),
                ("⚙  Compilar a ejecutable...", self.compilar_exe),
                ("✎  Formatear código (Ctrl+Shift+F)", self.formatear_codigo_actual),
            ],
            tooltip="Ejecutar (F5 / Ctrl+Enter) -- flechita para más opciones",
        )

        self._separador_barra()

        # --- Grupo buscar (el de uso mas frecuente despues de guardar/
        # ejecutar; Reemplazar vive adentro de Buscar via Ctrl+H, no
        # hace falta un boton aparte) ---
        self._boton_icono(self.barra, "buscar", "Buscar", self.abrir_buscar,
                           "Buscar en el archivo (Ctrl+F) -- Ctrl+H para reemplazar")

        # --- Grupo zoom ---
        marco_zoom = tk.Frame(self.barra, bg=colores["barra"])
        marco_zoom.pack(side=tk.LEFT, padx=(6, 2), pady=4)
        self._boton_icono(marco_zoom, "zoom_menos", "", lambda: self._acercar_alejar_fuente(-1),
                           "Achicar letra (Ctrl+-)")
        self._boton_icono(marco_zoom, "zoom_mas", "", lambda: self._acercar_alejar_fuente(1),
                           "Agrandar letra (Ctrl++)")

        self._separador_barra()

        # --- "Más" -- el resto de las herramientas (Terminal, Config,
        # Tutorial, Ejemplos) coleccionadas en UN solo boton desplegable
        # en vez de 4 botones sueltos. Antes la barra tenia ~14 botones
        # en fila y en pantallas normales la mitad quedaba fuera de
        # vista (habia que hacer scroll horizontal para notar que
        # existian); agruparlas ademas dentro de un split-button hace
        # que las acciones de uso diario (arriba) salten a la vista
        # mas rapido, sin perder nada -- todo sigue a un clic, solo que
        # organizado por frecuencia de uso en vez de todo al mismo nivel.
        self._boton_split(
            self.barra, "configurar", "Más", self.abrir_configuracion,
            [
                ("🖵  Mostrar/ocultar terminal (Ctrl+`)", self._alternar_panel_terminal),
                ("⚙  Preferencias...", self.abrir_configuracion),
                ("📖  Tutorial interactivo (F1)", self.abrir_tutorial),
                ("🗂  Galería de ejemplos", self.abrir_galeria_ejemplos),
                ("🌐  SiPi World (chat y comunidades)", self.abrir_sipi_world),
            ],
            tooltip="Más herramientas: terminal, preferencias, tutorial, ejemplos",
            bg="#6c7086", activebackground="#7f849c",
        )

        self._separador_barra()

        # --- Grupo IA (se deja siempre visible y destacado: es la
        # accion insignia de SiPi) ---
        boton_ia = self._boton_icono(self.barra, "ia", "SiPi IA", self.abrir_sipi_ia,
                                      "Asistente de IA: generar codigo y arreglar errores (Ctrl+I) "
                                      "-- clic derecho para configurar la clave de API",
                                      bg="#04a5e5", activebackground="#3fc1f0", fg="white", negrita=True)
        self._agregar_menu_configuracion_ia(boton_ia)
        self.boton_ia = boton_ia
        self._actualizar_boton_ia()

        # --- SiPi World: cuenta, chat global, comunidades y proyectos
        # con otra gente (ver sipi_world_ventana.py). Se deja al lado de
        # SiPi IA, como la otra accion "insignia" nueva del editor, en
        # vez de escondida adentro de "Más" -- aunque tambien vive ahi
        # (mas abajo) para quien la busque por costumbre.
        self._boton_icono(self.barra, "mundo", "SiPi World", self.abrir_sipi_world,
                           "SiPi World: chat global, comunidades y proyectos con otra gente",
                           bg="#8839ef", activebackground="#a565f5", fg="white", negrita=True)

        self._crear_selector_nivel()

        # Botones movibles: cualquier boton/grupo de la barra se puede
        # reubicar con clic derecho -> "Mover a la izquierda/derecha", y
        # el orden elegido se recuerda entre sesiones (ver
        # '_habilitar_reordenamiento_barra' mas abajo).
        self._habilitar_reordenamiento_barra()

        # Hairline de 1px debajo de toda la toolbar -- el mismo recurso
        # que ya separaba la barra de estado del editor, aplicado aca
        # tambien para que la "chrome" de la ventana (menu / toolbar /
        # pestañas / editor) se sienta hecha de capas apiladas y bien
        # definidas, como en VS Code, en vez de bloques de color que se
        # tocan sin ninguna separacion.
        tk.Frame(self.barra_exterior, bg="#45475a", height=1).pack(side=tk.BOTTOM, fill=tk.X)

    def _separador_barra(self):
        if _CTK_DISPONIBLE:
            ctk.CTkFrame(self.barra, fg_color="#45475a", width=1, corner_radius=0).pack(
                side=tk.LEFT, fill=tk.Y, padx=8, pady=8)
        else:
            tk.Frame(self.barra, bg="#45475a", width=1).pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=8)

    # ------- Barra de herramientas: botones movibles -------
    # Item pedido por el usuario: "que se puedan mover los botones". La
    # barra ya vivia como una fila de widgets empaquetados con
    # pack(side=LEFT) dentro de 'self.barra' (ver '_crear_barra_herramientas'
    # arriba) -- para reordenarlos alcanza con cambiar el ORDEN en que se
    # empaquetan, no hace falta reescribir la barra entera. Se eligio un
    # menu de clic derecho ("Mover a la izquierda/derecha") en vez de
    # arrastrar con el mouse: en Tkinter, arrastrar-y-soltar widgets reales
    # (no solo su dibujo) es fragil -- el layout de 'pack' se recalcula en
    # cada frame movido y el widget que se esta arrastrando puede terminar
    # "peleando" con el propio mouse a mitad de camino. El resultado final
    # es el mismo (los botones quedan donde el usuario elija) sin ese
    # riesgo de que la barra tiemble o un boton se pierda de vista.
    def _habilitar_reordenamiento_barra(self):
        for item in list(self.barra.pack_slaves()):
            self._hacer_item_barra_movible(item)
        self._aplicar_orden_barra_guardado()

    def _hacer_item_barra_movible(self, item):
        """Registra clic derecho en 'item' y en TODOS sus hijos (un
        boton con icono en realidad es un Frame con una imagen y un label
        adentro, y un boton split es un Frame con dos botones) para que
        no importe en que parte exacta del grupo se hace clic derecho --
        siempre se mueve el grupo entero como una unidad."""
        def _click_derecho(evento, item=item):
            self._menu_mover_item_barra(evento, item)

        def _registrar(widget):
            widget.bind("<Button-3>", _click_derecho, add="+")
            for hijo in widget.winfo_children():
                _registrar(hijo)

        _registrar(item)

    def _menu_mover_item_barra(self, evento, item):
        colores = self._colores()
        menu = tk.Menu(self.root, tearoff=0, font=("Segoe UI", 10),
                        bg=colores["barra"], fg=colores["texto"],
                        activebackground="#45475a", activeforeground="white")
        menu.add_command(label="◀  Mover a la izquierda", command=lambda: self._mover_item_barra(item, -1))
        menu.add_command(label="▶  Mover a la derecha", command=lambda: self._mover_item_barra(item, 1))
        menu.add_separator()
        menu.add_command(label="Restablecer orden de la barra", command=self._restablecer_orden_barra)
        menu.tk_popup(evento.x_root, evento.y_root)

    def _mover_item_barra(self, item, direccion):
        hijos = list(self.barra.pack_slaves())
        if item not in hijos:
            return
        idx = hijos.index(item)
        nuevo_idx = idx + direccion
        if not (0 <= nuevo_idx < len(hijos)):
            return  # ya esta en una punta, no hay a donde moverlo
        hijos[idx], hijos[nuevo_idx] = hijos[nuevo_idx], hijos[idx]
        self._reempaquetar_barra(hijos)
        self._guardar_orden_barra(hijos)

    def _reempaquetar_barra(self, hijos_en_orden):
        """Vuelve a empaquetar los hijos de 'self.barra' en el orden dado,
        preservando las opciones de pack (padx/pady/fill/etc.) que cada
        uno ya tenia -- asi un separador sigue siendo angosto y un grupo
        con margen extra (como el selector de nivel) no pierde su
        espaciado solo por haberse movido de lugar."""
        info_previa = {w: w.pack_info() for w in hijos_en_orden}
        for w in hijos_en_orden:
            w.pack_forget()
        for w in hijos_en_orden:
            w.pack(**info_previa[w])

    def _guardar_orden_barra(self, hijos_en_orden):
        # Se guarda el nombre interno de cada widget (estable entre
        # ejecuciones porque se generan en el mismo orden de creacion en
        # cada arranque, siempre que no cambie el codigo que arma la
        # barra) -- no el texto del boton, que puede repetirse o cambiar
        # con el idioma.
        self.config["orden_barra_herramientas"] = [str(w) for w in hijos_en_orden]
        self._guardar_config()

    def _aplicar_orden_barra_guardado(self):
        nombres_guardados = self.config.get("orden_barra_herramientas")
        if not nombres_guardados:
            return
        hijos_actuales = list(self.barra.pack_slaves())
        por_nombre = {str(w): w for w in hijos_actuales}
        # Si la version del editor cambio la cantidad/identidad de
        # botones desde que se guardo el orden, no se aplica nada -- es
        # preferible mostrar el orden por defecto y confiable a intentar
        # adivinar donde encajan botones nuevos que el usuario nunca vio.
        if set(nombres_guardados) != set(por_nombre.keys()):
            return
        nuevo_orden = [por_nombre[n] for n in nombres_guardados]
        self._reempaquetar_barra(nuevo_orden)

    def _restablecer_orden_barra(self):
        self.config.pop("orden_barra_herramientas", None)
        self._guardar_config()
        messagebox.showinfo(
            "Barra de herramientas",
            "Se restableció el orden de fábrica. El cambio se ve la próxima vez que abras SiPi.",
        )

    def _crear_selector_nivel(self):
        """Selector de nivel de dificultad (#nivel principiante/facil/medio/dificil/extremo).

        El motor de SiPi (sipi.py) ya tiene este sistema implementado hace
        varias versiones -- restringe que comandos se pueden usar segun el
        nivel elegido, con avisos claros de que nivel desbloquea cada
        comando -- pero no habia ninguna forma de elegirlo desde el editor
        visual sin escribir la directiva '#nivel' a mano en la primera
        linea. Este selector la lee/escribe por vos."""
        colores = self._colores()
        marco = tk.Frame(self.barra, bg=colores["barra"])
        marco.pack(side=tk.LEFT, padx=(16, 4), pady=4)

        tk.Label(marco, text="Nivel:", bg=colores["barra"], fg=colores["texto"],
                 font=("Segoe UI", 10)).pack(side=tk.LEFT, padx=(0, 4))

        self.OPCIONES_NIVEL = ["Sin restriccion", "Principiante", "Facil", "Medio", "Dificil", "Extremo"]
        self.nivel_var = tk.StringVar(value="Sin restriccion")
        self.selector_nivel = ttk.Combobox(marco, textvariable=self.nivel_var, values=self.OPCIONES_NIVEL,
                                            state="readonly", width=14, font=("Segoe UI", 10))
        self.selector_nivel.pack(side=tk.LEFT)
        self.selector_nivel.bind("<<ComboboxSelected>>", self._al_cambiar_nivel)

    def _leer_nivel_actual_del_codigo(self):
        """Busca una directiva '#nivel X' entre las primeras lineas no vacias
        del texto actual del editor. Devuelve el nombre capitalizado
        ('Principiante', etc.) o 'Sin restriccion' si no hay ninguna."""
        motor = self._obtener_motor()
        for linea in self.texto.get("1.0", tk.END).split("\n")[:5]:
            m = re.match(r"^#\s*nivel\s+(\w+)\s*$", linea.strip(), re.IGNORECASE)
            if m and motor and m.group(1).lower() in motor.NIVELES_SIPI:
                return m.group(1).lower().capitalize()
        return "Sin restriccion"

    def _actualizar_selector_nivel_desde_codigo(self):
        """Sincroniza el combobox con lo que diga el archivo recien abierto/
        cargado, para no pisar una directiva ya presente con 'Sin restriccion'
        por defecto."""
        if hasattr(self, "nivel_var"):
            self.nivel_var.set(self._leer_nivel_actual_del_codigo())

    def _al_cambiar_nivel(self, evento=None):
        """Reescribe (o quita) la directiva '#nivel' en la primera linea no
        vacia del programa, segun lo elegido en el combobox. No toca el
        resto del codigo."""
        elegido = self.nivel_var.get()
        contenido = self.texto.get("1.0", tk.END)
        lineas = contenido.split("\n")

        # Sacar cualquier directiva '#nivel' preexistente en las primeras lineas
        idx_directiva = None
        for i, linea in enumerate(lineas[:5]):
            if re.match(r"^#\s*nivel\s+\w+\s*$", linea.strip(), re.IGNORECASE):
                idx_directiva = i
                break

        if idx_directiva is not None:
            del lineas[idx_directiva]

        if elegido != "Sin restriccion":
            lineas.insert(0, f"#nivel {elegido.lower()}")

        nuevo_contenido = "\n".join(lineas)
        pos_cursor = self.texto.index(tk.INSERT)
        self.texto.delete("1.0", tk.END)
        self.texto.insert("1.0", nuevo_contenido)
        try:
            self.texto.mark_set(tk.INSERT, pos_cursor)
        except tk.TclError:
            pass
        self._resaltar_sintaxis()
        self._actualizar_numeros_linea()
        self._programar_vista_previa()
        if elegido == "Sin restriccion":
            self.estado.config(text="Nivel de dificultad quitado: todos los comandos disponibles, sin restriccion.")
        else:
            self.estado.config(text=f"Nivel de dificultad: {elegido}. Solo se van a poder usar comandos de ese nivel o mas bajo.")

    def _boton_barra(self, texto, comando):
        b = tk.Button(self.barra, text=texto, command=comando, **self.estilo_boton)
        b.pack(side=tk.LEFT, padx=4, pady=4)
        return b

    def _scroll_vertical_editor(self, *args):
        """Comando de la scrollbar vertical: mueve 'self.texto' (que es la
        fuente de verdad) y refleja el mismo desplazamiento en 'self.numeros'
        para que los numeros de linea no se desincronicen del contenido."""
        self.texto.yview(*args)
        self.numeros.yview(*args)

    def _instalar_scroll_sincronizado(self, texto, numeros, scroll_v):
        """Engancha rueda de mouse (Windows/Mac con <MouseWheel>, Linux con
        <Button-4>/<Button-5>) tanto para el texto como para los numeros de
        linea, moviendo siempre ambos widgets juntos. Shift+rueda scrollea
        horizontal -- antes, con wrap='none' y sin esto, una linea larga
        solo se podia recorrer con Fin/Inicio del teclado, lo que resultaba
        rarisimo para cualquiera acostumbrado a un editor normal."""

        def _sync_yview(*_args):
            primero, _ = texto.yview()
            numeros.yview_moveto(primero)
            return "break"

        def _rueda_vertical(evento):
            direccion = -1 if evento.delta > 0 else 1
            if evento.num == 4:
                direccion = -1
            elif evento.num == 5:
                direccion = 1
            texto.yview_scroll(direccion * 3, "units")
            numeros.yview_scroll(direccion * 3, "units")
            return "break"

        def _rueda_horizontal(evento):
            direccion = -1 if evento.delta > 0 else 1
            texto.xview_scroll(direccion * 5, "units")
            return "break"

        for widget in (texto, numeros):
            widget.bind("<MouseWheel>", _rueda_vertical)          # Windows / macOS
            widget.bind("<Button-4>", _rueda_vertical)             # Linux (arriba)
            widget.bind("<Button-5>", _rueda_vertical)             # Linux (abajo)
            widget.bind("<Shift-MouseWheel>", _rueda_horizontal)
            widget.bind("<Shift-Button-4>", _rueda_horizontal)
            widget.bind("<Shift-Button-5>", _rueda_horizontal)

        # Cualquier movimiento del cursor con teclado (flechas, PageUp/Down,
        # Home/End) tambien tiene que reflejarse en la barra y en los
        # numeros -- se enlaza al evento nativo de Tk que se dispara cuando
        # cambia el 'view' del widget.
        texto.bind("<<Change>>", _sync_yview, add="+")
        texto.bind("<Configure>", _sync_yview, add="+")
        texto.bind("<KeyRelease>", _sync_yview, add="+")

    def _crear_panel_bienvenida(self, marco_editor):
        """Pantalla de bienvenida (con el logo de SiPi de fondo) que se ve
        SOLO cuando la pestaña esta vacia -- el mismo patron de 'empty
        state' de cualquier editor profesional (VS Code, Sublime), en vez
        de una caja de texto en blanco sin ninguna imagen ni identidad
        visual la primera vez que se abre el programa. Se superpone al
        editor con 'place()' (no reemplaza a self.texto) y se
        oculta/muestra segun haya contenido o no -- ver '_actualizar_
        visibilidad_bienvenida', llamado desde '_al_escribir' y cada vez
        que cambia el contenido de la pestaña activa."""
        colores = self._colores()
        logo = self._icono("sipi_logo", tamano=200)

        self.panel_bienvenida = tk.Frame(marco_editor, bg=colores["fondo"])

        if logo is not None:
            etiqueta_logo = tk.Label(self.panel_bienvenida, image=logo, bg=colores["fondo"])
            etiqueta_logo.imagen = logo
            etiqueta_logo.pack(pady=(0, 18))

        tk.Label(self.panel_bienvenida, text="Bienvenido a SiPi", bg=colores["fondo"],
                 fg=colores["texto"], font=("Segoe UI", 22, "bold")).pack()
        tk.Label(self.panel_bienvenida, text="Programá en español, desde cero.",
                 bg=colores["fondo"], fg="#a6adc8", font=("Segoe UI", 12)).pack(pady=(4, 24))

        marco_acciones = tk.Frame(self.panel_bienvenida, bg=colores["fondo"])
        marco_acciones.pack()
        acciones = [
            ("nuevo", "Nuevo archivo", self.nuevo),
            ("abrir", "Abrir archivo...", self.abrir),
            ("galeria", "Ver ejemplos", self.abrir_galeria_ejemplos),
            ("tutorial", "Tutorial", self.abrir_tutorial),
        ]
        for icono, texto, comando in acciones:
            self._boton_icono(marco_acciones, icono, texto, comando,
                               bg=colores["barra"], activebackground=colores.get("linea_actual", colores["barra"]))

        enlace_curso = tk.Label(self.panel_bienvenida, text="Ver el curso completo online →",
                                 bg=colores["fondo"], fg="#04a5e5", font=("Segoe UI", 10, "underline"),
                                 cursor="hand2")
        enlace_curso.pack(pady=(14, 0))
        enlace_curso.bind("<Button-1>", lambda e: self.abrir_curso_online())

        self._actualizar_visibilidad_bienvenida()

    def _recolorear_panel_bienvenida(self):
        """Se llama al cambiar de tema: en vez de destruir y recrear todo
        el panel de bienvenida, recorre sus widgets y actualiza el color
        de fondo/texto -- mas barato y sin parpadeo."""
        if not hasattr(self, "panel_bienvenida"):
            return
        colores = self._colores()

        def _recolorear(widget, es_raiz=False):
            try:
                if isinstance(widget, (tk.Frame, tk.Label)) and not isinstance(widget, tk.Button):
                    widget.configure(bg=colores["fondo"])
                if isinstance(widget, tk.Label) and widget.cget("fg") not in ("#a6adc8", "#04a5e5"):
                    widget.configure(fg=colores["texto"])
            except Exception:
                pass
            for hijo in widget.winfo_children():
                _recolorear(hijo)

        _recolorear(self.panel_bienvenida, es_raiz=True)

    def _actualizar_visibilidad_bienvenida(self):
        """Se muestra unicamente si la pestaña activa esta completamente
        vacia; con la primera tecla escrita (o al abrir/cargar cualquier
        contenido) desaparece sola, sin que el usuario tenga que cerrarla
        a mano."""
        if not hasattr(self, "panel_bienvenida"):
            return
        vacio = self.texto.get("1.0", "end-1c") == ""
        if vacio:
            self.panel_bienvenida.place(relx=0.5, rely=0.42, anchor="center")
            self.panel_bienvenida.lift()
        else:
            self.panel_bienvenida.place_forget()

    def _crear_area_principal(self):
        colores = self._colores()
        self._crear_barra_pestanas()
        # PanedWindow se queda en tk puro: CTk no tiene equivalente
        # nativo con "sash" arrastrable. Lo re-tematizamos a mano.
        self.contenedor = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, bg=colores["fondo"],
                                          sashwidth=4, bd=0, sashrelief="flat", relief="flat",
                                          showhandle=False)
        self.contenedor.pack(fill=tk.BOTH, expand=True)

        # --- Panel explorador de archivos (item 13 del feedback) ---
        self._crear_panel_explorador()

        # --- Panel izquierdo: editor de codigo ---
        if _CTK_DISPONIBLE:
            panel_codigo = ctk.CTkFrame(self.contenedor, fg_color=colores["fondo"], corner_radius=0)
        else:
            panel_codigo = tk.Frame(self.contenedor, bg=colores["fondo"])
        # El editor de texto (con numeros de linea) sigue en tk.Text puro:
        # el resaltado de sintaxis usa tag_configure/tag_add por rango de
        # caracteres, algo que CTkTextbox no expone igual. Va envuelto en
        # un CTkFrame para que combine visualmente con el resto.
        # --- Scrollbars reales del editor -----------------------------
        # Antes no habia NINGUNA scrollbar (ni vertical ni horizontal) y
        # el texto solo se movia con la rueda del mouse o las flechas del
        # teclado -- ademas, como 'self.numeros' es un widget de texto
        # aparte, nunca se movia junto con 'self.texto' al hacer scroll
        # con la rueda (solo se resincronizaba al escribir), asi que los
        # numeros de linea se "desenganchaban" de su linea real apenas se
        # scrolleaba. Con wrap="none" (lineas largas no se ajustan al
        # ancho visible) tampoco habia forma de moverse "de costado" mas
        # que con Fin/Inicio. Esto arma un grid con scrollbar vertical +
        # horizontal, ata la vertical a numeros+texto a la vez, y agrega
        # rueda de mouse (vertical normal, Shift+rueda para horizontal,
        # y tambien touchpads/Linux con Button-4/5) para los tres SOs.
        marco_editor = tk.Frame(panel_codigo, bg=colores["fondo"])
        marco_editor.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        marco_editor.grid_rowconfigure(0, weight=1)
        marco_editor.grid_columnconfigure(0, weight=1)

        fuente_editor = _fuente_mono()
        self.numeros = tk.Text(marco_editor, width=4, bg=colores["barra"], fg="#6c7086",
                                bd=0, font=(fuente_editor, self.tamano_fuente), state="disabled",
                                cursor="arrow", takefocus=0)
        self.texto = tk.Text(marco_editor, bg=colores["fondo"], fg=colores["texto"],
                              insertbackground=colores["cursor"], insertwidth=2, bd=0,
                              selectbackground=colores["seleccion"], selectforeground=colores["texto"],
                              padx=8, pady=6,
                              font=(fuente_editor, self.tamano_fuente), undo=True, wrap="none")

        scroll_v = ttk.Scrollbar(marco_editor, orient=tk.VERTICAL, command=self._scroll_vertical_editor,
                                  style=getattr(self, "_estilo_scrollbar_v", "Vertical.TScrollbar"))
        scroll_h = ttk.Scrollbar(marco_editor, orient=tk.HORIZONTAL, command=self.texto.xview,
                                  style=getattr(self, "_estilo_scrollbar_h", "Horizontal.TScrollbar"))
        self.texto.config(yscrollcommand=scroll_v.set, xscrollcommand=scroll_h.set)
        self.numeros.config(yscrollcommand=lambda *a: None)  # la vertical la manda 'self.texto'

        self.numeros.grid(row=0, column=0, sticky="ns")
        self.texto.grid(row=0, column=1, sticky="nsew")
        scroll_v.grid(row=0, column=2, sticky="ns")
        scroll_h.grid(row=1, column=1, sticky="ew")
        marco_editor.grid_columnconfigure(1, weight=1)

        self._instalar_scroll_sincronizado(self.texto, self.numeros, scroll_v)
        self._crear_panel_bienvenida(marco_editor)
        self.contenedor.add(panel_codigo, minsize=300)

        # --- Panel derecho: vista previa en vivo ---
        if _CTK_DISPONIBLE:
            panel_previa = ctk.CTkFrame(self.contenedor, fg_color=colores["barra"], corner_radius=0)
            etiqueta_previa = ctk.CTkLabel(panel_previa, text="Vista previa en vivo (salida de consola)",
                                            fg_color=colores["barra"], text_color=colores["texto"],
                                            font=ctk.CTkFont(family="Segoe UI", size=13, weight="bold"),
                                            anchor="w")
            etiqueta_previa.pack(side=tk.TOP, fill=tk.X, padx=8, pady=6)
        else:
            panel_previa = tk.Frame(self.contenedor, bg=colores["barra"])
            etiqueta_previa = tk.Label(panel_previa, text="Vista previa en vivo (salida de consola)",
                                        bg=colores["barra"], fg=colores["texto"],
                                        font=("Segoe UI", 10, "bold"), anchor="w", padx=8, pady=6)
            etiqueta_previa.pack(side=tk.TOP, fill=tk.X)

        marco_previa = tk.Frame(panel_previa, bg=colores["barra"])
        marco_previa.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
        self.salida_previa = tk.Text(marco_previa, bg="#11111b", fg="#cdd6f4", bd=0,
                                      font=(_fuente_mono(), 11), state="disabled", wrap="word")
        scroll_previa = ttk.Scrollbar(marco_previa, orient=tk.VERTICAL, command=self.salida_previa.yview,
                                       style=getattr(self, "_estilo_scrollbar_v", "Vertical.TScrollbar"))
        self.salida_previa.config(yscrollcommand=scroll_previa.set)
        self.salida_previa.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll_previa.pack(side=tk.LEFT, fill=tk.Y)
        self.contenedor.add(panel_previa, minsize=250)

        self.texto.tag_configure("clave", foreground=colores["clave"])
        self.texto.tag_configure("cadena", foreground=colores["cadena"])
        self.texto.tag_configure("comentario", foreground=colores["comentario"])
        self.texto.tag_configure("numero", foreground=colores["numero"])
        # Resaltado de la linea actual, como en cualquier IDE profesional
        # (VS Code, PyCharm, etc.): una franja sutil detras del texto que
        # sigue al cursor. Se pinta DEBAJO de los demas tags para que las
        # palabras clave/cadenas/numeros se sigan viendo con su color normal
        # encima de la franja.
        self.texto.tag_configure("linea_actual", background=colores["linea_actual"])
        self.texto.tag_lower("linea_actual")
        self.texto.bind("<KeyRelease>", self._resaltar_linea_actual, add="+")
        self.texto.bind("<ButtonRelease-1>", self._resaltar_linea_actual, add="+")
        self._resaltar_linea_actual()

    def _resaltar_linea_actual(self, evento=None):
        """Mueve la franja de 'linea actual' a la fila donde esta el
        cursor ahora mismo."""
        if not hasattr(self, "texto"):
            return
        try:
            self.texto.tag_remove("linea_actual", "1.0", "end")
            fila = self.texto.index(tk.INSERT).split(".")[0]
            self.texto.tag_add("linea_actual", f"{fila}.0", f"{fila}.0+1line")
        except tk.TclError:
            pass

    # ------- Pestañas (item 14 del feedback) -------
    def _crear_barra_pestanas(self):
        colores = self._colores()
        if _CTK_DISPONIBLE:
            self.barra_pestanas = ctk.CTkFrame(self.root, fg_color=colores["barra"], height=32, corner_radius=0)
        else:
            self.barra_pestanas = tk.Frame(self.root, bg=colores["barra"], height=32)
        self.barra_pestanas.pack(side=tk.TOP, fill=tk.X)
        # Misma linea divisoria de 1px que separa toolbar/estado del
        # resto -- aca marca donde terminan las pestañas y arranca el
        # area de edicion.
        tk.Frame(self.root, bg="#45475a", height=1).pack(side=tk.TOP, fill=tk.X)

    # ------- Explorador de archivos (item 13 del feedback) -------
    def _crear_panel_explorador(self):
        """Arbol de carpetas simple (no un file manager completo: solo
        navegar y abrir), con carga perezosa -- una carpeta con miles de
        archivos no tarda nada en mostrarse porque solo se leen sus hijos
        cuando el usuario la despliega, no todo el arbol de una."""
        colores = self._colores()
        if _CTK_DISPONIBLE:
            panel = ctk.CTkFrame(self.contenedor, fg_color=colores["barra"], corner_radius=0)
        else:
            panel = tk.Frame(self.contenedor, bg=colores["barra"])
        self.contenedor.add(panel, minsize=180, width=220)

        if _CTK_DISPONIBLE:
            encabezado = ctk.CTkFrame(panel, fg_color=colores["barra"], corner_radius=0)
            encabezado.pack(side=tk.TOP, fill=tk.X)
            ctk.CTkLabel(encabezado, text="EXPLORADOR", text_color="#a6adc8",
                         font=ctk.CTkFont(family="Segoe UI", size=11, weight="bold"),
                         anchor="w").pack(side=tk.LEFT, padx=8, pady=4)
            boton_refrescar = ctk.CTkLabel(encabezado, text="Refrescar", text_color="#a6adc8",
                                            cursor="hand2", font=ctk.CTkFont(family="Segoe UI", size=12))
            boton_refrescar.pack(side=tk.RIGHT, padx=6)
            boton_refrescar.bind("<Button-1>", lambda e: self._refrescar_explorador())
            boton_carpeta = ctk.CTkLabel(encabezado, text="+ Carpeta", text_color="#a6adc8",
                                          cursor="hand2", font=ctk.CTkFont(family="Segoe UI", size=12))
            boton_carpeta.pack(side=tk.RIGHT, padx=6)
            boton_carpeta.bind("<Button-1>", lambda e: self._elegir_carpeta_proyecto())
        else:
            encabezado = tk.Frame(panel, bg=colores["barra"])
            encabezado.pack(side=tk.TOP, fill=tk.X)
            tk.Label(encabezado, text="EXPLORADOR", bg=colores["barra"], fg="#a6adc8",
                     font=("Segoe UI", 8, "bold"), anchor="w", padx=8, pady=4).pack(side=tk.LEFT)
            boton_refrescar = tk.Label(encabezado, text="Refrescar", bg=colores["barra"], fg="#a6adc8",
                                        cursor="hand2", font=("Segoe UI", 10), padx=6)
            boton_refrescar.pack(side=tk.RIGHT)
            boton_refrescar.bind("<Button-1>", lambda e: self._refrescar_explorador())
            boton_carpeta = tk.Label(encabezado, text="+ Carpeta", bg=colores["barra"], fg="#a6adc8",
                                      cursor="hand2", font=("Segoe UI", 10), padx=6)
            boton_carpeta.pack(side=tk.RIGHT)
            boton_carpeta.bind("<Button-1>", lambda e: self._elegir_carpeta_proyecto())

        estilo = ttk.Style()
        try:
            estilo.theme_use(estilo.theme_use())  # no-op, solo confirma que hay un tema activo
        except Exception:
            pass
        estilo.configure("Explorador.Treeview", background=colores["fondo"], fieldbackground=colores["fondo"],
                          foreground=colores["texto"], borderwidth=0)
        self._configurar_estilo_ttk(colores)
        self.arbol_explorador = ttk.Treeview(panel, show="tree", style="Explorador.Treeview", selectmode="browse")
        self.arbol_explorador.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
        self.arbol_explorador.bind("<<TreeviewOpen>>", self._al_desplegar_nodo_explorador)
        self.arbol_explorador.bind("<Double-1>", self._al_doble_clic_explorador)

        # La carpeta raiz por defecto es la del propio SiPi (mismo lugar
        # de siempre, predecible); "elegir carpeta" la cambia a la del
        # proyecto del usuario en el momento que la necesite.
        self.carpeta_proyecto = os.path.dirname(os.path.abspath(__file__))
        self._refrescar_explorador()

    def _elegir_carpeta_proyecto(self):
        carpeta = filedialog.askdirectory(title="Elegir carpeta del proyecto")
        if carpeta:
            self.carpeta_proyecto = carpeta
            self._refrescar_explorador()

    def _refrescar_explorador(self):
        self.arbol_explorador.delete(*self.arbol_explorador.get_children(""))
        nodo_raiz = self.arbol_explorador.insert(
            "", "end", text=f"📂 {os.path.basename(self.carpeta_proyecto) or self.carpeta_proyecto}",
            values=(self.carpeta_proyecto, "carpeta"), open=True)
        self._poblar_hijos_explorador(nodo_raiz, self.carpeta_proyecto)

    def _listar_entradas_carpeta(self, carpeta):
        """Lista una carpeta salteando ruido tipico (ocultos, __pycache__,
        node_modules, .git) y ordenando carpetas primero -- exactamente
        el mismo criterio que ya usa '_buscar_archivos_cache' en
        sipi_cli.py para no tardar de mas en proyectos grandes."""
        try:
            entradas = os.listdir(carpeta)
        except OSError:
            return []
        ocultar = {"__pycache__", "node_modules", ".git", ".sipi"}
        visibles = [e for e in entradas if not e.startswith(".") and e not in ocultar]
        carpetas = sorted(e for e in visibles if os.path.isdir(os.path.join(carpeta, e)))
        archivos = sorted(e for e in visibles if not os.path.isdir(os.path.join(carpeta, e)))
        return [(e, "carpeta") for e in carpetas] + [(e, "archivo") for e in archivos]

    def _poblar_hijos_explorador(self, nodo_padre, carpeta):
        for nombre, tipo in self._listar_entradas_carpeta(carpeta):
            ruta = os.path.join(carpeta, nombre)
            if tipo == "carpeta":
                nodo = self.arbol_explorador.insert(
                    nodo_padre, "end", text=f"📁 {nombre}", values=(ruta, "carpeta"))
                # Nodo "placeholder" para que la flechita de despliegue
                # aparezca sin tener que leer esta subcarpeta todavia --
                # recien se lee de verdad en '_al_desplegar_nodo_explorador'.
                if self._listar_entradas_carpeta(ruta):
                    self.arbol_explorador.insert(nodo, "end", text="")
            else:
                icono = "📄" if nombre.endswith(".sipi") else "  "
                self.arbol_explorador.insert(nodo_padre, "end", text=f"{icono} {nombre}", values=(ruta, "archivo"))

    def _al_desplegar_nodo_explorador(self, evento=None):
        nodo = self.arbol_explorador.focus()
        hijos = self.arbol_explorador.get_children(nodo)
        # Si el unico hijo es el placeholder vacio de arriba, se borra y
        # se cargan los hijos reales recien ahora (carga perezosa).
        if len(hijos) == 1 and not self.arbol_explorador.item(hijos[0], "text").strip():
            self.arbol_explorador.delete(hijos[0])
            valores = self.arbol_explorador.item(nodo, "values")
            if valores:
                self._poblar_hijos_explorador(nodo, valores[0])

    def _al_doble_clic_explorador(self, evento=None):
        nodo = self.arbol_explorador.focus()
        valores = self.arbol_explorador.item(nodo, "values")
        if not valores or valores[1] != "archivo":
            return
        self._abrir_ruta(valores[0])

    def _texto_pestana(self, pestana):
        nombre = os.path.basename(pestana["archivo"]) if pestana["archivo"] else "Sin titulo"
        return f"{nombre} *" if pestana["modificado"] else nombre

    def _redibujar_barra_pestanas(self):
        colores = self._colores()
        for hijo in self.barra_pestanas.winfo_children():
            hijo.destroy()
        acento = colores.get("acento", "#40a02b")
        for idx, pestana in enumerate(self.pestanas):
            activa = idx == self.indice_pestana_actual
            color_fondo_pestana = colores["fondo"] if activa else colores["barra"]
            marco = tk.Frame(self.barra_pestanas, bg=color_fondo_pestana)
            marco.pack(side=tk.LEFT, padx=(2, 0), pady=(2, 0))
            # Franja de acento arriba de la pestaña activa (como en los
            # editores de codigo profesionales) para que se distinga de
            # un vistazo, no solo por el color de fondo, cuando hay
            # varias pestañas abiertas una al lado de la otra.
            tk.Frame(marco, bg=acento if activa else color_fondo_pestana, height=2).pack(
                side=tk.TOP, fill=tk.X)
            fila = tk.Frame(marco, bg=color_fondo_pestana)
            fila.pack(side=tk.TOP)
            etiqueta = tk.Label(
                fila, text=self._texto_pestana(pestana),
                bg=color_fondo_pestana,
                fg=colores["texto"] if activa else "#a6adc8",
                font=("Segoe UI", 9, "bold" if activa else "normal"), padx=10, pady=4, cursor="hand2",
            )
            etiqueta.pack(side=tk.LEFT)
            etiqueta.bind("<Button-1>", lambda e, i=idx: self._cambiar_a_pestana(i))
            if not activa:
                # Hover en pestañas inactivas: adelanta visualmente que
                # son clickeables, en vez de solo enterarse al clickear.
                self._agregar_hover(etiqueta, color_fondo_pestana, colores["fondo"])
                self._agregar_hover(marco, color_fondo_pestana, colores["fondo"])
                self._agregar_hover(fila, color_fondo_pestana, colores["fondo"])
            boton_cerrar = tk.Label(
                fila, text="\u2715", bg=color_fondo_pestana,
                fg="#a6adc8", font=("Segoe UI", 8), padx=4, pady=4, cursor="hand2",
            )
            boton_cerrar.pack(side=tk.LEFT)
            boton_cerrar.bind("<Button-1>", lambda e, i=idx: self._cerrar_pestana(i))
            # El boton de cerrar se pone rojo suave al pasar el mouse
            # (patron estandar de "x" de cerrar en cualquier IDE/browser),
            # en vez de quedarse gris siempre e invitar menos a usarlo.
            self._agregar_hover(boton_cerrar, "#a6adc8", "#f38ba8", atributo="fg")
        boton_nueva = tk.Label(
            self.barra_pestanas, text="+", bg=colores["barra"], fg=colores["texto"],
            font=("Segoe UI", 11, "bold"), padx=10, pady=4, cursor="hand2",
        )
        boton_nueva.pack(side=tk.LEFT, padx=(4, 0))
        boton_nueva.bind("<Button-1>", lambda e: self.nueva_pestana())
        self._agregar_hover(boton_nueva, colores["texto"], acento, atributo="fg")

    def _guardar_estado_pestana_actual(self):
        """Vuelca el contenido y estado actuales de 'self.texto' de vuelta
        a 'self.pestanas[indice_actual]', para no perderlo al cambiar de
        pestaña (el widget de texto es UNO SOLO, compartido por todas las
        pestañas -- no se clona un Text por pestaña, mas liviano)."""
        if not self.pestanas:
            return
        pestana = self.pestanas[self.indice_pestana_actual]
        pestana["contenido"] = self.texto.get("1.0", "end-1c")
        pestana["archivo"] = self.archivo_actual
        pestana["modificado"] = self.modificado

    def _cambiar_a_pestana(self, indice):
        if indice == self.indice_pestana_actual or not (0 <= indice < len(self.pestanas)):
            return
        self._guardar_estado_pestana_actual()
        self.indice_pestana_actual = indice
        pestana = self.pestanas[indice]
        self.texto.delete("1.0", "end")
        self.texto.insert("1.0", pestana["contenido"])
        self.archivo_actual = pestana["archivo"]
        self.modificado = pestana["modificado"]
        self._resaltar_sintaxis()
        self._actualizar_numeros_linea()
        self._actualizar_titulo()
        self._redibujar_barra_pestanas()
        self._programar_vista_previa()
        self._actualizar_selector_nivel_desde_codigo()
        self.estado.config(text=f"Pestaña: {self._texto_pestana(pestana)}")
        self._actualizar_visibilidad_bienvenida()
        self._actualizar_indicador_cursor()

    def nueva_pestana(self):
        self._guardar_estado_pestana_actual()
        self.pestanas.append({"archivo": None, "contenido": "", "modificado": False})
        self.indice_pestana_actual = len(self.pestanas) - 1
        self.texto.delete("1.0", "end")
        self.archivo_actual = None
        self.modificado = False
        self._resaltar_sintaxis()
        self._actualizar_numeros_linea()
        self._actualizar_titulo()
        self._redibujar_barra_pestanas()
        self._programar_vista_previa()
        self._actualizar_selector_nivel_desde_codigo()
        self._actualizar_visibilidad_bienvenida()

    def abrir_pestana_con_contenido(self, contenido, nombre_sugerido=None, sipi_world=None):
        """Abre una pestaña nueva con 'contenido' ya cargado, sin archivo en
        disco asociado (como 'nueva_pestana', pero con texto). Si se pasa
        'sipi_world' (dict con comunidad_id/proyecto_id/clave_acceso/nombre),
        la pestaña queda marcada como traída de un proyecto de SiPi World --
        eso es lo que usa 'guardar_en_sipi_world' para saber a que proyecto
        subir los cambios sin volver a pedir el ID."""
        self.nueva_pestana()
        self.texto.insert("1.0", contenido)
        self.modificado = False
        pestana = self.pestanas[self.indice_pestana_actual]
        pestana["contenido"] = contenido
        pestana["modificado"] = False
        pestana["sipi_world"] = sipi_world
        if nombre_sugerido:
            pestana["nombre_sugerido"] = nombre_sugerido
        self._resaltar_sintaxis()
        self._actualizar_numeros_linea()
        self._actualizar_titulo()
        self._redibujar_barra_pestanas()
        self._programar_vista_previa()
        self._actualizar_selector_nivel_desde_codigo()
        self._actualizar_visibilidad_bienvenida()

    def guardar_en_sipi_world(self):
        """Sube el contenido de la pestaña actual de vuelta al proyecto de
        SiPi World del que vino (ver 'abrir_pestana_con_contenido'). Si la
        pestaña actual no vino de SiPi World, ofrece elegir un proyecto
        abierto desde la ventana de SiPi World si hay una abierta."""
        self._guardar_estado_pestana_actual()
        pestana = self.pestanas[self.indice_pestana_actual]
        origen = pestana.get("sipi_world")
        if origen is None:
            messagebox.showinfo(
                "SiPi World",
                "Esta pestaña no vino de un proyecto de SiPi World. Abrí el "
                "código de un proyecto primero (botón 'Abrir código' en "
                "SiPi World → Proyectos) para poder subir cambios de vuelta.")
            return
        ventana = getattr(self, "_ventana_sipi_world", None)
        if ventana is None or not ventana.ventana.winfo_exists() or not ventana.cliente.conectado:
            messagebox.showwarning(
                "SiPi World",
                "Tenés que tener SiPi World abierto y conectado para subir el código "
                "(el editor no guarda tu sesión cuando cerrás esa ventana).")
            return
        ventana.subir_codigo_pestana_actual(origen, pestana["contenido"])

    def _indice_pestana_para_archivo(self, ruta):
        """Si 'ruta' ya esta abierta en alguna pestaña, devuelve su
        indice -- evita abrir el mismo archivo dos veces en pestañas
        distintas, que llevaria a confusion sobre cual version es la
        real al guardar."""
        for idx, pestana in enumerate(self.pestanas):
            if pestana["archivo"] and os.path.abspath(pestana["archivo"]) == os.path.abspath(ruta):
                return idx
        return None

    def _cerrar_pestana(self, indice):
        if not (0 <= indice < len(self.pestanas)):
            return
        # Si se cierra la pestaña activa, primero se sincroniza su estado
        # real (por si el usuario tenia cambios sin guardar justo antes
        # de tocar la 'x') para poder preguntar con informacion correcta.
        if indice == self.indice_pestana_actual:
            self._guardar_estado_pestana_actual()
        pestana = self.pestanas[indice]
        if pestana["modificado"]:
            nombre = self._texto_pestana(pestana)
            respuesta = messagebox.askyesnocancel(
                "SiPi", f"'{nombre}' tiene cambios sin guardar. ¿Queres guardarlos antes de cerrar la pestaña?"
            )
            if respuesta is None:
                return
            if respuesta:
                archivo_previo, indice_previo = self.archivo_actual, self.indice_pestana_actual
                self.archivo_actual, self.indice_pestana_actual = pestana["archivo"], indice
                contenido_previo_widget = self.texto.get("1.0", "end-1c")
                self.texto.delete("1.0", "end")
                self.texto.insert("1.0", pestana["contenido"])
                self.modificado = True
                self.guardar_si_hace_falta()
                guardado_ok = not self.modificado
                pestana["archivo"] = self.archivo_actual
                self.texto.delete("1.0", "end")
                self.texto.insert("1.0", contenido_previo_widget)
                self.archivo_actual, self.indice_pestana_actual = archivo_previo, indice_previo
                if not guardado_ok:
                    return  # se cancelo el "Guardar como"
        del self.pestanas[indice]
        if not self.pestanas:
            self.pestanas = [{"archivo": None, "contenido": "", "modificado": False}]
        if self.indice_pestana_actual >= len(self.pestanas):
            self.indice_pestana_actual = len(self.pestanas) - 1
        elif indice < self.indice_pestana_actual:
            self.indice_pestana_actual -= 1
        pestana_activa = self.pestanas[self.indice_pestana_actual]
        self.texto.delete("1.0", "end")
        self.texto.insert("1.0", pestana_activa["contenido"])
        self.archivo_actual = pestana_activa["archivo"]
        self.modificado = pestana_activa["modificado"]
        self._resaltar_sintaxis()
        self._actualizar_numeros_linea()
        self._actualizar_titulo()
        self._redibujar_barra_pestanas()
        self._programar_vista_previa()

    def _crear_barra_estado(self):
        colores = self._colores()
        # Linea divisoria fina arriba de la barra de estado -- un detalle
        # chico pero comun en editores profesionales (VS Code, Sublime,
        # etc.) que separa visualmente el area de edicion de la barra
        # inferior en vez de que ambas floten con el mismo color.
        tk.Frame(self.root, bg="#45475a", height=1).pack(side=tk.BOTTOM, fill=tk.X)
        # La barra de estado usa el color de ACENTO del tema como fondo
        # (no el gris de siempre) -- es la firma visual mas reconocible
        # de VS Code: esa franja de color solido pegada abajo de todo.
        # Como el acento de cada tema ya esta elegido para verse bien
        # sobre fondo oscuro, blanco encima funciona en los siete temas.
        color_barra_estado = colores.get("acento", colores["barra"])
        marco_estado = tk.Frame(self.root, bg=color_barra_estado)
        marco_estado.pack(side=tk.BOTTOM, fill=tk.X)
        self.marco_estado = marco_estado

        self.estado = tk.Label(marco_estado, text="Listo.", bg=color_barra_estado, fg="#ffffff",
                                anchor="w", font=("Segoe UI", 9), padx=8, pady=4)
        self.estado.pack(side=tk.LEFT, fill=tk.X, expand=True)

        # Segmentos a la derecha, como en cualquier IDE de verdad (VS
        # Code, PyCharm, Sublime): posicion del cursor, cantidad de
        # lineas del archivo, y el "lenguaje" del documento actual --
        # antes la barra de estado solo tenia un mensaje generico a la
        # izquierda, lo que la hacia sentir mucho mas basica de lo que
        # el resto del editor ya era.
        self.indicador_lenguaje = tk.Label(marco_estado, text="SiPi", bg=color_barra_estado, fg="#ffffff",
                                            font=("Segoe UI", 9), padx=10, pady=4)
        self.indicador_lenguaje.pack(side=tk.RIGHT)
        self._tooltip(self.indicador_lenguaje, "Lenguaje del archivo actual")

        tk.Frame(marco_estado, bg="#ffffff", width=1).pack(side=tk.RIGHT, fill=tk.Y, pady=6)

        self.indicador_codificacion = tk.Label(marco_estado, text="UTF-8", bg=color_barra_estado, fg="#ffffff",
                                                font=("Segoe UI", 9), padx=10, pady=4)
        self.indicador_codificacion.pack(side=tk.RIGHT)

        tk.Frame(marco_estado, bg="#ffffff", width=1).pack(side=tk.RIGHT, fill=tk.Y, pady=6)

        self.indicador_cursor = tk.Label(marco_estado, text="Ln 1, Col 1", bg=color_barra_estado, fg="#ffffff",
                                          font=("Segoe UI", 9), padx=10, pady=4)
        self.indicador_cursor.pack(side=tk.RIGHT)

        self._actualizar_indicador_cursor()

    def _actualizar_indicador_cursor(self, evento=None):
        """Actualiza 'Ln X, Col Y' con la posicion real del cursor y el
        total de lineas del archivo -- se llama en cada tecla y cada
        click, igual que en cualquier editor de codigo de verdad."""
        if not hasattr(self, "indicador_cursor") or not hasattr(self, "texto"):
            return
        try:
            fila, columna = self.texto.index(tk.INSERT).split(".")
            total_lineas = int(self.texto.index("end-1c").split(".")[0])
            try:
                inicio_sel = self.texto.index("sel.first")
                fin_sel = self.texto.index("sel.last")
                seleccionados = len(self.texto.get(inicio_sel, fin_sel))
                sufijo_seleccion = f"  ({seleccionados} seleccionados)" if seleccionados else ""
            except tk.TclError:
                sufijo_seleccion = ""
            self.indicador_cursor.configure(
                text=f"Ln {fila}, Col {int(columna) + 1}  ·  {total_lineas} lineas{sufijo_seleccion}")
        except (tk.TclError, AttributeError):
            pass

    # ------- Terminal integrada (item 12 del feedback) -------
    def _crear_panel_terminal(self):
        """Terminal simple y honesta: cada linea que el usuario escribe se
        corre como UN comando independiente (no una sesion de shell
        interactiva persistente ni un pseudo-terminal) -- misma idea que
        la terminal integrada de cualquier editor de codigo: comandos que
        el USUARIO elige y escribe a mano, con sus propios permisos, en
        su propia carpeta de proyecto. No es mas riesgo que abrir una
        terminal aparte (que el usuario ya puede hacer en cualquier
        momento); la unica diferencia es la comodidad de no salir del
        editor. 'cd' se maneja aparte porque cada comando corre en un
        proceso nuevo, asi que un 'cd' normal no persistiria entre
        comandos si no se guardara la carpeta actual aca."""
        colores = self._colores()
        self.panel_terminal = tk.Frame(self.root, bg=colores["fondo"], height=220)
        # No se empaqueta todavia -- arranca oculta, _alternar_panel_terminal
        # la muestra/oculta.
        # pack_propagate(False) para que 'height' se respete de verdad --
        # sin esto, los hijos (que se empaquetan con fill/expand) hacen que
        # el frame se autoajuste a su contenido e ignore cualquier alto que
        # le pongamos, incluido el que fija el grip de abajo al arrastrar.
        self.panel_terminal.pack_propagate(False)

        # --- Grip arrastrable (item pedido: "que el cuadro de texto se
        # pueda mover tambien para arriba") -- antes el panel de la
        # terminal/consola de abajo tenia un alto fijo de 220px sin forma
        # de cambiarlo; con este grip el usuario arrastra el borde
        # superior de la terminal hacia arriba o abajo para agrandarla o
        # achicarla, y el area de texto de arriba crece/decrece en
        # consecuencia (al ser el unico widget con expand=True en el
        # 'pack' del root, se ajusta solo al espacio que le queda).
        self.grip_terminal = tk.Frame(self.panel_terminal, bg=colores["barra"], height=5,
                                       cursor="sb_v_double_arrow")
        self.grip_terminal.pack(side=tk.TOP, fill=tk.X)
        self.grip_terminal.bind("<ButtonPress-1>", self._iniciar_resize_panel_terminal)
        self.grip_terminal.bind("<B1-Motion>", self._redimensionar_panel_terminal)

        encabezado = tk.Frame(self.panel_terminal, bg=colores["barra"])
        encabezado.pack(side=tk.TOP, fill=tk.X)
        tk.Label(encabezado, text="TERMINAL", bg=colores["barra"], fg="#a6adc8",
                 font=("Segoe UI", 8, "bold"), anchor="w", padx=8, pady=4).pack(side=tk.LEFT)
        self.etiqueta_cwd_terminal = tk.Label(encabezado, text="", bg=colores["barra"], fg="#6c7086",
                                               font=(_fuente_mono(), 8), anchor="w")
        self.etiqueta_cwd_terminal.pack(side=tk.LEFT, padx=8)
        boton_cerrar_term = tk.Label(encabezado, text="x", bg=colores["barra"], fg="#a6adc8",
                                      cursor="hand2", font=("Segoe UI", 9), padx=8)
        boton_cerrar_term.pack(side=tk.RIGHT)
        boton_cerrar_term.bind("<Button-1>", lambda e: self._alternar_panel_terminal())

        self.salida_terminal = tk.Text(self.panel_terminal, bg="#11111b", fg="#cdd6f4", bd=0,
                                        font=(_fuente_mono(), 9), state="disabled", height=10, wrap="word")
        self.salida_terminal.pack(side=tk.TOP, fill=tk.BOTH, expand=True)

        marco_entrada = tk.Frame(self.panel_terminal, bg=colores["fondo"])
        marco_entrada.pack(side=tk.BOTTOM, fill=tk.X)
        tk.Label(marco_entrada, text="$", bg=colores["fondo"], fg="#a6e3a1",
                 font=(_fuente_mono(), 10, "bold"), padx=6).pack(side=tk.LEFT)
        self.entrada_terminal = tk.Entry(marco_entrada, bg="#11111b", fg="#cdd6f4",
                                          insertbackground="white", bd=0, font=(_fuente_mono(), 10))
        self.entrada_terminal.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6), pady=4)
        self.entrada_terminal.bind("<Return>", self._al_enviar_comando_terminal)

        self.terminal_visible = False
        self.terminal_cwd = self.carpeta_proyecto if hasattr(self, "carpeta_proyecto") else os.getcwd()
        self.historial_terminal = []
        self.indice_historial_terminal = 0

    def _alternar_panel_terminal(self):
        if self.terminal_visible:
            self.panel_terminal.pack_forget()
            self.terminal_visible = False
        else:
            self.panel_terminal.pack(side=tk.BOTTOM, fill=tk.X)
            self.terminal_visible = True
            self._actualizar_etiqueta_cwd_terminal()
            self.entrada_terminal.focus_set()

    def _iniciar_resize_panel_terminal(self, evento):
        """Arranca el arrastre del grip: guarda la posicion Y absoluta del
        mouse y el alto actual del panel para calcular el delta en
        '_redimensionar_panel_terminal'."""
        self._resize_terminal_y_inicial = evento.y_root
        self._resize_terminal_alto_inicial = self.panel_terminal.winfo_height()

    def _redimensionar_panel_terminal(self, evento):
        """Mueve el borde superior de la terminal siguiendo el mouse.
        Arrastrar hacia arriba (Y absoluto menor) agranda la terminal y
        empuja el cuadro de texto del editor hacia arriba; arrastrar hacia
        abajo la achica. Se limita entre un minimo usable y un maximo que
        deja lugar para la barra de pestanas/herramientas de arriba."""
        delta = self._resize_terminal_y_inicial - evento.y_root
        nuevo_alto = self._resize_terminal_alto_inicial + delta
        alto_maximo = max(150, self.root.winfo_height() - 200)
        nuevo_alto = max(80, min(nuevo_alto, alto_maximo))
        self.panel_terminal.configure(height=nuevo_alto)

    def _actualizar_etiqueta_cwd_terminal(self):
        self.etiqueta_cwd_terminal.config(text=self.terminal_cwd)

    def _escribir_en_terminal(self, texto):
        self.salida_terminal.config(state="normal")
        self.salida_terminal.insert("end", texto)
        self.salida_terminal.see("end")
        self.salida_terminal.config(state="disabled")

    def _al_enviar_comando_terminal(self, evento=None):
        comando = self.entrada_terminal.get().strip()
        self.entrada_terminal.delete(0, "end")
        if not comando:
            return
        self.historial_terminal.append(comando)
        self.indice_historial_terminal = len(self.historial_terminal)
        self._escribir_en_terminal(f"$ {comando}\n")

        # 'cd' se maneja localmente (sin lanzar un proceso) porque cada
        # comando de aca en mas corre en un subprocess.Popen nuevo -- un
        # 'cd' real solo cambiaria el directorio de ESE proceso hijo, que
        # muere apenas termina el comando, y el siguiente comando
        # arrancaria de nuevo en la carpeta vieja. Guardando el cwd
        # nosotros mismos, el 'cd' persiste entre comandos como se espera.
        if comando == "cd" or comando.startswith("cd "):
            destino = comando[2:].strip() or os.path.expanduser("~")
            nueva_ruta = destino if os.path.isabs(destino) else os.path.join(self.terminal_cwd, destino)
            nueva_ruta = os.path.normpath(nueva_ruta)
            if os.path.isdir(nueva_ruta):
                self.terminal_cwd = nueva_ruta
                self._actualizar_etiqueta_cwd_terminal()
            else:
                self._escribir_en_terminal(f"cd: no existe la carpeta: {nueva_ruta}\n")
            return

        if comando in ("cls", "clear"):
            self.salida_terminal.config(state="normal")
            self.salida_terminal.delete("1.0", "end")
            self.salida_terminal.config(state="disabled")
            return

        hilo = threading.Thread(target=self._correr_comando_terminal, args=(comando,), daemon=True)
        hilo.start()

    def _correr_comando_terminal(self, comando):
        """Corre en un hilo aparte para no congelar la interfaz mientras
        el comando tarda; el resultado se vuelca al panel via
        'root.after', que es la unica forma segura de tocar widgets de
        Tk desde un hilo que no es el principal."""
        try:
            resultado = subprocess.run(
                comando, shell=True, cwd=self.terminal_cwd,
                capture_output=True, text=True, timeout=120,
                encoding="utf-8", errors="replace",
            )
            salida = (resultado.stdout or "") + (resultado.stderr or "")
        except subprocess.TimeoutExpired:
            salida = "[SiPi] El comando tardo mas de 120 segundos, se corto.\n"
        except OSError as error:
            salida = f"[SiPi] No se pudo correr el comando: {error}\n"
        salida_final = salida if salida.endswith("\n") or not salida else salida + "\n"
        try:
            self.root.after(0, lambda: self._escribir_en_terminal(salida_final))
        except RuntimeError:
            pass  # la ventana ya se cerro mientras el comando corria; no hay donde mostrarlo

    def _configurar_estilo_ttk(self, colores):
        """Restylea los widgets ttk (Treeview del explorador y las
        scrollbars) para que combinen con el tema activo y se vean
        finas/planas -- las scrollbars 'de fabrica' de Tk (grises,
        gruesas, con flechitas 3D en los extremos) eran, junto con la
        falta de esto, la señal mas fuerte de que el editor se veia
        "de otra epoca" comparado con VS Code/Sublime/etc, que usan
        scrollbars delgadas, planas, y que resaltan solo al pasar el
        mouse por encima."""
        estilo = ttk.Style()
        try:
            estilo.theme_use("clam")  # base que SI permite personalizar el grosor/color a fondo
        except tk.TclError:
            pass

        estilo.configure("Explorador.Treeview", background=colores["fondo"], fieldbackground=colores["fondo"],
                          foreground=colores["texto"], borderwidth=0, rowheight=22,
                          font=("Segoe UI", 10))
        estilo.map("Explorador.Treeview",
                    background=[("selected", colores.get("seleccion", "#264f78"))],
                    foreground=[("selected", colores["texto"])])
        estilo.layout("Explorador.Treeview", [("Explorador.Treeview.treearea", {"sticky": "nswe"})])

        for orientacion in ("Vertical", "Horizontal"):
            nombre = f"Delgada.{orientacion}.TScrollbar"
            estilo.configure(
                nombre,
                background=colores["barra"], troughcolor=colores["fondo"],
                bordercolor=colores["fondo"], darkcolor=colores["barra"], lightcolor=colores["barra"],
                arrowsize=1,  # tan chico como el tema lo permite: sin flechitas visibles, estilo VS Code
                gripcount=0, relief="flat", width=10,
            )
            estilo.map(
                nombre,
                background=[("active", colores.get("acento", "#007acc")), ("!active", colores["barra"])],
            )
        self._estilo_scrollbar_v = "Delgada.Vertical.TScrollbar"
        self._estilo_scrollbar_h = "Delgada.Horizontal.TScrollbar"

    def _aplicar_tema(self):
        colores = self._colores()
        self._configurar_estilo_ttk(colores)
        self.root.configure(bg=colores["fondo"])
        if _CTK_DISPONIBLE:
            self.barra.configure(fg_color=colores["barra"])
        else:
            self.barra.configure(bg=colores["barra"])
        self.barra_exterior.configure(bg=colores["barra"])
        self.barra_canvas.configure(bg=colores["barra"])
        fuente_editor = _fuente_mono()
        self.numeros.configure(bg=colores["barra"], font=(fuente_editor, self.tamano_fuente))
        self.texto.configure(bg=colores["fondo"], fg=colores["texto"], font=(fuente_editor, self.tamano_fuente),
                              insertbackground=colores["cursor"],
                              selectbackground=colores["seleccion"], selectforeground=colores["texto"])
        color_barra_estado = colores.get("acento", colores["barra"])
        self.estado.configure(bg=color_barra_estado)
        if hasattr(self, "marco_estado"):
            self.marco_estado.configure(bg=color_barra_estado)
            self.indicador_cursor.configure(bg=color_barra_estado)
            self.indicador_codificacion.configure(bg=color_barra_estado)
            self.indicador_lenguaje.configure(bg=color_barra_estado)
        self.texto.tag_configure("clave", foreground=colores["clave"])
        self.texto.tag_configure("cadena", foreground=colores["cadena"])
        self.texto.tag_configure("comentario", foreground=colores["comentario"])
        self.texto.tag_configure("numero", foreground=colores["numero"])
        self.texto.tag_configure("linea_actual", background=colores["linea_actual"])
        self.texto.tag_lower("linea_actual")
        self._recolorear_panel_bienvenida()
        self._guardar_config()

    # ------- Resaltado de sintaxis real -------
    def _al_modificarse_contenido(self, evento=None):
        """Enganchado a <<Modified>>, que Tk dispara con cualquier cambio
        de contenido del Text (tecleado o programatico). Solo se encarga
        de la bienvenida y de bajar la bandera interna del widget; el
        resaltado/numeros/vista previa para tecleo real siguen viviendo
        en '_al_escribir' via <KeyRelease>, para no duplicar trabajo en
        cada pulsacion de tecla."""
        self._actualizar_visibilidad_bienvenida()
        self.texto.edit_modified(False)

    def _al_escribir(self, evento=None):
        self._marcar_modificado()
        if evento is not None and evento.keysym in ("Control_L", "Control_R", "Shift_L", "Shift_R",
                                                      "Alt_L", "Alt_R"):
            pass  # modificadores solos no cambian contenido, no hace falta recolorear
        else:
            # Tipeo normal: solo la linea actual (rapido, ver
            # '_resaltar_sintaxis_incremental'). El pegado (Ctrl+V) NO
            # pasa por aca -- tiene su propio manejador que fuerza el
            # resaltado completo, ver '_al_pegar'.
            self._resaltar_sintaxis_incremental(evento)
        self._actualizar_numeros_linea()
        self._programar_vista_previa()
        self._actualizar_visibilidad_bienvenida()
        # No mostramos/actualizamos autocompletado en teclas de control o
        # de navegacion (evita que se abra o cierre erraticamente).
        if evento is not None and evento.keysym in (
            "Tab", "Return", "Escape", "Up", "Down", "Left", "Right",
            "Shift_L", "Shift_R", "Control_L", "Control_R", "Alt_L", "Alt_R",
        ):
            return
        self._actualizar_autocompletado()

    # ------- Autocompletado real: comandos, variables y funciones -------
    def _palabra_actual(self):
        """Devuelve (inicio, fin, texto) de la palabra que se esta escribiendo
        justo antes del cursor."""
        indice_cursor = self.texto.index("insert")
        inicio_linea = self.texto.index(f"{indice_cursor} linestart")
        texto_linea = self.texto.get(inicio_linea, indice_cursor)
        m = re.search(r"[A-Za-z_][A-Za-z0-9_]*$", texto_linea)
        if not m:
            return None, None, ""
        columna_inicio = m.start()
        fila = indice_cursor.split(".")[0]
        return f"{fila}.{columna_inicio}", indice_cursor, m.group(0)

    def _obtener_motor(self):
        """Carga el motor de SiPi UNA sola vez y lo cachea (no en cada
        tecla presionada), para poder reusar sus mismas listas de palabras
        de apertura de bloque (PALABRAS_APERTURA_BLOQUE/PALABRAS_MISMO_NIVEL)
        en el autocompletado contextual, en vez de mantener una copia
        separada que se desincronice del lenguaje real."""
        if self._motor_sipi_cache is None:
            try:
                aqui = os.path.dirname(os.path.abspath(__file__))
                self._motor_sipi_cache = _cargar_motor_sipi(aqui)
            except Exception:
                self._motor_sipi_cache = False
        return self._motor_sipi_cache or None

    def _bloque_actual_en_cursor(self):
        """Item 5 de tu feedback (autocompletado 'con intencion'): escanea
        desde el principio del archivo hasta la linea del cursor, con el
        mismo criterio de apertura/cierre de bloques que usa
        formatear_codigo_actual, para saber en que tipo de bloque
        (si/mientras/repetir/para_cada/funcion/etc.) esta parado el cursor
        ahora mismo. Devuelve la palabra del bloque mas interno abierto en
        ese punto, o None si esta en el nivel superior del archivo."""
        motor = self._obtener_motor()
        if motor is None:
            return None
        indice_cursor = self.texto.index("insert")
        fila_cursor = int(indice_cursor.split(".")[0])
        contenido_previo = self.texto.get("1.0", f"{fila_cursor}.0 lineend")
        pila = []
        for linea_cruda in contenido_previo.split("\n"):
            limpia = re.sub(r"//.*$", "", linea_cruda).strip()
            if limpia == "":
                continue
            palabra = limpia.split(" ")[0]
            resto_linea = limpia[len(palabra):].strip()
            es_asignacion = resto_linea.startswith("=") and not resto_linea.startswith("==")
            if palabra == "fin":
                if pila:
                    pila.pop()
            elif palabra in motor.PALABRAS_MISMO_NIVEL:
                continue
            elif palabra in motor.PALABRAS_APERTURA_BLOQUE and not es_asignacion:
                pila.append(palabra)
        return pila[-1] if pila else None

    def _candidatos_contextuales(self):
        """Sugerencias que dependen de DONDE esta parado el cursor, no de
        que letras ya escribio: dentro de un bucle, prioriza 'romper' y
        'continuar'; escribiendo una condicion, prioriza 'y'/'o'/'no'."""
        candidatos = []
        indice_cursor = self.texto.index("insert")
        inicio_linea = self.texto.index(f"{indice_cursor} linestart")
        texto_linea_hasta_cursor = self.texto.get(inicio_linea, indice_cursor)
        if re.match(r"^\s*(si|sino_si|mientras)\s+\S", texto_linea_hasta_cursor):
            candidatos += ["y", "o", "no"]
        bloque = self._bloque_actual_en_cursor()
        if bloque in ("mientras", "repetir", "para_cada", "cada"):
            candidatos += ["romper", "continuar"]
        return candidatos

    def _candidatos_autocompletado(self, prefijo):
        """Junta comandos del lenguaje + variables + funciones definidas en
        el programa actual que empiecen con el prefijo que se esta
        escribiendo, con las sugerencias contextuales (ver
        _candidatos_contextuales) siempre primero en la lista."""
        contenido = self.texto.get("1.0", "end-1c")
        variables = set(re.findall(r"\b(?:variable|var|const)\s+([A-Za-z_]\w*)", contenido))
        funciones = set(re.findall(r"\bfuncion\s+([A-Za-z_]\w*)", contenido))
        candidatos = set(PALABRAS_CLAVE) | variables | funciones
        coincidencias = sorted(c for c in candidatos if c.startswith(prefijo) and c != prefijo)

        contextuales = [c for c in self._candidatos_contextuales() if c.startswith(prefijo) and c != prefijo]
        resto = [c for c in coincidencias if c not in contextuales]
        return (contextuales + resto)[:8]

    def _actualizar_autocompletado(self):
        inicio, fin, palabra = self._palabra_actual()
        if len(palabra) < 2:
            # Con menos de 2 letras normalmente no mostramos nada (seria
            # demasiado ruido), PERO si el cursor esta en un contexto
            # reconocido (recien escribio un espacio dentro de un bucle, o
            # esta armando una condicion) SI mostramos las sugerencias
            # contextuales aunque no haya ninguna letra escrita todavia --
            # es la esencia del item 5: sugerir por intencion, no solo por
            # texto ya tipeado.
            if palabra == "" and self._candidatos_contextuales():
                inicio = fin = self.texto.index("insert")
            else:
                self._cerrar_autocompletado()
                return
        candidatos = self._candidatos_autocompletado(palabra)
        if not candidatos:
            self._cerrar_autocompletado()
            return
        self._mostrar_autocompletado(candidatos, inicio, fin)

    def _mostrar_autocompletado(self, candidatos, inicio, fin):
        self._rango_autocompletado = (inicio, fin)
        colores = self._colores()
        if self.popup_autocompletar is None:
            self.popup_autocompletar = tk.Toplevel(self.root)
            self.popup_autocompletar.overrideredirect(True)
            self.popup_autocompletar.attributes("-topmost", True)
            self.lista_autocompletar = tk.Listbox(
                self.popup_autocompletar, bg=colores["barra"], fg=colores["texto"],
                selectbackground=colores["acento"], selectforeground=colores["fondo"],
                font=(_fuente_mono(), max(self.tamano_fuente - 1, 9)), height=min(8, len(candidatos)),
                bd=0, highlightthickness=1, highlightbackground=colores["acento"],
            )
            self.lista_autocompletar.pack(fill=tk.BOTH, expand=True)
            self.lista_autocompletar.bind("<ButtonRelease-1>", lambda e: self._aceptar_autocompletado())
        else:
            self.lista_autocompletar.delete(0, tk.END)
            self.lista_autocompletar.config(height=min(8, len(candidatos)))
        for c in candidatos:
            self.lista_autocompletar.insert(tk.END, c)
        self.lista_autocompletar.selection_clear(0, tk.END)
        self.lista_autocompletar.selection_set(0)
        try:
            bbox = self.texto.bbox(fin)
        except tk.TclError:
            bbox = None
        if bbox:
            x, y, _, alto = bbox
            x_abs = self.texto.winfo_rootx() + x
            y_abs = self.texto.winfo_rooty() + y + alto
            self.popup_autocompletar.geometry(f"+{x_abs}+{y_abs}")

    def _cerrar_autocompletado(self, evento=None):
        if self.popup_autocompletar is not None:
            self.popup_autocompletar.destroy()
            self.popup_autocompletar = None
            self.lista_autocompletar = None

    def _mover_autocompletado(self, evento):
        if self.popup_autocompletar is None:
            return  # deja que Up/Down se comporten normal si no hay popup abierto
        seleccion = self.lista_autocompletar.curselection()
        idx = seleccion[0] if seleccion else 0
        total = self.lista_autocompletar.size()
        if evento.keysym == "Down":
            idx = min(idx + 1, total - 1)
        else:
            idx = max(idx - 1, 0)
        self.lista_autocompletar.selection_clear(0, tk.END)
        self.lista_autocompletar.selection_set(idx)
        self.lista_autocompletar.see(idx)
        return "break"

    def _aceptar_autocompletado(self, evento=None):
        if self.popup_autocompletar is None:
            return  # deja que Tab se comporte normal (indentar) si no hay popup abierto
        seleccion = self.lista_autocompletar.curselection()
        idx = seleccion[0] if seleccion else 0
        palabra = self.lista_autocompletar.get(idx)
        inicio, fin = self._rango_autocompletado
        self.texto.delete(inicio, fin)
        self.texto.insert(inicio, palabra)
        self._cerrar_autocompletado()
        self._resaltar_sintaxis()
        return "break"

    def _al_presionar_enter(self, evento=None):
        if self.popup_autocompletar is not None:
            return self._aceptar_autocompletado()
        return None  # Enter normal (nueva linea) si no hay popup abierto

    # Antes esta funcion (a) recorria las ~150 PALABRAS_CLAVE con un
    # re.finditer() POR CADA UNA en cada linea (150+ pasadas de regex
    # por linea), y (b) volvia a colorear el archivo ENTERO en cada
    # pulsacion de tecla via <KeyRelease> -- en un archivo de varios
    # cientos de lineas eso significaba decenas de miles de llamadas a
    # regex por letra tipeada, y el editor se sentia "pesado" apenas
    # crecia el archivo. Dos cambios lo arreglan de raiz:
    #
    #   1. Un unico regex combinado (compilado UNA sola vez, no en
    #      cada linea) con todas las palabras clave como alternativas,
    #      en vez de 150 regex sueltos.
    #   2. Resaltado INCREMENTAL: al tipear, solo se re-analiza la
    #      linea que cambio (mas la linea anterior, por si una cadena
    #      o comentario cruzaba el salto de linea), no el archivo
    #      completo. El resaltado del archivo entero sigue existiendo
    #      -- para abrir un archivo, pegar texto grande, cambiar de
    #      tema, etc. -- pero ya no corre en cada tecla.
    _RE_PATRON_TOKENS = None
    _RE_PALABRAS_CLAVE_CACHE = None

    @classmethod
    def _patron_tokens_sintaxis(cls):
        """Regex combinado (comentario | cadena | numero | palabra clave)
        compilado una sola vez y reutilizado. Se re-compila solo si
        PALABRAS_CLAVE cambio (por ejemplo, tras cargar el motor real de
        SiPi y descubrir mas comandos), no en cada resaltado."""
        if cls._RE_PATRON_TOKENS is not None and cls._RE_PALABRAS_CLAVE_CACHE == PALABRAS_CLAVE:
            return cls._RE_PATRON_TOKENS
        alternativas_clave = "|".join(re.escape(p) for p in sorted(PALABRAS_CLAVE, key=len, reverse=True))
        patron = re.compile(
            r"(?P<comentario>//.*$)"
            r"|(?P<cadena>\"[^\"]*\")"
            r"|(?P<clave>\b(?:" + alternativas_clave + r")\b)"
            r"|(?P<numero>\b\d+(?:\.\d+)?\b)",
            re.MULTILINE,
        )
        cls._RE_PATRON_TOKENS = patron
        cls._RE_PALABRAS_CLAVE_CACHE = PALABRAS_CLAVE
        return patron

    def _resaltar_linea(self, patron, i, linea):
        """Aplica los tags de UNA linea ya recortada del contenido."""
        for m in patron.finditer(linea):
            grupo = m.lastgroup
            self.texto.tag_add(grupo, f"{i}.{m.start()}", f"{i}.{m.end()}")

    def _resaltar_sintaxis(self):
        """Resaltado de sintaxis del archivo COMPLETO. Se usa al abrir un
        archivo, pegar texto, cambiar de tema, formatear, etc. -- no en
        cada tecla (ver '_resaltar_sintaxis_incremental')."""
        patron = self._patron_tokens_sintaxis()
        contenido = self.texto.get("1.0", "end-1c")
        for tag in ("clave", "cadena", "comentario", "numero"):
            self.texto.tag_remove(tag, "1.0", "end")
        for i, linea in enumerate(contenido.split("\n"), start=1):
            self._resaltar_linea(patron, i, linea)

    def _resaltar_sintaxis_incremental(self, evento=None):
        """Version rapida para tipeo normal: solo vuelve a colorear la
        linea del cursor (y la anterior, por si un '//' o una cadena sin
        cerrar cambiaron de estado con el Enter). Uso pensado para
        <KeyRelease>; para cambios grandes (abrir archivo, pegar,
        deshacer masivo) se sigue usando '_resaltar_sintaxis' completo."""
        try:
            fila_actual = int(self.texto.index("insert").split(".")[0])
        except (tk.TclError, ValueError):
            return self._resaltar_sintaxis()

        patron = self._patron_tokens_sintaxis()
        primera_fila = max(1, fila_actual - 1)
        ultima_fila = fila_actual + 1
        for tag in ("clave", "cadena", "comentario", "numero"):
            self.texto.tag_remove(tag, f"{primera_fila}.0", f"{ultima_fila}.end")

        for i in range(primera_fila, ultima_fila + 1):
            linea = self.texto.get(f"{i}.0", f"{i}.end")
            if not linea and i != fila_actual:
                continue
            self._resaltar_linea(patron, i, linea)

    def _actualizar_numeros_linea(self):
        total_lineas = int(self.texto.index("end-1c").split(".")[0])
        self.numeros.config(state="normal")
        self.numeros.delete("1.0", "end")
        self.numeros.insert("1.0", "\n".join(str(n) for n in range(1, total_lineas + 1)))
        self.numeros.config(state="disabled")

    # ------- Vista previa en vivo -------
    def _programar_vista_previa(self):
        if not self.vista_previa_activa:
            return
        if self.tarea_pendiente:
            self.root.after_cancel(self.tarea_pendiente)
        self.tarea_pendiente = self.root.after(900, self._actualizar_vista_previa)

    def _ruta_motor_sipi(self, aqui):
        """Devuelve la ruta al motor de SiPi que hay que usar: si estamos en
        una carpeta de publicacion (donde solo existe 'sipi_protegido.py'),
        lo usa a el; si estamos en la carpeta de desarrollo (donde existe
        'sipi.py'), usa ese. Antes esto estaba fijo a 'sipi.py', lo que
        rompia 'editor_protegido.py' generado por proteger_codigo.py: al
        distribuirse sin 'sipi.py' (solo con 'sipi_protegido.py'), el boton
        de Ejecutar/vista previa fallaba porque buscaba un archivo que ya
        no estaba en esa carpeta.
        """
        protegido = os.path.join(aqui, "sipi_protegido.py")
        normal = os.path.join(aqui, "sipi.py")
        if os.path.exists(normal):
            return normal
        if os.path.exists(protegido):
            return protegido
        return normal  # ninguno existe: dejamos que falle con un error claro mas abajo

    def _comando_motor(self, sipi_py, ruta_archivo):
        """Arma el argv correcto para correr un archivo .sipi, sea que
        estemos corriendo desde codigo fuente (dev, con un Python real
        al lado) o adentro de un .exe empaquetado (sin Python externo
        ni archivos .py sueltos disponibles). Ver
        '_intentar_correr_como_motor_interno' para el detalle del bug
        que esto soluciona."""
        if _es_exe_empaquetado():
            # sys.executable en un .exe de PyInstaller apunta al .exe
            # mismo -- lo volvemos a invocar con la bandera interna en
            # vez de pedirle a la maquina del usuario un 'python' que
            # no tiene.
            return [sys.executable, _bandera_reentrada_motor(), ruta_archivo]
        return [sys.executable, sipi_py, ruta_archivo]

    def _ruta_generar_exe(self, aqui):
        """Igual que _ruta_motor_sipi pero para el compilador a .exe."""
        protegido = os.path.join(aqui, "generar_exe_protegido.py")
        normal = os.path.join(aqui, "generar_exe.py")
        if os.path.exists(normal):
            return normal
        if os.path.exists(protegido):
            return protegido
        return normal

    def _actualizar_vista_previa(self):
        codigo = self.texto.get("1.0", "end-1c")
        aqui = os.path.dirname(os.path.abspath(__file__))
        sipi_py = self._ruta_motor_sipi(aqui)

        # No corremos ventanas/juegos en la vista previa (abrirían ventanas reales
        # cada vez que escribis); avisamos y solo mostramos que se detecto ese tipo.
        if re.search(r"\bventana\b|\bcrear_juego\b", codigo):
            self._mostrar_previa(
                "[Vista previa] Este programa abre una ventana o un juego real.\n"
                "Presiona '▶ Ejecutar' para verlo funcionando en su propia ventana."
            )
            return

        try:
            import tempfile
            with tempfile.NamedTemporaryFile("w", suffix=".sipi", delete=False, encoding="utf-8") as f:
                f.write(codigo)
                ruta_temporal = f.name
            resultado = subprocess.run(
                self._comando_motor(sipi_py, ruta_temporal),
                capture_output=True, text=True, timeout=5, cwd=aqui,
                encoding="utf-8", errors="replace",
                env={**os.environ, "PYTHONIOENCODING": "utf-8"},
            )
            salida = resultado.stdout
            if resultado.stderr:
                salida += "\n" + resultado.stderr
            os.remove(ruta_temporal)
            self._mostrar_previa(salida if salida.strip() else "(sin salida todavia)")
        except subprocess.TimeoutExpired:
            self._mostrar_previa("[Vista previa] El programa tarda demasiado o espera datos (preguntar/bucles).")
        except Exception as e:
            sipi_debug.log_debug(f"vista previa: excepcion no controlada: {type(e).__name__}: {e}")
            self._mostrar_previa(f"[Vista previa] {sipi_debug.mensaje_error_usuario(e)}")

    def _mostrar_previa(self, texto):
        self.salida_previa.config(state="normal")
        self.salida_previa.delete("1.0", "end")
        self.salida_previa.insert("1.0", texto)
        self.salida_previa.config(state="disabled")

    # ------- Ventana de configuracion -------
    def abrir_configuracion(self):
        ventana = tk.Toplevel(self.root)
        ventana.title("Configurar SiPi Editor")
        ventana.geometry("380x420")
        colores = self._colores()
        ventana.configure(bg=colores["barra"])

        tk.Label(ventana, text="Tema de colores", bg=colores["barra"], fg=colores["texto"],
                 font=("Segoe UI", 11, "bold")).pack(pady=(16, 6))

        var_tema = tk.StringVar(value=self.tema_actual)
        for nombre_tema in TEMAS:
            tk.Radiobutton(
                ventana, text=nombre_tema, variable=var_tema, value=nombre_tema,
                bg=colores["barra"], fg=colores["texto"], selectcolor=colores["fondo"],
                activebackground=colores["barra"], activeforeground=colores["texto"],
                font=("Segoe UI", 10),
                command=lambda v=var_tema: self._cambiar_tema(v.get())
            ).pack(anchor="w", padx=30)

        tk.Label(ventana, text="Tamaño de letra", bg=colores["barra"], fg=colores["texto"],
                 font=("Segoe UI", 11, "bold")).pack(pady=(20, 6))

        escala = tk.Scale(
            ventana, from_=9, to=24, orient=tk.HORIZONTAL, bg=colores["barra"],
            fg=colores["texto"], highlightthickness=0, troughcolor=colores["fondo"],
            command=lambda v: self._cambiar_tamano_fuente(int(v))
        )
        escala.set(self.tamano_fuente)
        escala.pack(fill=tk.X, padx=30)

        tk.Label(ventana, text="Vista previa en vivo", bg=colores["barra"], fg=colores["texto"],
                 font=("Segoe UI", 11, "bold")).pack(pady=(20, 6))

        var_previa = tk.BooleanVar(value=self.vista_previa_activa)
        tk.Checkbutton(
            ventana, text="Actualizar la vista previa mientras escribo", variable=var_previa,
            bg=colores["barra"], fg=colores["texto"], selectcolor=colores["fondo"],
            activebackground=colores["barra"], activeforeground=colores["texto"],
            font=("Segoe UI", 10),
            command=lambda: self._cambiar_vista_previa(var_previa.get())
        ).pack(anchor="w", padx=30)

        tk.Label(ventana, text="Color personalizado del texto", bg=colores["barra"],
                 fg=colores["texto"], font=("Segoe UI", 11, "bold")).pack(pady=(20, 6))
        tk.Button(ventana, text="Elegir color de texto...", command=self._elegir_color_texto,
                  bg="#313244", fg=colores["texto"], bd=0, padx=10, pady=6).pack()

        tk.Button(ventana, text="Cerrar", command=ventana.destroy, bg="#40a02b", fg="white",
                  bd=0, padx=16, pady=8, font=("Segoe UI", 10, "bold")).pack(pady=24)

    def _cambiar_tema(self, nombre_tema):
        self.tema_actual = nombre_tema
        self._aplicar_tema()
        self._resaltar_sintaxis()

    def _cambiar_tamano_fuente(self, tamano):
        self.tamano_fuente = tamano
        self.texto.configure(font=(_fuente_mono(), tamano))
        self.numeros.configure(font=(_fuente_mono(), tamano))
        self._guardar_config()

    def _acercar_alejar_fuente(self, delta):
        """Botones 'A-' / 'A+' de la barra: zoom rapido del tamaño de letra
        sin tener que abrir el panel de Configurar."""
        nuevo = max(8, min(36, self.tamano_fuente + delta))
        self._cambiar_tamano_fuente(nuevo)
        if hasattr(self, "_id_aviso_zoom") and self._id_aviso_zoom:
            self.root.after_cancel(self._id_aviso_zoom)
        self._mostrar_aviso_estado(f"Tamaño de letra: {nuevo}")

    def _mostrar_aviso_estado(self, texto, duracion_ms=1600):
        """Muestra un mensaje corto y transitorio en la barra de estado,
        si esta ya existe (self.estado)."""
        if hasattr(self, "estado"):
            anterior = self.estado.cget("text")
            self.estado.configure(text=texto)
            self._id_aviso_zoom = self.root.after(
                duracion_ms, lambda: self.estado.configure(text=anterior)
            )

    # ------- Tutorial interactivo -------
    LECCIONES_TUTORIAL = [
        {
            "titulo": "1. Tu primer programa",
            "explicacion": (
                "Todo programa en SiPi arranca con 'programa \"Nombre\"'. "
                "Despues podes crear variables con 'variable' y mostrar cosas "
                "en pantalla con 'decir'. Los textos entre llaves {asi} dentro "
                "de un 'decir' se reemplazan por el valor de esa variable."
            ),
            "codigo": (
                'programa "Mi primer programa"\n\n'
                'variable nombre = "Mundo"\n'
                'decir "Hola, {nombre}! Esto corre en SiPi de verdad."\n'
            ),
        },
        {
            "titulo": "2. Numeros y operaciones",
            "explicacion": (
                "Podes declarar variables numericas y operar con ellas usando "
                "'sumar', 'restar' o los operadores +, -, *, /. El resultado se "
                "puede guardar en otra variable y mostrarlo con 'decir'."
            ),
            "codigo": (
                'programa "Numeros"\n\n'
                'variable precio = 1500\n'
                'variable descuento = 300\n'
                'variable total = precio - descuento\n'
                'decir "Total a pagar: {total}"\n'
            ),
        },
        {
            "titulo": "3. Condicionales",
            "explicacion": (
                "Con 'si' / 'sino' podes ramificar el programa segun una "
                "condicion. SiPi evalua la condicion y ejecuta el bloque que "
                "corresponda."
            ),
            "codigo": (
                'programa "Condicionales"\n\n'
                'variable edad = 20\n\n'
                'si edad >= 18\n'
                '    decir "Sos mayor de edad."\n'
                'sino\n'
                '    decir "Sos menor de edad."\n'
                'fin\n'
            ),
        },
        {
            "titulo": "4. Repeticiones (bucles)",
            "explicacion": (
                "El bloque 'repetir ... veces' ejecuta el codigo interno la "
                "cantidad de veces indicada. Es la forma mas simple de repetir "
                "una accion en SiPi."
            ),
            "codigo": (
                'programa "Repetir"\n\n'
                'repetir 5 veces\n'
                '    decir "Hola desde SiPi!"\n'
                'fin\n'
            ),
        },
        {
            "titulo": "5. Funciones propias",
            "explicacion": (
                "Con 'funcion' podes agrupar codigo reutilizable, con "
                "parametros propios, y despues llamarla por su nombre las "
                "veces que necesites."
            ),
            "codigo": (
                'programa "Funciones"\n\n'
                'funcion saludar(nombre)\n'
                '    decir "Hola, {nombre}!"\n'
                'fin\n\n'
                'llamar saludar("Mateo")\n'
                'llamar saludar("SiPi")\n'
            ),
        },
        {
            "titulo": "6. Listas",
            "explicacion": (
                "Las listas guardan varios valores juntos. Se recorren con "
                "'para_cada' para procesar cada elemento uno por uno."
            ),
            "codigo": (
                'programa "Listas"\n\n'
                'variable frutas = ["manzana", "banana", "pera"]\n\n'
                'para_cada fruta en frutas\n'
                '    decir "Me gusta la {fruta}"\n'
                'fin\n'
            ),
        },
    ]

    def abrir_tutorial(self):
        """Item nuevo: tutorial interactivo integrado en el editor. Muestra
        una leccion a la vez con explicacion + codigo de ejemplo, y permite
        insertar ese codigo directamente en una pestaña nueva para probarlo
        sin salir del editor."""
        colores = self._colores()
        ventana = tk.Toplevel(self.root)
        ventana.title("Tutorial de SiPi")
        ventana.geometry("620x520")
        ventana.configure(bg=colores["fondo"])

        estado = {"indice": 0}

        encabezado = tk.Frame(ventana, bg=colores["barra"])
        encabezado.pack(fill=tk.X)
        etiqueta_titulo = tk.Label(encabezado, text="", bg=colores["barra"], fg=colores["texto"],
                                    font=("Segoe UI", 13, "bold"), anchor="w", padx=14, pady=10)
        etiqueta_titulo.pack(fill=tk.X)

        texto_explicacion = tk.Text(ventana, height=6, wrap="word", bg=colores["fondo"],
                                     fg=colores["texto"], font=("Segoe UI", 11), padx=14, pady=10,
                                     bd=0, relief="flat")
        texto_explicacion.pack(fill=tk.X)

        tk.Label(ventana, text="Codigo de ejemplo:", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 10, "bold"), anchor="w", padx=14).pack(fill=tk.X, pady=(6, 0))

        area_codigo = tk.Text(ventana, wrap="none", bg="#1e1e2e", fg="#a6e3a1",
                               insertbackground="white", font=(_fuente_mono(), 11), padx=10, pady=10)
        area_codigo.pack(fill=tk.BOTH, expand=True, padx=14, pady=(4, 10))

        barra_inferior = tk.Frame(ventana, bg=colores["fondo"])
        barra_inferior.pack(fill=tk.X, pady=(0, 14))

        etiqueta_progreso = tk.Label(barra_inferior, text="", bg=colores["fondo"], fg=colores["texto"],
                                      font=("Segoe UI", 9))
        etiqueta_progreso.pack(side=tk.LEFT, padx=14)

        def _mostrar_leccion():
            i = estado["indice"]
            leccion = self.LECCIONES_TUTORIAL[i]
            etiqueta_titulo.configure(text=leccion["titulo"])
            texto_explicacion.configure(state="normal")
            texto_explicacion.delete("1.0", tk.END)
            texto_explicacion.insert("1.0", leccion["explicacion"])
            texto_explicacion.configure(state="disabled")
            area_codigo.delete("1.0", tk.END)
            area_codigo.insert("1.0", leccion["codigo"])
            etiqueta_progreso.configure(text=f"Leccion {i + 1} de {len(self.LECCIONES_TUTORIAL)}")
            boton_anterior.configure(state=("normal" if i > 0 else "disabled"))
            boton_siguiente.configure(state=("normal" if i < len(self.LECCIONES_TUTORIAL) - 1 else "disabled"))

        def _anterior():
            if estado["indice"] > 0:
                estado["indice"] -= 1
                _mostrar_leccion()

        def _siguiente():
            if estado["indice"] < len(self.LECCIONES_TUTORIAL) - 1:
                estado["indice"] += 1
                _mostrar_leccion()

        def _probar_en_editor():
            leccion = self.LECCIONES_TUTORIAL[estado["indice"]]
            self.nueva_pestana()
            self.texto.delete("1.0", tk.END)
            self.texto.insert("1.0", leccion["codigo"])
            self._resaltar_sintaxis()
            self._actualizar_numeros_linea()
            self._programar_vista_previa()
            self._marcar_modificado()
            self._mostrar_aviso_estado("Leccion cargada en una pestaña nueva")

        boton_anterior = tk.Button(barra_inferior, text="< Anterior", command=_anterior,
                                    bg="#313244", fg=colores["texto"], bd=0, padx=12, pady=6,
                                    font=("Segoe UI", 10))
        boton_anterior.pack(side=tk.LEFT, padx=(0, 6))

        boton_siguiente = tk.Button(barra_inferior, text="Siguiente >", command=_siguiente,
                                     bg="#313244", fg=colores["texto"], bd=0, padx=12, pady=6,
                                     font=("Segoe UI", 10))
        boton_siguiente.pack(side=tk.LEFT)

        tk.Button(barra_inferior, text="> Probar en el editor", command=_probar_en_editor,
                  bg="#40a02b", fg="white", bd=0, padx=14, pady=6,
                  font=("Segoe UI", 10, "bold")).pack(side=tk.RIGHT, padx=14)

        tk.Button(barra_inferior, text="Ver curso completo online", command=self.abrir_curso_online,
                  bg="#04a5e5", fg="white", bd=0, padx=14, pady=6,
                  font=("Segoe UI", 10, "bold")).pack(side=tk.RIGHT, padx=(0, 6))

        _mostrar_leccion()

    def abrir_sipi_world(self):
        """SiPi World: cuenta, chat global, comunidades y proyectos para
        hacer con otra gente (cerca o lejos -- ver sipi_world_servidor.py
        para el detalle honesto de que hace falta para que funcione a
        distancia). Se abre una sola ventana por sesion del editor -- si
        ya esta abierta, la trae al frente en vez de duplicarla."""
        if getattr(self, "_ventana_sipi_world", None) is not None:
            try:
                self._ventana_sipi_world.ventana.deiconify()
                self._ventana_sipi_world.ventana.lift()
                return
            except tk.TclError:
                self._ventana_sipi_world = None
        try:
            from sipi_world_ventana import VentanaSiPiWorld
        except ImportError as e:
            messagebox.showerror("SiPi World", f"No se pudo cargar SiPi World: {e}")
            return
        self._ventana_sipi_world = VentanaSiPiWorld(self)

    def abrir_galeria_ejemplos(self):
        """Item nuevo: galeria rapida de fragmentos de codigo listos para
        insertar (distinto del tutorial paso a paso: son recetas sueltas
        para copiar y adaptar)."""
        colores = self._colores()
        recetas = [
            ("Pedir un dato al usuario",
             'programa "Entrada"\n\nvariable nombre = preguntar "Como te llamas? "\n'
             'decir "Encantado, {nombre}!"\n'),
            ("Mientras (bucle con condicion)",
             'programa "Mientras"\n\nvariable contador = 0\n\n'
             'mientras contador < 5\n'
             '    decir "Vuelta numero {contador}"\n'
             '    variable contador = contador + 1\n'
             'fin\n'),
            ("Lista + funcion",
             'programa "Lista y funcion"\n\n'
             'funcion es_par(numero)\n'
             '    devolver numero % 2 == 0\n'
             'fin\n\n'
             'variable numeros = [1, 2, 3, 4, 5, 6]\n'
             'para_cada n en numeros\n'
             '    si es_par(n)\n'
             '        decir "{n} es par"\n'
             '    fin\n'
             'fin\n'),
            ("Manejo de errores",
             'programa "Manejo de errores"\n\n'
             'intentar\n'
             '    variable resultado = 10 / 0\n'
             'capturar error\n'
             '    decir "Ocurrio un error: {error}"\n'
             'fin\n'),
        ]

        ventana = tk.Toplevel(self.root)
        ventana.title("Galeria de ejemplos rapidos")
        ventana.geometry("420x360")
        ventana.configure(bg=colores["fondo"])

        tk.Label(ventana, text="Elegi una receta para insertarla en una pestaña nueva:",
                 bg=colores["fondo"], fg=colores["texto"], font=("Segoe UI", 10),
                 wraplength=390, justify="left", padx=14, pady=10).pack(fill=tk.X)

        marco_lista = tk.Frame(ventana, bg=colores["fondo"])
        marco_lista.pack(fill=tk.BOTH, expand=True, padx=14, pady=4)

        def _insertar(codigo):
            self.nueva_pestana()
            self.texto.delete("1.0", tk.END)
            self.texto.insert("1.0", codigo)
            self._resaltar_sintaxis()
            self._actualizar_numeros_linea()
            self._programar_vista_previa()
            self._marcar_modificado()
            ventana.destroy()

        for titulo, codigo in recetas:
            tk.Button(marco_lista, text=titulo, anchor="w", command=lambda c=codigo: _insertar(c),
                      bg="#313244", fg=colores["texto"], bd=0, padx=12, pady=8,
                      font=("Segoe UI", 10), activebackground="#45475a").pack(fill=tk.X, pady=3)

    # ------- SiPi IA -------
    def _traer_al_frente(self, ventana):
        """Fuerza que una ventana nueva (Toplevel) quede asociada a la
        ventana principal, al frente y con foco -- sin esto, en varios
        gestores de ventanas de Linux una Toplevel recien creada puede
        aparecer detras de la ventana principal o sin foco, lo que se
        siente como si el boton no hubiera hecho nada."""
        try:
            ventana.transient(self.root)
            ventana.lift()
            ventana.attributes("-topmost", True)
            ventana.after(150, lambda: ventana.attributes("-topmost", False))
            ventana.focus_force()
        except Exception:
            pass

    def _actualizar_boton_ia(self):
        """Cambia el look del boton 'SiPi IA' de la barra segun si ya
        hay una clave de API configurada o no -- antes el boton se veia
        siempre igual, asi que cuando abria el dialogo de 'pegar tu
        clave' en vez del panel de IA (porque todavia no habia clave
        configurada) parecia que el boton 'no hacia nada'/'no abria'."""
        if not hasattr(self, "boton_ia"):
            return
        try:
            import sipi_ia
            configurada = sipi_ia.hay_api_key_configurada()
        except Exception:
            configurada = True  # ante la duda no se molesta al usuario con una alerta falsa
        texto = "SiPi IA" if configurada else "SiPi IA (config.)"
        bg = "#04a5e5" if configurada else "#fe640b"
        hover = "#3fc1f0" if configurada else "#fe8a3c"
        try:
            self.boton_ia.configure(text=(" " + texto), bg=bg, activebackground=hover)
        except (tk.TclError, ValueError):
            # Bug real encontrado al probar en Debian con customtkinter
            # instalado: un CTkButton no acepta 'bg'/'activebackground'
            # (son argumentos de tk.Button comun) y en vez de lanzar
            # tk.TclError lanza un ValueError propio de customtkinter, asi
            # que el 'except tk.TclError' de aca abajo nunca lo atajaba y
            # el editor entero crasheaba apenas se creaba la barra de
            # herramientas.
            try:
                self.boton_ia.configure(text=(" " + texto), fg_color=bg, hover_color=hover)
            except Exception:
                pass

    def _agregar_menu_configuracion_ia(self, boton):
        """Clic derecho sobre el boton 'SiPi IA' de la barra de
        herramientas abre directo el dialogo para pegar/cambiar la clave
        de API -- antes habia que abrir todo el panel de SiPi IA primero
        (y esperar a que revisara si ya habia una clave configurada)
        solo para llegar al boton 'Cambiar clave de API' de adentro."""
        colores = self._colores()
        menu = tk.Menu(self.root, tearoff=0, bg=colores["barra"], fg=colores["texto"],
                        activebackground="#45475a", activeforeground="white")
        menu.add_command(
            label="Configurar clave de API...",
            command=lambda: self._pedir_api_key_ia(None, reintentar=self.abrir_sipi_ia, forzar=True))
        menu.add_command(label="Abrir SiPi IA", command=self.abrir_sipi_ia)

        def _mostrar_menu(evento):
            try:
                menu.tk_popup(evento.x_root, evento.y_root)
            finally:
                menu.grab_release()

        def _bind_recursivo(widget):
            widget.bind("<Button-3>", _mostrar_menu)
            for hijo in widget.winfo_children():
                _bind_recursivo(hijo)

        _bind_recursivo(boton)

    def abrir_traductor_spir(self):
        """Panel del traductor SPIR (SiPi IA) <-> SiPi: dos cajas de
        texto lado a lado, botones para traducir en cualquier direccion,
        y un boton para copiar el 'prompt de sistema' autocontenido que
        cualquier persona puede pegarle a su IA favorita (ChatGPT,
        Claude, Gemini, un modelo local, lo que sea) para que aprenda a
        escribir SPIR sin necesitar nada de este repositorio salvo ese
        texto."""
        try:
            import sipi_ia_lang as spir_lang
            from sipi_ia_spec import generar_prompt_sistema
        except ImportError:
            messagebox.showerror(
                "SiPi IA -- Traductor",
                "No se encontraron los archivos de SiPi IA para IA\n"
                "('sipi_ia_lang.py' / 'sipi_ia_tabla.py' / 'sipi_ia_spec.py')\n"
                "junto al editor.",
            )
            return

        if getattr(self, "_ventana_spir", None) is not None:
            try:
                if self._ventana_spir.winfo_exists():
                    self._ventana_spir.deiconify()
                    self._ventana_spir.lift()
                    self._ventana_spir.focus_force()
                    return
            except Exception:
                pass

        colores = self._colores()
        fuente_editor = _fuente_mono()
        ventana = tk.Toplevel(self.root)
        self._ventana_spir = ventana
        ventana.title("SiPi IA -- Traductor (SPIR)")
        ventana.geometry("980x600")
        ventana.configure(bg=colores["fondo"])

        tk.Label(
            ventana,
            text="SPIR es el dialecto compacto pensado para que una IA escriba SiPi mucho más rápido "
                 "(comandos como códigos '$xx' y bloques con llaves en vez de 'fin'). Traducilo acá en "
                 "cualquier dirección, o copiá el prompt de sistema para que tu IA aprenda a escribirlo.",
            bg=colores["fondo"], fg=colores["texto"], font=("Segoe UI", 9),
            wraplength=940, justify="left", anchor="w",
        ).pack(fill=tk.X, padx=10, pady=(10, 6))

        marco_paneles = tk.Frame(ventana, bg=colores["fondo"])
        marco_paneles.pack(fill=tk.BOTH, expand=True, padx=10)

        marco_izq = tk.Frame(marco_paneles, bg=colores["fondo"])
        marco_izq.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
        tk.Label(marco_izq, text="SPIR (SiPi IA)", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 9, "bold")).pack(anchor="w")
        texto_spir = tk.Text(marco_izq, bg=colores["fondo"], fg=colores["texto"],
                              insertbackground=colores["cursor"], font=(fuente_editor, 11), undo=True)
        texto_spir.pack(fill=tk.BOTH, expand=True)

        marco_der = tk.Frame(marco_paneles, bg=colores["fondo"])
        marco_der.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 0))
        tk.Label(marco_der, text="SiPi (para humanos)", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 9, "bold")).pack(anchor="w")
        texto_sipi = tk.Text(marco_der, bg=colores["fondo"], fg=colores["texto"],
                              insertbackground=colores["cursor"], font=(fuente_editor, 11), undo=True)
        texto_sipi.pack(fill=tk.BOTH, expand=True)

        etiqueta_estado = tk.Label(ventana, text="", bg=colores["fondo"], fg="#f38ba8",
                                    font=("Segoe UI", 9), anchor="w")
        etiqueta_estado.pack(fill=tk.X, padx=10)

        def _traducir_a_sipi():
            try:
                resultado = spir_lang.traducir_a_sipi(texto_spir.get("1.0", "end-1c"))
                texto_sipi.delete("1.0", "end")
                texto_sipi.insert("1.0", resultado)
                etiqueta_estado.configure(text="Traducido SPIR -> SiPi.", fg="#a6e3a1")
            except spir_lang.ErrorTraduccionSpir as e:
                etiqueta_estado.configure(text=f"Error en la línea {e.linea}: {e}", fg="#f38ba8")

        def _traducir_a_spir():
            resultado = spir_lang.traducir_desde_sipi(texto_sipi.get("1.0", "end-1c"))
            texto_spir.delete("1.0", "end")
            texto_spir.insert("1.0", resultado)
            etiqueta_estado.configure(text="Traducido SiPi -> SPIR.", fg="#a6e3a1")

        def _ejecutar_spir():
            _traducir_a_sipi()
            if not etiqueta_estado.cget("text").startswith("Error"):
                self.nueva_pestana()
                self.texto.insert("1.0", texto_sipi.get("1.0", "end-1c"))
                self._resaltar_sintaxis()
                self.ejecutar()

        def _copiar_prompt_ia():
            prompt = generar_prompt_sistema()
            self.root.clipboard_clear()
            self.root.clipboard_append(prompt)
            etiqueta_estado.configure(
                text="Prompt de sistema copiado al portapapeles -- pegalo en las instrucciones de tu IA.",
                fg="#a6e3a1",
            )

        marco_botones = tk.Frame(ventana, bg=colores["fondo"])
        marco_botones.pack(fill=tk.X, padx=10, pady=8)
        tk.Button(marco_botones, text="SPIR → SiPi", command=_traducir_a_sipi,
                  bg="#40a02b", fg="white", activebackground="#5fc93f", bd=0, padx=12, pady=4,
                  font=("Segoe UI", 9, "bold"), cursor="hand2").pack(side=tk.LEFT, padx=(0, 6))
        tk.Button(marco_botones, text="SiPi → SPIR", command=_traducir_a_spir,
                  bg="#04a5e5", fg="white", activebackground="#3fc1f0", bd=0, padx=12, pady=4,
                  font=("Segoe UI", 9, "bold"), cursor="hand2").pack(side=tk.LEFT, padx=6)
        tk.Button(marco_botones, text="▶ Traducir y ejecutar", command=_ejecutar_spir,
                  bg="#fe640b", fg="white", activebackground="#fe8a3c", bd=0, padx=12, pady=4,
                  font=("Segoe UI", 9, "bold"), cursor="hand2").pack(side=tk.LEFT, padx=6)

        def _generar_con_ia():
            try:
                import sipi_ia
                import sipi_ia_generador_spir as generador_spir
            except ImportError:
                etiqueta_estado.configure(text="Falta 'sipi_ia.py' / 'sipi_ia_generador_spir.py'.", fg="#f38ba8")
                return
            if not sipi_ia.hay_api_key_configurada():
                self._pedir_api_key_ia(None, reintentar=self.abrir_traductor_spir, forzar=True)
                return
            descripcion = simpledialog.askstring(
                "Generar con IA (en SPIR)",
                "Describí qué programa querés (la IA lo va a escribir directo en SPIR,\n"
                "mucho más rápido que si lo escribiera en SiPi normal):",
                parent=ventana,
            )
            if not descripcion:
                return
            etiqueta_estado.configure(text="Generando en SPIR y verificando contra el motor real...", fg=colores["texto"])
            ventana.update_idletasks()

            aqui = os.path.dirname(os.path.abspath(__file__))
            ruta_motor = os.path.join(aqui, "sipi.py")

            def _tarea():
                try:
                    resultado = generador_spir.generar_con_auto_correccion_spir(descripcion, ruta_motor)
                except sipi_ia.ErrorSiPiIA as e:
                    self.root.after(0, lambda: etiqueta_estado.configure(text=f"Error de SiPi IA: {e}", fg="#f38ba8"))
                    return

                def _mostrar():
                    ultimo_spir = resultado.intentos[-1]["codigo_spir"] if resultado.intentos else ""
                    texto_spir.delete("1.0", "end")
                    texto_spir.insert("1.0", ultimo_spir or "")
                    texto_sipi.delete("1.0", "end")
                    texto_sipi.insert("1.0", resultado.codigo_final)
                    if resultado.exito:
                        etiqueta_estado.configure(
                            text=f"Listo en {len(resultado.intentos)} intento(s): {resultado.motivo_fin}",
                            fg="#a6e3a1",
                        )
                    else:
                        etiqueta_estado.configure(text=resultado.motivo_fin, fg="#f38ba8")

                self.root.after(0, _mostrar)

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

        tk.Button(marco_botones, text="✨ Generar con IA (en SPIR)", command=_generar_con_ia,
                  bg="#8839ef", fg="white", activebackground="#a565f5", bd=0, padx=12, pady=4,
                  font=("Segoe UI", 9, "bold"), cursor="hand2").pack(side=tk.LEFT, padx=6)
        tk.Button(marco_botones, text="📋 Copiar prompt para tu IA", command=_copiar_prompt_ia,
                  bg=colores["barra"], fg=colores["texto"], activebackground="#45475a", bd=0, padx=12, pady=4,
                  font=("Segoe UI", 9), cursor="hand2").pack(side=tk.RIGHT)

    def abrir_sipi_ia(self):
        """Panel de SiPi IA: pedirle a una IA que genere codigo SiPi desde
        una descripcion, que corrija el ultimo error de ejecucion, que
        complete/extienda lo que ya esta escrito, o que explique el
        codigo actual. Todo insertable directo en el editor con un
        click, para programar mas rapido y resolver bugs mas facil."""
        import sipi_ia

        # Bug real que impedia abrir el panel en la practica: antes se
        # creaba una ventana Toplevel VACIA primero, y recien despues se
        # chequeaba si habia clave de API -- si no la habia, esa ventana
        # en blanco se destruia y se abria el dialogo de configuracion,
        # pero en varios gestores de ventanas ese crear-y-destruir
        # inmediato hacia que el dialogo de la clave quedara sin foco o
        # tapado detras de la ventana principal, como si "no abriera"
        # nada. Ahora se chequea la clave ANTES de crear ninguna ventana.
        if not sipi_ia.hay_api_key_configurada():
            self._pedir_api_key_ia(None, reintentar=self.abrir_sipi_ia, forzar=True)
            return

        # Si ya hay un panel de SiPi IA abierto, se trae al frente en vez
        # de amontonar ventanas nuevas cada vez que se aprieta el boton.
        if getattr(self, "_ventana_sipi_ia", None) is not None:
            try:
                if self._ventana_sipi_ia.winfo_exists():
                    self._ventana_sipi_ia.deiconify()
                    self._ventana_sipi_ia.lift()
                    self._ventana_sipi_ia.focus_force()
                    return
            except Exception:
                pass

        colores = self._colores()
        ventana = tk.Toplevel(self.root)
        self._ventana_sipi_ia = ventana
        ventana.title("SiPi IA")
        ventana.geometry("680x560")
        ventana.configure(bg=colores["fondo"])
        self._traer_al_frente(ventana)

        encabezado = tk.Frame(ventana, bg=colores["barra"])
        encabezado.pack(fill=tk.X)
        tk.Label(encabezado, text="SiPi IA — asistente de programacion", bg=colores["barra"],
                 fg=colores["texto"], font=("Segoe UI", 13, "bold"), anchor="w",
                 padx=14, pady=10).pack(side=tk.LEFT)
        tk.Button(encabezado, text="Cambiar clave de API", bg=colores["barra"], fg="#a6adc8",
                  bd=0, font=("Segoe UI", 9), command=lambda: self._pedir_api_key_ia(
                      ventana, reintentar=self.abrir_sipi_ia, forzar=True)
                  ).pack(side=tk.RIGHT, padx=14)
        tk.Button(encabezado, text="Construir proyecto completo (multi-archivo)", bg="#5e5ce6", fg="white",
                  bd=0, font=("Segoe UI", 9, "bold"), padx=8,
                  command=self.abrir_sipi_ia_proyecto).pack(side=tk.RIGHT, padx=6)
        tk.Button(encabezado, text="Servidor para otras IAs", bg="#04a5e5", fg="white",
                  bd=0, font=("Segoe UI", 9, "bold"), padx=8,
                  command=self.abrir_servidor_ia).pack(side=tk.RIGHT, padx=6)

        modos = tk.Frame(ventana, bg=colores["fondo"])
        modos.pack(fill=tk.X, padx=14, pady=(10, 4))

        modo_var = tk.StringVar(value="generar")
        opciones_modo = [
            ("generar", "Generar codigo nuevo"),
            ("completar", "Completar/extender lo que tengo"),
            ("corregir", "Corregir el ultimo error"),
            ("explicar", "Explicar el codigo actual"),
            ("auto", "Generar y auto-corregir (loop)"),
        ]
        for valor, etiqueta in opciones_modo:
            tk.Radiobutton(modos, text=etiqueta, variable=modo_var, value=valor,
                            bg=colores["fondo"], fg=colores["texto"], selectcolor=colores["barra"],
                            activebackground=colores["fondo"], activeforeground=colores["texto"],
                            font=("Segoe UI", 9)).pack(side=tk.LEFT, padx=(0, 10))

        tk.Label(ventana, text="Que necesitas (descripcion, instruccion, o dejalo vacio para 'corregir'/'explicar'):",
                 bg=colores["fondo"], fg=colores["texto"], font=("Segoe UI", 9), anchor="w",
                 padx=14).pack(fill=tk.X, pady=(6, 0))

        entrada = tk.Text(ventana, height=3, wrap="word", bg="#1e1e2e", fg=colores["texto"],
                           insertbackground="white", font=("Segoe UI", 10), padx=8, pady=6)
        entrada.pack(fill=tk.X, padx=14, pady=(2, 6))
        entrada.insert("1.0", "Por ejemplo: un programa que pida 3 numeros y diga el promedio")

        salida = tk.Text(ventana, wrap="word", bg="#11111b", fg="#a6e3a1", insertbackground="white",
                          font=(_fuente_mono(), 10), padx=10, pady=10)
        salida.pack(fill=tk.BOTH, expand=True, padx=14, pady=(0, 6))
        salida.insert("1.0", "Esperando pedido...")
        salida.configure(state="disabled")

        barra_inferior = tk.Frame(ventana, bg=colores["fondo"])
        barra_inferior.pack(fill=tk.X, pady=(0, 14))

        etiqueta_estado_ia = tk.Label(barra_inferior, text="", bg=colores["fondo"], fg="#f9e2af",
                                       font=("Segoe UI", 9))
        etiqueta_estado_ia.pack(side=tk.LEFT, padx=14)

        def _set_salida(texto):
            salida.configure(state="normal")
            salida.delete("1.0", tk.END)
            salida.insert("1.0", texto)
            salida.configure(state="disabled")

        def _en_hilo(func):
            hilo = threading.Thread(target=func, daemon=True)
            hilo.start()

        def _pedir():
            modo = modo_var.get()
            pedido = entrada.get("1.0", "end-1c").strip()
            codigo_actual = self.texto.get("1.0", "end-1c")
            boton_pedir.configure(state="disabled")
            etiqueta_estado_ia.configure(text="Consultando a la IA...")
            _set_salida("Pensando...")

            def _trabajo():
                try:
                    if modo == "generar":
                        resultado = sipi_ia.generar_codigo(pedido, codigo_actual)
                    elif modo == "completar":
                        resultado = sipi_ia.completar_codigo(codigo_actual, pedido)
                    elif modo == "corregir":
                        error = pedido or self._capturar_error_ejecucion_actual(codigo_actual)
                        if not error:
                            self.root.after(0, lambda: _set_salida(
                                "El programa se ejecuto sin errores (o no se pudo determinar "
                                "el error). Si tenes el mensaje de error a mano, pegalo en el "
                                "campo de arriba y volve a preguntar."))
                            self.root.after(0, lambda: boton_pedir.configure(state="normal"))
                            self.root.after(0, lambda: etiqueta_estado_ia.configure(text=""))
                            return
                        resultado = sipi_ia.corregir_error(codigo_actual, error)
                    elif modo == "explicar":
                        resultado = sipi_ia.explicar_codigo(codigo_actual)
                    else:  # auto: generar y auto-corregir en loop real contra el motor
                        aqui = os.path.dirname(os.path.abspath(__file__))
                        try:
                            ruta_motor = self._ruta_motor_sipi(aqui)
                        except ImportError:
                            resultado = "[SiPi IA] No se encontro el motor de SiPi (sipi.py) para verificar el codigo."
                            self.root.after(0, lambda: _set_salida(resultado))
                            self.root.after(0, lambda: boton_pedir.configure(state="normal"))
                            self.root.after(0, lambda: etiqueta_estado_ia.configure(text=""))
                            return

                        def _progreso(numero, codigo_intento, ok, salida_p, error_p):
                            estado_txt = "OK" if ok else "con error, corrigiendo..."
                            self.root.after(0, lambda: etiqueta_estado_ia.configure(
                                text=f"Intento {numero}/4: ejecutando y verificando... ({estado_txt})"))
                            self.root.after(0, lambda: _set_salida(
                                f"Intento {numero} -- {'funciono' if ok else 'fallo, la IA lo esta corrigiendo'}\n\n"
                                f"```sipi\n{codigo_intento}\n```\n\n"
                                + (f"Error detectado:\n{error_p}" if not ok and error_p else "")
                            ))

                        codigo_base = codigo_actual if codigo_actual.strip() else None
                        resultado_loop = sipi_ia.generar_con_auto_correccion(
                            pedido or "un programa SiPi util y funcional",
                            ruta_motor, codigo_inicial=codigo_base, max_intentos=4,
                            on_intento=_progreso,
                        )
                        cabecera = (
                            f"[{'LISTO' if resultado_loop.exito else 'NO SE LOGRO'}] "
                            f"{resultado_loop.motivo_fin} (se probaron {len(resultado_loop.intentos)} version(es))\n\n"
                        )
                        resultado = cabecera + f"```sipi\n{resultado_loop.codigo_final}\n```"
                except sipi_ia.ErrorSiPiIA as e:
                    resultado = f"[SiPi IA] {e}"
                self.root.after(0, lambda: _set_salida(resultado))
                self.root.after(0, lambda: boton_pedir.configure(state="normal"))
                self.root.after(0, lambda: etiqueta_estado_ia.configure(text="Listo."))

            _en_hilo(_trabajo)

        def _insertar_resultado():
            texto_resultado = salida.get("1.0", "end-1c")
            codigo = sipi_ia._extraer_bloque_codigo(texto_resultado)
            self.nueva_pestana()
            self.texto.delete("1.0", tk.END)
            self.texto.insert("1.0", codigo)
            self._resaltar_sintaxis()
            self._actualizar_numeros_linea()
            self._programar_vista_previa()
            self._marcar_modificado()
            self._mostrar_aviso_estado("Codigo de SiPi IA insertado en una pestaña nueva")

        boton_pedir = tk.Button(barra_inferior, text="Preguntarle a la IA", command=_pedir,
                                 bg="#04a5e5", fg="white", bd=0, padx=14, pady=6,
                                 font=("Segoe UI", 10, "bold"))
        boton_pedir.pack(side=tk.RIGHT, padx=(4, 14))

        tk.Button(barra_inferior, text="Insertar en el editor", command=_insertar_resultado,
                  bg="#40a02b", fg="white", bd=0, padx=14, pady=6,
                  font=("Segoe UI", 10)).pack(side=tk.RIGHT)

    def abrir_servidor_ia(self):
        """Panel para prender/apagar el servidor HTTP de SiPi IA
        (sipi_ia_servidor.py) sin salir del editor, pensado para dejar
        SiPi IA disponible como herramienta para otro agente/IA que
        corra en la misma maquina (o en la red, con token)."""
        import sipi_ia_servidor
        colores = self._colores()

        if getattr(self, "_ventana_servidor_ia", None) is not None:
            try:
                if self._ventana_servidor_ia.winfo_exists():
                    self._ventana_servidor_ia.lift()
                    self._ventana_servidor_ia.focus_force()
                    return
            except Exception:
                pass

        ventana = tk.Toplevel(self.root)
        self._ventana_servidor_ia = ventana
        ventana.title("Servidor SiPi IA para otras IAs")
        ventana.geometry("520x360")
        ventana.configure(bg=colores["fondo"])
        self._traer_al_frente(ventana)

        tk.Label(ventana, text="Servidor HTTP de SiPi IA", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 13, "bold"), padx=14, pady=4, anchor="w").pack(fill=tk.X, pady=(14, 0))
        tk.Label(ventana, text=(
            "Prende un servidor local para que otro agente de IA (o cualquier "
            "herramienta que hable HTTP) pueda usar SiPi IA como su herramienta "
            "principal para programar en SiPi: generar codigo, auto-corregir, "
            "construir proyectos multi-archivo, buscar y refactorizar."
        ), bg=colores["fondo"], fg="#a6adc8", font=("Segoe UI", 9), wraplength=480,
                 justify="left", padx=14).pack(fill=tk.X)

        marco_opciones = tk.Frame(ventana, bg=colores["fondo"])
        marco_opciones.pack(fill=tk.X, padx=14, pady=12)

        tk.Label(marco_opciones, text="Puerto:", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 9)).grid(row=0, column=0, sticky="w")
        puerto_var = tk.StringVar(value="8420")
        tk.Entry(marco_opciones, textvariable=puerto_var, width=8, font=("Segoe UI", 9),
                  bg="#1e1e2e", fg=colores["texto"], insertbackground="white").grid(row=0, column=1, padx=6)

        exponer_red_var = tk.BooleanVar(value=False)
        tk.Checkbutton(marco_opciones, text="Permitir acceso desde otras computadoras de la red (no solo esta)",
                        variable=exponer_red_var, bg=colores["fondo"], fg=colores["texto"],
                        selectcolor=colores["barra"], font=("Segoe UI", 9),
                        activebackground=colores["fondo"]).grid(row=1, column=0, columnspan=2, sticky="w", pady=(8, 0))

        estado_label = tk.Label(ventana, text="Servidor detenido.", bg=colores["fondo"], fg="#f38ba8",
                                 font=("Segoe UI", 10, "bold"), padx=14, pady=10)
        estado_label.pack(fill=tk.X)

        token_label = tk.Label(ventana, text="", bg=colores["fondo"], fg="#a6adc8", font=(_fuente_mono(), 9),
                                padx=14, wraplength=480, justify="left")
        token_label.pack(fill=tk.X)

        estado = {"servidor": None, "token": None}

        def _prender():
            import secrets
            host = "0.0.0.0" if exponer_red_var.get() else "127.0.0.1"
            token = secrets.token_hex(16) if exponer_red_var.get() else None
            try:
                puerto = int(puerto_var.get())
            except ValueError:
                messagebox.showerror("SiPi IA", "El puerto tiene que ser un numero.")
                return
            try:
                servidor = sipi_ia_servidor.iniciar_servidor(host=host, puerto=puerto, token=token, en_hilo_aparte=True)
            except OSError as e:
                messagebox.showerror("SiPi IA", f"No se pudo iniciar el servidor: {e}")
                return
            estado["servidor"] = servidor
            estado["token"] = token
            estado_label.configure(text=f"Servidor activo en http://{host}:{puerto}", fg="#40a02b")
            if token:
                token_label.configure(text=f"Token de acceso (header X-SiPi-Token): {token}")
            else:
                token_label.configure(text="Solo accesible desde esta computadora (sin token).")
            boton_prender.configure(state="disabled")
            boton_apagar.configure(state="normal")

        def _apagar():
            if estado["servidor"]:
                estado["servidor"].shutdown()
                estado["servidor"] = None
            estado_label.configure(text="Servidor detenido.", fg="#f38ba8")
            token_label.configure(text="")
            boton_prender.configure(state="normal")
            boton_apagar.configure(state="disabled")

        marco_botones = tk.Frame(ventana, bg=colores["fondo"])
        marco_botones.pack(fill=tk.X, padx=14, pady=10)
        boton_prender = tk.Button(marco_botones, text="Iniciar servidor", command=_prender, bg="#40a02b",
                                   fg="white", bd=0, padx=14, pady=8, font=("Segoe UI", 10, "bold"))
        boton_prender.pack(side=tk.LEFT)
        boton_apagar = tk.Button(marco_botones, text="Detener servidor", command=_apagar, bg="#d20f39",
                                  fg="white", bd=0, padx=14, pady=8, font=("Segoe UI", 10, "bold"),
                                  state="disabled")
        boton_apagar.pack(side=tk.LEFT, padx=8)

        ventana.protocol("WM_DELETE_WINDOW", lambda: (_apagar(), ventana.destroy()))

    def abrir_sipi_ia_proyecto(self):
        """Panel del agente multi-archivo (sipi_ia_agente.py): pensado
        para pedidos grandes -- 'un sistema de reservas con lista de
        espera', 'un juego de rol por turnos con inventario' -- que no
        entran comodos en un solo archivo. Muestra el plan, el progreso
        de generacion de cada archivo con sus intentos de auto-
        correccion, y al final la verificacion de integracion."""
        import sipi_ia
        import sipi_ia_agente

        colores = self._colores()
        if not sipi_ia.hay_api_key_configurada():
            self._pedir_api_key_ia(None, reintentar=self.abrir_sipi_ia_proyecto, forzar=True)
            return

        if getattr(self, "_ventana_sipi_ia_proyecto", None) is not None:
            try:
                if self._ventana_sipi_ia_proyecto.winfo_exists():
                    self._ventana_sipi_ia_proyecto.lift()
                    self._ventana_sipi_ia_proyecto.focus_force()
                    return
            except Exception:
                pass

        ventana = tk.Toplevel(self.root)
        self._ventana_sipi_ia_proyecto = ventana
        ventana.title("SiPi IA — Construir proyecto completo")
        ventana.geometry("760x600")
        ventana.configure(bg=colores["fondo"])
        self._traer_al_frente(ventana)

        tk.Label(ventana, text="Construir un proyecto de varios archivos", bg=colores["fondo"],
                 fg=colores["texto"], font=("Segoe UI", 13, "bold"), anchor="w",
                 padx=14, pady=4).pack(fill=tk.X, pady=(12, 0))
        tk.Label(ventana, text=(
            "Para pedidos grandes: primero se arma un plan de archivos, despues se genera y "
            "auto-corrige cada uno contra el motor real, y al final se verifica que el punto "
            "de entrada corra bien con todo el proyecto en su lugar."
        ), bg=colores["fondo"], fg="#a6adc8", font=("Segoe UI", 9), wraplength=720,
                 justify="left", padx=14).pack(fill=tk.X)

        marco_entrada = tk.Frame(ventana, bg=colores["fondo"])
        marco_entrada.pack(fill=tk.X, padx=14, pady=8)
        entrada = tk.Text(marco_entrada, height=3, wrap="word", bg="#1e1e2e", fg=colores["texto"],
                           insertbackground="white", font=("Segoe UI", 10), padx=8, pady=6)
        entrada.pack(fill=tk.X)
        entrada.insert("1.0", "Ejemplo: un sistema de biblioteca que preste y devuelva libros, "
                               "con lista de libros disponibles y multas por atraso")

        marco_carpeta = tk.Frame(ventana, bg=colores["fondo"])
        marco_carpeta.pack(fill=tk.X, padx=14)
        carpeta_var = tk.StringVar(value=os.path.join(os.path.expanduser("~"), "SiPi", "proyecto_nuevo"))
        tk.Label(marco_carpeta, text="Carpeta destino:", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 9)).pack(side=tk.LEFT)
        tk.Entry(marco_carpeta, textvariable=carpeta_var, font=("Segoe UI", 9),
                  bg="#1e1e2e", fg=colores["texto"], insertbackground="white").pack(
            side=tk.LEFT, fill=tk.X, expand=True, padx=8)

        def _elegir_carpeta():
            ruta = filedialog.askdirectory()
            if ruta:
                carpeta_var.set(os.path.join(ruta, "proyecto_sipi"))

        tk.Button(marco_carpeta, text="Elegir...", command=_elegir_carpeta, bg="#313244",
                  fg=colores["texto"], bd=0, padx=8).pack(side=tk.LEFT)

        progreso = tk.Text(ventana, wrap="word", bg="#11111b", fg="#a6e3a1", insertbackground="white",
                            font=(_fuente_mono(), 9), padx=10, pady=10)
        progreso.pack(fill=tk.BOTH, expand=True, padx=14, pady=10)
        progreso.configure(state="disabled")

        def _log(linea, color=None):
            progreso.configure(state="normal")
            progreso.insert(tk.END, linea + "\n")
            progreso.see(tk.END)
            progreso.configure(state="disabled")

        barra_inferior = tk.Frame(ventana, bg=colores["fondo"])
        barra_inferior.pack(fill=tk.X, pady=(0, 14))

        def _on_evento(tipo, datos):
            if tipo == "planificando":
                self.root.after(0, lambda: _log("Armando el plan de archivos..."))
            elif tipo == "plan_listo":
                plan = datos["plan"]
                self.root.after(0, lambda: _log(f"Plan listo: {len(plan)} archivo(s)."))
                for item in plan:
                    self.root.after(0, lambda i=item: _log(f"  - {i['nombre']}: {i.get('proposito', '')}"))
            elif tipo == "generando_archivo":
                self.root.after(0, lambda: _log(f"\nGenerando {datos['archivo']}..."))
            elif tipo == "intento_archivo":
                estado_txt = "OK" if datos["ok"] else f"fallo (corrigiendo): {datos.get('error', '')[:120]}"
                self.root.after(0, lambda: _log(f"  intento {datos['intento']}: {estado_txt}"))
            elif tipo == "archivo_terminado":
                marca = "listo" if datos["exito"] else "con problemas"
                self.root.after(0, lambda: _log(
                    f"  -> {datos['archivo']} {marca} ({datos['intentos']} intento(s)). {datos['motivo_fin']}"))
            elif tipo == "verificacion_integracion":
                marca = "OK" if datos.get("ok") else "con errores"
                self.root.after(0, lambda: _log(f"\nVerificacion de integracion: {marca}"))
                if not datos.get("ok") and datos.get("error"):
                    self.root.after(0, lambda: _log(f"  {datos['error'][:400]}"))

        def _construir():
            boton_construir.configure(state="disabled")
            progreso.configure(state="normal")
            progreso.delete("1.0", tk.END)
            progreso.configure(state="disabled")

            objetivo = entrada.get("1.0", "end-1c").strip()
            carpeta = carpeta_var.get().strip()

            def _trabajo():
                try:
                    resultado = sipi_ia_agente.construir_proyecto(objetivo, carpeta, on_evento=_on_evento)
                    marca_final = "LISTO" if resultado.exito else "TERMINADO CON PROBLEMAS"
                    self.root.after(0, lambda: _log(f"\n=== {marca_final} === Carpeta: {carpeta}"))
                except sipi_ia.ErrorSiPiIA as e:
                    mensaje_error = str(e)
                    self.root.after(0, lambda: _log(f"\n[Error] {mensaje_error}"))
                self.root.after(0, lambda: boton_construir.configure(state="normal"))
                self.root.after(0, lambda: boton_abrir_carpeta.configure(state="normal"))

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

        def _abrir_carpeta_resultado():
            carpeta = carpeta_var.get().strip()
            if not os.path.isdir(carpeta):
                return
            try:
                if os.name == "nt":
                    os.startfile(carpeta)  # noqa: S606 - accion explicita del usuario sobre una carpeta que el mismo eligio
                elif sys.platform == "darwin":
                    subprocess.Popen(["open", carpeta])
                else:
                    subprocess.Popen(["xdg-open", carpeta])
            except OSError:
                messagebox.showinfo("SiPi IA", f"El proyecto quedo en:\n{carpeta}")

        boton_construir = tk.Button(barra_inferior, text="Construir proyecto", command=_construir,
                                     bg="#5e5ce6", fg="white", bd=0, padx=16, pady=8,
                                     font=("Segoe UI", 10, "bold"))
        boton_construir.pack(side=tk.RIGHT, padx=14)
        boton_abrir_carpeta = tk.Button(barra_inferior, text="Abrir carpeta del proyecto",
                                         command=_abrir_carpeta_resultado, bg="#313244",
                                         fg=colores["texto"], bd=0, padx=12, pady=8,
                                         font=("Segoe UI", 10), state="disabled")
        boton_abrir_carpeta.pack(side=tk.RIGHT)

        tk.Button(barra_inferior, text="Buscar / Refactor en el proyecto",
                  command=lambda: self._abrir_buscar_refactor_proyecto(carpeta_var.get().strip()),
                  bg="#179299", fg="white", bd=0, padx=12, pady=8,
                  font=("Segoe UI", 10)).pack(side=tk.LEFT, padx=14)

    def _abrir_buscar_refactor_proyecto(self, carpeta):
        """Panel que usa sipi_ia_memoria para buscar donde esta definido
        algo en un proyecto multi-archivo, y para renombrarlo de forma
        consistente en todos los archivos a la vez -- con vista previa
        (dry-run) antes de aplicar el cambio de verdad."""
        import sipi_ia_memoria
        colores = self._colores()
        if not os.path.isdir(carpeta):
            messagebox.showwarning("SiPi IA", "Primero construi o abri un proyecto en una carpeta valida.")
            return

        ventana = tk.Toplevel(self.root)
        ventana.title("Buscar / Refactor en el proyecto")
        ventana.geometry("560x480")
        ventana.configure(bg=colores["fondo"])

        tk.Label(ventana, text=f"Proyecto: {carpeta}", bg=colores["fondo"], fg="#a6adc8",
                 font=("Segoe UI", 9), anchor="w", padx=14, pady=6, wraplength=520).pack(fill=tk.X, pady=(12, 0))

        pestañas = ttk.Notebook(ventana)
        pestañas.pack(fill=tk.BOTH, expand=True, padx=14, pady=10)

        # --- Pestaña buscar ---
        tab_buscar = tk.Frame(pestañas, bg=colores["fondo"])
        pestañas.add(tab_buscar, text="Buscar")
        marco_b = tk.Frame(tab_buscar, bg=colores["fondo"])
        marco_b.pack(fill=tk.X, padx=10, pady=10)
        entrada_termino = tk.Entry(marco_b, font=("Segoe UI", 10), bg="#1e1e2e", fg=colores["texto"],
                                    insertbackground="white")
        entrada_termino.pack(side=tk.LEFT, fill=tk.X, expand=True)
        resultados_txt = tk.Text(tab_buscar, wrap="word", bg="#11111b", fg="#a6e3a1",
                                  font=(_fuente_mono(), 10), padx=8, pady=8)
        resultados_txt.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))

        def _buscar():
            termino = entrada_termino.get().strip()
            if not termino:
                return
            indice = sipi_ia_memoria.IndiceProyecto(carpeta)
            indice.reconstruir(con_resumen_ia=False)
            resultados = indice.buscar(termino)
            resultados_txt.delete("1.0", tk.END)
            if not resultados:
                resultados_txt.insert("1.0", "No se encontraron coincidencias.")
                return
            for r in resultados:
                resultados_txt.insert(tk.END, f"{r['archivo']}  [{r['tipo']}]  {r['nombre']}\n")

        tk.Button(marco_b, text="Buscar", command=_buscar, bg="#179299", fg="white", bd=0,
                  padx=12, pady=4).pack(side=tk.LEFT, padx=(8, 0))

        # --- Pestaña refactor ---
        tab_refactor = tk.Frame(pestañas, bg=colores["fondo"])
        pestañas.add(tab_refactor, text="Renombrar")
        marco_r = tk.Frame(tab_refactor, bg=colores["fondo"])
        marco_r.pack(fill=tk.X, padx=10, pady=10)
        tk.Label(marco_r, text="Nombre actual:", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 9)).grid(row=0, column=0, sticky="w")
        entrada_viejo = tk.Entry(marco_r, font=("Segoe UI", 10), bg="#1e1e2e", fg=colores["texto"],
                                  insertbackground="white")
        entrada_viejo.grid(row=0, column=1, sticky="ew", padx=6, pady=4)
        tk.Label(marco_r, text="Nombre nuevo:", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 9)).grid(row=1, column=0, sticky="w")
        entrada_nuevo = tk.Entry(marco_r, font=("Segoe UI", 10), bg="#1e1e2e", fg=colores["texto"],
                                  insertbackground="white")
        entrada_nuevo.grid(row=1, column=1, sticky="ew", padx=6, pady=4)
        marco_r.columnconfigure(1, weight=1)

        vista_previa = tk.Text(tab_refactor, wrap="word", bg="#11111b", fg="#f9e2af",
                                font=(_fuente_mono(), 10), padx=8, pady=8, height=10)
        vista_previa.pack(fill=tk.BOTH, expand=True, padx=10, pady=6)

        estado_refactor = {"ultimo_resultado": None}

        def _previsualizar():
            viejo, nuevo = entrada_viejo.get().strip(), entrada_nuevo.get().strip()
            if not viejo or not nuevo:
                return
            resultado = sipi_ia_memoria.renombrar_en_proyecto(carpeta, viejo, nuevo, aplicar=False)
            estado_refactor["ultimo_resultado"] = (viejo, nuevo)
            vista_previa.delete("1.0", tk.END)
            if not resultado["archivos_afectados"]:
                vista_previa.insert("1.0", "No se encontraron coincidencias de ese nombre.")
                return
            vista_previa.insert(tk.END, f"Vista previa -- {resultado['total_ocurrencias']} ocurrencia(s):\n\n")
            for a in resultado["archivos_afectados"]:
                vista_previa.insert(tk.END, f"  {a['archivo']}: {a['ocurrencias']} ocurrencia(s)\n")
            vista_previa.insert(tk.END, "\nRevisá y tocá 'Aplicar' para confirmar el cambio en los archivos.")

        def _aplicar():
            if not estado_refactor["ultimo_resultado"]:
                messagebox.showinfo("SiPi IA", "Primero generá una vista previa.")
                return
            viejo, nuevo = estado_refactor["ultimo_resultado"]
            if not messagebox.askyesno("Confirmar renombrado",
                                        f"¿Renombrar '{viejo}' a '{nuevo}' en todos los archivos del proyecto?"):
                return
            resultado = sipi_ia_memoria.renombrar_en_proyecto(carpeta, viejo, nuevo, aplicar=True)
            messagebox.showinfo("SiPi IA", f"Listo. {resultado['total_ocurrencias']} ocurrencia(s) renombradas "
                                            f"en {len(resultado['archivos_afectados'])} archivo(s).")

        marco_botones_refactor = tk.Frame(tab_refactor, bg=colores["fondo"])
        marco_botones_refactor.pack(fill=tk.X, padx=10, pady=(0, 10))
        tk.Button(marco_botones_refactor, text="Vista previa", command=_previsualizar, bg="#313244",
                  fg=colores["texto"], bd=0, padx=12, pady=6).pack(side=tk.LEFT)
        tk.Button(marco_botones_refactor, text="Aplicar", command=_aplicar, bg="#d20f39", fg="white",
                  bd=0, padx=12, pady=6).pack(side=tk.LEFT, padx=8)

    def _pedir_api_key_ia(self, ventana_padre, reintentar=None, forzar=False):
        """Dialogo para configurar SiPi IA: elegir proveedor (Anthropic,
        OpenAI, Google Gemini, OpenRouter, o cualquier otra API
        compatible con OpenAI -- local incluida, tipo Ollama/LM Studio),
        pegar su clave, y opcionalmente el modelo/URL. Se guarda una
        clave por proveedor (podes cambiar de proveedor sin perder la
        clave de los otros), en el archivo de configuracion propio de
        SiPi IA (permisos 0600), no en el config general del editor."""
        import sipi_ia
        colores = self._colores()
        if not forzar:
            ventana_padre.destroy()
        dialogo = tk.Toplevel(self.root)
        dialogo.title("Configurar SiPi IA")
        dialogo.geometry("520x460")
        dialogo.configure(bg=colores["fondo"])
        self._traer_al_frente(dialogo)

        tk.Label(dialogo, text="Configurar SiPi IA", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 13, "bold"), anchor="w", padx=16, pady=6).pack(fill=tk.X, pady=(14, 0))
        tk.Label(dialogo, text=(
            "SiPi IA funciona con cualquiera de estos proveedores -- no hace falta que "
            "sea Anthropic. La clave se guarda solo en tu computadora, una por proveedor."
        ), bg=colores["fondo"], fg="#a6adc8", font=("Segoe UI", 9), wraplength=480,
                 justify="left", padx=16).pack(fill=tk.X)

        marco_proveedor = tk.Frame(dialogo, bg=colores["fondo"])
        marco_proveedor.pack(fill=tk.X, padx=16, pady=(14, 4))
        tk.Label(marco_proveedor, text="Proveedor:", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 10, "bold")).pack(side=tk.LEFT)

        proveedor_actual = sipi_ia.obtener_proveedor()
        etiquetas_proveedores = {clave: info["nombre"] for clave, info in sipi_ia.PROVEEDORES_IA.items()}
        var_proveedor_texto = tk.StringVar(value=etiquetas_proveedores.get(proveedor_actual, proveedor_actual))
        combo_proveedor = ttk.Combobox(marco_proveedor, textvariable=var_proveedor_texto, state="readonly",
                                        values=list(etiquetas_proveedores.values()), width=42)
        combo_proveedor.pack(side=tk.LEFT, padx=(8, 0))

        etiqueta_ayuda_clave = tk.Label(dialogo, text="", bg=colores["fondo"], fg="#a6adc8",
                                         font=("Segoe UI", 9), wraplength=480, justify="left", padx=16)
        etiqueta_ayuda_clave.pack(fill=tk.X, pady=(2, 0))

        tk.Label(dialogo, text="Clave de API:", bg=colores["fondo"], fg=colores["texto"],
                 font=("Segoe UI", 10, "bold"), anchor="w", padx=16).pack(fill=tk.X, pady=(10, 0))
        campo_clave = tk.Entry(dialogo, show="*", font=("Segoe UI", 10), bg="#1e1e2e", fg=colores["texto"],
                                insertbackground="white")
        campo_clave.pack(fill=tk.X, padx=16, pady=(4, 0))

        marco_url = tk.Frame(dialogo, bg=colores["fondo"])
        etiqueta_url = tk.Label(marco_url, text="URL del endpoint (/chat/completions):", bg=colores["fondo"],
                                 fg=colores["texto"], font=("Segoe UI", 10, "bold"), anchor="w")
        etiqueta_url.pack(fill=tk.X)
        campo_url = tk.Entry(marco_url, font=("Segoe UI", 10), bg="#1e1e2e", fg=colores["texto"],
                              insertbackground="white")
        campo_url.pack(fill=tk.X, pady=(4, 0))
        campo_url.insert(0, sipi_ia.obtener_url_personalizada())

        tk.Label(dialogo, text="Modelo (opcional, se usa uno por defecto si se deja vacio):",
                 bg=colores["fondo"], fg=colores["texto"], font=("Segoe UI", 10, "bold"),
                 anchor="w", padx=16).pack(fill=tk.X, pady=(10, 0))
        campo_modelo = tk.Entry(dialogo, font=("Segoe UI", 10), bg="#1e1e2e", fg=colores["texto"],
                                 insertbackground="white")
        campo_modelo.pack(fill=tk.X, padx=16, pady=(4, 0))

        def _clave_proveedor_de_etiqueta(etiqueta):
            for clave, nombre in etiquetas_proveedores.items():
                if nombre == etiqueta:
                    return clave
            return sipi_ia.PROVEEDOR_POR_DEFECTO

        def _refrescar_para_proveedor(evento=None):
            proveedor = _clave_proveedor_de_etiqueta(var_proveedor_texto.get())
            info = sipi_ia.PROVEEDORES_IA[proveedor]
            etiqueta_ayuda_clave.configure(text=info["ayuda_clave"])
            if proveedor == "personalizado":
                marco_url.pack(fill=tk.X, padx=16, pady=(10, 0))
            else:
                marco_url.pack_forget()
            campo_modelo.delete(0, tk.END)
            modelo_guardado = sipi_ia.cargar_config().get("modelos_por_proveedor", {}).get(proveedor, "")
            campo_modelo.insert(0, modelo_guardado)
            campo_modelo.configure(fg=colores["texto"])
            if not modelo_guardado:
                campo_modelo.insert(0, info["modelo_por_defecto"])
                campo_modelo.configure(fg="#6c7086")

        combo_proveedor.bind("<<ComboboxSelected>>", _refrescar_para_proveedor)
        _refrescar_para_proveedor()
        campo_clave.focus_set()

        def _guardar():
            proveedor = _clave_proveedor_de_etiqueta(var_proveedor_texto.get())
            clave = campo_clave.get().strip()
            if not clave:
                # Si ya habia una clave guardada para este proveedor de antes,
                # dejarla vacia es "no cambiarla", no un error -- solo hace
                # falta pegar una clave nueva cuando se configura por primera
                # vez o se quiere reemplazarla.
                if not sipi_ia.cargar_config().get("claves_por_proveedor", {}).get(proveedor):
                    messagebox.showwarning("SiPi IA", "Pega una clave de API valida.")
                    return
            modelo = campo_modelo.get().strip()
            if modelo == sipi_ia.PROVEEDORES_IA[proveedor]["modelo_por_defecto"] and str(campo_modelo.cget("fg")) == "#6c7086":
                modelo = ""  # era solo el placeholder gris, no algo que el usuario haya tipeado
            try:
                if clave:
                    sipi_ia.guardar_api_key(clave, proveedor=proveedor)
                sipi_ia.guardar_proveedor(proveedor, modelo=modelo, url_personalizada=campo_url.get().strip())
            except sipi_ia.ErrorSiPiIA as e:
                messagebox.showerror("SiPi IA", str(e))
                return
            dialogo.destroy()
            self._actualizar_boton_ia()
            if reintentar:
                reintentar()

        tk.Button(dialogo, text="Guardar y continuar", command=_guardar, bg="#40a02b", fg="white",
                  bd=0, padx=14, pady=8, font=("Segoe UI", 10, "bold")).pack(pady=(18, 16))

    def _cambiar_vista_previa(self, activa):
        self.vista_previa_activa = activa
        self._guardar_config()
        if activa:
            self._programar_vista_previa()
        else:
            self._mostrar_previa("(vista previa desactivada)")

    def _elegir_color_texto(self):
        color = colorchooser.askcolor(title="Elegi el color del texto")[1]
        if color:
            colores_tema = dict(self._colores())
            colores_tema["texto"] = color
            TEMAS[self.tema_actual] = colores_tema
            self._aplicar_tema()

    # ------- Atajos de teclado, titulo, indicador de cambios (items 8, 10, F11) -------
    def _configurar_atajos_teclado(self):
        """Item 10 del feedback: atajos claros y sin conflictos entre si.

        Ctrl+N        Nuevo
        Ctrl+O        Abrir
        Ctrl+S        Guardar
        Ctrl+Shift+S  Guardar como
        Ctrl+Enter/F5 Ejecutar
        Ctrl+D        Depurar
        Ctrl+Shift+F  Formatear
        Ctrl+=/Ctrl+- Zoom de fuente (+/-)
        F1            Tutorial
        Ctrl+I        SiPi IA
        F11           Pantalla completa
        Ctrl+Z/Ctrl+Y Deshacer/Rehacer (Tk ya trae Ctrl+Z; Ctrl+Y se agrega
                      explicitamente porque en Windows/Linux Tk no lo
                      mapea a 'redo' por defecto)
        Ctrl+F        Buscar
        Ctrl+H        Reemplazar
        Ctrl+T        Nueva pestaña
        Ctrl+W        Cerrar pestaña
        Ctrl+PgUp/Dn  Pestaña anterior/siguiente
        Ctrl+G        Ir a línea
        Ctrl+`        Terminal
        """
        self.root.bind_all("<Control-s>", lambda e: (self.guardar(), "break")[1])
        self.root.bind_all("<Control-S>", lambda e: (self.guardar(), "break")[1])
        self.root.bind_all("<Control-Shift-S>", lambda e: (self.guardar_como(), "break")[1])
        self.root.bind_all("<Control-Return>", lambda e: (self.ejecutar(), "break")[1])
        self.root.bind_all("<F5>", lambda e: (self.ejecutar(), "break")[1])
        self.root.bind_all("<F11>", self._alternar_pantalla_completa)
        self.root.bind_all("<Escape>", self._salir_pantalla_completa)
        self.texto.bind("<Control-y>", lambda e: (self.texto.edit_redo(), "break")[1])
        self.texto.bind("<Control-Y>", lambda e: (self.texto.edit_redo(), "break")[1])
        self.root.bind_all("<Control-f>", lambda e: (self.abrir_buscar(), "break")[1])
        self.root.bind_all("<Control-h>", lambda e: (self.abrir_reemplazar(), "break")[1])
        self.root.bind_all("<Control-t>", lambda e: (self.nueva_pestana(), "break")[1])
        self.root.bind_all("<Control-w>", lambda e: (self._cerrar_pestana(self.indice_pestana_actual), "break")[1])
        self.root.bind_all("<Control-grave>", lambda e: (self._alternar_panel_terminal(), "break")[1])
        self.root.bind_all("<Control-g>", lambda e: (self.abrir_ir_a_linea(), "break")[1])
        self.root.bind_all("<Control-d>", lambda e: (self.abrir_depurador(), "break")[1])
        self.root.bind_all("<Control-Shift-F>", lambda e: (self.formatear_codigo_actual(), "break")[1])
        self.root.bind_all("<Control-Shift-f>", lambda e: (self.formatear_codigo_actual(), "break")[1])
        self.root.bind_all("<Control-equal>", lambda e: (self._acercar_alejar_fuente(1), "break")[1])
        self.root.bind_all("<Control-plus>", lambda e: (self._acercar_alejar_fuente(1), "break")[1])
        self.root.bind_all("<Control-minus>", lambda e: (self._acercar_alejar_fuente(-1), "break")[1])
        self.root.bind_all("<F1>", lambda e: (self.abrir_tutorial(), "break")[1])
        self.root.bind_all("<Control-i>", lambda e: (self.abrir_sipi_ia(), "break")[1])
        self.root.bind_all("<Control-R>", lambda e: (self.abrir_revision_codigo(), "break")[1])
        self.root.bind_all("<Control-n>", lambda e: (self.nuevo(), "break")[1])
        self.root.bind_all("<Control-o>", lambda e: (self.abrir(), "break")[1])
        self.root.bind_all("<Control-Next>", lambda e: (self._cambiar_a_pestana(min(self.indice_pestana_actual + 1, len(self.pestanas) - 1)), "break")[1])
        self.root.bind_all("<Control-Prior>", lambda e: (self._cambiar_a_pestana(max(self.indice_pestana_actual - 1, 0)), "break")[1])
        self.texto.bind("<KeyRelease>", self._resaltar_bracket_pareja, add="+")
        self.texto.bind("<ButtonRelease-1>", self._resaltar_bracket_pareja, add="+")
        self.texto.bind("<KeyRelease>", self._actualizar_indicador_cursor, add="+")
        self.texto.bind("<ButtonRelease-1>", self._actualizar_indicador_cursor, add="+")
        self._pantalla_completa = False

    def _alternar_pantalla_completa(self, evento=None):
        """Bug #5 del feedback: F11 no hacia nada porque no habia ningun
        binding para esa tecla en todo el editor (se confirmo grepeando
        el archivo entero: cero resultados para 'F11'/'fullscreen' antes
        de este cambio). 'attributes(-fullscreen', ...)' es la forma
        correcta en Tk multiplataforma (Windows/Linux/Mac), a diferencia
        de maximizar la ventana con 'state(zoomed)' que en Windows dejaria
        la barra de titulo visible."""
        self._pantalla_completa = not self._pantalla_completa
        self.root.attributes("-fullscreen", self._pantalla_completa)
        return "break"

    def _salir_pantalla_completa(self, evento=None):
        if getattr(self, "_pantalla_completa", False):
            self._pantalla_completa = False
            self.root.attributes("-fullscreen", False)
        return "break"

    # ------- Ir a linea (item 19) -------
    def _saltar_a_linea(self, numero):
        """Version programatica de 'Ir a linea', reutilizada por el panel
        de 'Revisar codigo' para saltar directo a la linea de un hallazgo
        con un solo clic, sin pasar por el dialogo de pedir el numero."""
        total_lineas = int(self.texto.index("end-1c").split(".")[0])
        numero = max(1, min(numero, total_lineas))
        self.texto.mark_set("insert", f"{numero}.0")
        self.texto.see(f"{numero}.0")
        self.texto.tag_remove("sel", "1.0", "end")
        self.texto.tag_add("sel", f"{numero}.0", f"{numero}.end")
        self.texto.focus_set()

    def abrir_ir_a_linea(self):
        colores = self._colores()
        ventana = tk.Toplevel(self.root)
        ventana.title("Ir a linea")
        ventana.configure(bg=colores["barra"])
        ventana.transient(self.root)
        total_lineas = int(self.texto.index("end-1c").split(".")[0])
        tk.Label(ventana, text=f"Numero de linea (1-{total_lineas}):",
                 bg=colores["barra"], fg=colores["texto"]).pack(padx=10, pady=(10, 4))
        entrada = tk.Entry(ventana, width=10)
        entrada.pack(padx=10, pady=4)
        entrada.focus_set()

        def _ir():
            texto = entrada.get().strip()
            if not texto.isdigit():
                return
            numero = max(1, min(int(texto), total_lineas))
            self.texto.mark_set("insert", f"{numero}.0")
            self.texto.see(f"{numero}.0")
            self.texto.tag_remove("sel", "1.0", "end")
            self.texto.tag_add("sel", f"{numero}.0", f"{numero}.end")
            self.texto.focus_set()
            ventana.destroy()

        entrada.bind("<Return>", lambda e: _ir())
        tk.Button(ventana, text="Ir", command=_ir).pack(padx=10, pady=(4, 10))

    # ------- Bracket matching (item 22) -------
    _PARES_BRACKETS = {"(": ")", "[": "]", "{": "}"}
    _PARES_BRACKETS_INVERSO = {")": "(", "]": "[", "}": "{"}

    def _resaltar_bracket_pareja(self, evento=None):
        """Cuando el cursor esta pegado a un parentesis/corchete/llave,
        busca su pareja (respetando anidamiento -- un '(' de mas adentro
        no hace match con el primer ')' que aparezca, cuenta niveles) y
        resalta ambos. Se limpia el resaltado si el cursor no esta al
        lado de ninguno."""
        self.texto.tag_remove("bracket_pareja", "1.0", "end")
        pos_cursor = self.texto.index("insert")
        candidatos = [pos_cursor]
        # También revisa el caracter inmediatamente ANTES del cursor (caso
        # tipico: usuario acaba de escribir ')' y el cursor quedo despues).
        candidatos.append(self.texto.index(f"{pos_cursor}-1c"))
        for pos in candidatos:
            caracter = self.texto.get(pos)
            if caracter in self._PARES_BRACKETS or caracter in self._PARES_BRACKETS_INVERSO:
                pareja = self._buscar_bracket_pareja(pos, caracter)
                if pareja:
                    self.texto.tag_add("bracket_pareja", pos, f"{pos}+1c")
                    self.texto.tag_add("bracket_pareja", pareja, f"{pareja}+1c")
                    self.texto.tag_configure("bracket_pareja", background="#585b70", foreground="#f9e2af")
                return

    def _buscar_bracket_pareja(self, pos, caracter):
        contenido = self.texto.get("1.0", "end-1c")
        indice_plano = len(self.texto.get("1.0", pos))
        if caracter in self._PARES_BRACKETS:
            objetivo = self._PARES_BRACKETS[caracter]
            nivel = 1
            i = indice_plano + 1
            while i < len(contenido):
                if contenido[i] == caracter:
                    nivel += 1
                elif contenido[i] == objetivo:
                    nivel -= 1
                    if nivel == 0:
                        return self.texto.index(f"1.0+{i}c")
                i += 1
        else:
            objetivo = self._PARES_BRACKETS_INVERSO[caracter]
            nivel = 1
            i = indice_plano - 1
            while i >= 0:
                if contenido[i] == caracter:
                    nivel += 1
                elif contenido[i] == objetivo:
                    nivel -= 1
                    if nivel == 0:
                        return self.texto.index(f"1.0+{i}c")
                i -= 1
        return None

    def _marcar_modificado(self):
        if not self.modificado:
            self.modificado = True
            self._actualizar_titulo()
            if self.pestanas:
                self.pestanas[self.indice_pestana_actual]["modificado"] = True
                self._redibujar_barra_pestanas()
        self._programar_autoguardado_recuperacion()

    def _programar_autoguardado_recuperacion(self):
        """Item 9: guarda un snapshot de recuperacion 2 segundos despues
        de la ultima tecla (debounced, igual que la vista previa, para no
        escribir a disco en cada tecla), no el archivo real del usuario
        -- si SiPi se cierra de golpe (falla electrica, cierre por error,
        crash), este snapshot es lo que se ofrece recuperar al reabrir."""
        if getattr(self, "_tarea_autoguardado", None):
            self.root.after_cancel(self._tarea_autoguardado)
        self._tarea_autoguardado = self.root.after(2000, self._guardar_snapshot_recuperacion)

    def _guardar_snapshot_recuperacion(self):
        import time
        datos = {
            "ruta": self.archivo_actual,
            "contenido": self.texto.get("1.0", "end-1c"),
            "fecha": time.time(),
        }
        try:
            with open(ARCHIVO_RECUPERACION, "w", encoding="utf-8") as f:
                json.dump(datos, f, ensure_ascii=False)
        except OSError:
            pass  # la recuperacion es un extra, nunca debe interrumpir al usuario

    def _borrar_snapshot_recuperacion(self):
        try:
            os.remove(ARCHIVO_RECUPERACION)
        except OSError:
            pass

    def _ofrecer_recuperacion_si_corresponde(self):
        """Se llama una sola vez, apenas arranca el editor. Si hay un
        snapshot de una sesion anterior que nunca se borro (es decir, esa
        sesion nunca cerro limpio), se le pregunta al usuario si lo quiere
        recuperar -- y se le muestra que archivo era y cuando, para que
        decida con contexto."""
        if not os.path.exists(ARCHIVO_RECUPERACION):
            return
        try:
            with open(ARCHIVO_RECUPERACION, "r", encoding="utf-8") as f:
                datos = json.load(f)
        except (OSError, json.JSONDecodeError):
            self._borrar_snapshot_recuperacion()
            return
        import time
        antiguedad_min = (time.time() - datos.get("fecha", 0)) / 60
        nombre = datos.get("ruta") or "(sin guardar)"
        quiere = messagebox.askyesno(
            "SiPi",
            f"Se encontro trabajo sin guardar de una sesion anterior que se cerro "
            f"sin guardar (hace {antiguedad_min:.0f} minuto(s)):\n\n{nombre}\n\n"
            f"¿Deseas recuperar ese trabajo?"
        )
        if quiere:
            self.texto.delete("1.0", "end")
            self.texto.insert("1.0", datos.get("contenido", ""))
            if datos.get("ruta") and os.path.exists(datos["ruta"]):
                self.archivo_actual = datos["ruta"]
            self.modificado = True
            self._actualizar_titulo()
            self._resaltar_sintaxis()
            self._actualizar_numeros_linea()
            self._programar_vista_previa()
            self._actualizar_selector_nivel_desde_codigo()
            self.estado.config(text="Trabajo recuperado de la sesion anterior.")
        self._borrar_snapshot_recuperacion()

    def _actualizar_titulo(self):
        """Item 8: 'main.sipi *' en la barra de titulo cuando hay cambios
        sin guardar, igual que cualquier IDE."""
        nombre = os.path.basename(self.archivo_actual) if self.archivo_actual else "Sin titulo"
        marca = " *" if self.modificado else ""
        self.root.title(f"SiPi - Editor Visual - {nombre}{marca}")

    def _limpiar_carpeta_temporal(self):
        shutil.rmtree(self.carpeta_temporal_ejecucion, ignore_errors=True)

    def _al_cerrar_ventana(self):
        # Item 14: al cerrar el editor entero, hay que revisar TODAS las
        # pestañas por cambios sin guardar, no solo la que esta activa en
        # este momento -- si no, cerrar con una pestaña de fondo
        # modificada perderia ese trabajo en silencio.
        self._guardar_estado_pestana_actual()
        for idx, pestana in enumerate(self.pestanas):
            if not pestana["modificado"]:
                continue
            nombre = self._texto_pestana(pestana)
            respuesta = messagebox.askyesnocancel(
                "SiPi", f"'{nombre}' tiene cambios sin guardar. ¿Queres guardarlos antes de salir?"
            )
            if respuesta is None:
                return
            if respuesta:
                archivo_previo, indice_previo = self.archivo_actual, self.indice_pestana_actual
                contenido_previo = self.texto.get("1.0", "end-1c")
                self.texto.delete("1.0", "end")
                self.texto.insert("1.0", pestana["contenido"])
                self.archivo_actual, self.indice_pestana_actual, self.modificado = pestana["archivo"], idx, True
                self.guardar_si_hace_falta()
                if self.modificado:
                    # Se cancelo el "Guardar como" de esta pestaña: se
                    # restaura la vista y se aborta el cierre entero.
                    self.texto.delete("1.0", "end")
                    self.texto.insert("1.0", contenido_previo)
                    self.archivo_actual, self.indice_pestana_actual = archivo_previo, indice_previo
                    return
                self.texto.delete("1.0", "end")
                self.texto.insert("1.0", contenido_previo)
                self.archivo_actual, self.indice_pestana_actual = archivo_previo, indice_previo
        # Cierre limpio (el usuario dijo explicitamente que no quiere
        # guardar, o ya no habia cambios pendientes): no tiene sentido
        # ofrecer "recuperar" este mismo contenido la proxima vez.
        self._borrar_snapshot_recuperacion()
        self._limpiar_carpeta_temporal()
        # Cancela callbacks 'after' pendientes (autoguardado, oferta de
        # recuperacion) antes de destruir la ventana -- si no, Tk imprime
        # un error benigno pero confuso a stderr cuando el callback
        # intenta dispararse contra un widget que ya no existe.
        for id_pendiente in (getattr(self, "_tarea_autoguardado", None),
                              getattr(self, "_id_recuperacion_pendiente", None),
                              self.tarea_pendiente):
            if id_pendiente:
                try:
                    self.root.after_cancel(id_pendiente)
                except Exception:
                    pass
        self.root.destroy()

    # ------- Archivo -------
    def nuevo(self):
        # Con pestañas, "Nuevo" abre una pestaña nueva en vez de
        # reemplazar el contenido de la actual (evita perder trabajo por
        # error si el usuario tenia algo sin guardar en la pestaña
        # activa).
        self.nueva_pestana()
        self.estado.config(text="Nuevo archivo.")

    def abrir(self):
        ruta = filedialog.askopenfilename(filetypes=[("Archivos SiPi", "*.sipi"), ("Todos", "*.*")])
        if ruta:
            self._abrir_ruta(ruta)

    def _abrir_ruta(self, ruta):
        """Logica real de abrir un archivo en una pestaña, separada de
        'abrir()' (que solo se encarga del dialogo de seleccion) para que
        el explorador de archivos (item 13 del feedback) pueda abrir un
        archivo con un doble clic sin pasar por un filedialog."""
        indice_existente = self._indice_pestana_para_archivo(ruta)
        if indice_existente is not None:
            self._cambiar_a_pestana(indice_existente)
            self.estado.config(text=f"Ya estaba abierto: {ruta}")
            return
        with open(ruta, "r", encoding="utf-8") as f:
            contenido = f.read()
        pestana_actual = self.pestanas[self.indice_pestana_actual]
        # Si la pestaña activa esta vacia y sin usar (recien creada, sin
        # archivo ni cambios), se reutiliza en vez de abrir una pestaña
        # de mas -- asi "Abrir" en un editor recien iniciado no deja una
        # pestaña "Sin titulo" vacia dando vueltas sin motivo.
        if pestana_actual["archivo"] is None and not pestana_actual["modificado"] and \
                self.texto.get("1.0", "end-1c") == "":
            self.texto.delete("1.0", "end")
            self.texto.insert("1.0", contenido)
            self.archivo_actual = ruta
            self.modificado = False
        else:
            self._guardar_estado_pestana_actual()
            self.pestanas.append({"archivo": ruta, "contenido": contenido, "modificado": False})
            self.indice_pestana_actual = len(self.pestanas) - 1
            self.texto.delete("1.0", "end")
            self.texto.insert("1.0", contenido)
            self.archivo_actual = ruta
            self.modificado = False
        self._actualizar_titulo()
        self._resaltar_sintaxis()
        self._actualizar_numeros_linea()
        self._redibujar_barra_pestanas()
        self._programar_vista_previa()
        self._actualizar_selector_nivel_desde_codigo()
        self.estado.config(text=f"Abierto: {ruta}")
        self._actualizar_visibilidad_bienvenida()

    def guardar(self):
        if not self.archivo_actual:
            return self.guardar_como()
        with open(self.archivo_actual, "w", encoding="utf-8") as f:
            f.write(self.texto.get("1.0", "end-1c"))
        self.modificado = False
        self._actualizar_titulo()
        self._borrar_snapshot_recuperacion()
        if self.pestanas:
            pestana = self.pestanas[self.indice_pestana_actual]
            pestana["archivo"] = self.archivo_actual
            pestana["modificado"] = False
            self._redibujar_barra_pestanas()
        self.estado.config(text=f"Guardado: {self.archivo_actual}")

    def guardar_como(self):
        ruta = filedialog.asksaveasfilename(defaultextension=".sipi",
                                             filetypes=[("Archivos SiPi", "*.sipi")])
        if ruta:
            self.archivo_actual = ruta
            self.guardar()

    def guardar_si_hace_falta(self):
        if self.archivo_actual:
            self.guardar()
        else:
            self.guardar_como()

    # ------- Ejecucion y compilacion reales -------
    def _ruta_para_ejecutar_contenido_actual(self):
        """Items 6/7 del feedback ("ejecutar sin guardar"): antes, Ejecutar
        exigia 'guardar_si_hace_falta()' y si el usuario cancelaba el
        dialogo de 'Guardar como', mostraba una advertencia y no corria
        nada. Ahora: si el archivo ya esta guardado y sin cambios, se usa
        esa ruta real tal cual (asi el codigo puede hacer cosas como
        leer archivos relativos a su propia carpeta). Si no -- sin
        guardar todavia, o con cambios pendientes -- se escribe el
        contenido actual del editor a un archivo dentro de
        'carpeta_temporal_ejecucion' (creada una sola vez en __init__ y
        NO borrada hasta cerrar el editor), preservando el nombre
        original si existe. El usuario ni se entera: escribe y ejecuta,
        sin que 'Guardar' sea un paso obligatorio en el medio."""
        if self.archivo_actual and not self.modificado and os.path.exists(self.archivo_actual):
            return self.archivo_actual, False
        nombre_base = os.path.basename(self.archivo_actual) if self.archivo_actual else "sin_titulo.sipi"
        ruta_temporal = os.path.join(self.carpeta_temporal_ejecucion, nombre_base)
        with open(ruta_temporal, "w", encoding="utf-8") as f:
            f.write(self.texto.get("1.0", "end-1c"))
        self._ruta_temporal_actual = ruta_temporal
        return ruta_temporal, True

    def ejecutar(self, banderas_extra=None):
        ruta_a_ejecutar, es_temporal = self._ruta_para_ejecutar_contenido_actual()
        aqui = os.path.dirname(os.path.abspath(__file__))
        try:
            sipi_py = self._ruta_motor_sipi(aqui)
        except ImportError as error:
            self._mostrar_error_motor_no_encontrado(aqui, error)
            return
        aviso_temporal = " (sin guardar todavia -- corriendo desde una copia temporal)" if es_temporal else ""
        self.estado.config(text=f"Ejecutando{aviso_temporal} (juegos y ventanas se abren en su propia ventana real)...")
        comando = self._comando_motor(sipi_py, ruta_a_ejecutar)
        if banderas_extra:
            # Las banderas del motor (--sin-cache, --depurar) se aceptan
            # en cualquier posicion (ver 'banderas_conocidas' en
            # sipi.py), asi que alcanza con agregarlas al final.
            comando = comando + list(banderas_extra)
        if os.name == "nt":
            # 'chcp 65001' fuerza la consola nueva a UTF-8 antes de correr
            # el programa -- sin esto, tildes/emojis salian como
            # caracteres raros aunque sipi.py ya reconfigura su propio
            # stdout a UTF-8 (la consola de Windows seguia leyendolo con
            # su codepage viejo, cp850/cp1252, por defecto).
            # OJO: antes esto tenia 'python "{sipi_py}" ...' escrito a
            # mano, que asumia un 'python' instalado en el PATH del
            # usuario final -- ese era EL bug del .exe (ver
            # '_comando_motor' y '_intentar_correr_como_motor_interno').
            #
            # Antes se veia crudo "Microsoft Windows... C:\>chcp 65001"
            # en una ventana generica de cmd.exe sin identificar (el
            # usuario reporto esto como "codigo 67001", que en realidad
            # es el chcp 65001 quedando visible). Ahora: 'chcp 65001>nul'
            # sigue silenciado, se le pone un titulo real a la ventana
            # (nombre del archivo en vez de "cmd.exe" generico) y un
            # 'cls' limpia cualquier resto antes de mostrar la salida del
            # programa, para que la consola que se abre se sienta parte
            # de SiPi y no una terminal de Windows suelta.
            nombre_programa = os.path.basename(ruta_a_ejecutar)
            titulo_ventana = f"SiPi - {nombre_programa}"
            comando_texto = " ".join(f'"{parte}"' for parte in comando)
            subprocess.Popen(
                f'start "{titulo_ventana}" cmd /k '
                f'"title {titulo_ventana} && chcp 65001>nul && cls && {comando_texto}"',
                shell=True,
            )
        else:
            subprocess.Popen(comando)
        self.estado.config(text=f"Ejecutado: {ruta_a_ejecutar}{aviso_temporal}")

    def ejecutar_sin_cache(self):
        """Variante de 'Ejecutar' que fuerza a ignorar cualquier .sipic
        existente (ver docs/CACHE.md) -- util cuando se sospecha que la
        cache de bytecode quedo desactualizada. Antes esto solo era
        posible desde la terminal con 'python sipi.py --sin-cache
        archivo.sipi'; ahora esta a un click via el desplegable del
        boton Ejecutar."""
        self.estado.config(text="Ejecutando sin usar la caché de bytecode...")
        self.ejecutar(banderas_extra=["--sin-cache"])

    # ------- Revisar codigo (analizador estatico, --revisar) -------
    def abrir_revision_codigo(self):
        """Corre 'sipi.py --revisar' de forma sincronica sobre el contenido
        actual del editor (sin tener que guardarlo primero) y muestra el
        reporte en un panel propio, con cada 'Línea N:' clickeable para
        saltar directo ahi en el editor -- mismo espiritu que un linter
        de un IDE serio, integrado en vez de vivir solo en la terminal."""
        colores = self._colores()
        codigo = self.texto.get("1.0", "end-1c")
        aqui = os.path.dirname(os.path.abspath(__file__))
        try:
            sipi_py = self._ruta_motor_sipi(aqui)
        except ImportError as error:
            self._mostrar_error_motor_no_encontrado(aqui, error)
            return

        ruta_temp = os.path.join(self.carpeta_temporal_ejecucion, "_revision.sipi")
        try:
            with open(ruta_temp, "w", encoding="utf-8") as f:
                f.write(codigo)
            resultado = subprocess.run(
                [sys.executable, sipi_py, "--revisar", ruta_temp],
                capture_output=True, text=True, timeout=15,
                encoding="utf-8", errors="replace",
            )
            salida = resultado.stdout or resultado.stderr or "(sin salida)"
        except (subprocess.TimeoutExpired, OSError) as e:
            messagebox.showerror("SiPi", f"No se pudo revisar el código:\n{e}")
            return

        ventana = tk.Toplevel(self.root)
        ventana.title("Revisar código")
        ventana.configure(bg=colores["barra"])
        ventana.geometry("720x480")

        marco = tk.Frame(ventana, bg=colores["barra"])
        marco.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
        scroll = ttk.Scrollbar(marco, style=getattr(self, "_estilo_scrollbar_v", "Vertical.TScrollbar"))
        scroll.pack(side=tk.RIGHT, fill=tk.Y)
        area = tk.Text(marco, bg="#11111b", fg=colores["texto"], bd=0, wrap="word",
                        font=(_fuente_mono(), 11), yscrollcommand=scroll.set)
        area.pack(fill=tk.BOTH, expand=True)
        scroll.config(command=area.yview)
        patron_linea = re.compile(r"Línea (\d+)")
        for idx_renglon, renglon in enumerate(salida.split("\n")):
            m = patron_linea.search(renglon)
            if m:
                numero = int(m.group(1))
                tag = f"clic_{idx_renglon}"
                inicio_linea = area.index("end-1c")
                area.insert("end", renglon + "\n")
                fin_linea = area.index("end-1c")
                area.tag_add(tag, inicio_linea, fin_linea)
                area.tag_configure(tag, foreground="#89b4fa", underline=True)
                area.tag_bind(tag, "<Button-1>", lambda e, n=numero: (
                    ventana.lift(), self._saltar_a_linea(n)))
                area.tag_bind(tag, "<Enter>", lambda e: area.configure(cursor="hand2"))
                area.tag_bind(tag, "<Leave>", lambda e: area.configure(cursor="arrow"))
            else:
                area.insert("end", renglon + "\n")
        area.configure(state="disabled")

        tk.Label(ventana, text="Hacé clic en cualquier línea del reporte que diga 'Línea N' para saltar ahí en el editor.",
                 bg=colores["barra"], fg="#a6adc8", font=("Segoe UI", 9), wraplength=700, justify="left"
                 ).pack(fill=tk.X, padx=8, pady=(0, 8))

    def _capturar_error_ejecucion_actual(self, codigo):
        """Corre el programa actual de forma sincronica (capturando
        stderr) unicamente para conseguirle a SiPi IA el mensaje de error
        real -- separado de 'ejecutar()', que lanza el proceso en su
        propia ventana de forma asincronica para juegos/GUIs y no captura
        salida. Se usa un timeout corto porque esto es solo para
        diagnostico, no para correr el programa de verdad."""
        aqui = os.path.dirname(os.path.abspath(__file__))
        try:
            sipi_py = self._ruta_motor_sipi(aqui)
        except ImportError:
            return None
        ruta_temp = os.path.join(self.carpeta_temporal_ejecucion, "_diagnostico_ia.sipi")
        try:
            with open(ruta_temp, "w", encoding="utf-8") as f:
                f.write(codigo)
            resultado = subprocess.run(
                self._comando_motor(sipi_py, ruta_temp),
                capture_output=True, text=True, timeout=8,
                encoding="utf-8", errors="replace",
                env={**os.environ, "PYTHONIOENCODING": "utf-8"},
            )
            salida = (resultado.stderr or "").strip() or (
                resultado.stdout.strip() if resultado.returncode != 0 else ""
            )
            return salida or None
        except subprocess.TimeoutExpired:
            return None
        except OSError:
            return None

    def _mostrar_error_motor_no_encontrado(self, aqui, error):
        """Item 4 del feedback ("manejo de errores"): en vez de dejar
        pasar un '[Errno 2] No such file or directory' crudo de Python
        (el mensaje real que vio el tester en Windows), se explica que se
        busco, donde, y la causa mas probable (carpeta ejecutandose desde
        un ZIP sin extraer, o un antivirus/limpiador de temporales que
        borro el archivo)."""
        mensaje = (
            "[SiPi] No se encontro 'sipi.py' ni 'sipi_protegido.py'.\n\n"
            f"Carpeta donde se busco:\n{aqui}\n\n"
            "Causas mas probables:\n"
            " - Estas ejecutando SiPi directamente desde adentro de un archivo\n"
            "   .zip descargado (por ejemplo, haciendo doble clic sin extraer\n"
            "   primero). Extrae el .zip completo a una carpeta normal del\n"
            "   disco (no dentro de Temp) y volve a intentar.\n"
            " - Un antivirus o limpiador de temporales borro el archivo\n"
            "   despues de que se extrajo.\n\n"
            f"Detalle tecnico: {error}"
        )
        messagebox.showerror("SiPi", mensaje)
        self.estado.config(text="Error: no se encontro el motor de SiPi.")

    # ------- Buscar y reemplazar (item 15) -------
    def abrir_buscar(self):
        self._abrir_dialogo_busqueda(con_reemplazo=False)

    def abrir_reemplazar(self):
        self._abrir_dialogo_busqueda(con_reemplazo=True)

    def _abrir_dialogo_busqueda(self, con_reemplazo):
        colores = self._colores()
        ventana = tk.Toplevel(self.root)
        ventana.title("Reemplazar" if con_reemplazo else "Buscar")
        ventana.configure(bg=colores["barra"])
        ventana.transient(self.root)

        tk.Label(ventana, text="Buscar:", bg=colores["barra"], fg=colores["texto"]).grid(
            row=0, column=0, padx=6, pady=6, sticky="e")
        entrada_buscar = tk.Entry(ventana, width=30)
        entrada_buscar.grid(row=0, column=1, padx=6, pady=6)
        entrada_buscar.focus_set()

        entrada_reemplazo = None
        if con_reemplazo:
            tk.Label(ventana, text="Reemplazar por:", bg=colores["barra"], fg=colores["texto"]).grid(
                row=1, column=0, padx=6, pady=6, sticky="e")
            entrada_reemplazo = tk.Entry(ventana, width=30)
            entrada_reemplazo.grid(row=1, column=1, padx=6, pady=6)

        etiqueta_resultado = tk.Label(ventana, text="", bg=colores["barra"], fg="#a6adc8")
        etiqueta_resultado.grid(row=2, column=0, columnspan=2)

        def _limpiar_resaltado():
            self.texto.tag_remove("busqueda", "1.0", "end")

        def _buscar_todas(mostrar_resultado=True):
            _limpiar_resaltado()
            self.texto.tag_configure("busqueda", background="#f9c74f", foreground="#1e1e2e")
            objetivo = entrada_buscar.get()
            if not objetivo:
                return 0
            cantidad = 0
            inicio = "1.0"
            while True:
                pos = self.texto.search(objetivo, inicio, stopindex="end")
                if not pos:
                    break
                fin = f"{pos}+{len(objetivo)}c"
                self.texto.tag_add("busqueda", pos, fin)
                inicio = fin
                cantidad += 1
            if mostrar_resultado:
                etiqueta_resultado.config(
                    text=f"{cantidad} coincidencia(s)." if cantidad else "Sin coincidencias.")
            return cantidad

        def _reemplazar_todas():
            objetivo = entrada_buscar.get()
            nuevo = entrada_reemplazo.get() if entrada_reemplazo else ""
            if not objetivo:
                return
            contenido = self.texto.get("1.0", "end-1c")
            cantidad = contenido.count(objetivo)
            contenido = contenido.replace(objetivo, nuevo)
            self.texto.delete("1.0", "end")
            self.texto.insert("1.0", contenido)
            self._marcar_modificado()
            self._resaltar_sintaxis()
            self._actualizar_numeros_linea()
            etiqueta_resultado.config(text=f"{cantidad} reemplazo(s) hecho(s).")

        botones = tk.Frame(ventana, bg=colores["barra"])
        botones.grid(row=3, column=0, columnspan=2, pady=(0, 8))
        tk.Button(botones, text="Buscar", command=_buscar_todas).pack(side=tk.LEFT, padx=4)
        if con_reemplazo:
            tk.Button(botones, text="Reemplazar todas", command=_reemplazar_todas).pack(side=tk.LEFT, padx=4)
        entrada_buscar.bind("<Return>", lambda e: _buscar_todas())

        def _al_cerrar():
            _limpiar_resaltado()
            ventana.destroy()
        ventana.protocol("WM_DELETE_WINDOW", _al_cerrar)

    def compilar_exe(self):
        # A diferencia de Ejecutar, compilar a .exe si necesita un archivo
        # real y permanente (PyInstaller lee del disco, no de un stdin ni
        # de un temporal que podria desaparecer a mitad de la compilacion,
        # que puede tardar bastante mas que una ejecucion normal).
        self.guardar_si_hace_falta()
        if not self.archivo_actual:
            messagebox.showwarning("SiPi", "Guarda el archivo antes de compilarlo.")
            return
        aqui = os.path.dirname(os.path.abspath(__file__))
        if _es_exe_empaquetado():
            # A diferencia de "Ejecutar" (que corre el archivo con el
            # motor de SiPi ya embebido, sin depender de nada externo),
            # compilar UN programa del usuario a su propio .exe si
            # necesita PyInstaller de verdad instalado -- y PyInstaller
            # no viaja adentro del .exe del editor (seria enorme y,
            # ademas, generar_exe.py necesita poder invocarlo como
            # 'python -m PyInstaller', que tampoco esta disponible sin
            # un Python real en la maquina). Avisamos claro en vez de
            # fallar con un error crudo de 'python' no encontrado.
            messagebox.showinfo(
                "SiPi - Compilar a .exe",
                "Compilar tu programa a un .exe standalone necesita PyInstaller "
                "y un Python instalado en esta maquina (el editor .exe que estas "
                "usando no lo trae adentro, seria demasiado pesado).\n\n"
                "Instala Python desde python.org, despues:\n"
                "  pip install pyinstaller\n"
                "  python generar_exe.py \"" + os.path.basename(self.archivo_actual) + "\"\n\n"
                "(usando el codigo fuente completo de SiPi, no este .exe del editor)."
            )
            self.estado.config(text="Compilar a .exe necesita Python + PyInstaller instalados (ver mensaje).")
            return
        try:
            generar_exe = self._ruta_generar_exe(aqui)
        except ImportError as error:
            self._mostrar_error_motor_no_encontrado(aqui, error)
            return
        self.estado.config(text="Compilando a ejecutable, mira la consola...")
        if os.name == "nt":
            subprocess.Popen(f'start cmd /k "{sys.executable}" "{generar_exe}" "{self.archivo_actual}"', shell=True)
        else:
            subprocess.Popen([sys.executable, generar_exe, self.archivo_actual])

    # ------- Depurador visual paso a paso -------
    def abrir_depurador(self):
        self.guardar_si_hace_falta()
        if not self.archivo_actual:
            messagebox.showwarning("SiPi", "Guarda el archivo antes de depurarlo.")
            return
        if re.search(r"\bventana\b|\bcrear_juego\b", self.texto.get("1.0", "end-1c")):
            messagebox.showinfo(
                "SiPi",
                "El depurador visual esta pensado para programas de consola.\n"
                "Los programas con 'ventana' o 'crear_juego' abren su propia ventana real; "
                "usa el boton 'Ejecutar' para esos casos."
            )
            return
        VentanaDepurador(self.root, self.archivo_actual, self._colores())

    def formatear_codigo_actual(self):
        aqui = os.path.dirname(os.path.abspath(__file__))
        motor_sipi = _cargar_motor_sipi(aqui)

        contenido_actual = self.texto.get("1.0", "end-1c")
        lineas_originales = contenido_actual.split("\n")

        resultado = []
        nivel = 0
        dentro_comentario_bloque = False
        dentro_cadena_multilinea = False
        for linea_cruda in lineas_originales:
            limpia = linea_cruda.strip()

            if dentro_comentario_bloque:
                resultado.append(linea_cruda)
                if "*/" in limpia:
                    dentro_comentario_bloque = False
                continue
            if dentro_cadena_multilinea:
                resultado.append(linea_cruda)
                if '"""' in limpia:
                    dentro_cadena_multilinea = False
                continue

            if limpia == "":
                resultado.append("")
                continue

            if limpia.startswith("/*") and "*/" not in limpia:
                resultado.append("    " * nivel + limpia)
                dentro_comentario_bloque = True
                continue
            if limpia.count('"""') == 1:
                resultado.append("    " * nivel + limpia)
                dentro_cadena_multilinea = True
                continue

            palabra = limpia.split(" ")[0]
            if palabra == "fin":
                nivel = max(0, nivel - 1)
                resultado.append("    " * nivel + limpia)
            elif palabra in motor_sipi.PALABRAS_MISMO_NIVEL:
                resultado.append("    " * max(0, nivel - 1) + limpia)
            else:
                resultado.append("    " * nivel + limpia)
                if palabra in motor_sipi.PALABRAS_APERTURA_BLOQUE:
                    nivel += 1

        texto_formateado = "\n".join(resultado)
        if texto_formateado == contenido_actual.rstrip("\n"):
            self.estado.config(text="El codigo ya estaba formateado correctamente.")
            return
        self.texto.delete("1.0", "end")
        self.texto.insert("1.0", texto_formateado)
        self._resaltar_sintaxis()
        self._actualizar_numeros_linea()
        self.estado.config(text="Codigo formateado (reindentado con 4 espacios por nivel).")


class VentanaDepurador:
    """Depurador visual real: ejecuta el programa paso a paso en un hilo
    aparte, mostrando en vivo el valor de las variables, listas y
    diccionarios, con soporte de breakpoints (clic en el numero de linea)."""

    def __init__(self, root_padre, archivo, colores):
        aqui = os.path.dirname(os.path.abspath(__file__))
        motor_sipi = _cargar_motor_sipi(aqui)
        self.motor_sipi = motor_sipi

        self.archivo = archivo
        self.colores = colores
        self.breakpoints = set()
        self.evento_continuar = threading.Event()
        self.modo_paso = True
        self.detener = False
        self.hilo = None
        self.interprete = None

        # Item 8 de tu feedback: "viaje en el tiempo". Guardamos una foto de
        # las variables en CADA paso ejecutado, para poder navegar hacia
        # atras y ver como estaban las cosas antes -- sin re-ejecutar nada
        # (el programa real no se puede "deshacer" si ya imprimio algo o
        # escribio un archivo), es un historial de solo lectura para mirar
        # atras y entender en que paso una variable tomo el valor que no
        # esperabas.
        self.historial = []  # lista de {"linea": int, "variables": dict, "locales": dict}
        self.indice_historial = None  # None = viendo el paso en vivo

        self.ventana = tk.Toplevel(root_padre)
        self.ventana.title(f"SiPi - Depurador: {os.path.basename(archivo)}")
        self.ventana.geometry("900x600")
        self.ventana.configure(bg=colores["barra"])

        self._crear_interfaz()
        self._cargar_codigo_fuente()
        self._iniciar_sesion()

    def _crear_interfaz(self):
        colores = self.colores
        barra = tk.Frame(self.ventana, bg=colores["barra"])
        barra.pack(side=tk.TOP, fill=tk.X)

        estilo = {"bg": "#313244", "fg": "white", "bd": 0, "padx": 12, "pady": 6, "font": ("Segoe UI", 10)}
        tk.Button(barra, text="<< Retroceder", command=self.retroceder, **estilo).pack(side=tk.LEFT, padx=4, pady=4)
        tk.Button(barra, text=">> Adelante", command=self.avanzar, **estilo).pack(side=tk.LEFT, padx=4, pady=4)
        tk.Button(barra, text="> Paso", command=self.paso, **estilo).pack(side=tk.LEFT, padx=4, pady=4)
        tk.Button(barra, text="> Continuar", command=self.continuar, **estilo).pack(side=tk.LEFT, padx=4, pady=4)
        tk.Button(barra, text="[ ] Detener", command=self.detener_sesion, bg="#d20f39",
                  fg="white", bd=0, padx=12, pady=6, font=("Segoe UI", 10)).pack(side=tk.LEFT, padx=4, pady=4)
        tk.Label(barra, text="  Clic en el numero de linea para poner/quitar un breakpoint",
                 bg=colores["barra"], fg="#a6adc8", font=("Segoe UI", 9)).pack(side=tk.LEFT, padx=10)

        contenedor = tk.PanedWindow(self.ventana, orient=tk.HORIZONTAL, bg=colores["fondo"], sashwidth=4,
                                     bd=0, sashrelief="flat", relief="flat", showhandle=False)
        contenedor.pack(fill=tk.BOTH, expand=True)

        panel_codigo = tk.Frame(contenedor, bg=colores["fondo"])
        self.numeros = tk.Text(panel_codigo, width=4, bg=colores["barra"], fg="#6c7086",
                                bd=0, font=(_fuente_mono(), 11))
        self.numeros.pack(side=tk.LEFT, fill=tk.Y)
        self.numeros.bind("<Button-1>", self._al_clic_numero)

        self.codigo = tk.Text(panel_codigo, bg=colores["fondo"], fg=colores["texto"], bd=0,
                               font=(_fuente_mono(), 11), wrap="none", state="disabled")
        self.codigo.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.codigo.tag_configure("linea_actual", background="#45475a")
        self.numeros.tag_configure("con_breakpoint", background="#d20f39", foreground="white")
        contenedor.add(panel_codigo, minsize=350)

        panel_variables = tk.Frame(contenedor, bg=colores["barra"])
        tk.Label(panel_variables, text="Variables en vivo", bg=colores["barra"], fg=colores["texto"],
                 font=("Segoe UI", 11, "bold"), anchor="w", padx=8, pady=6).pack(side=tk.TOP, fill=tk.X)
        self.panel_vars = tk.Text(panel_variables, bg="#11111b", fg="#cdd6f4", bd=0,
                                   font=(_fuente_mono(), 10), state="disabled", wrap="word")
        self.panel_vars.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
        contenedor.add(panel_variables, minsize=250)

        self.estado = tk.Label(self.ventana, text="Preparando sesion de depuracion...",
                                bg=colores["barra"], fg="#a6adc8", anchor="w",
                                font=("Segoe UI", 9), padx=8, pady=4)
        self.estado.pack(side=tk.BOTTOM, fill=tk.X)

    def _cargar_codigo_fuente(self):
        with open(self.archivo, "r", encoding="utf-8") as f:
            contenido = f.read()
        total_lineas = contenido.count("\n") + 1
        self.numeros.delete("1.0", "end")
        self.numeros.insert("1.0", "\n".join(str(n) for n in range(1, total_lineas + 1)))
        self.codigo.config(state="normal")
        self.codigo.delete("1.0", "end")
        self.codigo.insert("1.0", contenido)
        self.codigo.config(state="disabled")

    def _al_clic_numero(self, evento):
        indice = self.numeros.index(f"@{evento.x},{evento.y}")
        num_linea = int(indice.split(".")[0])
        if num_linea in self.breakpoints:
            self.breakpoints.discard(num_linea)
        else:
            self.breakpoints.add(num_linea)
        self._repintar_breakpoints()

    def _repintar_breakpoints(self):
        self.numeros.tag_remove("con_breakpoint", "1.0", "end")
        for num_linea in self.breakpoints:
            self.numeros.tag_add("con_breakpoint", f"{num_linea}.0", f"{num_linea}.end+1c")

    def _iniciar_sesion(self):
        self.interprete = self.motor_sipi.Interprete(self.archivo)
        self.interprete.hook_linea = self._al_llegar_a_linea

        def ejecutar_programa():
            try:
                self.interprete.ejecutar()
                if not self.detener:
                    self.ventana.after(0, lambda: self.estado.config(text="Programa terminado correctamente."))
            except self.motor_sipi.SiPiError as e:
                mensaje = str(e)
                self.ventana.after(0, lambda: self.estado.config(text=f"Error: {mensaje}"))
            except Exception as e:
                sipi_debug.log_debug(f"editor: excepcion no controlada al ejecutar sesion: {type(e).__name__}: {e}")
                mensaje = sipi_debug.mensaje_error_usuario(e, "Error inesperado")
                self.ventana.after(0, lambda: self.estado.config(text=mensaje))

        self.hilo = threading.Thread(target=ejecutar_programa, daemon=True)
        self.hilo.start()

    def _al_llegar_a_linea(self, num, linea):
        """Se ejecuta DESDE EL HILO del programa SiPi antes de cada linea."""
        if self.detener:
            # Antes se usaba RetornoFuncion para cortar la ejecucion, pero si
            # el programa estaba dentro de una llamada a funcion en ese
            # momento, esa misma funcion atrapaba el RetornoFuncion como si
            # fuera un 'devolver' normal, y el programa seguia corriendo en
            # vez de detenerse (el boton "Detener" no detenia nada si el
            # breakpoint caia dentro de una funcion). Ahora se usa una
            # excepcion dedicada que ninguna llamada a funcion atrapa.
            raise self.motor_sipi.DepuracionDetenida()

        snapshot_variables = dict(self.interprete.entorno.variables)
        # Si estamos dentro de una llamada a funcion, tambien mostramos las
        # variables locales de esa llamada (antes solo se mostraban las
        # globales, asi que dentro de una funcion el panel "Variables en
        # vivo" aparecia vacio o incompleto, aunque la funcion si tuviera
        # variables propias).
        pila_local = list(getattr(self.interprete, "pila_scopes", []))
        snapshot_locales = dict(pila_local[-1]) if pila_local else {}
        self.historial.append({"linea": num, "variables": snapshot_variables, "locales": snapshot_locales})
        self.ventana.after(0, lambda: self._actualizar_vista(num, snapshot_variables, snapshot_locales))

        if num in self.breakpoints or self.modo_paso:
            self.evento_continuar.clear()
            self.evento_continuar.wait()

    def _actualizar_vista(self, num_linea, variables, locales=None, viendo_historial=False):
        self.codigo.tag_remove("linea_actual", "1.0", "end")
        self.codigo.tag_add("linea_actual", f"{num_linea}.0", f"{num_linea}.end+1c")
        self.codigo.see(f"{num_linea}.0")
        if viendo_historial:
            total = len(self.historial)
            self.estado.config(
                text=f"Viendo historial (paso {self.indice_historial + 1} de {total}) - "
                     f"solo lectura, linea {num_linea}. Usa 'Adelante' para volver al presente."
            )
        else:
            self.estado.config(text=f"Ejecutando linea {num_linea}...")

        self.panel_vars.config(state="normal")
        self.panel_vars.delete("1.0", "end")
        if locales:
            self.panel_vars.insert("end", "-- Variables locales (funcion actual) --\n")
            for nombre, valor in locales.items():
                self.panel_vars.insert("end", f"{nombre} = {valor!r}\n")
            self.panel_vars.insert("end", "\n-- Variables globales --\n")
        if not variables and not locales:
            self.panel_vars.insert("1.0", "(sin variables todavia)")
        else:
            for nombre, valor in variables.items():
                self.panel_vars.insert("end", f"{nombre} = {valor!r}\n")
        self.panel_vars.config(state="disabled")

    def retroceder(self):
        """Item 8: navega un paso hacia atras en el historial grabado, sin
        tocar la ejecucion real (que sigue pausada donde estaba). Es una
        vista de solo lectura para entender como llegaron las variables a
        su valor actual."""
        if not self.historial:
            return
        if self.indice_historial is None:
            self.indice_historial = len(self.historial) - 1
        if self.indice_historial > 0:
            self.indice_historial -= 1
        paso = self.historial[self.indice_historial]
        self._actualizar_vista(paso["linea"], paso["variables"], paso["locales"], viendo_historial=True)

    def avanzar(self):
        """Complemento de retroceder(): avanza un paso en el historial ya
        grabado. Si llega al ultimo paso grabado, vuelve al modo 'en vivo'
        normal (siguiendo la ejecucion real, no una foto vieja)."""
        if self.indice_historial is None or not self.historial:
            return
        if self.indice_historial >= len(self.historial) - 1:
            self.indice_historial = None
            paso = self.historial[-1]
            self._actualizar_vista(paso["linea"], paso["variables"], paso["locales"], viendo_historial=False)
            return
        self.indice_historial += 1
        paso = self.historial[self.indice_historial]
        viendo_pasado = self.indice_historial < len(self.historial) - 1
        self._actualizar_vista(paso["linea"], paso["variables"], paso["locales"], viendo_historial=viendo_pasado)

    def paso(self):
        # Si el usuario estaba mirando el historial hacia atras, 'Paso'
        # vuelve primero al presente antes de avanzar la ejecucion real --
        # avanzar la ejecucion de verdad solo tiene sentido desde el estado
        # actual, no desde una foto vieja.
        self.indice_historial = None
        self.modo_paso = True
        self.evento_continuar.set()

    def continuar(self):
        self.indice_historial = None
        self.modo_paso = False
        self.evento_continuar.set()

    def detener_sesion(self):
        self.detener = True
        self.evento_continuar.set()
        self.estado.config(text="Sesion de depuracion detenida por el usuario.")


def _advertir_si_carpeta_volatil(aqui):
    """Bug #3/#1 del feedback: el error real que vio el tester en Windows
    ("can't open file ...\\Temp\\...\\SiPi-main\\sipi_protegido.py") pasa
    cuando SiPi entero se esta ejecutando desde ADENTRO de la carpeta
    temporal de Windows -- tipicamente porque se abrio el .zip descargado
    con doble clic y se corrio un .bat desde ahi sin extraerlo primero a
    una carpeta normal del disco. Windows (y algunos antivirus) limpian
    esa carpeta temporal en cualquier momento, incluso a mitad de la
    ejecucion, asi que un archivo que existia hace un segundo desaparece
    sin aviso. Se detecta comparando la carpeta real del editor contra la
    carpeta temporal del sistema, y se avisa ANTES de que se rompa nada,
    en vez de dejar que aparezca un traceback confuso mas tarde."""
    try:
        temporal = os.path.normcase(os.path.abspath(tempfile.gettempdir()))
        actual = os.path.normcase(os.path.abspath(aqui))
        if actual.startswith(temporal):
            print("=" * 70)
            print("[SiPi] Aviso: parece que estas ejecutando SiPi desde una carpeta")
            print("       temporal del sistema:")
            print(f"       {aqui}")
            print()
            print("       Esto normalmente pasa cuando se abre el .zip descargado")
            print("       haciendo doble clic y se corre un archivo desde ADENTRO")
            print("       del .zip, sin extraerlo primero. Windows puede borrar esa")
            print("       carpeta temporal en cualquier momento (incluso mientras")
            print("       SiPi esta corriendo), lo que causa errores como")
            print("       '[Errno 2] No such file or directory'.")
            print()
            print("       Recomendado: extrae el .zip completo a una carpeta normal")
            print("       (por ejemplo, tu Escritorio o Documentos) y volve a correr")
            print("       SiPi desde ahi.")
            print("=" * 70)
            return True
    except OSError:
        pass
    return False


def main():
    aqui = os.path.dirname(os.path.abspath(__file__))
    _advertir_si_carpeta_volatil(aqui)
    if _CTK_DISPONIBLE:
        root = ctk.CTk()
    else:
        root = tk.Tk()
    EditorSiPi(root)
    root.mainloop()


if __name__ == "__main__":
    # Si nos re-invocaron con la bandera interna (ver
    # '_intentar_correr_como_motor_interno' arriba del archivo), correr
    # como motor de SiPi puro y salir -- sin tocar Tk ni abrir la GUI.
    # Este chequeo tiene que pasar ANTES de crear cualquier ventana.
    if not _intentar_correr_como_motor_interno():
        main()
