#!/usr/bin/env python3
"""
generar_exe_ide.py - Compila el editor de SiPi (la IDE completa, no un
programa .sipi puntual) a un .exe standalone de Windows, listo para
subir a un hosting como Mediafire.

Distinto de src/generar_exe.py (ese compila UN programa .sipi hecho por
el usuario final). Este compila la aplicacion en si.

Que hace, en orden:
  1. Corre proteger_codigo.py para generar las versiones protegidas
     (bytecode, no texto plano) de los modulos con logica real, en una
     carpeta de build temporal.
  2. Empaqueta esa carpeta con PyInstaller en un unico .exe (--onefile,
     --windowed, sin consola), incluyendo los iconos y sipi_celular.html
     como recursos.
  3. Aplica las opciones de PyInstaller que hacen mas trabajoso el
     analisis del binario resultante (strip de simbolos, UPX si esta
     disponible, sin bloque de debug).

Igual que con proteger_codigo.py: esto encarece el analisis, no lo hace
imposible. Un .exe de PyInstaller sigue siendo, en el fondo, un
interprete de Python empaquetado + los .pyc protegidos adentro;
herramientas como `pyinstxtractor` los extraen sin mayor dificultad.
Para el nivel de proteccion que tiene sentido en un proyecto educativo,
esto ya es un salto grande respecto de distribuir los .py sueltos.

Requisitos: PyInstaller instalado (`pip install pyinstaller
--break-system-packages`). UPX es opcional (mejora la compresion/
ofuscacion superficial del binario) — si no esta en el PATH, se
compila igual sin el.

Uso:
    python3 herramientas/generar_exe_ide.py
        (genera SiPi-IDE.exe en dist/, con icono y todos los recursos)

    python3 herramientas/generar_exe_ide.py --nombre SiPi-Editor --consola
        (nombre custom, y deja la consola visible para ver errores)
"""
import os
import sys
import shutil
import subprocess
import tempfile
import argparse

RAIZ_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CARPETA_SRC = os.path.join(RAIZ_REPO, "src")

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import proteger_codigo  # noqa: E402


def _verificar_pyinstaller():
    try:
        import PyInstaller  # noqa: F401
        return True
    except ImportError:
        return False


def _soporte_cifrado_pyinstaller():
    """PyInstaller <6.0 podia cifrar con AES el bytecode empaquetado si
    estaba instalado 'tinyaes' (opcion --key). Anthropic... digo,
    PyInstaller le saco esa opcion en la version 6.0 (la consideraron
    seguridad-por-oscuridad de bajo valor real -- ver el propio PR de
    ellos). Si la version instalada la soporta todavia, se usa; si no,
    se sigue sin ella y ya (UPX + la proteccion propia de
    proteger_codigo.py siguen aplicando igual)."""
    try:
        import tinyaes  # noqa: F401
    except ImportError:
        return False
    try:
        import PyInstaller
        version_mayor = int(PyInstaller.__version__.split(".")[0])
    except (ImportError, ValueError, IndexError):
        return False
    return version_mayor < 6


def _generar_clave_cifrado():
    import secrets
    return secrets.token_hex(8)  # PyInstaller pide --key de hasta 16 caracteres


def _hay_upx():
    return shutil.which("upx") is not None


def _icono_windows():
    ruta = os.path.join(RAIZ_REPO, "pkg-assets", "icons", "sipi.ico")
    return ruta if os.path.exists(ruta) else None


def _modulos_importados_por_ast(ruta):
    """Modulos que importa un .py, leidos con ast (no con una regex ni
    ejecutando el archivo). Devuelve tanto paquetes raiz ('tkinter')
    como candidatos de submodulo con el punto ('tkinter.filedialog')
    -- 'from tkinter import filedialog' no alcanza con avisarle a
    PyInstaller de 'tkinter': el paquete tkinter no importa sus
    submodulos (filedialog, messagebox, ttk, etc.) automaticamente al
    importarse, asi que si no se piden por separado el .exe empaqueta
    tkinter/__init__.py pero no sus submodulos y revienta con
    'cannot import name filedialog from tkinter' en cuanto el codigo
    real (recien ahi, ya corriendo) intenta usarlos."""
    import ast as ast_mod
    with open(ruta, "r", encoding="utf-8") as f:
        arbol = ast_mod.parse(f.read(), filename=ruta)
    raices = set()
    candidatos_submodulo = set()
    for nodo in ast_mod.walk(arbol):
        if isinstance(nodo, ast_mod.Import):
            for alias in nodo.names:
                raices.add(alias.name.split(".")[0])
        elif isinstance(nodo, ast_mod.ImportFrom):
            if nodo.module and nodo.level == 0:
                raices.add(nodo.module.split(".")[0])
                for alias in nodo.names:
                    candidatos_submodulo.add(f"{nodo.module}.{alias.name}")
    return raices, candidatos_submodulo


def _recolectar_hidden_imports():
    """PyInstaller arma la lista de modulos a empaquetar analizando el
    codigo fuente (AST) de tu script de entrada. El problema: el
    'script de entrada' real ahora es un loader que solo hace
    'exec(codigo_bytecode, globals())' -- ni 'tkinter' ni 'sqlite3' ni
    ningun import real aparecen ahi como texto, estan adentro del
    bytecode marshaled. Sin este paso, PyInstaller arma un .exe que
    revienta con ModuleNotFoundError en cuanto el codigo real (ya
    corriendo) intenta importar tkinter/sqlite3/etc.

    Aca escaneamos con ast TODOS los .py de src/ (los originales, sin
    proteger) para saber que importan de verdad, y se lo pasamos a
    PyInstaller de forma explicita con --hidden-import. Los que no
    esten instalados en esta maquina se avisan y se omiten (son
    dependencias opcionales de features puntuales -- pygame para
    videojuegos, pymysql/pg8000 para bases de datos externas, etc. --
    el codigo ya las importa con try/except adentro)."""
    import importlib.util

    encontrados = set()
    candidatos_submodulo = set()
    for nombre_archivo in os.listdir(CARPETA_SRC):
        if nombre_archivo.endswith(".py"):
            raices, submods = _modulos_importados_por_ast(os.path.join(CARPETA_SRC, nombre_archivo))
            encontrados |= raices
            candidatos_submodulo |= submods

    # Modulos propios de SiPi (no son dependencias externas, ya se
    # empaquetan aparte como modulos protegidos).
    prefijos_propios = ("sipi", "editor", "ast_sipi", "lexer_sipi", "logros", "iconos", "generar_exe")
    externos = sorted(n for n in encontrados if not n.startswith(prefijos_propios))

    disponibles, faltantes = [], []
    for modulo in externos:
        if importlib.util.find_spec(modulo) is not None:
            disponibles.append(modulo)
        else:
            faltantes.append(modulo)

    # Ahora los submodulos puntuales (tkinter.filedialog, etc.) -- solo
    # los que resuelven de verdad como modulo importable (no una
    # funcion/clase suelta que vive adentro del paquete padre).
    for candidato in sorted(candidatos_submodulo):
        raiz = candidato.split(".")[0]
        if raiz not in disponibles:
            continue  # si el paquete padre ni esta disponible, no tiene sentido probar el submodulo
        try:
            if importlib.util.find_spec(candidato) is not None:
                disponibles.append(candidato)
        except (ImportError, ModuleNotFoundError, ValueError):
            pass  # no es un submodulo real (era una funcion/clase/constante) -- se ignora, es lo esperado

    if faltantes:
        print(f"[generar_exe_ide] AVISO: no estan instalados en esta maquina (se omiten del .exe, "
              f"son opcionales de features puntuales): {', '.join(faltantes)}")
    return disponibles


def compilar(nombre_salida="SiPi-IDE", con_consola=False, carpeta_salida=None):
    if not _verificar_pyinstaller():
        print("[generar_exe_ide] PyInstaller no esta instalado. Instalalo con:")
        print("       pip install pyinstaller --break-system-packages")
        raise SystemExit(1)

    carpeta_salida = carpeta_salida or os.path.join(RAIZ_REPO, "dist")
    hidden_imports_externos = _recolectar_hidden_imports()

    with tempfile.TemporaryDirectory(prefix="sipi_ide_build_") as carpeta_build:
        carpeta_protegida = os.path.join(carpeta_build, "src_protegido")
        print("[generar_exe_ide] Paso 1/3: protegiendo el codigo fuente...")
        modulos_propios = []
        for nombre_origen in proteger_codigo._archivos_logica_src():
            ruta_origen = os.path.join(CARPETA_SRC, nombre_origen)
            nombre_out = proteger_codigo.ARCHIVOS_POR_DEFECTO.get(nombre_origen, nombre_origen)
            resultado = proteger_codigo.proteger_archivo(ruta_origen, nombre_out, carpeta_protegida)
            if resultado and nombre_out != "editor_protegido.py":
                modulos_propios.append(os.path.splitext(nombre_out)[0])

        # Datos (iconos) se copian tal cual -- no tienen logica que proteger.
        for nombre in ("iconos_datos.py", "iconos_datos_kids.py"):
            origen = os.path.join(CARPETA_SRC, nombre)
            if os.path.exists(origen):
                shutil.copy2(origen, os.path.join(carpeta_protegida, nombre))
                modulos_propios.append(os.path.splitext(nombre)[0])

        punto_entrada = os.path.join(carpeta_protegida, "editor_protegido.py")
        if not os.path.exists(punto_entrada):
            raise SystemExit("[generar_exe_ide] No se genero 'editor_protegido.py'. Revisar herramientas/proteger_codigo.py.")

        print("[generar_exe_ide] Paso 2/3: armando la lista de dependencias reales...")
        # Este es EL paso critico que antes faltaba. 'editor_protegido.py'
        # (el punto de entrada real) no contiene ni un solo 'import
        # tkinter' en su codigo fuente -- todo esta comprimido dentro del
        # bytecode que ejecuta con exec(). PyInstaller arma la lista de
        # que empaquetar analizando el TEXTO del punto de entrada, asi
        # que sin decirselo a mano no incluye tkinter/sqlite3/etc y el
        # .exe termina crasheando con ModuleNotFoundError en cuanto
        # arranca de verdad. Por eso se lo pasamos explicito aca, mas
        # los modulos propios de SiPi (sipi_protegido, sipi_ia, etc.),
        # que por la misma razon tampoco se detectan solos.
        print(f"[generar_exe_ide]   Dependencias externas: {', '.join(hidden_imports_externos)}")
        print(f"[generar_exe_ide]   Modulos propios de SiPi: {', '.join(modulos_propios)}")

        print("[generar_exe_ide] Paso 3/3: empaquetando con PyInstaller...")
        comando = [
            sys.executable, "-m", "PyInstaller",
            "--onefile",
            "--name", nombre_salida,
            "--distpath", carpeta_salida,
            "--workpath", os.path.join(carpeta_build, "work"),
            "--specpath", carpeta_build,
            "--paths", carpeta_protegida,
            "--noconfirm",
            "--clean",
            "--strip",
        ]
        if not con_consola:
            comando.append("--windowed")

        for modulo in modulos_propios + hidden_imports_externos:
            comando += ["--hidden-import", modulo]

        icono = _icono_windows()
        if icono and os.name == "nt":
            comando += ["--icon", icono]

        if not _hay_upx():
            comando.append("--noupx")
            print("[generar_exe_ide] AVISO: UPX no encontrado en el PATH -- se compila sin el "
                  "(el .exe funciona igual, solo pesa mas y es algo mas facil de inspeccionar).")
        else:
            print("[generar_exe_ide] UPX encontrado -- se comprime el .exe con el (dificulta el "
                  "analisis estatico directo del binario).")

        if _soporte_cifrado_pyinstaller():
            clave = _generar_clave_cifrado()
            comando += ["--key", clave]
            print(f"[generar_exe_ide] Cifrado AES de PyInstaller activado (paquete 'tinyaes' presente). "
                  f"Clave de esta build: {clave}")
            print("[generar_exe_ide] Esa clave queda embebida en el propio .exe (PyInstaller la necesita "
                  "para descifrar en runtime) -- reduce la extraccion trivial con herramientas genericas "
                  "como pyinstxtractor, no la hace imposible para alguien que sepa buscarla en el binario.")
        else:
            import PyInstaller as _pyinstaller_mod
            print(f"[generar_exe_ide] AVISO: sin cifrado extra de PyInstaller -- la version instalada "
                  f"({_pyinstaller_mod.__version__}) le saco esa opcion a partir de la 6.0, o falta "
                  f"'tinyaes'. No es grave: UPX + la ofuscacion de proteger_codigo.py (XOR + rotacion de "
                  f"bits + nombres/trozos al azar) siguen aplicando igual.")

        separador = ";" if os.name == "nt" else ":"
        carpeta_iconos_editor = os.path.join(CARPETA_SRC, "iconos_editor")
        carpeta_iconos_kids = os.path.join(CARPETA_SRC, "iconos_kids")
        ruta_celular = os.path.join(CARPETA_SRC, "sipi_celular.html")
        # Solo datos que NO son codigo Python van por --add-data (los
        # .py van por --paths/--hidden-import de arriba, que es lo que
        # realmente los deja importables desde adentro del .exe).
        if os.path.isdir(carpeta_iconos_editor):
            comando += ["--add-data", f"{carpeta_iconos_editor}{separador}iconos_editor"]
        if os.path.isdir(carpeta_iconos_kids):
            comando += ["--add-data", f"{carpeta_iconos_kids}{separador}iconos_kids"]
        if os.path.exists(ruta_celular):
            comando += ["--add-data", f"{ruta_celular}{separador}."]

        comando.append(punto_entrada)

        resultado = subprocess.run(comando, cwd=carpeta_build)
        if resultado.returncode != 0:
            raise SystemExit("[generar_exe_ide] La compilacion con PyInstaller fallo. Revisa el detalle arriba.")

    extension = ".exe" if os.name == "nt" else ""
    ruta_final = os.path.join(carpeta_salida, nombre_salida + extension)
    print(f"\n[generar_exe_ide] Listo: {ruta_final}")
    print("[generar_exe_ide] Ese archivo es el unico que hace falta subir (a Mediafire u otro hosting).")
    return ruta_final


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--nombre", default="SiPi-IDE", help="Nombre del ejecutable final (sin extension).")
    parser.add_argument("--consola", action="store_true",
                         help="Deja la ventana de consola visible (util para depurar; por default se oculta).")
    parser.add_argument("--salida", metavar="CARPETA", help="Carpeta donde dejar el .exe final (default: dist/).")
    args = parser.parse_args()
    compilar(nombre_salida=args.nombre, con_consola=args.consola, carpeta_salida=args.salida)


if __name__ == "__main__":
    main()
