#!/usr/bin/env python3
"""harkaq-suggest — convierte la deuda en una acción concreta (SDD 16 §4.5).

    harkaq-suggest.py <verdict-crudo.json> <politica> <store> [recipes-dir]

Un veredicto `Impuro` dice *qué* usó el build sin declarar. Esto dice *qué hacer*: por cada path
de deuda busca en el store un artefacto sellado que provea ese mismo path relativo. Si lo hay, la
deuda es **declarable** — la receta sólo tiene que añadir la dep, y hammer ya sabe construirla.

La distinción importa para la métrica de la Fase 2 (<5% de recetas necesitan excepción):

  declarable   el store ya lo provee ⇒ NO es una excepción, es una línea en `[deps]`. El
               diagnóstico es accionable y el arreglo, mecánico.
  irreducible  nadie lo provee ⇒ o se escribe una receta, o se acepta como runtime base. Ésta
               es la que cuenta contra el <5%.

Ejemplo real: zlib salió `Impuro` por `/usr/bin/make`. Parecía runtime base — pero
`recipes/make.toml` existe y `store/…-make` está sellado, y `SWAP_MAKE` es uno de los swaps del
selfhost-verify. O sea: no es base, es deuda declarable. La diferencia entre "aceptamos que
hammer usa el make de Alpine" y "a zlib le falta una línea" la decide este cruce, no una opinión.
"""
import json
import os
import sys


def indice_del_store(store):
    """path-dentro-del-sandbox → [artefactos que lo proveen].

    `--overlay-src <dep>` funde `<dep>/usr/...` en `/usr/...`, así que el path relativo dentro
    del artefacto ES el path del sandbox. El mismo mapeo que usa harkaq-policy, al revés.
    """
    idx = {}
    for art in sorted(os.listdir(store)):
        raiz = os.path.join(store, art)
        if not os.path.isdir(raiz):
            continue
        for dirpath, _, files in os.walk(raiz):
            if "/.hammer" in dirpath:
                continue
            for f in files:
                completo = os.path.join(dirpath, f)
                dentro = "/" + os.path.relpath(completo, raiz)
                idx.setdefault(dentro, []).append(art)
    return idx


def nombre_de(art):
    """`<64hex>-<name>` → `<name>`."""
    return art.split("-", 1)[1] if "-" in art else art


def catalogo(recipes_dir):
    """Nombres de receta que hammer SABE construir, tenga o no el artefacto a mano.

    Sin esto, "declarable" sería relativo a la máquina: el store del worker de la granja trae 103
    artefactos y el del laptop cientos, así que el MISMO path (`/usr/bin/make`) salía declarable
    en uno e irreducible en el otro. Eso no es una propiedad de la receta: es del disco donde
    corrés el barrido, y habría inflado la métrica de la Fase 2 en la granja — que es justo donde
    se va a medir en serio.

    El catálogo es la respuesta correcta a "¿esto lo sabemos construir?". El store sólo dice
    "¿lo tenemos construido AHORA?", que es otra pregunta.
    """
    if not recipes_dir or not os.path.isdir(recipes_dir):
        return set()
    return {f[:-5] for f in os.listdir(recipes_dir) if f.endswith(".toml")}


# Los frontends del gcc de Alpine. Son "sonda esperada" (D3) SÓLO cuando la receta compila con
# zig (el default): el configure los sondea y zig no los usa. Pero para una receta `compiler=gcc`
# (`CC=gcc`, el gcc de Alpine), gcc ES el compilador y su uso es DEUDA DE COMPILADOR — el frente
# matar-gcc. Sin esta distinción, harkaq marcaba `Hermetico` recetas que dependen del gcc de
# Alpine (47 de ellas), ciego a la deuda mayor. SDD 16, límite del modelo (2026-07-16).
FRONTENDS_GCC = {"/usr/bin/gcc", "/usr/bin/cpp", "/usr/bin/c89", "/usr/bin/c99", "/usr/bin/g++",
                 "/usr/bin/cc", "/usr/bin/ld", "/usr/bin/as"}


def compiler_de(recipe_toml):
    """`gcc` si la receta usa el gcc de Alpine como compilador, si no `zig`."""
    try:
        s = open(recipe_toml).read()
    except OSError:
        return "zig"
    import re
    m = re.search(r'compiler\s*=\s*"([^"]+)"', s)
    return "gcc" if m and m.group(1) == "gcc" else "zig"


def main():
    if len(sys.argv) < 4:
        print(__doc__.split("\n")[2].strip(), file=sys.stderr)
        return 2
    crudo = json.load(open(sys.argv[1]))
    politica, store = sys.argv[2], sys.argv[3]
    recipes_dir = sys.argv[4] if len(sys.argv) > 4 else os.path.join(
        os.path.dirname(os.path.abspath(store)), "recipes")
    # 5º arg opcional: la receta que se midió. Si usa compiler=gcc, los frontends de gcc dejan de
    # ser "esperada" y pasan a DEUDA DE COMPILADOR (matar-gcc).
    receta_toml = sys.argv[5] if len(sys.argv) > 5 else None
    usa_gcc = receta_toml and compiler_de(receta_toml) == "gcc"

    esperadas = set()
    # RUNTIME BASE: lo que la política ya CONCEDE (`ro`/`rw`/`list`). Si un path está acá, NO es
    # deuda declarable aunque el store lo provea — es contrato del sandbox.
    #
    # Este bug costó caro: `/bin/busybox` está concedido (el sandbox corre `sh -c` y /bin/sh→
    # busybox), pero como `recipes/busybox.toml` existe, el clasificador propuso "declarar dep:
    # busybox" y la cosecha lo declaró en 29 recetas. Declararlo apila el busybox de hammer sobre
    # el de Alpine y ROMPE el build (binutils: "cannot run C compiled programs"). Además va contra
    # la Etapa C, que reemplaza busybox por uutils. Medir bien y accionar mal.
    concedidos = set()
    with open(politica) as f:
        for l in f:
            l = l.strip()
            if l.startswith("# expect "):
                esperadas.add(l[len("# expect "):].strip())
            elif l[:3] in ("ro ", "rw ") or l.startswith("list "):
                concedidos.add(l.split(None, 1)[1].strip())
    # Si la receta compila con gcc, los frontends NO se perdonan: son la deuda de compilador.
    if usa_gcc:
        esperadas -= FRONTENDS_GCC

    deuda = sorted({d["path"] for d in crudo.get("denials", []) if d["path"] not in esperadas})
    if not deuda:
        print("sin deuda ✓")
        return 0

    idx = indice_del_store(store)
    recetas = catalogo(recipes_dir)
    declarable, irreducible, compilador, base = {}, [], [], []
    for p in deuda:
        if usa_gcc and p in FRONTENDS_GCC:
            compilador.append(p)
            continue
        # ¿Ya concedido por la política? ⇒ runtime base, NO se declara (ver arriba: busybox).
        if p in concedidos or any(p.startswith(c.rstrip("/") + "/") for c in concedidos if c != "/"):
            base.append(p)
            continue
        provs = idx.get(p)
        if provs:
            # El store es autoritativo: sabe qué ficheros aporta cada artefacto.
            declarable[p] = sorted({nombre_de(a) for a in provs})
        elif os.path.basename(p) in recetas:
            # Respaldo por catálogo: el artefacto no está construido AQUÍ, pero hammer sabe
            # construirlo. Es declarable igual — la deuda es de la receta, no del disco.
            #
            # OJO, heurística por NOMBRE y por eso sólo un respaldo: acierta con `make`, pero
            # falla con `/usr/bin/ranlib` (lo trae `binutils`) y `/usr/bin/diff` (lo trae
            # `diffutils`). Un fichero no se llama como su paquete. El único que sabe la verdad
            # es el store, porque conoce la lista de ficheros de cada artefacto.
            #
            # ⇒ CLASIFICAR EN EL HUB, no en el worker. El worker de la granja mide (produce los
            # veredictos crudos) y el hub clasifica, porque es quien tiene el store COMPLETO. Con
            # el store parcial del worker (103 artefactos) esto marcaba bash y doas como deuda
            # IRREDUCIBLE y habría dado una métrica de Fase 2 inflada. Misma separación que
            # lector/clasificador: el que tiene el privilegio (o los datos) hace lo mínimo.
            declarable[p] = [os.path.basename(p) + " (receta en el catálogo, sin construir aquí)"]
        else:
            irreducible.append(p)

    if base:
        print("RUNTIME BASE — ya concedido por la política; NO declarar (es contrato del sandbox):")
        for p in base:
            print(f"    {p}")
    if compilador:
        print("DEUDA DE COMPILADOR (matar-gcc) — la receta usa el gcc de Alpine (compiler=gcc):")
        for p in compilador:
            print(f"    {p}   → migrar a zig-cc, o aceptar como escape declarativo documentado")
    if declarable:
        print("DEUDA DECLARABLE — el store ya lo provee; falta la línea en [deps]:")
        for p, quien in declarable.items():
            print(f"    {p}\n        → declarar dep: {', '.join(quien)}")
    if irreducible:
        print("DEUDA IRREDUCIBLE — nadie la provee (escribir receta, o aceptar como runtime base):")
        for p in irreducible:
            print(f"    {p}")
    # El exit code separa "arreglable con una línea" de "hace falta trabajo de verdad": es lo que
    # un barrido de la Fase 2 necesita para contar contra el <5% sin leer texto.
    return 0 if not irreducible else 1


if __name__ == "__main__":
    sys.exit(main())
