//! `SO_PEERCRED` UDS authentication.
//!
//! Captures the caller's `{uid, gid, pid, username}` at accept time and
//! exposes authorization helpers that every UDS verb handler consults
//! before touching state. The authz table lives here as code:
//!
//! | Verb                                    | Rule                                    |
//! |-----------------------------------------|-----------------------------------------|
//! | `SEND`                                  | caller uid owns the `From:` mailbox, OR is root |
//! | `MARK-READ` / `MARK-UNREAD`             | caller uid owns the target mailbox, OR is root |
//! | `MAILBOX-CREATE` / `MAILBOX-DELETE`     | root only                               |
//! | `HOOK-CREATE` / `HOOK-DELETE`           | caller uid owns the target mailbox, OR is root |
//!
//! Root is a blanket bypass on every ownership check (`decision =
//! "root_bypass"`), logged at info level so `aimx logs` reveals the
//! escalation.

use std::sync::OnceLock;

use tokio::net::UnixStream;
use tokio::net::unix::UCred;

use crate::config::MailboxConfig;
use crate::send_protocol::ErrCode;

/// Peer-credentials snapshot taken at `accept()` time. `username` is
/// resolved lazily on the first call to [`Caller::username`] and then
/// memoized; `getpwuid` misses produce `None` so unknown uids don't
/// crash the handler.
#[derive(Debug)]
pub struct Caller {
    pub uid: u32,
    pub gid: u32,
    pub pid: Option<i32>,
    username_cache: OnceLock<Option<String>>,
}

impl Clone for Caller {
    fn clone(&self) -> Self {
        let cache = OnceLock::new();
        if let Some(v) = self.username_cache.get() {
            let _ = cache.set(v.clone());
        }
        Self {
            uid: self.uid,
            gid: self.gid,
            pid: self.pid,
            username_cache: cache,
        }
    }
}

impl Caller {
    /// Construct from raw `tokio::net::unix::UCred`. The production
    /// accept loop in `src/serve.rs` calls `peer_cred()` on the freshly
    /// accepted `UnixStream` and feeds the value through this helper
    /// so username resolution stays lazy.
    pub fn from_ucred(cred: UCred) -> Self {
        Self {
            uid: cred.uid(),
            gid: cred.gid(),
            pid: cred.pid(),
            username_cache: OnceLock::new(),
        }
    }

    /// Construct an explicit caller (tests and internal dispatchers).
    ///
    /// Gated behind `#[cfg(test)]` because every production call site
    /// uses [`Caller::from_ucred`] instead. Tests and integration
    /// fixtures that synthesize a caller go through this constructor.
    #[cfg(test)]
    pub fn new(uid: u32, gid: u32, pid: Option<i32>) -> Self {
        Self {
            uid,
            gid,
            pid,
            username_cache: OnceLock::new(),
        }
    }

    /// A root (uid 0) caller used where no real credentials exist, e.g.
    /// the in-process inbound ingest path which never crosses the UDS.
    /// Also convenient for tests and harnesses that bypass the real
    /// SO_PEERCRED path.
    ///
    /// Gated behind `#[cfg(test)]`: production call sites either go
    /// through [`Caller::from_ucred`] after a real `SO_PEERCRED`
    /// handshake, or (for the in-process ingest path) synthesize a
    /// caller via platform APIs rather than this helper.
    #[cfg(test)]
    pub fn internal_root() -> Self {
        Self::new(0, 0, None)
    }

    /// Construct a caller with a pre-populated username cache. Tests
    /// across the crate (not just `uds_authz`) use this to synthesize a
    /// peer whose `getpwuid` lookup resolves to a known name without
    /// touching the host's user database.
    #[cfg(test)]
    #[allow(dead_code)]
    pub fn with_username(uid: u32, gid: u32, username: &str) -> Self {
        let c = Self::new(uid, gid, None);
        let _ = c.username_cache.set(Some(username.to_string()));
        c
    }

    /// Lazily resolve the caller's username via `getpwuid(3)`. Root is
    /// always `Some("root")`. Unknown uids return `None`.
    pub fn username(&self) -> Option<&str> {
        self.username_cache
            .get_or_init(|| lookup_username(self.uid))
            .as_deref()
    }

    /// Log-friendly rendering of the caller's username, falling back to
    /// `"?"` so emitted tracing fields are always populated.
    pub fn username_display(&self) -> &str {
        self.username().unwrap_or("?")
    }

    pub fn is_root(&self) -> bool {
        self.uid == 0
    }
}

/// Resolve a `SO_PEERCRED` uid to the canonical username string the
/// rest of the codebase uses for mailbox owners.
///
/// This is the inverse of `MailboxConfig::owner_uid()` (which calls
/// `getpwnam` on a username and returns the uid): given the uid we
/// captured from `SO_PEERCRED`, we look up the corresponding username
/// via `getpwuid` so the value can round-trip through the on-disk
/// `config.toml` `owner` field and any subsequent `owner_uid()` call
/// returns the same uid we started with.
///
/// Returns `Err("uid <N> has no passwd entry")` for orphan uids — uids
/// that exist on the running process (e.g. captured from `SO_PEERCRED`)
/// but have no matching entry in `/etc/passwd`. This happens when a
/// local user was deleted via `userdel` while still holding aimx state,
/// or in exotic container setups where the host's passwd file does not
/// list every uid that can connect to the daemon.
///
/// Errors — does not panic — so handlers can map the failure into a
/// wire-format `ErrCode::Validation` rather than crashing the daemon.
pub fn peer_username(uid: u32) -> Result<String, String> {
    lookup_username(uid).ok_or_else(|| format!("uid {uid} has no passwd entry"))
}

/// Resolve a Linux uid to a username via `getpwuid(3)`. Root is returned
/// as `Some("root")` without touching `getpwuid` because test resolvers
/// and minimal container images have been known to return null for uid 0.
/// Unknown uids return `None`.
pub fn lookup_username(uid: u32) -> Option<String> {
    // Fast path: root is guaranteed by POSIX. Avoids a `getpwuid(0)`
    // call — test resolvers and minimal container images alike have
    // been known to return null for it.
    if uid == 0 {
        return Some("root".to_string());
    }
    // SAFETY: `getpwuid` reads a process-global static; we copy the
    // `pw_name` C string into an owned `String` before returning so
    // callers never dereference it after another `getpw*` call.
    unsafe {
        let pw = libc::getpwuid(uid as libc::uid_t);
        if pw.is_null() {
            return None;
        }
        let name_ptr = (*pw).pw_name;
        if name_ptr.is_null() {
            return None;
        }
        let cstr = std::ffi::CStr::from_ptr(name_ptr);
        cstr.to_str().ok().map(str::to_string)
    }
}

/// Peer-credential extraction. Returns `Err(ErrCode::Validation)` with
/// a `peer cred unavailable` reason when the kernel can't supply creds
/// (closed connection, non-UDS socket, etc.). Callers forward the
/// error verbatim to the client.
pub fn caller_from_stream(stream: &UnixStream) -> Result<Caller, String> {
    match stream.peer_cred() {
        Ok(c) => Ok(Caller::from_ucred(c)),
        Err(e) => Err(format!("peer cred unavailable: {e}")),
    }
}

/// Decision returned from an authz check. Handlers reject on
/// [`AuthzDecision::Reject`] and continue otherwise; the `RootBypass`
/// variant carries the info-log side-effect that fires from the helper.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthzDecision {
    Accept,
    RootBypass,
}

/// Authz error with a stable `code` for the wire plus a human reason.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthzReject {
    pub code: ErrCode,
    pub reason: String,
}

/// Require the caller to own `mailbox`, or be root. Used by `SEND`,
/// `MARK-READ`, `MARK-UNREAD`, `HOOK-CREATE`, `HOOK-DELETE`.
pub fn require_mailbox_owner_or_root(
    caller: &Caller,
    mailbox_name: &str,
    mailbox: &MailboxConfig,
) -> Result<AuthzDecision, AuthzReject> {
    if caller.is_root() {
        return Ok(AuthzDecision::RootBypass);
    }
    let caller_name = match caller.username() {
        Some(n) => n,
        None => {
            return Err(AuthzReject {
                code: ErrCode::Eaccess,
                reason: format!(
                    "caller uid {} has no resolvable username; cannot authorize against mailbox '{mailbox_name}'",
                    caller.uid
                ),
            });
        }
    };
    if caller_name == mailbox.owner {
        Ok(AuthzDecision::Accept)
    } else {
        Err(AuthzReject {
            code: ErrCode::Eaccess,
            reason: format!(
                "caller '{caller_name}' is not the owner of mailbox '{mailbox_name}' \
                 (owner: '{}')",
                mailbox.owner
            ),
        })
    }
}

/// Decision value passed to [`log_decision`]. Typed so the compiler —
/// rather than a string-match wildcard — catches new variants: if a
/// future caller invents a fourth outcome, it has to add an arm here
/// instead of silently falling through to a "debug accept" line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogDecision {
    Accept,
    RootBypass,
    Reject,
}

impl LogDecision {
    pub fn as_str(self) -> &'static str {
        match self {
            LogDecision::Accept => "accept",
            LogDecision::RootBypass => "root_bypass",
            LogDecision::Reject => "reject",
        }
    }
}

/// Emit a structured tracing event under `aimx::uds` describing one
/// authz decision. `verb` / `mailbox` / `decision` identify the event;
/// `caller_uid` / `caller_username` identify the actor.
pub fn log_decision(
    verb: &str,
    caller: &Caller,
    mailbox: Option<&str>,
    decision: LogDecision,
    reason: Option<&str>,
) {
    let mailbox = mailbox.unwrap_or("");
    let reason = reason.unwrap_or("");
    let decision_str = decision.as_str();
    match decision {
        LogDecision::Reject => tracing::warn!(
            target: "aimx::uds",
            verb = verb,
            caller_uid = caller.uid,
            caller_username = caller.username_display(),
            mailbox = mailbox,
            decision = decision_str,
            reason = reason,
            "uds authz reject"
        ),
        LogDecision::RootBypass => tracing::info!(
            target: "aimx::uds",
            verb = verb,
            caller_uid = caller.uid,
            caller_username = caller.username_display(),
            mailbox = mailbox,
            decision = decision_str,
            "uds authz root_bypass"
        ),
        LogDecision::Accept => tracing::debug!(
            target: "aimx::uds",
            verb = verb,
            caller_uid = caller.uid,
            caller_username = caller.username_display(),
            mailbox = mailbox,
            decision = decision_str,
            "uds authz accept"
        ),
    }
}

/// Canonical wire reason on every authz reject.
///
/// Kept deliberately opaque so the wire response cannot be used to
/// enumerate mailboxes or probe ownership ("does this mailbox exist?",
/// "who owns it?"). The rich diagnostic text returned by `require_*`
/// is logged at `warn` for the operator — see [`log_decision`] — but
/// never sent over the socket.
const WIRE_REASON_NOT_AUTHORIZED: &str = "not authorized";

/// Convenience: evaluate + log in one call. Used by handlers that want
/// the structured log for every decision (accept + reject + root_bypass)
/// without duplicating the match arms.
///
/// On reject, the structured log keeps the rich diagnostic reason
/// (`reason = "caller 'bob' is not the owner of mailbox 'alice' …"`)
/// for operator-side debugging via `aimx logs`, but the wire response
/// is tightened to the opaque [`WIRE_REASON_NOT_AUTHORIZED`] so the
/// daemon does not leak whether the mailbox exists or who owns it.
pub fn enforce_mailbox_owner_or_root(
    verb: &str,
    caller: &Caller,
    mailbox_name: &str,
    mailbox: &MailboxConfig,
) -> Result<(), AuthzReject> {
    match require_mailbox_owner_or_root(caller, mailbox_name, mailbox) {
        Ok(AuthzDecision::Accept) => {
            log_decision(verb, caller, Some(mailbox_name), LogDecision::Accept, None);
            Ok(())
        }
        Ok(AuthzDecision::RootBypass) => {
            log_decision(
                verb,
                caller,
                Some(mailbox_name),
                LogDecision::RootBypass,
                None,
            );
            Ok(())
        }
        Err(reject) => {
            log_decision(
                verb,
                caller,
                Some(mailbox_name),
                LogDecision::Reject,
                Some(&reject.reason),
            );
            Err(AuthzReject {
                code: reject.code,
                reason: WIRE_REASON_NOT_AUTHORIZED.to_string(),
            })
        }
    }
}

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

    fn mb(owner: &str) -> MailboxConfig {
        MailboxConfig {
            address: "alice@example.com".into(),
            owner: owner.into(),
            hooks: vec![],
            trust: None,
            trusted_senders: None,
            allow_root_catchall: false,
        }
    }

    #[test]
    fn caller_internal_root_is_root() {
        let c = Caller::internal_root();
        assert!(c.is_root());
        assert_eq!(c.username(), Some("root"));
    }

    #[test]
    fn caller_clone_preserves_cached_username() {
        let c = Caller::internal_root();
        let _ = c.username();
        let c2 = c.clone();
        assert_eq!(c2.username(), Some("root"));
    }

    #[test]
    fn lookup_unknown_uid_returns_none() {
        // 32-bit max UID is reserved for "nobody" on some systems but
        // typically unmapped on CI runners.
        assert!(lookup_username(4_294_967_294).is_none());
    }

    #[test]
    fn peer_username_for_root_returns_root() {
        // Root is guaranteed by POSIX. The `lookup_username` fast-path
        // also avoids hitting `getpwuid(0)` so this works on every
        // host, including minimal container images.
        assert_eq!(peer_username(0).unwrap(), "root");
    }

    #[test]
    fn peer_username_orphan_uid_returns_specific_error() {
        // Pin the exact wording the handler maps into
        // `ErrCode::Validation`. Tests at the handler layer assert
        // the same text string-identical, so a future tweak to this
        // message has to be updated in lockstep.
        let err = peer_username(4_294_967_294).unwrap_err();
        assert_eq!(err, "uid 4294967294 has no passwd entry");
    }

    #[test]
    fn peer_username_does_not_panic_on_orphan_uid() {
        // The whole point of `peer_username` returning `Result` is that
        // a daemon call from a uid the host's passwd file doesn't know
        // about must surface as an authz reject, never a process
        // crash. This tiny smoke confirms the API contract holds for
        // multiple sentinel orphan uids.
        for orphan in [4_294_967_294u32, 4_294_967_293, 4_294_967_292] {
            let res = peer_username(orphan);
            assert!(res.is_err(), "uid {orphan} unexpectedly resolved");
        }
    }

    #[test]
    fn peer_username_round_trip_with_owner_uid() {
        // Round-trip: for any uid `U` reachable via the current
        // process, `MailboxConfig { owner: peer_username(U)?, ... }
        // .owner_uid()? == U`. Using the current effective uid
        // guarantees the host's passwd file has an entry for it, so
        // the round-trip can run without any test-only resolver
        // overrides.
        let uid = unsafe { libc::geteuid() };
        let name = peer_username(uid).expect("current euid must resolve via getpwuid");
        let mb = MailboxConfig {
            address: "owner@example.com".into(),
            owner: name,
            hooks: vec![],
            trust: None,
            trusted_senders: None,
            allow_root_catchall: false,
        };
        assert_eq!(
            mb.owner_uid()
                .expect("owner_uid resolves the round-trip name"),
            uid,
            "uid -> username -> uid must round-trip identity",
        );
    }

    #[test]
    fn require_mailbox_owner_or_root_accepts_owner_by_name() {
        // Simulate a caller whose username resolves to "alice".
        let c = Caller::new(1001, 1001, None);
        let _ = c.username_cache.set(Some("alice".to_string()));
        let m = mb("alice");
        assert_eq!(
            require_mailbox_owner_or_root(&c, "alice", &m).unwrap(),
            AuthzDecision::Accept
        );
    }

    #[test]
    fn require_mailbox_owner_or_root_rejects_non_owner() {
        let c = Caller::new(1002, 1002, None);
        let _ = c.username_cache.set(Some("bob".to_string()));
        let m = mb("alice");
        let err = require_mailbox_owner_or_root(&c, "alice", &m).unwrap_err();
        assert_eq!(err.code, ErrCode::Eaccess);
        assert!(err.reason.contains("bob"));
        assert!(err.reason.contains("alice"));
    }

    #[test]
    fn require_mailbox_owner_or_root_root_is_bypass() {
        let c = Caller::internal_root();
        let m = mb("alice");
        assert_eq!(
            require_mailbox_owner_or_root(&c, "alice", &m).unwrap(),
            AuthzDecision::RootBypass
        );
    }

    #[test]
    fn require_mailbox_owner_or_root_unresolvable_uid_rejects() {
        let c = Caller::new(4_294_967_294, 4_294_967_294, None);
        let _ = c.username_cache.set(None);
        let m = mb("alice");
        let err = require_mailbox_owner_or_root(&c, "alice", &m).unwrap_err();
        assert_eq!(err.code, ErrCode::Eaccess);
        assert!(err.reason.contains("no resolvable username"));
    }

    #[test]
    #[tracing_test::traced_test]
    fn enforce_mailbox_owner_or_root_logs_accept() {
        let c = Caller::new(1001, 1001, None);
        let _ = c.username_cache.set(Some("alice".to_string()));
        let m = mb("alice");
        enforce_mailbox_owner_or_root("SEND", &c, "alice", &m).unwrap();
        assert!(logs_contain("decision=\"accept\""));
        assert!(logs_contain("verb=\"SEND\""));
        assert!(logs_contain("caller_username=\"alice\""));
    }

    #[test]
    #[tracing_test::traced_test]
    fn enforce_mailbox_owner_or_root_logs_root_bypass() {
        let c = Caller::internal_root();
        let m = mb("alice");
        enforce_mailbox_owner_or_root("SEND", &c, "alice", &m).unwrap();
        assert!(logs_contain("decision=\"root_bypass\""));
        assert!(logs_contain("caller_uid=0"));
    }

    #[test]
    #[tracing_test::traced_test]
    fn enforce_mailbox_owner_or_root_logs_reject() {
        let c = Caller::new(1002, 1002, None);
        let _ = c.username_cache.set(Some("bob".to_string()));
        let m = mb("alice");
        let err = enforce_mailbox_owner_or_root("SEND", &c, "alice", &m).unwrap_err();
        assert_eq!(err.code, ErrCode::Eaccess);
        // Wire reason is opaque — does not leak owner/mailbox identity.
        assert_eq!(err.reason, "not authorized");
        // Log keeps the rich diagnostic for the operator.
        assert!(logs_contain("decision=\"reject\""));
        assert!(logs_contain("caller_username=\"bob\""));
        assert!(logs_contain("not the owner of mailbox"));
    }

    #[test]
    fn enforce_mailbox_owner_or_root_wire_reason_does_not_leak_identity() {
        let c = Caller::new(1002, 1002, None);
        let _ = c.username_cache.set(Some("bob".to_string()));
        let m = mb("alice");
        let err = enforce_mailbox_owner_or_root("SEND", &c, "alice", &m).unwrap_err();
        // The wire reason must not contain the caller name, the
        // mailbox name, or the owner name.
        assert!(
            !err.reason.contains("alice"),
            "leaked mailbox: {}",
            err.reason
        );
        assert!(!err.reason.contains("bob"), "leaked caller: {}", err.reason);
        assert!(
            !err.reason.contains("owner"),
            "leaked owner: {}",
            err.reason
        );
    }
}
