#!/usr/bin/env python3
"""
proteger_codigo.py - Genera las versiones de distribucion de SiPi que no
llevan el codigo fuente como texto plano.

Vive en herramientas/, separado de src/, sin dependencia de Debian: es
una herramienta de build generica que puede usar cualquier proceso de
empaquetado (deb, instalador de Windows, zip suelto) como paso previo.

Uso:
    python3 herramientas/proteger_codigo.py
        (protege el set por defecto: sipi.py, editor_sipi.py, generar_exe.py)

    python3 herramientas/proteger_codigo.py --archivo src/sipi_cli.py
        (protege un archivo puntual)

    python3 herramientas/proteger_codigo.py --salida /ruta/de/publicacion
        (deja los archivos protegidos en esa carpeta en vez de al lado
        del original)

    python3 herramientas/proteger_codigo.py --publicacion
        (arma la carpeta publicacion/ completa, lista para subir)

Nota de alcance: esto reduce la copia/edicion casual del codigo, no es
una medida criptografica. El mecanismo interno no se documenta en
detalle aca a proposito -- ver AUDITORIA_SEGURIDAD_INTERNA.md (uso
interno, no se publica) para el analisis honesto de que tan resistente
es esto en la practica.
"""
import os
import sys
import argparse
import py_compile
import marshal
import base64
import shutil
import zlib
import hashlib
import random
import string
import importlib.util

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

# Mapeo origen -> nombre de salida. sipi.py / editor_sipi.py / generar_exe.py
# tienen logica de fallback en el resto del proyecto que busca estos
# nombres exactos cuando no encuentra el original.
ARCHIVOS_POR_DEFECTO = {
    "sipi.py": "sipi_protegido.py",
    "editor_sipi.py": "editor_protegido.py",
    "generar_exe.py": "generar_exe_protegido.py",
}

_SAL_FIJA = b"SiPi-NovaLab-2026"


def _clave_derivada(nombre_archivo):
    """Deriva una clave de ofuscacion distinta por archivo (no una unica
    clave global reusada en todos los loaders)."""
    return hashlib.sha256(_SAL_FIJA + nombre_archivo.encode("utf-8")).digest()


def _aplicar_xor(datos, clave):
    return bytes(b ^ clave[i % len(clave)] for i, b in enumerate(datos))


def _rotar_bits(datos, cantidad):
    """Rotacion de bits por byte (ROL). Encadenada con el XOR, hace que
    un volcado hexadecimal del payload no muestre el patron repetitivo
    que delata un XOR simple contra una clave corta."""
    cantidad %= 8
    if cantidad == 0:
        return bytes(datos)
    return bytes(((b << cantidad) | (b >> (8 - cantidad))) & 0xFF for b in datos)


def _desrotar_bits(datos, cantidad):
    cantidad %= 8
    if cantidad == 0:
        return bytes(datos)
    return bytes(((b >> cantidad) | (b << (8 - cantidad))) & 0xFF for b in datos)


def _nombre_variable_aleatorio(generador):
    letras = string.ascii_lowercase
    return "_" + "".join(generador.choice(letras) for _ in range(generador.randint(2, 4)))


PLANTILLA_LOADER = '''\
#!/usr/bin/env python3
import marshal, base64, zlib, hashlib, sys, os

{v_r} = os.path.abspath(__file__)
{v_s} = {sal!r}
{v_n} = {nombre!r}
{v_k} = hashlib.sha256({v_s} + {v_n}.encode("utf-8")).digest()
{v_rot} = {rotacion!r}
{v_chunks} = {chunks!r}
{v_p} = base64.b64decode("".join({v_chunks}))
{v_p} = bytes(((b >> {v_rot}) | (b << (8 - {v_rot}))) & 0xFF for b in {v_p}) if {v_rot} else {v_p}
{v_p} = bytes(b ^ {v_k}[i % len({v_k})] for i, b in enumerate({v_p}))
{v_c} = marshal.loads(zlib.decompress({v_p}))
sys.argv[0] = {v_r}
exec({v_c}, globals())
'''


def _dividir_en_trozos(texto, cantidad_min=3, cantidad_max=6, generador=None):
    """Parte un string base64 en varios trozos de largo variable, para
    que no aparezca como un unico literal largo y reconocible de un
    vistazo (o con un grep simple de 'base64.b64decode(".{200,}")')."""
    generador = generador or random
    n = generador.randint(cantidad_min, cantidad_max)
    largo = len(texto)
    cortes = sorted(generador.sample(range(1, largo), min(n - 1, max(0, largo - 1))))
    trozos = []
    anterior = 0
    for c in cortes:
        trozos.append(texto[anterior:c])
        anterior = c
    trozos.append(texto[anterior:])
    return [t for t in trozos if t]


def _compilar_a_bytecode(ruta_origen):
    """Compila el .py y devuelve el objeto codigo, validando sintaxis
    antes de empaquetar (si el fuente tiene un error, falla aca con un
    mensaje claro en vez de romper silenciosamente el archivo final)."""
    py_compile.compile(ruta_origen, doraise=True)
    with open(ruta_origen, "r", encoding="utf-8") as f:
        fuente = f.read()
    return compile(fuente, os.path.basename(ruta_origen), "exec")


def proteger_archivo(ruta_origen, nombre_salida, carpeta_salida=None):
    if not os.path.exists(ruta_origen):
        print(f"[proteger_codigo] AVISO: no existe '{ruta_origen}', se omite.")
        return None

    codigo = _compilar_a_bytecode(ruta_origen)
    crudo = zlib.compress(marshal.dumps(codigo), level=9)
    clave = _clave_derivada(nombre_salida)

    generador = random.Random()  # semilla aleatoria real, distinta en cada build
    rotacion = generador.randint(1, 7)
    datos_ofuscados = _rotar_bits(_aplicar_xor(crudo, clave), rotacion)
    payload_b64 = base64.b64encode(datos_ofuscados).decode("ascii")
    trozos = _dividir_en_trozos(payload_b64, generador=generador)

    # Nombres de variable distintos en cada archivo generado (y en cada
    # corrida): un grep o una firma fija basada en "_r, _s, _n..." deja
    # de servir para detectar automaticamente todos los loaders a la vez.
    nombres = {}
    usados = set()
    for clave_nombre in ("v_r", "v_s", "v_n", "v_k", "v_rot", "v_chunks", "v_p", "v_c"):
        nombre_var = _nombre_variable_aleatorio(generador)
        while nombre_var in usados:
            nombre_var = _nombre_variable_aleatorio(generador)
        usados.add(nombre_var)
        nombres[clave_nombre] = nombre_var

    carpeta_salida = carpeta_salida or os.path.dirname(ruta_origen)
    os.makedirs(carpeta_salida, exist_ok=True)
    ruta_salida = os.path.join(carpeta_salida, nombre_salida)

    contenido = PLANTILLA_LOADER.format(
        sal=_SAL_FIJA, nombre=nombre_salida, rotacion=rotacion, chunks=trozos, **nombres
    )
    with open(ruta_salida, "w", encoding="utf-8") as f:
        f.write(contenido)

    tamano_kb = os.path.getsize(ruta_salida) / 1024
    print(f"[proteger_codigo] {os.path.basename(ruta_origen)} -> {ruta_salida} ({tamano_kb:.0f} KB)")
    return ruta_salida


def verificar_protegido(ruta_protegido, argumento_prueba=None):
    """Sanity check: importa el archivo generado (sin correr su bloque
    __main__) para confirmar que carga sin errores antes de distribuir.
    No reemplaza probar el programa real."""
    carpeta = os.path.dirname(os.path.abspath(ruta_protegido))
    argv_original = sys.argv
    path_original = list(sys.path)
    sys.argv = [ruta_protegido]
    if carpeta not in sys.path:
        sys.path.insert(0, carpeta)
    try:
        spec = importlib.util.spec_from_file_location("_verificacion_protegido", ruta_protegido)
        modulo = importlib.util.module_from_spec(spec)
        try:
            spec.loader.exec_module(modulo)
        except SystemExit:
            pass  # varios scripts hacen sys.exit() al no recibir argumentos -- esperado
        except Exception as e:
            print(f"[proteger_codigo] ADVERTENCIA: '{ruta_protegido}' fallo al cargarse: {e}")
            return False
        return True
    finally:
        sys.argv = argv_original
        sys.path[:] = path_original


def _copiar_arbol_compatible(origen, destino):
    """Como shutil.copytree(dirs_exist_ok=True) pero compatible con
    Python 3.7 (SiPi apunta a esa version tambien para las herramientas
    de build). Copia recursiva manual, fusionando con lo que ya exista."""
    os.makedirs(destino, exist_ok=True)
    for nombre in os.listdir(origen):
        if nombre in ("__pycache__",) or nombre.endswith(".pyc"):
            continue
        ruta_origen = os.path.join(origen, nombre)
        ruta_destino = os.path.join(destino, nombre)
        if os.path.isdir(ruta_origen):
            _copiar_arbol_compatible(ruta_origen, ruta_destino)
        else:
            shutil.copy2(ruta_origen, ruta_destino)


def _archivos_logica_src():
    """Todos los .py de src/ con logica real (los de puros datos, como
    los iconos en base64, se copian tal cual: protegerlos no aporta y
    solo duplica tamano)."""
    excluidos = {"iconos_datos.py", "iconos_datos_kids.py"}
    if not os.path.isdir(CARPETA_SRC):
        return []
    return sorted(
        f for f in os.listdir(CARPETA_SRC)
        if f.endswith(".py") and f not in excluidos
    )


def preparar_publicacion(carpeta_publicacion=None):
    """Arma una carpeta publicacion/ lista para subir: codigo con logica
    real protegido, mas recursos que no son codigo copiados tal cual
    (iconos, docs, ejemplos, assets de empaquetado, licencia). No toca
    nada de Debian -- eso vive en su propio repositorio."""
    carpeta_publicacion = carpeta_publicacion or os.path.join(RAIZ_REPO, "publicacion")
    carpeta_src_pub = os.path.join(carpeta_publicacion, "src")

    if os.path.isdir(carpeta_publicacion):
        shutil.rmtree(carpeta_publicacion)
    os.makedirs(carpeta_src_pub, exist_ok=True)

    print(f"[preparar_publicacion] Carpeta de salida: {carpeta_publicacion}")
    print("[preparar_publicacion] Protegiendo codigo fuente...")

    generados = []
    for nombre_origen in _archivos_logica_src():
        ruta_origen = os.path.join(CARPETA_SRC, nombre_origen)
        nombre_salida = ARCHIVOS_POR_DEFECTO.get(nombre_origen, nombre_origen)
        resultado = proteger_archivo(ruta_origen, nombre_salida, carpeta_src_pub)
        if resultado:
            generados.append(resultado)

    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_src_pub, nombre))
            print(f"[preparar_publicacion] {nombre} -> copiado tal cual (son datos, no logica)")

    print("\n[preparar_publicacion] Verificando que cada archivo protegido cargue bien...")
    fallos = 0
    for ruta in generados:
        ok = verificar_protegido(ruta)
        print(f"  {'OK' if ok else 'FALLO'} - {os.path.basename(ruta)}")
        if not ok:
            fallos += 1

    print("\n[preparar_publicacion] Copiando recursos que no son codigo...")
    recursos = [
        ("src/iconos_editor", "src/iconos_editor"),
        ("src/iconos_kids", "src/iconos_kids"),
        ("src/sipi_celular.html", "src/sipi_celular.html"),
        ("src/tests", "src/tests"),
        ("docs", "docs"),
        ("examples", "examples"),
        ("pkg-assets", "pkg-assets"),
        ("claude", "claude"),
        ("LICENSE", "LICENSE"),
        ("README.md", "README.md"),
        ("requirements.txt", "requirements.txt"),
    ]
    for origen_rel, destino_rel in recursos:
        origen = os.path.join(RAIZ_REPO, origen_rel)
        destino = os.path.join(carpeta_publicacion, destino_rel)
        if not os.path.exists(origen):
            continue
        if os.path.isdir(origen):
            _copiar_arbol_compatible(origen, destino)
        else:
            os.makedirs(os.path.dirname(destino), exist_ok=True)
            shutil.copy2(origen, destino)
        print(f"[preparar_publicacion] {origen_rel} -> {destino_rel}")

    # Chequeo final: ningun archivo que deberia estar protegido puede
    # quedar en texto plano en la carpeta de publicacion.
    for nombre_origen in _archivos_logica_src():
        ruta_deberia_ser_loader = os.path.join(
            carpeta_src_pub,
            ARCHIVOS_POR_DEFECTO.get(nombre_origen, nombre_origen),
        )
        if os.path.exists(ruta_deberia_ser_loader):
            with open(ruta_deberia_ser_loader, "r", encoding="utf-8", errors="ignore") as f:
                primeras_lineas = f.read(200)
            if "import marshal, base64, zlib" not in primeras_lineas:
                print(f"[preparar_publicacion] ADVERTENCIA: '{ruta_deberia_ser_loader}' "
                      "no parece protegido -- revisar antes de publicar.")

    total = len(generados)
    print(f"\n[preparar_publicacion] Listo: {total} archivo(s) de codigo protegidos, "
          f"{fallos} fallo(s) de verificacion.")

    print("\n[preparar_publicacion] Chequeo final de seguridad (no deberia encontrar nada)...")
    alertas = _chequeo_final_de_secretos(carpeta_publicacion)
    for alerta in alertas:
        print(f"  [ALERTA] {alerta}")
    if not alertas:
        print("  OK - no se encontraron patrones de API keys/tokens/rutas locales.")

    # Limpieza: el paso de verificacion (que importa cada loader para
    # confirmar que carga) deja __pycache__ con el .pyc del propio
    # loader -- no expone nada nuevo (el loader ya es texto plano y el
    # .pyc es de ESE wrapper, no del codigo real descifrado en runtime),
    # pero no tiene sentido distribuirlo.
    for carpeta_actual, subcarpetas, _ in os.walk(carpeta_publicacion):
        if "__pycache__" in subcarpetas:
            shutil.rmtree(os.path.join(carpeta_actual, "__pycache__"), ignore_errors=True)
            subcarpetas.remove("__pycache__")

    print(f"[preparar_publicacion] Carpeta final: {carpeta_publicacion}")
    print("[preparar_publicacion] Esta carpeta no incluye nada de Debian ni de PACKAGING_VINARI.md.")
    return {"ok": fallos == 0, "carpeta": carpeta_publicacion, "generados": total, "fallos": fallos}


def _chequeo_final_de_secretos(carpeta_publicacion):
    """Red de seguridad automatica antes de publicar: recorre los
    archivos de texto de la carpeta ya armada buscando patrones de API
    keys, tokens y rutas absolutas locales. No reemplaza una revision
    humana, pero evita el caso tonto de un secreto que se cuela por un
    commit apurado."""
    import re

    patrones = [
        (re.compile(r"sk-[a-zA-Z0-9]{16,}"), "posible API key (patron 'sk-...')"),
        (re.compile(r"AKIA[0-9A-Z]{12,}"), "posible AWS access key"),
        (re.compile(r"ghp_[a-zA-Z0-9]{20,}"), "posible GitHub token"),
        (re.compile(r"/home/[a-zA-Z0-9_.\-]+/"), "ruta local absoluta tipo /home/usuario/"),
        (re.compile(r"C:\\\\Users\\\\[a-zA-Z0-9_.\-]+"), "ruta local absoluta tipo C:\\Users\\..."),
    ]
    extensiones_texto = {".py", ".md", ".txt", ".json", ".html", ".bat", ".sh", ".cfg", ".ini"}
    alertas = []
    for carpeta_actual, _, archivos in os.walk(carpeta_publicacion):
        for nombre in archivos:
            if os.path.splitext(nombre)[1].lower() not in extensiones_texto:
                continue
            ruta = os.path.join(carpeta_actual, nombre)
            try:
                with open(ruta, "r", encoding="utf-8", errors="ignore") as f:
                    contenido = f.read()
            except OSError:
                continue
            for patron, descripcion in patrones:
                if patron.search(contenido):
                    alertas.append(f"{descripcion} en {os.path.relpath(ruta, carpeta_publicacion)}")
    return alertas


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--archivo", action="append", metavar="RUTA",
                         help="Proteger un archivo puntual (se puede repetir). Si no se pasa, protege el set por defecto.")
    parser.add_argument("--salida", metavar="CARPETA",
                         help="Carpeta donde dejar los archivos protegidos (default: al lado del original).")
    parser.add_argument("--verificar", action="store_true",
                         help="Despues de proteger, hace un sanity-check de carga de cada archivo generado.")
    parser.add_argument("--publicacion", action="store_true",
                         help="Arma la carpeta 'publicacion/' completa, lista para subir. Ignora "
                              "--archivo/--salida si se pasa junto con esta opcion.")
    args = parser.parse_args()

    if args.publicacion:
        resultado = preparar_publicacion(args.salida)
        sys.exit(0 if resultado["ok"] else 1)

    generados = []
    if args.archivo:
        for ruta in args.archivo:
            nombre_salida = os.path.splitext(os.path.basename(ruta))[0] + "_protegido.py"
            resultado = proteger_archivo(ruta, nombre_salida, args.salida)
            if resultado:
                generados.append(resultado)
    else:
        for nombre_origen, nombre_salida in ARCHIVOS_POR_DEFECTO.items():
            ruta_origen = os.path.join(CARPETA_SRC, nombre_origen)
            resultado = proteger_archivo(ruta_origen, nombre_salida, args.salida)
            if resultado:
                generados.append(resultado)

    if args.verificar:
        print("\n[proteger_codigo] Verificando archivos generados...")
        for ruta in generados:
            ok = verificar_protegido(ruta)
            print(f"  {'OK' if ok else 'FALLO'} - {ruta}")

    print(f"\n[proteger_codigo] Listo: {len(generados)} archivo(s) protegido(s).")


if __name__ == "__main__":
    main()
