//! El content-addressed store. Ver `docs/03-hydration.md` §1.
//!
//! Inmutable, append-only, direccionado por `ArtifactHash`. `seal` mueve el árbol de salida
//! del sandbox a la ruta del store y lo marca read-only recursivo.

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

use crate::hash::ArtifactHash;
use crate::recipe::Recipe;

/// Ruta relativa, dentro de cada artefacto sellado, donde el lab guarda la receta original
/// como sidecar de provenance. Estable: el receptor de un `.swm` exportado puede asumir
/// este path para reconstruir la receta de un artefacto cualquiera.
pub const RECIPE_SIDECAR_REL: &str = ".hammer/recipe.toml";

pub struct Store {
    root: PathBuf,
}

impl Store {
    /// Abre (o prepara) un store en `root` (p. ej. `/store`).
    pub fn open(root: impl Into<PathBuf>) -> crate::Result<Self> {
        let root = root.into();
        std::fs::create_dir_all(&root)?;
        Ok(Store { root })
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Ruta del artefacto en el store para un hash + nombre legible.
    pub fn path_of(&self, h: &ArtifactHash, name: &str) -> PathBuf {
        self.root.join(h.store_dir_name(name))
    }

    /// ¿Ya existe el artefacto? (caché del lab; ver `docs/02-build-lab.md` §5).
    pub fn has(&self, h: &ArtifactHash, name: &str) -> bool {
        self.path_of(h, name).is_dir()
    }

    /// Resuelve el directorio de un artefacto a partir de un prefijo del hex de su hash
    /// (con o sin `b3:`), sin necesidad de conocer el `name`. Devuelve error si no hay
    /// match o si el prefijo es ambiguo (lista los candidatos).
    pub fn find_by_hash(&self, hex_or_prefixed: &str) -> crate::Result<PathBuf> {
        let prefix = hex_or_prefixed
            .strip_prefix("b3:")
            .unwrap_or(hex_or_prefixed);
        if prefix.is_empty() {
            return Err(crate::Error::Store("hash vacío".into()));
        }
        let mut matches = Vec::new();
        let read = std::fs::read_dir(&self.root)?;
        for entry in read {
            let entry = entry?;
            let name = entry.file_name();
            let Some(name) = name.to_str() else { continue };
            if name.starts_with(prefix) && entry.file_type()?.is_dir() {
                matches.push(entry.path());
            }
        }
        match matches.len() {
            0 => Err(crate::Error::Store(format!(
                "ningún artefacto en {} con prefix '{prefix}'",
                self.root.display()
            ))),
            1 => Ok(matches.pop().unwrap()),
            _ => Err(crate::Error::Store(format!(
                "prefix '{prefix}' ambiguo, {} candidatos: {}",
                matches.len(),
                matches
                    .iter()
                    .map(|p| p.display().to_string())
                    .collect::<Vec<_>>()
                    .join(", ")
            ))),
        }
    }

    /// Lee la receta sidecar (`.hammer/recipe.toml`) dentro de un artefacto sellado, si
    /// existe. Devuelve `None` para artefactos viejos sin sidecar (builds anteriores a la
    /// introducción del provenance) o cuando el sidecar fue eliminado a mano. Es
    /// best-effort: nunca falla por ausencia, sólo por sidecar corrupto.
    pub fn recipe_for_dir(&self, artifact_dir: &Path) -> crate::Result<Option<Recipe>> {
        let p = artifact_dir.join(RECIPE_SIDECAR_REL);
        if !p.is_file() {
            return Ok(None);
        }
        let text = std::fs::read_to_string(&p)?;
        Recipe::from_toml(&text).map(Some)
    }

    /// Como `recipe_for_dir` pero resuelve el artefacto por hash (con o sin `b3:`).
    pub fn recipe_for_hash(&self, hex_or_prefixed: &str) -> crate::Result<Option<Recipe>> {
        let dir = match self.find_by_hash(hex_or_prefixed) {
            Ok(d) => d,
            Err(_) => return Ok(None),
        };
        self.recipe_for_dir(&dir)
    }

    /// Sella el árbol de salida de un build en el store bajo su hash.
    ///
    /// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`.
    /// - Si no, mueve `out_dir` al destino y marca el árbol read-only (todos los archivos
    ///   pierden `w`; directorios mantienen `x` para poder atravesarlos).
    ///
    /// Camino rápido: `rename` atómico cuando `out_dir` y el store comparten filesystem.
    /// Camino EXDEV (Etapa B3, `/store` es una partición dedicada ≠ la de `/work`): `rename(2)`
    /// no cruza filesystems ⇒ caemos a copia recursiva a un staging DENTRO del store + rename
    /// store-interno (mismo FS, atómico) + borrado del original. La publicación sigue siendo
    /// atómica (nadie ve el `dst` a medio escribir).
    pub fn seal(
        &self,
        out_dir: &Path,
        h: &ArtifactHash,
        name: &str,
    ) -> crate::Result<PathBuf> {
        let dst = self.path_of(h, name);
        if dst.is_dir() {
            return Ok(dst);
        }
        if let Some(parent) = dst.parent() {
            std::fs::create_dir_all(parent)?;
        }
        match std::fs::rename(out_dir, &dst) {
            Ok(()) => {}
            // EXDEV (18) = Cross-device link: out_dir y el store viven en filesystems distintos.
            Err(e) if e.raw_os_error() == Some(EXDEV) => {
                let parent = dst.parent().unwrap_or_else(|| Path::new("."));
                let fname = dst.file_name().expect("dst con nombre");
                let staging = parent.join(format!(".seal-tmp-{}", fname.to_string_lossy()));
                let _ = std::fs::remove_dir_all(&staging); // restos de un intento previo
                copy_tree(out_dir, &staging).map_err(|e| {
                    crate::Error::Store(format!(
                        "copia cross-device {} → {}: {e}",
                        out_dir.display(),
                        staging.display()
                    ))
                })?;
                std::fs::rename(&staging, &dst).map_err(|e| {
                    let _ = std::fs::remove_dir_all(&staging);
                    crate::Error::Store(format!("rename store-interno → {}: {e}", dst.display()))
                })?;
                std::fs::remove_dir_all(out_dir).map_err(|e| {
                    crate::Error::Store(format!("limpiar origen {}: {e}", out_dir.display()))
                })?;
            }
            Err(e) => {
                return Err(crate::Error::Store(format!(
                    "rename {} → {}: {e}",
                    out_dir.display(),
                    dst.display()
                )))
            }
        }
        make_tree_read_only(&dst)?;
        Ok(dst)
    }
}

/// EXDEV en Linux. Evita una dependencia de `libc` por una sola constante.
const EXDEV: i32 = 18;

/// Copia recursiva de `src` a `dst` preservando symlinks (target literal, sin resolver) y
/// los modos de archivos y directorios. Usada por el fallback cross-device de `seal`.
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
    let meta = std::fs::symlink_metadata(src)?;
    let ft = meta.file_type();
    if ft.is_symlink() {
        let target = std::fs::read_link(src)?;
        std::os::unix::fs::symlink(target, dst)?;
    } else if ft.is_dir() {
        std::fs::create_dir_all(dst)?;
        for entry in std::fs::read_dir(src)? {
            let entry = entry?;
            copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
        }
        // Modos del directorio DESPUÉS de poblarlo (un dir 0o555 no admitiría escrituras).
        std::fs::set_permissions(dst, meta.permissions())?;
    } else {
        // Archivo regular: `std::fs::copy` lleva contenido + bits de permiso.
        std::fs::copy(src, dst)?;
    }
    Ok(())
}

/// Quita los bits de escritura de cada archivo regular del árbol. Los directorios mantienen
/// sus permisos originales (típicamente 0o755): eso es suficiente para garantizar
/// "contenido inmutable" sin estorbar al GC o a operaciones administrativas como `rm -rf`.
/// La inmutabilidad estricta del store entero (impedir crear archivos *nuevos* dentro de un
/// dir sellado) se aplicará vía mount read-only en el track de distro propia.
fn make_tree_read_only(root: &Path) -> crate::Result<()> {
    use std::os::unix::fs::PermissionsExt;

    fn walk(path: &Path) -> std::io::Result<()> {
        let meta = std::fs::symlink_metadata(path)?;
        let ft = meta.file_type();
        if ft.is_symlink() {
            return Ok(());
        }
        if ft.is_dir() {
            for entry in std::fs::read_dir(path)? {
                walk(&entry?.path())?;
            }
        } else if ft.is_file() {
            let mut perms = meta.permissions();
            perms.set_mode(perms.mode() & !0o222);
            std::fs::set_permissions(path, perms)?;
        }
        Ok(())
    }

    walk(root).map_err(|e| crate::Error::Store(format!("chmod r/o en {}: {e}", root.display())))
}

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

    fn hash() -> ArtifactHash {
        ArtifactHash::from_hex("deadbeef")
    }

    #[test]
    fn seal_moves_tree_and_marks_readonly() {
        let store_dir = tempfile::tempdir().unwrap();
        let store = Store::open(store_dir.path()).unwrap();

        let out = tempfile::tempdir_in(store_dir.path()).unwrap(); // mismo FS → rename atómico
        std::fs::write(out.path().join("hola.bin"), b"x").unwrap();
        std::fs::create_dir(out.path().join("sub")).unwrap();
        std::fs::write(out.path().join("sub/anidado.bin"), b"y").unwrap();

        // tempdir_in nos devuelve un wrapper que borra el dir al hacer Drop. Nosotros vamos
        // a moverlo con rename; suprimimos el Drop quedándonos con el PathBuf.
        let out_path = out.keep();
        let dst = store.seal(&out_path, &hash(), "demo").unwrap();
        assert!(dst.ends_with("deadbeef-demo"));
        assert!(dst.is_dir());
        assert!(dst.join("hola.bin").is_file());

        // Read-only: intentar escribir debe fallar.
        let write_err = std::fs::write(dst.join("hola.bin"), b"nuevo").unwrap_err();
        assert_eq!(write_err.kind(), std::io::ErrorKind::PermissionDenied);

        // Y has() lo ve.
        assert!(store.has(&hash(), "demo"));
    }

    #[test]
    fn copy_tree_preserva_symlinks_y_modos() {
        use std::os::unix::fs::PermissionsExt;
        let tmp = tempfile::tempdir().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(src.join("sub")).unwrap();
        std::fs::write(src.join("sub/exe"), b"#!/bin/sh\n").unwrap();
        std::fs::set_permissions(src.join("sub/exe"), std::fs::Permissions::from_mode(0o755))
            .unwrap();
        std::os::unix::fs::symlink("sub/exe", src.join("enlace")).unwrap();

        let dst = tmp.path().join("dst");
        copy_tree(&src, &dst).unwrap();

        // El symlink se replica con su target literal (no se resuelve).
        let link = std::fs::symlink_metadata(dst.join("enlace")).unwrap();
        assert!(link.file_type().is_symlink());
        assert_eq!(std::fs::read_link(dst.join("enlace")).unwrap().to_str(), Some("sub/exe"));
        // El modo 0o755 del archivo sobrevive.
        let exe = std::fs::metadata(dst.join("sub/exe")).unwrap();
        assert_eq!(exe.permissions().mode() & 0o777, 0o755);
        assert_eq!(std::fs::read(dst.join("sub/exe")).unwrap(), b"#!/bin/sh\n");
    }

    #[test]
    fn find_by_hash_resolves_by_prefix() {
        let store_dir = tempfile::tempdir().unwrap();
        let store = Store::open(store_dir.path()).unwrap();
        std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
        std::fs::create_dir(store.root().join("bbb222-bar")).unwrap();
        let p = store.find_by_hash("aaa1").unwrap();
        assert!(p.ends_with("aaa111-foo"));
        let p = store.find_by_hash("b3:bbb").unwrap();
        assert!(p.ends_with("bbb222-bar"));
        let err = store.find_by_hash("nope").unwrap_err().to_string();
        assert!(err.contains("ningún artefacto"), "{err}");
    }

    #[test]
    fn find_by_hash_ambiguous_lists_candidates() {
        let store_dir = tempfile::tempdir().unwrap();
        let store = Store::open(store_dir.path()).unwrap();
        std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
        std::fs::create_dir(store.root().join("aaa222-bar")).unwrap();
        let err = store.find_by_hash("aaa").unwrap_err().to_string();
        assert!(err.contains("ambiguo"), "{err}");
        assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}");
    }

    #[test]
    fn recipe_for_dir_returns_none_without_sidecar() {
        let store_dir = tempfile::tempdir().unwrap();
        let store = Store::open(store_dir.path()).unwrap();
        let art = store_dir.path().join("aaa-foo");
        std::fs::create_dir_all(&art).unwrap();
        let r = store.recipe_for_dir(&art).unwrap();
        assert!(r.is_none());
    }

    #[test]
    fn recipe_for_dir_reads_sidecar() {
        let store_dir = tempfile::tempdir().unwrap();
        let store = Store::open(store_dir.path()).unwrap();
        let art = store_dir.path().join("aaa-foo");
        std::fs::create_dir_all(art.join(".hammer")).unwrap();
        std::fs::write(
            art.join(RECIPE_SIDECAR_REL),
            r#"
name = "grep"
version = "3.12"
[source]
repo   = "git://example/grep.git"
commit = "deadbeef"
[build]
compiler = "zig-cc"
target   = "x86_64-linux-musl"
link     = "static"
flags    = []
"#,
        )
        .unwrap();
        let r = store.recipe_for_dir(&art).unwrap().expect("Some");
        assert_eq!(r.name, "grep");
        assert_eq!(r.version, "3.12");
    }

    #[test]
    fn recipe_for_hash_resolves_by_prefix_then_reads() {
        let store_dir = tempfile::tempdir().unwrap();
        let store = Store::open(store_dir.path()).unwrap();
        let art = store.root().join("cafe1234-demo");
        std::fs::create_dir_all(art.join(".hammer")).unwrap();
        std::fs::write(
            art.join(RECIPE_SIDECAR_REL),
            r#"
name = "demo"
version = "0.1"
[source]
tarball = "https://x/y.tar.gz"
sha256  = "abc123"
[build]
"#,
        )
        .unwrap();
        let r = store.recipe_for_hash("cafe").unwrap().expect("Some");
        assert_eq!(r.name, "demo");
        // Hash inexistente: None (no error).
        assert!(store.recipe_for_hash("ffffff").unwrap().is_none());
    }

    #[test]
    fn seal_is_idempotent_when_dst_exists() {
        let store_dir = tempfile::tempdir().unwrap();
        let store = Store::open(store_dir.path()).unwrap();

        // Pre-sellar una vez.
        let first = tempfile::tempdir_in(store_dir.path()).unwrap();
        std::fs::write(first.path().join("a"), b"1").unwrap();
        let first_path = first.keep();
        let p1 = store.seal(&first_path, &hash(), "demo").unwrap();

        // Segundo intento: el out_dir no debe consumirse (devuelve la ruta cacheada).
        let second = tempfile::tempdir_in(store_dir.path()).unwrap();
        std::fs::write(second.path().join("b"), b"2").unwrap();
        let p2 = store.seal(second.path(), &hash(), "demo").unwrap();

        assert_eq!(p1, p2);
        assert!(second.path().is_dir()); // intacto
    }
}
