//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable.
//!
//! Dos modos, decididos por la receta (ver `hammer_core::SourceKind`):
//!
//! * **Git** — mirror local en `<work_root>/repos/<name>.git` + `git archive` del commit
//!   fijado al árbol trabajable. El mirror se reusa entre builds; el árbol es descartable.
//! * **Tarball** — descarga con `curl`, verificación de sha256, extracción con `tar`. Se
//!   cachean los archivos descargados en `<work_root>/tarballs/`.
//!
//! La red sólo se permite aquí, FUERA del sandbox de build. Después del fetch, el árbol del
//! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa
//! copia (sin red).

use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use hammer_core::{Recipe, SourceKind};

/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta.
pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf> {
    match recipe.source.kind()? {
        SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
        SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
    }
}

fn fetch_git(
    recipe: &Recipe,
    repo: &str,
    commit: &str,
    work_root: &Path,
) -> hammer_core::Result<PathBuf> {
    let repos_dir = work_root.join("repos");
    let sources_dir = work_root.join("sources");
    std::fs::create_dir_all(&repos_dir)?;
    std::fs::create_dir_all(&sources_dir)?;

    let mirror = repos_dir.join(format!("{}.git", recipe.name));
    if !mirror.is_dir() {
        run_git(
            &[
                "clone",
                "--mirror",
                "--filter=blob:none",
                repo,
                mirror.to_str().unwrap(),
            ],
            None,
        )?;
    }

    // Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
    if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
        run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
        run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| {
            hammer_core::Error::Other(anyhow::anyhow!(
                "commit {commit} no existe en {repo} tras fetch"
            ))
        })?;
    }

    // Materializa un árbol trabajable mediante `git archive` → tar → extract.
    // Esto evita el coste de un worktree completo y deja sólo los archivos del commit.
    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit));
    if work_tree.is_dir() {
        std::fs::remove_dir_all(&work_tree)?;
    }
    std::fs::create_dir_all(&work_tree)?;

    let archive = Command::new("git")
        .arg("-C")
        .arg(&mirror)
        .args(["archive", "--format=tar", commit])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;

    let tar_status = Command::new("tar")
        .args(["-x", "-C"])
        .arg(&work_tree)
        .stdin(archive.stdout.unwrap())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;

    if !tar_status.success() {
        return Err(hammer_core::Error::Other(anyhow::anyhow!(
            "git archive | tar -x falló (tar exit {:?})",
            tar_status.code()
        )));
    }
    Ok(work_tree)
}

fn fetch_tarball(
    recipe: &Recipe,
    url: &str,
    sha256: &str,
    work_root: &Path,
) -> hammer_core::Result<PathBuf> {
    let tarballs_dir = work_root.join("tarballs");
    let sources_dir = work_root.join("sources");
    std::fs::create_dir_all(&tarballs_dir)?;
    std::fs::create_dir_all(&sources_dir)?;

    // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y
    // cambiar la URL sin cambiar el sha (mirror) no invalida la caché.
    let cached = tarballs_dir.join(format!("{sha256}.tar"));
    if !cached.is_file() {
        download_verify(url, sha256, &cached)?;
    } else {
        verify_sha256(&cached, sha256)?;
    }

    // ⚠ CARRERA CONOCIDA, SIN RESOLVER — ver docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
    // El path es determinista y NO lleva nada del constructor ⇒ dos builds de la misma receta (la
    // misma dep alcanzada por dos caminos) resuelven al mismo directorio: uno borra mientras el otro
    // extrae y el árbol queda roto con "Directory not empty" (os error 39) hasta borrarlo a mano.
    // Mitigado SÓLO entre campana-deuda.sh y farm-worker-loop.sh con un lock de script (commit
    // 1b436a7); el `xargs -P2` de build-farm.sh sigue expuesto.
    // NO "arreglar" con un lock alrededor de esta extracción: parece correcto y no lo es, porque un
    // segundo proceso puede borrar un árbol que un bwrap ya está usando para compilar. Este árbol es
    // caché y workspace mutable a la vez (lib.rs lo parchea y vendorea in situ); el ADR 0012 plantea
    // las tres salidas y qué medir para elegir.
    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
    if work_tree.is_dir() {
        std::fs::remove_dir_all(&work_tree)?;
    }
    std::fs::create_dir_all(&work_tree)?;

    // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo
    // identificador interno (no afecta a la detección).
    let strip = recipe.source.strip_components.to_string();
    let status = Command::new("tar")
        .arg("-x")
        // `--no-same-owner`: extrae como el usuario actual (no preserva el uid del tarball). Si el
        // fetch corre como root (granja), `tar` por defecto preservaría el owner original → ficheros
        // de un uid que el sandbox (userns) no puede chmod/escribir → configure de autotools falla
        // ("Permission denied" creando Makefile, "Operation not permitted" en chmod). Inocuo para el
        // of_tree (es el árbol de FUENTES, no el output). Destraba libs C como oniguruma.
        .arg("--no-same-owner")
        .arg("-f")
        .arg(&cached)
        .arg("--strip-components")
        .arg(&strip)
        .arg("-C")
        .arg(&work_tree)
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?;
    if !status.success() {
        return Err(hammer_core::Error::Other(anyhow::anyhow!(
            "tar -x falló (exit {:?})",
            status.code()
        )));
    }
    Ok(work_tree)
}

/// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo
/// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché).
fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
    let tmp = dst.with_extension("tar.partial");
    let status = Command::new("curl")
        .args(["-fL", "--retry", "3", "--connect-timeout", "30", "-o"])
        .arg(&tmp)
        .arg(url)
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?;
    if !status.success() {
        let _ = std::fs::remove_file(&tmp);
        return Err(hammer_core::Error::Other(anyhow::anyhow!(
            "curl {url} falló (exit {:?})",
            status.code()
        )));
    }
    if let Err(e) = verify_sha256(&tmp, sha256) {
        let _ = std::fs::remove_file(&tmp);
        return Err(e);
    }
    std::fs::rename(&tmp, dst)?;
    Ok(())
}

fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> {
    use sha2::{Digest, Sha256};
    let mut f = std::fs::File::open(path)?;
    let mut hasher = Sha256::new();
    std::io::copy(&mut f, &mut hasher)?;
    let got = hex::encode(hasher.finalize());
    if !got.eq_ignore_ascii_case(expected) {
        return Err(hammer_core::Error::Other(anyhow::anyhow!(
            "sha256 mismatch en {}: esperado {expected}, obtenido {got}",
            path.display()
        )));
    }
    Ok(())
}

/// Vendoriza las dependencias de un proyecto Cargo en `<src>/vendor` y escribe
/// `<src>/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`.
///
/// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch).
///
/// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con
/// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está
/// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo
/// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`:
/// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden
/// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente
/// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8.
///
/// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo.
pub fn vendor_cargo_deps(src: &Path, vendor_dir: &str) -> hammer_core::Result<()> {
    let locked = src.join("Cargo.lock").is_file();
    let mut args: Vec<&str> = vec!["vendor"];
    if locked {
        args.push("--locked");
    } else {
        tracing::warn!(
            src = %src.display(),
            "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \
             tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)."
        );
    }
    // Directorio de salida (default `vendor`; override per-receta vía `[source] cargo_vendor_dir`
    // para no pisar un `vendor/` propio del proyecto). `cargo vendor` emite el `[source.*]` a stdout
    // apuntando a ESTE dir, así que el `.cargo/config.toml` que persistimos abajo resuelve solo.
    args.push(vendor_dir);
    let output = Command::new("cargo")
        .current_dir(src)
        .args(&args)
        .stderr(Stdio::inherit())
        .output()
        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?;
    if !output.status.success() {
        return Err(hammer_core::Error::Other(anyhow::anyhow!(
            "cargo vendor falló (exit {:?})",
            output.status.code()
        )));
    }
    // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el
    // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que
    // resuelve igual dentro del sandbox donde el source se monta en /src).
    let cargo_dir = src.join(".cargo");
    std::fs::create_dir_all(&cargo_dir)?;
    std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?;
    Ok(())
}

/// Vendoriza los módulos de un proyecto Go en `<src>/vendor` para que `go build -mod=vendor`
/// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como
/// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol.
///
/// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum`
/// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo
/// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que
/// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo);
/// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga.
///
/// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo.
pub fn vendor_go_deps(src: &Path) -> hammer_core::Result<()> {
    // Proyecto WORKSPACE (multimódulo): si el árbol trae `go.work`, `go mod vendor` con GOWORK=off
    // vendorea SÓLO el módulo raíz → el build (que ve todo el workspace) se queja de "inconsistent
    // vendoring". `go work vendor` (go 1.22+) produce un `vendor/` consistente para TODO el workspace.
    // Destraba la clase go.work (go-mockery, k3d, git-town, csvtk…). Sin go.work, el camino clásico
    // `go mod vendor` con GOWORK=off (ignora cualquier go.work espurio y vendorea el raíz).
    let has_work = src.join("go.work").is_file();
    let (verb, label): ([&str; 2], &str) = if has_work {
        (["work", "vendor"], "go work vendor")
    } else {
        (["mod", "vendor"], "go mod vendor")
    };
    let mut cmd = Command::new("go");
    cmd.current_dir(src)
        .args(verb)
        .env("GOFLAGS", "-mod=mod")
        .env("GOTOOLCHAIN", "local");
    if !has_work {
        cmd.env("GOWORK", "off");
    }
    let output = cmd
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .output()
        .map_err(|e| {
            hammer_core::Error::Other(anyhow::anyhow!(
                "spawn {label}: {e} (¿está `go` en el PATH del host? lo provee el lab, \
                 cf. scripts/bootstrap-devfs.sh / vps-setup.sh)"
            ))
        })?;
    if !output.status.success() {
        return Err(hammer_core::Error::Other(anyhow::anyhow!(
            "{label} falló (exit {:?})",
            output.status.code()
        )));
    }
    Ok(())
}

/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
    for p in &recipe.source.patches {
        let patch_path = recipe.base_dir.join(p);
        let bytes = std::fs::read(&patch_path).map_err(|e| {
            hammer_core::Error::Recipe(format!(
                "no pude leer patch {}: {e}",
                patch_path.display()
            ))
        })?;
        let mut child = Command::new("patch")
            .args(["-p1", "--batch", "--silent"])
            .current_dir(tree)
            .stdin(Stdio::piped())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .spawn()
            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?;
        if let Some(mut stdin) = child.stdin.take() {
            use std::io::Write;
            stdin
                .write_all(&bytes)
                .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
        }
        let status = child
            .wait()
            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
        if !status.success() {
            return Err(hammer_core::Error::Other(anyhow::anyhow!(
                "patch {} falló (exit {:?})",
                patch_path.display(),
                status.code()
            )));
        }
    }
    Ok(())
}

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

    #[test]
    fn vendor_cargo_deps_runs_and_writes_config() {
        // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
        // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`.
        let tmp = tempfile::tempdir().unwrap();
        let src = tmp.path();
        std::fs::write(
            src.join("Cargo.toml"),
            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
        )
        .unwrap();
        std::fs::create_dir_all(src.join("src")).unwrap();
        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();

        // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps).
        let lock_ok = Command::new("cargo")
            .args(["generate-lockfile", "--offline"])
            .current_dir(src)
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if !lock_ok {
            eprintln!("SKIP: cargo no disponible para generate-lockfile");
            return;
        }

        vendor_cargo_deps(src, "vendor").unwrap();
        assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring");
    }

    #[test]
    fn vendor_cargo_deps_without_committed_lock() {
        // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked,
        // generando el lock al vuelo. Para un crate depless es offline.
        let tmp = tempfile::tempdir().unwrap();
        let src = tmp.path();
        std::fs::write(
            src.join("Cargo.toml"),
            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
        )
        .unwrap();
        std::fs::create_dir_all(src.join("src")).unwrap();
        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
        assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock");

        if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) {
            eprintln!("SKIP: cargo no disponible");
            return;
        }
        // No debe fallar por falta de --locked; genera el lock y escribe el config.
        vendor_cargo_deps(src, "vendor").unwrap();
        assert!(src.join(".cargo/config.toml").is_file());
        assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock");
    }
}

fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_core::Result<()> {
    let mut cmd = Command::new("git");
    if let Some(c) = cwd {
        cmd.arg("-C").arg(c);
    }
    cmd.args(args);
    let status = cmd
        .stdin(Stdio::null())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
    if !status.success() {
        return Err(hammer_core::Error::Other(anyhow::anyhow!(
            "git {args:?} falló (exit {:?})",
            status.code()
        )));
    }
    Ok(())
}
