#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# (la línea de arriba es para que un Python 2 pueda leer este archivo hasta el
#  chequeo de versión de más abajo, en vez de morir con un SyntaxError por las
#  tildes; sin ella, quien no programa ve un error incomprensible)
"""Instala el aviso de contexto en la configuración de Claude Code de esta máquina.

    python3 instalar.py              # instala
    python3 instalar.py --quitar     # lo saca
    python3 instalar.py --umbral 400 # instala avisando a los 400k en vez de 250k
    sudo python3 instalar.py --imponer   # ADEMAS obliga a compactar en el umbral

Qué hace: copia `avisar-contexto.py` a ~/.claude/hooks/ y agrega el hook de
UserPromptSubmit a ~/.claude/settings.json **sin tocar el resto del archivo** —
permisos, tema y todo lo demás quedan igual. Funciona en Mac, Linux y Windows.

Después de instalar hay que abrir `/hooks` una vez (o reiniciar Claude Code) para
que la sesión que ya está corriendo lo cargue.

Con `--imponer` (necesita sudo, y sólo en Mac y Linux) además escribe la configuración
impuesta del sistema, que fija `autoCompactWindow`: al llegar al umbral, Claude Code
resume la conversación solo. El usuario no puede subir ese valor desde su configuración.

La diferencia importa: **el aviso avisa; lo impuesto obliga.** Se puede tener sólo el
aviso —es lo normal— y agregar lo impuesto a quien se sigue topando con el límite
después de avisado. Compactar resume la conversación, así que no es gratis: se pierde
detalle. Por eso no viene encendido por defecto.
"""

import glob
import json
import os
import shutil
import subprocess
import sys

# La consola de Windows suele venir en cp1252, donde los emojis de los avisos (⚠️, 💡)
# no existen: al imprimirlos Python corta con UnicodeEncodeError y el instalador muere
# a mitad de camino, sin decir por qué. Se pide que reemplace lo que no pueda escribir
# en vez de fallar — las tildes sí están en cp1252, así que el texto en español se lee
# igual y sólo los emojis salen como signo de pregunta.
# (Lo reportó María el 13-ago-2026: tuvo que correrlo con PYTHONIOENCODING=utf-8.)
def _salida_tolerante():
    """Hace que stdout y stderr reemplacen lo que no puedan escribir, en vez de cortar."""
    import io
    for nombre in ("stdout", "stderr"):
        canal = getattr(sys, nombre)
        try:
            canal.reconfigure(errors="replace")
            continue
        except (AttributeError, ValueError):
            pass
        # Python 3.6 no tiene reconfigure. Sin este envoltorio el arreglo no hacía
        # nada justo en la versión más vieja que decimos soportar, que es donde más
        # probable es encontrarse una consola en cp1252.
        try:
            setattr(sys, nombre, io.TextIOWrapper(
                canal.buffer, encoding=canal.encoding,
                errors="replace", line_buffering=True))
        except Exception:
            pass


_salida_tolerante()

# Los mensajes de error de acá los va a leer gente que no programa (muchas veces a
# través de Claude, que se los repite). Tienen que decir QUÉ HACER, no qué falló.
#
# ⚠️ Para que este chequeo llegue a ejecutarse, TODO el archivo tiene que ser
# parseable por el Python viejo que lo corrió: Python lee el archivo entero antes
# de ejecutar la primera línea. Por eso acá no se usa nada de sintaxis 3.6+ —
# nada de f-strings ni de literales como `250_000`—, aunque el resto del proyecto
# sí pueda. Un `SyntaxError` crudo no le dice nada a quien no programa.
if sys.version_info < (3, 6):
    print("ERROR: esto necesita Python 3.6 o más nuevo.")
    print("Esta máquina lo corrió con Python {}.{}.".format(*sys.version_info[:2]))
    print("Probá con `python3 instalar.py` en vez de `python instalar.py`.")
    print("Si no tenés Python 3, pedile a Claude que te lo instale.")
    sys.exit(1)

MARCA = "avisar-contexto.py"  # así se reconoce nuestro hook entre los demás
ORIGEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "avisar-contexto.py")
DESTINO_DIR = os.path.join(os.path.expanduser("~"), ".claude", "hooks")
DESTINO = os.path.join(DESTINO_DIR, "avisar-contexto.py")
SETTINGS = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")


def leer_settings():
    if not os.path.exists(SETTINGS):
        return {}
    with open(SETTINGS, encoding="utf-8") as f:
        contenido = f.read().strip()
    if not contenido:
        return {}
    try:
        return json.loads(contenido)
    except ValueError as e:
        print("ERROR: {} no es JSON válido ({}).".format(SETTINGS, e))
        print("Un settings.json roto desactiva TODA la configuración. Arreglalo antes.")
        sys.exit(1)


def escribir_settings(datos):
    respaldo = SETTINGS + ".antes-de-claude-costo"
    # La carpeta puede no existir todavía: pasa al desinstalar en una máquina donde
    # nunca se instaló nada. Antes eso reventaba con un FileNotFoundError crudo.
    try:
        os.makedirs(os.path.dirname(SETTINGS), exist_ok=True)
    except OSError as e:
        print("ERROR: no se pudo crear {} ({}).".format(os.path.dirname(SETTINGS), e))
        sys.exit(1)
    if os.path.exists(SETTINGS) and not os.path.exists(respaldo):
        shutil.copy2(SETTINGS, respaldo)
        print("Respaldo del anterior en: {}".format(respaldo))
    with open(SETTINGS, "w", encoding="utf-8") as f:
        json.dump(datos, f, indent=2, ensure_ascii=False)
        f.write("\n")


def es_nuestro(hook):
    """¿Este hook es el nuestro? Mira el comando y sus argumentos.

    Con la forma `args` el script no va en `command` (ahí va el intérprete), así
    que buscar sólo en `command` dejaría instalaciones viejas sin desinstalar.
    """
    return MARCA in str(hook.get("command", "")) + str(hook.get("args", ""))


def sacar_el_nuestro(settings):
    """Quita cualquier instalación previa de este hook, deje o no otros hooks."""
    grupos = (settings.get("hooks") or {}).get("UserPromptSubmit") or []
    limpios = []
    for grupo in grupos:
        quedan = [h for h in grupo.get("hooks", []) if not es_nuestro(h)]
        if quedan:
            nuevo = dict(grupo)
            nuevo["hooks"] = quedan
            limpios.append(nuevo)
    if limpios:
        settings.setdefault("hooks", {})["UserPromptSubmit"] = limpios
    elif (settings.get("hooks") or {}).get("UserPromptSubmit") is not None:
        del settings["hooks"]["UserPromptSubmit"]
        if not settings["hooks"]:
            del settings["hooks"]
    return settings


def interprete():
    """Ruta absoluta y estable del Python con el que va a correr el hook.

    `sys.executable` apunta a la instalación concreta —en Homebrew, algo como
    .../python@3.13/3.13.14_1/...— y esa ruta desaparece en la próxima
    actualización, dejando el hook muerto sin que nadie se entere. El `python3`
    del PATH es un enlace estable que sobrevive las actualizaciones, así que se
    prefiere ése siempre que sea el mismo Python (misma versión mayor.menor).
    """
    ruta = shutil.which("python3")
    if ruta:
        try:
            salida = subprocess.check_output(
                [ruta, "-c", "import sys;print('%d.%d' % sys.version_info[:2])"],
                stderr=subprocess.DEVNULL,
            )
            if salida.decode().strip() == "{}.{}".format(*sys.version_info[:2]):
                return os.path.abspath(ruta)
        except (OSError, subprocess.CalledProcessError):
            pass
    return sys.executable


def autoprueba(argv):
    """Corre el hook recién instalado contra una transcripción real.

    Instalar sin comprobar deja exactamente el problema que el hook viene a
    resolver: algo que parece hecho y no lo está.
    """
    transcripciones = sorted(
        glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")),
        key=os.path.getsize,
        reverse=True,
    )
    if not transcripciones:
        # Le pasa a quien recién empieza a usar Claude Code. No es una falla, y el
        # mensaje tiene que dejarlo claro: quien lo lea puede no ser técnico.
        print("\nQuedó instalado. Todavía no hay conversaciones guardadas en esta")
        print("máquina, así que no se pudo mostrar el aviso de ejemplo — va a")
        print("aparecer solo, cuando una ventana acumule bastante.")
        return
    # El id cambia en cada corrida: el hook no repite un aviso ya dado, y con un id
    # fijo la segunda instalación no mostraría nada y parecería rota.
    entrada = json.dumps(
        {
            "session_id": "autoprueba-{}".format(os.getpid()),
            "transcript_path": transcripciones[0],
        }
    )
    try:
        proc = subprocess.run(
            # Sin shell, y con el mismo intérprete y script que se acaban de
            # guardar: se prueba exactamente lo que Claude Code va a ejecutar.
            # El umbral en 1 va solo (sin el que haya pedido quien instala) para
            # que dispare seguro y se pruebe el camino completo: leer la
            # transcripción y redactar el aviso, no sólo que el proceso arranque.
            [argv[0], argv[1], "--umbral", "1"], input=entrada.encode(),
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20,
        )
    except (OSError, subprocess.SubprocessError) as e:
        print("\n⚠️  El hook quedó escrito pero NO corre: {}".format(e))
        return
    if proc.returncode != 0:
        print("\n⚠️  El hook quedó escrito pero devolvió error {}: {}".format(
            proc.returncode, proc.stderr.decode()[:200]))
        return
    salida = proc.stdout.decode().strip()
    if salida:
        try:
            mensaje = json.loads(salida)["systemMessage"]
            print("\nProbado contra una sesión real. Así se va a ver el aviso:\n")
            print("   " + mensaje.replace("\n", "\n   "))
            return
        except (ValueError, KeyError):
            pass
    print("\n⚠️  El hook corrió pero no produjo el aviso esperado. Revisalo.")


# --- Configuración impuesta del sistema (opcional, con --imponer) --------------

def ruta_impuesta():
    """Dónde van los ajustes impuestos, según el sistema. None si no aplica."""
    import platform
    s = platform.system()
    if s == "Darwin":
        return "/Library/Application Support/ClaudeCode/managed-settings.json"
    if s == "Linux":
        return "/etc/claude-code/managed-settings.json"
    return None  # en Windows sería C:\\Program Files\\ClaudeCode, sin probar todavía


# Las tres claves que este instalador pone en los ajustes impuestos. Se listan acá
# para que instalar y desinstalar no puedan desincronizarse.
CLAVES_IMPUESTAS = ("autoCompactWindow", "autoCompactEnabled")
VAR_IMPUESTA = "CLAUDE_CODE_AUTO_COMPACT_WINDOW"


def leer_json(ruta):
    """Devuelve (datos, error). `datos` es un dict; error es un texto o None."""
    try:
        with open(ruta, encoding="utf-8") as f:
            datos = json.load(f)
    except ValueError:
        return None, "no es JSON válido"
    except (IOError, OSError) as e:
        return None, "no se pudo leer ({})".format(e)
    if not isinstance(datos, dict):
        return None, "no contiene un objeto JSON sino un {}".format(type(datos).__name__)
    return datos, None


def imponer(umbral, quitar=False):
    """Escribe (o saca) la configuración impuesta que fija la compactación automática.

    Sin `autoCompactWindow` configurado, Claude Code NO compacta hasta llegar al límite
    del modelo — un millón de tokens en Opus 5 —, y mientras tanto cada mensaje paga por
    toda la conversación anterior. Esto le pone techo sin que nadie tenga que acordarse.

    Al instalar se guarda **una sola vez** una copia del archivo original en
    `.antes-de-claude-costo`. Desinstalar no borra estas claves a ciegas: mira esa copia
    y **restaura el valor que la organización tuviera antes**, si tenía alguno. Sin eso,
    un ciclo instalar/desinstalar se llevaría en silencio una política de compactación
    que ya existía — y el `.bak`, que se reescribe en cada corrida, no serviría para
    recuperarla.
    """
    destino = ruta_impuesta()
    if destino is None:
        print("\n⚠️  --imponer todavía no está probado en este sistema; no se hizo nada.")
        print("    El aviso sí quedó instalado y funciona igual.")
        return
    if os.geteuid() != 0:
        print("\n⚠️  --imponer necesita sudo y no lo tiene; no se escribió nada.")
        print("    El aviso sí quedó instalado. Para agregar lo impuesto:")
        print("        sudo python3 {} --imponer".format(os.path.basename(__file__)))
        return

    original = destino + ".antes-de-claude-costo"

    if quitar:
        # Simétrico con la instalación: al instalar se funde con lo que hubiera, así
        # que al quitar se saca SÓLO lo propio. Llevarse el archivo entero se llevaría
        # también cualquier política de la empresa que viva ahí.
        if not os.path.exists(destino):
            return
        previo, error = leer_json(destino)
        if error:
            print("\n⚠️  El {} de esta máquina {}.".format(destino, error))
            print("    No se tocó, para no empeorarlo.")
            return

        # Lo que la organización tenía ANTES de que esto se instalara. Si no hay copia,
        # se asume que no había nada propio de compactación y las claves salen.
        antes = {}
        if os.path.exists(original):
            antes, error = leer_json(original)
            if error:
                print("\n⚠️  El respaldo {} {}.".format(original, error))
                print("    No se tocó nada: sin él no se puede saber qué restaurar.")
                return

        try:
            shutil.copy2(destino, destino + ".bak")
        except (IOError, OSError) as e:
            print("\n⚠️  No se pudo respaldar {} ({}). No se tocó nada.".format(destino, e))
            return

        for clave in CLAVES_IMPUESTAS:
            if clave in antes:
                previo[clave] = antes[clave]  # era de la organización: se devuelve
            else:
                previo.pop(clave, None)
        env_antes = antes.get("env") if isinstance(antes.get("env"), dict) else {}
        if isinstance(previo.get("env"), dict):
            if VAR_IMPUESTA in env_antes:
                previo["env"][VAR_IMPUESTA] = env_antes[VAR_IMPUESTA]
            else:
                previo["env"].pop(VAR_IMPUESTA, None)
            if not previo["env"]:
                previo.pop("env")

        restaurado = any(c in antes for c in CLAVES_IMPUESTAS) or VAR_IMPUESTA in env_antes
        try:
            if previo:
                with open(destino, "w", encoding="utf-8") as f:
                    json.dump(previo, f, indent=2, ensure_ascii=False)
                    f.write("\n")
                if restaurado:
                    print("Se restauró la compactación que había antes; el resto de {} "
                          "quedó igual.".format(destino))
                else:
                    print("Se quitó la compactación impuesta; el resto de {} quedó igual."
                          .format(destino))
            else:
                os.remove(destino)
                print("Configuración impuesta quitada (no quedaba nada más en el archivo).")
        except (IOError, OSError) as e:
            print("\n⚠️  No se pudo escribir {} ({}). Está el respaldo en {}.bak"
                  .format(destino, e, destino))
        return

    # No pisar a ciegas: si ya había ajustes impuestos, se conservan los demás.
    previo = {}
    if os.path.exists(destino):
        previo, error = leer_json(destino)
        if error:
            print("\n⚠️  El {} de esta máquina {}.".format(destino, error))
            print("    No se tocó, para no empeorarlo. Arreglalo y volvé a correr esto.")
            return
        try:
            shutil.copy2(destino, destino + ".bak")
            # Una sola vez: esta copia es el estado ORIGINAL, el que permite devolver
            # las cosas como estaban. El `.bak` se pisa en cada corrida y no sirve.
            if not os.path.exists(original):
                shutil.copy2(destino, original)
        except (IOError, OSError) as e:
            print("\n⚠️  No se pudo respaldar {} ({}). No se tocó nada.".format(destino, e))
            return

    previo["autoCompactWindow"] = umbral
    previo["autoCompactEnabled"] = True
    if not isinstance(previo.get("env"), dict):
        previo["env"] = {}
    previo["env"][VAR_IMPUESTA] = str(umbral)

    try:
        os.makedirs(os.path.dirname(destino), exist_ok=True)
        with open(destino, "w", encoding="utf-8") as f:
            json.dump(previo, f, indent=2, ensure_ascii=False)
            f.write("\n")
        os.chmod(destino, 0o644)
    except (IOError, OSError) as e:
        print("\n⚠️  No se pudo escribir {} ({}).".format(destino, e))
        print("    El aviso sí quedó instalado y funciona igual.")
        return
    print("\nAdemás quedó IMPUESTO en {}:".format(destino))
    print("  al llegar a {}k, Claude Code resume la conversación solo.".format(umbral // 1000))
    print("  el usuario no puede subir ese valor. Comprobalo con /autocompact.")



def main():
    quitar = "--quitar" in sys.argv
    con_imponer = "--imponer" in sys.argv
    umbral = None
    if "--umbral" in sys.argv:
        try:
            umbral = int(sys.argv[sys.argv.index("--umbral") + 1]) * 1000
        except (IndexError, ValueError):
            print("ERROR: --umbral necesita un número en miles. Ej: --umbral 400")
            sys.exit(1)

    settings = sacar_el_nuestro(leer_settings())

    if quitar:
        # Si no había configuración, desinstalar no debe INVENTARLA: crear un
        # settings.json vacío donde no había nada deja rastro de algo que nunca se
        # instaló. (Efecto secundario del arreglo de la carpeta faltante.)
        if os.path.exists(SETTINGS):
            escribir_settings(settings)
        if os.path.exists(DESTINO):
            os.remove(DESTINO)
        if con_imponer:
            imponer(0, quitar=True)
        print("Listo: el aviso de contexto quedó desinstalado.")
        print("Abrí /hooks una vez (o reiniciá Claude Code) para que tome efecto.")
        return

    if not os.path.exists(ORIGEN):
        print("ERROR: falta el archivo avisar-contexto.py, que tiene que estar junto")
        print("a este instalador. Buscado en: {}".format(ORIGEN))
        print("Traé el repo mbinv completo y corré el instalador desde ahí:")
        print("    python3 herramientas/claude-costo/instalar.py")
        sys.exit(1)

    os.makedirs(DESTINO_DIR, exist_ok=True)
    shutil.copy2(ORIGEN, DESTINO)
    try:
        os.chmod(DESTINO, 0o755)
    except OSError:
        pass  # en Windows no aplica

    # Forma `args`: Claude Code ejecuta el intérprete directamente, sin pasar por
    # un shell. Así una ruta con espacios, comillas, `$` o backticks —el HOME de
    # cualquiera puede tenerlos— nunca llega a un parser de shell. El umbral va
    # como argumento y no como prefijo `VAR=x`, que además sólo entendería bash.
    argumentos = [DESTINO]
    if umbral:
        argumentos += ["--umbral", str(umbral)]

    entrada = {
        "type": "command",
        "command": interprete(),
        "args": argumentos,
        "timeout": 5,
        "statusMessage": "Revisando el contexto acumulado",
    }
    settings.setdefault("hooks", {}).setdefault("UserPromptSubmit", []).append(
        {"hooks": [entrada]}
    )
    escribir_settings(settings)

    print("Listo. El aviso quedó instalado en {}".format(SETTINGS))
    print("Umbral: {}k de contexto.".format((umbral or 250000) // 1000))
    autoprueba([entrada["command"]] + argumentos)
    if con_imponer:
        # 250000 sin guiones bajos a propósito: ver el comentario del chequeo de
        # versión, arriba de todo. Un literal 3.6+ acá mata el archivo entero.
        imponer(umbral or 250000)
    print("\nAbrí /hooks una vez (o reiniciá Claude Code) para que la sesión lo cargue.")


if __name__ == "__main__":
    main()
