//! La **base de datos de paquetes instalados** (Etapa F): qué paquetes hay puestos en un root y
//! qué ficheros aportó cada uno. Es lo que hace posible `hammer uninstall`: sin un registro de
//! "este paquete creó estos ficheros", quitar un paquete sería adivinar.
//!
//! Modelo simple y honesto: `install` registra los ficheros que CREÓ (los hidratados del
//! `source_patch` + `file_drop` + `init_rule`; un `config_edit` MODIFICA un fichero ajeno, no lo
//! crea, así que no se registra ni se deshace). `uninstall` borra los ficheros del paquete, salvo
//! los que OTRO paquete instalado también aporta (refcount por ruta) — nunca rompe a un tercero.
//!
//! Las rutas se guardan ABSOLUTAS (bajo el prefix o `/` según donde se instaló), así `uninstall`
//! no necesita saber el root: borra exactamente lo que registró el `install`.

use std::path::Path;

use serde::{Deserialize, Serialize};

/// Nombre por defecto de la base de datos de instalados.
pub const DEFAULT_DB: &str = "/var/lib/hammer/installed.json";

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstalledDb {
    #[serde(default)]
    pub packages: Vec<InstalledPackage>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledPackage {
    pub name: String,
    pub version: String,
    /// `expected_hash` del paquete (si lo tenía) — sólo informativo en la DB.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hash: Option<String>,
    /// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra.
    pub files: Vec<String>,
    /// Superficies del sistema que este paquete **reclama** → hash del contenido (H4b, SDD 15 §H4).
    /// Vacío por defecto (compatibilidad hacia atrás: una DB previa a H4b no lo tiene). Con esto,
    /// `system_state` deriva el `slot → hash` instalado que el gate de compatibilidad consulta.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub claims: std::collections::BTreeMap<String, String>,
}

impl InstalledDb {
    /// Carga la DB. Un fichero ausente es una DB VACÍA válida (nada instalado todavía).
    pub fn load(path: impl AsRef<Path>) -> crate::Result<InstalledDb> {
        match std::fs::read_to_string(path.as_ref()) {
            Ok(text) => serde_json::from_str(&text)
                .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.as_ref().display()))),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledDb::default()),
            Err(e) => Err(crate::Error::Io(e)),
        }
    }

    /// Escribe la DB (creando el directorio padre si hace falta). JSON con saltos, diffeable.
    pub fn save(&self, path: impl AsRef<Path>) -> crate::Result<()> {
        let path = path.as_ref();
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let text = serde_json::to_string_pretty(self)
            .map_err(|e| crate::Error::Serde(e.to_string()))?;
        std::fs::write(path, text)?;
        Ok(())
    }

    pub fn find(&self, name: &str) -> Option<&InstalledPackage> {
        self.packages.iter().find(|p| p.name == name)
    }

    /// Registra (o reemplaza por nombre) un paquete instalado. Re-instalar actualiza la lista de
    /// ficheros (una versión nueva puede aportar otros).
    pub fn record(&mut self, pkg: InstalledPackage) {
        if let Some(slot) = self.packages.iter_mut().find(|p| p.name == pkg.name) {
            *slot = pkg;
        } else {
            self.packages.push(pkg);
            self.packages.sort_by(|a, b| a.name.cmp(&b.name));
        }
    }

    /// Quita el registro de un paquete y lo devuelve (para que el caller borre sus ficheros).
    pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
        let i = self.packages.iter().position(|p| p.name == name)?;
        Some(self.packages.remove(i))
    }

    /// El **estado del sistema** como `slot → hash`: la unión de los `claims` de todos los
    /// paquetes instalados (H4b, SDD 15 §H4). Es el `Estado` contra el que el gate de
    /// compatibilidad evalúa una config entrante. Un slot reclamado por dos paquetes no debería
    /// pasar (el gate lo impide al instalar); si aún así ocurre, gana el orden de la lista (ya
    /// ordenada por nombre) — determinista.
    pub fn system_state(&self) -> std::collections::BTreeMap<String, String> {
        let mut estado = std::collections::BTreeMap::new();
        for p in &self.packages {
            for (slot, hash) in &p.claims {
                estado.entry(slot.clone()).or_insert_with(|| hash.clone());
            }
        }
        estado
    }

    /// El primer paquete instalado que posee `file` (aportó ese path), si alguno. Lo usa el gate
    /// de compatibilidad observada (H4c) para nombrar al propietario de una colisión de path.
    pub fn owner_of(&self, file: &str) -> Option<&str> {
        self.packages
            .iter()
            .find(|p| p.files.iter().any(|f| f == file))
            .map(|p| p.name.as_str())
    }

    /// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe
    /// borrarlo (lo seguiría necesitando ese tercero).
    pub fn owned_by_others(&self, name: &str, file: &str) -> bool {
        self.packages
            .iter()
            .any(|p| p.name != name && p.files.iter().any(|f| f == file))
    }
}

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

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

    #[test]
    fn system_state_une_los_claims() {
        let mut db = InstalledDb::default();
        db.record(InstalledPackage {
            name: "tema-oscuro".into(),
            version: "1".into(),
            hash: None,
            files: vec![],
            claims: [("logo".to_string(), "b3:rojo".to_string())]
                .into_iter()
                .collect(),
        });
        let estado = db.system_state();
        assert_eq!(estado.get("logo"), Some(&"b3:rojo".to_string()));
    }

    #[test]
    fn load_missing_is_empty() {
        let d = tempfile::tempdir().unwrap();
        let db = InstalledDb::load(d.path().join("installed.json")).unwrap();
        assert!(db.packages.is_empty());
    }

    #[test]
    fn record_save_load_find() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("installed.json");
        let mut db = InstalledDb::default();
        db.record(pkg("ripgrep", &["/usr/bin/rg"]));
        db.save(&p).unwrap();
        let back = InstalledDb::load(&p).unwrap();
        assert_eq!(back.find("ripgrep").unwrap().files, vec!["/usr/bin/rg"]);
        assert!(back.find("nope").is_none());
    }

    #[test]
    fn remove_returns_entry() {
        let mut db = InstalledDb::default();
        db.record(pkg("rg", &["/usr/bin/rg"]));
        let removed = db.remove("rg").unwrap();
        assert_eq!(removed.files, vec!["/usr/bin/rg"]);
        assert!(db.find("rg").is_none());
        assert!(db.remove("rg").is_none());
    }

    #[test]
    fn owned_by_others_refcount() {
        let mut db = InstalledDb::default();
        db.record(pkg("a", &["/usr/lib/libshared.so", "/usr/bin/a"]));
        db.record(pkg("b", &["/usr/lib/libshared.so", "/usr/bin/b"]));
        // El .so lo aportan ambos: al desinstalar 'a' no debe borrarse.
        assert!(db.owned_by_others("a", "/usr/lib/libshared.so"));
        // El binario propio de 'a' no lo aporta nadie más.
        assert!(!db.owned_by_others("a", "/usr/bin/a"));
    }
}
