//! Puente entre `hammer_core::Mutation::SourcePatch` y el laboratorio.
//!
//! Convierte la mutación serializada en una `Recipe` efímera + un archivo de patch
//! temporal, y delega en `build` para sellar el artefacto en el store. Luego se hidrata.
//!
//! Este módulo vive en `hammer-build` (no en `hammer-core`) porque depende del store y del
//! sandbox. `hammer-core` debe permanecer puro (sin red ni mounts) para que el watcher y los
//! tests no arrastren toda la cadena del lab.

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

use hammer_core::{
    swm::{Mutation, SwmBuild},
    ArtifactHash, Compiler, LinkMode, Recipe, Store,
};

use crate::{build, BuildConfig};

/// Construye una `SourcePatch` y devuelve el hash sellado en el store. El llamador hidrata
/// luego (típicamente al overlay activo).
///
/// `scratch_root` sirve para escribir el patch inline a disco — usamos `cfg.work_root` por
/// defecto si el caller no quiere gestionarlo a mano.
pub fn build_source_patch(
    mutation: &Mutation,
    cfg: &BuildConfig,
    store: &Store,
    scratch_root: Option<&Path>,
    name: Option<&str>,
) -> hammer_core::Result<ArtifactHash> {
    let catalog_dir = catalog_dir_for(cfg, scratch_root);
    // El NOMBRE de la receta sintetizada decide el sufijo del store (`<hash>-<name>`) Y la
    // clave de cache-hit (`store.has(hash, name)`). Si quien llama conoce el nombre del paquete
    // (p.ej. `install ripgrep`), lo pasa: así reproduce como "ripgrep" y cache-hitea el artefacto
    // del corpus, en vez de derivar "rg" del target_bin y sellar un duplicado bajo otro nombre.
    let recipe = recipe_from_source_patch(mutation, name, &catalog_dir)?;

    let expected = match mutation {
        Mutation::SourcePatch { expected_hash, .. } => expected_hash.clone(),
        _ => None,
    };
    let hash = build(&recipe, cfg, store)?;
    if let Some(want) = &expected {
        let want_hex = want.strip_prefix("b3:").unwrap_or(want);
        let got_hex = hash
            .as_str()
            .strip_prefix("b3:")
            .expect("of_inputs siempre devuelve 'b3:'");
        if want_hex != got_hex {
            return Err(hammer_core::Error::Recipe(format!(
                "expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
            )));
        }
    }
    Ok(hash)
}

/// El directorio-catálogo donde se materializan los patches y los `{dep}.toml`. Determinista a
/// partir de `cfg.work_root` (o `scratch_root`): por eso `install` puede pre-poblarlo con los
/// `{dep}.toml` y `build_source_patch` los encontrará al resolver build-deps del mismo `base_dir`.
pub fn catalog_dir_for(cfg: &BuildConfig, scratch_root: Option<&Path>) -> PathBuf {
    scratch_root.unwrap_or(&cfg.work_root).join("swm-recipes")
}

/// Sintetiza (SIN construir) la `Recipe` efímera de un `source_patch`: parsea el origen,
/// materializa el patch inline/remoto a disco bajo `catalog_dir`, y arma la receta con sus
/// `deps` y `base_dir = catalog_dir` (para que las build-deps resuelvan `{dep}.toml` ahí).
///
/// `name` sobreescribe el nombre derivado del `target_bin`. Es OBLIGATORIO al escribir un
/// `{dep}.toml`: el nombre de la receta DEBE ser el de la dep para que el dependiente la resuelva.
pub fn recipe_from_source_patch(
    mutation: &Mutation,
    name: Option<&str>,
    catalog_dir: &Path,
) -> hammer_core::Result<Recipe> {
    let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence, slots) =
        match mutation {
            Mutation::SourcePatch {
                repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
                target_bin, deps, evidence, slots, ..
            } => (
                repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
                target_bin, deps, evidence, slots,
            ),
            _ => {
                return Err(hammer_core::Error::Recipe(
                    "recipe_from_source_patch: la mutación no es 'source_patch'".into(),
                ));
            }
        };

    // git xor tarball — misma regla que el schema (`swm_source_kind`).
    let source_kind = hammer_core::swm::swm_source_kind(
        repo.as_deref(),
        commit.as_deref(),
        tarball.as_deref(),
        sha256.as_deref(),
    )
    .map_err(hammer_core::Error::Recipe)?;

    std::fs::create_dir_all(catalog_dir)?;

    // Clave estable del origen para nombrar el patch a disco: el commit (git) o el sha256
    // (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
    let source_key: &str = match &source_kind {
        hammer_core::SourceKind::Git { commit, .. } => commit,
        hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
    };

    let mut patches: Vec<String> = Vec::new();
    if patch.is_some() || patch_url.is_some() {
        // Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar
        // entre `{dep}.patch` distintos en el mismo catálogo.
        let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
        // `source.patches` guarda el NOMBRE relativo a `base_dir` (= catalog_dir); el lab resuelve
        // `base_dir.join(nombre)` UNA vez al construir (fetch::apply_patches / Recipe hash). Guardar el
        // path completo acá lo haría re-unir a base_dir aguas abajo (doble/triple prefijo).
        let filename = format!("{label}-{source_key}.patch");
        let patch_path = catalog_dir.join(&filename);
        if let Some(text) = patch {
            std::fs::write(&patch_path, text.as_bytes())?;
        } else if let Some(url) = patch_url {
            tracing::info!(%url, "fetch: descargando patch remoto");
            crate::download::fetch_url_to_file(url, &patch_path)?;
        }
        patches.push(filename);
    }

    let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
    synthesize_recipe(
        &name,
        &source_kind,
        build_cfg,
        *strip_components,
        deps,
        evidence,
        slots,
        patches,
        catalog_dir,
    )
}

/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
fn derive_name(target_bin: &str) -> String {
    Path::new(target_bin)
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("swm-bin")
        .to_string()
}

#[allow(clippy::too_many_arguments)]
fn synthesize_recipe(
    name: &str,
    source: &hammer_core::SourceKind<'_>,
    build_cfg: &SwmBuild,
    strip_components: Option<usize>,
    deps: &hammer_core::Deps,
    evidence: &hammer_core::Evidence,
    slots: &hammer_core::Slots,
    patches: Vec<String>,
    base_dir: &Path,
) -> hammer_core::Result<Recipe> {
    let compiler = parse_compiler(&build_cfg.compiler)?;
    let link = parse_link(&build_cfg.link)?;
    // Sección `[source]` y sufijo de versión según el modo de origen. El validador de
    // `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente.
    let (version_key, source_block) = match source {
        hammer_core::SourceKind::Git { repo, commit } => (
            *commit,
            format!("repo   = \"{repo}\"\ncommit = \"{commit}\"\n"),
        ),
        hammer_core::SourceKind::Tarball { url, sha256 } => (
            *sha256,
            format!("tarball = \"{url}\"\nsha256  = \"{sha256}\"\n"),
        ),
    };
    // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
    let toml_text = format!(
        r#"
name = "{name}"
version = "swm-{version_short}"

[source]
{source_block}
[build]
compiler = "{compiler}"
target   = "{target}"
link     = "{link}"
flags    = []
"#,
        name = name,
        version_short = &version_key[..version_key.len().min(12)],
        source_block = source_block,
        compiler = compiler.as_str(),
        target = build_cfg.target,
        link = link.as_str(),
    );
    let mut recipe = Recipe::from_toml(&toml_text)?;
    recipe.build.flags = build_cfg.flags.clone();
    // Fidelidad de reconstrucción: fases custom, zig por-paquete y strip del tarball viajan en
    // el .swm (si vienen) y se reinyectan en la receta efímera tal cual los tenía el original.
    recipe.build.phases = build_cfg.phases.clone();
    recipe.build.zig_version = build_cfg.zig_version.clone();
    if let Some(n) = strip_components {
        recipe.source.strip_components = n;
    }
    // `patches` ya viene como nombres relativos a `base_dir` (o rutas absolutas que generamos
    // nosotros): el lab los resuelve con `base_dir.join(p)` UNA vez al construir. NO los re-unimos
    // acá — hacerlo doblaba el prefijo `base_dir` y el join del build lo triplicaba (bug que el
    // dogfood de `jq`, primer paquete con patch + build-dep instalado, destapó).
    recipe.source.patches = patches;
    // Deps por NOMBRE (de la receta original): el lab las resolverá como `{dep}.toml` bajo el
    // `base_dir` — por eso base_dir = el catálogo (no "/"): los patches son absolutos y no lo
    // necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los
    // `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta.
    recipe.deps = deps.clone();
    // Evidencia de comportamiento (H1): viaja en el .swm y se reinyecta en la receta efímera para
    // que el checker la vea al reproducir. No afecta el hash del artefacto (comportamiento ≠ identidad).
    recipe.evidence = evidence.clone();
    // Superficies del sistema (H4b): viajan en el .swm y se reinyectan en la receta efímera para
    // que el gate de compatibilidad de `install` las evalúe. Como la evidencia, no afectan el hash.
    recipe.slots = slots.clone();
    recipe.base_dir = base_dir.to_path_buf();
    Ok(recipe)
}

fn parse_compiler(s: &str) -> hammer_core::Result<Compiler> {
    match s {
        "zig-cc" => Ok(Compiler::ZigCc),
        "clang" => Ok(Compiler::Clang),
        "gcc" => Ok(Compiler::Gcc),
        other => Err(hammer_core::Error::Recipe(format!(
            "compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
        ))),
    }
}

fn parse_link(s: &str) -> hammer_core::Result<LinkMode> {
    match s {
        "static" => Ok(LinkMode::Static),
        "dynamic" => Ok(LinkMode::Dynamic),
        other => Err(hammer_core::Error::Recipe(format!(
            "link desconocido: '{other}' (esperado static/dynamic)"
        ))),
    }
}

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

    #[test]
    fn slots_sobreviven_receta_a_swm_a_receta() {
        // H4b: el gate de compatibilidad depende de que las superficies declaradas en la receta
        // viajen intactas por el `.swm` y se recuperen al reproducir. Ejercita AMBAS direcciones
        // del puente (Swm::from_recipe → recipe_from_source_patch).
        let toml = r#"
name = "tema-oscuro"
version = "1.0"
[source]
repo = "git://x/tema.git"
commit = "abc123"
[build]
compiler = "zig-cc"
[slots.claims]
logo = "b3:azul"
[slots.requires]
"wayland-protocol" = "b3:stock"
"#;
        let recipe = hammer_core::Recipe::from_toml(toml).unwrap();
        let swm =
            hammer_core::Swm::from_recipe(&recipe, "/usr/bin/tema", None, None, "2026-06-06").unwrap();
        // El .swm YAML debe llevar el bloque slots.
        let yaml = swm.to_yaml().unwrap();
        assert!(yaml.contains("slots"), "el .swm debe transportar slots: {yaml}");

        // Vuelta: source_patch → Recipe recupera exactamente los slots.
        let d = tempfile::tempdir().unwrap();
        let mutation = swm
            .mutations
            .iter()
            .find(|m| matches!(m, Mutation::SourcePatch { .. }))
            .unwrap();
        let back = recipe_from_source_patch(mutation, Some("tema-oscuro"), d.path()).unwrap();
        assert_eq!(back.slots, recipe.slots);
        assert_eq!(back.slots.claims.get("logo").unwrap(), "b3:azul");
        assert_eq!(back.slots.requires.get("wayland-protocol").unwrap(), "b3:stock");
    }

    fn fake_swm_build() -> SwmBuild {
        SwmBuild {
            compiler: "zig-cc".into(),
            target: "x86_64-linux-musl".into(),
            link: "static".into(),
            flags: vec!["--enable-foo".into()],
            phases: Default::default(),
            zig_version: None,
        }
    }

    #[test]
    fn derive_name_from_target() {
        assert_eq!(derive_name("/usr/bin/grep"), "grep");
        assert_eq!(derive_name("/bin/ls"), "ls");
        assert_eq!(derive_name("/"), "swm-bin");
    }

    #[test]
    fn synthesize_recipe_basic() {
        let d = tempfile::tempdir().unwrap();
        let r = synthesize_recipe(
            "grep",
            &hammer_core::SourceKind::Git {
                repo: "git://example/grep.git",
                commit: "a1b2c3d4e5f6a7b8c9",
            },
            &fake_swm_build(),
            None,
            &hammer_core::Deps::default(),
            &hammer_core::Evidence::default(),
            &hammer_core::Slots::default(),
            vec![],
            d.path(),
        )
        .unwrap();
        assert_eq!(r.name, "grep");
        assert!(r.version.starts_with("swm-a1b2c3d4"));
        assert_eq!(r.build.flags, vec!["--enable-foo"]);
        match r.source.kind().unwrap() {
            hammer_core::SourceKind::Git { repo, commit } => {
                assert_eq!(repo, "git://example/grep.git");
                assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
            }
            _ => panic!("modo git esperado"),
        }
    }

    #[test]
    fn synthesize_recipe_tarball() {
        let d = tempfile::tempdir().unwrap();
        let r = synthesize_recipe(
            "grep",
            &hammer_core::SourceKind::Tarball {
                url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz",
                sha256: "abcdef0123456789",
            },
            &fake_swm_build(),
            None,
            &hammer_core::Deps::default(),
            &hammer_core::Evidence::default(),
            &hammer_core::Slots::default(),
            vec![],
            d.path(),
        )
        .unwrap();
        assert_eq!(r.name, "grep");
        assert!(r.version.starts_with("swm-abcdef012345"));
        match r.source.kind().unwrap() {
            hammer_core::SourceKind::Tarball { url, sha256 } => {
                assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz");
                assert_eq!(sha256, "abcdef0123456789");
            }
            _ => panic!("modo tarball esperado"),
        }
    }

    #[test]
    fn inline_patch_resuelve_con_un_solo_join_de_base_dir() {
        // Regresión: `source.patches` debe guardar el NOMBRE relativo a base_dir, de modo que el
        // lab resuelva `base_dir.join(p)` UNA sola vez. El bug doblaba/triplicaba el prefijo
        // `swm-recipes/` (lo destapó el dogfood de `jq`, primer paquete con patch instalado).
        let d = tempfile::tempdir().unwrap();
        let catalog = d.path().join("swm-recipes");
        let m = Mutation::SourcePatch {
            repo: None,
            commit: None,
            tarball: Some("https://x/jq-1.8.1.tar.gz".into()),
            sha256: Some("2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0".into()),
            strip_components: None,
            patch: Some("--- a\n+++ b\n".into()),
            patch_url: None,
            build: fake_swm_build(),
            target_bin: "/usr/bin/jq".into(),
            expected_hash: None,
            deps: Default::default(),
            evidence: Default::default(),
                slots: Default::default(),
        };
        let recipe = recipe_from_source_patch(&m, None, &catalog).unwrap();
        assert_eq!(recipe.source.patches.len(), 1);
        let p = &recipe.source.patches[0];
        // El nombre NO debe llevar embebido el dir del catálogo (eso causaba el doble prefijo).
        assert!(!p.contains("swm-recipes"), "patch debe ser relativo a base_dir, no llevar el catálogo: {p}");
        // Y base_dir.join(p) — el ÚNICO join que hace el lab — debe dar el fichero real escrito.
        let resolved = recipe.base_dir.join(p);
        assert!(resolved.is_file(), "base_dir.join(patch) debe existir en un solo paso: {}", resolved.display());
        assert_eq!(std::fs::read(&resolved).unwrap(), b"--- a\n+++ b\n");
    }

    #[test]
    fn parse_compiler_and_link() {
        assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
        assert!(parse_compiler("xlc").is_err());
        assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
        assert!(parse_link("partial").is_err());
    }

    #[test]
    fn rejects_non_source_patch_mutation() {
        let m = Mutation::ConfigEdit {
            file: "/etc/x".into(),
            inline_diff: "- a\n+ b\n".into(),
        };
        let d = tempfile::tempdir().unwrap();
        let store = Store::open(d.path().join("store")).unwrap();
        let cfg = BuildConfig::defaults_for_store(store.root());
        let err = build_source_patch(&m, &cfg, &store, None, None).unwrap_err().to_string();
        assert!(err.contains("source_patch"), "{err}");
    }

    #[test]
    fn patch_url_is_downloaded_before_build() {
        // Servimos el patch por file:// (sin red). build_source_patch debe descargarlo y
        // materializarlo en swm-recipes/<commit>.patch ANTES de fallar aguas abajo por no
        // haber rootfs de build en este entorno. Eso prueba que el patch_url quedó cableado.
        let d = tempfile::tempdir().unwrap();
        let patch_src = d.path().join("remote.patch");
        std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap();
        let commit = "deadbeefcafe";
        let m = Mutation::SourcePatch {
            repo: Some("git://x".into()),
            commit: Some(commit.into()),
            tarball: None,
            sha256: None,
            strip_components: None,
            patch: None,
            patch_url: Some(format!("file://{}", patch_src.display())),
            build: fake_swm_build(),
            target_bin: "/bin/x".into(),
            expected_hash: None,
            deps: Default::default(),
            evidence: Default::default(),
                slots: Default::default(),
        };
        let store = Store::open(d.path().join("store")).unwrap();
        let cfg = BuildConfig::defaults_for_store(store.root());
        let scratch = d.path().join("scratch");
        // Falla (no hay rootfs), pero el patch ya debe estar descargado.
        let _ = build_source_patch(&m, &cfg, &store, Some(&scratch), None);
        // El patch se materializa como `{label}-{source_key}.patch` (label = derive_name del
        // target_bin) para no colisionar entre deps en el catálogo.
        let landed = scratch.join("swm-recipes").join(format!("x-{commit}.patch"));
        assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display());
        assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n");
    }
}
