//! Boot-event log — "who installed what, when, from where".
//!
//! Each `/boot/<entry>.ipxe` fetch that actually goes on to serve a boot
//! script lands an entry here. The log is bounded in memory (newest-first,
//! ring-buffered at [`BootLog::CAP`]) and is mirrored append-only to
//! `<work_dir>/boot_log.jsonl`. Mirrors `HostBindings`'s "in-memory is
//! authoritative, disk is a cache" policy — a corrupt log file should
//! never block PXE for the network.
//!
//! We deliberately don't push these onto the `LogBus` (the operator
//! terminal stream). The terminal already shows the http traces; the
//! Host log is a curated, persistent, easy-to-scan view of "what got
//! imaged on what hardware" and conflating the two would be noisy.

use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootEvent {
    #[serde(with = "time::serde::rfc3339")]
    pub timestamp: OffsetDateTime,
    /// Lowercase, colon-separated. `None` when iPXE didn't supply
    /// `?mac=${mac}` in the chain URL (older bookmarks, custom scripts).
    pub mac: Option<String>,
    /// Connecting peer's IP — taken from the TCP socket when available
    /// (PXE clients connect direct, no reverse proxy), and falls back to
    /// `X-Forwarded-For` for the rare case where one is present.
    pub ip: Option<IpAddr>,
    /// `BootEntry::id` — the same id used in `/boot/<id>.ipxe`.
    pub target_id: String,
    /// Human-friendly label: the ISO's filename / volume label / entry
    /// title. Pre-resolved at log time so the UI can render without
    /// joining against the ISO store (and so "what image was installed?"
    /// survives the operator deleting the ISO later).
    pub target_title: String,
}

/// In-memory ring + disk-backed append log of boot events. Cheap to
/// clone; the inner state is `Arc<RwLock<_>>`.
#[derive(Debug, Clone)]
pub struct BootLog {
    path: Arc<PathBuf>,
    inner: Arc<RwLock<VecDeque<BootEvent>>>,
}

impl BootLog {
    /// Newest entries we retain in memory. Past this, the oldest gets
    /// evicted — the on-disk JSONL keeps the full history for offline
    /// inspection. 500 covers a typical install-day's worth without
    /// turning the Hosts tab into a wall of text.
    pub const CAP: usize = 500;

    /// Load up to `CAP` newest events from `<work_dir>/boot_log.jsonl`,
    /// or start empty if the file is missing / unreadable.
    #[must_use]
    pub fn load_or_default(work_dir: &std::path::Path) -> Self {
        let path = work_dir.join("boot_log.jsonl");
        let mut events = VecDeque::with_capacity(Self::CAP);
        if let Ok(text) = std::fs::read_to_string(&path) {
            for line in text.lines() {
                if line.trim().is_empty() {
                    continue;
                }
                match serde_json::from_str::<BootEvent>(line) {
                    Ok(ev) => {
                        if events.len() == Self::CAP {
                            events.pop_front();
                        }
                        events.push_back(ev);
                    }
                    Err(e) => {
                        tracing::warn!(
                            target: "openpxe::boot_log",
                            "skipping unparseable boot_log line: {e}"
                        );
                    }
                }
            }
        }
        Self {
            path: Arc::new(path),
            inner: Arc::new(RwLock::new(events)),
        }
    }

    /// Append an event. Persistence is best-effort and never blocks the
    /// caller on a failed write (the in-memory copy is the source of
    /// truth for the live UI; the JSONL is just for crash survival).
    pub fn record(&self, ev: &BootEvent) {
        // Push into the ring first so a slow / failing disk doesn't lose
        // events for the live UI.
        {
            let mut g = self.inner.write();
            if g.len() == Self::CAP {
                g.pop_front();
            }
            g.push_back(ev.clone());
        }
        tracing::info!(
            target: "openpxe::boot_log",
            mac = ev.mac.as_deref().unwrap_or("?"),
            ip = ev.ip.map(|i| i.to_string()).as_deref().unwrap_or("?"),
            target = %ev.target_id,
            "boot event"
        );
        // Append to disk. We tolerate write failures — they'd show up as
        // missing entries on the next restart only.
        let mut line = match serde_json::to_string(ev) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(target: "openpxe::boot_log", "serialize boot event: {e}");
                return;
            }
        };
        line.push('\n');
        if let Some(parent) = self.path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        match std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(self.path.as_path())
        {
            Ok(mut f) => {
                if let Err(e) = f.write_all(line.as_bytes()) {
                    tracing::warn!(target: "openpxe::boot_log", "append boot_log.jsonl: {e}");
                }
            }
            Err(e) => {
                tracing::warn!(target: "openpxe::boot_log", "open boot_log.jsonl: {e}");
            }
        }
    }

    /// Newest-first snapshot, up to `CAP` entries.
    #[must_use]
    pub fn list(&self) -> Vec<BootEvent> {
        let g = self.inner.read();
        // VecDeque preserves insertion order; reverse so newest is first.
        g.iter().rev().cloned().collect()
    }

    #[must_use]
    pub fn len(&self) -> usize {
        self.inner.read().len()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Wipe in-memory + the on-disk file. Used by the `terminal clear`
    /// equivalent or future operator action; not currently wired to a UI
    /// button but exposed for completeness.
    pub fn clear(&self) {
        self.inner.write().clear();
        let _ = std::fs::remove_file(self.path.as_path());
    }
}

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

    fn ev(target: &str, mac: Option<&str>) -> BootEvent {
        BootEvent {
            timestamp: OffsetDateTime::now_utc(),
            mac: mac.map(str::to_string),
            ip: Some("10.0.0.42".parse().unwrap()),
            target_id: target.into(),
            target_title: format!("{target}.iso"),
        }
    }

    #[test]
    fn record_then_list_is_newest_first() {
        let dir = tempdir().unwrap();
        let log = BootLog::load_or_default(dir.path());
        assert!(log.is_empty());
        log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
        log.record(&ev("beta", Some("aa:bb:cc:00:00:02")));
        let list = log.list();
        assert_eq!(list.len(), 2);
        assert_eq!(list[0].target_id, "beta");
        assert_eq!(list[1].target_id, "alpha");
    }

    #[test]
    fn round_trip_through_disk() {
        let dir = tempdir().unwrap();
        let log = BootLog::load_or_default(dir.path());
        log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
        log.record(&ev("beta", None));
        drop(log);
        let log2 = BootLog::load_or_default(dir.path());
        assert_eq!(log2.len(), 2);
        let list = log2.list();
        assert_eq!(list[0].target_id, "beta");
        assert_eq!(list[1].target_id, "alpha");
        assert!(list[0].mac.is_none());
        assert_eq!(list[1].mac.as_deref(), Some("aa:bb:cc:00:00:01"));
    }

    #[test]
    fn ring_evicts_oldest_past_cap() {
        let dir = tempdir().unwrap();
        let log = BootLog::load_or_default(dir.path());
        for i in 0..(BootLog::CAP + 5) {
            log.record(&ev(&format!("e{i}"), None));
        }
        assert_eq!(log.len(), BootLog::CAP);
        let list = log.list();
        // Newest first; the most recent push is the last index inserted.
        assert_eq!(list[0].target_id, format!("e{}", BootLog::CAP + 4));
        // Oldest in-memory should be the 6th push (0..5 were evicted).
        assert_eq!(list[BootLog::CAP - 1].target_id, "e5");
    }

    #[test]
    fn clear_wipes_memory_and_disk() {
        let dir = tempdir().unwrap();
        let log = BootLog::load_or_default(dir.path());
        log.record(&ev("alpha", None));
        log.clear();
        assert!(log.is_empty());
        let log2 = BootLog::load_or_default(dir.path());
        assert!(log2.is_empty());
    }

    #[test]
    fn corrupt_disk_lines_are_skipped_not_fatal() {
        // Write a file with one valid + one garbage line; loader should
        // surface the valid one and skip the garbage.
        let dir = tempdir().unwrap();
        let path = dir.path().join("boot_log.jsonl");
        let valid = serde_json::to_string(&ev("ok", Some("aa:bb:cc:00:00:09"))).unwrap();
        std::fs::write(&path, format!("{valid}\nNOT_JSON\n{valid}\n")).unwrap();
        let log = BootLog::load_or_default(dir.path());
        assert_eq!(log.len(), 2);
    }
}
