//! The process log: each line goes to stderr *and* a bounded in-memory ring
//! the GUI's Logs tab reads (a windowed run on Windows has no console).
//! Command output is a program's answer, not a background event: not logged.

use std::collections::VecDeque;
use std::io::Write;
use std::sync::{LazyLock, Mutex, MutexGuard};
use std::time::{SystemTime, UNIX_EPOCH};

/// Lines retained before the oldest are dropped.
pub const CAPACITY: usize = 5_000;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
    Info,
    Warn,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
    /// Unix seconds when it was recorded.
    pub at: u64,
    pub level: Level,
    /// Exactly the text written to stderr, prefix included.
    pub text: String,
}

#[macro_export]
macro_rules! log_info {
    ($($arg:tt)*) => {
        $crate::log::record($crate::log::Level::Info, ::std::format!($($arg)*))
    };
}

/// The stored and printed text gains a `Warning: ` prefix; call sites pass
/// the message alone.
#[macro_export]
macro_rules! log_warn {
    ($($arg:tt)*) => {
        $crate::log::record($crate::log::Level::Warn, ::std::format!($($arg)*))
    };
}

/// Write `message` to stderr and to the ring. A failed stderr write is
/// ignored: `eprintln!` *panics* when the handle is unwritable, which on a
/// process launched without stdio would take down a background thread.
pub fn record(level: Level, message: String) {
    let text = match level {
        Level::Warn => format!("Warning: {}", message),
        Level::Info => message,
    };
    // Almost every line names a path, and an escape sequence in one reaches
    // a terminal three ways (stderr, GUI-from-shell, the Logs tab's Copy
    // button). Diagnostics are not data, so they are scrubbed unconditionally.
    // Line breaks are collapsed *first*: `scrub_controls` counts them as
    // controls and would leave U+FFFD where a space belongs.
    let text = if text.contains(['\n', '\r']) {
        text.replace(['\n', '\r'], " ")
    } else {
        text
    };
    let text = crate::textenc::scrub_controls(&text).into_owned();
    let _ = writeln!(std::io::stderr(), "{}", text);
    lock().push(level, text);
}

/// A cap on how many times one *kind* of warning may speak: some failures
/// arrive per-file in the thousands (Windows sharing violations are routine),
/// and each log line costs a global mutex plus an unbuffered stderr write on
/// the walk's hottest path. The first few speak, the rest are counted.
pub struct Throttle {
    limit: u64,
    seen: std::sync::atomic::AtomicU64,
}

impl Throttle {
    pub const fn new(limit: u64) -> Throttle {
        Throttle {
            limit,
            seen: std::sync::atomic::AtomicU64::new(0),
        }
    }

    /// Count one occurrence; answer whether it may be logged individually.
    pub fn allow(&self) -> bool {
        self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < self.limit
    }

    pub fn seen(&self) -> u64 {
        self.seen.load(std::sync::atomic::Ordering::Relaxed)
    }

    /// How many went unlogged — what a summary line should report.
    pub fn suppressed(&self) -> u64 {
        self.seen().saturating_sub(self.limit)
    }

    pub fn reset(&self) {
        self.seen.store(0, std::sync::atomic::Ordering::Relaxed);
    }
}

/// Every retained line, oldest first.
pub fn snapshot() -> Vec<LogLine> {
    lock().lines.iter().cloned().collect()
}

/// Lines recorded since the process started, including ones since dropped.
/// Only ever grows — the cheap "anything new?" poll.
pub fn recorded() -> u64 {
    lock().recorded
}

/// How many lines the ring has evicted since the last [`clear`].
pub fn dropped() -> u64 {
    lock().dropped
}

/// Forget every retained line. [`recorded`] keeps counting.
pub fn clear() {
    lock().clear();
}

static LOG: LazyLock<Mutex<Ring>> = LazyLock::new(|| Mutex::new(Ring::new(CAPACITY)));

/// Logging must not turn one panic into a cascade: a thread that died
/// mid-push would poison the lock and take down every later logger. The
/// worst a poisoned guard can hold is a half-added line.
fn lock() -> MutexGuard<'static, Ring> {
    crate::lock_ok(&LOG)
}

/// The bounded line buffer, split from the global so it can be tested on
/// its own instance.
struct Ring {
    lines: VecDeque<LogLine>,
    capacity: usize,
    recorded: u64,
    dropped: u64,
}

impl Ring {
    fn new(capacity: usize) -> Ring {
        Ring {
            lines: VecDeque::new(),
            capacity: capacity.max(1),
            recorded: 0,
            dropped: 0,
        }
    }

    fn push(&mut self, level: Level, text: String) {
        while self.lines.len() >= self.capacity {
            self.lines.pop_front();
            self.dropped += 1;
        }
        self.lines.push_back(LogLine {
            at: now_unix(),
            level,
            text,
        });
        self.recorded += 1;
    }

    fn clear(&mut self) {
        self.lines.clear();
        self.dropped = 0;
    }
}

/// Seconds since the Unix epoch; a clock set before 1970 yields `0`.
pub fn now_unix() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

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

    fn texts(ring: &Ring) -> Vec<&str> {
        ring.lines.iter().map(|l| l.text.as_str()).collect()
    }

    #[test]
    fn lines_come_back_oldest_first() {
        let mut ring = Ring::new(8);
        ring.push(Level::Info, "one".into());
        ring.push(Level::Warn, "two".into());
        assert_eq!(texts(&ring), vec!["one", "two"]);
        assert_eq!(ring.lines[1].level, Level::Warn);
        assert_eq!(ring.recorded, 2);
        assert_eq!(ring.dropped, 0);
    }

    #[test]
    fn the_oldest_lines_are_dropped_at_capacity() {
        let mut ring = Ring::new(3);
        for i in 0..5 {
            ring.push(Level::Info, format!("line {}", i));
        }
        assert_eq!(
            texts(&ring),
            vec!["line 2", "line 3", "line 4"],
            "only the newest `capacity` lines survive"
        );
        assert_eq!(ring.dropped, 2, "and the count of the lost ones is kept");
        assert_eq!(ring.recorded, 5, "recorded counts everything ever pushed");
    }

    /// A zero capacity would spin the eviction loop forever on first push.
    #[test]
    fn a_zero_capacity_still_holds_one_line() {
        let mut ring = Ring::new(0);
        ring.push(Level::Info, "kept".into());
        assert_eq!(texts(&ring), vec!["kept"]);
    }

    #[test]
    fn clearing_empties_the_ring_but_not_the_total() {
        let mut ring = Ring::new(2);
        for i in 0..4 {
            ring.push(Level::Info, format!("line {}", i));
        }
        ring.clear();
        assert!(ring.lines.is_empty());
        assert_eq!(ring.dropped, 0, "dropped counts against what is shown");
        assert_eq!(ring.recorded, 4, "the running total survives a clear");
    }

    #[test]
    fn a_throttle_lets_the_first_few_through_and_counts_the_rest() {
        let t = Throttle::new(3);
        assert_eq!((t.seen(), t.suppressed()), (0, 0));
        for _ in 0..3 {
            assert!(t.allow(), "the first `limit` occurrences speak");
        }
        for _ in 0..7 {
            assert!(!t.allow(), "the rest are counted only");
        }
        assert_eq!(t.seen(), 10);
        assert_eq!(t.suppressed(), 7);

        t.reset();
        assert_eq!((t.seen(), t.suppressed()), (0, 0));
        assert!(t.allow(), "a reset throttle speaks again");
    }

    #[test]
    fn a_zero_limit_throttle_logs_nothing() {
        let t = Throttle::new(0);
        assert!(!t.allow());
        assert_eq!(t.seen(), 1);
        assert_eq!(t.suppressed(), 1);
    }

    /// Written to tolerate lines from tests running in parallel.
    #[test]
    fn recorded_lines_reach_the_snapshot() {
        let before = recorded();
        crate::log_info!("test-marker info {}", 1);
        crate::log_warn!("test-marker warn {}", 2);
        assert!(recorded() >= before + 2);

        let lines = snapshot();
        let info = lines.iter().find(|l| l.text == "test-marker info 1");
        let warn = lines
            .iter()
            .find(|l| l.text == "Warning: test-marker warn 2");
        assert_eq!(info.map(|l| l.level), Some(Level::Info));
        assert_eq!(
            warn.map(|l| l.level),
            Some(Level::Warn),
            "a warning is stored with the prefix it printed with"
        );
        assert!(info.unwrap().at > 0, "timestamped when recorded");
    }
}
