//! El manifiesto `.swm` (Software Mutación). Ver `docs/06-swm-format.md`.
//!
//! Es la unidad de intercambio: receta de transformación sobre fuente pública + ediciones de
//! config. NUNCA transporta binarios cocidos (salvo `FileDrop` con hash declarado y, opcional,
//! contenido inline base64 para `.swm`s autocontenidos en tests/escenarios offline).

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Swm {
    pub swm_version: u32,
    pub base: Base,
    pub mutations: Vec<Mutation>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signature: Option<Signature>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Base {
    pub distro_version: String,
    #[serde(default)]
    pub pins: BTreeMap<String, String>,
}

/// Referencia a la base **local** — lo que el sistema receptor tiene cocido. Se compara
/// contra `Swm::base` en `verify_base`. La construye el receptor leyendo, p. ej.,
/// `/etc/hammer/base.json`; en tests la fabricamos a mano.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseRef {
    pub distro_version: String,
    #[serde(default)]
    pub pins: BTreeMap<String, String>,
}

impl From<&Base> for BaseRef {
    fn from(b: &Base) -> Self {
        BaseRef {
            distro_version: b.distro_version.clone(),
            pins: b.pins.clone(),
        }
    }
}

/// Resultado de `verify_base`. `Ok` ⇒ las pins del SWM coinciden con las locales (al menos
/// para las llaves que el SWM declara) y la `distro_version` es idéntica. Cualquier
/// discrepancia se materializa como variante con detalle: `apply` puede decidir abortar,
/// el humano puede leer qué difiere.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaseCompat {
    /// La base local satisface todas las pins declaradas por el SWM.
    Ok,
    /// La `distro_version` no coincide.
    DistroMismatch { local: String, swm: String },
    /// Una o más pins difieren entre la base local y el SWM. Cada entrada es
    /// `(pin_name, local_value, swm_value)`; un `local_value` ausente significa que la
    /// llave no existe en la base local.
    PinMismatch(Vec<PinDiff>),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinDiff {
    pub name: String,
    pub local: Option<String>,
    pub swm: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Mutation {
    SourcePatch {
        // Origen git (modo histórico) **o** tarball — exactamente uno, igual que
        // [`crate::recipe::Source`]. Antes sólo se modelaba git y los tarballs caían a
        // `file_drop`; ahora `hammer export` los reconstruye como source_patch. Los campos
        // son `Option` (con `serde(default)`) para que los `.swm` git existentes —que sólo
        // traen `repo`+`commit`— sigan parseando sin cambios.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        repo: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        commit: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        tarball: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        sha256: Option<String>,
        /// Componentes a recortar al extraer un tarball (modo tarball). `None` ⇒ el default
        /// del lab (1, un único top-level `proyecto-version/`). Sólo se emite si difiere.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        strip_components: Option<usize>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        patch: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        patch_url: Option<String>,
        build: SwmBuild,
        target_bin: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        expected_hash: Option<String>,
        /// Dependencias del paquete por NOMBRE (las de la receta original). Las build-deps deben
        /// estar presentes como paquetes en el repo del receptor: `install` reconstruye con ellas
        /// un catálogo de recetas para que el lab las materialice en el sandbox al reproducir.
        /// Vacío ⇒ paquete autocontenido (el caso común, binarios estáticos sin deps).
        #[serde(default, skip_serializing_if = "crate::recipe::Deps::is_empty")]
        deps: crate::recipe::Deps,
        /// Evidencia de comportamiento que acompaña al paquete (H1 — proof-carrying recipes). El
        /// receptor puede correr `hammer swm-verify --evidence` para reproducir cada check en el
        /// sandbox antes de aceptar la mutación. Vacío ⇒ paquete sin evidencia (sólo reproducibilidad).
        #[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")]
        evidence: crate::recipe::Evidence,
        /// Superficies del sistema que el paquete reclama/requiere (H4b — configs compatibles, SDD
        /// 15 §H4). El receptor las evalúa contra su estado instalado (`crate::compat`) antes de
        /// hidratar: un requisito que no resuelve aborta, un reclamo que pisa otra config pide
        /// elección. Vacío ⇒ paquete que no declara topología (no participa del gate).
        #[serde(default, skip_serializing_if = "crate::recipe::Slots::is_empty")]
        slots: crate::recipe::Slots,
    },
    ConfigEdit {
        file: String,
        inline_diff: String,
    },
    InitRule {
        action: String,
        service: String,
        command: String,
    },
    FileDrop {
        path: String,
        /// hash BLAKE3 del contenido, declarado para verificación.
        content_hash: String,
        /// Contenido base64 inline (RFC 4648, sin saltos de línea). Si está presente, el
        /// receptor lo decodifica directamente; si no, debe usar `content_url`. Permite
        /// `.swm`s autocontenidos sin red — útil para tests, snapshots offline, e
        /// intercambio de pequeños binarios de datos (catálogos, listas) cuyo hash basta.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        content_b64: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        content_url: Option<String>,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwmBuild {
    #[serde(default = "default_compiler")]
    pub compiler: String,
    #[serde(default = "default_target")]
    pub target: String,
    #[serde(default = "default_link")]
    pub link: String,
    #[serde(default)]
    pub flags: Vec<String>,
    /// Overrides explícitos de las fases del build (configure/compile/install). Sin esto un
    /// `source_patch` sólo reproduciría builds heurísticos; con esto un paquete que viene de
    /// una `Recipe` con install custom (p. ej. los multicall coreutils/findutils) se rehace
    /// fiel. Vacío ⇒ heurística del lab, idéntico a una receta sin `[build.phases]`.
    #[serde(default, skip_serializing_if = "crate::recipe::Phases::is_empty")]
    pub phases: crate::recipe::Phases,
    /// Versión de zig por-paquete (escotilla a la regresión de zig 0.14; ver `Build::zig_version`).
    /// `None` ⇒ el zig por defecto del receptor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub zig_version: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signature {
    pub by: String,
    pub alg: String,
    pub sig: String,
}

fn default_compiler() -> String {
    "zig-cc".into()
}
fn default_target() -> String {
    "x86_64-linux-musl".into()
}
fn default_link() -> String {
    "static".into()
}

impl Swm {
    pub fn from_yaml(s: &str) -> crate::Result<Swm> {
        serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
    }

    pub fn to_yaml(&self) -> crate::Result<String> {
        serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string()))
    }

    /// Sanity checks **estructurales** del manifiesto. Lo que aquí se valida no depende del
    /// sistema local — sólo del documento. Llamarlo antes de `verify_base` evita malgastar
    /// pasadas sobre un `.swm` mal formado.
    pub fn verify_schema(&self) -> crate::Result<()> {
        if self.swm_version != 1 {
            return Err(crate::Error::Serde(format!(
                "swm_version no soportada: {} (soportada: 1)",
                self.swm_version
            )));
        }
        if self.base.distro_version.is_empty() {
            return Err(crate::Error::Serde(
                "base.distro_version vacía".into(),
            ));
        }
        for (i, m) in self.mutations.iter().enumerate() {
            m.verify_schema().map_err(|e| {
                crate::Error::Serde(format!("mutations[{i}]: {e}"))
            })?;
        }
        Ok(())
    }

    /// Compara la base del `.swm` con la del sistema local. Una pin del SWM **debe** estar
    /// presente en la base local con el mismo valor. Las pins extra de la base local (no
    /// declaradas por el SWM) no son problema — el SWM sólo declara lo que necesita.
    pub fn verify_base(&self, local: &BaseRef) -> BaseCompat {
        if self.base.distro_version != local.distro_version {
            return BaseCompat::DistroMismatch {
                local: local.distro_version.clone(),
                swm: self.base.distro_version.clone(),
            };
        }
        let mut diffs = Vec::new();
        for (name, swm_val) in &self.base.pins {
            match local.pins.get(name) {
                Some(local_val) if local_val == swm_val => {}
                Some(local_val) => diffs.push(PinDiff {
                    name: name.clone(),
                    local: Some(local_val.clone()),
                    swm: swm_val.clone(),
                }),
                None => diffs.push(PinDiff {
                    name: name.clone(),
                    local: None,
                    swm: swm_val.clone(),
                }),
            }
        }
        if diffs.is_empty() {
            BaseCompat::Ok
        } else {
            BaseCompat::PinMismatch(diffs)
        }
    }

    /// Construye un `.swm` de un único `source_patch` a partir de una [`crate::Recipe`] del
    /// corpus — la dirección **forward** que cierra la paquetería (Etapa F): una receta que el
    /// sistema YA sabe construir se vuelve un paquete distribuible y reproducible-desde-fuente.
    /// Es la inversa de `hammer_build::swm_bridge` (que va `source_patch` → `Recipe` → build).
    ///
    /// `hammer-core` no toca disco: el caller resuelve y **lee** los patches de la receta
    /// (relativos a `recipe.base_dir`) y pasa su texto ya concatenado en `patch_text`. El
    /// `expected_hash`, si se da, ancla "verificar, no confiar" (el receptor rehace y compara).
    pub fn from_recipe(
        recipe: &crate::Recipe,
        target_bin: impl Into<String>,
        patch_text: Option<String>,
        expected_hash: Option<String>,
        distro_version: impl Into<String>,
    ) -> crate::Result<Swm> {
        use crate::recipe::SourceKind;

        // strip_components sólo viaja si difiere del default del lab (1) y es modo tarball.
        let strip_components = match recipe.source.kind()? {
            SourceKind::Tarball { .. } if recipe.source.strip_components != 1 => {
                Some(recipe.source.strip_components)
            }
            _ => None,
        };
        let (repo, commit, tarball, sha256) = match recipe.source.kind()? {
            SourceKind::Git { repo, commit } => {
                (Some(repo.to_string()), Some(commit.to_string()), None, None)
            }
            SourceKind::Tarball { url, sha256 } => {
                (None, None, Some(url.to_string()), Some(sha256.to_string()))
            }
        };

        let build = 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(),
        };

        let swm = Swm {
            swm_version: 1,
            base: Base {
                distro_version: distro_version.into(),
                pins: BTreeMap::new(),
            },
            mutations: vec![Mutation::SourcePatch {
                repo,
                commit,
                tarball,
                sha256,
                strip_components,
                patch: patch_text,
                patch_url: None,
                build,
                target_bin: target_bin.into(),
                expected_hash,
                deps: recipe.deps.clone(),
                evidence: recipe.evidence.clone(),
                slots: recipe.slots.clone(),
            }],
            signature: None,
        };
        swm.verify_schema()?;
        Ok(swm)
    }
}

/// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos
/// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la
/// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad.
pub fn swm_source_kind<'a>(
    repo: Option<&'a str>,
    commit: Option<&'a str>,
    tarball: Option<&'a str>,
    sha256: Option<&'a str>,
) -> Result<crate::recipe::SourceKind<'a>, String> {
    use crate::recipe::SourceKind;
    match (repo, commit, tarball, sha256) {
        (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("source_patch: usa repo+commit O tarball+sha256, no ambos".into())
        }
        _ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()),
    }
}

impl Mutation {
    /// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
    /// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
    /// schema.
    pub fn verify_schema(&self) -> Result<(), String> {
        use crate::recipe::SourceKind;
        match self {
            Mutation::SourcePatch {
                repo, commit, tarball, sha256, patch, patch_url, target_bin, evidence, ..
            } => {
                match swm_source_kind(
                    repo.as_deref(),
                    commit.as_deref(),
                    tarball.as_deref(),
                    sha256.as_deref(),
                )? {
                    SourceKind::Git { repo, commit } => {
                        if repo.is_empty() {
                            return Err("source_patch: 'repo' vacío".into());
                        }
                        if commit.is_empty() {
                            return Err("source_patch: 'commit' vacío".into());
                        }
                    }
                    SourceKind::Tarball { url, sha256 } => {
                        if url.is_empty() {
                            return Err("source_patch: 'tarball' vacío".into());
                        }
                        if sha256.is_empty() {
                            return Err("source_patch: 'sha256' vacío".into());
                        }
                    }
                }
                if patch.is_some() && patch_url.is_some() {
                    return Err(
                        "source_patch: usa 'patch' (inline) O 'patch_url', no ambos".into(),
                    );
                }
                if !target_bin.starts_with('/') {
                    return Err(format!(
                        "source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'"
                    ));
                }
                // La evidencia (H1) viaja con el paquete: validamos su FORMA acá (cmd no vacío,
                // hash bien formado). La EJECUCIÓN la hace `hammer swm-verify --evidence` (H1b).
                evidence.validate().map_err(|e| e.to_string())?;
                Ok(())
            }
            Mutation::ConfigEdit { file, inline_diff } => {
                if !file.starts_with('/') {
                    return Err(format!("config_edit: file debe ser absoluto, no '{file}'"));
                }
                if inline_diff.is_empty() {
                    return Err("config_edit: inline_diff vacío".into());
                }
                Ok(())
            }
            Mutation::InitRule { action, service, command } => {
                if action.is_empty() || service.is_empty() || command.is_empty() {
                    return Err("init_rule: action/service/command no pueden estar vacíos".into());
                }
                Ok(())
            }
            Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
                if !path.starts_with('/') {
                    return Err(format!("file_drop: path debe ser absoluto, no '{path}'"));
                }
                if content_hash.is_empty() {
                    return Err("file_drop: content_hash vacío".into());
                }
                if content_b64.is_none() && content_url.is_none() {
                    return Err(
                        "file_drop: hace falta 'content_b64' (inline) o 'content_url'".into(),
                    );
                }
                if content_b64.is_some() && content_url.is_some() {
                    return Err(
                        "file_drop: usa 'content_b64' O 'content_url', no ambos".into(),
                    );
                }
                Ok(())
            }
        }
    }
}

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

    #[test]
    fn roundtrip_yaml() {
        let yaml = r#"
swm_version: 1
base:
  distro_version: "2026-06-06"
  pins:
    grep: "a1b2c3d"
mutations:
  - type: config_edit
    file: "/etc/network.conf"
    inline_diff: |
      - DHCP=yes
      + IP=192.168.1.100
"#;
        let swm = Swm::from_yaml(yaml).expect("parse");
        assert_eq!(swm.swm_version, 1);
        assert_eq!(swm.mutations.len(), 1);
        let back = swm.to_yaml().expect("serialize");
        assert!(back.contains("config_edit"));
    }

    fn base_v(distro: &str, pins: &[(&str, &str)]) -> Base {
        Base {
            distro_version: distro.into(),
            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
        }
    }
    fn baseref_v(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
        BaseRef {
            distro_version: distro.into(),
            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
        }
    }

    #[test]
    fn verify_base_happy() {
        let swm = Swm {
            swm_version: 1,
            base: base_v("2026-06-06", &[("grep", "a1b2")]),
            mutations: vec![],
            signature: None,
        };
        // Pins extra en local están bien.
        let local = baseref_v("2026-06-06", &[("grep", "a1b2"), ("musl", "deadbeef")]);
        assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
    }

    #[test]
    fn verify_base_distro_mismatch() {
        let swm = Swm {
            swm_version: 1,
            base: base_v("2026-06-06", &[]),
            mutations: vec![],
            signature: None,
        };
        let local = baseref_v("2026-06-07", &[]);
        assert_eq!(
            swm.verify_base(&local),
            BaseCompat::DistroMismatch {
                local: "2026-06-07".into(),
                swm: "2026-06-06".into(),
            }
        );
    }

    #[test]
    fn verify_base_pin_mismatch_and_missing() {
        let swm = Swm {
            swm_version: 1,
            base: base_v("2026-06-06", &[("grep", "a1b2"), ("musl", "FFFF")]),
            mutations: vec![],
            signature: None,
        };
        let local = baseref_v("2026-06-06", &[("grep", "OTRO")]); // musl no existe
        let got = swm.verify_base(&local);
        match got {
            BaseCompat::PinMismatch(diffs) => {
                assert_eq!(diffs.len(), 2);
                let grep = diffs.iter().find(|d| d.name == "grep").unwrap();
                assert_eq!(grep.local.as_deref(), Some("OTRO"));
                assert_eq!(grep.swm, "a1b2");
                let musl = diffs.iter().find(|d| d.name == "musl").unwrap();
                assert!(musl.local.is_none());
            }
            other => panic!("esperaba PinMismatch, obtuve {other:?}"),
        }
    }

    #[test]
    fn verify_schema_rejects_bad_version() {
        let swm = Swm {
            swm_version: 9,
            base: base_v("x", &[]),
            mutations: vec![],
            signature: None,
        };
        let err = swm.verify_schema().unwrap_err().to_string();
        assert!(err.contains("swm_version"), "{err}");
    }

    #[test]
    fn verify_schema_rejects_relative_path() {
        let swm = Swm {
            swm_version: 1,
            base: base_v("x", &[]),
            mutations: vec![Mutation::ConfigEdit {
                file: "etc/network.conf".into(),
                inline_diff: "- a\n+ b\n".into(),
            }],
            signature: None,
        };
        let err = swm.verify_schema().unwrap_err().to_string();
        assert!(err.contains("absoluto"), "{err}");
    }

    #[test]
    fn verify_schema_filedrop_needs_content_source() {
        let swm = Swm {
            swm_version: 1,
            base: base_v("x", &[]),
            mutations: vec![Mutation::FileDrop {
                path: "/var/lib/x".into(),
                content_hash: "b3:xxx".into(),
                content_b64: None,
                content_url: None,
            }],
            signature: None,
        };
        let err = swm.verify_schema().unwrap_err().to_string();
        assert!(err.contains("content_b64") || err.contains("content_url"), "{err}");
    }

    #[test]
    fn verify_schema_filedrop_rejects_both_inline_and_url() {
        let swm = Swm {
            swm_version: 1,
            base: base_v("x", &[]),
            mutations: vec![Mutation::FileDrop {
                path: "/var/lib/x".into(),
                content_hash: "b3:xxx".into(),
                content_b64: Some("AA==".into()),
                content_url: Some("https://x".into()),
            }],
            signature: None,
        };
        assert!(swm.verify_schema().is_err());
    }

    #[test]
    fn verify_schema_source_patch_basic() {
        let swm = Swm {
            swm_version: 1,
            base: base_v("x", &[]),
            mutations: vec![Mutation::SourcePatch {
                repo: Some("git://x".into()),
                commit: Some("abc".into()),
                tarball: None,
                sha256: None,
                strip_components: None,
                patch: None,
                patch_url: None,
                build: SwmBuild {
                    compiler: "zig-cc".into(),
                    target: "x86_64-linux-musl".into(),
                    link: "static".into(),
                    flags: vec![],
                    phases: Default::default(),
                    zig_version: None,
                },
                target_bin: "/bin/x".into(),
                expected_hash: None,
                deps: Default::default(),
                evidence: Default::default(),
                slots: Default::default(),
            }],
            signature: None,
        };
        swm.verify_schema().unwrap();
    }

    #[test]
    fn verify_schema_source_patch_tarball() {
        let swm = Swm {
            swm_version: 1,
            base: base_v("x", &[]),
            mutations: vec![Mutation::SourcePatch {
                repo: None,
                commit: None,
                tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()),
                sha256: Some("deadbeef".into()),
                strip_components: None,
                patch: None,
                patch_url: None,
                build: SwmBuild {
                    compiler: "zig-cc".into(),
                    target: "x86_64-linux-musl".into(),
                    link: "static".into(),
                    flags: vec![],
                    phases: Default::default(),
                    zig_version: None,
                },
                target_bin: "/bin/grep".into(),
                expected_hash: None,
                deps: Default::default(),
                evidence: Default::default(),
                slots: Default::default(),
            }],
            signature: None,
        };
        swm.verify_schema().unwrap();
    }

    #[test]
    fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() {
        let m = Mutation::SourcePatch {
            repo: Some("git://x".into()),
            commit: Some("abc".into()),
            tarball: Some("https://x/a.tar.gz".into()),
            sha256: Some("deadbeef".into()),
            strip_components: None,
            patch: None,
            patch_url: None,
            build: SwmBuild {
                compiler: "zig-cc".into(),
                target: "x86_64-linux-musl".into(),
                link: "static".into(),
                flags: vec![],
                phases: Default::default(),
                zig_version: None,
            },
            target_bin: "/bin/x".into(),
            expected_hash: None,
            deps: Default::default(),
            evidence: Default::default(),
                slots: Default::default(),
        };
        let err = m.verify_schema().unwrap_err();
        assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
    }

    #[test]
    fn from_recipe_git_preserva_build_phases_y_patch() {
        // Receta git con install custom, flags, zig_version → el paquete debe reproducir todo.
        let toml = r#"
name = "ripgrep"
version = "14.1.1"
[source]
repo   = "https://github.com/BurntSushi/ripgrep"
commit = "4649aa9700619f94cf9c66876e9549d83420e16c"
[build]
compiler = "zig-cc"
target   = "x86_64-linux-musl"
link     = "static"
flags    = ["--bin", "rg"]
[build.phases]
install = "cp target/release/rg /out/usr/bin/rg"
"#;
        let recipe = crate::Recipe::from_toml(toml).unwrap();
        let swm = Swm::from_recipe(
            &recipe,
            "/usr/bin/rg",
            Some("PATCH-TEXT".into()),
            Some("b3:cafe".into()),
            "2026-06-21",
        )
        .unwrap();
        assert_eq!(swm.base.distro_version, "2026-06-21");
        match &swm.mutations[0] {
            Mutation::SourcePatch {
                repo, commit, tarball, patch, build, target_bin, expected_hash, strip_components, ..
            } => {
                assert_eq!(repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep"));
                assert_eq!(commit.as_deref(), Some("4649aa9700619f94cf9c66876e9549d83420e16c"));
                assert!(tarball.is_none());
                assert_eq!(patch.as_deref(), Some("PATCH-TEXT"));
                assert_eq!(target_bin, "/usr/bin/rg");
                assert_eq!(expected_hash.as_deref(), Some("b3:cafe"));
                assert!(strip_components.is_none(), "git no lleva strip_components");
                assert_eq!(build.compiler, "zig-cc");
                assert_eq!(build.link, "static");
                assert_eq!(build.flags, vec!["--bin".to_string(), "rg".to_string()]);
                assert_eq!(build.phases.install.as_deref(), Some("cp target/release/rg /out/usr/bin/rg"));
            }
            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
        }
    }

    #[test]
    fn from_recipe_tarball_lleva_zig_version_y_strip() {
        let toml = r#"
name = "openssl"
version = "3.5.4"
[source]
tarball = "https://example/openssl-3.5.4.tar.gz"
sha256  = "deadbeef"
strip_components = 2
[build]
compiler = "zig-cc"
zig_version = "0.13.0"
"#;
        let recipe = crate::Recipe::from_toml(toml).unwrap();
        let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", None, None, "dev").unwrap();
        match &swm.mutations[0] {
            Mutation::SourcePatch { tarball, sha256, strip_components, build, expected_hash, .. } => {
                assert_eq!(tarball.as_deref(), Some("https://example/openssl-3.5.4.tar.gz"));
                assert_eq!(sha256.as_deref(), Some("deadbeef"));
                assert_eq!(*strip_components, Some(2), "strip != 1 debe viajar");
                assert_eq!(build.zig_version.as_deref(), Some("0.13.0"));
                assert!(expected_hash.is_none());
            }
            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
        }
    }

    #[test]
    fn verify_schema_source_patch_rechaza_sin_origen() {
        let m = Mutation::SourcePatch {
            repo: None,
            commit: None,
            tarball: None,
            sha256: None,
            strip_components: None,
            patch: None,
            patch_url: None,
            build: SwmBuild {
                compiler: "zig-cc".into(),
                target: "x86_64-linux-musl".into(),
                link: "static".into(),
                flags: vec![],
                phases: Default::default(),
                zig_version: None,
            },
            target_bin: "/bin/x".into(),
            expected_hash: None,
            deps: Default::default(),
            evidence: Default::default(),
                slots: Default::default(),
        };
        let err = m.verify_schema().unwrap_err();
        assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
    }

    #[test]
    fn from_recipe_carries_evidence() {
        // H1a: la evidencia de la receta viaja al .swm (source_patch), roundtripea por YAML,
        // y verify_schema la valida.
        let toml = r#"
name = "grep"
version = "3.11"
[source]
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
sha256  = "deadbeef"
[build]
[[evidence.checks]]
kind = "cmd-exit"
cmd  = "grep --version"
[[evidence.checks]]
kind = "proptest"
cmd  = "cd /src && cargo test proptest_"
expected_exit = 0
"#;
        let recipe = crate::Recipe::from_toml(toml).unwrap();
        let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", None, None, "dev").unwrap();
        // La evidencia llegó a la mutación.
        match &swm.mutations[0] {
            Mutation::SourcePatch { evidence, .. } => {
                assert_eq!(evidence.checks.len(), 2);
                assert_eq!(evidence.checks[0].kind, crate::recipe::EvidenceKind::CmdExit);
                assert_eq!(evidence.checks[1].kind, crate::recipe::EvidenceKind::Proptest);
            }
            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
        }
        // Roundtrip YAML: el bloque evidence sobrevive.
        let yaml = swm.to_yaml().unwrap();
        assert!(yaml.contains("evidence"), "el YAML debe traer evidence: {yaml}");
        let back = Swm::from_yaml(&yaml).unwrap();
        match &back.mutations[0] {
            Mutation::SourcePatch { evidence, .. } => assert_eq!(evidence.checks.len(), 2),
            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
        }
        back.verify_schema().unwrap();
    }

    #[test]
    fn verify_schema_rejects_evidence_with_empty_cmd() {
        let yaml = r#"
swm_version: 1
base:
  distro_version: "dev"
mutations:
  - type: source_patch
    tarball: "https://x/a.tar.gz"
    sha256: "deadbeef"
    build: {}
    target_bin: "/usr/bin/x"
    evidence:
      checks:
        - kind: cmd-exit
          cmd: "   "
"#;
        let swm = Swm::from_yaml(yaml).unwrap();
        let err = swm.verify_schema().unwrap_err().to_string();
        assert!(err.contains("vacío"), "mensaje inesperado: {err}");
    }

    #[test]
    fn source_patch_without_evidence_omits_block() {
        // skip_serializing_if ⇒ un paquete sin evidencia no emite el bloque (compat hacia atrás).
        let toml = r#"
name = "x"
version = "0"
[source]
repo = "git://x"
commit = "deadbeef"
[build]
"#;
        let recipe = crate::Recipe::from_toml(toml).unwrap();
        let swm = Swm::from_recipe(&recipe, "/usr/bin/x", None, None, "dev").unwrap();
        let yaml = swm.to_yaml().unwrap();
        assert!(!yaml.contains("evidence"), "no debería emitir evidence: {yaml}");
    }
}
