//! `hammer` — el punto de entrada de todo flujo manual. Ver `docs/`.
//!
//! Los subcomandos están mapeados a las fases del roadmap (`docs/10-roadmap.md`). Los que aún
//! no están implementados devuelven un error claro indicando su fase, para que el esqueleto
//! sea navegable desde el día uno.

use std::path::PathBuf;

use clap::{Parser, Subcommand};

mod alpine_import;
mod nix_import;

const DEFAULT_STORE: &str = "/store";
const DEFAULT_REPO: &str = "/var/lib/hammer/repo";

#[derive(Parser)]
#[command(
    name = "hammer",
    version,
    about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
    long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo."
)]
struct Cli {
    /// Ruta del content-addressed store.
    #[arg(long, default_value = DEFAULT_STORE, global = true)]
    store: String,

    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// [Fase 0] Compila una receta y la sella en el store.
    Build {
        /// Ruta a la receta TOML.
        recipe: String,
    },
    /// [Fase 0] Calcula el `ArtifactHash` de una receta SIN construir — puro sobre las recetas
    /// (source_id + compiler/target/link + patches + flags + fases + hashes de deps). No baja
    /// fuentes ni compila. Responde "¿cuál es el hash VIGENTE de esta receta?", que el store no
    /// puede dar sin sellar. Con `--check`: exit 0 si el artefacto vigente ya está en el store,
    /// 1 si no — el dry-run que le faltaba a scripts/static-audit.sh para no auditar sellados rancios.
    Hash {
        /// Ruta a la receta TOML.
        recipe: String,
        /// No imprime el hash; verifica si el artefacto vigente está sellado en el store (exit 0/1).
        #[arg(long)]
        check: bool,
    },
    /// [SDD 17 §1.2] Explica POR QUÉ dos artefactos difieren — el diffoscope propio. Cuando un hash
    /// no reproduce, el store sólo sabe decir "no coincide"; esto nombra la CAUSA: un MTIME embebido
    /// en un gzip, la cabecera `ar` de un `.a`, qué sección ELF cambió (`.comment` ⇒ otro compilador),
    /// una ruta del árbol de build filtrada al binario. Desciende sólo donde los hashes difieren.
    /// Exit 0 si los dos árboles son idénticos, 1 si divergen.
    WhyDiffers {
        /// Artefacto A: un directorio del store (`store/<hash>-<nombre>`) o cualquier árbol.
        a: String,
        /// Artefacto B, el que se compara contra A.
        b: String,
        /// Informe JSON — evidencia legible por máquina para el barrido de granja y el bucle agéntico.
        #[arg(long)]
        json: bool,
    },
    /// [Atestación] Verifica un árbol FHS (p.ej. un product-rootfs hidratado) contra su
    /// `/ente/attest.json`: recomputa el BLAKE3 de cada binario crítico y lo compara. Es el gate de
    /// integridad que arje aplicará al boot, hecho hoy por hammer ("reproducir, no confiar"). Exit≠0
    /// si algún binario diverge o falta.
    Attest {
        /// Directorio raíz del rootfs a verificar (contiene `ente/attest.json`).
        #[arg(long)]
        rootfs: String,
    },
    /// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
    Hydrate {
        /// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
        hash: String,
        #[arg(long, default_value = "/")]
        into: String,
        /// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf).
        #[arg(long, default_value = "static")]
        link: String,
    },
    /// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
    /// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …).
    Try {
        /// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto.
        targets: Vec<PathBuf>,
        /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
        #[arg(long)]
        state_root: Option<PathBuf>,
    },
    /// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
    /// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
    Commit {
        id: String,
        #[arg(long)]
        state_root: Option<PathBuf>,
        /// Directorio del diario donde anotar la promoción.
        #[arg(long, default_value = "/var/lib/hammer/journal")]
        journal: PathBuf,
        /// Saltarse el registro en el diario (útil para experimentos efímeros).
        #[arg(long)]
        no_journal: bool,
    },
    /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
    Discard {
        id: String,
        #[arg(long)]
        state_root: Option<PathBuf>,
    },
    /// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
    Status {
        #[arg(long)]
        state_root: Option<PathBuf>,
    },
    /// [Fase 3] Ver/seguir el diario de mutaciones.
    Journal {
        /// Directorio del diario (mismo que `hammerd --journal`).
        #[arg(long, default_value = "/var/lib/hammer/journal")]
        dir: PathBuf,
        /// Muestra los últimos N eventos. Default: 20.
        #[arg(long, short, default_value = "20")]
        tail: usize,
        /// Sigue el archivo: imprime nuevos eventos según aparecen.
        #[arg(long, short)]
        follow: bool,
        /// Formato. `pretty` (humano, default) o `json` (raw passthrough).
        #[arg(long, default_value = "pretty")]
        format: String,
    },
    /// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
    /// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
    /// (útil para tests offline o staging en otro filesystem).
    Apply {
        file: String,
        /// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
        /// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
        #[arg(long)]
        prefix: Option<PathBuf>,
        /// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
        /// config_edit/file_drop sin lab disponible.
        #[arg(long)]
        skip_source_patch: bool,
        /// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
        #[arg(long)]
        base_ref: Option<PathBuf>,
        /// Raíz del overlay (sólo en modo overlay).
        #[arg(long)]
        state_root: Option<PathBuf>,
    },
    /// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), el hash de cada
    /// `file_drop` inline y, con `--trust DIR`, la firma Ed25519 contra el TrustStore local.
    SwmVerify {
        file: String,
        #[arg(long)]
        base_ref: Option<PathBuf>,
        /// Directorio de claves de confianza (`*.ed25519.pub`). Default:
        /// /var/lib/hammer/trust. La firma reporta estado, NO autoriza promover (ver SDD 09).
        #[arg(long)]
        trust: Option<PathBuf>,
        /// [H1] Además del schema/firma, REPRODUCE cada paquete source_patch y corre su bloque
        /// `evidence` (proof-carrying recipes) en el sandbox. Falla ⇒ exit != 0 (no proponer).
        #[arg(long)]
        evidence: bool,
    },
    /// [Fase 4] Genera un par de claves Ed25519 para firmar `.swm`. Escribe `<name>.ed25519`
    /// (privada, 0600) y `<name>.ed25519.pub` en `--out` (default: /var/lib/hammer/trust).
    Keygen {
        /// Nombre de la identidad (autoría). Será el `by` de las firmas.
        name: String,
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// [Fase 4] Firma un `.swm` con una clave privada y escribe el manifiesto firmado.
    SwmSign {
        file: String,
        /// Ruta a la clave privada `<name>.ed25519`.
        #[arg(long)]
        key: PathBuf,
        /// Autoría declarada (`signature.by`). Default: el basename de la clave sin sufijo.
        #[arg(long)]
        by: Option<String>,
        /// Destino. Si se omite, sobrescribe el propio `file` in-place.
        #[arg(long, short)]
        out: Option<PathBuf>,
    },
    /// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
    /// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
    /// mismas pero con `note` indicando el origen (warning legible para el receptor).
    Export {
        /// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
        /// se emite una base con `distro_version: "unknown"` y se imprime un warning.
        #[arg(long)]
        base_ref: Option<PathBuf>,
        /// Directorio del diario.
        #[arg(long, default_value = "/var/lib/hammer/journal")]
        journal: PathBuf,
        /// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
        #[arg(long)]
        since: Option<String>,
    },
    /// [Etapa F] Empaqueta una receta del corpus (`recipes/foo.toml`) como un `.swm` de un
    /// único `source_patch`: la dirección **forward** que vuelve una receta que el sistema ya
    /// sabe construir un paquete distribuible y reproducible-desde-fuente. Inversa de `apply`.
    Pack {
        /// Ruta a la receta a empaquetar (p. ej. `recipes/ripgrep.toml`).
        recipe: PathBuf,
        /// Ancla de sanity: ruta absoluta del binario que debe existir tras hidratar el
        /// artefacto. Default `/usr/bin/<name>`. (apply hidrata el artefacto ENTERO; esto sólo
        /// verifica que algo esperado quedó en su sitio.)
        #[arg(long)]
        target_bin: Option<String>,
        /// Archivo `.swm` de salida. Sin esto (ni `--repo`), se imprime a stdout.
        #[arg(long, short)]
        out: Option<PathBuf>,
        /// Publica el paquete en este repositorio: escribe `<repo>/<name>-<version>.swm` y
        /// actualiza `<repo>/index.json` (upsert por nombre). Compatible con `--out`.
        #[arg(long)]
        repo: Option<PathBuf>,
        /// `distro_version` de la base del paquete (la base local del receptor debe coincidir).
        #[arg(long, default_value = "dev")]
        distro_version: String,
        /// `expected_hash` declarado (`b3:…`) — el ancla de "verificar, no confiar". Si se omite
        /// y no se pasa `--build`, el paquete sale sin ancla (el receptor reproduce igual, pero
        /// no hay hash autor contra el que comparar).
        #[arg(long)]
        expected: Option<String>,
        /// Construir la receta AHORA en el lab para sellar el `expected_hash` real (necesita
        /// store + sandbox). Mutuamente informativo con `--expected` (este último gana si ambos).
        #[arg(long)]
        build: bool,
        /// Firmar el `.swm` resultante con esta clave privada Ed25519 (como `swm-sign`).
        #[arg(long)]
        sign: Option<PathBuf>,
    },
    /// [Etapa F] Instala un paquete por nombre desde un repositorio: resuelve `nombre` en el
    /// índice, verifica la firma (con `--trust`) y la base, reproduce el `source_patch` desde
    /// fuente y lo hidrata. Es `apply` con resolución por nombre — nunca corre un binario ajeno.
    Install {
        /// Nombre del paquete a instalar (clave del índice del repo).
        name: String,
        /// Repositorio: un directorio local (con `index.json`) o una URL HTTP(S) (`http(s)://…`).
        #[arg(long, default_value = DEFAULT_REPO)]
        repo: String,
        /// Re-rootea las mutaciones bajo este prefix (tests/staging) en vez de abrir overlay.
        #[arg(long)]
        prefix: Option<PathBuf>,
        /// Verifica el `source_patch` y la base, pero NO construye ni hidrata (dry-run de schema).
        #[arg(long)]
        skip_source_patch: bool,
        /// Base local (JSON) contra la que validar la base del paquete. Sin esto no se chequea.
        #[arg(long)]
        base_ref: Option<PathBuf>,
        /// Directorio de claves de confianza para validar la firma del `.swm`. Sin esto, la
        /// firma se informa pero no bloquea (igual que `swm-verify` sin `--trust`).
        #[arg(long)]
        trust: Option<PathBuf>,
        /// Raíz de estado para el overlay (modo sin `--prefix`).
        #[arg(long)]
        state_root: Option<PathBuf>,
        /// Base de datos de instalados donde registrar el paquete (para `uninstall`).
        #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
        db: PathBuf,
        /// Modo estricto: abortar si el release del repo no está firmado por una clave confiada
        /// (no basta con que el `.swm` reproduzca — exige autoría del catálogo verificada).
        #[arg(long)]
        require_signed: bool,
        /// [H4b] Ante una COLISIÓN de superficie (el paquete reclama un slot que ya ocupa otra
        /// config, p. ej. el logo), adopta la nueva en vez de abortar. No afecta a un requisito
        /// insatisfecho (ese rechazo es duro: el paquete no encaja con lo que tenés).
        #[arg(long)]
        force_slots: bool,
    },
    /// [Etapa F] Desinstala un paquete: borra los ficheros que registró (refcount: respeta los
    /// que otro paquete instalado también aporta) y lo quita de la DB de instalados.
    Uninstall {
        /// Nombre del paquete a desinstalar.
        name: String,
        /// Base de datos de instalados.
        #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
        db: PathBuf,
    },
    /// [Etapa F] Lista los paquetes instalados (de la DB).
    Installed {
        #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
        db: PathBuf,
    },
    /// [H4d] BÚSQUEDA de compatibilidad: particiona un repo entero contra el estado instalado en
    /// {compatibles, requieren-elección, incompatibles} — sin construir ni tocar nada. Responde
    /// "cuando busco, ¿cuáles puedo adoptar?" (SDD 15 §H4).
    Compat {
        /// Repositorio: directorio local (con `index.json`) o URL HTTP(S).
        #[arg(long, default_value = DEFAULT_REPO)]
        repo: String,
        /// Base de datos de instalados contra la que evaluar.
        #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
        db: PathBuf,
    },
    /// [Etapa G] Importa una receta DESDE nixpkgs: toma el JSON normalizado que produce
    /// `scripts/nix-import.sh` (vía `nix eval`) y emite una receta hammer (`.toml`). Trae la
    /// RECETA (source+hash+deps+flags), nunca el binario del cache de nix — hammer reconstruye.
    /// Es un PUNTO DE PARTIDA: el build (zig/musl) suele necesitar adaptación por paquete.
    ImportNix {
        /// JSON normalizado del paquete nix. `-` o ausente ⇒ lee de stdin.
        #[arg(default_value = "-")]
        file: String,
        /// Receta de salida (`.toml`). Sin esto, imprime a stdout.
        #[arg(long, short)]
        out: Option<PathBuf>,
    },
    /// [Etapa G] Importa una receta DESDE un APKBUILD de Alpine (aports). Crucial: carga los
    /// `.patch` de musl de Alpine — lo que un import de nix pierde — así está MUCHO más cerca de
    /// compilar en el lab estático de hammer. `scripts/alpine-import.sh <pkg>` baja el APKBUILD.
    ImportAlpine {
        /// El APKBUILD (texto). `-` o ausente ⇒ stdin.
        #[arg(default_value = "-")]
        file: String,
        /// Receta de salida (`.toml`). Sin esto, imprime a stdout.
        #[arg(long, short)]
        out: Option<PathBuf>,
    },
    /// [Etapa G Fase 2] Ancla el `commit` de una receta git: si es un TAG flotante (v1.0.0),
    /// lo resuelve al SHA inmutable vía `git ls-remote` (host-agnóstico, sin API ni tokens) y
    /// reescribe la receta. El sello determinista al estilo Nix (ADR 0006). No-op si ya es un SHA.
    Pin {
        /// Receta a anclar (`recipes/foo.toml`).
        recipe: PathBuf,
        /// Salida. Sin esto, reescribe la receta IN-PLACE.
        #[arg(long, short)]
        out: Option<PathBuf>,
    },
    /// [Etapa F] Inspecciona un repositorio de paquetes.
    Repo {
        #[command(subcommand)]
        sub: RepoCmd,
    },
    /// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
    /// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
    Ai {
        /// La intención en lenguaje natural. En modo mock (`--catalog`), debe coincidir
        /// exactamente con una entrada del catálogo; en modo `--llm`, es texto libre.
        intent: String,
        /// Catálogo YAML de intent → swm (MockTranslator). Mutuamente excluyente con `--llm`.
        #[arg(long)]
        catalog: Option<PathBuf>,
        /// Activa el traductor LLM real (Claude API). Requiere `ANTHROPIC_API_KEY` en el env
        /// y un binario compilado con `--features llm-claude`. Modelo por defecto:
        /// `claude-opus-4-8` (sobreescribible con `HAMMER_LLM_MODEL`).
        #[arg(long)]
        llm: bool,
        /// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
        /// dev/CI sin root ni overlayfs.
        #[arg(long)]
        prefix: Option<PathBuf>,
        /// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
        #[arg(long)]
        base_ref: Option<PathBuf>,
        /// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama
        /// Compile por el bus. Si no, source_patch se omite con warning.
        #[arg(long)]
        bus: Option<PathBuf>,
        /// Raíz de overlays para `try`. Default coincide con `hammer try`.
        #[arg(long)]
        state_root: Option<PathBuf>,
        /// Si está, activa el bucle de auto-reparación: tras aplicar, espera en el bus
        /// de eventos (mismo socket que `--bus` si no se da otro) un evento `Crashed`
        /// durante `--repair-window-ms`. Si llega, traduce `(service, code)` a una nueva
        /// intención y reaplica, hasta este máximo de intentos. Sin `--repair-max-attempts`,
        /// el bucle agéntico corre una sola vez (compatible hacia atrás).
        #[arg(long)]
        repair_max_attempts: Option<u32>,
        /// Ventana de espera por un Crashed tras cada intento. Default: 5000 ms.
        #[arg(long, default_value = "5000")]
        repair_window_ms: u64,
    },
    /// [Fase 5] Envía un comando al FIFO de control humano del init.
    /// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
    /// pero con un mensaje de error claro si el FIFO no existe.
    Ctl {
        /// La línea a enviar (p. ej. "start web", "restart network").
        line: String,
        #[arg(long, default_value = "/run/init.control")]
        fifo: PathBuf,
    },
    /// [Fase 6] Evalúa una expresión del mini-lenguaje (SDD 08 §6) contra el sistema local.
    /// Forma: `kind:value`. Kinds soportados: `bin`, `file`, `pin`, `service`, `depends`.
    /// Ejemplos:
    ///   hammer query bin:grep
    ///   hammer query file:/etc/hosts
    ///   hammer query depends:/usr/bin/curl
    ///   hammer query pin:musl --base-ref base.json
    Query {
        /// La expresión a evaluar.
        expr: String,
        /// Base local (JSON) para resolver `pin:<name>`. Opcional.
        #[arg(long)]
        base_ref: Option<PathBuf>,
        /// Re-rootea las rutas absolutas bajo este prefix (útil para probar contra un overlay
        /// o un prefix de test sin tocar el FHS real).
        #[arg(long)]
        fs_root: Option<PathBuf>,
    },
    /// [Track posterior] Bootstrap from-scratch (SDD 11). Encadena el toolchain semilla y el
    /// userland mínimo hacia el auto-alojamiento.
    Bootstrap {
        #[command(subcommand)]
        sub: BootstrapCmd,
    },
    /// [Etapa E3] Mirror del store content-addressed: replica artefactos por hash entre máquinas,
    /// verificando `of_tree` (BLAKE3) en recepción. `--store` es el store local.
    Mirror {
        #[command(subcommand)]
        sub: MirrorCmd,
    },

    /// [Etapa E4] Upgrades atómicos con rollback: aplica un árbol Stage1/producto del store al root
    /// vivo dejando una generación nueva; `rollback` revierte exactamente al árbol anterior.
    Upgrade {
        /// Root del FHS vivo sobre el que se proyecta el árbol (default `/`).
        #[arg(long, default_value = "/", global = true)]
        root: String,
        /// Directorio de estado de generaciones (default `/var/lib/hammer/upgrades`).
        #[arg(long, default_value = hammer_upgrade::DEFAULT_STATE_ROOT, global = true)]
        state_root: String,
        /// Diario donde registrar cada cambio (default `/var/lib/hammer/journal`).
        #[arg(long, default_value = "/var/lib/hammer/journal", global = true)]
        journal: String,
        #[command(subcommand)]
        sub: UpgradeCmd,
    },

    /// [ADR 0010] Arranque por grafo: el menú de boot es la navegación del grafo de estados del
    /// sistema. `graph` publica el grafo para mirada; `activate` deja el sistema en un nodo.
    Boot {
        /// Root del FHS vivo sobre el que se activa un nodo (default `/`).
        #[arg(long, default_value = "/", global = true)]
        root: String,
        /// Directorio de estado de generaciones (default `/var/lib/hammer/upgrades`).
        #[arg(long, default_value = hammer_upgrade::DEFAULT_STATE_ROOT, global = true)]
        state_root: String,
        /// Diario donde registrar cada cambio al activar (default `/var/lib/hammer/journal`).
        #[arg(long, default_value = "/var/lib/hammer/journal", global = true)]
        journal: String,
        #[command(subcommand)]
        sub: BootCmd,
    },
}

#[derive(Subcommand)]
enum BootCmd {
    /// Emite el grafo de estados a `--out` (default `/run/hammer/boot-graph.json`) para que mirada
    /// dibuje el menú. Regenerable, world-readable.
    Graph {
        /// Path de salida del grafo.
        #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_GRAPH_PATH)]
        out: PathBuf,
        /// En vez de escribir el fichero, imprime el grafo por stdout (para inspección/tests).
        #[arg(long)]
        stdout: bool,
    },
    /// Activa el nodo `<id>` del grafo (deja el sistema en ese estado). Con `--from-select` lee el id
    /// que mirada dejó en `/run/hammer/boot-select` en vez de pasarlo por argumento.
    ///
    /// `--from-select` es IDEMPOTENTE: sin fichero (o vacío) es un no-op limpio (exit 0), y tras
    /// activar con éxito el fichero se consume. Contrato PLAN-KIKIN §9 (tawasuyu, 2026-07-16):
    /// mirada escribe la selección y dispara el reinicio SIN salir (sostiene el DRM master);
    /// arje-zero (PID1) invoca esto INCONDICIONAL en su secuencia de apagado, antes del reboot —
    /// `/run` es tmpfs, así que la selección se aplica en el apagado o no se aplica nunca.
    Activate {
        /// Id (BLAKE3, con o sin `b3:`) del nodo a activar. Omitir con `--from-select`.
        id: Option<String>,
        /// Lee el id desde el canal de vuelta de mirada (no-op limpio si no existe o está vacío).
        #[arg(long)]
        from_select: bool,
        /// Path del canal de vuelta que deja mirada.
        #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_SELECT_PATH)]
        select: PathBuf,
    },
    /// [ADR 0010 · HARNESS de dev/VM] Orquesta el menú de boot completo: emite el grafo, lanza el
    /// compositor que lo dibuja sobre KMS, ESPERA su salida y activa lo que dejó en boot-select.
    /// Sirve para validar el glue en OVMF/QEMU con un compositor stub que sí termina.
    ///
    /// EN PRODUCCIÓN NO SE USA (PLAN-KIKIN §4.1/§9, 2026-07-16): mirada real NUNCA sale — sostiene
    /// el DRM master del greeter al escritorio para no re-modesetear (parpadeo). El camino real es:
    /// arje-zero publica el grafo al arrancar (`hammer boot graph`), mirada escribe boot-select y
    /// dispara el reboot por el bus de arje, y arje-zero corre `hammer boot activate --from-select`
    /// en su secuencia de apagado.
    Menu {
        /// Comando del compositor a lanzar (se parte por espacios). El primer token es el binario.
        #[arg(long, default_value = "mirada-compositor --arranque")]
        compositor: String,
        /// Path donde publicar el grafo antes de lanzar el compositor.
        #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_GRAPH_PATH)]
        out: PathBuf,
        /// Path del canal de vuelta donde el compositor deja el id elegido.
        #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_SELECT_PATH)]
        select: PathBuf,
    },
}

#[derive(Subcommand)]
enum RepoCmd {
    /// Lista los paquetes publicados en el repositorio.
    List {
        /// Directorio del repositorio (con `index.json`).
        #[arg(long, default_value = DEFAULT_REPO)]
        repo: PathBuf,
    },
    /// Firma el ÍNDICE entero (release): ancla qué paquetes/versiones/hashes existen. Re-firmá
    /// tras publicar (cada `pack --repo` invalida la firma previa).
    Sign {
        #[arg(long, default_value = DEFAULT_REPO)]
        repo: PathBuf,
        /// Clave privada Ed25519 con la que firmar el release.
        #[arg(long)]
        key: PathBuf,
        /// Autor de la firma. Default: basename de la clave sin `.ed25519`.
        #[arg(long)]
        by: Option<String>,
    },
    /// Verifica la firma del release del repositorio contra las claves de confianza.
    Verify {
        #[arg(long, default_value = DEFAULT_REPO)]
        repo: PathBuf,
        /// Directorio de claves de confianza. Default `/var/lib/hammer/trust`.
        #[arg(long)]
        trust: Option<PathBuf>,
    },
}

#[derive(Subcommand)]
enum UpgradeCmd {
    /// Aplica el árbol `<tree-dir>` (`<hash>-<name>` del store) al root vivo, dejando una generación.
    Apply {
        /// Directorio del artefacto en el store (p.ej. `b3hex…-product-rootfs`).
        tree_dir: String,
        /// `of_tree` esperado (`b3:…`) — de un índice de mirror o release firmada; si se da, se exige.
        #[arg(long)]
        expected: Option<String>,
    },
    /// Revierte la generación viva al árbol anterior (o al estado pre-upgrades).
    Rollback,
    /// Completa (o deshace con --rollback) un apply interrumpido por un corte/reinicio (intento pendiente).
    Recover {
        /// Deshace el apply a medias (vuelve al estado pre-apply) en vez de completarlo.
        #[arg(long)]
        rollback: bool,
    },
    /// Muestra la generación viva y el historial.
    Status,
    /// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
    Prune {
        /// Recorta también la cadena viva a sus N generaciones más nuevas (limita la profundidad de
        /// rollback). Sin flag, conserva toda la cadena y borra sólo las huérfanas.
        #[arg(long)]
        keep: Option<usize>,
    },
}

#[derive(Subcommand)]
enum MirrorCmd {
    /// Replica al store REMOTO los artefactos que le faltan (verificados antes de sellar).
    Push {
        /// Ruta del store remoto destino (puede ser un montaje de red/sshfs).
        remote: String,
    },
    /// Trae del store REMOTO los artefactos que le faltan al local (verificados antes de sellar).
    Pull {
        /// Ruta del store remoto origen.
        remote: String,
    },
    /// Muestra el diff entre el store local y uno remoto (qué falta en cada lado, conflictos). No copia.
    Status {
        /// Ruta del store remoto a comparar.
        remote: String,
    },
}

#[derive(Subcommand)]
enum BootstrapCmd {
    /// [Stage 0] Ingiere un toolchain semilla pinned al store. Verifica el sha256 antes de
    /// extraer y lo sella; idempotente si ya está. Imprime el hash del artefacto.
    Stage0 {
        /// URL del tarball del toolchain (cualquier esquema de curl; `file://` para offline).
        #[arg(long)]
        url: String,
        /// sha256 hex del tarball, fijado. Se verifica antes de sellar.
        #[arg(long)]
        sha256: String,
        /// Versión de la semilla (entra en la identidad/hash del artefacto).
        #[arg(long)]
        version: String,
        /// Clase de semilla.
        #[arg(long, default_value = "zig")]
        seed: String,
    },
    /// [Stage 1] Cross-compila el userland mínimo (musl + busybox) con la semilla de Stage 0,
    /// ensambla un rootfs FHS y lo sella. Imprime el hash del rootfs.
    Stage1 {
        /// Hash de la semilla ya sellada por Stage 0 (con o sin prefijo `b3:`).
        #[arg(long)]
        seed_hash: String,
        /// Clase de semilla (debe coincidir con la de Stage 0).
        #[arg(long, default_value = "zig")]
        seed: String,
        /// Directorio de recetas (`musl.toml`, `busybox.toml`).
        #[arg(long, default_value = "recipes")]
        recipes: String,
    },
    /// [Producto] Capa de SERVICIOS: toma el rootfs base verificado del 4/4 (`--rootfs`, el que
    /// imprimió `stage1`) e inyecta por hidratación tardía los componentes de servicio (sshd/openssh
    /// + netup) y sus configs, sellando un `product-rootfs` APARTE. El mecanismo base no se toca (su
    /// `of_tree` sigue blindado para el selfhost-verify). Separación Mecanismo/Política.
    Product {
        /// Hash del rootfs base de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
        #[arg(long)]
        rootfs: String,
        /// Directorio de recetas (`openssh.toml`, `netup.toml`, …).
        #[arg(long, default_value = "recipes")]
        recipes: String,
        /// Produce además el producto ATESTADO (`product-attested-rootfs`): hidrata el init CON gate
        /// (`arje-zero-attest`) y FIRMA la seed con `arje-packager`. Imprime el hash del atestado.
        #[arg(long)]
        attest: bool,
        /// Política del gate de atestación: `halt` (default) | `degraded` | `warn`. Sólo con `--attest`.
        #[arg(long, default_value = "halt")]
        policy: String,
        /// Fichero con la rootkey de 32 bytes raw para firmar. Sin él se usa la rootkey de desarrollo
        /// determinista (firmas reproducibles). Sólo con `--attest`.
        #[arg(long)]
        rootkey: Option<String>,
        /// [Dogfood] Arma el userland REPRODUCIÉNDOLO desde un REPO FIRMADO (dir local) en vez de las
        /// recetas locales: verifica la firma del release y reconstruye cada paquete desde su `.swm`
        /// comparando el `expected_hash`. El `product-rootfs` sale bit-idéntico al de la vía local (hash
        /// por-contenido). Requiere `--trust`. La cadena de suministro de la propia imagen.
        #[arg(long)]
        from_repo: Option<String>,
        /// Directorio de claves de confianza para verificar la firma del release del `--from-repo`.
        #[arg(long)]
        trust: Option<String>,
    },
    /// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad
    /// (anota la línea del manifiesto). Con `--verify <hash>` compara contra un rebuild `stage1'`
    /// (producido en la VM) y emite el veredicto de auto-alojamiento.
    Stage2 {
        /// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
        #[arg(long)]
        rootfs: String,
        /// Content-hash de un rebuild `stage1'` para comparar. Sin él, sólo ancla la referencia.
        #[arg(long)]
        verify: Option<String>,
    },
    /// [Stage 2 · builder] Ensambla el **builder rootfs** (Stage 1 + toolchain) que, booteado en la
    /// VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento, SDD 11 §7). Variante
    /// pragmática: el toolchain entra desde `.dev-fs/alpine`.
    Builder {
        /// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
        #[arg(long)]
        stage1: String,
        /// Hash de la semilla sellada por Stage 0 (con o sin `b3:`).
        #[arg(long)]
        seed_hash: String,
        /// Clase de semilla (debe coincidir con la de Stage 0).
        #[arg(long, default_value = "zig")]
        seed: String,
        /// Directorio de recetas a embeber.
        #[arg(long, default_value = "recipes")]
        recipes: String,
        /// Binario `hammer` estático a instalar en `/usr/bin/hammer`.
        #[arg(long, default_value = "target/x86_64-unknown-linux-musl/release/hammer")]
        hammer_bin: String,
        /// Toolchain del sandbox de build → `/toolchain` (rootfs Alpine de `.dev-fs`).
        #[arg(long, default_value = ".dev-fs/alpine")]
        toolchain: String,
        /// Etiqueta de identidad del toolchain (entra al hash lógico del builder).
        #[arg(long, default_value = "alpine")]
        toolchain_tag: String,
        /// Content-hash `of_tree(stage1)` (referencia anclada por `stage2`) para que el rebuild se
        /// auto-verifique en la VM. Sin él, el driver sólo imprime el content-hash de `stage1'`.
        #[arg(long)]
        ref_content: Option<String>,
        /// Caché de fuentes (`work/`) a embeber en `/work` para un rebuild offline.
        #[arg(long)]
        work_cache: Option<String>,
        /// Pieza del toolchain construida por hammer desde fuente (variante b, SDD 11 §7.2b) que pisa
        /// la de Alpine en `/toolchain`. Formato `name=hash[:rel_path]`; `rel_path` por defecto
        /// `usr/bin/<name>`. Repetible. Ej.: `--swap make=fbad44ac…`. Cada swap entra al hash lógico.
        #[arg(long = "swap", value_name = "name=hash[:rel_path]")]
        swaps: Vec<String>,
        /// Dónde ensamblar el builder (se reensambla limpio en cada corrida).
        #[arg(long, default_value = "work/builder-rootfs")]
        out: String,
    },
    /// [Stage 0+1+2] Encadena las tres etapas (semilla → rootfs → referencia anclada) en una corrida
    /// y deja el `bootstrap.json` poblado (SDD 11 §5). El veredicto `✓ REPRODUCIBLE` lo sella el
    /// rebuild nativo dentro del rootfs (VM, `scripts/selfhost-verify.sh`), no esta corrida del host.
    All {
        /// URL del tarball del toolchain semilla (cualquier esquema de curl; `file://` para offline).
        #[arg(long)]
        url: String,
        /// sha256 hex del tarball, fijado. Se verifica antes de sellar.
        #[arg(long)]
        sha256: String,
        /// Versión de la semilla (entra en la identidad/hash del artefacto).
        #[arg(long)]
        version: String,
        /// Clase de semilla.
        #[arg(long, default_value = "zig")]
        seed: String,
        /// Directorio de recetas (`musl.toml`, `busybox.toml`, `hammerd.toml`, `arje-zero.toml`).
        #[arg(long, default_value = "recipes")]
        recipes: String,
    },
    /// Imprime el `bootstrap.json` del store: el log de transparencia (SDD 11 §4). Un tercero
    /// reproduce el bootstrap y verifica que sus hashes coinciden con los de estas líneas.
    Manifest,
}

/// Parsea la clase de semilla del flag `--seed`. Único punto de verdad para todas las subcomandos.
fn parse_seed_kind(s: &str) -> anyhow::Result<hammer_bootstrap::SeedKind> {
    match s {
        "zig" => Ok(hammer_bootstrap::SeedKind::Zig),
        "musl-cross-make" => Ok(hammer_bootstrap::SeedKind::MuslCrossMake),
        other => anyhow::bail!("--seed debe ser 'zig' o 'musl-cross-make', no '{other}'"),
    }
}

fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
    match format {
        "json" => {
            // Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
            // formato es estable porque MutationEvent es la fuente de verdad del schema.
            match serde_json::to_string(ev) {
                Ok(s) => println!("{s}"),
                Err(e) => eprintln!("journal: error serializando: {e}"),
            }
        }
        _ => {
            let actor = match &ev.by.source {
                hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
                hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
                    Some(a) => format!("commit {overlay} ({a})"),
                    None => format!("commit {overlay}"),
                },
                hammer_journal::Source::External => {
                    let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
                    let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
                    format!("external {pid} {uid}").trim().to_string()
                }
            };
            println!(
                "{ts} {op:<7} {path}  ←  {actor}",
                ts = ev.ts,
                op = ev.op.as_str(),
                path = ev.path.display(),
            );
        }
    }
}

fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info".into()),
        )
        .init();

    let cli = Cli::parse();

    // Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store.
    // Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía.
    match cli.cmd {
        Cmd::Build { recipe } => {
            let store = hammer_core::Store::open(&cli.store)?;
            let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
            let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
            let hash = hammer_build::build(&recipe, &cfg, &store)?;
            println!("{hash}");
        }
        Cmd::Hash { recipe, check } => {
            let store = hammer_core::Store::open(&cli.store)?;
            let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
            let hash = hammer_build::artifact_hash(&recipe, &store)?;
            if check {
                // ¿el artefacto VIGENTE (el que corresponde a la receta de HOY) ya está sellado?
                // Es lo que static-audit.sh no podía saber: `ls -dt store/*-<n>` da el más RECIENTE,
                // no el vigente. Exit 0 = sellado al día; 1 = haría falta construir.
                if store.has(&hash, &recipe.name) {
                    println!("{hash}\tSELLADO");
                } else {
                    println!("{hash}\tNO-SELLADO");
                    std::process::exit(1);
                }
            } else {
                println!("{hash}");
            }
        }
        Cmd::WhyDiffers { a, b, json } => {
            let informe = hammer_core::why_differs(std::path::Path::new(&a), std::path::Path::new(&b))?;
            if json {
                println!("{}", serde_json::to_string_pretty(&informe)?);
            } else {
                println!("== why-differs");
                println!("   A: {}", informe.a);
                println!("   B: {}", informe.b);
                println!(
                    "   {} entradas idénticas · {} divergen",
                    informe.identicos,
                    informe.divergencias.len()
                );
                for d in &informe.divergencias {
                    println!("\n   {}", d.ruta);
                    println!("     causa: {}", d.explicacion);
                    if let Some(r) = &d.remedio {
                        println!("     → {r}");
                    }
                }
                if informe.reproduce() {
                    println!("\n   ✓ los dos árboles son idénticos: REPRODUCE");
                } else {
                    let resumen: Vec<String> = informe
                        .por_causa()
                        .iter()
                        .map(|(c, n)| format!("{c}×{n}"))
                        .collect();
                    println!("\n   resumen: {}", resumen.join("  "));
                }
            }
            if !informe.reproduce() {
                std::process::exit(1);
            }
        }
        Cmd::Attest { rootfs } => {
            let report = hammer_bootstrap::verify_attestation(std::path::Path::new(&rootfs))?;
            for p in &report.ok {
                println!("  ✓ {p}");
            }
            for p in &report.missing {
                println!("  ✗ FALTA   {p}");
            }
            for p in &report.mismatched {
                println!("  ✗ DIVERGE {p}");
            }
            if report.passed() {
                println!(
                    "✓ ATESTACIÓN OK: {} binarios críticos casan su BLAKE3 esperado",
                    report.ok.len()
                );
            } else {
                anyhow::bail!(
                    "✗ ATESTACIÓN FALLÓ: {} diverge(n), {} falta(n) — integridad comprometida",
                    report.mismatched.len(),
                    report.missing.len()
                );
            }
        }
        Cmd::Hydrate { hash, into, link } => {
            let store = hammer_core::Store::open(&cli.store)?;
            let mode = match link.as_str() {
                "static" => hammer_core::LinkMode::Static,
                "dynamic" => hammer_core::LinkMode::Dynamic,
                other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
            };
            let artifact_dir = store.find_by_hash(&hash)?;
            let report = hammer_build::run_hydrate(
                &artifact_dir,
                std::path::Path::new(&into),
                mode,
                None,
            )?;
            println!(
                "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
                report.files.len(),
                report.dirs_created,
                report.symlinks,
                into
            );
            for f in &report.files {
                println!("  {}", f.dst.display());
            }
        }
        Cmd::Try { targets, state_root } => {
            let root = state_root
                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
            std::fs::create_dir_all(&root)?;
            let id = hammer_overlay::try_overlay(&targets, &root)?;
            println!("{id}");
        }
        Cmd::Commit { id, state_root, journal, no_journal } => {
            let root = state_root
                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
            let id = hammer_overlay::OverlayId(id);
            let report = if no_journal {
                hammer_overlay::commit(&id, &root)?
            } else {
                let j = hammer_journal::Journal::open(&journal)?;
                hammer_overlay::commit_with_journal(&id, &root, &j)?
            };
            println!(
                "commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
                report.copied.len(),
                report.removed.len(),
                if no_journal { "" } else { " (anotado en el diario)" }
            );
        }
        Cmd::Discard { id, state_root } => {
            let root = state_root
                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
            hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?;
            println!("discard OK");
        }
        Cmd::Status { state_root } => {
            let root = state_root
                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
            let overlays = hammer_overlay::status(&root)?;
            if overlays.is_empty() {
                println!("(sin overlays activos)");
            } else {
                for o in overlays {
                    println!(
                        "{}  created={}  mounts={}",
                        o.id,
                        o.created_at,
                        o.mounts.len()
                    );
                    for m in &o.mounts {
                        println!("  └─ {}  upper={}", m.target.display(), m.upper.display());
                    }
                }
            }
        }
        Cmd::Journal { dir, tail, follow, format } => {
            let j = hammer_journal::Journal::open(&dir)?;
            let initial = j.tail(tail)?;
            for ev in &initial {
                print_event(ev, &format);
            }
            if follow {
                use std::io::Write;
                let _ = std::io::stdout().flush();
                let mut cursor = j.end_offset()?;
                loop {
                    std::thread::sleep(std::time::Duration::from_millis(500));
                    let (news, new_cursor) = j.read_from_offset(cursor)?;
                    cursor = new_cursor;
                    for ev in &news {
                        print_event(ev, &format);
                    }
                    let _ = std::io::stdout().flush();
                }
            }
        }
        Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
            run_apply(
                &cli.store,
                &file,
                prefix.as_deref(),
                skip_source_patch,
                base_ref.as_deref(),
                state_root.as_deref(),
                None, // `apply` aplica un .swm genérico: no conoce el nombre del paquete
            )?;
        }
        Cmd::SwmVerify { file, base_ref, trust, evidence } => {
            run_swm_verify(
                &file,
                base_ref.as_deref(),
                trust.as_deref(),
                evidence,
                std::path::Path::new(&cli.store),
            )?;
        }
        Cmd::Keygen { name, out } => {
            run_keygen(&name, out.as_deref())?;
        }
        Cmd::SwmSign { file, key, by, out } => {
            run_swm_sign(&file, &key, by.as_deref(), out.as_deref())?;
        }
        Cmd::Export { base_ref, journal, since } => {
            run_export(base_ref.as_deref(), &journal, since.as_deref(), &cli.store)?;
        }
        Cmd::Pack {
            recipe,
            target_bin,
            out,
            repo,
            distro_version,
            expected,
            build,
            sign,
        } => {
            run_pack(
                &recipe,
                target_bin.as_deref(),
                out.as_deref(),
                repo.as_deref(),
                &distro_version,
                expected.as_deref(),
                build,
                sign.as_deref(),
                &cli.store,
            )?;
        }
        Cmd::Install {
            name,
            repo,
            prefix,
            skip_source_patch,
            base_ref,
            trust,
            state_root,
            db,
            require_signed,
            force_slots,
        } => {
            run_install(
                &cli.store,
                &name,
                &repo,
                prefix.as_deref(),
                skip_source_patch,
                base_ref.as_deref(),
                trust.as_deref(),
                state_root.as_deref(),
                &db,
                require_signed,
                force_slots,
            )?;
        }
        Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
        Cmd::Installed { db } => run_installed_list(&db)?,
        Cmd::Compat { repo, db } => run_compat(&repo, &db)?,
        Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?,
        Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?,
        Cmd::Pin { recipe, out } => run_pin(&recipe, out.as_deref())?,
        Cmd::Repo { sub } => match sub {
            RepoCmd::List { repo } => run_repo_list(&repo)?,
            RepoCmd::Sign { repo, key, by } => run_repo_sign(&repo, &key, by.as_deref())?,
            RepoCmd::Verify { repo, trust } => run_repo_verify(&repo, trust.as_deref())?,
        },
        Cmd::Ai {
            intent,
            catalog,
            llm,
            prefix,
            base_ref,
            bus,
            state_root,
            repair_max_attempts,
            repair_window_ms,
        } => {
            run_ai(
                &intent,
                catalog.as_deref(),
                llm,
                prefix.as_deref(),
                base_ref.as_deref(),
                bus.as_deref(),
                state_root.as_deref(),
                &cli.store,
                repair_max_attempts,
                repair_window_ms,
            )?;
        }
        Cmd::Ctl { line, fifo } => {
            run_ctl(&line, &fifo)?;
        }
        Cmd::Query { expr, base_ref, fs_root } => {
            run_query(&expr, base_ref.as_deref(), fs_root.as_deref())?;
        }
        Cmd::Bootstrap { sub } => match sub {
            BootstrapCmd::Stage0 { url, sha256, version, seed } => {
                let kind = parse_seed_kind(&seed)?;
                let store = hammer_core::Store::open(&cli.store)?;
                let spec = hammer_bootstrap::SeedSpec { kind, version, url, sha256 };
                let hash = hammer_bootstrap::stage0(&spec, &store)?;
                println!("{hash}");
            }
            BootstrapCmd::Stage1 { seed_hash, seed, recipes } => {
                let seed_kind = parse_seed_kind(&seed)?;
                let store = hammer_core::Store::open(&cli.store)?;
                let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
                let spec = hammer_bootstrap::Stage1Spec {
                    seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
                    seed_kind,
                    recipes_dir: std::path::PathBuf::from(recipes),
                };
                let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?;
                println!("{hash}");
            }
            BootstrapCmd::Product { rootfs, recipes, attest, policy, rootkey, from_repo, trust } => {
                let store = hammer_core::Store::open(&cli.store)?;
                // El zig del lab (no la semilla): los servicios usan su `zig_version` propia
                // (openssh ⇒ 0.13.0, hermano del default), y si ya están sellados, salen cacheados.
                let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
                let base = hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
                let recipes_dir = std::path::Path::new(&recipes);
                let hash = if let Some(repo) = from_repo {
                    // [Dogfood] userland desde el repo FIRMADO (la imagen arma su userland por la cadena
                    // de suministro de paquetes, no por recetas locales). --attest se combina como capa
                    // posterior; acá producimos el product-rootfs base reproducido del repo.
                    if attest {
                        anyhow::bail!("--from-repo y --attest aún no se combinan; producí el base con --from-repo y atestá aparte");
                    }
                    let trust_dir = trust.ok_or_else(|| {
                        anyhow::anyhow!("--from-repo requiere --trust <dir> (verificar la firma del release)")
                    })?;
                    hammer_bootstrap::product_from_repo(
                        &base,
                        std::path::Path::new(&repo),
                        std::path::Path::new(&trust_dir),
                        &base_cfg,
                        &store,
                    )?
                } else if attest {
                    let mut cfg = hammer_bootstrap::AttestConfig { policy, ..Default::default() };
                    if let Some(path) = rootkey {
                        let bytes = std::fs::read(&path)?;
                        cfg.rootkey = bytes.as_slice().try_into().map_err(|_| {
                            anyhow::anyhow!("la rootkey {path} debe ser exactamente 32 bytes (son {})", bytes.len())
                        })?;
                    }
                    hammer_bootstrap::product_attested(&base, &base_cfg, recipes_dir, &store, &cfg)?
                } else {
                    hammer_bootstrap::product(&base, &base_cfg, recipes_dir, &store)?
                };
                println!("{hash}");
            }
            BootstrapCmd::Stage2 { rootfs, verify } => {
                let store = hammer_core::Store::open(&cli.store)?;
                let rootfs_hash =
                    hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
                let report = hammer_bootstrap::stage2(&rootfs_hash, &store)?;
                println!("stage1 content-hash: {}", report.stage1_content);
                match verify {
                    None => println!("referencia anclada (rebuild nativo de stage1': correr en la VM)"),
                    Some(rebuilt) => {
                        let rb =
                            hammer_core::ArtifactHash::from_hex(rebuilt.trim_start_matches("b3:"));
                        match hammer_bootstrap::verify_against(&report, rb) {
                            hammer_bootstrap::Reproducibility::Reproducible => {
                                println!("✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit)");
                            }
                            hammer_bootstrap::Reproducibility::Divergent { stage1, rebuilt } => {
                                anyhow::bail!(
                                    "✗ DIVERGENTE: stage1={stage1} rebuilt={rebuilt} — hay no-determinismo que cazar (SDD 09 §2)"
                                );
                            }
                            hammer_bootstrap::Reproducibility::RebuildPending => unreachable!(),
                        }
                    }
                }
            }
            BootstrapCmd::Builder {
                stage1,
                seed_hash,
                seed,
                recipes,
                hammer_bin,
                toolchain,
                toolchain_tag,
                ref_content,
                work_cache,
                swaps,
                out,
            } => {
                let seed_kind = parse_seed_kind(&seed)?;
                let store = hammer_core::Store::open(&cli.store)?;
                let swaps = swaps.iter().map(|s| parse_swap(s)).collect::<anyhow::Result<Vec<_>>>()?;
                let spec = hammer_bootstrap::BuilderSpec {
                    stage1_rootfs: hammer_core::ArtifactHash::from_hex(stage1.trim_start_matches("b3:")),
                    seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
                    seed_kind,
                    recipes_dir: std::path::PathBuf::from(recipes),
                    hammer_bin: std::path::PathBuf::from(hammer_bin),
                    toolchain_src: std::path::PathBuf::from(toolchain),
                    toolchain_tag,
                    ref_content: ref_content
                        .map(|h| hammer_core::ArtifactHash::from_hex(h.trim_start_matches("b3:").to_string())),
                    work_cache: work_cache.map(std::path::PathBuf::from),
                    swaps,
                };
                let out = std::path::PathBuf::from(out);
                let report = hammer_bootstrap::builder_rootfs(&spec, &store, &out)?;
                println!("builder rootfs: {}", report.builder_hash);
                println!("ensamblado en: {}", report.out_dir.display());
                println!(
                    "siguiente: empaquetar como initramfs y bootear en la VM; adentro corre `rebuild-stage1` (runbook §8c)"
                );
            }
            BootstrapCmd::All { url, sha256, version, seed, recipes } => {
                let kind = parse_seed_kind(&seed)?;
                let store = hammer_core::Store::open(&cli.store)?;
                let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
                let spec = hammer_bootstrap::SeedSpec { kind, version, url, sha256 };
                let report = hammer_bootstrap::all(
                    &spec,
                    std::path::Path::new(&recipes),
                    &base_cfg,
                    &store,
                )?;
                println!("Stage 0 · semilla     {}", report.seed_hash);
                println!("Stage 1 · rootfs      {}", report.stage1_rootfs);
                println!("Stage 2 · referencia  {}", report.stage2.stage1_content);
                println!();
                println!(
                    "bootstrap.json: {} línea(s) anotada(s) (`hammer bootstrap manifest` para verlo)",
                    report.manifest.entries.len()
                );
                println!(
                    "auto-alojamiento: el rebuild nativo que sella ✓ REPRODUCIBLE corre dentro del rootfs (VM):"
                );
                println!(
                    "  ./scripts/selfhost-verify.sh   # reconstruye stage1' y lo compara con la referencia de arriba"
                );
            }
            BootstrapCmd::Manifest => {
                let store = hammer_core::Store::open(&cli.store)?;
                let manifest = hammer_bootstrap::BootstrapManifest::load(&store)?;
                println!("{}", serde_json::to_string_pretty(&manifest)?);
            }
        },
        Cmd::Mirror { sub } => {
            let local = std::path::PathBuf::from(&cli.store);
            match sub {
                MirrorCmd::Push { remote } => {
                    let remote = std::path::PathBuf::from(&remote);
                    let r = hammer_mirror::sync(&local, &remote)?;
                    print_sync("push", &r);
                    if !r.ok() {
                        anyhow::bail!("mirror push incompleto: {} fallo(s), {} conflicto(s)", r.failed.len(), r.conflicts.len());
                    }
                }
                MirrorCmd::Pull { remote } => {
                    let remote = std::path::PathBuf::from(&remote);
                    let r = hammer_mirror::sync(&remote, &local)?;
                    print_sync("pull", &r);
                    if !r.ok() {
                        anyhow::bail!("mirror pull incompleto: {} fallo(s), {} conflicto(s)", r.failed.len(), r.conflicts.len());
                    }
                }
                MirrorCmd::Status { remote } => {
                    let remote = std::path::PathBuf::from(&remote);
                    let (missing_remote, missing_local, conflicts) = hammer_mirror::diff(&local, &remote)?;
                    println!("faltan en el remoto (push los lleva): {}", missing_remote.len());
                    for a in &missing_remote { println!("  → {}", a.dir); }
                    println!("faltan en el local (pull los trae):   {}", missing_local.len());
                    for a in &missing_local { println!("  ← {}", a.dir); }
                    println!("conflictos (mismo hash, otro contenido): {}", conflicts.len());
                    for d in &conflicts { println!("  ✗ {d}"); }
                }
            }
        }
        Cmd::Upgrade { root, state_root, journal, sub } => {
            let store = std::path::PathBuf::from(&cli.store);
            let root = std::path::PathBuf::from(&root);
            let state_root = std::path::PathBuf::from(&state_root);
            match sub {
                UpgradeCmd::Apply { tree_dir, expected } => {
                    let j = hammer_journal::Journal::open(&journal)?;
                    let r = hammer_upgrade::apply(
                        &store, &tree_dir, expected.as_deref(), &root, &state_root, Some(&j),
                    )?;
                    if r.already_current {
                        println!("generación {} ya viva — árbol idéntico, no-op", r.generation);
                    } else {
                        println!("✓ generación {} aplicada", r.generation);
                        println!("  + {} añadidos, ~ {} pisados, - {} retirados",
                            r.added.len(), r.replaced.len(), r.removed.len());
                    }
                }
                UpgradeCmd::Rollback => {
                    let j = hammer_journal::Journal::open(&journal)?;
                    let r = hammer_upgrade::rollback(&root, &state_root, Some(&j))?;
                    println!("✓ generación {} revertida", r.reverted);
                    match r.now_current {
                        Some(id) => println!("  ahora viva: generación {id}"),
                        None => println!("  ahora viva: estado pre-upgrades"),
                    }
                    println!("  {} restaurados, {} borrados", r.restored.len(), r.deleted.len());
                }
                UpgradeCmd::Recover { rollback } => {
                    let j = hammer_journal::Journal::open(&journal)?;
                    let r = hammer_upgrade::recover(&store, &root, &state_root, Some(&j), rollback)?;
                    if !r.recovered {
                        println!("no hay apply interrumpido — nada que recuperar");
                    } else if r.rolled_forward {
                        println!("✓ apply interrumpido COMPLETADO (generación {})", r.generation);
                    } else {
                        println!("✓ apply interrumpido DESHECHO (generación {} descartada)", r.generation);
                    }
                }
                UpgradeCmd::Status => {
                    if let Some(p) = hammer_upgrade::pending(&state_root)? {
                        println!("⚠ apply interrumpido pendiente (generación {}) — corré `hammer upgrade recover [--rollback]`", p.id);
                    }
                    let cur = hammer_upgrade::current(&state_root)?;
                    match cur {
                        Some(id) => println!("generación viva: {id}"),
                        None => println!("generación viva: (ninguna — sistema sin upgrades aplicados)"),
                    }
                    let gens = hammer_upgrade::list(&state_root)?;
                    println!("historial: {} generación(es)", gens.len());
                    for g in &gens {
                        let mark = if Some(g.id) == cur { "*" } else { " " };
                        println!("  {mark} {} → {} ({} ficheros)", g.id, g.tree_dir, g.files.len());
                    }
                }
                UpgradeCmd::Prune { keep } => {
                    let r = hammer_upgrade::prune(&state_root, keep)?;
                    println!("✓ prune: {} borradas, {} conservadas", r.removed.len(), r.kept.len());
                    for id in &r.removed { println!("  - {id}"); }
                }
            }
        }
        Cmd::Boot { root, state_root, journal, sub } => {
            use hammer_upgrade::boot_graph;
            let root = std::path::PathBuf::from(&root);
            let state_root = std::path::PathBuf::from(&state_root);
            match sub {
                BootCmd::Graph { out, stdout } => {
                    if stdout {
                        let g = boot_graph::build(&state_root)?;
                        println!("{}", serde_json::to_string_pretty(&g)?);
                    } else {
                        boot_graph::emit(&state_root, &out)?;
                        let g = boot_graph::build(&state_root)?;
                        let vivo = if g.current.is_empty() { "(ninguno)" } else { &g.current };
                        println!("✓ grafo de arranque escrito en {}", out.display());
                        println!("  {} nodo(s); vivo: {vivo}", g.nodes.len());
                    }
                }
                BootCmd::Activate { id, from_select, select } => {
                    let id = match (id, from_select) {
                        (Some(id), _) => id,
                        // Idempotente (contrato PLAN-KIKIN §9): arje-zero llama esto incondicional
                        // en TODO apagado; sin selección no hay nada que aplicar y el apagado sigue.
                        (None, true) => match std::fs::read_to_string(&select) {
                            Ok(s) if s.trim().is_empty() => {
                                println!(
                                    "hammer boot activate: {} vacío — sin selección, no-op",
                                    select.display()
                                );
                                return Ok(());
                            }
                            Ok(s) => s.trim().to_string(),
                            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                                println!(
                                    "hammer boot activate: sin selección ({} no existe) — no-op",
                                    select.display()
                                );
                                return Ok(());
                            }
                            Err(e) => anyhow::bail!("no pude leer {}: {e}", select.display()),
                        },
                        (None, false) => anyhow::bail!(
                            "pasá un <id> o usá --from-select para leerlo de {}",
                            select.display()),
                    };
                    let j = hammer_journal::Journal::open(&journal)?;
                    let graph_out = std::path::PathBuf::from(boot_graph::BOOT_GRAPH_PATH);
                    activate_and_report(&root, &state_root, &j, &id, &graph_out)?;
                    // La selección ya se aplicó: consumir el canal para que una segunda corrida
                    // (u otro apagado en la misma sesión) sea el no-op de arriba. Sólo en éxito —
                    // si activate falló, el `?` ya salió y el fichero queda para diagnóstico.
                    if from_select {
                        let _ = std::fs::remove_file(&select);
                    }
                }
                BootCmd::Menu { compositor, out, select } => {
                    // 1) Publicar el grafo (siempre, aunque no haya compositor: otros lo leen).
                    if let Some(parent) = out.parent() { std::fs::create_dir_all(parent).ok(); }
                    boot_graph::emit(&state_root, &out)?;
                    let g = boot_graph::build(&state_root)?;
                    println!("hammer boot menu: grafo en {} ({} nodo/s)", out.display(), g.nodes.len());

                    // 2) Lanzar el compositor (primer token = binario; resto = args). Limpiamos un
                    //    boot-select viejo para no re-activar una elección anterior.
                    let _ = std::fs::remove_file(&select);
                    let parts: Vec<&str> = compositor.split_whitespace().collect();
                    let Some((bin, args)) = parts.split_first() else {
                        anyhow::bail!("--compositor vacío");
                    };
                    match std::process::Command::new(bin).args(args).status() {
                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                            println!("hammer boot menu: sin compositor ({bin}) — arranque normal");
                            return Ok(());
                        }
                        Err(e) => {
                            eprintln!("hammer boot menu: no pude lanzar {bin}: {e} — arranque normal");
                            return Ok(());
                        }
                        Ok(s) if !s.success() => {
                            eprintln!("hammer boot menu: el compositor salió con {s} — sigo con lo que dejó en boot-select");
                        }
                        Ok(_) => {}
                    }

                    // 3) ¿El usuario eligió un nodo? Si sí, activarlo; si no, arranque normal.
                    let sel = std::fs::read_to_string(&select).ok()
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty());
                    match sel {
                        None => println!("hammer boot menu: sin selección — arranque normal"),
                        Some(id) => {
                            let j = hammer_journal::Journal::open(&journal)?;
                            activate_and_report(&root, &state_root, &j, &id, &out)?;
                        }
                    }
                }
            }
        }
    }
    Ok(())
}

/// Activa un nodo del grafo de arranque, imprime el resultado y republica el grafo (la vista de mirada
/// debe reflejar el nuevo nodo vivo). Compartido por `hammer boot activate` y `hammer boot menu`.
fn activate_and_report(
    root: &std::path::Path,
    state_root: &std::path::Path,
    journal: &hammer_journal::Journal,
    id: &str,
    graph_out: &std::path::Path,
) -> anyhow::Result<()> {
    use hammer_upgrade::boot_graph;
    let r = boot_graph::activate(root, state_root, Some(journal), id)?;
    if r.already_current {
        println!("✓ nodo {} ya era el vivo — no-op", r.target);
    } else if r.kind == boot_graph::NodeKind::Recovery {
        println!("✓ recuperación: generación {:?} revertida", r.rolled_back);
    } else {
        println!("✓ nodo {} activado", r.target);
        println!("  {} generación(es) revertida(s): {:?}", r.rolled_back.len(), r.rolled_back);
    }
    // Republica el grafo: la vista de mirada debe reflejar el nuevo nodo vivo.
    boot_graph::emit(state_root, graph_out).ok();
    Ok(())
}

/// Imprime el reporte de un `mirror push|pull`.
fn print_sync(verbo: &str, r: &hammer_mirror::SyncReport) {
    for d in &r.copied {
        println!("  ✓ {d}");
    }
    for (d, why) in &r.failed {
        println!("  ✗ {d}: {why}");
    }
    for d in &r.conflicts {
        println!("  ⚠ conflicto {d} (no sobrescrito)");
    }
    println!(
        "mirror {verbo}: {} copiados, {} ya presentes, {} conflictos, {} fallos",
        r.copied.len(),
        r.skipped_present,
        r.conflicts.len(),
        r.failed.len()
    );
}

/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
    let yaml = std::fs::read_to_string(file)
        .map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
    hammer_core::Swm::from_yaml(&yaml)
        .map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
}

/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
/// pueda decidir entre fallar o seguir con un warning.
fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
    let Some(p) = path else { return Ok(None) };
    if !p.is_file() {
        return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
    }
    let bytes = std::fs::read(p)?;
    let base: hammer_core::BaseRef =
        serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
    Ok(Some(base))
}

fn print_base_compat(c: &hammer_core::BaseCompat) {
    use hammer_core::BaseCompat::*;
    match c {
        Ok => eprintln!("verify_base: OK"),
        DistroMismatch { local, swm } => eprintln!(
            "verify_base: distro_version no coincide — local={local} swm={swm}"
        ),
        PinMismatch(diffs) => {
            eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
            for d in diffs {
                let local = d.local.as_deref().unwrap_or("(ausente)");
                eprintln!("  {}: local={local} swm={}", d.name, d.swm);
            }
        }
    }
}

fn run_swm_verify(
    file: &str,
    base_ref: Option<&std::path::Path>,
    trust: Option<&std::path::Path>,
    evidence: bool,
    store_path: &std::path::Path,
) -> anyhow::Result<()> {
    let swm = load_swm(file)?;
    swm.verify_schema()
        .map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
    println!("schema: OK  ({} mutación(es))", swm.mutations.len());
    // Verificación de hash de los file_drop inline (no toca disco).
    for (i, m) in swm.mutations.iter().enumerate() {
        if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
            hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
                anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
            })?;
        }
    }
    println!("file_drop inline: OK");
    if let Some(local) = load_local_base(base_ref)? {
        print_base_compat(&swm.verify_base(&local));
    } else {
        eprintln!("verify_base: omitido (sin --base-ref)");
    }
    print_sig_status(&swm, trust)?;
    if evidence {
        run_swm_evidence(&swm, store_path)?;
    }
    Ok(())
}

/// [H1b] Reproduce cada paquete `source_patch` del `.swm` y corre su bloque `evidence` en el sandbox
/// reproducible. Imprime el veredicto por check y ABORTA (exit != 0) si alguno falla — el gate de
/// "reproduce + estas N pruebas pasan" antes de que el humano proponga (SDD 15 §H1). Frontera:
/// paquetes con build-deps necesitan esas recetas resolubles en el catálogo del lab (como `install`);
/// para el caso self-contained (binario estático sin deps) alcanza con el `.swm`.
fn run_swm_evidence(swm: &hammer_core::Swm, store_path: &std::path::Path) -> anyhow::Result<()> {
    // Corto-circuito ANTES de tocar el store/catálogo: si ninguna mutación trae evidencia, no hay
    // nada que reproducir (y no forzamos abrir un store que quizá no exista/no sea escribible).
    let has_evidence = swm.mutations.iter().any(|m| {
        matches!(m, hammer_core::Mutation::SourcePatch { evidence, .. } if !evidence.is_empty())
    });
    if !has_evidence {
        println!("evidence: ninguna mutación declara evidencia (nada que verificar)");
        return Ok(());
    }

    let store = hammer_core::Store::open(store_path)?;
    let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
    let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(&cfg, None);
    std::fs::create_dir_all(&catalog_dir)?;

    let mut ran = 0usize;
    let mut failed = 0usize;
    for m in &swm.mutations {
        let hammer_core::Mutation::SourcePatch { evidence, target_bin, .. } = m else {
            continue;
        };
        if evidence.is_empty() {
            continue;
        }
        let recipe =
            hammer_build::swm_bridge::recipe_from_source_patch(m, None, &catalog_dir)?;
        println!(
            "evidence: reproduciendo {target_bin} y corriendo {} check(s)…",
            evidence.checks.len()
        );
        let report = hammer_build::run_evidence(&recipe, &cfg, &store)?;
        for r in &report.results {
            let mark = if r.outcome.passed { "✓" } else { "✗" };
            println!(
                "  {mark} [{}] {} — {}",
                r.kind.as_str(),
                r.cmd,
                r.outcome.detail
            );
        }
        ran += report.results.len();
        failed += report.results.iter().filter(|r| !r.outcome.passed).count();
        if report.all_passed() {
            if let Some(lvl) = report.max_level_passed() {
                println!("  → verificado hasta el estrato '{}'", lvl.as_str());
            }
        }
    }

    if ran == 0 {
        println!("evidence: ninguna mutación declara evidencia (nada que verificar)");
        Ok(())
    } else if failed > 0 {
        anyhow::bail!("evidence: {failed}/{ran} check(s) fallaron ⇒ NO proponer");
    } else {
        println!("evidence: {ran} check(s), todos pasan ✓");
        Ok(())
    }
}

const DEFAULT_TRUST_DIR: &str = "/var/lib/hammer/trust";

/// Carga el TrustStore (de `--trust` o el default) y reporta el estado de la firma. La firma
/// informa autoría/integridad; NO autoriza promover (eso lo decide la reproducción + commit
/// humano). Ver `docs/09-trust-model.md`.
fn print_sig_status(
    swm: &hammer_core::Swm,
    trust: Option<&std::path::Path>,
) -> anyhow::Result<()> {
    let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
    let store = hammer_core::TrustStore::load(trust_dir)?;
    let status = swm.verify_signature(&store);
    match &status {
        hammer_core::SigStatus::Trusted { by } => {
            println!("firma: trusted (by {by})");
        }
        hammer_core::SigStatus::UnknownKey { by } => {
            println!(
                "firma: unknown-key (by {by}) — añade su clave a {} para confiar en la autoría",
                trust_dir.display()
            );
        }
        hammer_core::SigStatus::BadSig { by, reason } => {
            eprintln!("firma: BAD-SIG (by {by}): {reason}");
            anyhow::bail!("la firma del .swm no valida");
        }
        hammer_core::SigStatus::Unsigned => {
            println!("firma: unsigned");
        }
    }
    Ok(())
}

fn run_keygen(name: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
    let dir = out.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
    let kp = hammer_core::KeyPair::generate()?;
    let pub_path = kp.write_to(dir, name)?;
    println!("clave pública: {}", pub_path.display());
    println!("clave privada: {} (modo 0600 — guárdala)", dir.join(format!("{name}.ed25519")).display());
    println!("pub b64: {}", kp.public_b64());
    Ok(())
}

/// Empaqueta una receta como un `.swm` de un único `source_patch` (Etapa F). Lee la receta,
/// materializa el texto de sus patches (resueltos contra `base_dir`, concatenados), opcionalmente
/// construye para sellar el `expected_hash` real, firma si se pide, y emite el manifiesto.
#[allow(clippy::too_many_arguments)]
fn run_pack(
    recipe_path: &std::path::Path,
    target_bin: Option<&str>,
    out: Option<&std::path::Path>,
    repo: Option<&std::path::Path>,
    distro_version: &str,
    expected: Option<&str>,
    do_build: bool,
    sign: Option<&std::path::Path>,
    store_path: &str,
) -> anyhow::Result<()> {
    let recipe = hammer_core::Recipe::load_from_path(recipe_path)
        .map_err(|e| anyhow::anyhow!("no pude cargar la receta {}: {e}", recipe_path.display()))?;

    // Las deps por NOMBRE viajan en el `source_patch` y en el índice; `install` resuelve el cierre
    // desde el repo. Avisamos sólo para recordar que esas deps deben ESTAR publicadas en el mismo
    // repo (si no, `install` fallará al resolver el catálogo).
    if !recipe.deps.build.is_empty() {
        eprintln!(
            "nota: este paquete depende de {:?} — publicá esas deps en el mismo repo para que \
             `install` pueda reproducirlo",
            recipe.deps.build
        );
    }

    // Texto de los patches: cada uno se resuelve relativo al directorio de la receta (igual que
    // el lab) y se concatena. El `source_patch` del .swm modela un único `patch` inline; un diff
    // unificado multi-fichero concatenado se aplica hunk-a-hunk igual (git apply / patch -p1).
    let patch_text = if recipe.source.patches.is_empty() {
        None
    } else {
        let mut buf = String::new();
        for p in &recipe.source.patches {
            let path = std::path::Path::new(p);
            let abs = if path.is_absolute() {
                path.to_path_buf()
            } else {
                recipe.base_dir.join(path)
            };
            let txt = std::fs::read_to_string(&abs)
                .map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
            if !buf.is_empty() && !buf.ends_with('\n') {
                buf.push('\n');
            }
            buf.push_str(&txt);
        }
        Some(buf)
    };

    // target_bin: `--target-bin` manda. Si no, y la receta Cargo declara `--bin <X>`, el binario
    // instalado es X (no el nombre del paquete: ripgrep→rg, repgrep→rgr) ⇒ derivamos de ahí. Sólo
    // si no hay `--bin`, caemos a `/usr/bin/{name}` (correcto para C y para name==bin).
    let target_bin = target_bin.map(|s| s.to_string()).unwrap_or_else(|| {
        let bin = recipe
            .build
            .flags
            .windows(2)
            .find(|w| w[0] == "--bin")
            .map(|w| w[1].clone())
            .unwrap_or_else(|| recipe.name.clone());
        format!("/usr/bin/{bin}")
    });

    // expected_hash: `--expected` manda; si no, `--build` lo sella construyendo de verdad en el
    // lab; si ninguno, el paquete sale sin ancla de verificación (válido, sólo más débil).
    let expected_hash: Option<String> = if let Some(h) = expected {
        Some(h.to_string())
    } else if do_build {
        let store = hammer_core::Store::open(store_path)?;
        let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
        let hash = hammer_build::build(&recipe, &cfg, &store)
            .map_err(|e| anyhow::anyhow!("construyendo la receta para sellar expected_hash: {e}"))?;
        eprintln!("construida: {hash}");
        Some(hash.as_str().to_string())
    } else {
        None
    };

    let mut swm = hammer_core::Swm::from_recipe(
        &recipe,
        target_bin,
        patch_text,
        expected_hash.clone(),
        distro_version,
    )?;

    if let Some(key) = sign {
        let priv_b64 = std::fs::read_to_string(key)
            .map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
        let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
        let by = key
            .file_name()
            .map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
            .unwrap_or_else(|| "anon".into());
        swm.signature = Some(kp.sign(&swm, &by));
        eprintln!("firmado por {by}");
    }

    let yaml = swm.to_yaml()?;
    let signed = swm.signature.is_some();
    let mut emitted = false;

    if let Some(dest) = out {
        std::fs::write(dest, &yaml)?;
        eprintln!(
            "empaquetada {} → {} ({} mutación source_patch{})",
            recipe.name,
            dest.display(),
            swm.mutations.len(),
            if signed { ", firmado" } else { "" },
        );
        emitted = true;
    }

    if let Some(repo_dir) = repo {
        // Publicar: el repo asigna la identidad (nombre+versión de la receta). El `.swm` en sí
        // no lleva nombre — el índice es el namespace. Upsert por nombre (idempotente).
        let file_name = format!("{}-{}.swm", recipe.name, recipe.version);
        std::fs::create_dir_all(repo_dir)?;
        std::fs::write(repo_dir.join(&file_name), &yaml)?;
        let mut index = hammer_core::RepoIndex::load(repo_dir)?;
        let orphan = index.upsert(hammer_core::PackageEntry {
            name: recipe.name.clone(),
            version: recipe.version.clone(),
            file: file_name,
            distro_version: distro_version.to_string(),
            expected_hash: expected_hash.clone(),
            signed_by: swm.signature.as_ref().map(|s| s.by.clone()),
            deps: recipe.deps.build.clone(),
        });
        if let Some(old) = orphan {
            // Una versión nueva deja huérfano el `.swm` viejo: lo retiramos (best-effort).
            let _ = std::fs::remove_file(repo_dir.join(old));
        }
        index.save(repo_dir)?;
        eprintln!(
            "publicado {}-{} en repo {} ({} paquete(s) en el índice)",
            recipe.name,
            recipe.version,
            repo_dir.display(),
            index.packages.len(),
        );
        emitted = true;
    }

    if !emitted {
        print!("{yaml}");
    }
    Ok(())
}

/// De dónde viene un repo: un directorio local, o una URL HTTP(S) (el repo son ficheros
/// estáticos — `index.json` + los `.swm` —, así que cualquier servidor estático lo sirve).
enum RepoSource {
    Local(PathBuf),
    Http(String), // base sin barra final
}

impl RepoSource {
    fn parse(s: &str) -> RepoSource {
        if s.starts_with("http://") || s.starts_with("https://") {
            RepoSource::Http(s.trim_end_matches('/').to_string())
        } else {
            RepoSource::Local(PathBuf::from(s))
        }
    }

    fn label(&self) -> String {
        match self {
            RepoSource::Local(d) => d.display().to_string(),
            RepoSource::Http(b) => b.clone(),
        }
    }

    /// Lee el índice del repo (local: `index.json`; http: `GET <base>/index.json`).
    fn read_index(&self) -> anyhow::Result<hammer_core::RepoIndex> {
        match self {
            RepoSource::Local(d) => Ok(hammer_core::RepoIndex::load(d)?),
            RepoSource::Http(base) => {
                let url = format!("{base}/index.json");
                let bytes = hammer_build::download::fetch_url_bytes(&url)?;
                serde_json::from_slice(&bytes)
                    .map_err(|e| anyhow::anyhow!("índice remoto {url}: {e}"))
            }
        }
    }

    /// Materializa un directorio de repo LOCAL del que el resto del flujo lee los `.swm`. Para
    /// `Local` es el propio directorio; para `Http` baja `index.json` + los `.swm` de `files` a un
    /// dir temporal (que el caller mantiene vivo) ⇒ el camino de instalación es idéntico.
    fn materialize(
        &self,
        index: &hammer_core::RepoIndex,
        files: &[&str],
        tmp_holder: &mut Option<tempfile::TempDir>,
    ) -> anyhow::Result<PathBuf> {
        match self {
            RepoSource::Local(d) => Ok(d.clone()),
            RepoSource::Http(base) => {
                let tmp = tempfile::tempdir()?;
                let dir = tmp.path().to_path_buf();
                index.save(&dir)?; // index.json (con firma) re-cargable idéntico
                for f in files {
                    let url = format!("{base}/{f}");
                    let bytes = hammer_build::download::fetch_url_bytes(&url)?;
                    std::fs::write(dir.join(f), bytes)?;
                }
                eprintln!("repo: bajados {} .swm de {base}", files.len());
                *tmp_holder = Some(tmp); // mantener vivo hasta el final del install
                Ok(dir)
            }
        }
    }
}

/// Instala un paquete por nombre desde un repo (Etapa F): resuelve el nombre en el índice,
/// verifica firma (con `--trust`) y delega en el camino de `apply` (reproduce + hidrata). Nunca
/// corre un binario ajeno — sólo reconstruye desde fuente y compara. `repo` puede ser un
/// directorio local o una URL HTTP(S).
#[allow(clippy::too_many_arguments)]
fn run_install(
    store_path: &str,
    name: &str,
    repo: &str,
    prefix: Option<&std::path::Path>,
    skip_source_patch: bool,
    base_ref: Option<&std::path::Path>,
    trust: Option<&std::path::Path>,
    state_root: Option<&std::path::Path>,
    db_path: &std::path::Path,
    require_signed: bool,
    force_slots: bool,
) -> anyhow::Result<()> {
    let source = RepoSource::parse(repo);
    let index = source.read_index()?;

    // Verificación del RELEASE (firma del catálogo entero): si el índice está firmado, una firma
    // mala = índice manipulado ⇒ abortamos antes de tocar nada. Sin firma se informa; con --trust
    // se valida la autoría. Esto es ortogonal a la firma de cada `.swm`.
    {
        let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
        let store = hammer_core::TrustStore::load(trust_dir)?;
        match index.verify_signature(&store) {
            hammer_core::SigStatus::Trusted { by } => eprintln!("release: trusted (by {by})"),
            hammer_core::SigStatus::UnknownKey { by } => {
                eprintln!("release: unknown-key (by {by}) — autoría del catálogo no confiada");
                if require_signed {
                    anyhow::bail!("--require-signed: el release está firmado por '{by}', clave no confiada en {}", trust_dir.display());
                }
            }
            hammer_core::SigStatus::BadSig { by, reason } => {
                anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado")
            }
            hammer_core::SigStatus::Unsigned => {
                eprintln!("release: sin firmar");
                if require_signed {
                    anyhow::bail!("--require-signed: el repo no tiene firma de release (firmalo con `hammer repo sign`)");
                }
            }
        }
    }

    let entry = index.find(name).ok_or_else(|| {
        let avail: Vec<&str> = index.packages.iter().map(|p| p.name.as_str()).collect();
        anyhow::anyhow!(
            "paquete '{name}' no está en el repo {}. Disponibles: {}",
            source.label(),
            if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
        )
    })?;

    // Materializamos un repo LOCAL con los `.swm` que vamos a tocar (el target + su cierre de
    // deps). Para un repo local es el propio directorio; para HTTP, baja esos `.swm` a un temporal.
    // De ahí en más el flujo es idéntico (lee ficheros de `repo_dir`).
    let closure = index.resolve_closure(name)?;
    let needed: Vec<&str> = closure.iter().map(|p| p.file.as_str()).collect();
    let mut _tmp_repo: Option<tempfile::TempDir> = None;
    let repo_dir = source.materialize(&index, &needed, &mut _tmp_repo)?;
    let repo_dir = repo_dir.as_path();

    let swm_path = hammer_core::RepoIndex::swm_path(repo_dir, entry);
    eprintln!(
        "install {} {} ← {}",
        entry.name,
        entry.version,
        swm_path.display()
    );
    let swm_path_str = swm_path
        .to_str()
        .ok_or_else(|| anyhow::anyhow!("ruta de .swm no es UTF-8: {}", swm_path.display()))?;

    // Verificación de firma ANTES de reproducir nada (sólo con --trust; sin él, se informa
    // que no se verificó, como el resto de la CLI).
    if trust.is_some() {
        let swm = load_swm(swm_path_str)?;
        print_sig_status(&swm, trust)?;
    } else {
        eprintln!("firma: no verificada (pasá --trust <dir> para validar la autoría)");
    }

    // Gate de compatibilidad (H4b/H4c — configs compatibles, SDD 15 §H4). Antes de reconstruir o
    // tocar nada, evaluamos las superficies que el paquete tocará contra el estado INSTALADO. Es el
    // chequeo local y reproducible de "cuando instalo, ¿es compatible con lo que ya tengo?".
    let entrant_swm = load_swm(swm_path_str)?;

    // H4c — vía OBSERVADA: los paths que el paquete escribirá (declarados en el .swm, sin que el
    // autor prometa `slots`). Si alguno lo posee OTRO paquete instalado, es una colisión de fichero
    // (elección, no error) — salvo --force-slots. Se conoce antes de hidratar ⇒ aborta sin tocar nada.
    {
        let idb = hammer_core::InstalledDb::load(db_path)?;
        let paths = hammer_core::compat::output_paths(&entrant_swm);
        let cols = hammer_core::compat::path_collisions(&idb, name, &paths);
        if !cols.is_empty() {
            let detalle = cols
                .iter()
                .map(|c| format!("{} (de '{}')", c.path, c.propietario))
                .collect::<Vec<_>>()
                .join(", ");
            if force_slots {
                eprintln!("compat: {} colisión(es) de fichero: {detalle}; --force-slots ⇒ sobreescribo", cols.len());
            } else {
                anyhow::bail!(
                    "compat: COLISIÓN de fichero — '{name}' pisaría {detalle}. Es una elección: \
                     pasá --force-slots para sobreescribir el/los fichero(s) del otro paquete."
                );
            }
        }
    }

    // H4e — vía OBSERVADA de los requisitos: por cada dep de runtime, la versión (expected_hash)
    // que hay en el repo (contra la que el paquete se construyó). Si la tengo instalada a OTRO
    // hash, la diverguí ⇒ rechazo duro (el caso wayland, derivado — no lo salva --force-slots).
    {
        let idb = hammer_core::InstalledDb::load(db_path)?;
        let req = hammer_core::compat::observed_requires(&entrant_swm, &index);
        let conflicts = hammer_core::compat::version_conflicts(&idb, &req);
        if !conflicts.is_empty() {
            let d = conflicts
                .iter()
                .map(|c| format!("{} necesita {} pero tenés {}", c.dep, c.requerido, c.instalado))
                .collect::<Vec<_>>()
                .join("; ");
            anyhow::bail!(
                "compat: INCOMPATIBLE — '{name}' se construyó contra deps que divergiste: {d}. \
                 No encaja con tu sistema (rechazo duro, no lo fuerza --force-slots)."
            );
        }
    }

    // H4b — vía DECLARADA: las superficies (`slots`) que el autor declara reclamar/requerir.
    let entrant_slots = source_patch_slots(&entrant_swm);
    if !entrant_slots.is_empty() {
        entrant_slots
            .validate()
            .map_err(|e| anyhow::anyhow!("slots del paquete inválidos: {e}"))?;
        let idb = hammer_core::InstalledDb::load(db_path)?;
        let estado = idb.system_state();
        match hammer_core::compat::evaluar(&estado, &entrant_slots) {
            hammer_core::CompatVeredicto::Compatible => {
                eprintln!(
                    "compat: compatible ({} reclamo(s), {} requisito(s) resuelto(s))",
                    entrant_slots.claims.len(),
                    entrant_slots.requires.len()
                );
            }
            // Caso wayland: una dependencia sobre una superficie que el usuario divergió. Rechazo duro.
            hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } => {
                anyhow::bail!(
                    "compat: INCOMPATIBLE — '{name}' requiere la superficie '{slot}' @ {requerido}, \
                     pero el sistema tiene {}. Divergiste esa superficie: no se instala sobre ella.",
                    presente.as_deref().unwrap_or("(ausente)")
                );
            }
            // Caso logo: dos configs escriben la misma superficie. Es una ELECCIÓN, no un error.
            hammer_core::CompatVeredicto::Colision { slot, instalado, entrante } => {
                if force_slots {
                    eprintln!(
                        "compat: colisión en '{slot}' ({instalado} → {entrante}); --force-slots ⇒ me quedo con la nueva"
                    );
                } else {
                    anyhow::bail!(
                        "compat: COLISIÓN en la superficie '{slot}' — ya la ocupa {instalado} y '{name}' \
                         quiere {entrante}. Es una elección: pasá --force-slots para adoptar la nueva."
                    );
                }
            }
        }
    }

    // Dependencias: resolvemos el cierre transitivo de build-deps desde el índice (deps antes
    // que el dependiente) y poblamos el catálogo de recetas que el lab consultará al reproducir
    // el `source_patch`. Sin esto, un paquete con build-deps (openssh→zlib,openssl) no podría
    // reconstruirse: el sandbox no tendría headers/libs de las deps.
    if !entry.deps.is_empty() && !skip_source_patch {
        let store = hammer_core::Store::open(store_path)?;
        let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
        let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(&cfg, None);
        std::fs::create_dir_all(&catalog_dir)?;

        // El cierre incluye al propio target al final; el catálogo sólo necesita las DEPS (las
        // que el target y sus deps resolverán por nombre). Escribimos un `{dep}.toml` por cada una.
        let closure = index.resolve_closure(name)?;
        let dep_pkgs: Vec<&hammer_core::PackageEntry> =
            closure.into_iter().filter(|p| p.name != name).collect();
        if !dep_pkgs.is_empty() {
            eprintln!(
                "deps: resolviendo catálogo para {} dep(s): {}",
                dep_pkgs.len(),
                dep_pkgs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
            );
        }
        for dep in dep_pkgs {
            let dep_swm_path = hammer_core::RepoIndex::swm_path(repo_dir, dep);
            let dep_swm = load_swm(
                dep_swm_path
                    .to_str()
                    .ok_or_else(|| anyhow::anyhow!("ruta no UTF-8: {}", dep_swm_path.display()))?,
            )?;
            let mutation = source_patch_of(&dep_swm).ok_or_else(|| {
                anyhow::anyhow!("la dep '{}' no es un paquete source_patch", dep.name)
            })?;
            // El nombre de la receta DEBE ser el nombre del paquete: así el dependiente la
            // resuelve como `{dep}.toml`.
            let dep_recipe = hammer_build::swm_bridge::recipe_from_source_patch(
                mutation,
                Some(&dep.name),
                &catalog_dir,
            )?;
            let toml = dep_recipe.to_toml()?;
            std::fs::write(catalog_dir.join(format!("{}.toml", dep.name)), toml)?;
        }
    }

    // Identidad del paquete para la DB (antes de mover nada; `entry` vive mientras `index`).
    let pkg_name = entry.name.clone();
    let pkg_version = entry.version.clone();
    let pkg_hash = entry.expected_hash.clone();

    // El resto (schema, base, reproduce source_patch CON sus deps del catálogo, hidrata) es
    // exactamente `apply`: build_source_patch usa el MISMO catalog_dir determinista.
    let created = run_apply(
        store_path,
        swm_path_str,
        prefix,
        skip_source_patch,
        base_ref,
        state_root,
        Some(&pkg_name), // reproduce con el nombre del paquete ⇒ cache-hit del corpus, sin duplicar
    )?;

    // Registrar en la DB de instalados (salvo dry-run de schema). Permite `hammer uninstall`.
    if !skip_source_patch {
        let mut idb = hammer_core::InstalledDb::load(db_path)?;
        let files: Vec<String> = created.iter().map(|p| p.to_string_lossy().into_owned()).collect();
        let nfiles = files.len();
        idb.record(hammer_core::InstalledPackage {
            name: pkg_name.clone(),
            version: pkg_version,
            hash: pkg_hash,
            files,
            // Las superficies reclamadas quedan en la DB ⇒ pasan a formar el `system_state` que el
            // gate de la PRÓXIMA instalación consultará (H4b). Así una colisión se detecta después.
            claims: entrant_slots.claims.clone(),
        });
        idb.save(db_path)?;
        eprintln!("registrado {pkg_name} ({nfiles} fichero(s)) en {}", db_path.display());
    }
    Ok(())
}

/// Importa una receta desde el JSON normalizado de nixpkgs (Etapa G).
fn run_import_nix(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
    let json = if file == "-" {
        use std::io::Read;
        let mut s = String::new();
        std::io::stdin().read_to_string(&mut s)?;
        s
    } else {
        std::fs::read_to_string(file)
            .map_err(|e| anyhow::anyhow!("leyendo {file}: {e}"))?
    };
    let pkg: nix_import::NixPkg = serde_json::from_str(&json)
        .map_err(|e| anyhow::anyhow!("JSON normalizado de nix inválido: {e}"))?;
    let toml = nix_import::to_recipe_toml(&pkg).map_err(|e| anyhow::anyhow!(e))?;
    // Sanity: la receta emitida debe parsear como Recipe de hammer.
    hammer_core::Recipe::from_toml(&toml)
        .map_err(|e| anyhow::anyhow!("la receta importada no parsea (bug del importador): {e}"))?;
    match out {
        Some(dest) => {
            std::fs::write(dest, &toml)?;
            eprintln!("importada {} → {}", pkg.pname, dest.display());
        }
        None => print!("{toml}"),
    }
    Ok(())
}

/// `true` si `s` ya es un SHA de git (40 hex sha1 o 64 hex sha256) ⇒ ya está anclado.
fn is_git_sha(s: &str) -> bool {
    (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
}

/// Resuelve un tag/ref a su SHA de commit inmutable vía `git ls-remote` (cualquier host git, sin
/// API ni tokens). Para tags ANOTADOS prefiere el commit dereferenciado (`^{}`); para ligeros, el
/// ref directo. Devuelve error si el tag no existe en el remoto.
fn resolve_git_sha(repo: &str, tag: &str) -> anyhow::Result<String> {
    let out = std::process::Command::new("git")
        .args([
            "ls-remote",
            repo,
            &format!("refs/tags/{tag}"),
            &format!("refs/tags/{tag}^{{}}"),
            tag,
        ])
        .output()
        .map_err(|e| anyhow::anyhow!("git ls-remote: {e}"))?;
    if !out.status.success() {
        anyhow::bail!("git ls-remote {repo} {tag}: {}", String::from_utf8_lossy(&out.stderr).trim());
    }
    let text = String::from_utf8_lossy(&out.stdout);
    // Líneas `<sha>\t<ref>`. Preferimos la dereferenciada `^{}` (commit real del tag anotado).
    let mut plain: Option<String> = None;
    for line in text.lines() {
        let mut it = line.split('\t');
        let (Some(sha), Some(rf)) = (it.next(), it.next()) else { continue };
        if rf.ends_with("^{}") {
            return Ok(sha.to_string());
        }
        plain.get_or_insert_with(|| sha.to_string());
    }
    plain.ok_or_else(|| anyhow::anyhow!("'{tag}' no existe como tag/ref en {repo}"))
}

/// Ancla el `commit` de una receta git: tag flotante → SHA inmutable (Etapa G Fase 2).
fn run_pin(recipe_path: &std::path::Path, out: Option<&std::path::Path>) -> anyhow::Result<()> {
    let text = std::fs::read_to_string(recipe_path)
        .map_err(|e| anyhow::anyhow!("leyendo {}: {e}", recipe_path.display()))?;
    let recipe = hammer_core::Recipe::from_toml(&text)
        .map_err(|e| anyhow::anyhow!("{}: {e}", recipe_path.display()))?;

    let (repo, commit) = match recipe.source.kind() {
        Ok(hammer_core::SourceKind::Git { repo, commit }) => (repo.to_string(), commit.to_string()),
        Ok(hammer_core::SourceKind::Tarball { .. }) => {
            eprintln!("{}: tarball (ya anclado por sha256) — nada que pinear", recipe.name);
            return Ok(());
        }
        Err(e) => anyhow::bail!("{}: {e}", recipe.name),
    };
    if is_git_sha(&commit) {
        eprintln!("{}: commit ya es SHA ({}) — ya anclado", recipe.name, &commit[..commit.len().min(12)]);
        return Ok(());
    }

    let sha = resolve_git_sha(&repo, &commit)?;
    // Reescritura dirigida: la línea `commit = "<tag>"` (no tocamos version u otras que casen).
    let mut replaced = false;
    let new_text: String = text
        .lines()
        .map(|l| {
            if !replaced && l.trim_start().starts_with("commit") && l.contains(&format!("\"{commit}\"")) {
                replaced = true;
                l.replace(&format!("\"{commit}\""), &format!("\"{sha}\""))
            } else {
                l.to_string()
            }
        })
        .collect::<Vec<_>>()
        .join("\n");
    let new_text = if text.ends_with('\n') { format!("{new_text}\n") } else { new_text };
    if !replaced {
        anyhow::bail!("no encontré la línea `commit = \"{commit}\"` para reescribir");
    }
    let dest = out.unwrap_or(recipe_path);
    std::fs::write(dest, new_text)?;
    eprintln!("{}: {commit} → {sha} (anclado en {})", recipe.name, dest.display());
    Ok(())
}

/// Importa una receta desde un APKBUILD de Alpine (Etapa G).
fn run_import_alpine(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
    let text = if file == "-" {
        use std::io::Read;
        let mut s = String::new();
        std::io::stdin().read_to_string(&mut s)?;
        s
    } else {
        std::fs::read_to_string(file).map_err(|e| anyhow::anyhow!("leyendo {file}: {e}"))?
    };
    let toml = alpine_import::recipe_from_apkbuild(&text).map_err(|e| anyhow::anyhow!(e))?;
    hammer_core::Recipe::from_toml(&toml)
        .map_err(|e| anyhow::anyhow!("la receta importada no parsea (bug del importador): {e}"))?;
    match out {
        Some(dest) => {
            std::fs::write(dest, &toml)?;
            eprintln!("importada de Alpine → {}", dest.display());
        }
        None => print!("{toml}"),
    }
    Ok(())
}

/// Lista los paquetes instalados de la DB.
fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> {
    let idb = hammer_core::InstalledDb::load(db_path)?;
    if idb.packages.is_empty() {
        println!("nada instalado (DB {})", db_path.display());
        return Ok(());
    }
    println!("{} paquete(s) instalado(s) (DB {}):", idb.packages.len(), db_path.display());
    for p in &idb.packages {
        println!("  {:<16} {:<10} {} fichero(s)", p.name, p.version, p.files.len());
    }
    Ok(())
}

/// [H4d] La BÚSQUEDA de compatibilidad: evalúa cada paquete del repo contra el estado instalado
/// y lo clasifica. Es `compat::filtrar` del prototipo `wawa-memo`, ahora sobre el repo real —
/// read-only, no construye ni toca nada. Combina la vía DECLARADA (slots, H4b) con la OBSERVADA
/// (paths que el paquete escribe, H4c): incompatible (requisito sin resolver) domina; si no, una
/// colisión (de slot o de fichero) ⇒ requiere elección; si no, compatible.
fn run_compat(repo: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
    let source = RepoSource::parse(repo);
    let index = source.read_index()?;
    let idb = hammer_core::InstalledDb::load(db_path)?;
    let estado = idb.system_state();

    // Materializamos TODOS los `.swm` (para HTTP los baja; para local es el propio dir).
    let all_files: Vec<&str> = index.packages.iter().map(|p| p.file.as_str()).collect();
    let mut _tmp: Option<tempfile::TempDir> = None;
    let repo_dir = source.materialize(&index, &all_files, &mut _tmp)?;

    let (mut compatibles, mut elegibles, mut incompatibles) = (Vec::new(), Vec::new(), Vec::new());
    for entry in &index.packages {
        let swm_path = hammer_core::RepoIndex::swm_path(&repo_dir, entry);
        let swm = match swm_path.to_str().map(load_swm) {
            Some(Ok(s)) => s,
            _ => {
                eprintln!("aviso: no pude leer {} — omito", entry.name);
                continue;
            }
        };
        // 1a) Vía OBSERVADA de requisitos (H4e): una dep de runtime divergida ⇒ incompatible.
        let req = hammer_core::compat::observed_requires(&swm, &index);
        let vconf = hammer_core::compat::version_conflicts(&idb, &req);
        if !vconf.is_empty() {
            let d = vconf.iter().map(|c| format!("{}@{} (tenés {})", c.dep, c.requerido, c.instalado)).collect::<Vec<_>>().join(", ");
            incompatibles.push(format!("{:<16} deps divergidas: {d}", entry.name));
            continue;
        }
        // 1b) Vía DECLARADA (slots): una incompatibilidad domina todo.
        let slots = source_patch_slots(&swm);
        let veredicto = hammer_core::compat::evaluar(&estado, &slots);
        if let hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } = &veredicto {
            incompatibles.push(format!(
                "{:<16} requiere '{slot}' @ {requerido}, hay {}",
                entry.name,
                presente.as_deref().unwrap_or("(ausente)")
            ));
            continue;
        }
        // 2) Colisión: de slot declarado (H4b) o de fichero observado (H4c) ⇒ requiere elección.
        let paths = hammer_core::compat::output_paths(&swm);
        let pcols = hammer_core::compat::path_collisions(&idb, &entry.name, &paths);
        if let hammer_core::CompatVeredicto::Colision { slot, entrante, .. } = &veredicto {
            elegibles.push(format!("{:<16} colisión de slot '{slot}' (quiere {entrante})", entry.name));
        } else if !pcols.is_empty() {
            let d = pcols.iter().map(|c| format!("{} (de '{}')", c.path, c.propietario)).collect::<Vec<_>>().join(", ");
            elegibles.push(format!("{:<16} colisión de fichero: {d}", entry.name));
        } else {
            compatibles.push(entry.name.clone());
        }
    }

    println!(
        "compat: {} paquete(s) en {} contra {} superficie(s) instalada(s)",
        index.packages.len(),
        source.label(),
        estado.len()
    );
    println!("\ncompatibles ({}):", compatibles.len());
    for n in &compatibles {
        println!("  ✓ {n}");
    }
    println!("\nrequieren elección ({}) — pasá `install --force-slots`:", elegibles.len());
    for n in &elegibles {
        println!("  ~ {n}");
    }
    println!("\nincompatibles ({}) — descartar:", incompatibles.len());
    for n in &incompatibles {
        println!("  ✗ {n}");
    }
    Ok(())
}

/// Desinstala un paquete: borra los ficheros que registró (salvo los que otro paquete instalado
/// también aporta — refcount) y quita su entrada de la DB. NO deshace `config_edit` (modifican
/// ficheros ajenos, no creados por el paquete).
fn run_uninstall(name: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
    let mut idb = hammer_core::InstalledDb::load(db_path)?;
    let Some(pkg) = idb.find(name).cloned() else {
        let avail: Vec<&str> = idb.packages.iter().map(|p| p.name.as_str()).collect();
        anyhow::bail!(
            "'{name}' no está instalado (DB {}). Instalados: {}",
            db_path.display(),
            if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
        );
    };

    let (mut removed, mut kept) = (0usize, 0usize);
    let mut touched_dirs: Vec<PathBuf> = Vec::new();
    for f in &pkg.files {
        // Refcount: no borramos un fichero que otro paquete instalado también aporta.
        if idb.owned_by_others(name, f) {
            kept += 1;
            continue;
        }
        match std::fs::remove_file(f) {
            Ok(()) => {
                removed += 1;
                if let Some(parent) = std::path::Path::new(f).parent() {
                    touched_dirs.push(parent.to_path_buf());
                }
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // ya no estaba
            Err(e) => eprintln!("  aviso: no pude borrar {f}: {e}"),
        }
    }
    // Recoge los directorios que quedaron vacíos por el borrado (y sus padres, hacia arriba):
    // `remove_dir` sólo borra un dir VACÍO ⇒ se detiene solo al toparse con uno que aún tiene
    // contenido de otros paquetes. Procesamos de más profundo a menos para vaciar de abajo arriba.
    let mut pruned = 0usize;
    touched_dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
    touched_dirs.dedup();
    for dir in touched_dirs {
        let mut cur = dir;
        while std::fs::remove_dir(&cur).is_ok() {
            pruned += 1;
            match cur.parent() {
                Some(p) if !p.as_os_str().is_empty() => cur = p.to_path_buf(),
                _ => break,
            }
        }
    }

    idb.remove(name);
    idb.save(db_path)?;
    eprintln!(
        "desinstalado {name} {} — {removed} fichero(s) borrado(s){}{}",
        pkg.version,
        if kept > 0 { format!(", {kept} conservado(s) (los aporta otro paquete)") } else { String::new() },
        if pruned > 0 { format!(", {pruned} dir(s) vacío(s) retirado(s)") } else { String::new() },
    );
    Ok(())
}

/// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`).
fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> {
    swm.mutations
        .iter()
        .find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. }))
}

/// Las superficies del sistema (H4b) que declara el `source_patch` de un `.swm`, o `Slots` vacío
/// si el paquete no declara topología. Lo consume el gate de compatibilidad de `install`.
fn source_patch_slots(swm: &hammer_core::Swm) -> hammer_core::Slots {
    match source_patch_of(swm) {
        Some(hammer_core::Mutation::SourcePatch { slots, .. }) => slots.clone(),
        _ => hammer_core::Slots::default(),
    }
}

/// Firma el índice de un repo (release). Re-firmá tras publicar.
fn run_repo_sign(
    repo_dir: &std::path::Path,
    key: &std::path::Path,
    by: Option<&str>,
) -> anyhow::Result<()> {
    let mut index = hammer_core::RepoIndex::load(repo_dir)?;
    if index.packages.is_empty() {
        anyhow::bail!("repo {} vacío: nada que firmar", repo_dir.display());
    }
    let priv_b64 = std::fs::read_to_string(key)
        .map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
    let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
    let by = by.map(|s| s.to_string()).unwrap_or_else(|| {
        key.file_name()
            .map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
            .unwrap_or_else(|| "anon".into())
    });
    index.sign(&kp, &by);
    index.save(repo_dir)?;
    println!(
        "release firmado por {by} — {} paquete(s) en {}",
        index.packages.len(),
        repo_dir.display()
    );
    Ok(())
}

/// Verifica la firma del release de un repo contra el TrustStore.
fn run_repo_verify(
    repo_dir: &std::path::Path,
    trust: Option<&std::path::Path>,
) -> anyhow::Result<()> {
    let index = hammer_core::RepoIndex::load(repo_dir)?;
    let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
    let store = hammer_core::TrustStore::load(trust_dir)?;
    match index.verify_signature(&store) {
        hammer_core::SigStatus::Trusted { by } => {
            println!("release: trusted (by {by}) — {} paquete(s)", index.packages.len());
            Ok(())
        }
        hammer_core::SigStatus::UnknownKey { by } => {
            println!(
                "release: unknown-key (by {by}) — añadí su clave a {} para confiar",
                trust_dir.display()
            );
            Ok(())
        }
        hammer_core::SigStatus::BadSig { by, reason } => {
            anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado");
        }
        hammer_core::SigStatus::Unsigned => {
            println!("release: sin firmar (firmá con `hammer repo sign`)");
            Ok(())
        }
    }
}

/// Lista los paquetes publicados en un repositorio.
fn run_repo_list(repo_dir: &std::path::Path) -> anyhow::Result<()> {
    let index = hammer_core::RepoIndex::load(repo_dir)?;
    if index.packages.is_empty() {
        println!("repo {} vacío (sin index.json o sin paquetes)", repo_dir.display());
        return Ok(());
    }
    let rel = match &index.signature {
        Some(s) => format!(" [release firmado por {}]", s.by),
        None => String::new(),
    };
    println!("repo {} — {} paquete(s){}:", repo_dir.display(), index.packages.len(), rel);
    for p in &index.packages {
        let sig = p.signed_by.as_deref().map(|b| format!(" [firmado por {b}]")).unwrap_or_default();
        let anchor = p
            .expected_hash
            .as_deref()
            .map(|h| format!(" expected={h}"))
            .unwrap_or_default();
        println!(
            "  {:<16} {:<10} base={} {}{}{}",
            p.name, p.version, p.distro_version, p.file, anchor, sig
        );
    }
    Ok(())
}

fn run_swm_sign(
    file: &str,
    key: &std::path::Path,
    by: Option<&str>,
    out: Option<&std::path::Path>,
) -> anyhow::Result<()> {
    let mut swm = load_swm(file)?;
    swm.verify_schema()
        .map_err(|e| anyhow::anyhow!("no firmo un .swm con schema inválido: {e}"))?;
    let priv_b64 = std::fs::read_to_string(key)
        .map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
    let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;

    // `by` por defecto: basename de la clave sin el sufijo `.ed25519`.
    let by = by.map(|s| s.to_string()).unwrap_or_else(|| {
        key.file_name()
            .map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
            .unwrap_or_else(|| "anon".into())
    });

    swm.signature = Some(kp.sign(&swm, &by));
    let yaml = swm.to_yaml()?;
    let dest = out.unwrap_or_else(|| std::path::Path::new(file));
    std::fs::write(dest, yaml)?;
    println!("firmado por {by} → {}", dest.display());
    Ok(())
}

/// Aplica un `.swm`. Dos modos:
/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
///   staging.
/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
///   mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
fn run_apply(
    store_path: &str,
    file: &str,
    prefix: Option<&std::path::Path>,
    skip_source_patch: bool,
    base_ref: Option<&std::path::Path>,
    state_root: Option<&std::path::Path>,
    // Nombre del paquete (sólo lo sabe `install`): si está, la reproducción del source_patch usa
    // ESE nombre para la receta sintetizada ⇒ cache-hitea el artefacto del corpus (`<hash>-name`)
    // en vez de derivar el nombre del target_bin y sellar un duplicado (ripgrep→rg). `apply` pasa None.
    pkg_name: Option<&str>,
) -> anyhow::Result<Vec<PathBuf>> {
    let swm = load_swm(file)?;
    swm.verify_schema()?;
    // Ficheros que este apply CREA (hidratados + file_drop + init_rule). Sirven para registrar el
    // paquete en la DB de instalados (config_edit MODIFICA, no crea ⇒ no se registra).
    let mut created: Vec<PathBuf> = Vec::new();

    if let Some(local) = load_local_base(base_ref)? {
        match swm.verify_base(&local) {
            hammer_core::BaseCompat::Ok => {}
            other => {
                print_base_compat(&other);
                anyhow::bail!("base local no compatible con la base del .swm");
            }
        }
    }

    let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
    if prefix.is_none() {
        let root = state_root
            .map(|p| p.to_path_buf())
            .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
        std::fs::create_dir_all(&root)?;
        let id = hammer_overlay::try_overlay(&[], &root)?;
        eprintln!("overlay {id} abierto");
        overlay_id = Some(id);
    }

    let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
    for (i, m) in swm.mutations.iter().enumerate() {
        match m {
            hammer_core::Mutation::SourcePatch { target_bin, .. } => {
                counts.0 += 1;
                if skip_source_patch {
                    eprintln!("  [skip] source_patch #{} → {target_bin}", i + 1);
                    continue;
                }
                let store = hammer_core::Store::open(store_path)?;
                let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
                let hash = hammer_build::build_source_patch(m, &cfg, &store, None, pkg_name)?;
                let artifact = store.find_by_hash(hash.as_str())?;
                let into = hammer_core::apply::rebase_path("/", prefix);
                let report = hammer_build::run_hydrate(
                    &artifact,
                    &into,
                    hammer_core::LinkMode::Static,
                    None,
                )?;
                eprintln!(
                    "  source_patch #{}: {} → hidratados {} archivo(s) en {}",
                    i + 1,
                    hash,
                    report.files.len(),
                    into.display()
                );
                // Sanity: el target_bin declarado debe existir tras la hidratación.
                let abs = hammer_core::apply::rebase_path(target_bin, prefix);
                if !abs.exists() {
                    anyhow::bail!(
                        "source_patch declara target_bin={target_bin} pero no quedó en {}",
                        abs.display()
                    );
                }
                created.extend(report.files.iter().map(|f| f.dst.clone()));
            }
            hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
                counts.1 += 1;
                let target = hammer_core::apply::rebase_path(f, prefix);
                hammer_core::apply::apply_config_edit(&target, inline_diff)?;
                eprintln!("  config_edit #{} → {}", i + 1, target.display());
            }
            hammer_core::Mutation::InitRule { action, service, command } => {
                counts.2 += 1;
                let rules_dir =
                    hammer_core::apply::rebase_path(hammer_core::apply::INIT_RULES_DIR, prefix);
                let p = hammer_core::apply::apply_init_rule(&rules_dir, action, service, command)?;
                eprintln!("  init_rule #{}: {action} {service} → {}", i + 1, p.display());
                created.push(p);
            }
            hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
                counts.3 += 1;
                let target = hammer_core::apply::rebase_path(path, prefix);
                match (content_b64, content_url) {
                    (Some(b64), None) => {
                        hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
                        eprintln!("  file_drop #{} → {}", i + 1, target.display());
                        created.push(target.clone());
                    }
                    (None, Some(url)) => {
                        // Descarga remota (fuera de cualquier sandbox) + verificación BLAKE3
                        // ANTES de escribir nada: nunca tocamos disco con bytes sin verificar.
                        let bytes = hammer_build::download::fetch_url_bytes(url)?;
                        hammer_core::apply::write_filedrop_bytes(&target, &bytes, content_hash)?;
                        eprintln!(
                            "  file_drop #{} ← {url} ({} bytes) → {}",
                            i + 1,
                            bytes.len(),
                            target.display()
                        );
                        created.push(target.clone());
                    }
                    _ => unreachable!("verify_schema lo descarta"),
                }
            }
        }
    }
    eprintln!(
        "apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
        counts.0, counts.1, counts.2, counts.3
    );
    if let Some(id) = &overlay_id {
        println!("{id}");
        eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
    }
    Ok(created)
}

/// Exporta el diario actual como `.swm`. Provenance-aware:
///
/// - Eventos cuyo `Source` apunta a un `artifact` con receta sidecar en el store
///   (`.hammer/recipe.toml`) se agrupan por `artifact_hash` y emiten **un único**
///   `source_patch` por grupo, con repo/commit/build de la receta original. El receptor
///   reconstruye el binario desde fuente — provenance real, no un binario opaco.
/// - Eventos sin provenance trazable (`Source::External`, builds viejos sin sidecar,
///   `HammerCommit` con `artifact: None`) caen al fallback `file_drop` con `content_b64`
///   + hash, reproduciendo byte-a-byte pero perdiendo la receta. Se cuenta como warning.
/// - Eventos `Delete` invalidan estados previos del mismo path y luego se ignoran (un
///   `.swm` describe el estado final por adición/edición).
fn run_export(
    base_ref_path: Option<&std::path::Path>,
    journal_dir: &std::path::Path,
    since: Option<&str>,
    store_root: &str,
) -> anyhow::Result<()> {
    use std::collections::BTreeMap;

    let j = hammer_journal::Journal::open(journal_dir)?;
    let events = j.read_all()?;
    let filtered: Vec<_> = match since {
        Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
        None => events,
    };

    let base = match load_local_base(base_ref_path)? {
        Some(local) => hammer_core::Base {
            distro_version: local.distro_version,
            pins: local.pins,
        },
        None => {
            eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
            hammer_core::Base {
                distro_version: "unknown".into(),
                pins: BTreeMap::new(),
            }
        }
    };

    // El store es opcional: si el directorio no existe, seguimos en modo "todo file_drop"
    // (igual que el comportamiento pre-provenance). El receptor no nota la diferencia.
    let store_opt = match hammer_core::Store::open(store_root) {
        Ok(s) => Some(s),
        Err(e) => {
            eprintln!(
                "warning: store {store_root} no accesible ({e}); export sin provenance"
            );
            None
        }
    };

    let (mutations, stats) = build_export_mutations(&filtered, store_opt.as_ref());

    let swm = hammer_core::Swm {
        swm_version: 1,
        base,
        mutations,
        signature: None,
    };
    let yaml = swm.to_yaml()?;
    print!("{yaml}");
    eprintln!(
        "export: {} source_patch(es), {} file_drop(s), {} warning(s){}",
        stats.source_patches,
        stats.file_drops,
        stats.warnings,
        if stats.missing_recipe > 0 {
            format!(" ({} artefacto(s) sin sidecar de receta)", stats.missing_recipe)
        } else {
            String::new()
        },
    );
    Ok(())
}

#[derive(Debug, Default)]
struct ExportStats {
    source_patches: usize,
    file_drops: usize,
    warnings: usize,
    /// Cuántos artefactos referenciados por eventos no traían `.hammer/recipe.toml`
    /// y por tanto cayeron a file_drop. Útil para que el humano sepa que parte del
    /// export perdió provenance (típicamente builds anteriores a Fase 4).
    missing_recipe: usize,
}

/// Construye la lista de mutaciones del `.swm` exportado. Función pura sobre `events` +
/// el store: testeable sin abrir el journal real ni la CLI.
///
/// Estrategia:
/// 1. Aplicamos las semánticas de Delete (invalida estados anteriores del mismo path) y
///    dedupeamos por path quedándonos con el último evento.
/// 2. Particionamos los eventos sobrevivientes en dos: los que tienen un `artifact_hash`
///    cuyo store contiene la receta sidecar (→ candidates para `source_patch` agrupado),
///    y el resto (→ `file_drop`).
/// 3. Por cada grupo `artifact_hash` con receta: emitimos UN `source_patch`. El
///    `target_bin` es el primer path del grupo en orden alfabético; el resto de paths
///    queda implícito (el receptor hidrata el artefacto completo).
/// 4. Por cada evento de fallback: leemos el contenido actual del disco, codificamos
///    base64 y emitimos `file_drop`. Si la lectura falla, contamos warning y omitimos.
fn build_export_mutations(
    events: &[hammer_journal::MutationEvent],
    store: Option<&hammer_core::Store>,
) -> (Vec<hammer_core::Mutation>, ExportStats) {
    use base64::{engine::general_purpose::STANDARD, Engine as _};
    use std::collections::BTreeMap;

    // Paso 1: dedup respetando Delete.
    let mut last_by_path: BTreeMap<PathBuf, &hammer_journal::MutationEvent> = BTreeMap::new();
    for ev in events {
        if matches!(ev.op, hammer_journal::MutationOp::Delete) {
            last_by_path.remove(&ev.path);
            continue;
        }
        last_by_path.insert(ev.path.clone(), ev);
    }

    let mut stats = ExportStats::default();

    // Paso 2: particionamos. Para que la asignación a `source_patch` requiera la receta,
    // resolvemos el sidecar acá; si no está, el evento cae al fallback.
    //
    // Estructura: artifact_hash → (Recipe, Vec<path>). Los paths se ordenarán antes de
    // emitir, para que el target_bin sea determinista (importante para tests).
    let mut groups: BTreeMap<String, (hammer_core::Recipe, Vec<PathBuf>)> = BTreeMap::new();
    let mut fallback: Vec<(PathBuf, &hammer_journal::MutationEvent)> = Vec::new();

    let mut artifacts_seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    let mut artifacts_without_recipe: std::collections::BTreeSet<String> =
        std::collections::BTreeSet::new();

    for (path, ev) in &last_by_path {
        let artifact = match &ev.by.source {
            hammer_journal::Source::HammerHydrate { artifact } => Some(artifact.clone()),
            hammer_journal::Source::HammerCommit { artifact: Some(a), .. } => Some(a.clone()),
            _ => None,
        };
        let Some(art) = artifact else {
            fallback.push((path.clone(), *ev));
            continue;
        };
        artifacts_seen.insert(art.clone());

        // Si ya conseguimos la receta para este artifact_hash en una pasada anterior,
        // reusamos sin tocar disco otra vez.
        if let Some((_, paths)) = groups.get_mut(&art) {
            paths.push(path.clone());
            continue;
        }
        if artifacts_without_recipe.contains(&art) {
            fallback.push((path.clone(), *ev));
            continue;
        }

        let recipe_opt = store.and_then(|s| s.recipe_for_hash(&art).ok().flatten());
        match recipe_opt {
            Some(r) => {
                groups.insert(art, (r, vec![path.clone()]));
            }
            None => {
                artifacts_without_recipe.insert(art);
                fallback.push((path.clone(), *ev));
            }
        }
    }

    stats.missing_recipe = artifacts_without_recipe.len();

    let mut mutations: Vec<hammer_core::Mutation> = Vec::new();

    // Paso 3: source_patches (ordenados por artifact_hash para estabilidad del output).
    for (art_hash, (recipe, mut paths)) in groups {
        paths.sort();
        let target_bin = paths
            .first()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|| "/".into());

        // Convertimos la Recipe a los campos de origen que SourcePatch necesita
        // (git xor tarball — ambos se modelan ya, sin caer a file_drop).
        let (repo, commit, tarball, sha256) = match recipe.source.kind() {
            Ok(hammer_core::SourceKind::Git { repo, commit }) => {
                (Some(repo.to_string()), Some(commit.to_string()), None, None)
            }
            Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => {
                (None, None, Some(url.to_string()), Some(sha256.to_string()))
            }
            Err(_) => {
                stats.warnings += 1;
                eprintln!(
                    "warning: receta de {art_hash} tiene source inválido; cayendo a file_drop"
                );
                for p in &paths {
                    if let Some(ev) = last_by_path.get(p) {
                        fallback.push((p.clone(), *ev));
                    }
                }
                continue;
            }
        };

        // Patches inline: si la receta tenía patches, los leemos y concatenamos. El
        // hash del artefacto incluye su contenido (ver `Recipe::hash_inputs`), así que
        // el receptor reconstruye byte-a-byte si y sólo si el patch coincide.
        let patch_inline = collect_patches_inline(&recipe);

        if paths.len() > 1 {
            eprintln!(
                "  source_patch {} cubre {} archivo(s); target_bin={} (resto se hidrata al aplicar)",
                art_hash,
                paths.len(),
                target_bin
            );
        }

        mutations.push(hammer_core::Mutation::SourcePatch {
            repo,
            commit,
            tarball,
            sha256,
            strip_components: (recipe.source.strip_components != 1)
                .then_some(recipe.source.strip_components),
            patch: patch_inline,
            patch_url: None,
            build: hammer_core::SwmBuild {
                compiler: recipe.build.compiler.as_str().to_string(),
                target: recipe.build.target.clone(),
                link: recipe.build.link.as_str().to_string(),
                flags: recipe.build.flags.clone(),
                phases: recipe.build.phases.clone(),
                zig_version: recipe.build.zig_version.clone(),
            },
            target_bin,
            expected_hash: Some(art_hash),
            deps: recipe.deps.clone(),
            evidence: recipe.evidence.clone(),
            slots: recipe.slots.clone(),
        });
        stats.source_patches += 1;
    }

    // Paso 4: file_drops. Dedupeamos por path porque algunos paths pueden haber rebotado
    // del grupo de tarball-fallback al fallback general además del path original.
    let mut seen_paths: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
    fallback.sort_by(|(a, _), (b, _)| a.cmp(b));
    for (path, ev) in fallback {
        if !seen_paths.insert(path.clone()) {
            continue;
        }
        let bytes = match std::fs::read(&path) {
            Ok(b) => b,
            Err(e) => {
                stats.warnings += 1;
                eprintln!(
                    "warning: no pude leer {} para exportar (ev ts={}): {e}",
                    path.display(),
                    ev.ts
                );
                continue;
            }
        };
        let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
        let b64 = STANDARD.encode(&bytes);
        mutations.push(hammer_core::Mutation::FileDrop {
            path: path.display().to_string(),
            content_hash: h.as_str().to_string(),
            content_b64: Some(b64),
            content_url: None,
        });
        stats.file_drops += 1;
    }

    (mutations, stats)
}

/// Concatena los contenidos de todos los patches declarados por la receta en un único
/// blob (separado por una línea con `--- /dev/null` falsa). Si la receta no tiene
/// patches, devuelve `None`. Si algún patch no se puede leer, devuelve `None` y deja un
/// warning en stderr — el receptor reconstruirá sin patches y verificará con
/// `expected_hash` que el binario coincide, así que el error es detectable.
fn collect_patches_inline(recipe: &hammer_core::Recipe) -> Option<String> {
    if recipe.source.patches.is_empty() {
        return None;
    }
    let mut all = String::new();
    for p in &recipe.source.patches {
        let path = if std::path::Path::new(p).is_absolute() {
            std::path::PathBuf::from(p)
        } else {
            recipe.base_dir.join(p)
        };
        match std::fs::read_to_string(&path) {
            Ok(text) => all.push_str(&text),
            Err(e) => {
                eprintln!(
                    "warning: no pude leer patch {} para inline: {e}",
                    path.display()
                );
                return None;
            }
        }
    }
    Some(all)
}

/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un
/// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
fn run_ai(
    intent: &str,
    catalog: Option<&std::path::Path>,
    llm: bool,
    prefix: Option<&std::path::Path>,
    base_ref: Option<&std::path::Path>,
    bus: Option<&std::path::Path>,
    state_root: Option<&std::path::Path>,
    store_root: &str,
    repair_max_attempts: Option<u32>,
    repair_window_ms: u64,
) -> anyhow::Result<()> {
    use hammer_agent::orchestrator::{ApplyTarget, CompileMode, RepairPolicy};

    if llm && catalog.is_some() {
        anyhow::bail!("--llm y --catalog son mutuamente excluyentes");
    }
    if !llm && catalog.is_none() {
        anyhow::bail!("hace falta --catalog (modo mock) o --llm (modo LLM)");
    }

    // Calcular BaseRef. En modo catálogo se puede inferir del .swm; en modo LLM hace falta
    // --base-ref explícito porque el modelo no ha producido nada aún.
    let base = if llm {
        match load_local_base(base_ref)? {
            Some(b) => b,
            None => anyhow::bail!(
                "modo --llm requiere --base-ref para que el traductor reciba un SystemContext válido"
            ),
        }
    } else {
        infer_base_for_catalog(intent, catalog.unwrap(), base_ref)?
    };

    let apply = match prefix {
        Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() },
        None => ApplyTarget::Overlay {
            state_root: state_root
                .map(|p| p.to_path_buf())
                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)),
        },
    };

    let compile = match bus {
        Some(s) => CompileMode::ViaBus {
            sock: s.to_path_buf(),
            store: PathBuf::from(store_root),
            timeout: std::time::Duration::from_secs(600),
        },
        None => CompileMode::Skip,
    };

    let policy_opt = repair_max_attempts.map(|max| RepairPolicy {
        max_attempts: max,
        crash_window: std::time::Duration::from_millis(repair_window_ms),
        event_sock: bus.map(|p| p.to_path_buf()),
        ..RepairPolicy::default()
    });

    let proposal = if llm {
        run_with_llm_translator(intent, base, apply, compile, policy_opt.as_ref())?
    } else {
        run_with_mock_translator(
            intent,
            catalog.unwrap(),
            base,
            apply,
            compile,
            policy_opt.as_ref(),
        )?
    };

    println!("--- proposal ---");
    println!("intent: {}", proposal.intent);
    println!(
        "applied: source_patch={} config_edit={} init_rule={} file_drop={}",
        proposal.applied.source_patch,
        proposal.applied.config_edit,
        proposal.applied.init_rule,
        proposal.applied.file_drop,
    );
    if proposal.skipped_source_patches > 0 {
        println!(
            "skipped_source_patches: {} (sin --bus)",
            proposal.skipped_source_patches
        );
    }
    println!("checks:");
    for c in &proposal.checks {
        println!("  [{:?}] {}", c.level, c.msg);
    }
    if !proposal.repair_chain.is_empty() {
        println!("\nrepair_chain ({} intento(s)):", proposal.repair_chain.len());
        for r in &proposal.repair_chain {
            match &r.triggered_crash {
                Some(c) => println!(
                    "  #{:>2}  intent={:?}  overlay={:?}  → crash service={} code={}",
                    r.attempt, r.intent, r.overlay_id, c.service, c.code
                ),
                None => println!(
                    "  #{:>2}  intent={:?}  overlay={:?}  → estabilizado",
                    r.attempt, r.intent, r.overlay_id
                ),
            }
        }
    }
    match (&proposal.overlay_id, &proposal.prefix) {
        (Some(id), _) => {
            println!("\noverlay {id} listo.");
            println!("  Revisa el cambio en caliente y luego:");
            println!("    hammer commit {id}     # promueve + diario");
            println!("    hammer discard {id}    # descarta");
        }
        (None, Some(p)) => {
            println!("\nprefix {} actualizado (modo test, sin overlay).", p.display());
        }
        _ => {}
    }
    Ok(())
}

/// En modo catálogo, intenta deducir un BaseRef si el humano no pasó `--base-ref`:
/// usa la `base` del propio `.swm` mockeado. Esto facilita el smoke-test offline.
fn infer_base_for_catalog(
    intent: &str,
    catalog: &std::path::Path,
    base_ref: Option<&std::path::Path>,
) -> anyhow::Result<hammer_core::BaseRef> {
    if let Some(b) = load_local_base(base_ref)? {
        return Ok(b);
    }
    let cat = hammer_agent::IntentCatalog::load_from_path(catalog)
        .map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
    let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
    let ctx_swm = cat
        .intents
        .iter()
        .find(|e| e.intent == intent)
        .ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))?
        .swm_inline
        .clone()
        .or_else(|| {
            cat.intents
                .iter()
                .find(|e| e.intent == intent)
                .and_then(|e| e.swm_path.as_ref())
                .and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok())
                .and_then(|t| hammer_core::Swm::from_yaml(&t).ok())
        });
    match ctx_swm {
        Some(s) => Ok(hammer_core::BaseRef::from(&s.base)),
        None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"),
    }
}

fn run_with_mock_translator(
    intent: &str,
    catalog: &std::path::Path,
    base: hammer_core::BaseRef,
    apply: hammer_agent::orchestrator::ApplyTarget,
    compile: hammer_agent::orchestrator::CompileMode,
    policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
) -> anyhow::Result<hammer_agent::Proposal> {
    use hammer_agent::{IntentCatalog, MockTranslator, Orchestrator};
    let cat = IntentCatalog::load_from_path(catalog)
        .map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
    let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
    let translator = MockTranslator::from_catalog(&cat, base_dir)
        .map_err(|e| anyhow::anyhow!("catalog: {e}"))?;
    let orch = Orchestrator::new(translator, base, apply, compile);
    match policy {
        Some(p) => orch
            .run_with_repair(intent, p)
            .map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")),
        None => orch
            .run(intent)
            .map_err(|e| anyhow::anyhow!("orchestrator: {e}")),
    }
}

#[cfg(feature = "llm-claude")]
fn run_with_llm_translator(
    intent: &str,
    base: hammer_core::BaseRef,
    apply: hammer_agent::orchestrator::ApplyTarget,
    compile: hammer_agent::orchestrator::CompileMode,
    policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
) -> anyhow::Result<hammer_agent::Proposal> {
    use hammer_agent::{ClaudeConfig, ClaudeTranslator, Orchestrator};
    let cfg = ClaudeConfig::from_env()
        .map_err(|e| anyhow::anyhow!("ClaudeConfig::from_env: {e}"))?;
    let translator = ClaudeTranslator::new(cfg);
    let orch = Orchestrator::new(translator, base, apply, compile);
    match policy {
        Some(p) => orch
            .run_with_repair(intent, p)
            .map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")),
        None => orch
            .run(intent)
            .map_err(|e| anyhow::anyhow!("orchestrator: {e}")),
    }
}

#[cfg(not(feature = "llm-claude"))]
fn run_with_llm_translator(
    _intent: &str,
    _base: hammer_core::BaseRef,
    _apply: hammer_agent::orchestrator::ApplyTarget,
    _compile: hammer_agent::orchestrator::CompileMode,
    _policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
) -> anyhow::Result<hammer_agent::Proposal> {
    anyhow::bail!(
        "este binario se compiló sin el feature `llm-claude`. Recompila: \
         `cargo build -p hammer-cli --features llm-claude` (o desactiva --llm)."
    )
}

/// Evalúa una expresión del mini-lenguaje localmente e imprime el JSON resultante.
/// Espejo en proceso del path remoto vía bus (`AgentClient::query_expr`).
fn run_query(
    expr: &str,
    base_ref: Option<&std::path::Path>,
    fs_root: Option<&std::path::Path>,
) -> anyhow::Result<()> {
    let mut ctx = hammer_core::query::EvalContext::new();
    if let Some(b) = load_local_base(base_ref)? {
        ctx = ctx.with_base(b);
    }
    if let Some(r) = fs_root {
        ctx = ctx.with_fs_root(r.to_path_buf());
    }
    let value = hammer_core::query::eval_str(expr, &ctx);
    let json = serde_json::to_string_pretty(&value)?;
    println!("{json}");
    Ok(())
}

/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
    use std::io::Write;
    use std::os::unix::fs::FileTypeExt;
    let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
        anyhow::anyhow!(
            "no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
            fifo.display()
        )
    })?;
    if !meta.file_type().is_fifo() {
        anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
    }
    let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
    f.write_all(line.as_bytes())?;
    if !line.ends_with('\n') {
        f.write_all(b"\n")?;
    }
    Ok(())
}

/// Parsea un `--swap name=hash[:rel_path]` (variante b del builder, SDD 11 §7.2b). El `rel_path`
/// por defecto es `usr/bin/<name>`. Quitamos el prefijo `b3:` del hash ANTES de buscar el `:` del
/// rel_path opcional — si no, `make=b3:abc` se parsearía como hash=`b3`, rel_path=`abc`.
fn parse_swap(s: &str) -> anyhow::Result<hammer_bootstrap::ToolchainSwap> {
    let (name, rest) = s
        .split_once('=')
        .ok_or_else(|| anyhow::anyhow!("--swap '{s}': falta '=' (name=hash[:rel_path])"))?;
    let rest = rest.trim_start_matches("b3:");
    let (hash, rel_path) = match rest.split_once(':') {
        Some((h, p)) => (h, p.to_string()),
        None => (rest, format!("usr/bin/{name}")),
    };
    Ok(hammer_bootstrap::ToolchainSwap {
        name: name.to_string(),
        artifact: hammer_core::ArtifactHash::from_hex(hash),
        rel_path,
    })
}

#[cfg(test)]
mod pin_tests {
    use super::*;

    #[test]
    fn is_git_sha_detecta_sha_vs_tag() {
        assert!(is_git_sha("4a7b216552d64134c0fa17a59b9d557d89019f0f")); // sha1 40 hex
        assert!(is_git_sha(&"a".repeat(64))); // sha256 64 hex
        assert!(!is_git_sha("v1.1.0")); // tag
        assert!(!is_git_sha("main")); // branch
        assert!(!is_git_sha("4a7b21")); // corto
        assert!(!is_git_sha(&"z".repeat(40))); // 40 chars no-hex
    }
}

#[cfg(test)]
mod swap_tests {
    use super::*;

    #[test]
    fn swap_with_b3_prefix_keeps_full_hash() {
        // Regresión: el `:` del prefijo `b3:` no debe confundirse con el separador del rel_path.
        let s = parse_swap("make=b3:fbad44ac").unwrap();
        assert_eq!(s.name, "make");
        assert_eq!(s.artifact.as_str(), "b3:fbad44ac");
        assert_eq!(s.rel_path, "usr/bin/make", "rel_path por defecto");
    }

    #[test]
    fn swap_bare_hash_and_explicit_rel_path() {
        let s = parse_swap("bwrap=abcd:usr/bin/bwrap").unwrap();
        assert_eq!(s.name, "bwrap");
        assert_eq!(s.artifact.as_str(), "b3:abcd");
        assert_eq!(s.rel_path, "usr/bin/bwrap");
    }

    #[test]
    fn swap_b3_prefix_with_explicit_rel_path() {
        let s = parse_swap("make=b3:fbad:usr/local/bin/make").unwrap();
        assert_eq!(s.artifact.as_str(), "b3:fbad");
        assert_eq!(s.rel_path, "usr/local/bin/make");
    }

    #[test]
    fn swap_missing_equals_errors() {
        let err = parse_swap("makeb3:fbad").unwrap_err().to_string();
        assert!(err.contains("falta '='"), "{err}");
    }
}

#[cfg(test)]
mod export_tests {
    use super::*;
    use hammer_journal::{Actor, MutationEvent, MutationOp, Source};

    fn write_recipe_sidecar(art_dir: &std::path::Path, name: &str, commit: &str) {
        std::fs::create_dir_all(art_dir.join(".hammer")).unwrap();
        let toml = format!(
            r#"
name = "{name}"
version = "0.1"
[source]
repo   = "git://example/{name}.git"
commit = "{commit}"
[build]
compiler = "zig-cc"
target   = "x86_64-linux-musl"
link     = "static"
flags    = ["--enable-foo"]
"#
        );
        std::fs::write(art_dir.join(hammer_core::store::RECIPE_SIDECAR_REL), toml).unwrap();
    }

    fn ev_hydrate(path: &str, artifact: &str) -> MutationEvent {
        MutationEvent {
            ts: "2026-06-10T00:00:00Z".into(),
            op: MutationOp::Create,
            path: PathBuf::from(path),
            by: Actor {
                source: Source::HammerHydrate { artifact: artifact.into() },
                pid: None,
                uid: None,
            },
            content_hash: None,
            note: None,
        }
    }

    fn ev_external(path: &str) -> MutationEvent {
        MutationEvent {
            ts: "2026-06-10T00:00:01Z".into(),
            op: MutationOp::Edit,
            path: PathBuf::from(path),
            by: Actor { source: Source::External, pid: Some(42), uid: Some(1000) },
            content_hash: None,
            note: None,
        }
    }

    #[test]
    fn export_without_store_falls_back_to_file_drops() {
        let d = tempfile::tempdir().unwrap();
        let target = d.path().join("etc/hosts");
        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
        std::fs::write(&target, b"127.0.0.1 lo\n").unwrap();

        let events = vec![ev_external(target.to_str().unwrap())];
        let (mutations, stats) = build_export_mutations(&events, None);
        assert_eq!(stats.source_patches, 0);
        assert_eq!(stats.file_drops, 1);
        assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. }));
    }

    #[test]
    fn delete_event_invalidates_previous_state() {
        let d = tempfile::tempdir().unwrap();
        let target = d.path().join("a");
        std::fs::write(&target, b"x").unwrap();

        let mut create = ev_external(target.to_str().unwrap());
        create.op = MutationOp::Create;
        let mut delete = ev_external(target.to_str().unwrap());
        delete.op = MutationOp::Delete;
        let events = vec![create, delete];
        let (mutations, stats) = build_export_mutations(&events, None);
        assert!(mutations.is_empty());
        assert_eq!(stats.file_drops, 0);
    }

    #[test]
    fn hydrate_with_recipe_sidecar_emits_source_patch() {
        let d = tempfile::tempdir().unwrap();
        let store_root = d.path().join("store");
        let store = hammer_core::Store::open(&store_root).unwrap();
        let art_dir = store_root.join("cafe1234-grep");
        write_recipe_sidecar(&art_dir, "grep", "deadbeef");

        // Dos archivos del MISMO artifact_hash ⇒ deben colapsar en UN source_patch.
        let file_a = d.path().join("usr/bin/grep");
        let file_b = d.path().join("usr/lib/grep-helper.so");
        std::fs::create_dir_all(file_a.parent().unwrap()).unwrap();
        std::fs::create_dir_all(file_b.parent().unwrap()).unwrap();
        std::fs::write(&file_a, b"x").unwrap();
        std::fs::write(&file_b, b"y").unwrap();
        let events = vec![
            ev_hydrate(file_a.to_str().unwrap(), "b3:cafe1234"),
            ev_hydrate(file_b.to_str().unwrap(), "b3:cafe1234"),
        ];

        let (mutations, stats) = build_export_mutations(&events, Some(&store));
        assert_eq!(stats.source_patches, 1, "{:?}", stats);
        assert_eq!(stats.file_drops, 0);
        assert_eq!(stats.missing_recipe, 0);
        match &mutations[0] {
            hammer_core::Mutation::SourcePatch {
                repo,
                commit,
                build,
                target_bin,
                expected_hash,
                ..
            } => {
                assert_eq!(repo.as_deref(), Some("git://example/grep.git"));
                assert_eq!(commit.as_deref(), Some("deadbeef"));
                assert_eq!(build.compiler, "zig-cc");
                assert_eq!(build.link, "static");
                assert_eq!(build.flags, vec!["--enable-foo".to_string()]);
                // target_bin es el primer path alfabético del grupo.
                let tb = std::path::PathBuf::from(target_bin);
                assert!(tb.ends_with("usr/bin/grep"), "{target_bin}");
                assert_eq!(expected_hash.as_deref(), Some("b3:cafe1234"));
            }
            other => panic!("esperaba SourcePatch, llegó {other:?}"),
        }
    }

    #[test]
    fn hydrate_without_recipe_falls_back_to_file_drop() {
        let d = tempfile::tempdir().unwrap();
        let store_root = d.path().join("store");
        let store = hammer_core::Store::open(&store_root).unwrap();
        // Creamos el artefacto SIN sidecar.
        std::fs::create_dir_all(store_root.join("badbeef-grep")).unwrap();

        let target = d.path().join("usr/bin/grep");
        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
        std::fs::write(&target, b"binary").unwrap();
        let events = vec![ev_hydrate(target.to_str().unwrap(), "b3:badbeef")];

        let (mutations, stats) = build_export_mutations(&events, Some(&store));
        assert_eq!(stats.source_patches, 0);
        assert_eq!(stats.file_drops, 1);
        assert_eq!(stats.missing_recipe, 1, "debe contar el artefacto sin sidecar");
        assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. }));
    }

    #[test]
    fn mixed_traceable_and_external_emit_both_kinds() {
        let d = tempfile::tempdir().unwrap();
        let store_root = d.path().join("store");
        let store = hammer_core::Store::open(&store_root).unwrap();
        let art_dir = store_root.join("aaaa1111-grep");
        write_recipe_sidecar(&art_dir, "grep", "feedface");

        let traced = d.path().join("usr/bin/grep");
        std::fs::create_dir_all(traced.parent().unwrap()).unwrap();
        std::fs::write(&traced, b"x").unwrap();
        let edited = d.path().join("etc/grep.conf");
        std::fs::create_dir_all(edited.parent().unwrap()).unwrap();
        std::fs::write(&edited, b"OPTS=\n").unwrap();

        let events = vec![
            ev_hydrate(traced.to_str().unwrap(), "b3:aaaa1111"),
            ev_external(edited.to_str().unwrap()),
        ];
        let (mutations, stats) = build_export_mutations(&events, Some(&store));
        assert_eq!(stats.source_patches, 1);
        assert_eq!(stats.file_drops, 1);
        // Orden: source_patches primero (BTreeMap por hash), luego file_drops.
        assert!(matches!(mutations[0], hammer_core::Mutation::SourcePatch { .. }));
        assert!(matches!(mutations[1], hammer_core::Mutation::FileDrop { .. }));
    }

    #[test]
    fn unreadable_path_in_fallback_only_warns() {
        // ev_external apuntando a un path inexistente: el evento se cuenta como warning
        // y queda fuera del .swm. La función no falla.
        let events = vec![ev_external("/no/existe/seguramente/x")];
        let (mutations, stats) = build_export_mutations(&events, None);
        assert_eq!(stats.warnings, 1);
        assert_eq!(stats.file_drops, 0);
        assert!(mutations.is_empty());
    }

}
