//! El diario de mutaciones. Ver `docs/05-journal.md`.
//!
//! Formato: archivo único de **JSON-líneas append-only** (`mutations.jsonl`). Cada línea es
//! un `MutationEvent` autónomo; `cat` / `grep` / `jq` los procesan sin parseo extra.
//!
//! Esta crate es **lógica pura**: nada de fanotify ni de mounts. El watcher real vive en
//! `hammerd` y empuja eventos aquí vía `Journal::record`. El hook desde
//! `hammer_overlay::commit` también empuja por aquí.
//!
//! Diseño rationale (SDD 05):
//! - El diario no bloquea acciones; sólo observa. Pero también acepta eventos "trazables"
//!   (de un `hydrate` o `commit`) que llegan con `actor.source = HammerHydrate { artifact }`.
//! - Las "mutaciones opacas" (pisada manual sin rastro de artefacto) llegan con
//!   `actor.source = External { pid, uid }`. Eso permite que `hammer export` distinga
//!   replays reproducibles de cambios que sólo se pueden compartir como warnings.

use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

/// Operación sobre el FHS. Ver SDD 05 §2 — la lista evolucionará con los eventos que el
/// watcher fanotify aprenda a distinguir.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MutationOp {
    /// Archivo nuevo donde antes no había nada.
    Create,
    /// Pisado: el archivo existía y fue sustituido (mismo path, distinto contenido o inode).
    Replace,
    /// Edición en sitio: el archivo cambió sin que cambiara el path (típico de configs en /etc).
    Edit,
    /// Borrado.
    Delete,
}

impl MutationOp {
    pub fn as_str(&self) -> &'static str {
        match self {
            MutationOp::Create => "create",
            MutationOp::Replace => "replace",
            MutationOp::Edit => "edit",
            MutationOp::Delete => "delete",
        }
    }
}

/// Quién originó la mutación. Distinguir trazable vs opaca es lo que más tarde permite
/// que `hammer export` produzca un `.swm` reproducible (artefactos) más una lista de
/// warnings (pisadas manuales).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Source {
    /// Vino de un `hammer hydrate <artifact>` — la mutación es replay-able a partir del hash.
    HammerHydrate { artifact: String },
    /// Vino de un `hammer commit <overlay>` — origen indirecto: el `actor` fue el overlay,
    /// y `artifact` es opcional (puede no haber, si la promoción fue de un edit manual).
    HammerCommit {
        overlay: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        artifact: Option<String>,
    },
    /// Pisada manual fuera del flujo de hammer. La grabamos sin juzgar — el SDD insiste en
    /// que es información, no error.
    External,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Actor {
    pub source: Source,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pid: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uid: Option<u32>,
}

/// Un evento atómico — una línea del diario.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationEvent {
    /// RFC 3339 UTC. El watcher fija el timestamp en el momento de la observación.
    pub ts: String,
    pub op: MutationOp,
    pub path: PathBuf,
    pub by: Actor,
    /// Opcional: hash blake3 del contenido del archivo tras la mutación. Permite a `export`
    /// detectar mutaciones idempotentes (mismo contenido tras N replaces → un sólo evento).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content_hash: Option<String>,
    /// Nota libre (humana). Útil para que la IA explique su intención.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
}

/// Handle del diario: una ruta a `mutations.jsonl`. Las operaciones son thread-safe en el
/// sentido de que cada `record` abre + append + cierra; suficiente para el throughput
/// esperado (cambios humanos, no log-de-tracing).
#[derive(Debug, Clone)]
pub struct Journal {
    pub dir: 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("journal: {0}")]
    Other(String),
}

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

impl Journal {
    /// Abre (creando el directorio si hace falta) un diario bajo `dir`. El archivo
    /// `mutations.jsonl` se crea en el primer `record`.
    pub fn open(dir: impl Into<PathBuf>) -> Result<Self> {
        let dir = dir.into();
        std::fs::create_dir_all(&dir)?;
        Ok(Self { dir })
    }

    pub fn file_path(&self) -> PathBuf {
        self.dir.join("mutations.jsonl")
    }

    /// Último evento registrado para `path` (o `None` si nunca apareció). Naïve: relee el
    /// diario; suficiente para la cadencia humana de Fase 3 (cuando crezca, un índice por
    /// path lo acelera).
    pub fn last_for_path(&self, path: &std::path::Path) -> Result<Option<MutationEvent>> {
        let all = self.read_all()?;
        Ok(all.into_iter().rev().find(|e| e.path == path))
    }

    /// Como [`record`], pero **de-dup idempotente**: si la mutación deja el archivo en el
    /// mismo estado que el último evento del diario para ese path, no la registra. Devuelve
    /// `true` si escribió un evento, `false` si lo consideró redundante.
    ///
    /// Dos casos de redundancia (ver SDD 05 — el diario es información, no ruido):
    /// 1. **Contenido idéntico:** el `content_hash` nuevo coincide con el último de ese path
    ///    (p. ej. un editor que reescribe el archivo sin cambiarlo, o un replay del mismo
    ///    artefacto).
    /// 2. **Borrado repetido:** un `Delete` sobre un path cuyo último evento ya era `Delete`.
    pub fn record_dedup(&self, ev: &MutationEvent) -> Result<bool> {
        if let Some(last) = self.last_for_path(&ev.path)? {
            let idempotent_content = matches!(
                (&ev.content_hash, &last.content_hash),
                (Some(a), Some(b)) if a == b
            );
            let repeated_delete =
                ev.op == MutationOp::Delete && last.op == MutationOp::Delete;
            if idempotent_content || repeated_delete {
                tracing::debug!(path = %ev.path.display(), "journal: evento idempotente, omitido");
                return Ok(false);
            }
        }
        self.record(ev)?;
        Ok(true)
    }

    /// Append-only. Una línea JSON + `\n`. Maneja la creación del archivo si no existe.
    pub fn record(&self, ev: &MutationEvent) -> Result<()> {
        let line = serde_json::to_string(ev)?;
        let mut f = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(self.file_path())?;
        f.write_all(line.as_bytes())?;
        f.write_all(b"\n")?;
        f.sync_data()?;
        Ok(())
    }

    /// Lee todos los eventos en orden de aparición. Líneas vacías o malformadas se loguean
    /// y se saltan — el diario es append-only pero queremos resilencia a truncados de
    /// emergencia (kernel-panic en medio de una escritura).
    pub fn read_all(&self) -> Result<Vec<MutationEvent>> {
        let path = self.file_path();
        if !path.is_file() {
            return Ok(Vec::new());
        }
        let f = std::fs::File::open(&path)?;
        let rdr = BufReader::new(f);
        let mut out = Vec::new();
        for (i, line) in rdr.lines().enumerate() {
            let line = line?;
            if line.trim().is_empty() {
                continue;
            }
            match serde_json::from_str::<MutationEvent>(&line) {
                Ok(ev) => out.push(ev),
                Err(e) => {
                    tracing::warn!(line = i + 1, error = %e, "journal: línea malformada, saltando");
                }
            }
        }
        Ok(out)
    }

    /// Lee los últimos `n` eventos sin cargar todo el archivo. Implementación naïve por
    /// ahora (delega a `read_all` y trunca); cuando el archivo crezca añadiremos un índice
    /// por bloques.
    pub fn tail(&self, n: usize) -> Result<Vec<MutationEvent>> {
        let mut all = self.read_all()?;
        if all.len() > n {
            all.drain(..(all.len() - n));
        }
        Ok(all)
    }

    /// Devuelve el offset en bytes del fin de archivo actual. Útil para implementar un
    /// "follow" en CLI: tras un read inicial, hacemos polling desde este offset.
    pub fn end_offset(&self) -> Result<u64> {
        let path = self.file_path();
        if !path.is_file() {
            return Ok(0);
        }
        let m = std::fs::metadata(&path)?;
        Ok(m.len())
    }

    /// Lee eventos desde un offset en bytes hasta el final actual. Devuelve los eventos y el
    /// nuevo offset (para usar en la siguiente iteración del follow).
    pub fn read_from_offset(&self, offset: u64) -> Result<(Vec<MutationEvent>, u64)> {
        let path = self.file_path();
        if !path.is_file() {
            return Ok((Vec::new(), 0));
        }
        let mut f = std::fs::File::open(&path)?;
        f.seek(SeekFrom::Start(offset))?;
        let mut buf = String::new();
        f.read_to_string(&mut buf)?;
        let new_offset = offset + buf.len() as u64;
        let mut out = Vec::new();
        for line in buf.lines() {
            if line.trim().is_empty() {
                continue;
            }
            match serde_json::from_str::<MutationEvent>(line) {
                Ok(ev) => out.push(ev),
                Err(e) => tracing::warn!(error = %e, "journal: línea malformada en tail"),
            }
        }
        Ok((out, new_offset))
    }
}

/// Hash BLAKE3 del contenido de un archivo, con prefijo `b3:`. Es un blake3 **plano** del
/// contenido (lo que daría `b3sum`), distinto del `ArtifactHash::of_inputs` de hammer-core
/// (que prefija longitudes para identidad de artefactos). Aquí queremos "¿el archivo cambió?".
pub fn content_hash_of(bytes: &[u8]) -> String {
    format!("b3:{}", blake3::hash(bytes).to_hex())
}

/// Hashea el contenido de `path` tras una mutación. Devuelve `None` si el archivo ya no existe
/// (p. ej. fue borrado, o es lo que el watcher ve como `... (deleted)`); propaga otros errores
/// de IO.
pub fn hash_file(path: &std::path::Path) -> Result<Option<String>> {
    match std::fs::read(path) {
        Ok(bytes) => Ok(Some(content_hash_of(&bytes))),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e.into()),
    }
}

/// Construye un timestamp RFC 3339 UTC del instante actual usando std-only (sin chrono).
pub fn now_rfc3339() -> String {
    use std::time::SystemTime;
    let d = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap_or_default();
    let secs = d.as_secs() as i64;
    let nanos = d.subsec_nanos();
    let (year, month, day, hour, minute, second) = unix_to_ymdhms(secs);
    format!(
        "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{:09}Z",
        nanos
    )
}

/// Conversión epoch → (year, month, day, hour, minute, second) UTC. Algoritmo de Howard
/// Hinnant (`civil_from_days`), válido para todo rango representable por i64.
fn unix_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
    let day_secs = 86_400i64;
    let mut days = secs.div_euclid(day_secs);
    let time_of_day = secs.rem_euclid(day_secs) as u32;
    let hour = time_of_day / 3600;
    let minute = (time_of_day / 60) % 60;
    let second = time_of_day % 60;

    days += 719_468; // shift al epoch del algoritmo (0000-03-01)
    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
    let doe = (days - era * 146_097) as u64; // [0, 146_096]
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
    let year = (y + if month <= 2 { 1 } else { 0 }) as i32;

    (year, month, day, hour, minute, second)
}

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

    fn ev(op: MutationOp, path: &str) -> MutationEvent {
        MutationEvent {
            ts: now_rfc3339(),
            op,
            path: PathBuf::from(path),
            by: Actor {
                source: Source::External,
                pid: Some(1234),
                uid: Some(1000),
            },
            content_hash: None,
            note: None,
        }
    }

    #[test]
    fn record_and_read_roundtrip() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        j.record(&ev(MutationOp::Replace, "/bin/grep")).unwrap();
        j.record(&ev(MutationOp::Edit, "/etc/hosts")).unwrap();
        let read = j.read_all().unwrap();
        assert_eq!(read.len(), 2);
        assert_eq!(read[0].op, MutationOp::Replace);
        assert_eq!(read[0].path, PathBuf::from("/bin/grep"));
        assert_eq!(read[1].op, MutationOp::Edit);
    }

    #[test]
    fn empty_journal_reads_empty() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        assert!(j.read_all().unwrap().is_empty());
        assert_eq!(j.end_offset().unwrap(), 0);
    }

    #[test]
    fn tail_returns_last_n() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        for i in 0..5 {
            j.record(&ev(MutationOp::Create, &format!("/etc/{i}"))).unwrap();
        }
        let last = j.tail(2).unwrap();
        assert_eq!(last.len(), 2);
        assert_eq!(last[0].path, PathBuf::from("/etc/3"));
        assert_eq!(last[1].path, PathBuf::from("/etc/4"));
    }

    #[test]
    fn follow_via_offset_yields_new_events_only() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        j.record(&ev(MutationOp::Create, "/etc/a")).unwrap();
        let cursor = j.end_offset().unwrap();
        j.record(&ev(MutationOp::Create, "/etc/b")).unwrap();
        j.record(&ev(MutationOp::Create, "/etc/c")).unwrap();
        let (news, new_cursor) = j.read_from_offset(cursor).unwrap();
        assert_eq!(news.len(), 2);
        assert_eq!(news[0].path, PathBuf::from("/etc/b"));
        assert_eq!(news[1].path, PathBuf::from("/etc/c"));
        assert_eq!(new_cursor, j.end_offset().unwrap());
        // Una segunda llamada desde el nuevo cursor devuelve vacío.
        let (more, _) = j.read_from_offset(new_cursor).unwrap();
        assert!(more.is_empty());
    }

    #[test]
    fn malformed_line_is_skipped_not_fatal() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        j.record(&ev(MutationOp::Create, "/etc/a")).unwrap();
        // Plantamos basura en medio (kernel panic mid-write).
        let mut f = std::fs::OpenOptions::new().append(true).open(j.file_path()).unwrap();
        f.write_all(b"{esto no es JSON valido\n").unwrap();
        f.write_all(b"\n").unwrap(); // línea vacía
        drop(f);
        j.record(&ev(MutationOp::Create, "/etc/b")).unwrap();
        let read = j.read_all().unwrap();
        assert_eq!(read.len(), 2, "líneas válidas se preservan");
    }

    fn ev_hash(op: MutationOp, path: &str, hash: Option<&str>) -> MutationEvent {
        let mut e = ev(op, path);
        e.content_hash = hash.map(|s| s.to_string());
        e
    }

    #[test]
    fn content_hash_is_plain_blake3_with_prefix() {
        let h = content_hash_of(b"hola hammer\n");
        assert!(h.starts_with("b3:"));
        // Determinista y sensible al contenido.
        assert_eq!(h, content_hash_of(b"hola hammer\n"));
        assert_ne!(h, content_hash_of(b"otra cosa"));
    }

    #[test]
    fn hash_file_some_and_none() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("x");
        std::fs::write(&f, b"abc").unwrap();
        assert_eq!(hash_file(&f).unwrap(), Some(content_hash_of(b"abc")));
        assert_eq!(hash_file(&d.path().join("noexiste")).unwrap(), None);
    }

    #[test]
    fn dedup_skips_identical_content() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        let h = content_hash_of(b"v1");
        // Primer evento: se registra.
        assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap());
        // Mismo contenido otra vez: idempotente, se omite.
        assert!(!j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap());
        // Contenido distinto: se registra.
        let h2 = content_hash_of(b"v2");
        assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h2))).unwrap());
        // Vuelta a v1 (no es el ÚLTIMO estado): se registra (no colapsamos histórico).
        assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap());

        let all = j.read_all().unwrap();
        assert_eq!(all.len(), 3, "v1, v2, v1 — sólo se omitió el v1→v1 consecutivo");
    }

    #[test]
    fn dedup_is_per_path() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        let h = content_hash_of(b"same");
        assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/a", Some(&h))).unwrap());
        // Mismo hash pero OTRO path: no es redundante.
        assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/b", Some(&h))).unwrap());
        assert_eq!(j.read_all().unwrap().len(), 2);
    }

    #[test]
    fn dedup_collapses_repeated_delete() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        assert!(j.record_dedup(&ev_hash(MutationOp::Delete, "/etc/x", None)).unwrap());
        assert!(!j.record_dedup(&ev_hash(MutationOp::Delete, "/etc/x", None)).unwrap());
        assert_eq!(j.read_all().unwrap().len(), 1);
    }

    #[test]
    fn dedup_does_not_collapse_hashless_replaces() {
        // Dos Replace sin content_hash NO se consideran idempotentes (no sabemos el contenido).
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/etc/x", None)).unwrap());
        assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/etc/x", None)).unwrap());
        assert_eq!(j.read_all().unwrap().len(), 2);
    }

    #[test]
    fn last_for_path_finds_latest() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        j.record(&ev(MutationOp::Create, "/etc/a")).unwrap();
        j.record(&ev(MutationOp::Replace, "/etc/a")).unwrap();
        j.record(&ev(MutationOp::Edit, "/etc/b")).unwrap();
        assert_eq!(j.last_for_path(&PathBuf::from("/etc/a")).unwrap().unwrap().op, MutationOp::Replace);
        assert_eq!(j.last_for_path(&PathBuf::from("/etc/b")).unwrap().unwrap().op, MutationOp::Edit);
        assert!(j.last_for_path(&PathBuf::from("/etc/z")).unwrap().is_none());
    }

    #[test]
    fn source_serializes_with_tag() {
        let e = ev(MutationOp::Replace, "/bin/grep");
        let j = serde_json::to_string(&e).unwrap();
        assert!(j.contains(r#""kind":"external""#), "got {j}");

        let mut e2 = e.clone();
        e2.by.source = Source::HammerHydrate {
            artifact: "b3:abc".into(),
        };
        let j2 = serde_json::to_string(&e2).unwrap();
        assert!(j2.contains(r#""kind":"hammer-hydrate""#), "got {j2}");
        assert!(j2.contains(r#""artifact":"b3:abc""#), "got {j2}");
    }

    #[test]
    fn rfc3339_format_basics() {
        let ts = now_rfc3339();
        // Verifica forma `YYYY-MM-DDThh:mm:ss.nnnnnnnnnZ` minimal.
        assert!(ts.len() >= 29);
        assert_eq!(&ts[4..5], "-");
        assert_eq!(&ts[7..8], "-");
        assert_eq!(&ts[10..11], "T");
        assert_eq!(&ts[13..14], ":");
        assert_eq!(&ts[16..17], ":");
        assert!(ts.ends_with('Z'));
        // Año razonable (2026 actualmente; este test sólo verifica que no salimos del rango
        // 1970..3000).
        let year: i32 = ts[..4].parse().unwrap();
        assert!((1970..3000).contains(&year), "year = {year}");
    }
}
