//! macOS Seatbelt sandboxing implementation
//!
//! Uses the macOS `sandbox_init_with_parameters` private API (Seatbelt) to
//! restrict the process after initialization. This is analogous to Linux
//! Landlock: existing file descriptors (BPF devices) remain usable, and only
//! future operations are restricted.
//!
//! # What We Restrict
//!
//! - Network: Outbound TCP/UDP connections (RustNet is passive)
//! - Filesystem writes: Only allowed to configured log and PCAP paths
//! - Filesystem writes: All user home directories blocked (/Users, /var/root)
//!
//! # Profile Strategy
//!
//! Uses allow-default with targeted denies. A deny-default profile would
//! require whitelisting all system libraries, Mach ports, locale data, etc.
//! Allow-default provides meaningful security against the primary threats
//! (outbound exfiltration, credential theft) without operational risk.
//!
//! Note: `home-subpath` is not available outside the App Sandbox context.
//! We use hardcoded paths for macOS user home directories instead.
//! SBPL specificity rules ensure that the explicit allow rules for log/PCAP
//! paths override the broader deny rules for /Users and /var/root.

use anyhow::{Context, Result, bail};
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use std::path::PathBuf;

use super::SandboxConfig;

/// Result of Seatbelt application
pub struct SeatbeltResult {
    /// Whether the sandbox was applied
    pub applied: bool,
    /// Human-readable message
    pub message: String,
    /// Whether filesystem write restrictions were applied
    pub fs_restricted: bool,
    /// Whether outbound network connections were blocked
    pub net_blocked: bool,
}

// macOS Seatbelt private API — stable since macOS 10.5, present through macOS 15+
// Part of libSystem.B.dylib which is linked by default on all macOS targets.
// flags = 0 means an inline SBPL profile string (not a named built-in profile).
unsafe extern "C" {
    fn sandbox_init_with_parameters(
        profile: *const c_char,
        flags: u64,
        parameters: *const *const c_char,
        errorbuf: *mut *mut c_char,
    ) -> c_int;

    fn sandbox_free_error(errorbuf: *mut c_char);
}

/// Parameter keys used in the SBPL profile via `(param "KEY")` substitution
const PARAM_LOG_DIR: &str = "LOG_DIR";
const PARAM_JSON_LOG_PATH: &str = "JSON_LOG_PATH";
const PARAM_PCAP_PATH: &str = "PCAP_PATH";
const PARAM_PCAP_JSONL_PATH: &str = "PCAP_JSONL_PATH";

/// Base SBPL profile: allow-default with filesystem restrictions.
///
/// Note: `home-subpath` is not used because it is only valid inside the
/// App Sandbox context and fails with `sandbox_init_with_parameters`.
/// Instead we hardcode the macOS-specific home directory prefixes.
const SBPL_PROFILE_BASE: &str = r#"(version 1)

;; Allow-default: everything permitted unless explicitly denied
(allow default)

;; Block writes to user home directories
;; Regular user homes on macOS are under /Users; root's home is /var/root.
;; Protects SSH keys, AWS credentials, GPG keys, browser profiles, etc.
;; SBPL specificity: more specific allow rules for log/PCAP paths below
;; will take precedence over these broader deny rules.
(deny file-write*
    (subpath "/Users")
    (subpath "/var/root"))

;; Allow writes to configured output paths (log files, PCAP exports)
;; These use more specific subpaths that take precedence over the deny rules
;; above when the log/PCAP paths happen to be inside a user home directory.
(allow file-write*
    (literal (param "LOG_DIR"))
    (subpath (param "LOG_DIR"))
    (literal (param "JSON_LOG_PATH"))
    (literal (param "PCAP_PATH"))
    (literal (param "PCAP_JSONL_PATH")))

;; Allow lsof subprocess for process-to-connection mapping
;; (needed when PKTAP is unavailable as a fallback)
(allow process-exec
    (literal "/usr/sbin/lsof"))
"#;

/// Network deny SBPL section, appended when `block_network` is true.
///
/// Blocks outbound TCP/UDP connections. Unix domain sockets are explicitly
/// allowed for Mach IPC. Already-open BPF/PKTAP file descriptors are unaffected.
const SBPL_NETWORK_DENY: &str = r#"
;; Block outbound TCP and UDP connections
;; RustNet only reads from BPF/PKTAP — already-open fds are unaffected
(deny network-outbound
    (remote tcp)
    (remote udp))

;; Allow Unix domain socket IPC (required for threading, Mach port bridge)
(allow network-outbound
    (remote unix-socket))
"#;

/// Build the complete SBPL profile string based on configuration.
fn build_sbpl_profile(block_network: bool) -> String {
    if block_network {
        format!("{}{}", SBPL_PROFILE_BASE, SBPL_NETWORK_DENY)
    } else {
        SBPL_PROFILE_BASE.to_string()
    }
}

/// Apply Seatbelt restrictions based on configuration.
///
/// The caller (`apply_sandbox` in mod.rs) handles the `Disabled` mode check,
/// so this function assumes sandboxing is requested.
pub fn apply_seatbelt(config: &SandboxConfig) -> Result<SeatbeltResult> {
    let profile = build_sbpl_profile(config.block_network);
    let profile_cstr = CString::new(profile).context("Profile contains null byte")?;
    let params = build_parameters(config).context("Failed to build sandbox parameters")?;

    // Build null-terminated array of *const c_char for the FFI call.
    // params must outlive ptrs.
    let ptrs: Vec<*const c_char> = params.iter().map(|s| s.as_ptr()).collect();
    let mut ptrs_with_null = ptrs;
    ptrs_with_null.push(std::ptr::null());

    let mut errorbuf: *mut c_char = std::ptr::null_mut();

    // SAFETY:
    // - profile_cstr is a valid null-terminated C string held on the stack
    // - ptrs_with_null is a valid null-terminated array of valid C strings
    //   (params lives at least as long as ptrs_with_null)
    // - errorbuf is a valid out-pointer; we free it with sandbox_free_error
    let ret = unsafe {
        sandbox_init_with_parameters(
            profile_cstr.as_ptr(),
            0,
            ptrs_with_null.as_ptr(),
            &mut errorbuf,
        )
    };

    // Always free the error buffer if non-null (may contain a warning on success)
    let error_message = if !errorbuf.is_null() {
        let msg = unsafe { CStr::from_ptr(errorbuf) }
            .to_string_lossy()
            .into_owned();
        unsafe { sandbox_free_error(errorbuf) };
        Some(msg)
    } else {
        None
    };

    if ret != 0 {
        let detail = error_message.unwrap_or_else(|| "unknown error".to_string());
        bail!("sandbox_init_with_parameters failed ({}): {}", ret, detail);
    }

    if let Some(warn) = &error_message {
        log::warn!("Seatbelt: warning from sandbox_init: {}", warn);
    }

    log::info!(
        "Seatbelt sandbox applied (fs_restricted=true, net_blocked={})",
        config.block_network
    );

    Ok(SeatbeltResult {
        applied: true,
        message: format!(
            "Seatbelt applied (fs restricted, net {})",
            if config.block_network {
                "blocked"
            } else {
                "allowed"
            }
        ),
        fs_restricted: true,
        net_blocked: config.block_network,
    })
}

/// Resolve a path to an absolute path for use in SBPL rules.
///
/// Sandbox path rules require absolute paths. Relative paths are resolved
/// against the current working directory at the time of sandbox application.
/// Falls back to the original path string if resolution fails.
fn resolve_to_absolute(path: &str) -> String {
    let p = PathBuf::from(path);
    if p.is_absolute() {
        path.to_string()
    } else {
        std::env::current_dir()
            .map(|cwd| cwd.join(&p).to_string_lossy().into_owned())
            .unwrap_or_else(|_| path.to_string())
    }
}

/// Build the flat key/value parameter array for `sandbox_init_with_parameters`.
///
/// Unused paths default to `/dev/null` (a safe sentinel that avoids empty-string
/// parse issues and is always present on macOS).
/// Relative paths are resolved to absolute paths before being passed.
fn build_parameters(config: &SandboxConfig) -> Result<Vec<CString>> {
    let devnull = CString::new("/dev/null").unwrap();

    let log_dir = config
        .log_dir
        .as_deref()
        .map(resolve_to_absolute)
        .map(CString::new)
        .transpose()
        .context("log_dir contains null byte")?
        .unwrap_or_else(|| devnull.clone());

    let json_log = config
        .json_log_path
        .as_deref()
        .map(resolve_to_absolute)
        .map(CString::new)
        .transpose()
        .context("json_log_path contains null byte")?
        .unwrap_or_else(|| devnull.clone());

    let pcap = config
        .pcap_export_path
        .as_deref()
        .map(resolve_to_absolute)
        .map(CString::new)
        .transpose()
        .context("pcap_export_path contains null byte")?
        .unwrap_or_else(|| devnull.clone());

    let pcap_jsonl_str = config
        .pcap_export_path
        .as_deref()
        .map(|p| format!("{}.connections.jsonl", resolve_to_absolute(p)));
    let pcap_jsonl = pcap_jsonl_str
        .as_deref()
        .map(CString::new)
        .transpose()
        .context("pcap_jsonl path contains null byte")?
        .unwrap_or_else(|| devnull.clone());

    // Flat key/value pairs: [KEY0, VAL0, KEY1, VAL1, ...]
    // The null terminator is added by the caller just before the FFI call.
    Ok(vec![
        CString::new(PARAM_LOG_DIR).unwrap(),
        log_dir,
        CString::new(PARAM_JSON_LOG_PATH).unwrap(),
        json_log,
        CString::new(PARAM_PCAP_PATH).unwrap(),
        pcap,
        CString::new(PARAM_PCAP_JSONL_PATH).unwrap(),
        pcap_jsonl,
    ])
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::network::platform::sandbox::SandboxMode;

    #[test]
    fn test_build_parameters_uses_devnull_for_none() {
        let config = SandboxConfig {
            mode: SandboxMode::BestEffort,
            block_network: true,
            log_dir: None,
            json_log_path: None,
            pcap_export_path: None,
        };
        let params = build_parameters(&config).unwrap();
        // Values at odd indices should all be /dev/null
        assert_eq!(params[1].to_str().unwrap(), "/dev/null");
        assert_eq!(params[3].to_str().unwrap(), "/dev/null");
        assert_eq!(params[5].to_str().unwrap(), "/dev/null");
        assert_eq!(params[7].to_str().unwrap(), "/dev/null");
    }

    #[test]
    fn test_build_parameters_with_absolute_paths() {
        let config = SandboxConfig {
            mode: SandboxMode::BestEffort,
            block_network: true,
            log_dir: Some("/tmp/rustnet/logs".to_string()),
            json_log_path: Some("/tmp/rustnet/events.jsonl".to_string()),
            pcap_export_path: Some("/tmp/rustnet/capture.pcap".to_string()),
        };
        let params = build_parameters(&config).unwrap();
        // Keys
        assert_eq!(params[0].to_str().unwrap(), PARAM_LOG_DIR);
        assert_eq!(params[2].to_str().unwrap(), PARAM_JSON_LOG_PATH);
        assert_eq!(params[4].to_str().unwrap(), PARAM_PCAP_PATH);
        assert_eq!(params[6].to_str().unwrap(), PARAM_PCAP_JSONL_PATH);
        // Values
        assert_eq!(params[1].to_str().unwrap(), "/tmp/rustnet/logs");
        assert_eq!(params[3].to_str().unwrap(), "/tmp/rustnet/events.jsonl");
        assert_eq!(params[5].to_str().unwrap(), "/tmp/rustnet/capture.pcap");
        assert_eq!(
            params[7].to_str().unwrap(),
            "/tmp/rustnet/capture.pcap.connections.jsonl"
        );
    }

    #[test]
    fn test_relative_path_is_resolved_to_absolute() {
        let abs = resolve_to_absolute("logs");
        assert!(abs.starts_with('/'), "Expected absolute path, got: {}", abs);
        assert!(
            abs.ends_with("/logs"),
            "Expected path ending with /logs, got: {}",
            abs
        );
    }

    #[test]
    fn test_absolute_path_is_unchanged() {
        assert_eq!(resolve_to_absolute("/tmp/foo"), "/tmp/foo");
    }

    #[test]
    fn test_profile_variants_are_valid_cstrings() {
        CString::new(SBPL_PROFILE_BASE).expect("SBPL_PROFILE_BASE must not contain null bytes");
        CString::new(build_sbpl_profile(true)).expect("full profile must not contain null bytes");
        CString::new(build_sbpl_profile(false))
            .expect("base-only profile must not contain null bytes");
    }

    #[test]
    fn test_profile_includes_network_deny_when_block_network_true() {
        let profile = build_sbpl_profile(true);
        assert!(
            profile.contains("deny network-outbound"),
            "Expected network deny in profile when block_network=true"
        );
    }

    #[test]
    fn test_profile_excludes_network_deny_when_block_network_false() {
        let profile = build_sbpl_profile(false);
        assert!(
            !profile.contains("deny network-outbound"),
            "Expected no network deny in profile when block_network=false"
        );
    }
}
