#!/usr/bin/env python3
"""
typst-ia: Genera presentaciones Typst con IA vía OpenRouter.

Uso:
  typst-ia "Tema de la presentación"
  typst-ia "Historia de Linux" --slides 10
  typst-ia "Comandos Git" --slides 8 --model "anthropic/claude-3.5-sonnet"
  typst-ia "Python async" --slides 10 --output python-async

Dependencias:
  pip install requests

Requiere Typst instalado:
  sudo apt install typst       # Debian/Ubuntu
  sudo pacman -S typst         # Arch
"""

import argparse
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path

import requests


# ── Constantes ──────────────────────────────────────────────────────────────

API_KEY = os.environ.get("OPENROUTER_API_KEY")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "deepseek/deepseek-chat"

# Directorio donde está este script
SCRIPT_DIR = Path(__file__).resolve().parent
TEMPLATE_FILE = SCRIPT_DIR / "template" / "slydst.typ"
OUTPUT_DIR = SCRIPT_DIR


# ── Funciones auxiliares ────────────────────────────────────────────────────

def leer_template():
    """Lee el template slydst.typ del directorio template/"""
    if not TEMPLATE_FILE.exists():
        raise FileNotFoundError(
            f"No se encuentra el template en {TEMPLATE_FILE}\n"
            f"Asegúrate de que existe template/slydst.typ junto al script"
        )
    return TEMPLATE_FILE.read_text()


def escapar_typst_string(texto):
    """Escapa caracteres especiales en strings Typst (comillas, saltos de línea)"""
    return texto.replace('"', '\\"').replace('\n', ' ')


def construir_system_prompt(tema, n_slides, template_content):
    """
    Construye el array de mensajes para OpenRouter.
    El system prompt incluye el template real para que la IA conozca
    las capacidades de la plantilla.
    """
    return [
        {
            "role": "system",
            "content": (
                "Eres un experto en crear presentaciones con Typst.\n"
                "Tu tarea es generar el contenido de una presentación en formato Typst.\n\n"
                "ESTRUCTURA:\n"
                "- `= Título` → heading level 1 (portada automática)\n"
                "- `== Título` → heading level 2 (nueva página, slide de contenido)\n"
                "- Listas con `- ` (bullet) o `+ ` (numerada)\n"
                "- Código con ```lenguaje ... ``` (tres backticks)\n"
                "- Tablas con #table(columns: ..., ...)\n"
                "- Imágenes con #image(\"images/placeholder.png\", width: 70%)\n"
                "- Dos columnas con #columns(2, gutter: 12pt)[ ... #colbreak() ... ]\n"
                "- Énfasis con _cursiva_ (guión bajo). NO uses * para nada — ni énfasis, ni comodines, ni wildcards (como `test_*`).\n\n"
                "REGLAS ESTRICTAS:\n"
                "1. NO incluyas '#import' — el script lo añade automáticamente\n"
                "2. NO incluyas '#show: slides.with(...)' — el script lo añade\n"
                "3. El primer heading DEBE ser `= ` (portada del tema) — la respuesta "
                "debe EMPEZAR directamente con `= `, sin texto ni comentarios previos\n"
                "4. Usa SIEMPRE `==` para slides de contenido\n"
                "5. NO uses caracteres como $, \\, # fuera de contexto Typst\n"
                "6. No envuelvas el código en bloques markdown (```)\n"
                "7. Máximo 6-8 bullet points por slide\n"
                "8. Incluye variedad: bullets, código, tablas según el tema\n"
                "9. NO uses NUNCA el carácter `*` en ningún contexto — ni para énfasis, ni para comodines (como `test_*`). En Typst `*` abre énfasis/itálica y rompe la compilación. Usa `_cursiva_` para énfasis y `- ` para viñetas.\n"
                "TEMPLATE DISPONIBLE:\n"
                f"{template_content}\n\n"
                f"Genera exactamente {n_slides} slides para el tema \"{tema}\".\n"
                "Incluye al menos un bloque de código y una tabla si el tema lo permite."
            ),
        },
        {
            "role": "user",
            "content": f"Tema: {tema}\nNúmero de slides: {n_slides}",
        },
    ]


def llamar_openrouter(messages, model):
    """
    Llama a OpenRouter API con reintentos ante rate limit, timeout o error de conexión.
    Devuelve el texto generado por la IA.
    """
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "HTTP-Referer": "https://atareao.es",
        "X-OpenRouter-Title": "typst-ia",
    }

    for intento in range(3):
        try:
            resp = requests.post(
                API_URL,
                headers=headers,
                json={
                    "model": model,
                    "messages": messages,
                    "temperature": 0.7,
                    "max_tokens": 4096,
                },
                timeout=120,
            )
            resp.raise_for_status()
            return resp.json()["choices"][0]["message"]["content"]

        except requests.exceptions.HTTPError as e:
            if resp.status_code == 429:
                wait = int(resp.headers.get("Retry-After", 10))
                print(f"  ⏳ Rate limit. Esperando {wait}s...")
                time.sleep(wait)
                continue
            elif resp.status_code == 401:
                sys.exit("❌ API Key inválida. Configura OPENROUTER_API_KEY")
            elif resp.status_code == 402:
                sys.exit("❌ Saldo insuficiente en OpenRouter. Añade créditos en openrouter.ai")
            else:
                sys.exit(f"❌ Error HTTP {resp.status_code}: {e}")

        except requests.exceptions.Timeout:
            if intento < 2:
                print(f"  ⏳ Timeout. Reintentando ({intento + 1}/3)...")
                time.sleep(2 ** intento)
                continue
            sys.exit("❌ Timeout tras 3 intentos. Prueba otro modelo o más tarde.")

        except requests.exceptions.ConnectionError:
            if intento < 2:
                print(f"  ⚠️  Error de conexión. Reintentando ({intento + 1}/3)...")
                time.sleep(2 ** intento)
                continue
            sys.exit("❌ Error de conexión con OpenRouter. Revisa tu conexión a Internet.")


def normalizar_listas(linea):
    """
    Convierte una línea de lista con `*` (que Typst interpreta como énfasis/itálica)
    en `-` (viñeta correcta de Typst).

    Solo afecta a líneas donde el asterisco va seguido de espacio o está al final
    de la línea (lista), NO a *texto* inline ni a **negrita** (estilo Markdown).
    """
    m = re.match(r'^(\s*)\*($|\s)', linea)
    if m and not linea.strip().startswith('**'):
        prefix = m.group(1)
        if m.group(2) == ' ':
            # * seguido de espacio: mantener el espacio
            return prefix + '- ' + linea[m.end():]
        # * al final de línea
        return prefix + '-'
    return linea


def convertir_negrita(linea):
    """
    Convierte **texto** (estilo Markdown) en _texto_ (strong de Typst).
    En Typst ** no es válido y genera el warning 'no text within stars'.
    """
    return re.sub(r'\*\*([^*\n]+)\*\*', r'_\1_', linea)


def escapar_asteriscos(linea):
    """
    Escapa asteriscos sueltos de una línea (fuera de bloques de código)
    que Typst interpretaría como énfasis y romperían la compilación.

    Convierte * a \\* para que Typst los muestre literalmente.
    Es un safety net para cuando la IA ignora la regla de no usar *.
    """
    # No tocar headings (= o ==): ahí * se usa para énfasis válido
    if linea.strip().startswith('='):
        return linea
    # No tocar asteriscos ya escapados
    return re.sub(r'(?<!\\)\*', '\\\\*', linea)


def limpiar_respuesta(texto):
    """
    Limpia la respuesta de la IA:
      - Conserva bloques de código ```lang ... ``` como raw blocks Typst válidos
        (y los cierra si la IA los dejó abiertos)
      - Quita líneas de #import o #show: slides.with (fuera de código)
      - Fuera de código: normaliza listas (* → -), convierte **x** → _x_,
        y escapa asteriscos sueltos
    """
    # ── Pasada 1: separar bloques de código, quitar imports ──
    lineas = []
    en_codigo = False
    for linea in texto.split('\n'):
        stripped = linea.strip()
        # Fence de apertura o cierre de bloque de código
        if stripped.startswith('```'):
            en_codigo = not en_codigo
            lineas.append(linea)
            continue
        if en_codigo:
            # Contenido de código: se conserva tal cual
            lineas.append(linea)
            continue
        if stripped.startswith('#import') or stripped.startswith('#show: slides.with'):
            continue
        lineas.append(linea)

    texto = '\n'.join(lineas)
    # Si quedó un bloque sin cerrar, cerrarlo (evita 'unclosed raw text')
    if en_codigo:
        texto += '\n```'

    # ── Pasada 2: procesar solo líneas fuera de bloques de código ──
    resultado = []
    en_codigo = False
    for linea in texto.split('\n'):
        stripped = linea.strip()
        if stripped.startswith('```'):
            en_codigo = not en_codigo
            resultado.append(linea)
            continue
        if en_codigo:
            resultado.append(linea)
            continue
        # Fuera de código: normalizar, negrita y escapar asteriscos
        linea = normalizar_listas(linea)
        linea = convertir_negrita(linea)
        linea = escapar_asteriscos(linea)
        resultado.append(linea)
    return '\n'.join(resultado)


def generar_archivo_typ(tema, contenido_typ, output_path, background="fondo-matrix-hd.jpg"):
    """
    Genera el archivo .typ completo con:
      - Import del template
      - Show rule con slides.with()
      - Configuración visual (raw, text, heading)
      - Contenido generado por la IA
    """
    with open(output_path, "w") as f:
        f.write('#import "template/slydst.typ": *\n')
        f.write('\n')
        f.write('#show: slides.with(\n')
        f.write(f'  title: "{escapar_typst_string(tema)}",\n')
        f.write('  subtitle: "Generado con IA",\n')
        f.write(f'  comment: "Generado con typst-ia",\n')
        f.write(f'  authors: "Lorenzo Carbonell <a.k.a atareao>",\n')
        f.write('  layout: "large",\n')
        f.write('  ratio: 16 / 9,\n')
        f.write(f'  background: "{background}",\n')
        f.write(')\n')
        f.write('\n')
        f.write('#show raw: set block(fill: rgb("1d2433"), width: 100%, inset: 0.6em)\n')
        f.write('#set text(size: 10pt)\n')
        f.write('#show heading: set text(size: 11pt)\n')
        f.write('\n')
        f.write(contenido_typ)
        f.write('\n')

    print(f"  📄 Archivo .typ guardado: {output_path}")


def compilar_typst(typ_path, pdf_path):
    """Compila .typ a PDF con typst compile. Devuelve True si éxito."""
    result = subprocess.run(
        ["typst", "compile", "--root", ".", str(typ_path), str(pdf_path)],
        capture_output=True,
        text=True,
        cwd=typ_path.parent,
    )

    if result.returncode != 0:
        print(f"  ❌ Error de compilación Typst:")
        print(f"     {result.stderr[:600]}")
        return False

    print(f"  ✅ PDF generado: {pdf_path}")
    return True


def compilar_con_reintentos(typ_path, pdf_path, messages, model, max_intentos=2):
    """
    Compila y si falla, pide a la IA que corrija el error.
    Útil porque la IA no siempre genera Typst 100% válido a la primera.

    A diferencia de la versión anterior, cada intento usa mensajes de corrección
    FRESCOS para evitar acumulación de correcciones anteriores.
    """
    for intento in range(max_intentos + 1):
        contenido_actual = typ_path.read_text()

        result = subprocess.run(
            ["typst", "compile", "--root", ".", str(typ_path), str(pdf_path)],
            capture_output=True,
            text=True,
            cwd=typ_path.parent,
        )
        if result.returncode == 0:
            print(f"  ✅ PDF generado: {pdf_path}")
            return True
        if intento == max_intentos:
            print(f"  ❌ Error tras {max_intentos} correcciones:")
            print(f"     {result.stderr[:500]}")
            return False

        error_msg = result.stderr[:1000]
        print(f"  ⚠️  Error de compilación. Pidiendo corrección (intento {intento + 1}/{max_intentos})...")

        # Crear mensajes de corrección FRESCOS por intento (evitar acumulación)
        correccion_msgs = list(messages) + [
            {"role": "assistant", "content": contenido_actual},
            {
                "role": "user",
                "content": (
                    f"El código Typst tiene este error de compilación:\n"
                    f"{error_msg}\n\n"
                    "Corrige SOLO el error. Devuelve TODO el contenido corregido, "
                    "sin el #import ni el #show, solo el contenido de las slides."
                ),
            },
        ]

        contenido_corregido = llamar_openrouter(correccion_msgs, model)
        if contenido_corregido is None:
            print("  ❌ La IA no devolvió una corrección válida.")
            return False
        contenido_corregido = limpiar_respuesta(contenido_corregido)

        # Reescribir el .typ: preámbulo original + contenido corregido
        preambulo = ""
        for linea in contenido_actual.split("\n"):
            preambulo += linea + "\n"
            if linea.strip().startswith("#show heading:"):
                break
        typ_path.write_text(preambulo + contenido_corregido + "\n")

    return False


def abrir_pdf(pdf_path):
    """Abre el PDF con el visor por defecto (zathura > xdg-open)"""
    if pdf_path.exists():
        # Intentar zathura primero
        if subprocess.run(["which", "zathura"], capture_output=True).returncode == 0:
            subprocess.Popen(["zathura", str(pdf_path)])
            return
        # Fallback a xdg-open
        subprocess.run(["xdg-open", str(pdf_path)], check=False)


# ── Punto de entrada ────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="typst-ia: Genera presentaciones Typst con IA",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""\
Ejemplos:
  typst-ia "Historia de Linux"
  typst-ia "Comandos Git" --slides 8
  typst-ia "Arquitectura de contenedores" --slides 12 --model "anthropic/claude-3.5-sonnet"
  typst-ia "Python async/await" --slides 10 --output python-async
        """,
    )
    parser.add_argument("tema", help="Tema de la presentación (entre comillas si tiene espacios)")
    parser.add_argument(
        "--slides", type=int, default=10,
        help="Número de slides a generar (default: 10)"
    )
    parser.add_argument(
        "--model", default=DEFAULT_MODEL,
        help=f"Modelo en OpenRouter (default: {DEFAULT_MODEL})"
    )
    parser.add_argument(
        "--output", "-o",
        help="Nombre del archivo de salida (sin extensión). Por defecto: slug del tema"
    )
    parser.add_argument(
        "--retries", type=int, default=0,
        help="Intentos de corrección automática si falla la compilación (default: 0)"
    )
    parser.add_argument(
        "--no-open", action="store_true",
        help="No abrir el PDF automáticamente al terminar"
    )
    parser.add_argument(
        "--background", default="fondo-matrix-hd.jpg",
        help="Imagen de fondo en images/ (default: fondo-matrix-hd.jpg)"
    )

    args = parser.parse_args()

    # ── Validaciones ────────────────────────────────────────────────────

    if not API_KEY:
        sys.exit(
            "❌ Configura OPENROUTER_API_KEY como variable de entorno\n"
            "   export OPENROUTER_API_KEY=\"sk-or-v1-tu-api-key-aqui\""
        )

    # ── Slug para nombres de archivo ────────────────────────────────────

    if args.output:
        slug = args.output
    else:
        slug = re.sub(r'[^a-z0-9]+', '-', args.tema.lower()).strip('-')[:50]

    typ_path = OUTPUT_DIR / f"{slug}.typ"
    pdf_path = OUTPUT_DIR / f"{slug}.pdf"

    # ── Inicio ──────────────────────────────────────────────────────────

    print(f"🚀  typst-ia — Generando presentación")
    print(f"    Tema:   \"{args.tema}\"")
    print(f"    Slides: {args.slides}")
    print(f"    Modelo: {args.model}")
    print()

    # 1. Leer el template
    print("📖  Leyendo template...")
    try:
        template = leer_template()
    except FileNotFoundError as e:
        sys.exit(f"❌ {e}")
    print(f"    Template: {TEMPLATE_FILE.name} ({len(template)} caracteres)")
    print()

    # 2. Construir prompt y llamar a OpenRouter
    print(f"🤖  Llamando a OpenRouter ({args.model})...")
    print("    (esto puede tardar unos segundos)")
    messages = construir_system_prompt(args.tema, args.slides, template)
    sys.stdout.flush()
    contenido = llamar_openrouter(messages, args.model)
    if contenido is None:
        sys.exit("❌ No se pudo generar contenido tras varios intentos.")
    contenido = limpiar_respuesta(contenido)
    print(f"    Recibidos ~{len(contenido.split())} tokens / ~{len(contenido.split(chr(10)))} líneas")
    print()

    # 3. Guardar archivo .typ
    print("📝  Generando archivo Typst...")
    generar_archivo_typ(args.tema, contenido, typ_path, background=args.background)
    print()

    # 4. Compilar a PDF
    print("⚙️   Compilando a PDF...")
    if args.retries > 0:
        exito = compilar_con_reintentos(typ_path, pdf_path, messages, args.model, args.retries)
    else:
        exito = compilar_typst(typ_path, pdf_path)

    if not exito:
        print()
        print("🔍  Contenido generado (primeros 600 caracteres):")
        print("    " + "-" * 50)
        print(contenido[:600])
        print("    " + "-" * 50)
        print()
        print("💡  Sugerencias:")
        print("    - Revisa el archivo .typ y corrige errores manualmente")
        print("    - Ejecuta: typst compile --root . " + str(typ_path) + " " + str(pdf_path))
        print("    - Usa --retries 2 para que la IA intente corregir automáticamente")
        sys.exit(1)

    print()

    # 5. Abrir PDF
    if not args.no_open:
        print("📂  Abriendo presentación...")
        abrir_pdf(pdf_path)

    print(f"✨  ¡Listo! Presentación generada: {pdf_path}")


if __name__ == "__main__":
    main()