//! La `Recipe`: descripción pura de un build. Ver `docs/02-build-lab.md` §1.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::hash::ArtifactHash;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recipe {
    pub name: String,
    pub version: String,
    pub source: Source,
    pub build: Build,
    #[serde(default)]
    pub deps: Deps,
    /// Evidencia de COMPORTAMIENTO que acompaña la receta (H1 — proof-carrying recipes, SDD 15).
    /// Opcional. **No entra en `hash_inputs`**: certifica comportamiento, no identidad ⇒ agregar o
    /// cambiar evidencia NO mueve el `artifact_hash` (el baseline de reproducibilidad no se toca).
    #[serde(default, skip_serializing_if = "Evidence::is_empty")]
    pub evidence: Evidence,
    /// **Superficies del sistema** que este paquete reclama y requiere (H4b — configs
    /// compatibles, SDD 15 §H4). Igual que `evidence`: **NO entra en `hash_inputs`** — declara
    /// TOPOLOGÍA (qué toca / de qué depende), no identidad del artefacto; agregar o cambiar el
    /// bloque no mueve el `artifact_hash`. Lo consume el gate de compatibilidad de `install`
    /// (`crate::compat`) contra el estado instalado, no el build.
    #[serde(default, skip_serializing_if = "Slots::is_empty")]
    pub slots: Slots,
    /// Directorio base contra el que se resuelven rutas relativas de la receta
    /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío.
    #[serde(skip, default)]
    pub base_dir: PathBuf,
}

/// `Source` admite dos modos, mutuamente excluyentes, validados en runtime:
///
/// 1. **Git** — `repo` + `commit` (SHA fijado, nunca HEAD; ADR 0006). Apropiado para
///    upstreams cuyo árbol de git ya es buildable o cuando queremos pinear un parche que
///    aún no salió en release.
/// 2. **Tarball** — `tarball` (URL) + `sha256`. Apropiado para releases estables: la
///    mayoría de proyectos GNU/autotools traen `configure` ya generado y embebidos los
///    submódulos (gnulib, etc.), evitando la necesidad de `./bootstrap` en el sandbox.
///
/// El hash de entrada del artefacto usa `commit` o `sha256` según el modo, ambos
/// identificadores inmutables del contenido fuente.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Source {
    // --- modo git ---
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commit: Option<String>,
    // --- modo tarball ---
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tarball: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sha256: Option<String>,
    /// Cuántos componentes recortar al extraer el tarball. Default 1 (los releases GNU
    /// siempre tienen un único top-level `proyecto-version/`).
    #[serde(default = "default_strip")]
    pub strip_components: usize,
    // --- común ---
    /// Destino del vendoreo cargo (default `vendor`). Override per-receta para proyectos que
    /// commitean su PROPIO `vendor/` (p.ej. mise → `vendor/aqua-registry`): `cargo vendor` borra
    /// lo que no reconoce, así que se vendorea a un subdir aparte para no pisarlo. El fragmento
    /// `.cargo/config.toml` que emite `cargo vendor` ya apunta al dir que se le pasa.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cargo_vendor_dir: Option<String>,
    /// Fuerza el vendoreo cargo aunque el árbol NO se detecte como `BuildSys::Cargo`.
    ///
    /// Existe porque `detect_build_system` asume **un** sistema de build por árbol, y hay proyectos
    /// que genuinamente tienen dos: C por fuera, Rust por dentro. El arquetipo es librsvg —trae
    /// `configure.ac` Y `Cargo.toml`, y autotools gana por precedencia—, pero su `Makefile` invoca
    /// `cargo build`. Sin vendoreo ese cargo intenta salir a la red DENTRO del sandbox hermético y
    /// el build muere. Es un patrón, no una excepción: cada vez hay más GNOME así.
    ///
    /// No cambia qué se construye, sólo de dónde salen las deps —el `Cargo.lock` del source es el
    /// que fija las versiones— así que **no entra al ArtifactHash**, igual que `cargo_vendor_dir`.
    /// Es lo que permite prenderla en una receta ya sellada sin re-hashearla.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cargo_vendor: Option<bool>,
    #[serde(default)]
    pub patches: Vec<String>,
}

fn default_strip() -> usize {
    1
}

/// Discriminante validado del `Source`. Se calcula bajo demanda; los campos de
/// `Source` permanecen flat en TOML para que las recetas sean legibles.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceKind<'a> {
    Git { repo: &'a str, commit: &'a str },
    Tarball { url: &'a str, sha256: &'a str },
}

impl Source {
    pub fn kind(&self) -> crate::Result<SourceKind<'_>> {
        match (
            self.repo.as_deref(),
            self.commit.as_deref(),
            self.tarball.as_deref(),
            self.sha256.as_deref(),
        ) {
            (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
            (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
            (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => Err(
                crate::Error::Recipe(
                    "source: usa repo+commit O tarball+sha256, no ambos".into(),
                ),
            ),
            _ => Err(crate::Error::Recipe(
                "source: faltan campos; necesito (repo+commit) o (tarball+sha256)".into(),
            )),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Build {
    #[serde(default)]
    pub compiler: Compiler,
    #[serde(default = "default_target")]
    pub target: String,
    #[serde(default)]
    pub link: LinkMode,
    #[serde(default)]
    pub flags: Vec<String>,
    /// Versión de zig a usar para ESTA receta, distinta del zig por defecto del lab. Sirve de
    /// escotilla por-receta para esquivar la regresión de zig 0.14 (que miscompila ciertos binarios
    /// musl dinámicos: flex/cmake/python/binutils/…): `zig_version = "0.13.0"` los construye con el
    /// zig bueno sin tocar el toolchain global ni el baseline de reproducibilidad (estas piezas son
    /// herramientas, no inputs del of_tree del 4/4). `None` ⇒ el zig por defecto. Ver SDD 11 §7.2b.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub zig_version: Option<String>,
    /// Habilita CGO en un build Go (`BuildSys::Go`). Por defecto el lab compila con `CGO_ENABLED=0`
    /// (Go puro, estático, sin C). Algunos proyectos necesitan cgo para drivers con C bundled (p.ej.
    /// el driver sqlite3 de mattn en usql/sq/dolt): con `cgo = true` el compile usa `CGO_ENABLED=1`
    /// + `CC="zig cc"` (musl nativo del sandbox) y liga estático (`-linkmode=external -extldflags=
    /// -static`). Sólo para C bundled; cgo contra libs C EXTERNAS (gpgme/libvirt) requiere además
    /// esas recetas en el sandbox. No-op fuera de BuildSys::Go.
    #[serde(default)]
    pub cgo: bool,
    /// Subdirectorio donde vive el módulo Go cuando NO está en la raíz del repo (p.ej. dolt → `go`,
    /// csvtk → su subdir). El lab vendorea, detecta el main y compila DENTRO de `<src>/<subdir>`.
    /// Vacío = raíz (lo normal). Sólo aplica a BuildSys::Go.
    #[serde(default)]
    pub subdir: String,
    /// Overrides explícitos de las fases del build. Lo no especificado se decide por
    /// heurística en el lab (ver `docs/02-build-lab.md` §4).
    #[serde(default)]
    pub phases: Phases,
}

/// Comandos shell que se ejecutan en el sandbox por cada fase. Cualquiera puede ser `None`:
/// el lab lo deriva por heurística (autotools / cmake / meson / make).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Phases {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub configure: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub compile: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub install: Option<String>,
}

impl Phases {
    /// `true` si ninguna fase está sobreescrita (todo se decide por heurística). Sirve a
    /// serde (`skip_serializing_if`) para que un `.swm` sin fases custom no las emita.
    pub fn is_empty(&self) -> bool {
        self.configure.is_none() && self.compile.is_none() && self.install.is_none()
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Deps {
    #[serde(default)]
    pub build: Vec<String>,
    #[serde(default)]
    pub runtime: Vec<String>,
}

impl Deps {
    /// `true` si no hay ninguna dep (ni build ni runtime). Sirve a serde para no emitir el
    /// bloque `deps` vacío en un `.swm`/receta.
    pub fn is_empty(&self) -> bool {
        self.build.is_empty() && self.runtime.is_empty()
    }
}

/// Las **superficies del sistema** de una config/paquete (H4b — SDD 15 §H4). Una config del
/// usuario = un conjunto de modificaciones direccionadas por contenido: cada modificación
/// **reclama** una superficie (la escribe) y/o **requiere** otra (depende de ella a una versión
/// exacta). El valor de cada slot es un hash `b3:…` del contenido — misma disciplina de "todo por
/// hash" que el resto del stack.
///
/// El gate de compatibilidad (`crate::compat`) usa estos dos mapas contra el estado instalado:
/// - un `requires` que no resuelve contra el estado ⇒ **incompatible** (el caso "modifiqué el
///   protocolo de Wayland y bajo algo que depende del Wayland stock"); es el análogo, a nivel de
///   sistema, de una referencia colgante.
/// - un `claims` que pisa una superficie ya ocupada por otro contenido ⇒ **colisión = elección**
///   (el caso "ya cambié el logo y bajo otra config que también lo cambia"), no un error.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Slots {
    /// Superficies que este paquete **escribe** → hash del contenido nuevo (`slot → b3:…`).
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub claims: std::collections::BTreeMap<String, String>,
    /// Superficies de las que **depende** → versión exacta requerida (`slot → b3:…`).
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub requires: std::collections::BTreeMap<String, String>,
}

impl Slots {
    /// `true` si no declara ninguna superficie (ni claims ni requires). Para serde: una receta
    /// sin bloque `slots` no lo emite.
    pub fn is_empty(&self) -> bool {
        self.claims.is_empty() && self.requires.is_empty()
    }

    /// Valida la forma (schema, no compatibilidad): cada valor de slot debe ser un hash `b3:…`.
    /// El gate de `install` corre esto antes de evaluar.
    pub fn validate(&self) -> crate::Result<()> {
        for (mapa, etiqueta) in [(&self.claims, "claims"), (&self.requires, "requires")] {
            for (slot, hash) in mapa {
                if !hash.starts_with("b3:") {
                    return Err(crate::Error::Recipe(format!(
                        "slots.{etiqueta}[{slot:?}]: el valor debe ser un hash `b3:…`, es {hash:?}"
                    )));
                }
            }
        }
        Ok(())
    }
}

/// Evidencia de comportamiento de una receta (H1 — proof-carrying recipes, SDD 15 §H1). La IA
/// entrega, junto al build, un conjunto de `checks` que el verificador (`hammer swm-verify
/// --evidence`) corre DENTRO del sandbox reproducible; la mutación sólo se propone si todos pasan.
/// La confianza vive en el checker (pequeño, auditable), no en el generador: el sistema mejora solo
/// sin poder corromperse solo. Deliberadamente FUERA de `Recipe::hash_inputs` (comportamiento ≠
/// identidad). Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta*.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Evidence {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub checks: Vec<EvidenceCheck>,
}

impl Evidence {
    /// `true` si no hay ningún check. Sirve a serde (`skip_serializing_if`) para no emitir el
    /// bloque `evidence` vacío en una receta/`.swm`.
    pub fn is_empty(&self) -> bool {
        self.checks.is_empty()
    }

    /// Valida la forma del bloque (schema, no ejecución): cada `cmd` no vacío y cada
    /// `expected_output`, si está, con prefijo `b3:`. El checker de H1b corre esto antes de ejecutar.
    pub fn validate(&self) -> crate::Result<()> {
        for (i, c) in self.checks.iter().enumerate() {
            if c.cmd.trim().is_empty() {
                return Err(crate::Error::Recipe(format!(
                    "evidence.checks[{i}] ({}): `cmd` vacío",
                    c.kind.as_str()
                )));
            }
            if let Some(h) = &c.expected_output {
                if !h.starts_with("b3:") {
                    return Err(crate::Error::Recipe(format!(
                        "evidence.checks[{i}] ({}): `expected_output` debe ser un hash `b3:…`, es {h:?}",
                        c.kind.as_str()
                    )));
                }
            }
        }
        Ok(())
    }
}

/// Un ítem de evidencia: un comando + el resultado exigido. `kind` estratifica la CONFIANZA
/// (`cmd-exit` < `proptest` < `contract` < `kani`), que se reporta tal cual — H1 no finge que todo
/// es prueba formal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceCheck {
    pub kind: EvidenceKind,
    /// Comando shell a correr en el sandbox reproducible tras el build (misma vía que las fases).
    pub cmd: String,
    /// Exit code exigido. Default 0.
    #[serde(default)]
    pub expected_exit: i32,
    /// Hash BLAKE3 (`b3:…`) opcional del stdout, para checks deterministas cuyo OUTPUT también se
    /// ancla (no sólo el exit). `None` ⇒ sólo se exige el exit code.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expected_output: Option<String>,
}

/// Estrato de confianza de un `EvidenceCheck`, en orden creciente (`Ord` = comparación de estratos).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EvidenceKind {
    /// El comando sale con `expected_exit`. La garantía más débil (un smoke test).
    CmdExit,
    /// Property tests (proptest/quickcheck): pasan sobre entradas generadas.
    Proptest,
    /// Contratos / aserciones de pre y post-condición.
    Contract,
    /// Prueba formal acotada (Kani/Creusot). La garantía más fuerte.
    Kani,
}

impl EvidenceKind {
    /// Nivel numérico de confianza (0 = más débil), para reportar/ordenar.
    pub fn level(&self) -> u8 {
        match self {
            EvidenceKind::CmdExit => 0,
            EvidenceKind::Proptest => 1,
            EvidenceKind::Contract => 2,
            EvidenceKind::Kani => 3,
        }
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            EvidenceKind::CmdExit => "cmd-exit",
            EvidenceKind::Proptest => "proptest",
            EvidenceKind::Contract => "contract",
            EvidenceKind::Kani => "kani",
        }
    }
}

/// Veredicto de un `EvidenceCheck` tras ejecutarlo (H1b). Se computa comparando el resultado
/// REAL (exit code + stdout) contra lo declarado — sin tocar el sandbox (eso lo hace el runner).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckOutcome {
    pub passed: bool,
    /// Explicación legible de por qué pasó o falló (para el reporte al humano).
    pub detail: String,
}

impl EvidenceCheck {
    /// Evalúa el resultado real de correr `cmd` contra lo declarado: exige `expected_exit` y, si
    /// hay `expected_output`, que `blake3(stdout)` coincida. **Función pura** — la ejecución en el
    /// sandbox reproducible es H1b; acá vive la lógica auditable de "¿el resultado es el exigido?".
    pub fn evaluate(&self, exit_code: i32, stdout: &[u8]) -> CheckOutcome {
        if exit_code != self.expected_exit {
            return CheckOutcome {
                passed: false,
                detail: format!("exit {exit_code} ≠ esperado {}", self.expected_exit),
            };
        }
        if let Some(want) = &self.expected_output {
            let got = ArtifactHash::of_bytes(stdout);
            if got.as_str() != want {
                return CheckOutcome {
                    passed: false,
                    detail: format!("stdout {got} ≠ esperado {want}"),
                };
            }
            return CheckOutcome {
                passed: true,
                detail: format!("exit {exit_code} ok + stdout {want} ok"),
            };
        }
        CheckOutcome {
            passed: true,
            detail: format!("exit {exit_code} ok"),
        }
    }
}

/// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc
/// para paquetes con gcc-ismos. Ver ADR 0003.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Compiler {
    #[default]
    ZigCc,
    Clang,
    Gcc,
}

impl Compiler {
    pub fn as_str(&self) -> &'static str {
        match self {
            Compiler::ZigCc => "zig-cc",
            Compiler::Clang => "clang",
            Compiler::Gcc => "gcc",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LinkMode {
    #[default]
    Static,
    Dynamic,
}

impl LinkMode {
    pub fn as_str(&self) -> &'static str {
        match self {
            LinkMode::Static => "static",
            LinkMode::Dynamic => "dynamic",
        }
    }
}

fn default_target() -> String {
    "x86_64-linux-musl".to_string()
}

impl Recipe {
    /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara
    /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`.
    pub fn from_toml(s: &str) -> crate::Result<Recipe> {
        toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))
    }

    /// Serializa la receta a TOML. Útil para el sidecar de provenance que `hammer-build`
    /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient).
    pub fn to_toml(&self) -> crate::Result<String> {
        toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string()))
    }

    /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el
    /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo).
    pub fn load_from_path(path: impl AsRef<Path>) -> crate::Result<Recipe> {
        let path = path.as_ref();
        let text = std::fs::read_to_string(path)?;
        let mut recipe = Recipe::from_toml(&text)?;
        if let Some(parent) = path.parent() {
            recipe.base_dir = parent.to_path_buf();
        }
        Ok(recipe)
    }

    /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el
    /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el
    /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2.
    pub fn hash_inputs(
        &self,
        dep_hashes: &[ArtifactHash],
    ) -> crate::Result<Vec<Vec<u8>>> {
        // El identificador inmutable del contenido fuente: el commit en modo git, el
        // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al
        // contenido exacto, lo que basta para reproducibilidad.
        let source_id = match self.source.kind()? {
            SourceKind::Git { commit, .. } => format!("git:{commit}"),
            SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"),
        };
        let mut v: Vec<Vec<u8>> = vec![
            source_id.into_bytes(),
            self.build.compiler.as_str().as_bytes().to_vec(),
            self.build.target.as_bytes().to_vec(),
            self.build.link.as_str().as_bytes().to_vec(),
        ];
        // Sólo entra al hash si está fijado: una receta sin `zig_version` mantiene su hash de antes
        // de existir el campo (compatibilidad hacia atrás; el baseline 9adefb82 no se mueve).
        if let Some(zv) = &self.build.zig_version {
            v.push(format!("zig:{zv}").into_bytes());
        }
        for p in &self.source.patches {
            let resolved = self.base_dir.join(p);
            let bytes = std::fs::read(&resolved).map_err(|e| {
                crate::Error::Recipe(format!("no pude leer patch {}: {e}", resolved.display()))
            })?;
            v.push(bytes);
        }
        for f in &self.build.flags {
            v.push(f.as_bytes().to_vec());
        }
        // Phases override: si alguien cambia el comando del compile, el artefacto cambia.
        // Etiquetamos cada uno para que añadir un override luego no colisione con un flag.
        for (label, val) in [
            ("phase:configure", &self.build.phases.configure),
            ("phase:compile", &self.build.phases.compile),
            ("phase:install", &self.build.phases.install),
        ] {
            if let Some(cmd) = val {
                v.push(format!("{label}={cmd}").into_bytes());
            }
        }
        for d in dep_hashes {
            v.push(d.as_str().as_bytes().to_vec());
        }
        Ok(v)
    }
}

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

    const SAMPLE: &str = r#"
name    = "grep"
version = "3.11"

[source]
repo   = "git://git.savannah.gnu.org/grep.git"
commit = "a1b2c3d4e5f6"

[build]
compiler = "zig-cc"
target   = "x86_64-linux-musl"
link     = "static"
flags    = ["--enable-perl-regexp"]

[deps]
build = ["pcre2"]
"#;

    #[test]
    fn parse_full() {
        let r = Recipe::from_toml(SAMPLE).expect("parse");
        assert_eq!(r.name, "grep");
        assert!(matches!(
            r.source.kind().unwrap(),
            SourceKind::Git { commit: "a1b2c3d4e5f6", .. }
        ));
        assert_eq!(r.build.compiler, Compiler::ZigCc);
        assert_eq!(r.build.link, LinkMode::Static);
        assert_eq!(r.deps.build, vec!["pcre2".to_string()]);
    }

    #[test]
    fn to_toml_roundtrips_via_from_toml() {
        let r1 = Recipe::from_toml(SAMPLE).unwrap();
        let serialized = r1.to_toml().expect("to_toml");
        let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)");
        assert_eq!(r2.name, r1.name);
        assert_eq!(r2.version, r1.version);
        assert_eq!(r2.build.compiler, r1.build.compiler);
        assert_eq!(r2.build.link, r1.build.link);
        assert_eq!(r2.build.flags, r1.build.flags);
        assert_eq!(r2.deps.build, r1.deps.build);
        // El kind del source también roundtripea.
        match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) {
            (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => {
                assert_eq!(c1, c2);
            }
            _ => panic!("source kind cambió en el roundtrip"),
        }
    }

    #[test]
    fn parse_tarball_source() {
        let s = r#"
name = "grep"
version = "3.11"
[source]
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
sha256  = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
[build]
"#;
        let r = Recipe::from_toml(s).expect("parse");
        assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. }));
        assert_eq!(r.source.strip_components, 1);
    }

    #[test]
    fn source_mixed_modes_rejected() {
        let s = r#"
name = "x"
version = "0"
[source]
repo = "git://x"
commit = "deadbeef"
tarball = "https://x/a.tar.gz"
sha256 = "abc"
[build]
"#;
        let r = Recipe::from_toml(s).unwrap();
        let err = r.source.kind().unwrap_err().to_string();
        assert!(err.contains("no ambos"), "msg = {err}");
    }

    #[test]
    fn source_missing_fields_rejected() {
        let s = r#"
name = "x"
version = "0"
[source]
repo = "git://x"
[build]
"#;
        let r = Recipe::from_toml(s).unwrap();
        let err = r.source.kind().unwrap_err().to_string();
        assert!(err.contains("faltan campos"), "msg = {err}");
    }

    #[test]
    fn tarball_and_git_hash_to_different_values() {
        let git = Recipe::from_toml(SAMPLE).unwrap();
        let tar_toml = r#"
name = "grep"
version = "3.11"
[source]
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
sha256  = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
[build]
flags = ["--enable-perl-regexp"]
"#;
        let tar = Recipe::from_toml(tar_toml).unwrap();
        assert_ne!(git.hash_inputs(&[]).unwrap(), tar.hash_inputs(&[]).unwrap());
    }

    #[test]
    fn parse_minimal_uses_defaults() {
        let s = r#"
name = "x"
version = "0"
[source]
repo = "git://x"
commit = "deadbeef"
[build]
"#;
        let r = Recipe::from_toml(s).expect("parse");
        assert_eq!(r.build.compiler, Compiler::ZigCc); // default
        assert_eq!(r.build.target, "x86_64-linux-musl"); // default
        assert_eq!(r.build.link, LinkMode::Static); // default
        assert!(r.deps.build.is_empty());
    }

    #[test]
    fn hash_no_patches_is_deterministic() {
        let r = Recipe::from_toml(SAMPLE).unwrap();
        let a = r.hash_inputs(&[]).unwrap();
        let b = r.hash_inputs(&[]).unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn hash_uses_patch_contents_not_path() {
        let dir1 = tempfile::tempdir().unwrap();
        let dir2 = tempfile::tempdir().unwrap();
        std::fs::write(dir1.path().join("p.patch"), b"diff --content").unwrap();
        std::fs::write(dir2.path().join("otro-nombre.patch"), b"diff --content").unwrap();

        let mut r1 = Recipe::from_toml(SAMPLE).unwrap();
        r1.source.patches = vec!["p.patch".into()];
        r1.base_dir = dir1.path().to_path_buf();

        let mut r2 = Recipe::from_toml(SAMPLE).unwrap();
        r2.source.patches = vec!["otro-nombre.patch".into()];
        r2.base_dir = dir2.path().to_path_buf();

        // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas.
        assert_eq!(r1.hash_inputs(&[]).unwrap(), r2.hash_inputs(&[]).unwrap());
    }

    #[test]
    fn hash_changes_when_patch_content_changes() {
        let dir = tempfile::tempdir().unwrap();
        let patch = dir.path().join("p.patch");
        std::fs::write(&patch, b"version A").unwrap();

        let mut r = Recipe::from_toml(SAMPLE).unwrap();
        r.source.patches = vec!["p.patch".into()];
        r.base_dir = dir.path().to_path_buf();
        let h_a = r.hash_inputs(&[]).unwrap();

        std::fs::write(&patch, b"version B").unwrap();
        let h_b = r.hash_inputs(&[]).unwrap();
        assert_ne!(h_a, h_b);
    }

    #[test]
    fn missing_patch_reports_path() {
        let mut r = Recipe::from_toml(SAMPLE).unwrap();
        r.source.patches = vec!["no-existe.patch".into()];
        r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer");
        let err = r.hash_inputs(&[]).unwrap_err().to_string();
        assert!(err.contains("no-existe.patch"), "msg = {err}");
    }

    #[test]
    fn load_from_path_sets_base_dir() {
        let dir = tempfile::tempdir().unwrap();
        let recipe_path = dir.path().join("foo.toml");
        std::fs::write(&recipe_path, SAMPLE).unwrap();
        let r = Recipe::load_from_path(&recipe_path).unwrap();
        assert_eq!(r.base_dir, dir.path());
    }

    const WITH_EVIDENCE: &str = r#"
name    = "grep"
version = "3.11"
[source]
repo   = "git://git.savannah.gnu.org/grep.git"
commit = "a1b2c3d4e5f6"
[build]
[deps]
build = ["pcre2"]

[[evidence.checks]]
kind = "cmd-exit"
cmd  = "grep --version"

[[evidence.checks]]
kind = "proptest"
cmd  = "cd /src && cargo test --release proptest_"
expected_exit = 0

[[evidence.checks]]
kind = "cmd-exit"
cmd  = "printf hola | grep -q hola"
expected_output = "b3:deadbeef"
"#;

    #[test]
    fn parse_evidence_block() {
        let r = Recipe::from_toml(WITH_EVIDENCE).expect("parse");
        assert_eq!(r.evidence.checks.len(), 3);
        assert_eq!(r.evidence.checks[0].kind, EvidenceKind::CmdExit);
        assert_eq!(r.evidence.checks[0].cmd, "grep --version");
        assert_eq!(r.evidence.checks[0].expected_exit, 0); // default
        assert_eq!(r.evidence.checks[1].kind, EvidenceKind::Proptest);
        assert_eq!(r.evidence.checks[2].expected_output.as_deref(), Some("b3:deadbeef"));
        r.evidence.validate().expect("schema válido");
    }

    #[test]
    fn evidence_absent_is_empty_and_omitted() {
        let r = Recipe::from_toml(SAMPLE).unwrap();
        assert!(r.evidence.is_empty());
        // `skip_serializing_if` ⇒ una receta sin evidencia no emite el bloque.
        let toml = r.to_toml().unwrap();
        assert!(!toml.contains("evidence"), "toml no debería traer `evidence`: {toml}");
    }

    #[test]
    fn evidence_does_not_affect_artifact_hash() {
        // La clave de H1a: la evidencia certifica COMPORTAMIENTO, no IDENTIDAD ⇒ el hash del
        // artefacto es idéntico con y sin bloque evidence (el baseline de reproducibilidad no se mueve).
        // Comparamos LA MISMA receta con y sin evidencia (clon + limpieza), no dos recetas distintas.
        let con = Recipe::from_toml(WITH_EVIDENCE).unwrap();
        let mut sin = con.clone();
        sin.evidence = Evidence::default();
        assert!(con.evidence.checks.len() == 3 && sin.evidence.is_empty());
        assert_eq!(
            sin.hash_inputs(&[]).unwrap(),
            con.hash_inputs(&[]).unwrap(),
            "la evidencia NO debe entrar en hash_inputs"
        );
    }

    #[test]
    fn cargo_vendor_roundtripea_y_no_afecta_el_hash() {
        // `cargo_vendor` decide DE DÓNDE salen las deps de Cargo (registro vs. `vendor/` local), no
        // CUÁLES: eso lo fija el `Cargo.lock` del source, que ya está bajo el hash de la fuente. Por
        // eso no entra a `hash_inputs`, igual que `cargo_vendor_dir` — y por eso se puede prender en
        // una receta YA SELLADA para destrabar su build offline sin re-hashear nada aguas abajo.
        let sin = Recipe::from_toml(SAMPLE).unwrap();
        let mut con = sin.clone();
        con.source.cargo_vendor = Some(true);
        assert_eq!(
            sin.hash_inputs(&[]).unwrap(),
            con.hash_inputs(&[]).unwrap(),
            "cargo_vendor NO debe entrar en hash_inputs"
        );
        // Y roundtripea por TOML: si se perdiera al serializar, el forzado se apagaría solo en
        // cualquier flujo que reescriba la receta.
        let r = Recipe::from_toml(&con.to_toml().unwrap()).unwrap();
        assert_eq!(r.source.cargo_vendor, Some(true));
        // Ausente sigue siendo ausente (no `Some(false)`), que es lo que mantiene el TOML limpio.
        assert_eq!(
            Recipe::from_toml(&sin.to_toml().unwrap())
                .unwrap()
                .source
                .cargo_vendor,
            None
        );
    }

    #[test]
    fn slots_round_trip_y_no_afectan_el_hash() {
        // H4b: el bloque `slots` declara TOPOLOGÍA (qué toca / de qué depende), no identidad ⇒
        // como la evidencia, NO entra en hash_inputs. Y round-trippea por TOML.
        let con_toml = format!(
            "{SAMPLE}\n[slots.claims]\nlogo = \"b3:rojo\"\n[slots.requires]\n\"wayland-protocol\" = \"b3:stock\"\n"
        );
        let con = Recipe::from_toml(&con_toml).unwrap();
        assert_eq!(con.slots.claims.get("logo").unwrap(), "b3:rojo");
        assert_eq!(con.slots.requires.get("wayland-protocol").unwrap(), "b3:stock");
        con.slots.validate().unwrap();

        // Round-trip: serializa y vuelve a parsear ⇒ mismos slots.
        let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap();
        assert_eq!(re.slots, con.slots);

        // Mismo hash con y sin slots.
        let mut sin = con.clone();
        sin.slots = Slots::default();
        assert!(!con.slots.is_empty() && sin.slots.is_empty());
        assert_eq!(
            sin.hash_inputs(&[]).unwrap(),
            con.hash_inputs(&[]).unwrap(),
            "los slots NO deben entrar en hash_inputs"
        );
    }

    #[test]
    fn slots_validate_rechaza_hash_sin_prefijo() {
        let mut s = Slots::default();
        s.claims.insert("logo".into(), "rojo".into()); // falta `b3:`
        assert!(s.validate().is_err());
    }

    #[test]
    fn evidence_validate_rejects_empty_cmd_and_bad_hash() {
        let bad_cmd = r#"
name = "x"
version = "0"
[source]
repo = "git://x"
commit = "deadbeef"
[build]
[[evidence.checks]]
kind = "cmd-exit"
cmd  = "   "
"#;
        let r = Recipe::from_toml(bad_cmd).unwrap();
        assert!(r.evidence.validate().unwrap_err().to_string().contains("vacío"));

        let bad_hash = r#"
name = "x"
version = "0"
[source]
repo = "git://x"
commit = "deadbeef"
[build]
[[evidence.checks]]
kind = "cmd-exit"
cmd  = "true"
expected_output = "sha256:zzz"
"#;
        let r = Recipe::from_toml(bad_hash).unwrap();
        assert!(r.evidence.validate().unwrap_err().to_string().contains("b3:"));
    }

    #[test]
    fn evidence_kind_levels_are_stratified() {
        assert!(EvidenceKind::CmdExit < EvidenceKind::Proptest);
        assert!(EvidenceKind::Proptest < EvidenceKind::Contract);
        assert!(EvidenceKind::Contract < EvidenceKind::Kani);
        assert_eq!(EvidenceKind::CmdExit.level(), 0);
        assert_eq!(EvidenceKind::Kani.level(), 3);
    }

    fn check(expected_exit: i32, expected_output: Option<&str>) -> EvidenceCheck {
        EvidenceCheck {
            kind: EvidenceKind::CmdExit,
            cmd: "irrelevante".into(),
            expected_exit,
            expected_output: expected_output.map(|s| s.into()),
        }
    }

    #[test]
    fn evaluate_exit_only_pass_and_fail() {
        let c = check(0, None);
        assert!(c.evaluate(0, b"lo que sea").passed);
        let bad = c.evaluate(1, b"");
        assert!(!bad.passed);
        assert!(bad.detail.contains("exit 1"), "{}", bad.detail);
    }

    #[test]
    fn evaluate_non_zero_expected_exit() {
        // Un check puede exigir un exit != 0 (p.ej. "el binario rechaza input inválido con 2").
        let c = check(2, None);
        assert!(c.evaluate(2, b"").passed);
        assert!(!c.evaluate(0, b"").passed);
    }

    #[test]
    fn evaluate_output_hash_pass_and_fail() {
        let want = ArtifactHash::of_bytes(b"hola\n");
        let c = check(0, Some(want.as_str()));
        // stdout correcto ⇒ pasa.
        let ok = c.evaluate(0, b"hola\n");
        assert!(ok.passed, "{}", ok.detail);
        assert!(ok.detail.contains("hash") || ok.detail.contains(want.as_str()));
        // stdout distinto ⇒ falla aunque el exit sea correcto.
        let bad = c.evaluate(0, b"chau\n");
        assert!(!bad.passed);
        assert!(bad.detail.contains("stdout"), "{}", bad.detail);
        // exit malo ⇒ falla antes de mirar el hash.
        assert!(!c.evaluate(1, b"hola\n").passed);
    }
}
