//! `why-differs` — el diffoscope propio de hammer ([SDD 17 §1.2](../../docs/17-cierres-frontera.md)).
//!
//! Cuando un artefacto **no reproduce**, el store sólo sabe decir "el hash no coincide". Eso deja el
//! trabajo entero al humano: desempacar los dos árboles, `cmp` a mano, adivinar. Este módulo responde
//! la pregunta siguiente — **POR QUÉ** difieren — y la responde en términos de la CAUSA, no del byte:
//! un MTIME embebido en un gzip, la cabecera `ar` de un `.a`, la sección `.comment` de un ELF (=otro
//! compilador), una ruta del árbol de build que se coló en el binario.
//!
//! Es un multiplicador doble (por eso es uno de los dos elegidos del SDD 17 §5): hace escalar el
//! barrido de la granja —hoy debuggear una no-reproducción es artesanal— y produce evidencia
//! **legible por máquina** (`--json`) para el bucle agéntico.
//!
//! **Descenso, no comparación total** (el cruce con `format`/`reconcile`): dos subárboles idénticos
//! colapsan al mismo hash, así que el diff sólo desciende donde los hashes difieren. Comparar dos
//! artefactos de miles de ficheros cuesta un hash por fichero y un diagnóstico sólo por los que
//! divergen.
//!
//! **Sin dependencias externas**: los parsers (gzip/ar/ELF) son mínimos y viven acá, en el mismo
//! estilo que `query::parse_elf_info`. Un diffoscope de verdad se apoya en medio mundo de binarios
//! ajenos; eso es exactamente lo que hammer no puede permitirse (ADR 0004: el catálogo se construye,
//! no se importa).

use serde::Serialize;
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

/// Cuánto de un fichero divergente se lee para diagnosticar. Un artefacto puede traer un firmware de
/// cientos de MB; el diagnóstico no mejora por leerlo entero, y sí empeora la latencia.
const MAX_DIAGNOSTICO: u64 = 256 * 1024 * 1024;

/// Bytes de contexto que se muestran alrededor del primer byte divergente.
const VENTANA: usize = 24;

/// Una entrada del árbol, reducida a lo que decide si dos artefactos son el mismo.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entrada {
    Directorio,
    /// El destino del symlink (NO se sigue: un symlink que apunta a otro lado ES una divergencia).
    Symlink(String),
    Fichero {
        hash: String,
        tamano: u64,
        /// Sólo el bit de ejecución importa: el resto del modo lo normaliza el sellado.
        ejecutable: bool,
    },
}

impl Entrada {
    fn tipo(&self) -> &'static str {
        match self {
            Entrada::Directorio => "directorio",
            Entrada::Symlink(_) => "symlink",
            Entrada::Fichero { .. } => "fichero",
        }
    }
}

/// La causa de una divergencia — el veredicto que este módulo existe para dar.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "causa")]
pub enum Causa {
    /// Presente sólo en el artefacto A.
    SoloEnA,
    /// Presente sólo en el artefacto B.
    SoloEnB,
    /// La misma ruta es fichero en uno y symlink/directorio en el otro.
    TipoDistinto { a: String, b: String },
    /// Symlinks que apuntan a destinos distintos.
    SymlinkDistinto { a: String, b: String },
    /// Mismo contenido, distinto bit de ejecución.
    ModoDistinto { a_ejecutable: bool, b_ejecutable: bool },
    /// gzip con el MTIME embebido en la cabecera (bytes 4..8). **La causa clásica** de
    /// no-reproducibilidad en tarballs y páginas de man comprimidas: se mata con `gzip -n`.
    GzipMtime { a: u32, b: u32 },
    /// Cabecera de un miembro de archivo `ar` (los `.a`): mtime/uid/gid. Se mata con `ar D` o
    /// `ARFLAGS=Dcr` (modo determinista, que pone esos campos a 0).
    ArCabecera { miembro: String, campo: String, a: String, b: String },
    /// Secciones ELF que difieren. `.comment` ⇒ otro compilador; `.note.gnu.build-id` sólo ⇒ el
    /// build-id deriva del resto y no es causa raíz; `.debug_*` ⇒ típicamente rutas del árbol de
    /// build sin `-fdebug-prefix-map`.
    ElfSecciones {
        secciones: Vec<String>,
        interpretacion: String,
        /// Cadenas concretas que están en un ELF y no en el otro — la evidencia detrás de la
        /// interpretación (ver `evidencia_de_cadenas`).
        evidencia: Vec<String>,
    },
    /// Fichero de texto: la primera línea que difiere.
    TextoLinea { linea: usize, a: String, b: String },
    /// Un fragmento divergente contiene una ruta del árbol de build (`/build`, `work/sources`,
    /// `/tmp/…`): el build path se filtró al artefacto.
    RutaDeBuild { a: String, b: String },
    /// Nada más específico: primer offset divergente y su ventana en hex.
    Bytes { offset: u64, a_hex: String, b_hex: String },
    /// Mismo prefijo, distinto tamaño (uno es truncamiento/extensión del otro).
    Tamano { a: u64, b: u64 },
}

impl Causa {
    /// Etiqueta corta y estable — es la que el bucle agéntico agrupa y cuenta.
    pub fn etiqueta(&self) -> &'static str {
        match self {
            Causa::SoloEnA => "solo-en-a",
            Causa::SoloEnB => "solo-en-b",
            Causa::TipoDistinto { .. } => "tipo-distinto",
            Causa::SymlinkDistinto { .. } => "symlink-distinto",
            Causa::ModoDistinto { .. } => "modo-distinto",
            Causa::GzipMtime { .. } => "gzip-mtime",
            Causa::ArCabecera { .. } => "ar-cabecera",
            Causa::ElfSecciones { .. } => "elf-secciones",
            Causa::TextoLinea { .. } => "texto-linea",
            Causa::RutaDeBuild { .. } => "ruta-de-build",
            Causa::Bytes { .. } => "bytes",
            Causa::Tamano { .. } => "tamano",
        }
    }

    /// Si esta causa se arregla con una acción conocida, cuál. `None` = hay que investigar.
    pub fn remedio(&self) -> Option<&'static str> {
        match self {
            Causa::GzipMtime { .. } => Some("comprimir con `gzip -n` (no embeber MTIME)"),
            Causa::ArCabecera { .. } => Some("archivar en modo determinista (`ar D` / `ARFLAGS=Dcr`)"),
            Causa::RutaDeBuild { .. } => {
                Some("normalizar el build path (`-ffile-prefix-map`) o construir en ruta fija")
            }
            _ => None,
        }
    }
}

/// Una divergencia concreta, ya diagnosticada.
#[derive(Debug, Clone, Serialize)]
pub struct Divergencia {
    /// Ruta relativa a la raíz del artefacto.
    pub ruta: String,
    #[serde(flatten)]
    pub causa: Causa,
    /// Frase lista para imprimir.
    pub explicacion: String,
    /// Qué hacer, si se sabe.
    pub remedio: Option<String>,
}

/// El resultado completo de comparar dos artefactos.
#[derive(Debug, Clone, Serialize)]
pub struct Informe {
    pub a: String,
    pub b: String,
    /// Entradas con hash idéntico (las que el descenso NO tuvo que mirar por dentro).
    pub identicos: usize,
    pub divergencias: Vec<Divergencia>,
}

impl Informe {
    /// `true` si los dos árboles son idénticos entrada por entrada.
    pub fn reproduce(&self) -> bool {
        self.divergencias.is_empty()
    }

    /// Cuántas divergencias hay de cada causa — el resumen que el barrido de granja agrega.
    pub fn por_causa(&self) -> BTreeMap<&'static str, usize> {
        let mut m = BTreeMap::new();
        for d in &self.divergencias {
            *m.entry(d.causa.etiqueta()).or_insert(0) += 1;
        }
        m
    }
}

/// Compara dos árboles de artefacto y explica cada divergencia.
pub fn why_differs(a: &Path, b: &Path) -> crate::Result<Informe> {
    let arbol_a = walk(a)?;
    let arbol_b = walk(b)?;

    let mut divergencias = Vec::new();
    let mut identicos = 0usize;

    // Unión ordenada de rutas: BTreeMap ya las da ordenadas, así el informe es estable (y por tanto
    // diffeable entre corridas — un informe que cambia de orden no sirve como evidencia).
    let mut rutas: Vec<&String> = arbol_a.keys().chain(arbol_b.keys()).collect();
    rutas.sort();
    rutas.dedup();

    for ruta in rutas {
        match (arbol_a.get(ruta), arbol_b.get(ruta)) {
            (Some(ea), Some(eb)) if ea == eb => identicos += 1,
            (Some(ea), Some(eb)) => {
                let causa = comparar_entradas(ea, eb, &a.join(ruta), &b.join(ruta));
                divergencias.push(construir(ruta, causa));
            }
            (Some(_), None) => divergencias.push(construir(ruta, Causa::SoloEnA)),
            (None, Some(_)) => divergencias.push(construir(ruta, Causa::SoloEnB)),
            (None, None) => unreachable!("la ruta salió de la unión de ambos árboles"),
        }
    }

    Ok(Informe {
        a: a.display().to_string(),
        b: b.display().to_string(),
        identicos,
        divergencias,
    })
}

fn construir(ruta: &str, causa: Causa) -> Divergencia {
    Divergencia {
        ruta: ruta.to_string(),
        explicacion: explicar(&causa),
        remedio: causa.remedio().map(str::to_string),
        causa,
    }
}

fn comparar_entradas(ea: &Entrada, eb: &Entrada, pa: &Path, pb: &Path) -> Causa {
    match (ea, eb) {
        (Entrada::Symlink(sa), Entrada::Symlink(sb)) => Causa::SymlinkDistinto {
            a: sa.clone(),
            b: sb.clone(),
        },
        (
            Entrada::Fichero { hash: ha, ejecutable: xa, .. },
            Entrada::Fichero { hash: hb, ejecutable: xb, .. },
        ) if ha == hb && xa != xb => Causa::ModoDistinto {
            a_ejecutable: *xa,
            b_ejecutable: *xb,
        },
        (Entrada::Fichero { tamano: ta, .. }, Entrada::Fichero { tamano: tb, .. }) => {
            diagnosticar(pa, pb, *ta, *tb)
        }
        _ => Causa::TipoDistinto {
            a: ea.tipo().to_string(),
            b: eb.tipo().to_string(),
        },
    }
}

/// El corazón: dos ficheros con distinto contenido — ¿por qué?
///
/// El orden de los detectores importa: van de la causa **más específica y accionable** (gzip, ar) a
/// la más genérica (primer byte divergente). El primero que reconoce el formato gana.
fn diagnosticar(pa: &Path, pb: &Path, ta: u64, tb: u64) -> Causa {
    if ta > MAX_DIAGNOSTICO || tb > MAX_DIAGNOSTICO {
        return Causa::Tamano { a: ta, b: tb };
    }
    let (da, db) = match (std::fs::read(pa), std::fs::read(pb)) {
        (Ok(x), Ok(y)) => (x, y),
        _ => return Causa::Tamano { a: ta, b: tb },
    };

    if let Some(c) = detectar_gzip(&da, &db) {
        return c;
    }
    if let Some(c) = detectar_ar(&da, &db) {
        return c;
    }
    if let Some(c) = detectar_elf(&da, &db) {
        return c;
    }
    if let Some(c) = detectar_texto(&da, &db) {
        return c;
    }
    detectar_bytes(&da, &db)
}

// ── detectores ───────────────────────────────────────────────────────────────────────────────

/// gzip: `1f 8b`, y el MTIME en los bytes 4..8 (little-endian). Si es lo único que cambia, la
/// divergencia entera se explica por no haber usado `gzip -n`.
fn detectar_gzip(a: &[u8], b: &[u8]) -> Option<Causa> {
    if a.len() < 8 || b.len() < 8 || a[0..2] != [0x1f, 0x8b] || b[0..2] != [0x1f, 0x8b] {
        return None;
    }
    let ma = u32::from_le_bytes(a[4..8].try_into().ok()?);
    let mb = u32::from_le_bytes(b[4..8].try_into().ok()?);
    if ma == mb {
        return None;
    }
    Some(Causa::GzipMtime { a: ma, b: mb })
}

/// Un miembro de archivo `ar`: cabecera ASCII de 60 bytes —
/// nombre[16] mtime[12] uid[6] gid[6] modo[8] tamaño[10] fmag[2].
struct MiembroAr {
    nombre: String,
    mtime: String,
    uid: String,
    gid: String,
    modo: String,
    tamano: usize,
    inicio: usize,
}

fn parsear_ar(d: &[u8]) -> Option<Vec<MiembroAr>> {
    if d.len() < 8 || &d[0..8] != b"!<arch>\n" {
        return None;
    }
    let campo = |b: &[u8]| String::from_utf8_lossy(b).trim().to_string();
    let mut out = Vec::new();
    let mut cur = 8usize;
    while cur + 60 <= d.len() {
        let h = &d[cur..cur + 60];
        if &h[58..60] != b"`\n" {
            break;
        }
        let tamano: usize = campo(&h[48..58]).parse().ok()?;
        out.push(MiembroAr {
            nombre: campo(&h[0..16]),
            mtime: campo(&h[16..28]),
            uid: campo(&h[28..34]),
            gid: campo(&h[34..40]),
            modo: campo(&h[40..48]),
            tamano,
            inicio: cur + 60,
        });
        // Los miembros se alinean a 2 bytes.
        cur = cur + 60 + tamano + (tamano % 2);
    }
    Some(out)
}

/// `.a` (y `.deb`/`.lib`): si las cabeceras traen mtime/uid/gid distintos, ésa es la causa — el
/// contenido de los objetos puede ser idéntico. Se mata archivando en modo determinista.
fn detectar_ar(a: &[u8], b: &[u8]) -> Option<Causa> {
    let (ma, mb) = (parsear_ar(a)?, parsear_ar(b)?);
    for (x, y) in ma.iter().zip(mb.iter()) {
        for (campo, va, vb) in [
            ("mtime", &x.mtime, &y.mtime),
            ("uid", &x.uid, &y.uid),
            ("gid", &x.gid, &y.gid),
            ("modo", &x.modo, &y.modo),
        ] {
            if va != vb {
                return Some(Causa::ArCabecera {
                    miembro: x.nombre.clone(),
                    campo: campo.to_string(),
                    a: va.clone(),
                    b: vb.clone(),
                });
            }
        }
        // Cabeceras iguales pero contenido distinto ⇒ la causa está DENTRO del objeto: se
        // diagnostica recursivamente (un `.o` es ELF, así que casi siempre cae en detectar_elf).
        let (fa, fb) = (x.inicio + x.tamano, y.inicio + y.tamano);
        if fa <= a.len() && fb <= b.len() {
            let (ca, cb) = (&a[x.inicio..fa], &b[y.inicio..fb]);
            if ca != cb {
                if let Some(Causa::ElfSecciones { secciones, interpretacion, evidencia }) =
                    detectar_elf(ca, cb)
                {
                    // Un `.a` trae decenas de objetos: sin decir cuál, el veredicto obliga a
                    // desarmar el archivo a mano — justo el trabajo artesanal que esto elimina.
                    return Some(Causa::ElfSecciones {
                        secciones,
                        interpretacion: format!("miembro `{}` — {interpretacion}", x.nombre),
                        evidencia,
                    });
                }
            }
        }
    }
    None
}

/// Los bytes de una sección concreta, si existe y cae dentro del fichero.
fn bytes_de_seccion<'a>(d: &'a [u8], nombre: &str) -> Option<&'a [u8]> {
    let (_, off, size) = secciones_crudas(d)?.into_iter().find(|(n, ..)| n == nombre)?;
    d.get(off..off + size)
}

/// Cadenas NUL-terminadas imprimibles de una sección (`.debug_str`, `.comment`: son tablas de
/// cadenas). Es lo que convierte "difieren las secciones de depuración" en evidencia concreta.
fn cadenas_de(d: &[u8], nombre: &str) -> Vec<String> {
    let Some(b) = bytes_de_seccion(d, nombre) else {
        return Vec::new();
    };
    b.split(|&c| c == 0)
        .filter(|s| s.len() >= 4 && s.iter().all(|&c| (0x20..0x7f).contains(&c)))
        .map(|s| String::from_utf8_lossy(s).to_string())
        .collect()
}

/// La tabla de secciones como (nombre, offset, tamaño).
///
/// Cabecera de sección ELF64: sh_name[4] sh_type[4] sh_flags[8] sh_addr[8] sh_offset[8] sh_size[8] …
fn secciones_crudas(d: &[u8]) -> Option<Vec<(String, usize, usize)>> {
    if d.len() < 64 || &d[0..4] != b"\x7fELF" || d[4] != 2 || d[5] != 1 {
        return None;
    }
    let e_shoff = u64::from_le_bytes(d[40..48].try_into().ok()?) as usize;
    let e_shentsize = u16::from_le_bytes(d[58..60].try_into().ok()?) as usize;
    let e_shnum = u16::from_le_bytes(d[60..62].try_into().ok()?) as usize;
    let e_shstrndx = u16::from_le_bytes(d[62..64].try_into().ok()?) as usize;
    if e_shoff == 0 || e_shnum == 0 || e_shstrndx >= e_shnum || e_shentsize < 64 {
        return None;
    }
    let leer = |i: usize| -> Option<(u32, u64, u64)> {
        let off = e_shoff + i * e_shentsize;
        if off + 64 > d.len() {
            return None;
        }
        let sh_name = u32::from_le_bytes(d[off..off + 4].try_into().ok()?);
        let sh_offset = u64::from_le_bytes(d[off + 24..off + 32].try_into().ok()?);
        let sh_size = u64::from_le_bytes(d[off + 32..off + 40].try_into().ok()?);
        Some((sh_name, sh_offset, sh_size))
    };
    // La tabla de nombres de sección: sus bytes son cadenas NUL-terminadas indexadas por sh_name.
    let (_, str_off, str_size) = leer(e_shstrndx)?;
    let (str_off, str_size) = (str_off as usize, str_size as usize);
    if str_off + str_size > d.len() {
        return None;
    }
    let strtab = &d[str_off..str_off + str_size];

    let mut out = Vec::with_capacity(e_shnum);
    for i in 0..e_shnum {
        let (sh_name, sh_offset, sh_size) = leer(i)?;
        let ini = sh_name as usize;
        let nombre = if ini < strtab.len() {
            let fin = strtab[ini..].iter().position(|&c| c == 0).unwrap_or(0) + ini;
            String::from_utf8_lossy(&strtab[ini..fin]).to_string()
        } else {
            format!("<sección {i}>")
        };
        out.push((nombre, sh_offset as usize, sh_size as usize));
    }
    Some(out)
}

/// Las secciones con el hash de su contenido — la base de la comparación.
fn secciones_elf(d: &[u8]) -> Option<BTreeMap<String, (u64, String)>> {
    let mut out = BTreeMap::new();
    for (nombre, off, size) in secciones_crudas(d)? {
        // SHT_NOBITS (.bss) y las secciones fuera de rango no tienen bytes que hashear.
        let contenido = match d.get(off..off + size) {
            Some(b) => blake3::hash(b).to_hex().to_string(),
            None => String::from("<fuera de rango>"),
        };
        out.insert(nombre, (size as u64, contenido));
    }
    Some(out)
}

/// ELF: comparar sección por sección. Saber CUÁLES difieren es casi todo el diagnóstico — es la
/// diferencia entre "el binario cambió" y "sólo cambió `.comment` ⇒ otro compilador".
fn detectar_elf(a: &[u8], b: &[u8]) -> Option<Causa> {
    let (sa, sb) = (secciones_elf(a)?, secciones_elf(b)?);
    let mut difieren: Vec<String> = Vec::new();
    for (nombre, va) in &sa {
        match sb.get(nombre) {
            Some(vb) if vb == va => {}
            Some(_) => difieren.push(nombre.clone()),
            None => difieren.push(format!("{nombre} (sólo en A)")),
        }
    }
    for nombre in sb.keys() {
        if !sa.contains_key(nombre) {
            difieren.push(format!("{nombre} (sólo en B)"));
        }
    }
    if difieren.is_empty() {
        return None;
    }
    Some(Causa::ElfSecciones {
        interpretacion: interpretar_secciones(&difieren),
        evidencia: evidencia_de_cadenas(a, b, &difieren),
        secciones: difieren,
    })
}

/// Cuántas cadenas divergentes se muestran como evidencia. Más que esto es ruido: el patrón se ve
/// con dos o tres.
const MAX_EVIDENCIA: usize = 3;

/// Saca las cadenas que están en un ELF y no en el otro, para las secciones de texto que difieren.
///
/// Esto es lo que separa la sospecha del hecho. `.debug_str` difiere ⇒ la hipótesis es "rutas del
/// árbol de build"; mostrar la cadena la confirma o la desmiente. Caso real que motivó esto
/// (barrido del 2026-07-21, `alsa-lib`): la interpretación decía "típicamente rutas de build" y la
/// evidencia mostró `/src/target/release/build/libsodium-sys-<hash-cargo>/out/…` — una ruta con un
/// hash de cargo que cambia entre builds. Sin la cadena, eso era una corazonada.
fn evidencia_de_cadenas(a: &[u8], b: &[u8], difieren: &[String]) -> Vec<String> {
    const INTERESANTES: [&str; 3] = [".debug_str", ".comment", ".rodata"];
    let mut out = Vec::new();
    for sec in difieren {
        let nombre = sec.split(' ').next().unwrap_or(sec);
        if !INTERESANTES.contains(&nombre) {
            continue;
        }
        let (ca, cb) = (cadenas_de(a, nombre), cadenas_de(b, nombre));
        let (sa, sb): (std::collections::BTreeSet<_>, std::collections::BTreeSet<_>) =
            (ca.into_iter().collect(), cb.into_iter().collect());
        for s in sa.difference(&sb).take(MAX_EVIDENCIA) {
            out.push(format!("{nombre} sólo en A: {}", recortar(s)));
        }
        for s in sb.difference(&sa).take(MAX_EVIDENCIA) {
            out.push(format!("{nombre} sólo en B: {}", recortar(s)));
        }
        if out.len() >= MAX_EVIDENCIA * 2 {
            break;
        }
    }
    out
}

/// La lectura experta: qué significa que difieran ESAS secciones y no otras.
fn interpretar_secciones(difieren: &[String]) -> String {
    let tiene = |p: &str| difieren.iter().any(|s| s.starts_with(p));
    let solo = |ps: &[&str]| difieren.iter().all(|s| ps.iter().any(|p| s.starts_with(p)));

    if solo(&[".comment"]) {
        return "sólo `.comment`: mismo código, distinta VERSIÓN de compilador".into();
    }
    if solo(&[".note.gnu.build-id"]) {
        return "sólo el build-id: deriva del resto del binario — si nada más difiere, \
                es un residuo, no la causa"
            .into();
    }
    if solo(&[".debug", ".zdebug", ".note.gnu.build-id"]) {
        return "sólo info de depuración: típicamente rutas del árbol de build sin \
                `-ffile-prefix-map` (el código ejecutable es idéntico)"
            .into();
    }
    if tiene(".text") || tiene(".data") || tiene(".rodata") {
        let mut s = String::from("difiere código/datos ejecutables");
        if tiene(".comment") {
            s.push_str(" Y `.comment` ⇒ sospechar primero la versión del compilador");
        }
        return s;
    }
    if solo(&[".symtab", ".strtab", ".dynsym", ".dynstr"]) {
        return "sólo tablas de símbolos: el ORDEN de los símbolos cambió (enlazado no \
                determinista); el código es idéntico"
            .into();
    }
    "secciones sin patrón conocido — hay que mirar".into()
}

/// Rutas del árbol de build que no deberían acabar dentro de un artefacto.
const MARCAS_BUILD: [&str; 5] = ["work/sources/", "/build/", "/tmp/", "/nix/store/", "/out/"];

/// Texto: la primera línea que difiere dice más que cualquier offset.
fn detectar_texto(a: &[u8], b: &[u8]) -> Option<Causa> {
    let (ta, tb) = (std::str::from_utf8(a).ok()?, std::str::from_utf8(b).ok()?);
    for (i, (la, lb)) in ta.lines().zip(tb.lines()).enumerate() {
        if la != lb {
            if MARCAS_BUILD.iter().any(|m| la.contains(m) || lb.contains(m)) {
                return Some(Causa::RutaDeBuild {
                    a: recortar(la),
                    b: recortar(lb),
                });
            }
            return Some(Causa::TextoLinea {
                linea: i + 1,
                a: recortar(la),
                b: recortar(lb),
            });
        }
    }
    // Mismo prefijo línea a línea: uno tiene más líneas que el otro.
    let (na, nb) = (ta.lines().count(), tb.lines().count());
    Some(Causa::TextoLinea {
        linea: na.min(nb) + 1,
        a: if na > nb { "<línea de más>".into() } else { "<fin>".into() },
        b: if nb > na { "<línea de más>".into() } else { "<fin>".into() },
    })
}

/// Último recurso: el primer byte que difiere y su ventana. Si en la ventana aparece una ruta de
/// build, eso es más informativo que el hex y se reporta como tal.
fn detectar_bytes(a: &[u8], b: &[u8]) -> Causa {
    let offset = a.iter().zip(b.iter()).position(|(x, y)| x != y);
    let Some(off) = offset else {
        return Causa::Tamano {
            a: a.len() as u64,
            b: b.len() as u64,
        };
    };
    let ini = off.saturating_sub(VENTANA / 2);
    let va = &a[ini..(ini + VENTANA).min(a.len())];
    let vb = &b[ini..(ini + VENTANA).min(b.len())];

    let (sa, sb) = (imprimible(va), imprimible(vb));
    if MARCAS_BUILD.iter().any(|m| sa.contains(m) || sb.contains(m)) {
        return Causa::RutaDeBuild { a: sa, b: sb };
    }
    Causa::Bytes {
        offset: off as u64,
        a_hex: hex(va),
        b_hex: hex(vb),
    }
}

fn hex(d: &[u8]) -> String {
    d.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
}

fn imprimible(d: &[u8]) -> String {
    d.iter()
        .map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' })
        .collect()
}

fn recortar(s: &str) -> String {
    const MAX: usize = 120;
    if s.chars().count() <= MAX {
        return s.to_string();
    }
    let corto: String = s.chars().take(MAX).collect();
    format!("{corto}…")
}

/// La frase que se imprime. Vive acá (y no en el CLI) para que el informe JSON y el humano digan
/// exactamente lo mismo.
fn explicar(c: &Causa) -> String {
    match c {
        Causa::SoloEnA => "existe sólo en A".into(),
        Causa::SoloEnB => "existe sólo en B".into(),
        Causa::TipoDistinto { a, b } => format!("es {a} en A y {b} en B"),
        Causa::SymlinkDistinto { a, b } => format!("symlink → `{a}` en A, → `{b}` en B"),
        Causa::ModoDistinto { a_ejecutable, .. } => format!(
            "mismo contenido, distinto bit de ejecución (A {}, B {})",
            if *a_ejecutable { "x" } else { "no-x" },
            if *a_ejecutable { "no-x" } else { "x" }
        ),
        Causa::GzipMtime { a, b } => {
            format!("gzip con MTIME embebido: {a} vs {b} (cabecera, bytes 4..8)")
        }
        Causa::ArCabecera { miembro, campo, a, b } => {
            format!("cabecera `ar` del miembro `{miembro}`: {campo} {a} vs {b}")
        }
        Causa::ElfSecciones { secciones, interpretacion, evidencia } => {
            let mut t = format!("ELF, difieren [{}] — {interpretacion}", secciones.join(", "));
            for e in evidencia {
                t.push_str(&format!("\n      · {e}"));
            }
            t
        }
        Causa::TextoLinea { linea, a, b } => format!("texto, línea {linea}: `{a}` vs `{b}`"),
        Causa::RutaDeBuild { a, b } => {
            format!("ruta del árbol de build embebida: `{a}` vs `{b}`")
        }
        Causa::Bytes { offset, a_hex, b_hex } => {
            format!("primer byte distinto en 0x{offset:x}\n      A: {a_hex}\n      B: {b_hex}")
        }
        Causa::Tamano { a, b } => format!("distinto tamaño: {a} vs {b} bytes"),
    }
}

// ── recorrido del árbol ──────────────────────────────────────────────────────────────────────

fn walk(raiz: &Path) -> crate::Result<BTreeMap<String, Entrada>> {
    if !raiz.exists() {
        return Err(crate::Error::Store(format!("no existe: {}", raiz.display())));
    }
    let mut out = BTreeMap::new();
    let mut pila: Vec<PathBuf> = vec![raiz.to_path_buf()];
    while let Some(dir) = pila.pop() {
        for entrada in std::fs::read_dir(&dir)? {
            let entrada = entrada?;
            let ruta = entrada.path();
            let rel = ruta
                .strip_prefix(raiz)
                .map_err(|e| crate::Error::Store(e.to_string()))?
                .to_string_lossy()
                .to_string();
            let md = std::fs::symlink_metadata(&ruta)?;
            if md.is_symlink() {
                let destino = std::fs::read_link(&ruta)?.to_string_lossy().to_string();
                out.insert(rel, Entrada::Symlink(destino));
            } else if md.is_dir() {
                out.insert(rel, Entrada::Directorio);
                pila.push(ruta);
            } else {
                out.insert(
                    rel,
                    Entrada::Fichero {
                        hash: hash_fichero(&ruta)?,
                        tamano: md.len(),
                        ejecutable: md.permissions().mode() & 0o111 != 0,
                    },
                );
            }
        }
    }
    Ok(out)
}

/// BLAKE3 por streaming: un artefacto puede traer ficheros de cientos de MB y no hay razón para
/// tenerlos enteros en memoria sólo para saber si son iguales.
fn hash_fichero(p: &Path) -> crate::Result<String> {
    let mut f = std::fs::File::open(p)?;
    let mut h = blake3::Hasher::new();
    std::io::copy(&mut f, &mut h)?;
    Ok(h.finalize().to_hex().to_string())
}

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

    fn escribir(dir: &Path, rel: &str, datos: &[u8]) {
        let p = dir.join(rel);
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
        std::fs::File::create(&p).unwrap().write_all(datos).unwrap();
    }

    /// Dos árboles idénticos reproducen: sin divergencias y todo contado como idéntico.
    #[test]
    fn arboles_identicos_reproducen() {
        let t = tempfile::tempdir().unwrap();
        let (a, b) = (t.path().join("a"), t.path().join("b"));
        for r in [&a, &b] {
            escribir(r, "usr/bin/hola", b"contenido");
            escribir(r, "usr/share/doc/leeme", b"texto\n");
        }
        let inf = why_differs(&a, &b).unwrap();
        assert!(inf.reproduce(), "divergencias: {:?}", inf.divergencias);
        // usr, usr/bin, usr/bin/hola, usr/share, usr/share/doc, usr/share/doc/leeme
        assert_eq!(inf.identicos, 6);
    }

    /// El MTIME de un gzip es LA causa clásica; tiene que salir nombrada, no como "bytes distintos".
    #[test]
    fn gzip_mtime_se_nombra_y_trae_remedio() {
        let t = tempfile::tempdir().unwrap();
        let (a, b) = (t.path().join("a"), t.path().join("b"));
        // cabecera gzip mínima: magic, CM, FLG, MTIME(4), XFL, OS
        let mut ga = vec![0x1f, 0x8b, 0x08, 0x00];
        ga.extend_from_slice(&1_000u32.to_le_bytes());
        ga.extend_from_slice(&[0x00, 0x03, 0xde, 0xad]);
        let mut gb = ga.clone();
        gb[4..8].copy_from_slice(&2_000u32.to_le_bytes());
        escribir(&a, "m.gz", &ga);
        escribir(&b, "m.gz", &gb);

        let inf = why_differs(&a, &b).unwrap();
        assert_eq!(inf.divergencias.len(), 1);
        let d = &inf.divergencias[0];
        assert_eq!(d.causa, Causa::GzipMtime { a: 1000, b: 2000 });
        assert!(d.remedio.as_ref().unwrap().contains("gzip -n"));
    }

    /// Una ruta del árbol de build embebida se reporta como tal (no como una línea cualquiera).
    #[test]
    fn ruta_de_build_embebida() {
        let t = tempfile::tempdir().unwrap();
        let (a, b) = (t.path().join("a"), t.path().join("b"));
        escribir(&a, "cfg", b"prefix=/home/x/work/sources/zlib-aaa\n");
        escribir(&b, "cfg", b"prefix=/home/x/work/sources/zlib-bbb\n");
        let inf = why_differs(&a, &b).unwrap();
        assert_eq!(inf.divergencias[0].causa.etiqueta(), "ruta-de-build");
        assert!(inf.divergencias[0].remedio.is_some());
    }

    /// Presencia/ausencia y symlinks divergentes son divergencias de pleno derecho.
    #[test]
    fn presencia_y_symlinks() {
        let t = tempfile::tempdir().unwrap();
        let (a, b) = (t.path().join("a"), t.path().join("b"));
        escribir(&a, "solo-a", b"x");
        escribir(&b, "solo-b", b"x");
        std::fs::create_dir_all(a.join("d")).unwrap();
        std::fs::create_dir_all(b.join("d")).unwrap();
        std::os::unix::fs::symlink("destino-1", a.join("d/enlace")).unwrap();
        std::os::unix::fs::symlink("destino-2", b.join("d/enlace")).unwrap();

        let inf = why_differs(&a, &b).unwrap();
        let etiquetas = inf.por_causa();
        assert_eq!(etiquetas.get("solo-en-a"), Some(&1));
        assert_eq!(etiquetas.get("solo-en-b"), Some(&1));
        assert_eq!(etiquetas.get("symlink-distinto"), Some(&1));
    }

    /// Un `.a` cuyos miembros sólo difieren en el mtime de la cabecera: causa nombrada + remedio.
    #[test]
    fn ar_mtime_de_cabecera() {
        let t = tempfile::tempdir().unwrap();
        let (a, b) = (t.path().join("a"), t.path().join("b"));
        let armar = |mtime: &str| {
            let mut v = Vec::from(*b"!<arch>\n");
            v.extend_from_slice(format!("{:<16}", "obj.o").as_bytes());
            v.extend_from_slice(format!("{mtime:<12}").as_bytes());
            v.extend_from_slice(format!("{:<6}", "0").as_bytes());
            v.extend_from_slice(format!("{:<6}", "0").as_bytes());
            v.extend_from_slice(format!("{:<8}", "100644").as_bytes());
            v.extend_from_slice(format!("{:<10}", "4").as_bytes());
            v.extend_from_slice(b"`\n");
            v.extend_from_slice(b"data");
            v
        };
        escribir(&a, "lib.a", &armar("1700000000"));
        escribir(&b, "lib.a", &armar("1800000000"));
        let inf = why_differs(&a, &b).unwrap();
        assert_eq!(inf.divergencias[0].causa.etiqueta(), "ar-cabecera");
        assert!(inf.divergencias[0].remedio.as_ref().unwrap().contains("determinista"));
    }
}
