//! Hidratación: del store al FHS. Ver `docs/03-hydration.md`.
//!
//! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia, cero RPATH que
//! reescribir. El FHS queda con archivos reales (no symlinks) compartiendo inode con el store.
//! Pisar un archivo del FHS rompe el hardlink (CoW al kernel) sin tocar el artefacto del store
//! — base de rollback (`hammer hydrate <hash>` lo restaura).
//!
//! Estrategia secundaria (`LinkMode::Dynamic`): los ELF que necesitan normalización de
//! interpreter/RPATH se **copian** (no se hardlinkean: `patchelf` reescribe el binario y un
//! hardlink mutaría el inode del store) y se parchean con `patchelf` según el [`DynamicSpec`].
//! Los archivos no-ELF (datos, scripts, libs ya correctas) siguen hardlinkeándose como en
//! estático. Ver `docs/03-hydration.md` §4.

use std::os::unix::fs::FileTypeExt;
use std::path::{Path, PathBuf};
use std::process::Command;

use hammer_core::LinkMode;

#[derive(Debug, Clone)]
pub struct HydratedFile {
    pub src: PathBuf,
    pub dst: PathBuf,
}

#[derive(Debug, Default)]
pub struct HydrateReport {
    pub files: Vec<HydratedFile>,
    pub dirs_created: usize,
    pub symlinks: usize,
    /// Cuántos ELF se copiaron+parchearon con patchelf (sólo en modo dinámico).
    pub patched: usize,
}

/// Parámetros de la hidratación dinámica: qué fijarle a cada ELF antes de proyectarlo. Ambos
/// son opcionales; si los dos son `None`, el modo dinámico se comporta como el estático
/// (nada que parchear ⇒ hardlink directo). En producción salen del contexto de la distro
/// (loader musl + layout de `/usr/lib`).
#[derive(Debug, Clone, Default)]
pub struct DynamicSpec {
    /// Interpreter (dynamic loader) a fijar, p. ej. `/lib/ld-musl-x86_64.so.1`.
    pub interpreter: Option<String>,
    /// RPATH a fijar, p. ej. `/usr/lib:/lib`.
    pub rpath: Option<String>,
}

impl DynamicSpec {
    /// `true` si hay algo que patchelf debería reescribir.
    fn has_work(&self) -> bool {
        self.interpreter.is_some() || self.rpath.is_some()
    }
}

/// Proyecta el contenido de `artifact_dir` al árbol bajo `target_fhs`. Conserva la jerarquía
/// relativa: `<artifact>/usr/bin/grep` → `<target>/usr/bin/grep`.
///
/// Sobre archivos preexistentes en el target, sobrescribe atómicamente (link tmp + rename) —
/// los archivos antiguos se descartan, pero los inodes del store siguen intactos.
pub fn hydrate(
    artifact_dir: &Path,
    target_fhs: &Path,
    mode: LinkMode,
    dynamic: Option<&DynamicSpec>,
) -> hammer_core::Result<HydrateReport> {
    if !artifact_dir.is_dir() {
        return Err(hammer_core::Error::Store(format!(
            "artefacto inexistente: {}",
            artifact_dir.display()
        )));
    }
    // En modo dinámico con patcheo real, exigimos que `patchelf` exista de antemano: mejor
    // fallar limpio que dejar el FHS a medio proyectar.
    let spec = match mode {
        LinkMode::Dynamic => {
            let spec = dynamic.cloned().unwrap_or_default();
            if spec.has_work() {
                ensure_patchelf()?;
            }
            Some(spec)
        }
        LinkMode::Static => None,
    };
    std::fs::create_dir_all(target_fhs)?;

    let mut report = HydrateReport::default();
    walk_and_link(artifact_dir, artifact_dir, target_fhs, spec.as_ref(), &mut report)?;
    Ok(report)
}

fn walk_and_link(
    root: &Path,
    cur: &Path,
    target_fhs: &Path,
    spec: Option<&DynamicSpec>,
    report: &mut HydrateReport,
) -> hammer_core::Result<()> {
    for entry in std::fs::read_dir(cur)? {
        let entry = entry?;
        let src = entry.path();
        let rel = src.strip_prefix(root).expect("rel under root");
        let dst = target_fhs.join(rel);
        let ft = entry.file_type()?;

        if ft.is_dir() {
            std::fs::create_dir_all(&dst)?;
            report.dirs_created += 1;
            walk_and_link(root, &src, target_fhs, spec, report)?;
        } else if ft.is_symlink() {
            // Replicamos el symlink con el mismo target literal (no resolvemos).
            let link_target = std::fs::read_link(&src)?;
            atomic_replace_symlink(&link_target, &dst)?;
            report.symlinks += 1;
            report.files.push(HydratedFile { src, dst });
        } else if ft.is_file() {
            // Modo dinámico: un ELF que necesita interpreter/RPATH se copia y se parchea
            // (hardlinkearlo mutaría el store). Todo lo demás se hardlinkea como en estático.
            let patch = match spec {
                Some(s) if s.has_work() && is_elf(&src)? => Some(s),
                _ => None,
            };
            match patch {
                Some(s) => {
                    atomic_copy_and_patch(&src, &dst, s)?;
                    report.patched += 1;
                }
                None => atomic_hardlink(&src, &dst)?,
            }
            report.files.push(HydratedFile { src, dst });
        } else if ft.is_block_device() || ft.is_char_device() || ft.is_fifo() || ft.is_socket() {
            // Los artefactos del lab no deberían contener nodos especiales; lo señalamos.
            return Err(hammer_core::Error::Other(anyhow::anyhow!(
                "tipo no soportado en {}: {ft:?}",
                src.display()
            )));
        }
    }
    Ok(())
}

/// Crea un hardlink `dst → src` reemplazando atómicamente cualquier archivo previo en `dst`.
/// Implementación: `link(src, dst.tmp)` + `rename(dst.tmp, dst)`. La técnica garantiza que
/// el archivo en `dst` nunca queda en estado intermedio y que el inode del store no se toca.
fn atomic_hardlink(src: &Path, dst: &Path) -> hammer_core::Result<()> {
    if let Some(parent) = dst.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let tmp = with_suffix(dst, ".hammer-tmp");
    // Limpia un tmp olvidado de una corrida anterior.
    let _ = std::fs::remove_file(&tmp);
    std::fs::hard_link(src, &tmp).map_err(|e| {
        hammer_core::Error::Store(format!(
            "hardlink {} → {}: {e}",
            src.display(),
            tmp.display()
        ))
    })?;
    // rename sobreescribe atómicamente si dst es un archivo (no si es directorio).
    if let Err(e) = std::fs::rename(&tmp, dst) {
        let _ = std::fs::remove_file(&tmp);
        return Err(hammer_core::Error::Store(format!(
            "rename {} → {}: {e}",
            tmp.display(),
            dst.display()
        )));
    }
    Ok(())
}

/// `true` si el archivo empieza por el magic ELF (`\x7fELF`). Errores de I/O se propagan;
/// un archivo más corto que 4 bytes simplemente no es ELF.
fn is_elf(path: &Path) -> hammer_core::Result<bool> {
    use std::io::Read;
    let mut f = std::fs::File::open(path)?;
    let mut magic = [0u8; 4];
    match f.read_exact(&mut magic) {
        Ok(()) => Ok(magic == [0x7f, b'E', b'L', b'F']),
        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
        Err(e) => Err(e.into()),
    }
}

/// Verifica que `patchelf` esté disponible antes de empezar a proyectar.
fn ensure_patchelf() -> hammer_core::Result<()> {
    match Command::new("patchelf").arg("--version").output() {
        Ok(o) if o.status.success() => Ok(()),
        Ok(o) => Err(hammer_core::Error::Other(anyhow::anyhow!(
            "patchelf falló al invocarse: {}",
            String::from_utf8_lossy(&o.stderr).trim()
        ))),
        Err(e) => Err(hammer_core::Error::Other(anyhow::anyhow!(
            "hidratación dinámica requiere `patchelf` en el PATH y no se pudo ejecutar: {e}"
        ))),
    }
}

/// Copia `src → dst` atómicamente (copy a tmp → patchelf el tmp → rename), preservando los
/// permisos (`std::fs::copy` los replica, incluido el bit de ejecución). Patcheamos el tmp
/// **antes** del rename para que `dst` nunca quede en un estado a medio parchear.
fn atomic_copy_and_patch(src: &Path, dst: &Path, spec: &DynamicSpec) -> hammer_core::Result<()> {
    if let Some(parent) = dst.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let tmp = with_suffix(dst, ".hammer-tmp");
    let _ = std::fs::remove_file(&tmp);
    std::fs::copy(src, &tmp).map_err(|e| {
        hammer_core::Error::Store(format!("copy {} → {}: {e}", src.display(), tmp.display()))
    })?;
    if let Err(e) = run_patchelf(&tmp, spec) {
        let _ = std::fs::remove_file(&tmp);
        return Err(e);
    }
    if let Err(e) = std::fs::rename(&tmp, dst) {
        let _ = std::fs::remove_file(&tmp);
        return Err(hammer_core::Error::Store(format!(
            "rename {} → {}: {e}",
            tmp.display(),
            dst.display()
        )));
    }
    Ok(())
}

/// Ejecuta `patchelf [--set-interpreter I] [--set-rpath R] <file>`. Asume que el caller ya
/// comprobó (`has_work`) que hay al menos una de las dos.
fn run_patchelf(file: &Path, spec: &DynamicSpec) -> hammer_core::Result<()> {
    let mut cmd = Command::new("patchelf");
    if let Some(interp) = &spec.interpreter {
        cmd.arg("--set-interpreter").arg(interp);
    }
    if let Some(rpath) = &spec.rpath {
        cmd.arg("--set-rpath").arg(rpath);
    }
    cmd.arg(file);
    let out = cmd.output().map_err(|e| {
        hammer_core::Error::Other(anyhow::anyhow!(
            "no pude ejecutar patchelf sobre {}: {e}",
            file.display()
        ))
    })?;
    if !out.status.success() {
        return Err(hammer_core::Error::Other(anyhow::anyhow!(
            "patchelf falló sobre {}: {}",
            file.display(),
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(())
}

fn atomic_replace_symlink(link_target: &Path, dst: &Path) -> hammer_core::Result<()> {
    use std::os::unix::fs::symlink;
    if let Some(parent) = dst.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let tmp = with_suffix(dst, ".hammer-tmp");
    let _ = std::fs::remove_file(&tmp);
    symlink(link_target, &tmp).map_err(|e| {
        hammer_core::Error::Store(format!("symlink {} → {}: {e}", link_target.display(), tmp.display()))
    })?;
    if let Err(e) = std::fs::rename(&tmp, dst) {
        let _ = std::fs::remove_file(&tmp);
        return Err(hammer_core::Error::Store(format!(
            "rename {} → {}: {e}",
            tmp.display(),
            dst.display()
        )));
    }
    Ok(())
}

fn with_suffix(p: &Path, suffix: &str) -> PathBuf {
    let mut s = p.as_os_str().to_owned();
    s.push(suffix);
    PathBuf::from(s)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::fs::MetadataExt;

    fn populate(dir: &Path) {
        std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
        std::fs::write(dir.join("usr/bin/hello"), b"#!/bin/sh\necho hi\n").unwrap();
        std::fs::create_dir_all(dir.join("usr/share/doc")).unwrap();
        std::fs::write(dir.join("usr/share/doc/README"), b"docs").unwrap();
        // symlink: usr/sbin/hello -> ../bin/hello (target relativo)
        std::fs::create_dir_all(dir.join("usr/sbin")).unwrap();
        std::os::unix::fs::symlink("../bin/hello", dir.join("usr/sbin/hello")).unwrap();
    }

    #[test]
    fn hydrate_creates_hardlinks_with_shared_inode() {
        let artifact = tempfile::tempdir().unwrap();
        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
        populate(artifact.path());

        let report = hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
        assert_eq!(report.files.len(), 3); // hello, README, symlink
        assert_eq!(report.symlinks, 1);

        let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
        let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
        assert_eq!(src_ino, dst_ino, "hardlink ⇒ mismo inode");
    }

    #[test]
    fn hydrate_overwrites_existing_without_touching_store() {
        let artifact = tempfile::tempdir().unwrap();
        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
        populate(artifact.path());

        // Pre-existe un archivo distinto en el target.
        std::fs::create_dir_all(target.path().join("usr/bin")).unwrap();
        std::fs::write(target.path().join("usr/bin/hello"), b"contenido viejo").unwrap();

        hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();

        // El target ahora apunta al inode del store.
        let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
        let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
        assert_eq!(src_ino, dst_ino);
        // El contenido viejo desapareció; el del store sigue intacto.
        let got = std::fs::read_to_string(target.path().join("usr/bin/hello")).unwrap();
        assert!(got.starts_with("#!/bin/sh"));
        let store_still = std::fs::read_to_string(artifact.path().join("usr/bin/hello")).unwrap();
        assert!(store_still.starts_with("#!/bin/sh"));
    }

    #[test]
    fn hydrate_is_idempotent() {
        let artifact = tempfile::tempdir().unwrap();
        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
        populate(artifact.path());
        hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
        // Segundo paso no debe fallar.
        hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
    }

    #[test]
    fn hydrate_replicates_symlink_target_literally() {
        let artifact = tempfile::tempdir().unwrap();
        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
        populate(artifact.path());
        hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
        let link = std::fs::read_link(target.path().join("usr/sbin/hello")).unwrap();
        assert_eq!(link, std::path::PathBuf::from("../bin/hello"));
    }

    #[test]
    fn hydrate_dynamic_sin_spec_se_comporta_como_estatico() {
        // Sin interpreter ni rpath no hay nada que parchear ⇒ hardlink directo (mismo inode),
        // sin necesitar patchelf. Es el caso que prueba que el modo dinámico no rompe.
        let artifact = tempfile::tempdir().unwrap();
        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
        populate(artifact.path());
        let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, None).unwrap();
        assert_eq!(report.patched, 0);
        let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
        let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
        assert_eq!(src_ino, dst_ino, "sin spec ⇒ hardlink ⇒ mismo inode");
    }

    #[test]
    fn is_elf_detecta_magic() {
        let d = tempfile::tempdir().unwrap();
        let elf = d.path().join("bin");
        std::fs::write(&elf, [0x7f, b'E', b'L', b'F', 0x02, 0x01]).unwrap();
        let noelf = d.path().join("script");
        std::fs::write(&noelf, b"#!/bin/sh\n").unwrap();
        let corto = d.path().join("corto");
        std::fs::write(&corto, b"hi").unwrap();
        assert!(is_elf(&elf).unwrap());
        assert!(!is_elf(&noelf).unwrap());
        assert!(!is_elf(&corto).unwrap(), "archivo <4 bytes no es ELF");
    }

    #[test]
    fn hydrate_dynamic_con_spec_pero_sin_patchelf_falla_limpio() {
        // Con un interpreter a fijar y un "ELF" presente, se exige patchelf. En este entorno
        // patchelf no está instalado ⇒ debe fallar con mensaje claro ANTES de proyectar.
        if Command::new("patchelf").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
            eprintln!("patchelf presente; salto el test del camino de error");
            return;
        }
        let artifact = tempfile::tempdir().unwrap();
        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
        std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap();
        std::fs::write(artifact.path().join("usr/bin/elf"), [0x7f, b'E', b'L', b'F', 0, 0, 0, 0]).unwrap();
        let spec = DynamicSpec {
            interpreter: Some("/lib/ld-musl-x86_64.so.1".into()),
            rpath: Some("/usr/lib:/lib".into()),
        };
        let err = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec))
            .unwrap_err()
            .to_string();
        assert!(err.contains("patchelf"), "{err}");
    }

    /// Camino feliz real: requiere patchelf + un ELF de verdad. Gated como el resto de tests
    /// dependientes del host (`HAMMER_HOST_ELF_TESTS=1`).
    #[test]
    fn hydrate_dynamic_patchea_elf_real() {
        if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() {
            return;
        }
        let host_bin = std::path::Path::new("/bin/sh");
        if !host_bin.exists() {
            return;
        }
        let artifact = tempfile::tempdir().unwrap();
        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
        std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap();
        std::fs::copy(host_bin, artifact.path().join("usr/bin/sh")).unwrap();
        let spec = DynamicSpec { interpreter: None, rpath: Some("/usr/lib:/lib".into()) };
        let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec)).unwrap();
        assert_eq!(report.patched, 1);
        // El binario proyectado es una COPIA (distinto inode del artefacto), porque patchelf lo
        // reescribió sin tocar el store.
        let src_ino = std::fs::metadata(artifact.path().join("usr/bin/sh")).unwrap().ino();
        let dst_ino = std::fs::metadata(target.path().join("usr/bin/sh")).unwrap().ino();
        assert_ne!(src_ino, dst_ino, "ELF parcheado ⇒ copia ⇒ inode distinto");
    }
}
