//
// Syd: rock-solid application kernel
// src/log.rs: Simple logging on standard error using JSON lines
//
// Copyright (c) 2023, 2024, 2025 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

use std::{
    io::Write,
    os::fd::{AsRawFd, BorrowedFd, RawFd},
    time::{SystemTime, UNIX_EPOCH},
};

use hex::DisplayHex;
use nix::{
    errno::Errno,
    unistd::{fdatasync, write, Pid, Uid},
};
use serde_json::{Map, Value};

use crate::{
    config::*,
    fs::{lock_fd, retry_on_eintr, unlock_fd},
    proc::*,
    syslog::{LogLevel, *},
};

// Whether we concluded the output is a TTY.
pub(crate) static LOG_TTY: std::sync::atomic::AtomicBool =
    std::sync::atomic::AtomicBool::new(false);
// Log destination file descriptor.
pub(crate) static LOG_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-42);

/// emerg! logging macro
#[macro_export]
macro_rules! emerg {
    ($($key:literal : $value:expr),+) => {
        if $crate::log_enabled!($crate::syslog::LogLevel::Emergent) {
            let timestamp = $crate::log::now();
            let mut map = serde_json::Map::new();
            $(
                if let Ok(value) = serde_json::to_value($value) {
                    map.insert($key.to_string(), value);
                } else {
                    map.insert($key.to_string(), serde_json::Value::Null);
                }
            )+
            if !map.is_empty() {
                $crate::log::log($crate::syslog::LogLevel::Emergent, timestamp, map);
            }
        }
    }
}

/// alert! logging macro
#[macro_export]
macro_rules! alert {
    ($($key:literal : $value:expr),+) => {
        if $crate::log_enabled!($crate::syslog::LogLevel::Alert) {
            let timestamp = $crate::log::now();
            let mut map = serde_json::Map::new();
            $(
                if let Ok(value) = serde_json::to_value($value) {
                    map.insert($key.to_string(), value);
                } else {
                    map.insert($key.to_string(), serde_json::Value::Null);
                }
            )+
            if !map.is_empty() {
                $crate::log::log($crate::syslog::LogLevel::Alert, timestamp, map);
            }
        }
    }
}

/// crit! logging macro
#[macro_export]
macro_rules! crit {
    ($($key:literal : $value:expr),+) => {
        if $crate::log_enabled!($crate::syslog::LogLevel::Crit) {
            let timestamp = $crate::log::now();
            let mut map = serde_json::Map::new();
            $(
                if let Ok(value) = serde_json::to_value($value) {
                    map.insert($key.to_string(), value);
                } else {
                    map.insert($key.to_string(), serde_json::Value::Null);
                }
            )+
            if !map.is_empty() {
                $crate::log::log($crate::syslog::LogLevel::Crit, timestamp, map);
            }
        }
    }
}

/// error! logging macro
#[macro_export]
macro_rules! error {
    ($($key:literal : $value:expr),+) => {
        if $crate::log_enabled!($crate::syslog::LogLevel::Err) {
            let timestamp = $crate::log::now();
            let mut map = serde_json::Map::new();
            $(
                if let Ok(value) = serde_json::to_value($value) {
                    map.insert($key.to_string(), value);
                } else {
                    map.insert($key.to_string(), serde_json::Value::Null);
                }
            )+
            if !map.is_empty() {
                $crate::log::log($crate::syslog::LogLevel::Err, timestamp, map);
            }
        }
    }
}

/// warn! logging macro
#[macro_export]
macro_rules! warn {
    ($($key:literal : $value:expr),+) => {
        if $crate::log_enabled!($crate::syslog::LogLevel::Warn) {
            let timestamp = $crate::log::now();
            let mut map = serde_json::Map::new();
            $(
                if let Ok(value) = serde_json::to_value($value) {
                    map.insert($key.to_string(), value);
                } else {
                    map.insert($key.to_string(), serde_json::Value::Null);
                }
            )+
            if !map.is_empty() {
                $crate::log::log($crate::syslog::LogLevel::Warn, timestamp, map);
            }
        }
    }
}

/// notice! logging macro
#[macro_export]
macro_rules! notice {
    ($($key:literal : $value:expr),+) => {
        if $crate::log_enabled!($crate::syslog::LogLevel::Notice) {
            let timestamp = $crate::log::now();
            let mut map = serde_json::Map::new();
            $(
                if let Ok(value) = serde_json::to_value($value) {
                    map.insert($key.to_string(), value);
                } else {
                    map.insert($key.to_string(), serde_json::Value::Null);
                }
            )+
            if !map.is_empty() {
                $crate::log::log($crate::syslog::LogLevel::Notice, timestamp, map);
            }
        }
    }
}

/// info! logging macro
#[macro_export]
macro_rules! info {
    ($($key:literal : $value:expr),+) => {
        if $crate::log_enabled!($crate::syslog::LogLevel::Info) {
            let timestamp = $crate::log::now();
            let mut map = serde_json::Map::new();
            $(
                if let Ok(value) = serde_json::to_value($value) {
                    map.insert($key.to_string(), value);
                } else {
                    map.insert($key.to_string(), serde_json::Value::Null);
                }
            )+
            if !map.is_empty() {
                $crate::log::log($crate::syslog::LogLevel::Info, timestamp, map);
            }
        }
    }
}

/// debug! logging macro
#[macro_export]
macro_rules! debug {
    ($($key:literal : $value:expr),+) => {
        if $crate::log_enabled!($crate::syslog::LogLevel::Debug) {
            let timestamp = $crate::log::now();
            let mut map = serde_json::Map::new();
            $(
                if let Ok(value) = serde_json::to_value($value) {
                    map.insert($key.to_string(), value);
                } else {
                    map.insert($key.to_string(), serde_json::Value::Null);
                }
            )+
            if !map.is_empty() {
                $crate::log::log($crate::syslog::LogLevel::Debug, timestamp, map);
            }
        }
    }
}

// A wrapper that implements Write for a raw fd, but does not close it
// on drop. We can then use standard .write_all(...) to handle EINTR.
pub(crate) struct LockedWriter<'a> {
    fd: BorrowedFd<'a>,
}

impl<'a> LockedWriter<'a> {
    pub(crate) fn new(fd: BorrowedFd<'a>) -> Result<Self, Errno> {
        lock_fd(&fd, true, true)?;
        Ok(Self { fd })
    }
}

impl Drop for LockedWriter<'_> {
    fn drop(&mut self) {
        let _ = self.flush();
        let _ = unlock_fd(&self.fd);
    }
}

impl Write for LockedWriter<'_> {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        write(self.fd, buf).map_err(|e| std::io::Error::from_raw_os_error(e as i32))
    }

    fn flush(&mut self) -> std::io::Result<()> {
        // SAFETY:
        // 1. Retry on EINTR.
        // 2. Ignore EINVAL which indicates special file.
        match retry_on_eintr(|| fdatasync(self.fd.as_raw_fd())) {
            Ok(_) | Err(Errno::EINVAL) => Ok(()),
            Err(e) => Err(std::io::Error::from_raw_os_error(e as i32)),
        }
    }
}

/// Initializes Syslog global object, reading environment variables.
#[cfg(feature = "log")]
#[allow(clippy::cognitive_complexity)]
pub fn log_init(default_level: LogLevel, default_log_fd: Option<RawFd>) -> Result<(), Errno> {
    use std::os::unix::ffi::OsStrExt;

    // Parse the desired console log level from ENV_LOG,
    // or use default_level if not set/invalid.
    let level = if let Some(val) = std::env::var_os(ENV_LOG) {
        parse_loglevel(val.as_os_str().as_bytes(), default_level)
    } else {
        default_level
    };

    // Determine if syslog host forwarding is on or off
    let host_syslog = std::env::var_os(ENV_NO_SYSLOG).is_none();

    // Determine the main FD for logging:
    // By default, we use stderr. If ENV_LOG_FD is set, we parse it.
    let fd = match std::env::var_os(ENV_LOG_FD) {
        None => default_log_fd,
        Some(val) => {
            Some(btoi::btoi::<RawFd>(val.as_os_str().as_bytes()).map_err(|_| Errno::EBADF)?)
        }
    };
    if let Some(fd) = fd {
        LOG_FD.store(fd, std::sync::atomic::Ordering::Relaxed);
    }

    // Decide if we want a TTY-like console.
    let mut tty = std::env::var_os(ENV_FORCE_TTY).is_some();
    if !tty {
        if fd == Some(libc::STDERR_FILENO) && std::env::var_os(ENV_QUIET_TTY).is_none() {
            // check if we have a TTY
            tty = nix::unistd::isatty(libc::STDIN_FILENO).unwrap_or(false)
                && nix::unistd::isatty(libc::STDERR_FILENO).unwrap_or(false);
        } else {
            tty = false;
        }
    }

    // Record TTY information to an atomic for easy access.
    LOG_TTY.store(tty, std::sync::atomic::Ordering::Relaxed);

    // Allocate the syslog(2) ring buffer on the stack by default.
    // The size is architecture-dependent, see config.rs.
    //
    // If the user specifies an alternative capacity with `SYD_LOG_BUF_LEN`,
    // parse this size using parse-size and use it instead.
    let mut logbuflen = 0usize;
    let mut use_stack = true;
    if let Some(var) = std::env::var_os(ENV_LOG_BUF_LEN) {
        logbuflen = parse_size::Config::new()
            .with_binary()
            .parse_size(var.as_bytes())
            .or(Err(Errno::EINVAL))?
            .try_into()
            .or(Err(Errno::EINVAL))?;
        use_stack = false;
    }
    init_global_syslog(logbuflen, fd, level, host_syslog, use_stack)?;

    // Finally let's make some noise!
    info!("ctx": "init", "op": "sing", "chapter": 24,
        "msg": "Change return success. Going and coming without error. Action brings good fortune.");

    Ok(())
}

/// A simpler variant that turns off host syslog from the start and
/// locks the ring buffer.  This can be used by small utilities that do
/// not want the ring buffer overhead.
#[cfg(feature = "log")]
pub fn log_init_simple(default_level: LogLevel) -> Result<(), Errno> {
    use std::os::unix::ffi::OsStrExt;

    // Parse the desired console log level from ENV_LOG,
    // or use default_level if not set/invalid.
    let level = if let Some(val) = std::env::var_os(ENV_LOG) {
        parse_loglevel(val.as_os_str().as_bytes(), default_level)
    } else {
        default_level
    };

    // We'll turn OFF host syslog, always.
    let host_syslog = false;

    // Determine the main FD for logging:
    // By default, we use stderr. If ENV_LOG_FD is set, we parse it.
    let fd = match std::env::var_os(ENV_LOG_FD) {
        None => libc::STDERR_FILENO,
        Some(val) => btoi::btoi::<RawFd>(val.as_os_str().as_bytes()).map_err(|_| Errno::EBADF)?,
    };
    LOG_FD.store(fd, std::sync::atomic::Ordering::Relaxed);

    // Decide if we want a TTY-like console.
    let mut tty = std::env::var_os(ENV_FORCE_TTY).is_some();
    if !tty {
        if fd == libc::STDERR_FILENO && std::env::var_os(ENV_QUIET_TTY).is_none() {
            // check if we have a TTY
            tty = nix::unistd::isatty(libc::STDIN_FILENO).unwrap_or(false)
                && nix::unistd::isatty(libc::STDERR_FILENO).unwrap_or(false);
        } else {
            tty = false;
        }
    }

    // Record TTY information to an atomic for easy access.
    LOG_TTY.store(tty, std::sync::atomic::Ordering::Relaxed);

    // We'll do the same FD detection.
    let mut tty = std::env::var_os(ENV_FORCE_TTY).is_some();
    if !tty {
        if std::env::var_os(ENV_QUIET_TTY).is_none() {
            tty = nix::unistd::isatty(libc::STDIN_FILENO).unwrap_or(false)
                && nix::unistd::isatty(libc::STDERR_FILENO).unwrap_or(false);
        } else {
            tty = false;
        }
    }
    LOG_TTY.store(tty, std::sync::atomic::Ordering::Relaxed);

    // Create a global syslog with ring.
    init_global_syslog(0, Some(fd), level, host_syslog, true)?;

    if let Some(sys) = global_syslog() {
        // Now lock it immediately,
        // so ring is unavailable.
        sys.lock();
    }

    Ok(())
}

/// Main entry point for appending log entries in JSON-line style.
#[cfg(feature = "log")]
#[allow(clippy::cognitive_complexity)]
pub fn log(level: crate::syslog::LogLevel, timestamp: u64, mut msg: Map<String, Value>) {
    // For "higher" severities, we add more contextual fields.
    let add_context = level.as_u8() <= crate::syslog::LogLevel::Notice.as_u8();
    let tty = LOG_TTY.load(std::sync::atomic::Ordering::Relaxed);

    // If there's a "pid", we might add cmd/cwd, etc.
    // We remove and readd to reorder for better visibility.
    if let Some(pid_v) = msg.remove("pid").and_then(|v| v.as_i64()) {
        #[allow(clippy::cast_possible_truncation)]
        let pid = Pid::from_raw(pid_v as nix::libc::pid_t);
        if pid.as_raw() != 0 {
            if add_context {
                if let Ok(cmd) = proc_cmdline(pid) {
                    msg.insert("cmd".to_string(), Value::String(cmd.to_string()));
                } else {
                    msg.insert("cmd".to_string(), Value::Null);
                }
            } else if let Ok(cmd) = proc_comm(pid) {
                msg.insert("cmd".to_string(), Value::String(cmd.to_string()));
            } else {
                msg.insert("cmd".to_string(), Value::Null);
            }
            if let Ok(dir) = proc_cwd(pid) {
                msg.insert("cwd".to_string(), Value::String(dir.to_string()));
            } else {
                msg.insert("cwd".to_string(), Value::Null);
            }
        }
        msg.insert("pid".to_string(), Value::Number(pid.as_raw().into()));
    }

    // Add current user if add_context.
    if add_context {
        msg.insert(
            "uid".to_string(),
            Value::Number(Uid::current().as_raw().into()),
        );
    }

    // Add "syd" = current thread ID.
    let syd = nix::unistd::gettid().as_raw().into();
    msg.insert("syd".to_string(), Value::Number(syd));

    // Add ISO8601 time.
    msg.insert("time".to_string(), Value::String(format_iso8601(timestamp)));

    // Reorder req and informational fields for better visibility.
    if let Some(src) = msg.remove("req") {
        msg.insert("req".to_string(), src);
    }
    if let Some(m) = msg.remove("msg") {
        msg.insert("msg".to_string(), m);
    }
    if let Some(err) = msg.remove("error") {
        msg.insert("error".to_string(), err);
    }
    if let Some(tip) = msg.remove("tip") {
        msg.insert("tip".to_string(), tip);
    }

    // Convert to JSON line.
    let msg_data = serde_json::to_string(&msg).unwrap_or_else(|e| {
        let e = serde_json::to_string(&format!("{e:?}")).unwrap_or("?".to_string());
        format!("{{\"ctx\":\"log\",\"op\":\"serialize\",\"error\": \"{e}\"}}")
    });
    let msg_pretty = if tty {
        // A "pretty" output for TTY.
        Some(serde_json::to_string_pretty(&msg).unwrap_or_else(|e| {
            let e = serde_json::to_string(&format!("{e:?}")).unwrap_or("?".to_string());
            format!("{{\"ctx\":\"log\",\"op\":\"serialize\",\"error\": \"{e}\"}}")
        }))
    } else {
        None
    };

    if let Some(sys) = crate::syslog::global_syslog() {
        sys.write_log(level, &msg_data, msg_pretty.as_deref());
    }
}

/// Initializes Syslog global object, reading environment variables.
#[cfg(not(feature = "log"))]
#[inline(always)]
pub fn log_init(_default_level: LogLevel, default_log_fd: Option<RawFd>) -> Result<(), Errno> {
    use std::os::unix::ffi::OsStrExt;

    // Decide if we want a TTY-like console.
    let mut tty = std::env::var_os(ENV_FORCE_TTY).is_some();
    if !tty {
        if std::env::var_os(ENV_LOG_FD).is_none() && std::env::var_os(ENV_QUIET_TTY).is_none() {
            // check if we have a TTY
            tty = nix::unistd::isatty(libc::STDIN_FILENO).unwrap_or(false)
                && nix::unistd::isatty(libc::STDERR_FILENO).unwrap_or(false);
        } else {
            tty = false;
        }
    }

    // Record TTY information to an atomic for easy access.
    LOG_TTY.store(tty, std::sync::atomic::Ordering::Relaxed);

    // Determine the main FD for logging:
    // By default, we use stderr. If ENV_LOG_FD is set, we parse it.
    let fd = match std::env::var_os(ENV_LOG_FD) {
        None => default_log_fd,
        Some(val) => {
            Some(btoi::btoi::<RawFd>(val.as_os_str().as_bytes()).map_err(|_| Errno::EBADF)?)
        }
    };
    if let Some(fd) = fd {
        LOG_FD.store(fd, std::sync::atomic::Ordering::Relaxed);
    }
    info!("ctx": "init", "op": "sing", "chapter": 24,
        "msg": "Change return success. Going and coming without error. Action brings good fortune.");

    Ok(())
}

/// A simpler variant that turns off host syslog from the start and
/// locks the ring buffer.  This can be used by small utilities that do
/// not want the ring buffer overhead.
#[cfg(not(feature = "log"))]
#[inline(always)]
pub fn log_init_simple(default_level: LogLevel) -> Result<(), Errno> {
    log_init(default_level, Some(libc::STDERR_FILENO))
}

/// Main entry point for appending log entries in JSON-line style.
#[cfg(not(feature = "log"))]
#[allow(clippy::cognitive_complexity)]
pub fn log(level: crate::syslog::LogLevel, timestamp: u64, mut msg: Map<String, Value>) {
    // For "higher" severities, we add more contextual fields, and honour TTY.
    let add_context = level.as_u8() <= crate::syslog::LogLevel::Notice.as_u8();
    let tty = add_context && LOG_TTY.load(std::sync::atomic::Ordering::Relaxed);

    // If there's a "pid", we might add cmd/cwd, etc.
    // We remove and readd to reorder for better visibility.
    if let Some(pid_v) = msg.remove("pid").and_then(|v| v.as_i64()) {
        let pid = Pid::from_raw(pid_v as nix::libc::pid_t);
        if pid.as_raw() != 0 {
            if add_context {
                if let Ok(cmd) = proc_cmdline(pid) {
                    msg.insert("cmd".to_string(), Value::String(cmd.to_string()));
                } else {
                    msg.insert("cmd".to_string(), Value::Null);
                }
            } else if let Ok(cmd) = proc_comm(pid) {
                msg.insert("cmd".to_string(), Value::String(cmd.to_string()));
            } else {
                msg.insert("cmd".to_string(), Value::Null);
            }
            if let Ok(dir) = proc_cwd(pid) {
                msg.insert("cwd".to_string(), Value::String(dir.to_string()));
            } else {
                msg.insert("cwd".to_string(), Value::Null);
            }
        }
        msg.insert("pid".to_string(), Value::Number(pid.as_raw().into()));
    }

    // Add current user if add_context.
    if add_context {
        msg.insert(
            "uid".to_string(),
            Value::Number(Uid::current().as_raw().into()),
        );
    }

    // Add "syd" = current thread ID.
    let syd = nix::unistd::gettid().as_raw().into();
    msg.insert("syd".to_string(), Value::Number(syd));

    // Add ISO8601 time
    msg.insert("time".to_string(), Value::String(format_iso8601(timestamp)));

    // Reorder req and informational fields for better visibility.
    if let Some(src) = msg.remove("req") {
        msg.insert("req".to_string(), src);
    }
    if let Some(m) = msg.remove("msg") {
        msg.insert("msg".to_string(), m);
    }
    if let Some(err) = msg.remove("error") {
        msg.insert("error".to_string(), err);
    }
    if let Some(tip) = msg.remove("tip") {
        msg.insert("tip".to_string(), tip);
    }

    // Convert to JSON line
    let msg_data = serde_json::to_string(&msg).unwrap_or_else(|e| {
        let e = serde_json::to_string(&format!("{e:?}")).unwrap_or("?".to_string());
        format!("{{\"ctx\":\"log\",\"op\":\"serialize\",\"error\": \"{e}\"}}")
    });

    // Warn or higher goes to syslog too unless SYD_NO_SYSLOG is set.
    if std::env::var_os(ENV_NO_SYSLOG).is_none()
        && level.as_u8() <= crate::syslog::LogLevel::Warn.as_u8()
    {
        host_syslog(&msg_data);
    }

    let msg_info = if tty {
        // A "pretty" output for TTY.
        Some(serde_json::to_string_pretty(&msg).unwrap_or_else(|e| {
            let e = serde_json::to_string(&format!("{e:?}")).unwrap_or("?".to_string());
            format!("{{\"ctx\":\"log\",\"op\":\"serialize\",\"error\": \"{e}\"}}")
        }))
    } else {
        None
    };

    // Finally, log to fd or standard error.
    let fd = LOG_FD.load(std::sync::atomic::Ordering::Relaxed);
    if fd >= 0 {
        // SAFETY: we trust log fd is valid.
        let fd = unsafe { BorrowedFd::borrow_raw(fd) };
        if let Ok(mut writer) = LockedWriter::new(fd).map(std::io::BufWriter::new) {
            if let Some(ref msg) = msg_info {
                let _ = writer.write_all(msg.as_bytes());
            } else {
                let _ = writer.write_all(msg_data.as_bytes());
            }
            let _ = writer.write_all(b"\n");
        }
    }
}

/// Return the current time in seconds since the unix epoch.
pub fn now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

/// Returns current time as a compact ISO8601-formatted string.
///
/// The format is currently "YYYYMMDDThhmmssZ". The format may change in
/// the future but it will always remain conformant to the ISO8601
/// standard.
#[allow(clippy::arithmetic_side_effects)]
#[allow(deprecated)]
fn format_iso8601(timestamp: u64) -> String {
    let timestamp = match nix::libc::time_t::try_from(timestamp) {
        Ok(t) => t,
        Err(_) => return "error on time()".to_string(),
    };
    let mut tm = std::mem::MaybeUninit::uninit();

    // SAFETY: gmtime_r returns NULL on error.
    if unsafe { nix::libc::gmtime_r(&timestamp, tm.as_mut_ptr()) }.is_null() {
        return "error on gmtime_r()".to_string();
    }

    // SAFETY: gmtime_r returned success.
    let tm = unsafe { tm.assume_init() };

    let year = tm.tm_year + 1900;
    // tm_mon ranges from 0 to 11.
    let month = tm.tm_mon + 1;
    let day = tm.tm_mday;
    let hour = tm.tm_hour;
    let minute = tm.tm_min;
    let second = tm.tm_sec;

    // Format: YYYYMMDDThhmmssZ
    format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
}

/// Logs an untrusted buffer, escaping it as hex if it contains control characters.
/// Returns a boolean in addition to the String which is true if String is hex-encoded.
pub fn log_untrusted_buf(buf: &[u8]) -> (String, bool) {
    if contains_ascii_unprintable(buf) {
        (buf.to_lower_hex_string(), true)
    } else if let Ok(s) = std::str::from_utf8(buf) {
        (s.to_string(), false)
    } else {
        (buf.to_lower_hex_string(), true)
    }
}

/// Checks if the buffer contains ASCII unprintable characters.
pub fn contains_ascii_unprintable(buf: &[u8]) -> bool {
    buf.iter().any(|byte| !is_ascii_printable(*byte))
}

/// Checks if the given character is ASCII printable.
pub fn is_ascii_printable(byte: u8) -> bool {
    (0x20..=0x7e).contains(&byte)
}
