//! Primitivas para aplicar mutaciones de un `.swm` a disco. Ver `docs/06-swm-format.md` §3.
//!
//! Estas funciones son **puras** sobre el sistema de archivos: reciben paths absolutos (ya
//! re-rooteados al overlay o a un prefix de test) y aplican un único `Mutation` cada una.
//! La orquestación (montar overlay, iterar mutaciones, build de source_patch) vive en
//! `hammer-cli`, no aquí — hammer-core no depende de hammer-build ni del overlay.
//!
//! ## `config_edit`
//!
//! El `inline_diff` se parsea como una secuencia de hunks. Un hunk es un bloque contiguo de
//! líneas `- ` (a eliminar) seguido de un bloque contiguo de líneas `+ ` (a insertar). Las
//! líneas que no empiezan por `- ` ni `+ ` son separadores entre hunks (líneas vacías,
//! contexto humano) y se ignoran.
//!
//! Aplicación: para cada hunk, se busca el bloque "removed" **exacto** en el archivo y se
//! sustituye por el "added". Si aparece varias veces ⇒ error (ambiguo). Si no aparece ⇒
//! error. No hay 3-way ni fuzz por ahora; un `.swm` que pinche dos veces falla pronto.
//!
//! ## `file_drop`
//!
//! Decodifica el base64, verifica que el BLAKE3 del contenido coincide con el
//! `content_hash` declarado, y escribe el archivo (creando los directorios padre).
//! `content_url` se rechaza aquí — el fetch externo lo decide la capa CLI.

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

use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::Serialize;

use crate::hash::ArtifactHash;

/// Directorio canónico donde `init_rule` materializa una regla por servicio. El init
/// (arje, PID 1) lee este árbol para saber qué supervisar. Es el contrato on-disk entre
/// hammer (declara) y el init (ejecuta) — análogo a `/etc/systemd/system` pero nativo.
pub const INIT_RULES_DIR: &str = "/etc/hammer/init.d";

#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("config_edit: {0}")]
    ConfigEdit(String),
    #[error("file_drop: {0}")]
    FileDrop(String),
    #[error("init_rule: {0}")]
    InitRule(String),
}

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

/// Aplica un `config_edit` re-rooteado: `file` ya es la ruta final en disco (el caller
/// re-rootea al upper del overlay o a un prefix de test antes de llamar).
pub fn apply_config_edit(file: &Path, inline_diff: &str) -> ApplyResult<()> {
    let original = std::fs::read_to_string(file).map_err(|e| {
        ApplyError::ConfigEdit(format!("leyendo {}: {e}", file.display()))
    })?;
    let hunks = parse_hunks(inline_diff)?;
    if hunks.is_empty() {
        return Err(ApplyError::ConfigEdit(
            "inline_diff no contiene hunks (líneas con '- ' o '+ ')".into(),
        ));
    }
    let mut content = original;
    for (i, hunk) in hunks.iter().enumerate() {
        content = apply_hunk(&content, hunk).map_err(|e| {
            ApplyError::ConfigEdit(format!(
                "hunk #{} en {}: {e}",
                i + 1,
                file.display()
            ))
        })?;
    }
    // Escribimos con `cp -a`-equivalente: para preservar permisos, usamos write directo y
    // confiamos en que el archivo destino ya existe (config_edit nunca crea archivos).
    std::fs::write(file, content)?;
    Ok(())
}

/// Aplica un `file_drop`: decodifica `content_b64`, verifica el hash, escribe `path`.
/// El `content_hash` admite el prefijo `b3:` (es el formato canónico) o el hex puro.
pub fn apply_file_drop(
    path: &Path,
    content_b64: &str,
    content_hash: &str,
) -> ApplyResult<()> {
    let bytes = STANDARD
        .decode(content_b64.as_bytes())
        .map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?;
    let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]);
    // Normalizamos ambos al hex puro (sin "b3:") para comparar.
    let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
    let actual_hex = actual
        .as_str()
        .strip_prefix("b3:")
        .expect("of_inputs siempre devuelve 'b3:'");
    if declared_hex != actual_hex {
        return Err(ApplyError::FileDrop(format!(
            "hash de contenido no coincide: declarado={declared_hex}, calculado={actual_hex}",
        )));
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, &bytes)?;
    Ok(())
}

/// Acción de un `init_rule`. Decide si la regla se **materializa** (el servicio debe
/// existir/correr) o se **retira** (no debe correr).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InitAction {
    Enable,
    Disable,
    Start,
    Stop,
    Restart,
}

impl InitAction {
    pub fn parse(s: &str) -> Result<Self, String> {
        match s {
            "enable" => Ok(Self::Enable),
            "disable" => Ok(Self::Disable),
            "start" => Ok(Self::Start),
            "stop" => Ok(Self::Stop),
            "restart" => Ok(Self::Restart),
            other => Err(format!(
                "acción desconocida '{other}' (enable|disable|start|stop|restart)"
            )),
        }
    }

    /// `true` si la acción implica que el servicio debe quedar declarado en disco.
    /// `disable`/`stop` son las "negativas": retiran la regla.
    fn materializes(self) -> bool {
        !matches!(self, Self::Disable | Self::Stop)
    }
}

/// Forma serializada de una regla de init en disco (TOML).
#[derive(Serialize)]
struct InitRuleFile<'a> {
    service: &'a str,
    action: &'a str,
    command: &'a str,
}

/// Aplica un `init_rule`: materializa (o retira) `{service}.rule` bajo `rules_dir` (el
/// caller pasa [`INIT_RULES_DIR`] re-rooteado al overlay o a un prefix de test). Acciones
/// positivas (enable/start/restart) escriben la regla con su `command`; negativas
/// (disable/stop) la borran (idempotente). Devuelve el path de la regla afectada.
pub fn apply_init_rule(
    rules_dir: &Path,
    action: &str,
    service: &str,
    command: &str,
) -> ApplyResult<PathBuf> {
    let act = InitAction::parse(action).map_err(ApplyError::InitRule)?;
    // El nombre del servicio se vuelve un nombre de archivo: no debe escapar del dir.
    if service.is_empty() || service.contains('/') || service.contains("..") {
        return Err(ApplyError::InitRule(format!(
            "nombre de servicio inválido: '{service}'"
        )));
    }
    let rule_path = rules_dir.join(format!("{service}.rule"));

    if !act.materializes() {
        // disable/stop: retiramos la regla si existe. No es error que falte.
        match std::fs::remove_file(&rule_path) {
            Ok(()) => {}
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(e) => return Err(ApplyError::Io(e)),
        }
        return Ok(rule_path);
    }

    if command.is_empty() {
        return Err(ApplyError::InitRule(format!(
            "init_rule '{action} {service}': command vacío"
        )));
    }
    let body = toml::to_string(&InitRuleFile { service, action, command })
        .map_err(|e| ApplyError::InitRule(format!("serializando regla: {e}")))?;
    if let Some(parent) = rule_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&rule_path, body)?;
    Ok(rule_path)
}

/// Para el caller que ya quiere chequear el hash sin escribir nada (p. ej. `swm verify`).
pub fn verify_content_hash(content_b64: &str, content_hash: &str) -> ApplyResult<()> {
    let bytes = STANDARD
        .decode(content_b64.as_bytes())
        .map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?;
    let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]);
    let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
    let actual_hex = actual.as_str().strip_prefix("b3:").unwrap();
    if declared_hex != actual_hex {
        return Err(ApplyError::FileDrop(format!(
            "hash no coincide: declarado={declared_hex}, calculado={actual_hex}",
        )));
    }
    Ok(())
}

/// Conveniencia para `file_drop` cuando el llamador (CLI/test) ya leyó los bytes desde una
/// URL u otro lado: pasa los bytes crudos y se verifica + escribe igual que el caso inline.
pub fn write_filedrop_bytes(
    path: &Path,
    bytes: &[u8],
    content_hash: &str,
) -> ApplyResult<()> {
    let actual = ArtifactHash::of_inputs(&[bytes]);
    let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
    let actual_hex = actual.as_str().strip_prefix("b3:").unwrap();
    if declared_hex != actual_hex {
        return Err(ApplyError::FileDrop(format!(
            "hash no coincide: declarado={declared_hex}, calculado={actual_hex}",
        )));
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, bytes)?;
    Ok(())
}

/// Re-rootea una ruta absoluta del `.swm` bajo un prefix. `/etc/foo` con prefix
/// `/tmp/overlay/etc` → `/tmp/overlay/etc/etc/foo`. Para overlay real, prefix es vacío y
/// devuelve la propia ruta.
pub fn rebase_path(path: &str, prefix: Option<&Path>) -> PathBuf {
    let p = Path::new(path);
    let rel = p.strip_prefix("/").unwrap_or(p);
    match prefix {
        Some(pre) => pre.join(rel),
        None => p.to_path_buf(),
    }
}

/// Un hunk del inline_diff: el bloque de líneas a quitar (sin el prefijo `- `) y el bloque
/// a añadir (sin `+ `). Cualquiera puede ser vacío, pero no ambos a la vez.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Hunk {
    remove: Vec<String>,
    add: Vec<String>,
}

fn parse_hunks(inline_diff: &str) -> ApplyResult<Vec<Hunk>> {
    let mut hunks = Vec::new();
    let mut cur = Hunk { remove: Vec::new(), add: Vec::new() };
    let mut seen_add = false;

    for raw in inline_diff.lines() {
        if let Some(rest) = raw.strip_prefix("- ") {
            // Un `-` después de `+` cierra el hunk actual.
            if seen_add {
                hunks.push(std::mem::replace(
                    &mut cur,
                    Hunk { remove: Vec::new(), add: Vec::new() },
                ));
                seen_add = false;
            }
            cur.remove.push(rest.to_string());
        } else if let Some(rest) = raw.strip_prefix("+ ") {
            seen_add = true;
            cur.add.push(rest.to_string());
        } else {
            // Línea que no es ni `- ` ni `+ `: separador. Cierra el hunk en curso si lo hay.
            if !cur.remove.is_empty() || !cur.add.is_empty() {
                hunks.push(std::mem::replace(
                    &mut cur,
                    Hunk { remove: Vec::new(), add: Vec::new() },
                ));
                seen_add = false;
            }
        }
    }
    if !cur.remove.is_empty() || !cur.add.is_empty() {
        hunks.push(cur);
    }
    Ok(hunks)
}

fn apply_hunk(content: &str, hunk: &Hunk) -> Result<String, String> {
    // Hunk vacío (no debería ocurrir, parse lo filtra) → no-op.
    if hunk.remove.is_empty() && hunk.add.is_empty() {
        return Ok(content.to_string());
    }
    // Hunk de pura inserción: sin remove no sabemos dónde insertar.
    if hunk.remove.is_empty() {
        return Err(
            "hunk sin líneas '- ' (no sabemos dónde insertar); usa '- ' como anclaje"
                .to_string(),
        );
    }
    // Buscar el bloque remove como secuencia EXACTA de líneas en el archivo. Trabajamos
    // a nivel de línea (no de bytes) para que la coincidencia no se rompa por finales de
    // línea o por sub-cadenas accidentales dentro de líneas más largas.
    let file_lines: Vec<&str> = content.split('\n').collect();
    let n = hunk.remove.len();

    let mut matches = Vec::new();
    if file_lines.len() >= n {
        for i in 0..=(file_lines.len() - n) {
            if (0..n).all(|k| file_lines[i + k] == hunk.remove[k]) {
                matches.push(i);
            }
        }
    }
    match matches.len() {
        0 => Err(format!(
            "bloque a eliminar no aparece en el archivo: primeras líneas = [{}]",
            hunk.remove.first().cloned().unwrap_or_default()
        )),
        1 => {
            let i = matches[0];
            let mut new_lines: Vec<&str> = Vec::with_capacity(file_lines.len() - n + hunk.add.len());
            new_lines.extend_from_slice(&file_lines[..i]);
            for a in &hunk.add {
                new_lines.push(a.as_str());
            }
            new_lines.extend_from_slice(&file_lines[i + n..]);
            Ok(new_lines.join("\n"))
        }
        m => Err(format!(
            "bloque a eliminar aparece {m} veces: el hunk es ambiguo. Añade más contexto."
        )),
    }
}

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

    #[test]
    fn parse_simple_hunk() {
        let d = "- DHCP=yes\n+ IP=192.168.1.100\n";
        let hs = parse_hunks(d).unwrap();
        assert_eq!(hs.len(), 1);
        assert_eq!(hs[0].remove, vec!["DHCP=yes"]);
        assert_eq!(hs[0].add, vec!["IP=192.168.1.100"]);
    }

    #[test]
    fn parse_multi_hunk_separated_by_blank() {
        let d = "- a\n+ A\n\n- b\n+ B\n";
        let hs = parse_hunks(d).unwrap();
        assert_eq!(hs.len(), 2);
        assert_eq!(hs[0].remove, vec!["a"]);
        assert_eq!(hs[1].add, vec!["B"]);
    }

    #[test]
    fn parse_multi_hunk_separated_by_back_to_dash() {
        // Sin línea en blanco entre hunks: en cuanto vemos un `-` después de un `+`, cierra.
        let d = "- a\n+ A\n- b\n+ B\n";
        let hs = parse_hunks(d).unwrap();
        assert_eq!(hs.len(), 2);
        assert_eq!(hs[1].remove, vec!["b"]);
        assert_eq!(hs[1].add, vec!["B"]);
    }

    #[test]
    fn apply_replace_single_line() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("net.conf");
        std::fs::write(&f, "USE=eth0\nDHCP=yes\nMTU=1500\n").unwrap();
        apply_config_edit(&f, "- DHCP=yes\n+ IP=192.168.1.100\n").unwrap();
        let got = std::fs::read_to_string(&f).unwrap();
        assert_eq!(got, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n");
    }

    #[test]
    fn apply_replace_multi_line_block() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("conf");
        std::fs::write(
            &f,
            "[net]\nUSE=eth0\nDHCP=yes\nMTU=1500\n[end]\n",
        )
        .unwrap();
        let diff = "- USE=eth0\n- DHCP=yes\n+ USE=eth1\n+ STATIC=1\n+ IP=10.0.0.1\n";
        apply_config_edit(&f, diff).unwrap();
        let got = std::fs::read_to_string(&f).unwrap();
        assert_eq!(
            got,
            "[net]\nUSE=eth1\nSTATIC=1\nIP=10.0.0.1\nMTU=1500\n[end]\n"
        );
    }

    #[test]
    fn apply_pure_deletion() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("conf");
        std::fs::write(&f, "keep\nDROP_ME\nkeep2\n").unwrap();
        apply_config_edit(&f, "- DROP_ME\n").unwrap();
        let got = std::fs::read_to_string(&f).unwrap();
        assert_eq!(got, "keep\nkeep2\n");
    }

    #[test]
    fn apply_fails_when_block_not_found() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("conf");
        std::fs::write(&f, "A\nB\nC\n").unwrap();
        let err = apply_config_edit(&f, "- NO-EXISTE\n+ X\n")
            .unwrap_err()
            .to_string();
        assert!(err.contains("no aparece"), "{err}");
    }

    #[test]
    fn apply_fails_when_block_ambiguous() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("conf");
        std::fs::write(&f, "X=1\nX=1\n").unwrap();
        let err = apply_config_edit(&f, "- X=1\n+ X=2\n").unwrap_err().to_string();
        assert!(err.contains("ambiguo"), "{err}");
    }

    #[test]
    fn apply_pure_insertion_rejected() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("conf");
        std::fs::write(&f, "A\n").unwrap();
        let err = apply_config_edit(&f, "+ extra\n").unwrap_err().to_string();
        assert!(err.contains("anclaje"), "{err}");
    }

    #[test]
    fn file_drop_roundtrip() {
        let d = tempfile::tempdir().unwrap();
        let path = d.path().join("var/lib/x/data");
        let payload = b"hola hammer\n";
        let b64 = STANDARD.encode(payload);
        let h = ArtifactHash::of_inputs(&[payload.as_slice()]);
        apply_file_drop(&path, &b64, h.as_str()).unwrap();
        let got = std::fs::read(&path).unwrap();
        assert_eq!(got, payload);
    }

    #[test]
    fn file_drop_accepts_hex_without_prefix() {
        let d = tempfile::tempdir().unwrap();
        let path = d.path().join("x");
        let payload = b"abc";
        let b64 = STANDARD.encode(payload);
        let h = ArtifactHash::of_inputs(&[payload.as_slice()]);
        let hex = h.as_str().strip_prefix("b3:").unwrap().to_string();
        apply_file_drop(&path, &b64, &hex).unwrap();
    }

    #[test]
    fn file_drop_rejects_wrong_hash() {
        let d = tempfile::tempdir().unwrap();
        let path = d.path().join("x");
        let b64 = STANDARD.encode(b"abc");
        let err = apply_file_drop(&path, &b64, "b3:deadbeef").unwrap_err().to_string();
        assert!(err.contains("no coincide"), "{err}");
        assert!(!path.exists(), "no debe haberse escrito si el hash falla");
    }

    #[test]
    fn file_drop_rejects_invalid_b64() {
        let d = tempfile::tempdir().unwrap();
        let path = d.path().join("x");
        let err = apply_file_drop(&path, "not base64!!!", "b3:00").unwrap_err().to_string();
        assert!(err.contains("base64"), "{err}");
    }

    #[test]
    fn rebase_with_prefix() {
        let r = rebase_path("/etc/foo", Some(Path::new("/tmp/overlay")));
        assert_eq!(r, PathBuf::from("/tmp/overlay/etc/foo"));
        let r = rebase_path("/etc/foo", None);
        assert_eq!(r, PathBuf::from("/etc/foo"));
    }

    #[test]
    fn init_rule_enable_escribe_regla() {
        let d = tempfile::tempdir().unwrap();
        let dir = d.path().join("etc/hammer/init.d");
        let p = apply_init_rule(&dir, "enable", "nginx", "/usr/sbin/nginx -g 'daemon off;'").unwrap();
        assert_eq!(p, dir.join("nginx.rule"));
        let body = std::fs::read_to_string(&p).unwrap();
        assert!(body.contains("service = \"nginx\""), "{body}");
        assert!(body.contains("action = \"enable\""), "{body}");
        assert!(body.contains("daemon off"), "{body}");
    }

    #[test]
    fn init_rule_disable_retira_la_regla() {
        let d = tempfile::tempdir().unwrap();
        let dir = d.path().join("init.d");
        let p = apply_init_rule(&dir, "enable", "foo", "/bin/foo").unwrap();
        assert!(p.exists());
        // disable borra; y es idempotente (segunda vez no falla aunque ya no esté).
        apply_init_rule(&dir, "disable", "foo", "/bin/foo").unwrap();
        assert!(!p.exists(), "disable debió borrar la regla");
        apply_init_rule(&dir, "stop", "foo", "/bin/foo").unwrap();
    }

    #[test]
    fn init_rule_rechaza_accion_y_servicio_invalidos() {
        let d = tempfile::tempdir().unwrap();
        let dir = d.path().join("init.d");
        let e1 = apply_init_rule(&dir, "frobnicate", "x", "/bin/x").unwrap_err().to_string();
        assert!(e1.contains("acción desconocida"), "{e1}");
        let e2 = apply_init_rule(&dir, "enable", "../escape", "/bin/x").unwrap_err().to_string();
        assert!(e2.contains("servicio inválido"), "{e2}");
        let e3 = apply_init_rule(&dir, "enable", "x", "").unwrap_err().to_string();
        assert!(e3.contains("command vacío"), "{e3}");
    }
}
