//! Watcher de mutaciones del FHS. Usa `fanotify` para observar `CLOSE_WRITE` (alguien
//! escribió en un archivo y lo cerró) sobre los directorios del SDD 05 §2.
//!
//! Diseño Fase 3 v1:
//!
//! * Marcamos como `FAN_MARK_ADD | FAN_MARK_ONLYDIR` cada directorio vigilado. La cobertura
//!   por defecto (ver `default_watch_dirs`) cubre el FHS clásico de userland.
//! * Filtro mínimo: ignoramos eventos cuya ruta resuelta cae dentro de un overlay activo
//!   (consultamos `hammer_overlay::status` en cada batch — para Fase 3 el coste es
//!   despreciable; cuando los overlays se vuelvan numerosos cachearemos los paths).
//! * Cada evento se materializa como `MutationEvent` con `source = External` y la `op`
//!   se deriva (`classify_op`): si el fd resuelve a un path borrado (`" (deleted)"`) es
//!   `Delete`; si el diario ya conocía ese path (con un evento previo no-`Delete`) es
//!   `Edit`; en otro caso es `Create` (primera mutación que hammer observa sobre el path).
//!   Nota: `FAN_CLOSE_WRITE` no distingue un `rename`-sobre-destino de una reescritura in
//!   situ, ni reporta el nombre en eventos de directorio — la atribución plena de
//!   `Create`/`Move`/`Delete` vía `FAN_REPORT_DFID_NAME` (con `open_by_handle_at` y
//!   `CAP_DAC_READ_SEARCH`) queda para el track posterior; `nix` 0.30 aún no parsea los
//!   info-records de FID, así que exigiría libc crudo y perdería el fd que hoy hashea el
//!   contenido.
//!
//! Privilegios: `fanotify_init` requiere `CAP_SYS_ADMIN`. Si no lo tenemos, fallamos rápido
//! con un mensaje claro y el daemon sigue arrancando los otros subsistemas.

use std::ffi::OsString;
use std::os::fd::AsFd;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};

use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source};
use nix::fcntl::AT_FDCWD;
use nix::sys::fanotify::{EventFFlags, Fanotify, InitFlags, MarkFlags, MaskFlags};

/// Directorios del FHS que vigilamos por defecto (SDD 05 §2).
pub fn default_watch_dirs() -> Vec<PathBuf> {
    ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/lib", "/usr/lib", "/etc"]
        .into_iter()
        .map(PathBuf::from)
        .collect()
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("fanotify_init falló (¿CAP_SYS_ADMIN?): {0}")]
    Init(nix::errno::Errno),
    #[error("fanotify_mark falló sobre {path}: {err}")]
    Mark { path: PathBuf, err: nix::errno::Errno },
    #[error("read_events: {0}")]
    Read(nix::errno::Errno),
    #[error("journal: {0}")]
    Journal(#[from] hammer_journal::Error),
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
}

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

pub struct Watcher {
    fan: Fanotify,
    journal: Journal,
    watched: Vec<PathBuf>,
    overlay_state_root: Option<PathBuf>,
    /// Si está, cada `MutationEvent` registrado se re-emite al bus de agente como
    /// `Event::Modified` (SDD 07 §3). Sin bus, el watcher actúa como en Fase 3.
    events: Option<crate::events::EventBus>,
}

impl Watcher {
    /// Inicializa el watcher y registra los `dirs` como vigilados.
    ///
    /// `overlay_state_root`, si está presente, se usa para filtrar eventos que ocurren
    /// dentro de un overlay activo (no entran al diario hasta el `commit` del overlay).
    #[allow(dead_code)]
    pub fn start(
        dirs: &[PathBuf],
        journal: Journal,
        overlay_state_root: Option<PathBuf>,
    ) -> Result<Self> {
        Self::start_with_events(dirs, journal, overlay_state_root, None)
    }

    /// Igual que `start`, pero conecta el watcher al bus de eventos del daemon. Cada
    /// mutación registrada se re-emite como `Event::Modified` a las conexiones abiertas.
    pub fn start_with_events(
        dirs: &[PathBuf],
        journal: Journal,
        overlay_state_root: Option<PathBuf>,
        events: Option<crate::events::EventBus>,
    ) -> Result<Self> {
        let fan = Fanotify::init(
            InitFlags::FAN_CLASS_NOTIF | InitFlags::FAN_CLOEXEC,
            EventFFlags::O_RDONLY | EventFFlags::O_CLOEXEC,
        )
        .map_err(Error::Init)?;

        for dir in dirs {
            if !dir.is_dir() {
                tracing::warn!(path = %dir.display(), "watcher: skip — no es directorio");
                continue;
            }
            fan.mark(
                MarkFlags::FAN_MARK_ADD | MarkFlags::FAN_MARK_ONLYDIR,
                MaskFlags::FAN_CLOSE_WRITE | MaskFlags::FAN_EVENT_ON_CHILD,
                AT_FDCWD,
                Some(dir.as_path()),
            )
            .map_err(|err| Error::Mark { path: dir.clone(), err })?;
            tracing::info!(path = %dir.display(), "watcher: vigilando");
        }

        Ok(Self {
            fan,
            journal,
            watched: dirs.to_vec(),
            overlay_state_root,
            events,
        })
    }

    /// Devuelve un fd que se puede usar con `poll`/`epoll` para esperar eventos sin bloquear.
    /// Útil cuando integremos esto con el bus de agente (Fase 5).
    #[allow(dead_code)]
    pub fn fd(&self) -> std::os::fd::BorrowedFd<'_> {
        self.fan.as_fd()
    }

    /// Loop bloqueante. Vuelve sólo si `read_events` falla irrecuperablemente. Se espera
    /// invocarlo en su propio thread.
    pub fn run_forever(&self) -> Result<()> {
        loop {
            self.poll_once()?;
        }
    }

    /// Una iteración del loop. Útil para tests.
    pub fn poll_once(&self) -> Result<usize> {
        let events = self.fan.read_events().map_err(Error::Read)?;
        let overlay_targets = self.overlay_targets();
        let mut recorded = 0;
        for ev in events {
            let pid = ev.pid() as u32;
            let Some(fd) = ev.fd() else { continue };
            let (path, deleted) = match path_of_fd(fd) {
                Ok(p) => p,
                Err(e) => {
                    tracing::warn!(error = %e, "watcher: no pude resolver path del fd, saltando");
                    continue;
                }
            };
            if !path_is_under_any(&path, &self.watched) {
                continue;
            }
            if path_is_under_any(&path, &overlay_targets) {
                tracing::debug!(path = %path.display(), "watcher: ignoro evento bajo overlay activo");
                continue;
            }
            // Hash del contenido tras la mutación: alimenta el de-dup idempotente y, más
            // tarde, la correlación en `export`. Si el archivo se borró (o no podemos leerlo
            // por permiso) seguimos con `None` — el evento sigue siendo información válida.
            let content_hash = if deleted {
                None
            } else {
                match hammer_journal::hash_file(&path) {
                    Ok(h) => h,
                    Err(e) => {
                        tracing::warn!(path = %path.display(), error = %e, "watcher: no pude hashear contenido");
                        None
                    }
                }
            };
            let op = self.classify_op(&path, deleted)?;
            let me = MutationEvent {
                ts: hammer_journal::now_rfc3339(),
                op,
                path,
                by: Actor {
                    source: Source::External,
                    pid: Some(pid),
                    uid: uid_of_pid(pid),
                },
                content_hash,
                note: None,
            };
            // De-dup: una reescritura que deja el archivo idéntico no ensucia el diario ni
            // despierta al bus. Sólo emitimos `Modified` cuando algo cambió de verdad.
            if !self.journal.record_dedup(&me)? {
                continue;
            }
            recorded += 1;
            if let Some(bus) = &self.events {
                bus.publish(&hammer_core::proto::Event::Modified {
                    path: me.path.display().to_string(),
                    op: me.op.as_str().to_string(),
                    ts: me.ts.clone(),
                });
            }
        }
        Ok(recorded)
    }

    /// Deriva la `op` de un `CLOSE_WRITE` a partir del estado del fd y del diario:
    ///
    /// * `deleted` (el fd resuelve a `" (deleted)"`) ⇒ `Delete`.
    /// * el diario ya tiene un evento previo no-`Delete` para el path ⇒ `Edit` (el archivo
    ///   ya era conocido y se reescribió).
    /// * en otro caso ⇒ `Create` — la primera mutación que hammer observa sobre el path, o
    ///   una resurrección tras un `Delete` previo.
    ///
    /// No es una verdad absoluta del FS (un archivo preexistente al daemon, editado por
    /// primera vez, se ve como `Create`); es la divergencia que el diario puede atestiguar.
    fn classify_op(&self, path: &Path, deleted: bool) -> Result<MutationOp> {
        classify_op(&self.journal, path, deleted)
    }

    fn overlay_targets(&self) -> Vec<PathBuf> {
        let Some(root) = &self.overlay_state_root else {
            return Vec::new();
        };
        match hammer_overlay::status(root) {
            Ok(overlays) => overlays
                .into_iter()
                .flat_map(|o| o.mounts.into_iter().map(|m| m.target))
                .collect(),
            Err(e) => {
                tracing::warn!(error = %e, "watcher: status() falló, no filtro overlays");
                Vec::new()
            }
        }
    }
}

/// Resuelve el path real apuntado por un fd procediendo por `/proc/self/fd/<n>`. Devuelve el
/// path destino del symlink y un flag `deleted`: `readlink` añade el sufijo `" (deleted)"`
/// cuando el archivo se desvinculó entre el evento y nuestra resolución. Lo recortamos para
/// que el path entre limpio al diario y propagamos el flag para clasificar la `op` como
/// `Delete`.
fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<(PathBuf, bool)> {
    use std::os::fd::AsRawFd;
    use std::os::unix::ffi::OsStrExt;
    let link = format!("/proc/self/fd/{}", fd.as_raw_fd());
    let target = std::fs::read_link(&link)?;
    const SUFFIX: &[u8] = b" (deleted)";
    let bytes = target.as_os_str().as_bytes();
    if let Some(trimmed) = bytes.strip_suffix(SUFFIX) {
        return Ok((PathBuf::from(std::ffi::OsStr::from_bytes(trimmed)), true));
    }
    Ok((target, false))
}

fn path_is_under_any(p: &Path, roots: &[PathBuf]) -> bool {
    roots.iter().any(|r| p.starts_with(r))
}

/// Deriva la `op` de un `CLOSE_WRITE` (ver [`Watcher::classify_op`]). Libre de estado del
/// fanotify para poder probarla sin `CAP_SYS_ADMIN`.
fn classify_op(journal: &Journal, path: &Path, deleted: bool) -> Result<MutationOp> {
    if deleted {
        return Ok(MutationOp::Delete);
    }
    Ok(match journal.last_for_path(path)? {
        Some(ev) if ev.op != MutationOp::Delete => MutationOp::Edit,
        _ => MutationOp::Create,
    })
}

/// Lee `/proc/<pid>/status` y extrae el UID real. Devuelve `None` si el proceso desapareció.
fn uid_of_pid(pid: u32) -> Option<u32> {
    let bytes = std::fs::read(format!("/proc/{pid}/status")).ok()?;
    let text = OsString::from_vec(bytes);
    let s = text.to_string_lossy();
    for line in s.lines() {
        if let Some(rest) = line.strip_prefix("Uid:") {
            let first = rest.split_whitespace().next()?;
            return first.parse().ok();
        }
    }
    None
}

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

    #[test]
    fn defaults_cover_fhs_userland() {
        let d = default_watch_dirs();
        assert!(d.iter().any(|p| p == Path::new("/bin")));
        assert!(d.iter().any(|p| p == Path::new("/etc")));
        assert!(d.iter().any(|p| p == Path::new("/usr/lib")));
        assert!(!d.iter().any(|p| p == Path::new("/tmp")));
    }

    #[test]
    fn path_filter_under_any() {
        let roots = vec![PathBuf::from("/bin"), PathBuf::from("/etc")];
        assert!(path_is_under_any(Path::new("/bin/grep"), &roots));
        assert!(path_is_under_any(Path::new("/etc/passwd"), &roots));
        assert!(!path_is_under_any(Path::new("/tmp/x"), &roots));
        assert!(!path_is_under_any(Path::new("/binx/y"), &roots));
    }

    fn me(op: MutationOp, path: &str, hash: Option<&str>) -> MutationEvent {
        MutationEvent {
            ts: hammer_journal::now_rfc3339(),
            op,
            path: PathBuf::from(path),
            by: Actor { source: Source::External, pid: Some(1), uid: Some(0) },
            content_hash: hash.map(str::to_string),
            note: None,
        }
    }

    #[test]
    fn classify_op_deleted_is_delete() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        assert_eq!(classify_op(&j, Path::new("/etc/x"), true).unwrap(), MutationOp::Delete);
    }

    #[test]
    fn classify_op_unknown_path_is_create() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        assert_eq!(classify_op(&j, Path::new("/bin/new"), false).unwrap(), MutationOp::Create);
    }

    #[test]
    fn classify_op_known_path_is_edit() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        j.record(&me(MutationOp::Create, "/etc/hosts", Some("b3:aaa"))).unwrap();
        assert_eq!(classify_op(&j, Path::new("/etc/hosts"), false).unwrap(), MutationOp::Edit);
    }

    #[test]
    fn classify_op_resurrection_after_delete_is_create() {
        let d = tempfile::tempdir().unwrap();
        let j = Journal::open(d.path()).unwrap();
        j.record(&me(MutationOp::Create, "/etc/svc", Some("b3:aaa"))).unwrap();
        j.record(&me(MutationOp::Delete, "/etc/svc", None)).unwrap();
        assert_eq!(classify_op(&j, Path::new("/etc/svc"), false).unwrap(), MutationOp::Create);
    }

    #[test]
    fn uid_of_self_matches() {
        let me = std::process::id();
        let uid = uid_of_pid(me).expect("self uid");
        // En entornos normales, comparable con getuid del proceso.
        let expected = unsafe { libc::getuid() };
        assert_eq!(uid, expected);
    }
}
