#!/usr/bin/env python3
"""
sipi_ia.py - SiPi IA: asistente de inteligencia artificial integrado a SiPi.

Objetivo: que programar en SiPi con ayuda de una IA sea mas rapido, mas
abierto y mas facil de depurar. Este modulo es independiente del editor
(se puede usar tambien desde la CLI o por su cuenta) y resuelve tres
tareas concretas:

  1. Generar codigo SiPi a partir de una descripcion en lenguaje natural.
  2. Corregir un programa que tira un error, a partir del codigo + el
     mensaje de error real que devolvio el motor.
  3. Explicar codigo SiPi existente en lenguaje simple.

No depende de ninguna libreria externa (usa unicamente 'urllib' de la
biblioteca estandar) para no agregarle una dependencia obligatoria al
editor. Habla con la API de Anthropic (api.anthropic.com/v1/messages),
usando la clave de API que el usuario configura una sola vez.

Diseño pensado para que sea facil de extender: agregar una nueva tarea
de IA (por ejemplo "optimizar codigo" o "traducir de Python a SiPi") es
solo agregar una funcion nueva que arme el prompt del sistema y llame a
'_llamar_modelo'.
"""
import os
import json
import re
import urllib.request
import urllib.error

MODELO_POR_DEFECTO = "claude-sonnet-4-6"
URL_API = "https://api.anthropic.com/v1/messages"

# ------- Proveedores de IA soportados -------
#
# Antes SiPi IA solo hablaba con la API de Anthropic. Ahora es
# multi-proveedor: cualquiera de estos (o un endpoint compatible con la
# API de OpenAI que el usuario pegue a mano, como un modelo local via
# Ollama/LM Studio) sirve para todo lo demas del modulo -- generar
# codigo, corregir errores, explicar, sesiones conversacionales -- sin
# que el resto del archivo sepa ni le importe cual esta hablando del
# otro lado. Formatos de request/response distintos por proveedor se
# resuelven todos adentro de '_llamar_modelo'.
PROVEEDORES_IA = {
    "anthropic": {
        "nombre": "Anthropic (Claude)",
        "modelo_por_defecto": "claude-sonnet-4-6",
        "url": "https://api.anthropic.com/v1/messages",
        "ayuda_clave": "Empieza con 'sk-ant-'. Se consigue en console.anthropic.com",
    },
    "openai": {
        "nombre": "OpenAI (GPT)",
        "modelo_por_defecto": "gpt-4o",
        "url": "https://api.openai.com/v1/chat/completions",
        "ayuda_clave": "Empieza con 'sk-'. Se consigue en platform.openai.com",
    },
    "google": {
        "nombre": "Google (Gemini)",
        "modelo_por_defecto": "gemini-2.0-flash",
        "url": "https://generativelanguage.googleapis.com/v1beta/models/{modelo}:generateContent",
        "ayuda_clave": "Se consigue gratis en aistudio.google.com/apikey",
    },
    "openrouter": {
        "nombre": "OpenRouter (cualquier modelo)",
        "modelo_por_defecto": "anthropic/claude-sonnet-4",
        "url": "https://openrouter.ai/api/v1/chat/completions",
        "ayuda_clave": "Un solo login da acceso a decenas de modelos. openrouter.ai/keys",
    },
    "personalizado": {
        "nombre": "Otra API compatible con OpenAI (local, propia, etc.)",
        "modelo_por_defecto": "",
        "url": "",  # el usuario tiene que pegar la URL del endpoint /chat/completions
        "ayuda_clave": "Sirve para modelos locales (Ollama, LM Studio) o cualquier "
                        "servidor que hable el mismo formato que la API de OpenAI.",
    },
}
PROVEEDOR_POR_DEFECTO = "anthropic"


def _carpeta_config_usuario():
    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:
        pass
    return carpeta


ARCHIVO_CONFIG_IA = os.path.join(_carpeta_config_usuario(), "sipi_ia_config.json")


class ErrorSiPiIA(Exception):
    """Errores propios de SiPi IA (clave faltante, fallo de red, respuesta
    invalida), separados de excepciones genericas para que quien llame
    (editor, CLI) pueda mostrar un mensaje claro sin adivinar la causa."""


# ------- Configuracion (clave de API, modelo) -------

def cargar_config():
    if os.path.exists(ARCHIVO_CONFIG_IA):
        try:
            with open(ARCHIVO_CONFIG_IA, "r", encoding="utf-8") as f:
                return json.load(f)
        except (json.JSONDecodeError, OSError):
            return {}
    return {}


def guardar_config(config):
    try:
        with open(ARCHIVO_CONFIG_IA, "w", encoding="utf-8") as f:
            json.dump(config, f, ensure_ascii=False, indent=2)
        try:
            os.chmod(ARCHIVO_CONFIG_IA, 0o600)  # la clave de API no deberia ser legible por otros usuarios
        except OSError:
            pass
    except OSError as e:
        raise ErrorSiPiIA(f"No se pudo guardar la configuracion de SiPi IA: {e}")


def obtener_api_key():
    """Busca la clave de API en, en orden: variable de entorno
    ANTHROPIC_API_KEY (retrocompatibilidad con como funcionaba antes,
    cuando solo existia Anthropic), luego la clave guardada para el
    proveedor actualmente elegido en el archivo de configuracion."""
    clave = os.environ.get("ANTHROPIC_API_KEY")
    if clave and obtener_proveedor() == "anthropic":
        return clave
    config = cargar_config()
    claves = config.get("claves_por_proveedor", {})
    clave_guardada = claves.get(obtener_proveedor())
    if clave_guardada:
        return clave_guardada
    # Retrocompatibilidad: configuraciones viejas (de antes de que
    # existiera multi-proveedor) guardaban la clave suelta en "api_key".
    return config.get("api_key") or clave


def guardar_api_key(clave, proveedor=None):
    proveedor = proveedor or obtener_proveedor()
    config = cargar_config()
    claves = config.setdefault("claves_por_proveedor", {})
    claves[proveedor] = clave.strip()
    config["proveedor"] = proveedor
    config.pop("api_key", None)  # limpia el formato viejo de una sola clave, ya migrado arriba
    guardar_config(config)


def obtener_proveedor():
    return cargar_config().get("proveedor", PROVEEDOR_POR_DEFECTO)


def obtener_modelo():
    """El modelo se guarda POR PROVEEDOR (igual que la clave) -- si no,
    elegir 'gpt-4o-mini' en OpenAI y despues cambiar a Anthropic hacia
    que SiPi IA tratara de usar ese mismo nombre de modelo contra la
    API de Anthropic, donde ese modelo no existe."""
    config = cargar_config()
    proveedor = obtener_proveedor()
    modelo_guardado = config.get("modelos_por_proveedor", {}).get(proveedor)
    return modelo_guardado or PROVEEDORES_IA.get(proveedor, {}).get("modelo_por_defecto", "")


def obtener_url_personalizada():
    return cargar_config().get("url_personalizada", "")


def guardar_proveedor(proveedor, modelo=None, url_personalizada=None):
    if proveedor not in PROVEEDORES_IA:
        raise ErrorSiPiIA(f"Proveedor de IA desconocido: '{proveedor}'.")
    config = cargar_config()
    config["proveedor"] = proveedor
    if modelo is not None:
        modelos = config.setdefault("modelos_por_proveedor", {})
        if modelo.strip():
            modelos[proveedor] = modelo.strip()
        else:
            modelos.pop(proveedor, None)  # vacio = "usar el modelo por defecto de este proveedor"
    config.pop("modelo", None)  # limpia el formato viejo (un solo modelo global), ya migrado arriba
    if url_personalizada is not None:
        config["url_personalizada"] = url_personalizada.strip()
    guardar_config(config)


def hay_api_key_configurada():
    return bool(obtener_api_key())


# ------- Documentacion breve del lenguaje, para darle contexto real al modelo -------

RESUMEN_LENGUAJE_SIPI = """
SiPi es un lenguaje de programacion en español. Sintaxis basica:

- Todo programa empieza con: programa "Nombre del programa"
- Variables: variable nombre = valor   (tipos: numero, texto entre
  comillas, booleano verdadero/falso, listas [a, b, c])
- Mostrar en pantalla: decir "texto con {variable} interpolada"
- Pedir datos: variable x = preguntar "Mensaje: "
- Condicionales (SIN 'entonces', el cierre es 'fin' a secas, NUNCA
  'fin si'):
    si condicion
        ...
    sino
        ...
    fin
- Bucle repetir N veces (cierre 'fin' a secas, NUNCA 'fin repetir'):
    repetir 5 veces
        ...
    fin
- Bucle mientras (SIN 'hace', cierre 'fin' a secas):
    mientras condicion
        ...
    fin
- Recorrer listas (el comando es 'para_cada' CON GUION BAJO, no 'para cada'):
    para_cada elemento en milista
        ...
    fin
- Funciones (cierre 'fin' a secas, NUNCA 'fin funcion'):
    funcion nombre(param1, param2)
        ...
        devolver valor
    fin
- Manejo de errores (cierre 'fin' a secas, NUNCA 'fin intentar'):
    intentar
        ...
    capturar error
        decir "Ocurrio: {error}"
    fin
- Programacion orientada a objetos: clase, hereda_de, metodo, nuevo,
  es_instancia_de. Instanciar y llamar metodos usan flecha de
  asignacion, NO 'variable x = ...':
    nuevo Perro() -> rex
    llamar_metodo rex "hacer_sonido"() -> resultado
- Comandos built-in que devuelven un valor usan flecha de asignacion
  ('comando argumentos -> variable'), NO 'variable = comando(...)':
  azar_entre 1 10 -> numero, leer_archivo "ruta" -> contenido,
  diccionario_crear -> dic
- REGLA GENERAL DE CIERRE: todo bloque (si/sino, repetir, mientras,
  para_cada, funcion, intentar/capturar, clase) cierra con la palabra
  'fin' SOLA. Nunca 'fin' seguido de otra palabra (nada de 'fin si',
  'fin funcion', 'fin repetir', etc. -- esas formas NO son validas).
- Operadores de comparacion habituales (==, !=, <, >, <=, >=) y logicos
  (y, o, no).
""".strip()


def _referencia_completa_lenguaje():
    """Ademas del resumen fijo de arriba (que se queda, porque insiste
    en las reglas de sintaxis que el modelo mas tiende a romper: 'fin'
    solo, flecha de asignacion), se intenta armar un catalogo COMPLETO
    de comandos leyendo 'AYUDA_COMANDOS' directo del motor real
    (sipi.py) -- la misma fuente que usa el comando ':ayuda' del
    editor. Asi la IA conoce los +100 comandos reales del lenguaje
    (hilos, vectores, bases de datos, API web, criptografia, etc.) con
    su sintaxis exacta, en vez de solo lo basico -- y si algun dia se
    agrega un comando nuevo al motor, SiPi IA lo sabe automaticamente
    sin que haya que tocar este archivo. Si por lo que sea no se puede
    importar sipi.py (falta el archivo, un error de sintaxis mientras
    se edita el motor, etc.), se sigue funcionando solo con el resumen
    fijo de arriba -- nunca es motivo para romper SiPi IA."""
    try:
        import sipi as _motor
        ayuda = getattr(_motor, "AYUDA_COMANDOS", None)
        if not ayuda:
            return ""
        lineas = ["\nCatalogo completo de comandos disponibles en SiPi (ademas de lo basico de arriba):"]
        for nombre, info in sorted(ayuda.items()):
            if not isinstance(info, (tuple, list)) or len(info) < 2:
                continue
            descripcion, ejemplo = info[0], info[1]
            ejemplo_una_linea = " / ".join(l.strip() for l in str(ejemplo).splitlines() if l.strip())
            lineas.append(f"- {nombre}: {descripcion} Ejemplo: {ejemplo_una_linea}")
        return "\n".join(lineas)
    except Exception:
        return ""


_REFERENCIA_COMPLETA_CACHEADA = None


def _referencia_lenguaje_para_prompt():
    """Cachea el catalogo completo (no cambia durante la corrida del
    programa) para no re-importar sipi.py en cada pedido a la IA."""
    global _REFERENCIA_COMPLETA_CACHEADA
    if _REFERENCIA_COMPLETA_CACHEADA is None:
        _REFERENCIA_COMPLETA_CACHEADA = RESUMEN_LENGUAJE_SIPI + (_referencia_completa_lenguaje() or "")
    return _REFERENCIA_COMPLETA_CACHEADA


def _llamar_modelo(mensajes, system, max_tokens=2000):
    """Punto unico de contacto con la IA para TODO el modulo (y para
    sipi_ia_agente.py, que lo llama directo). 'mensajes' es una lista
    [{'role': 'user'|'assistant', 'content': str}, ...] en el formato
    de Anthropic; para los demas proveedores se traduce internamente,
    asi el resto del codigo (generar_codigo, corregir_error, SesionIA,
    el agente multi-archivo, etc.) no necesita saber ni les importa que
    proveedor esta contestando del otro lado."""
    clave = obtener_api_key()
    if not clave:
        raise ErrorSiPiIA(
            "No hay ninguna clave de API configurada para SiPi IA. "
            "Configurala desde el boton 'SiPi IA' del editor, o con la "
            "variable de entorno ANTHROPIC_API_KEY."
        )
    proveedor = obtener_proveedor()
    modelo = obtener_modelo() or PROVEEDORES_IA.get(proveedor, {}).get("modelo_por_defecto", "")

    if proveedor == "anthropic":
        return _llamar_anthropic(clave, modelo, mensajes, system, max_tokens)
    if proveedor == "google":
        return _llamar_google(clave, modelo, mensajes, system, max_tokens)
    if proveedor in ("openai", "openrouter", "personalizado"):
        url = obtener_url_personalizada() if proveedor == "personalizado" else PROVEEDORES_IA[proveedor]["url"]
        if proveedor == "personalizado" and not url:
            raise ErrorSiPiIA(
                "Elegiste 'Otra API compatible con OpenAI' pero no configuraste la URL "
                "del endpoint. Configurala desde el boton 'SiPi IA' del editor."
            )
        return _llamar_estilo_openai(clave, modelo, url, mensajes, system, max_tokens, proveedor)
    raise ErrorSiPiIA(f"Proveedor de IA desconocido: '{proveedor}'.")


def _peticion_json(url, cuerpo, headers, timeout=60):
    """Envoltorio comun para las tres variantes de HTTP de abajo: arma
    el Request, lo manda, y traduce los errores de red/HTTP a
    ErrorSiPiIA con un mensaje que tiene sentido para quien programa en
    SiPi (no un traceback crudo de urllib)."""
    peticion = urllib.request.Request(
        url, data=json.dumps(cuerpo).encode("utf-8"), method="POST", headers=headers)
    try:
        with urllib.request.urlopen(peticion, timeout=timeout) as respuesta:
            return json.loads(respuesta.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        cuerpo_error = e.read().decode("utf-8", errors="ignore")
        if e.code == 401 or e.code == 403:
            raise ErrorSiPiIA(
                f"La clave de API no es valida (error {e.code}). Revisala en 'SiPi IA' "
                "-- clic derecho sobre el boton para volver a pegarla.")
        if e.code == 404:
            raise ErrorSiPiIA(
                f"La API respondio 404 (no encontrado). Revisa que la URL y el nombre "
                f"del modelo esten bien escritos. Detalle: {cuerpo_error[:200]}")
        if e.code == 429:
            raise ErrorSiPiIA("Se alcanzo el limite de pedidos de la API (error 429). Esperá un momento y probá de nuevo.")
        raise ErrorSiPiIA(f"La API respondio con un error ({e.code}): {cuerpo_error[:300]}")
    except urllib.error.URLError as e:
        raise ErrorSiPiIA(f"No se pudo conectar con la API de IA: {e.reason}")
    except (TimeoutError, OSError) as e:
        raise ErrorSiPiIA(f"Se agoto el tiempo de espera hablando con la IA: {e}")


def _llamar_anthropic(clave, modelo, mensajes, system, max_tokens):
    datos = _peticion_json(
        URL_API,
        {"model": modelo or MODELO_POR_DEFECTO, "max_tokens": max_tokens, "system": system, "messages": mensajes},
        {"Content-Type": "application/json", "x-api-key": clave, "anthropic-version": "2023-06-01"},
    )
    try:
        bloques_texto = [b["text"] for b in datos.get("content", []) if b.get("type") == "text"]
        return "\n".join(bloques_texto).strip()
    except (KeyError, TypeError):
        raise ErrorSiPiIA("La respuesta de la API de Anthropic no tuvo el formato esperado.")


def _llamar_estilo_openai(clave, modelo, url, mensajes, system, max_tokens, proveedor):
    """Sirve para OpenAI, OpenRouter, y cualquier endpoint 'personalizado'
    que hable el mismo formato (Ollama y LM Studio en modo compatible
    con OpenAI incluidos) -- los tres comparten el mismo esquema de
    request/response de /chat/completions."""
    mensajes_openai = [{"role": "system", "content": system}] + [
        {"role": m["role"], "content": m["content"]} for m in mensajes
    ]
    cabeceras = {"Content-Type": "application/json", "Authorization": f"Bearer {clave}"}
    if proveedor == "openrouter":
        # OpenRouter pide estos dos headers extra para atribuir el uso
        # -- son opcionales para que la API funcione, pero sin ellos
        # OpenRouter a veces limita mas agresivo por IP anonima.
        cabeceras["HTTP-Referer"] = "https://sipi-lenguaje.local"
        cabeceras["X-Title"] = "SiPi IA"
    datos = _peticion_json(
        url,
        {"model": modelo, "max_tokens": max_tokens, "messages": mensajes_openai},
        cabeceras,
    )
    try:
        return datos["choices"][0]["message"]["content"].strip()
    except (KeyError, IndexError, TypeError):
        raise ErrorSiPiIA("La respuesta de la API no tuvo el formato esperado (se esperaba 'choices[0].message.content').")


def _llamar_google(clave, modelo, mensajes, system, max_tokens):
    """La API de Gemini tiene su propio formato (roles 'user'/'model' en
    vez de 'user'/'assistant', 'parts' en vez de 'content' plano, y la
    instruccion de sistema va aparte en 'systemInstruction')."""
    contenidos = [
        {"role": ("model" if m["role"] == "assistant" else "user"), "parts": [{"text": m["content"]}]}
        for m in mensajes
    ]
    url = PROVEEDORES_IA["google"]["url"].format(modelo=modelo or PROVEEDORES_IA["google"]["modelo_por_defecto"])
    datos = _peticion_json(
        url,
        {
            "contents": contenidos,
            "systemInstruction": {"parts": [{"text": system}]},
            "generationConfig": {"maxOutputTokens": max_tokens},
        },
        {"Content-Type": "application/json", "x-goog-api-key": clave},
    )
    try:
        partes = datos["candidates"][0]["content"]["parts"]
        return "".join(p.get("text", "") for p in partes).strip()
    except (KeyError, IndexError, TypeError):
        if datos.get("candidates") and datos["candidates"][0].get("finishReason") == "MAX_TOKENS":
            raise ErrorSiPiIA("Gemini corto la respuesta por limite de tokens antes de terminar. Probá con un pedido mas chico.")
        raise ErrorSiPiIA("La respuesta de la API de Gemini no tuvo el formato esperado.")


def _extraer_bloque_codigo(texto):
    """Si la respuesta del modelo trae un bloque ```sipi ... ``` (o ```
    a secas), devuelve solo ese codigo. Si no hay bloque, devuelve el
    texto completo -- asi la funcion sirve tanto para 'insertar en el
    editor' como para mostrar una explicacion en prosa."""
    coincidencia = re.search(r"```(?:sipi)?\n(.*?)```", texto, re.DOTALL)
    if coincidencia:
        return coincidencia.group(1).strip()
    return texto.strip()


# ------- Tareas de IA -------

def generar_codigo(descripcion, codigo_actual=None):
    """Genera un programa SiPi nuevo (o una extension del actual) a
    partir de una descripcion en lenguaje natural. Devuelve codigo SiPi
    listo para insertar en el editor."""
    system = (
        "Sos SiPi IA, un asistente experto en el lenguaje de programacion "
        "SiPi (en español). Tu unica tarea es escribir codigo SiPi "
        "correcto, claro y bien indentado a partir de lo que te piden. "
        "Respondé SIEMPRE con un unico bloque de codigo ```sipi ... ``` "
        "y, como maximo, una linea de explicacion antes del bloque. No "
        "inventes comandos que no existen en SiPi.\n\n"
        f"Referencia del lenguaje:\n{_referencia_lenguaje_para_prompt()}"
    )
    contenido = descripcion
    if codigo_actual and codigo_actual.strip():
        contenido = (
            f"Este es el codigo SiPi que ya tengo en el editor:\n```sipi\n{codigo_actual}\n```\n\n"
            f"Lo que necesito: {descripcion}"
        )
    respuesta = _llamar_modelo([{"role": "user", "content": contenido}], system)
    return _extraer_bloque_codigo(respuesta)


def corregir_error(codigo, mensaje_error):
    """Recibe un programa SiPi + el error real que devolvio el motor al
    ejecutarlo, y devuelve el codigo corregido."""
    system = (
        "Sos SiPi IA, un asistente experto en depurar codigo del lenguaje "
        "SiPi (en español). Te van a pasar un programa y el error real "
        "que tiro al ejecutarse. Encontra la causa, corregila, y "
        "respondé con el programa COMPLETO ya corregido en un unico "
        "bloque ```sipi ... ```. Antes del bloque, en una o dos lineas, "
        "explicá brevemente cual era el bug.\n\n"
        f"Referencia del lenguaje:\n{_referencia_lenguaje_para_prompt()}"
    )
    contenido = (
        f"Codigo:\n```sipi\n{codigo}\n```\n\n"
        f"Error al ejecutarlo:\n{mensaje_error}"
    )
    respuesta = _llamar_modelo([{"role": "user", "content": contenido}], system)
    return respuesta  # se devuelve completo (explicacion + bloque) para mostrarlo tal cual


def explicar_codigo(codigo):
    """Explica en lenguaje simple que hace un programa SiPi -- pensado
    para quien esta aprendiendo o para entender codigo de otra persona."""
    system = (
        "Sos SiPi IA. Te pasan un programa en el lenguaje SiPi y tenes "
        "que explicar en español simple, paso a paso, que hace. No "
        "repitas el codigo linea por linea de forma mecanica: contá la "
        "logica general y despues los detalles que valga la pena "
        "aclarar. Se breve y concreto.\n\n"
        f"Referencia del lenguaje:\n{_referencia_lenguaje_para_prompt()}"
    )
    respuesta = _llamar_modelo([{"role": "user", "content": f"```sipi\n{codigo}\n```"}], system)
    return respuesta.strip()


def completar_codigo(codigo_antes, instruccion):
    """Continua/completa codigo SiPi a partir de lo que ya esta escrito,
    siguiendo una instruccion puntual (p. ej. 'agregale validacion de
    que el numero no sea negativo'). Pensado para 'programar mas
    rapido': tomar lo que el usuario ya escribio y ampliarlo sin que
    tenga que reescribir todo de cero."""
    system = (
        "Sos SiPi IA. Vas a recibir codigo SiPi incompleto o parcial, "
        "mas una instruccion de que agregar o completar. Devolvé el "
        "PROGRAMA COMPLETO resultante (lo que ya habia + lo nuevo, "
        "integrado prolijamente) en un unico bloque ```sipi ... ```, sin "
        "explicacion adicional.\n\n"
        f"Referencia del lenguaje:\n{_referencia_lenguaje_para_prompt()}"
    )
    contenido = f"Codigo actual:\n```sipi\n{codigo_antes}\n```\n\nInstruccion: {instruccion}"
    respuesta = _llamar_modelo([{"role": "user", "content": contenido}], system)
    return _extraer_bloque_codigo(respuesta)


# ------- Sistema de auto-correccion (escribir -> ejecutar -> verificar -> corregir) -------
#
# Este es el sistema mas ambicioso de SiPi IA: en vez de generar codigo
# una sola vez y confiar en que este bien, lo ESCRIBE, lo EJECUTA de
# verdad contra el motor real de SiPi (sipi.py, el mismo que usa el
# editor), lee el error real si lo hay, y le pide al modelo que lo
# corrija -- repitiendo el ciclo hasta que el programa corra limpio o
# se agote el limite de intentos. Es el mismo patron que Mateo ya usa
# en PiCi (write -> execute -> fix), aplicado nativamente a SiPi.
#
# No depende del editor: funciona standalone (CLI, o llamado desde
# cualquier otro script), siempre y cuando se le pase la ruta al motor
# real de sipi.py.

class ResultadoAutoCorreccion:
    """Resultado de una corrida del loop de auto-correccion. 'intentos'
    guarda el historial completo (codigo probado + resultado de cada
    intento) para que quien llame pueda mostrar el proceso completo, no
    solo el resultado final -- util para que el usuario entienda que
    la IA no acerto a la primera y vea que fue corrigiendo."""

    def __init__(self, exito, codigo_final, intentos, motivo_fin):
        self.exito = exito
        self.codigo_final = codigo_final
        self.intentos = intentos  # lista de dicts: {codigo, salida, error, ok}
        self.motivo_fin = motivo_fin


def _ejecutar_codigo_real(ruta_motor_sipi, codigo, timeout=8):
    """Escribe 'codigo' a un archivo temporal y lo corre de verdad con
    el motor real de SiPi (subprocess, sin importar el modulo en el
    mismo proceso, para que un error de ejecucion del programa generado
    no pueda tumbar el proceso de SiPi IA). Devuelve (ok, stdout, stderr)."""
    import subprocess
    import tempfile
    with tempfile.NamedTemporaryFile(
        mode="w", suffix=".sipi", delete=False, encoding="utf-8"
    ) as f:
        f.write(codigo)
        ruta_temp = f.name
    try:
        import os as _os
        resultado = subprocess.run(
            [__import__("sys").executable, ruta_motor_sipi, ruta_temp],
            capture_output=True, text=True, timeout=timeout,
            encoding="utf-8", errors="replace",
            env={**_os.environ, "PYTHONIOENCODING": "utf-8"},
        )
        ok = resultado.returncode == 0 and not resultado.stderr.strip()
        return ok, resultado.stdout, resultado.stderr
    except subprocess.TimeoutExpired:
        # Un timeout no siempre es un bug (puede ser un 'preguntar' que
        # espera entrada estandar que nunca llega en modo automatico) --
        # se trata como "no concluyente", no como fallo directo, salvo
        # que sea la unica señal disponible.
        return None, "", "(tiempo de espera agotado -- el programa puede estar esperando una entrada de teclado con 'preguntar')"
    finally:
        try:
            os.remove(ruta_temp)
        except OSError:
            pass


def generar_con_auto_correccion(descripcion, ruta_motor_sipi, codigo_inicial=None,
                                 max_intentos=4, on_intento=None):
    """Ciclo completo escribir -> ejecutar -> verificar -> corregir.

    - descripcion: que se quiere lograr (se usa para el primer intento
      si no hay codigo_inicial).
    - ruta_motor_sipi: ruta absoluta a sipi.py, para poder ejecutar cada
      intento de verdad y no solo confiar en lo que devuelve el modelo.
    - codigo_inicial: si ya hay un programa en el editor, se corrige
      sobre eso en vez de arrancar de cero.
    - max_intentos: tope de vueltas del ciclo (generar/corregir + ejecutar).
    - on_intento: callback opcional on_intento(numero, codigo, ok, salida, error)
      para que quien llame (por ejemplo el panel del editor) pueda
      mostrar el progreso en vivo, intento por intento.

    Devuelve un ResultadoAutoCorreccion.
    """
    intentos = []

    codigo = codigo_inicial if codigo_inicial and codigo_inicial.strip() else generar_codigo(descripcion)

    for numero in range(1, max_intentos + 1):
        ok, salida, error = _ejecutar_codigo_real(ruta_motor_sipi, codigo)
        registro = {"codigo": codigo, "salida": salida, "error": error, "ok": ok}
        intentos.append(registro)
        if on_intento:
            try:
                on_intento(numero, codigo, ok, salida, error)
            except Exception:
                pass  # un callback que falla no debe tumbar el loop de correccion

        if ok:
            return ResultadoAutoCorreccion(True, codigo, intentos, "El programa corrio sin errores.")

        if ok is None:
            # No concluyente (posible 'preguntar' esperando entrada) --
            # no tiene sentido seguir corrigiendo un programa que no
            # esta roto, asi que se corta aca y se devuelve tal cual.
            return ResultadoAutoCorreccion(
                True, codigo, intentos,
                "El programa parece estar esperando una entrada de teclado (comando 'preguntar'); "
                "no se puede verificar en automatico, pero no se detecto ningun error real."
            )

        if numero == max_intentos:
            break

        try:
            respuesta = corregir_error(codigo, error or salida or "Error desconocido")
            codigo = _extraer_bloque_codigo(respuesta)
        except ErrorSiPiIA as e:
            return ResultadoAutoCorreccion(False, codigo, intentos, f"Se corto por un error de SiPi IA: {e}")

    return ResultadoAutoCorreccion(
        False, codigo, intentos,
        f"Se llego al limite de {max_intentos} intentos sin lograr una ejecucion limpia."
    )


# ------- Sesiones conversacionales (refinamiento iterativo con memoria real) -------
#
# Todo lo de arriba resuelve pedidos de un solo turno: descripcion ->
# codigo. Pero programar de verdad casi siempre es iterativo: "hacelo",
# despues "ahora agregale esto", despues "che, cambia el nombre de esa
# variable", despues "no me gusto, volve a la version anterior". Una
# SesionIA mantiene el HISTORIAL REAL de la conversacion (no solo el
# ultimo codigo) para que cada pedido nuevo tenga el contexto completo
# de lo que se discutio antes -- igual que hablar con un programador
# que se acuerda de toda la conversacion, no solo de la ultima linea.
#
# Tambien guarda un historial de versiones del codigo (no solo de
# mensajes), para poder volver atras sin perder el trabajo.

class SesionIA:
    """Conversacion con memoria para refinar un programa SiPi en varias
    vueltas. Uso tipico:

        sesion = SesionIA()
        codigo1 = sesion.pedir("un programa que calcule el IVA de un precio")
        codigo2 = sesion.pedir("ahora que redondee a 2 decimales")
        codigo3 = sesion.pedir("agregale que tambien calcule el descuento")
        sesion.deshacer()  # vuelve a codigo2
    """

    MAX_MENSAJES_EN_CONTEXTO = 20  # tope para no mandar una conversacion infinita en cada pedido

    def __init__(self, ruta_motor_sipi=None, system_extra=""):
        self.mensajes = []  # historial real de la conversacion (role/content)
        self.historial_codigo = []  # cada version de codigo generada, en orden
        self.ruta_motor_sipi = ruta_motor_sipi
        self.system_extra = system_extra

    def _system(self):
        base = (
            "Sos SiPi IA, asistente experto en el lenguaje SiPi (programacion en "
            "español), trabajando en una conversacion continua con el usuario para "
            "refinar un programa a lo largo de varios pedidos. Tene en cuenta TODO "
            "lo que se discutio antes en la conversacion -- si el usuario dice "
            "'ahora agregale X' o 'cambiale el nombre a Y', se refiere al ULTIMO "
            "codigo que generaste, no a un programa nuevo. Respondé siempre con el "
            "PROGRAMA COMPLETO actualizado (no un fragmento) en un unico bloque "
            "```sipi ... ```, con como maximo una linea de explicacion antes.\n\n"
            f"Referencia del lenguaje:\n{_referencia_lenguaje_para_prompt()}"
        )
        if self.system_extra:
            base += f"\n\n{self.system_extra}"
        return base

    def pedir(self, mensaje, auto_verificar=False):
        """Manda un mensaje nuevo con todo el historial de contexto.
        Si auto_verificar=True y hay ruta_motor_sipi configurada,
        ejecuta el resultado y si falla le pide una correccion mas
        antes de devolverlo (una vuelta extra, no un loop completo)."""
        self.mensajes.append({"role": "user", "content": mensaje})
        contexto = self.mensajes[-self.MAX_MENSAJES_EN_CONTEXTO:]
        respuesta = _llamar_modelo(contexto, self._system(), max_tokens=2500)
        self.mensajes.append({"role": "assistant", "content": respuesta})
        codigo = _extraer_bloque_codigo(respuesta)

        if auto_verificar and self.ruta_motor_sipi:
            ok, salida, error = _ejecutar_codigo_real(self.ruta_motor_sipi, codigo)
            if ok is False:
                correccion = self.pedir(
                    f"Ese ultimo codigo tiro este error al ejecutarlo: {error}\n"
                    "Corregilo y devolveme el programa completo ya arreglado."
                )
                codigo = correccion

        self.historial_codigo.append(codigo)
        return codigo

    def deshacer(self):
        """Vuelve al codigo de la version anterior (sin borrar el
        historial de mensajes, para que la IA siga sabiendo que paso,
        incluido que el usuario decidio volver atras)."""
        if len(self.historial_codigo) < 2:
            return self.historial_codigo[-1] if self.historial_codigo else None
        self.historial_codigo.pop()
        anterior = self.historial_codigo[-1]
        self.mensajes.append({
            "role": "user",
            "content": "(El usuario decidio volver a la version anterior del codigo, descartando el ultimo cambio.)",
        })
        return anterior

    def codigo_actual(self):
        return self.historial_codigo[-1] if self.historial_codigo else None

    def exportar(self):
        """Serializa la sesion completa (mensajes + versiones) para
        poder guardarla en disco y continuar despues -- una sesion de
        refinamiento no tiene por que perderse al cerrar el editor."""
        return {"mensajes": self.mensajes, "historial_codigo": self.historial_codigo}

    @classmethod
    def desde_exportado(cls, datos, ruta_motor_sipi=None, system_extra=""):
        sesion = cls(ruta_motor_sipi=ruta_motor_sipi, system_extra=system_extra)
        sesion.mensajes = datos.get("mensajes", [])
        sesion.historial_codigo = datos.get("historial_codigo", [])
        return sesion


if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("Uso: python3 sipi_ia.py \"descripcion del programa que queres\"")
        sys.exit(1)
    if not hay_api_key_configurada():
        print("No hay clave de API configurada. Definí ANTHROPIC_API_KEY o usá el editor.")
        sys.exit(1)
    try:
        print(generar_codigo(" ".join(sys.argv[1:])))
    except ErrorSiPiIA as e:
        print(f"[SiPi IA] Error: {e}")
        sys.exit(1)
