//! El overlay de experimentación. Ver `docs/04-overlay.md`.
//!
//! Layout en disco bajo `state_root` (default `/var/lib/hammer/overlays/`):
//!
//! ```text
//! <state_root>/
//!   <id>/
//!     state.json                 manifiesto: targets, hora de creación, etc.
//!     mounts/
//!       <target_slug>/
//!         upper/                 escrituras
//!         work/                  workdir requerido por overlayfs (mismo FS que upper)
//! ```
//!
//! Diseño: la librería no asume privilegios — emite los comandos `mount`/`umount` y deja
//! que el proceso que la invoca tenga (o no) CAP_SYS_ADMIN. Esto permite:
//! - El CLI `hammer try` lo invoca con sudo (o setcap) en una máquina real.
//! - `hammerd` lo invoca desde su contexto privilegiado (Fase 5).
//! - Los tests lo ejercitan dentro de `bwrap --unshare-user-try --unshare-pid` que da
//!   CAP_SYS_ADMIN en un user-ns aislado.

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

use serde::{Deserialize, Serialize};

pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/overlays";

/// Targets por defecto cuando `try` se invoca sin argumentos. Cubren el FHS clásico de
/// userland (binarios + libs + config); el store y `/home` quedan fuera a propósito.
pub fn default_targets() -> Vec<PathBuf> {
    ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/lib", "/lib", "/etc"]
        .into_iter()
        .map(PathBuf::from)
        .collect()
}

/// Identificador de un overlay activo. Por ahora es una cadena corta legible
/// (timestamp + nonce) generada por el caller; mantenemos el tipo opaco para poder
/// cambiar el formato sin tocar consumidores.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayId(pub String);

impl OverlayId {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for OverlayId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// Una mount concreta dentro de un overlay: un único target (p. ej. `/usr/bin`) con sus
/// `upper`/`work` dedicados.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayMount {
    /// Punto de montaje real: el path del FHS. También sirve como `lowerdir`.
    pub target: PathBuf,
    /// Donde caen las escrituras. Vive bajo `<state>/<id>/mounts/<slug>/upper`.
    pub upper: PathBuf,
    /// Workdir que overlayfs necesita (mismo FS que `upper`, vacío al montar).
    pub work: PathBuf,
}

/// Manifiesto persistente del overlay — se serializa a `state.json` para que `status`
/// pueda enumerarlos sin depender de procesos anteriores.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayState {
    pub id: OverlayId,
    pub mounts: Vec<OverlayMount>,
    /// Segundos desde UNIX epoch al `try`. No tomamos `chrono` por ahora.
    pub created_at: u64,
}

/// Reporte de un `commit`. Lista cada archivo que se promocionó del upper al lower.
#[derive(Debug, Default)]
pub struct CommitReport {
    pub copied: Vec<PathBuf>,
    pub removed: Vec<PathBuf>,
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("json: {0}")]
    Json(#[from] serde_json::Error),
    #[error("overlay: {0}")]
    Overlay(String),
    #[error("mount falló: {0}")]
    Mount(String),
    #[error("umount falló: {0}")]
    Umount(String),
    #[error(
        "overlay {id} tiene overlays más jóvenes apilados encima ({}); \
         commitea/descarta primero los de arriba (LIFO)",
        .shadowed_by.iter().map(|o| o.as_str()).collect::<Vec<_>>().join(", ")
    )]
    Shadowed { id: OverlayId, shadowed_by: Vec<OverlayId> },
}

pub type Result<T> = std::result::Result<T, Error>;

/// Convierte un target absoluto en un slug usable como nombre de directorio.
/// `/usr/bin` → `usr-bin`, `/etc` → `etc`. Por diseño NO uses sufijo: queremos colisión
/// si alguien intenta el mismo target dos veces dentro del mismo overlay.
fn slug_for(target: &Path) -> String {
    let s: String = target
        .components()
        .filter_map(|c| match c {
            std::path::Component::Normal(n) => Some(n.to_string_lossy().to_string()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("-");
    if s.is_empty() { "root".to_string() } else { s }
}

/// Genera un `OverlayId` legible: `<unix_ts>-<pid>-<seq>`. El `seq` es un contador atómico
/// por proceso (zero-padded) que garantiza unicidad aunque dos `try` caigan en el mismo
/// segundo desde el mismo PID — el caso de la anidación real, donde un orquestador apila
/// varios overlays en ráfaga. El padding mantiene el orden lexicográfico del id alineado con
/// el orden de creación, que es justo lo que [`stack_key`] usa como desempate del apilamiento.
/// (La unicidad estricta entre procesos concurrentes no la cubre el seq; ver `stack_key`.)
fn fresh_id() -> OverlayId {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let ts = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
    OverlayId(format!("{ts}-{}-{seq:06}", std::process::id()))
}

/// Monta un overlay nuevo sobre `targets` y persiste su manifiesto.
///
/// `state_root` es donde viven los `upper`/`work` y el `state.json`. Cada overlay queda
/// bajo `<state_root>/<id>/`. Si `targets` está vacío usa [`default_targets`].
pub fn try_overlay(targets: &[PathBuf], state_root: &Path) -> Result<OverlayId> {
    let targets: Vec<PathBuf> = if targets.is_empty() {
        default_targets()
    } else {
        targets.to_vec()
    };

    let id = fresh_id();
    let base = state_root.join(id.as_str());
    let mounts_root = base.join("mounts");
    std::fs::create_dir_all(&mounts_root)?;

    let mut mounts: Vec<OverlayMount> = Vec::with_capacity(targets.len());
    for target in &targets {
        if !target.is_dir() {
            return Err(Error::Overlay(format!(
                "target {} no existe o no es directorio",
                target.display()
            )));
        }
        let slot = mounts_root.join(slug_for(target));
        let upper = slot.join("upper");
        let work = slot.join("work");
        std::fs::create_dir_all(&upper)?;
        std::fs::create_dir_all(&work)?;
        do_mount(target, &upper, &work)?;
        mounts.push(OverlayMount {
            target: target.clone(),
            upper,
            work,
        });
    }

    let state = OverlayState {
        id: id.clone(),
        mounts,
        created_at: std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0),
    };
    let state_file = base.join("state.json");
    std::fs::write(&state_file, serde_json::to_vec_pretty(&state)?)?;
    Ok(id)
}

/// Lista todos los overlays con manifiesto bajo `state_root`. No verifica que sus mounts
/// estén vivos; eso lo añade un consumidor que parsea `/proc/mounts` si lo necesita.
pub fn status(state_root: &Path) -> Result<Vec<OverlayState>> {
    if !state_root.is_dir() {
        return Ok(Vec::new());
    }
    let mut out = Vec::new();
    for entry in std::fs::read_dir(state_root)? {
        let entry = entry?;
        let state_file = entry.path().join("state.json");
        if !state_file.is_file() {
            continue;
        }
        let bytes = std::fs::read(&state_file)?;
        let state: OverlayState = serde_json::from_slice(&bytes)?;
        out.push(state);
    }
    out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
    Ok(out)
}

/// Desmonta todos los targets de un overlay y borra su árbol de estado. Idempotente sobre
/// mounts ya desmontados (errores de umount "not mounted" se ignoran).
pub fn discard(id: &OverlayId, state_root: &Path) -> Result<()> {
    let base = state_root.join(id.as_str());
    let state_file = base.join("state.json");
    if !state_file.is_file() {
        return Err(Error::Overlay(format!(
            "no encuentro overlay {} bajo {}",
            id,
            state_root.display()
        )));
    }
    let bytes = std::fs::read(&state_file)?;
    let state: OverlayState = serde_json::from_slice(&bytes)?;
    // LIFO: no desmontes una capa que aún tiene overlays más jóvenes apilados encima.
    let blockers = blocking_overlays(state_root, id)?;
    if !blockers.is_empty() {
        return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers });
    }
    // Desmonta en orden inverso al de montaje dentro de este overlay.
    for m in state.mounts.iter().rev() {
        do_umount_lenient(&m.target)?;
    }
    std::fs::remove_dir_all(&base)?;
    Ok(())
}

/// Fusiona los `upper`s al FHS y desmonta. Sin diario.
///
/// Para registrar cada promoción en el diario (SDD 04 §2 + SDD 05), usa
/// [`commit_with_journal`].
pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
    commit_inner(id, state_root, None)
}

/// Como [`commit`] pero registra cada archivo promocionado/eliminado como
/// `MutationEvent` con `source = HammerCommit { overlay }`. El SDD insiste en que el ruido
/// del experimento no entra al diario hasta el commit — esto es el momento exacto.
pub fn commit_with_journal(
    id: &OverlayId,
    state_root: &Path,
    journal: &hammer_journal::Journal,
) -> Result<CommitReport> {
    commit_inner(id, state_root, Some(journal))
}

fn commit_inner(
    id: &OverlayId,
    state_root: &Path,
    journal: Option<&hammer_journal::Journal>,
) -> Result<CommitReport> {
    let base = state_root.join(id.as_str());
    let state_file = base.join("state.json");
    if !state_file.is_file() {
        return Err(Error::Overlay(format!(
            "no encuentro overlay {} bajo {}",
            id,
            state_root.display()
        )));
    }
    let bytes = std::fs::read(&state_file)?;
    let state: OverlayState = serde_json::from_slice(&bytes)?;
    // LIFO: commitear una capa con overlays más jóvenes encima promocionaría el upper a un
    // target todavía sombreado por otro mount. Rechazamos hasta que se resuelvan los de arriba.
    let blockers = blocking_overlays(state_root, id)?;
    if !blockers.is_empty() {
        return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers });
    }

    let mut report = CommitReport::default();

    // Desmonta primero — necesitamos el upper "crudo".
    for m in state.mounts.iter().rev() {
        do_umount_lenient(&m.target)?;
    }

    for m in &state.mounts {
        promote_upper_to_lower(&m.upper, &m.target, &mut report)?;
    }

    if let Some(j) = journal {
        record_commit_to_journal(j, id, &report);
    }
    std::fs::remove_dir_all(&base)?;
    Ok(report)
}

fn record_commit_to_journal(
    j: &hammer_journal::Journal,
    id: &OverlayId,
    report: &CommitReport,
) {
    use hammer_journal::{Actor, MutationEvent, MutationOp, Source};
    let actor = Actor {
        source: Source::HammerCommit {
            overlay: id.as_str().to_string(),
            artifact: None,
        },
        pid: Some(std::process::id()),
        uid: Some(unsafe { libc::getuid() }),
    };
    let ts = hammer_journal::now_rfc3339();
    for p in &report.copied {
        let ev = MutationEvent {
            ts: ts.clone(),
            op: MutationOp::Replace,
            path: p.clone(),
            by: actor.clone(),
            content_hash: None,
            note: None,
        };
        if let Err(e) = j.record(&ev) {
            tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar commit");
        }
    }
    for p in &report.removed {
        let ev = MutationEvent {
            ts: ts.clone(),
            op: MutationOp::Delete,
            path: p.clone(),
            by: actor.clone(),
            content_hash: None,
            note: None,
        };
        if let Err(e) = j.record(&ev) {
            tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar remove");
        }
    }
}

/// Clave de orden de apilamiento total y determinista: primero por instante de creación,
/// desempatado por el `id` (que lleva ts+pid). Un overlay con clave mayor es "más joven" y,
/// si comparte targets, queda apilado *encima*.
fn stack_key(s: &OverlayState) -> (u64, &str) {
    (s.created_at, s.id.as_str())
}

/// `true` si dos targets se solapan en el árbol del FHS: iguales, o uno ancestro del otro
/// (p. ej. un overlay sobre `/usr` y otro sobre `/usr/bin` comparten el mountpoint efectivo).
fn targets_overlap(a: &Path, b: &Path) -> bool {
    a.starts_with(b) || b.starts_with(a)
}

/// Overlays más jóvenes que `id` que solapan alguno de sus targets — es decir, los que
/// están apilados *encima* en el FHS. Mientras existan, `commit`/`discard` de `id` violaría
/// el orden LIFO (desmontaría la capa equivocada del target compartido), así que los
/// rechazamos. Es inspección pura del estado, sin tocar mounts.
fn blocking_overlays(state_root: &Path, id: &OverlayId) -> Result<Vec<OverlayId>> {
    let all = status(state_root)?;
    let Some(me) = all.iter().find(|s| &s.id == id) else {
        return Ok(Vec::new());
    };
    let me_key = stack_key(me);
    let mut blockers: Vec<OverlayId> = all
        .iter()
        .filter(|other| &other.id != id && stack_key(other) > me_key)
        .filter(|other| {
            other.mounts.iter().any(|om| {
                me.mounts.iter().any(|mm| targets_overlap(&om.target, &mm.target))
            })
        })
        .map(|other| other.id.clone())
        .collect();
    blockers.sort_by(|a, b| a.as_str().cmp(b.as_str()));
    Ok(blockers)
}

fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> {
    // overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma,
    // overlayfs sólo soporta escaparla a través de opciones específicas — rechazamos en
    // lugar de adivinar (en el FHS clásico no aparece nunca).
    for p in [lower, upper, work] {
        let s = p.to_string_lossy();
        if s.contains(',') || s.contains(':') {
            return Err(Error::Mount(format!(
                "path con ',' o ':' no soportado por overlayfs: {}",
                p.display()
            )));
        }
    }
    let opts = format!(
        "lowerdir={},upperdir={},workdir={}",
        lower.display(),
        upper.display(),
        work.display()
    );
    let st = Command::new("mount")
        .args(["-t", "overlay", "overlay", "-o"])
        .arg(&opts)
        .arg(lower)
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .map_err(|e| Error::Mount(format!("spawn mount: {e}")))?;
    if !st.success() {
        return Err(Error::Mount(format!(
            "mount overlay sobre {} falló (exit {:?})",
            lower.display(),
            st.code()
        )));
    }
    Ok(())
}

fn do_umount_lenient(target: &Path) -> Result<()> {
    // `umount` retorna 32 para "not mounted"; el flag --lazy fuerza el desmontaje incluso
    // si hay procesos con file descriptors abiertos sobre el merged. Para Fase 2 nos vale.
    let out = Command::new("umount")
        .arg(target)
        .output()
        .map_err(|e| Error::Umount(format!("spawn umount: {e}")))?;
    if out.status.success() {
        return Ok(());
    }
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    if stderr.contains("not mounted") || stderr.contains("not found") {
        return Ok(());
    }
    Err(Error::Umount(format!(
        "umount {} falló (exit {:?}): {}",
        target.display(),
        out.status.code(),
        stderr.trim()
    )))
}

/// Camina el `upper` y replica cada entrada al `lower`, anotando en el reporte. Maneja:
///
/// - Archivos regulares → `cp -a` (preserva metadata + hardlinks dentro del upper).
/// - Directorios → `mkdir -p` con los permisos del upper.
/// - Symlinks → recreación literal.
/// - "Whiteouts" overlayfs (char dev 0/0) → `rm -rf` el correspondiente en el lower.
fn promote_upper_to_lower(
    upper: &Path,
    lower: &Path,
    report: &mut CommitReport,
) -> Result<()> {
    use std::os::unix::fs::FileTypeExt;
    walk(upper, &mut |path: &Path| -> Result<()> {
        let rel = path.strip_prefix(upper).expect("rel ⊂ upper");
        if rel.as_os_str().is_empty() {
            return Ok(());
        }
        let dst = lower.join(rel);
        let meta = std::fs::symlink_metadata(path)?;
        let ft = meta.file_type();
        if ft.is_char_device() && rdev_is_zero(&meta) {
            // Whiteout: el upper marca "borra esto del lower".
            if dst.exists() || std::fs::symlink_metadata(&dst).is_ok() {
                if dst.is_dir() && !dst.is_symlink() {
                    std::fs::remove_dir_all(&dst)?;
                } else {
                    std::fs::remove_file(&dst)?;
                }
                report.removed.push(dst);
            }
        } else if ft.is_dir() {
            std::fs::create_dir_all(&dst)?;
        } else if ft.is_symlink() {
            let tgt = std::fs::read_link(path)?;
            let _ = std::fs::remove_file(&dst);
            std::os::unix::fs::symlink(&tgt, &dst)?;
            report.copied.push(dst);
        } else if ft.is_file() {
            if let Some(parent) = dst.parent() {
                std::fs::create_dir_all(parent)?;
            }
            // `cp -a` para preservar perms/timestamps/ownership; copy_file_range para
            // archivos grandes se queda en futuro si lo necesitamos.
            let st = Command::new("cp")
                .arg("-a")
                .arg("--remove-destination")
                .arg(path)
                .arg(&dst)
                .status()?;
            if !st.success() {
                return Err(Error::Overlay(format!(
                    "cp -a {} → {} falló (exit {:?})",
                    path.display(),
                    dst.display(),
                    st.code()
                )));
            }
            report.copied.push(dst);
        }
        Ok(())
    })
}

fn rdev_is_zero(meta: &std::fs::Metadata) -> bool {
    use std::os::unix::fs::MetadataExt;
    meta.rdev() == 0
}

fn walk(root: &Path, f: &mut dyn FnMut(&Path) -> Result<()>) -> Result<()> {
    f(root)?;
    let rd = match std::fs::read_dir(root) {
        Ok(r) => r,
        Err(_) => return Ok(()),
    };
    for entry in rd {
        let entry = entry?;
        let p = entry.path();
        let ft = entry.file_type()?;
        if ft.is_dir() && !ft.is_symlink() {
            walk(&p, f)?;
        } else {
            f(&p)?;
        }
    }
    Ok(())
}

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

    #[test]
    fn slug_basic() {
        assert_eq!(slug_for(Path::new("/usr/bin")), "usr-bin");
        assert_eq!(slug_for(Path::new("/etc")), "etc");
        assert_eq!(slug_for(Path::new("/")), "root");
    }

    #[test]
    fn default_targets_are_fhs_userland() {
        let t = default_targets();
        assert!(t.iter().any(|p| p == Path::new("/usr/bin")));
        assert!(t.iter().any(|p| p == Path::new("/etc")));
        assert!(!t.iter().any(|p| p == Path::new("/")));
    }

    #[test]
    fn status_empty_dir_returns_empty() {
        let d = tempfile::tempdir().unwrap();
        let v = status(d.path()).unwrap();
        assert!(v.is_empty());
    }

    #[test]
    fn status_missing_dir_returns_empty() {
        let d = tempfile::tempdir().unwrap();
        let v = status(&d.path().join("no-existe")).unwrap();
        assert!(v.is_empty());
    }

    #[test]
    fn status_reads_planted_manifest() {
        let d = tempfile::tempdir().unwrap();
        let id = OverlayId("9999-1".into());
        let base = d.path().join(id.as_str());
        std::fs::create_dir_all(&base).unwrap();
        let st = OverlayState {
            id: id.clone(),
            mounts: vec![],
            created_at: 1234,
        };
        std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
        let v = status(d.path()).unwrap();
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].id, id);
        assert_eq!(v[0].created_at, 1234);
    }

    #[test]
    fn discard_on_unknown_id_errors_clearly() {
        let d = tempfile::tempdir().unwrap();
        let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string();
        assert!(err.contains("no encuentro overlay"), "msg = {err}");
    }

    /// Planta un manifiesto de overlay (sin mounts reales) para ejercitar la lógica de
    /// apilamiento sin overlayfs. `targets` son los paths que el overlay cubriría.
    fn plant(state_root: &Path, id: &str, created_at: u64, targets: &[&str]) {
        let base = state_root.join(id);
        std::fs::create_dir_all(&base).unwrap();
        let mounts = targets
            .iter()
            .map(|t| OverlayMount {
                target: PathBuf::from(t),
                upper: base.join("upper"),
                work: base.join("work"),
            })
            .collect();
        let st = OverlayState { id: OverlayId(id.into()), mounts, created_at };
        std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
    }

    #[test]
    fn no_blockers_for_disjoint_targets() {
        let d = tempfile::tempdir().unwrap();
        plant(d.path(), "100-1", 100, &["/usr/bin"]);
        plant(d.path(), "200-1", 200, &["/etc"]); // más joven pero target disjunto
        assert!(blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap().is_empty());
    }

    #[test]
    fn younger_overlapping_overlay_blocks() {
        let d = tempfile::tempdir().unwrap();
        plant(d.path(), "100-1", 100, &["/usr/bin"]);
        plant(d.path(), "200-1", 200, &["/usr/bin"]); // apilado encima del mismo target
        let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
        assert_eq!(blockers, vec![OverlayId("200-1".into())]);
        // El de arriba no tiene a nadie por encima: se puede resolver primero.
        assert!(blocking_overlays(d.path(), &OverlayId("200-1".into())).unwrap().is_empty());
    }

    #[test]
    fn ancestor_target_counts_as_overlap() {
        let d = tempfile::tempdir().unwrap();
        plant(d.path(), "100-1", 100, &["/usr"]);
        plant(d.path(), "200-1", 200, &["/usr/bin"]); // /usr/bin queda sombreado por /usr
        let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
        assert_eq!(blockers, vec![OverlayId("200-1".into())]);
    }

    #[test]
    fn same_second_ties_broken_by_id() {
        let d = tempfile::tempdir().unwrap();
        // Mismo created_at: el desempate por id define quién está encima.
        plant(d.path(), "100-1", 100, &["/etc"]);
        plant(d.path(), "100-2", 100, &["/etc"]);
        assert_eq!(
            blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap(),
            vec![OverlayId("100-2".into())]
        );
        assert!(blocking_overlays(d.path(), &OverlayId("100-2".into())).unwrap().is_empty());
    }

    #[test]
    fn commit_refuses_when_shadowed() {
        let d = tempfile::tempdir().unwrap();
        plant(d.path(), "100-1", 100, &["/usr/bin"]);
        plant(d.path(), "200-1", 200, &["/usr/bin"]);
        let err = commit(&OverlayId("100-1".into()), d.path()).unwrap_err();
        match err {
            Error::Shadowed { id, shadowed_by } => {
                assert_eq!(id.as_str(), "100-1");
                assert_eq!(shadowed_by, vec![OverlayId("200-1".into())]);
            }
            other => panic!("esperaba Shadowed, vino {other:?}"),
        }
    }

    #[test]
    fn discard_refuses_when_shadowed() {
        let d = tempfile::tempdir().unwrap();
        plant(d.path(), "100-1", 100, &["/etc"]);
        plant(d.path(), "200-1", 200, &["/etc"]);
        let err = discard(&OverlayId("100-1".into()), d.path()).unwrap_err();
        assert!(matches!(err, Error::Shadowed { .. }), "vino {err:?}");
    }

    #[test]
    fn journal_hook_records_copied_and_removed() {
        // No tocamos mount real: ejercitamos directamente la función privada que el commit
        // delega para anotar al diario. Eso valida el contrato sin necesidad de overlayfs.
        let d = tempfile::tempdir().unwrap();
        let journal = hammer_journal::Journal::open(d.path().join("journal")).unwrap();
        let report = CommitReport {
            copied: vec![PathBuf::from("/etc/hosts"), PathBuf::from("/bin/grep")],
            removed: vec![PathBuf::from("/bin/obsoleto")],
        };
        super::record_commit_to_journal(&journal, &OverlayId("1234-1".into()), &report);
        let events = journal.read_all().unwrap();
        assert_eq!(events.len(), 3, "2 copied + 1 removed = 3 eventos");
        // Los promocionados van como Replace, los removidos como Delete.
        let ops_paths: Vec<_> = events
            .iter()
            .map(|e| (e.op, e.path.display().to_string()))
            .collect();
        assert!(ops_paths.contains(&(hammer_journal::MutationOp::Replace, "/etc/hosts".into())));
        assert!(ops_paths.contains(&(hammer_journal::MutationOp::Replace, "/bin/grep".into())));
        assert!(ops_paths.contains(&(hammer_journal::MutationOp::Delete, "/bin/obsoleto".into())));
        // Todos comparten el mismo source con overlay id.
        for e in &events {
            match &e.by.source {
                hammer_journal::Source::HammerCommit { overlay, .. } => {
                    assert_eq!(overlay, "1234-1");
                }
                other => panic!("source inesperado: {other:?}"),
            }
        }
    }
}
