#!/usr/bin/env python3
"""
generar_exe.py - Compila un programa .sipi a un ejecutable independiente.

Uso: python3 generar_exe.py archivo.sipi

Genera un ejecutable standalone (no necesita Python instalado para
correr) que ejecuta ESE programa .sipi puntual, usando PyInstaller
como motor de empaquetado por debajo. El .sipi se empaqueta como
recurso de datos dentro del ejecutable junto con el motor de SiPi
(sipi.py o sipi_protegido.py, el que este disponible), asi que el
resultado es un binario auto-contenido de verdad.

Referenciado por editor_sipi.py (boton "Compilar a ejecutable") y por
sipi_cli.py -- este archivo es el que ambos invocan por subprocess con
la ruta al .sipi como unico argumento.
"""
import os
import sys
import re
import ast
import shutil
import subprocess
import tempfile

AQUI = os.path.dirname(os.path.abspath(__file__))

BOOTSTRAP_TEMPLATE = '''\
# Generado automaticamente por generar_exe.py -- no editar a mano.
# Bootstrap que corre el motor de SiPi contra el .sipi empaquetado como dato.
import os
import sys
import runpy

def _ruta_recurso(nombre):
    base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
    return os.path.join(base, nombre)

sys.argv = [sys.argv[0], _ruta_recurso("programa.sipi")]
runpy.run_path(_ruta_recurso("{motor}"), run_name="__main__")
'''


def _motor_sipi():
    for nombre in ("sipi.py", "sipi_protegido.py"):
        ruta = os.path.join(AQUI, nombre)
        if os.path.exists(ruta):
            return nombre, ruta
    raise SystemExit("[SiPi] No se encontro 'sipi.py' ni 'sipi_protegido.py' junto a generar_exe.py.")


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


def _instalar_pyinstaller_automaticamente():
    """Antes esto solo avisaba 'instalalo vos con pip install pyinstaller'
    y frenaba -- un paso manual de mas para compilar a ejecutable que no
    tenia por que existir. Ahora lo instala solo, pero de forma
    transparente: dice exactamente que comando va a correr (incluye
    acceso a internet, via pip) ANTES de correrlo, nunca en silencio.
    Se puede evitar con la variable de entorno SIPI_NO_AUTOINSTALAR=1,
    para entornos sin red o donde no se quiera que SiPi instale nada
    por su cuenta."""
    if os.environ.get("SIPI_NO_AUTOINSTALAR"):
        print("[SiPi] PyInstaller no esta instalado y SIPI_NO_AUTOINSTALAR esta activo,")
        print("       asi que no se instala solo. Instalalo vos con:")
        print("       pip install pyinstaller --break-system-packages")
        return False

    comando = [sys.executable, "-m", "pip", "install", "--break-system-packages", "--quiet", "pyinstaller"]
    print("[SiPi] PyInstaller no esta instalado todavia (hace falta para compilar a ejecutable).")
    print("       Instalandolo automaticamente con: " + " ".join(comando))
    print("       (esto va a descargar un paquete de internet; para evitarlo, correr con")
    print("        la variable de entorno SIPI_NO_AUTOINSTALAR=1 e instalarlo vos a mano)")
    resultado = subprocess.run(comando)
    if resultado.returncode != 0:
        # Algunos entornos (Debian/Ubuntu recientes, venvs marcados como
        # "externally managed") rechazan --break-system-packages con pip
        # del sistema; se reintenta sin esa bandera antes de rendirse,
        # por si el entorno actual no la necesita/soporta.
        resultado = subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "pyinstaller"])
    if resultado.returncode != 0:
        print("[SiPi] No se pudo instalar PyInstaller automaticamente. Instalalo vos con:")
        print("       pip install pyinstaller --break-system-packages")
        return False
    print("[SiPi] PyInstaller instalado correctamente.")
    return True


def _descubrir_modulos_importados(ruta_entrada):
    """Bug real que hacia fallar los ejecutables de cualquier programa
    con mas de un archivo: 'generar_exe.py' solo empaquetaba el .sipi
    principal, asi que un programa que usara 'importar "otro.sipi"'
    compilaba sin errores pero se rompia al ABRIR el .exe, con un
    FileNotFoundError buscando el modulo importado (que nunca se habia
    copiado adentro del paquete). Ahora se recorre el arbol completo de
    'importar' (recursivo, siguiendo tambien los import de los propios
    modulos importados) y se devuelve la lista de rutas absolutas a
    empaquetar, cada una con su ruta relativa al archivo de entrada
    (para poder recrear la misma estructura de carpetas adentro del
    ejecutable, que es de donde 'self.base_dir' las va a buscar)."""
    patron = re.compile(r'^\s*importar\s+"([^"]+)"', re.MULTILINE)
    raiz = os.path.dirname(os.path.abspath(ruta_entrada))
    vistos = set()
    fuera_de_raiz = []
    pendientes = [os.path.abspath(ruta_entrada)]

    while pendientes:
        actual = pendientes.pop()
        if actual in vistos or not os.path.exists(actual):
            continue
        vistos.add(actual)
        try:
            with open(actual, "r", encoding="utf-8") as f:
                contenido = f.read()
        except OSError:
            continue
        base_actual = os.path.dirname(actual)
        for nombre_importado in patron.findall(contenido):
            ruta_importada = os.path.abspath(os.path.join(base_actual, nombre_importado))
            if ruta_importada not in vistos:
                pendientes.append(ruta_importada)

    modulos = []
    for ruta in vistos:
        if os.path.abspath(ruta) == os.path.abspath(ruta_entrada):
            continue  # el archivo de entrada ya se copia aparte como "programa.sipi"
        try:
            relativa = os.path.relpath(ruta, raiz)
        except ValueError:
            relativa = os.path.basename(ruta)  # unidades de disco distintas en Windows
        if relativa.startswith(".."):
            # Un 'importar "../algo.sipi"' se sale de la carpeta del
            # programa principal -- no hay forma segura de reconstruir
            # esa estructura relativa adentro del paquete, asi que se
            # avisa en vez de fallar en silencio o armar rutas raras.
            fuera_de_raiz.append(ruta)
            continue
        modulos.append((ruta, relativa))
    return modulos, fuera_de_raiz


def _preparar_icono(carpeta_build):
    """Le pone el logo de SiPi al .exe generado (en vez del icono
    generico de PyInstaller) usando el mismo logo embebido del editor,
    asi el ejecutable final se ve como un programa de verdad y no como
    un script cualquiera. Si Pillow no esta disponible o la conversion
    falla por lo que sea, sigue compilando sin icono -- no es motivo
    para frenar la compilacion."""
    try:
        import iconos_datos
        import base64
        from PIL import Image
        import io

        datos_png = base64.b64decode(iconos_datos.ICONOS_EDITOR_B64["sipi_logo"])
        imagen = Image.open(io.BytesIO(datos_png)).convert("RGBA")
        ruta_ico = os.path.join(carpeta_build, "sipi_programa.ico")
        imagen.save(ruta_ico, format="ICO", sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
        return ruta_ico
    except Exception:
        return None


def _hidden_imports_para(ruta_motor):
    """PyInstaller a veces no detecta automaticamente modulos de la
    biblioteca estandar que son paquetes (con submodulos), como 'html'
    -- se vio en la practica: un ejecutable compilado fallaba en
    tiempo de ejecucion con 'ModuleNotFoundError: No module named
    html' pese a que 'import html' esta en la primera linea del motor
    y compilar no daba ningun error. En vez de mantener a mano una
    lista de que modulos hay que forzar, se leen todos los 'import X'
    de nivel superior del motor real y se pasan TODOS como
    '--hidden-import' explicito: no tiene costo si ya se detectaban
    solos, y evita que aparezcan mas casos como el de 'html' con
    versiones futuras del motor o de PyInstaller."""
    modulos = []
    try:
        with open(ruta_motor, "r", encoding="utf-8") as f:
            arbol = ast.parse(f.read(), filename=ruta_motor)
        for nodo in arbol.body:
            if isinstance(nodo, ast.Import):
                for alias in nodo.names:
                    modulos.append(alias.name)
            elif isinstance(nodo, ast.ImportFrom) and nodo.module and nodo.level == 0:
                modulos.append(nodo.module)
    except Exception:
        pass  # si el parseo falla por lo que sea, se compila igual sin la lista
    return sorted(set(modulos))


def compilar(ruta_sipi):
    if not os.path.exists(ruta_sipi):
        raise SystemExit(f"[SiPi] No se encontro el archivo '{ruta_sipi}'.")

    if not _verificar_pyinstaller():
        if not _instalar_pyinstaller_automaticamente():
            raise SystemExit(1)

    nombre_motor, ruta_motor = _motor_sipi()
    nombre_salida = os.path.splitext(os.path.basename(ruta_sipi))[0]
    modulos_importados, fuera_de_raiz = _descubrir_modulos_importados(ruta_sipi)

    with tempfile.TemporaryDirectory(prefix="sipi_build_") as carpeta_build:
        # Copia el .sipi y el motor a la carpeta de build con nombres fijos,
        # para que el bootstrap los referencie de forma predecible sin
        # importar como se llamaba el archivo original del usuario.
        shutil.copy(ruta_sipi, os.path.join(carpeta_build, "programa.sipi"))
        shutil.copy(ruta_motor, os.path.join(carpeta_build, nombre_motor))

        # Modulos locales que el programa trae con 'importar "..."' --
        # se copian preservando su ruta relativa al archivo principal,
        # que es la misma estructura que 'self.base_dir' va a buscar
        # adentro del ejecutable ya empaquetado.
        for ruta_absoluta, relativa in modulos_importados:
            destino = os.path.join(carpeta_build, "modulos", relativa)
            os.makedirs(os.path.dirname(destino), exist_ok=True)
            shutil.copy(ruta_absoluta, destino)
        if modulos_importados:
            print(f"[SiPi] Empaquetando {len(modulos_importados)} modulo(s) importado(s) ademas del principal:")
            for _, relativa in modulos_importados:
                print(f"       - {relativa}")
        if fuera_de_raiz:
            print("[SiPi] Aviso: los siguientes modulos importados quedan FUERA de la carpeta")
            print("       del programa principal (con '..') y no se pudieron empaquetar --")
            print("       el ejecutable puede fallar si los necesita en tiempo de ejecucion:")
            for ruta in fuera_de_raiz:
                print(f"       - {ruta}")

        ruta_bootstrap = os.path.join(carpeta_build, "_bootstrap_sipi.py")
        with open(ruta_bootstrap, "w", encoding="utf-8") as f:
            f.write(BOOTSTRAP_TEMPLATE.format(motor=nombre_motor))

        ruta_icono = _preparar_icono(carpeta_build)

        separador_datos = ";" if os.name == "nt" else ":"
        comando = [
            sys.executable, "-m", "PyInstaller",
            "--onefile", "--name", nombre_salida,
            "--distpath", os.path.dirname(os.path.abspath(ruta_sipi)) or ".",
            "--workpath", os.path.join(carpeta_build, "work"),
            "--specpath", carpeta_build,
            "--add-data", f"{os.path.join(carpeta_build, 'programa.sipi')}{separador_datos}.",
            "--add-data", f"{os.path.join(carpeta_build, nombre_motor)}{separador_datos}.",
        ]
        for ruta_absoluta, relativa in modulos_importados:
            origen = os.path.join(carpeta_build, "modulos", relativa)
            destino_relativo = os.path.dirname(relativa) or "."
            comando += ["--add-data", f"{origen}{separador_datos}{destino_relativo}"]
        if ruta_icono and os.name == "nt":
            comando += ["--icon", ruta_icono]
        for modulo in _hidden_imports_para(ruta_motor):
            comando += ["--hidden-import", modulo]
        comando.append(ruta_bootstrap)
        print(f"[SiPi] Compilando '{os.path.basename(ruta_sipi)}' a ejecutable independiente...")
        resultado = subprocess.run(comando)
        if resultado.returncode != 0:
            raise SystemExit("[SiPi] La compilacion con PyInstaller fallo. Revisa el detalle arriba.")

    extension = ".exe" if os.name == "nt" else ""
    print(f"[SiPi] Listo: {nombre_salida}{extension}")


def main():
    if len(sys.argv) < 2:
        print("Uso: python3 generar_exe.py archivo.sipi")
        sys.exit(1)
    compilar(sys.argv[1])


if __name__ == "__main__":
    main()
