//! H4b — el gate de **compatibilidad de configs** sobre la receta real (SDD 15 §H4).
//!
//! Sube a hammer el modelo de slots que `wawa-memo/src/compat.rs` demostró como
//! prototipo (host/std, 52 tests verdes): una config/paquete declara qué
//! superficies del sistema **reclama** (`Slots::claims`) y **requiere**
//! (`Slots::requires`), ambas por hash. Antes de tocar el sistema, `install`
//! evalúa la config entrante contra el **estado instalado**
//! ([`crate::InstalledDb::system_state`]) y decide:
//!
//! - **Compatible** — requisitos resueltos y reclamos disjuntos (o coincidentes):
//!   se puede instalar sin fricción.
//! - **Colisión** — reclama una superficie ya ocupada por OTRO contenido: es una
//!   **elección** del usuario (el caso "ya cambié el logo y bajo otra config que
//!   también lo cambia"), no un error. `install` la respeta salvo `--force-slots`.
//! - **Incompatible** — un requisito no resuelve contra el estado: **rechazo
//!   duro** (el caso "modifiqué el protocolo de Wayland y bajo algo que depende
//!   del Wayland stock"). Es el análogo de sistema a una referencia colgante.
//!
//! El chequeo es **local y reproducible** — el mismo "verificar, no confiar" de
//! SDD 09, ahora sobre la topología de superficies. "Compatible" (este módulo) es
//! el eje NUEVO; "completa" y "segura" ya viven en el stack (cierre transitivo /
//! reproducir el resultado) — ver el prototipo `wawa-memo` para la composición.

use std::collections::BTreeSet;

use crate::recipe::Slots;
use crate::swm::{Mutation, Swm};

/// El estado instalado como `slot → hash`. Alias de lo que produce
/// [`crate::InstalledDb::system_state`].
pub type Estado = std::collections::BTreeMap<String, String>;

/// El veredicto de evaluar unos [`Slots`] contra un [`Estado`]. Tres resultados
/// **mecánicamente distintos**: la incompatibilidad domina a la colisión.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Veredicto {
    /// Requisitos resueltos y reclamos disjuntos (o coincidentes en hash): instalable.
    Compatible,
    /// Un slot que la config reclama ya está ocupado por **otro** contenido. NO es
    /// error: es una **elección** (caso *logo*).
    Colision {
        slot: String,
        /// El hash que el slot tiene ahora en el estado.
        instalado: String,
        /// El hash que la config quiere poner.
        entrante: String,
    },
    /// Un requisito **no resuelve** contra el estado (caso *wayland*): rechazo duro.
    /// `presente = None` si el estado ni ofrece el slot; `Some(otro)` si otra versión.
    Incompatible {
        slot: String,
        /// El hash que la config exige para ese slot.
        requerido: String,
        /// Lo que el estado ofrece para ese slot (o `None`).
        presente: Option<String>,
    },
}

impl Veredicto {
    /// `true` sólo si es [`Veredicto::Compatible`].
    pub fn es_compatible(&self) -> bool {
        matches!(self, Veredicto::Compatible)
    }
}

/// Evalúa unos `slots` contra el `estado` instalado. Primero **requisitos**
/// (incompatibilidad dura), luego **reclamos** (colisión = elección). Devuelve la
/// **primera** objeción en orden determinista (`BTreeMap`), o
/// [`Veredicto::Compatible`]. Un reclamo que coincide en hash es idempotente.
pub fn evaluar(estado: &Estado, slots: &Slots) -> Veredicto {
    // 1. Requisitos: cada superficie requerida debe estar presente al hash exacto
    //    contra el que la config fue construida. Es la referencia colgante de sistema.
    for (slot, requerido) in &slots.requires {
        match estado.get(slot) {
            Some(h) if h == requerido => {}
            otro => {
                return Veredicto::Incompatible {
                    slot: slot.clone(),
                    requerido: requerido.clone(),
                    presente: otro.cloned(),
                };
            }
        }
    }
    // 2. Reclamos: un slot ya ocupado por otro contenido es colisión (elección).
    for (slot, entrante) in &slots.claims {
        if let Some(instalado) = estado.get(slot) {
            if instalado != entrante {
                return Veredicto::Colision {
                    slot: slot.clone(),
                    instalado: instalado.clone(),
                    entrante: entrante.clone(),
                };
            }
        }
    }
    Veredicto::Compatible
}

// ─────────────────────────────────────────────────────────────────────────
//  H4c — superficies OBSERVADAS: la vía "no prometas, observá"
// ─────────────────────────────────────────────────────────────────────────
//
// H4b confía en que el AUTOR declare `slots` a mano. La misma subida que hizo H1
// (de "prometer" a "verificar") aplica acá: la superficie más común y observable
// de un paquete es **el conjunto de paths que escribe**, y esos paths ya están
// DECLARADOS en el `.swm` (`target_bin` de cada source_patch, `path` de cada
// file_drop) — se conocen ANTES de hidratar, así que el gate puede abortar sin
// tocar nada. El "slot" es el path; la señal de colisión es que OTRO paquete
// instalado ya lo posea. No hace falta que el autor prometa nada: se observa.

/// Los paths ABSOLUTOS que un paquete **escribirá**, leídos de su `.swm`: el
/// `target_bin` de cada `source_patch` y el `path` de cada `file_drop`. Es la
/// superficie observada de H4c — conocida sin construir.
pub fn output_paths(swm: &Swm) -> BTreeSet<String> {
    let mut paths = BTreeSet::new();
    for m in &swm.mutations {
        match m {
            Mutation::SourcePatch { target_bin, .. } => {
                paths.insert(target_bin.clone());
            }
            Mutation::FileDrop { path, .. } => {
                paths.insert(path.clone());
            }
            // config_edit MODIFICA un fichero ajeno (no lo crea/posee); init_rule escribe en el
            // dir de reglas del init. Ninguno "reclama" una superficie propia — se omiten.
            Mutation::ConfigEdit { .. } | Mutation::InitRule { .. } => {}
        }
    }
    paths
}

/// Una colisión de path observada: `entrante` escribirá un `path` que YA posee otro
/// paquete instalado (`propietario`). Sobreescribir el fichero de otro paquete es una
/// **elección** (el caso *logo* a nivel de fichero), no un error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColisionPath {
    pub path: String,
    /// El paquete instalado que ya posee ese path.
    pub propietario: String,
}

/// Colisiones de path del `entrante` (por nombre + los paths que escribirá) contra el estado
/// instalado. Reinstalar el MISMO paquete sobre sus propios paths NO colisiona (es upgrade);
/// sólo pisar un fichero de OTRO paquete cuenta. Reusa lo que `InstalledDb` ya registra — no
/// necesita que la receta declare nada (H4c: observar, no prometer).
pub fn path_collisions(
    db: &crate::InstalledDb,
    entrante_name: &str,
    entrante_paths: &BTreeSet<String>,
) -> Vec<ColisionPath> {
    let mut cols = Vec::new();
    for p in entrante_paths {
        if let Some(dueno) = db.owner_of(p) {
            if dueno != entrante_name {
                cols.push(ColisionPath {
                    path: p.clone(),
                    propietario: dueno.to_string(),
                });
            }
        }
    }
    cols
}

// ─────────────────────────────────────────────────────────────────────────
//  H4e — `requires` OBSERVADOS: la versión contra la que el paquete se construyó
// ─────────────────────────────────────────────────────────────────────────
//
// H4c observó lo que un paquete *escribe*; falta observar de qué *depende a una
// versión* (el caso wayland automático). La fuente observable: un paquete se
// construyó contra la versión de sus deps que hay EN EL REPO. Si el usuario tiene
// esa dep instalada a OTRO hash, la diverguió ⇒ el paquete no encaja. No hace
// falta que el autor prometa `requires`: se lee del cierre (deps del `.swm` +
// `expected_hash` de cada dep en el índice). Es el análogo de sistema a un
// requisito que no resuelve — el mismo rechazo duro de H4b, ahora derivado.

/// [H4e] Los `requires` OBSERVADOS de un paquete: por cada dep de **runtime**
/// declarada en su `.swm`, la versión (`expected_hash`) que esa dep tiene **en el
/// índice** — o sea, contra la que el paquete se construyó. Deps sin `expected_hash`
/// en el índice se omiten (no hay versión observable que exigir).
pub fn observed_requires(
    swm: &Swm,
    index: &crate::RepoIndex,
) -> std::collections::BTreeMap<String, String> {
    let mut req = std::collections::BTreeMap::new();
    for m in &swm.mutations {
        if let Mutation::SourcePatch { deps, .. } = m {
            for dep in &deps.runtime {
                if let Some(h) = index.find(dep).and_then(|e| e.expected_hash.clone()) {
                    req.insert(dep.clone(), h);
                }
            }
        }
    }
    req
}

/// Un conflicto de versión observado: una dep de runtime está instalada a un hash
/// **distinto** del que el paquete espera (el que hay en el repo). Es el caso
/// *wayland* derivado: divergiste una superficie de la que este paquete depende.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictoVersion {
    pub dep: String,
    /// El hash que el paquete espera (la versión del repo contra la que se construyó).
    pub requerido: String,
    /// El hash al que la dep está instalada AHORA (divergido).
    pub instalado: String,
}

/// Conflictos de versión del entrante contra el estado instalado: por cada
/// `requires` observado, si la dep está instalada a otro hash. Una dep que NO está
/// instalada no cuenta acá (es un asunto de presencia, no de divergencia) — sólo
/// pisar una versión distinta rechaza. Reusa lo que `InstalledDb` ya registra.
pub fn version_conflicts(
    db: &crate::InstalledDb,
    requires: &std::collections::BTreeMap<String, String>,
) -> Vec<ConflictoVersion> {
    let mut out = Vec::new();
    for (dep, requerido) in requires {
        if let Some(pkg) = db.find(dep) {
            if let Some(instalado) = &pkg.hash {
                if instalado != requerido {
                    out.push(ConflictoVersion {
                        dep: dep.clone(),
                        requerido: requerido.clone(),
                        instalado: instalado.clone(),
                    });
                }
            }
        }
    }
    out
}

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

    fn estado(pares: &[(&str, &str)]) -> Estado {
        pares.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect()
    }

    fn slots(claims: &[(&str, &str)], requires: &[(&str, &str)]) -> Slots {
        Slots {
            claims: claims.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(),
            requires: requires.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(),
        }
    }

    #[test]
    fn logo_ya_cambiado_es_eleccion() {
        let mio = estado(&[("logo", "b3:rojo")]);
        let entrante = slots(&[("logo", "b3:azul")], &[]);
        match evaluar(&mio, &entrante) {
            Veredicto::Colision { slot, instalado, entrante } => {
                assert_eq!(slot, "logo");
                assert_eq!(instalado, "b3:rojo");
                assert_eq!(entrante, "b3:azul");
            }
            otro => panic!("esperaba Colision, fue {otro:?}"),
        }
    }

    #[test]
    fn reclamar_lo_mismo_es_idempotente() {
        let mio = estado(&[("logo", "b3:rojo")]);
        assert!(evaluar(&mio, &slots(&[("logo", "b3:rojo")], &[])).es_compatible());
    }

    #[test]
    fn wayland_divergido_rechaza_dependiente() {
        let mio = estado(&[("wayland-protocol", "b3:mio")]);
        let descargado = slots(&[("app-foo", "b3:v1")], &[("wayland-protocol", "b3:stock")]);
        match evaluar(&mio, &descargado) {
            Veredicto::Incompatible { slot, requerido, presente } => {
                assert_eq!(slot, "wayland-protocol");
                assert_eq!(requerido, "b3:stock");
                assert_eq!(presente, Some("b3:mio".to_string()));
            }
            otro => panic!("esperaba Incompatible, fue {otro:?}"),
        }
        // Con el wayland stock, la misma descarga es compatible.
        let stock = estado(&[("wayland-protocol", "b3:stock")]);
        assert!(evaluar(&stock, &descargado).es_compatible());
    }

    #[test]
    fn requisito_ausente_incompatible() {
        let vacio = estado(&[]);
        match evaluar(&vacio, &slots(&[], &[("wayland-protocol", "b3:stock")])) {
            Veredicto::Incompatible { presente, .. } => assert_eq!(presente, None),
            otro => panic!("esperaba Incompatible/None, fue {otro:?}"),
        }
    }

    // ── H4c: superficies observadas (paths escritos, sin declarar slots) ──

    fn pkg_con_files(name: &str, files: &[&str]) -> crate::InstalledPackage {
        crate::InstalledPackage {
            name: name.into(),
            version: "1".into(),
            hash: None,
            files: files.iter().map(|s| s.to_string()).collect(),
            claims: Default::default(),
        }
    }

    #[test]
    fn colision_de_path_observada_contra_otro_paquete() {
        let mut db = crate::InstalledDb::default();
        db.record(pkg_con_files("tema-claro", &["/usr/share/logo.png"]));

        // 'tema-oscuro' escribirá el mismo path ⇒ colisión con 'tema-claro'.
        let entrante: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
        let cols = path_collisions(&db, "tema-oscuro", &entrante);
        assert_eq!(cols.len(), 1);
        assert_eq!(cols[0].path, "/usr/share/logo.png");
        assert_eq!(cols[0].propietario, "tema-claro");
    }

    #[test]
    fn reinstalar_el_mismo_paquete_no_colisiona() {
        let mut db = crate::InstalledDb::default();
        db.record(pkg_con_files("tema-oscuro", &["/usr/share/logo.png"]));
        // El mismo paquete sobre sus propios paths = upgrade, no colisión.
        let paths: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
        assert!(path_collisions(&db, "tema-oscuro", &paths).is_empty());
    }

    // ── H4e: requires observados (versión de la dep contra la que se construyó) ──

    #[test]
    fn wayland_divergido_observado_da_conflicto_de_version() {
        // El paquete 'app' depende (runtime) de 'wayland-protocol'. En el repo, wayland está a
        // b3:stock (la versión contra la que app se construyó).
        let swm = crate::Swm {
            swm_version: 1,
            base: crate::swm::Base { distro_version: "dev".into(), pins: Default::default() },
            mutations: vec![Mutation::SourcePatch {
                repo: Some("git://x/app.git".into()),
                commit: Some("abc".into()),
                tarball: None,
                sha256: None,
                strip_components: None,
                patch: None,
                patch_url: None,
                build: crate::swm::SwmBuild {
                    compiler: "zig-cc".into(),
                    target: "x86_64-linux-musl".into(),
                    link: "static".into(),
                    flags: vec![],
                    phases: Default::default(),
                    zig_version: None,
                },
                target_bin: "/usr/bin/app".into(),
                expected_hash: Some("b3:app".into()),
                deps: crate::recipe::Deps { build: vec![], runtime: vec!["wayland-protocol".into()] },
                evidence: Default::default(),
                slots: Default::default(),
            }],
            signature: None,
        };
        let index: crate::RepoIndex = serde_json::from_str(
            r#"{"packages":[{"name":"wayland-protocol","version":"1","file":"w.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}]}"#,
        )
        .unwrap();

        let req = observed_requires(&swm, &index);
        assert_eq!(req.get("wayland-protocol").unwrap(), "b3:stock");

        // Tengo wayland instalado a b3:mine (divergido) ⇒ conflicto.
        let mut db = crate::InstalledDb::default();
        db.record(crate::InstalledPackage {
            name: "wayland-protocol".into(),
            version: "1".into(),
            hash: Some("b3:mine".into()),
            files: vec![],
            claims: Default::default(),
        });
        let cs = version_conflicts(&db, &req);
        assert_eq!(cs.len(), 1);
        assert_eq!(cs[0].requerido, "b3:stock");
        assert_eq!(cs[0].instalado, "b3:mine");

        // Si tengo la versión stock, no hay conflicto.
        let mut ok = crate::InstalledDb::default();
        ok.record(crate::InstalledPackage {
            name: "wayland-protocol".into(),
            version: "1".into(),
            hash: Some("b3:stock".into()),
            files: vec![],
            claims: Default::default(),
        });
        assert!(version_conflicts(&ok, &req).is_empty());
    }

    #[test]
    fn output_paths_lee_target_bin_y_file_drop() {
        let swm = crate::Swm {
            swm_version: 1,
            base: crate::swm::Base {
                distro_version: "2026-06-06".into(),
                pins: Default::default(),
            },
            mutations: vec![
                Mutation::FileDrop {
                    path: "/usr/share/logo.png".into(),
                    content_hash: "b3:x".into(),
                    content_b64: Some("AA==".into()),
                    content_url: None,
                },
                Mutation::ConfigEdit {
                    file: "/etc/foo.conf".into(),
                    inline_diff: "".into(),
                },
            ],
            signature: None,
        };
        let paths = output_paths(&swm);
        // El file_drop cuenta; el config_edit (modifica ajeno) NO.
        assert!(paths.contains("/usr/share/logo.png"));
        assert!(!paths.contains("/etc/foo.conf"));
        assert_eq!(paths.len(), 1);
    }
}
