# common.sh -- shared shell functions for the aitken.com admin tree
#
# Source after aitken.conf:
#   . "${ADMIN_BASE}/config/aitken.conf"
#   . "${ADMIN_BASE}/lib/common.sh"
#
# POSIX sh only.  No bash-isms.  The immutable-flag helpers (_set_immutable,
# _clear_immutable) are the sole OS-specific exception; all other OS-specific
# commands belong in a lib/freebsd.sh / lib/ubuntu.sh (not yet built -- such
# helpers are currently inlined in each setup.sh / package install.sh).


# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------

log() {
    printf '%s\n' "$*"
}

warn() {
    printf 'WARNING: %s\n' "$*" >&2
}

# Log message to both console and syslog (facility from ADMIN_SYSLOG_FACILITY).
syslog() {
    logger -t "aitken-admin" -p "${ADMIN_SYSLOG_FACILITY:-user}.info" "$*"
    log "$*"
}

# Log error to both stderr and syslog.
syslog_err() {
    logger -t "aitken-admin" -p "${ADMIN_SYSLOG_FACILITY:-user}.err" "$*"
    warn "$*"
}

# Print message to stderr and exit non-zero.
die() {
    printf 'ERROR: %s\n' "$*" >&2
    exit 1
}


# ---------------------------------------------------------------------------
# Interactive prompt
# ---------------------------------------------------------------------------

# Usage: ask_yes_no "Prompt text" [Y|N]
# Returns 0 for yes, 1 for no.  Second arg sets the default (Y or N).
ask_yes_no() {
    _prompt="$1"
    _default="$2"

    case "$_default" in
        [Yy]*) _hint=" [Y/n]: " ;;
        [Nn]*) _hint=" [y/N]: " ;;
        *)     _hint=" (y/n): " ;;
    esac

    while true; do
        printf '%s%s' "$_prompt" "$_hint"
        read -r _response

        if [ -z "$_response" ]; then
            case "$_default" in
                [Yy]*) return 0 ;;
                [Nn]*) return 1 ;;
            esac
        fi

        case "$_response" in
            [Yy]*) return 0 ;;
            [Nn]*) return 1 ;;
            *) log "Please answer 'y' or 'n'." ;;
        esac
    done
}


# ---------------------------------------------------------------------------
# File operations
# ---------------------------------------------------------------------------

# Usage: ensure_dir path owner group mode
# Idempotent: creates path if absent, then sets owner/group/mode unconditionally.
ensure_dir() {
    _path="$1" _owner="$2" _group="$3" _mode="$4"
    mkdir -p "$_path"
    chown "$_owner:$_group" "$_path"
    chmod "$_mode" "$_path"
}

# Set the immutable flag on a file (OS-aware).
_set_immutable() {
    case "$(uname -s)" in
        Linux)   chattr +i "$1" 2>/dev/null || warn "_set_immutable: chattr +i failed on $1" ;;
        FreeBSD) chflags schg "$1" 2>/dev/null || warn "_set_immutable: chflags schg failed on $1" ;;
    esac
}

# Clear the immutable flag on a file (OS-aware).
_clear_immutable() {
    case "$(uname -s)" in
        Linux)   chattr -i "$1" 2>/dev/null || warn "_clear_immutable: chattr -i failed on $1" ;;
        FreeBSD) chflags noschg "$1" 2>/dev/null || warn "_clear_immutable: chflags noschg failed on $1" ;;
    esac
}

# Usage: backup_file path
# Copies path to path.dist with the immutable flag set.
# No-op if path does not exist or if path.dist already exists (preserving the
# original OS copy across repeated script runs).
backup_file() {
    _dest="$1"
    [ -e "$_dest" ] || return 0
    [ -e "${_dest}.dist" ] && return 0
    cp -p "$_dest" "${_dest}.dist"
    _set_immutable "${_dest}.dist"
    syslog "backup: $_dest -> ${_dest}.dist"
}

# Usage: install_file src dest mode
# Backs up dest to dest.dist (immutable) if not already backed up, then
# installs src as dest with the given mode.  No-op if src and dest are
# already identical (avoids redundant NFS reads on re-runs).
install_file() {
    _src="$1" _dest="$2" _mode="$3"
    backup_file "$_dest"
    cmp -s "$_src" "$_dest" && return 0
    install -m "$_mode" "$_src" "$_dest"
    syslog "install: $_src -> $_dest"
}

# Usage: append_once marker file content
# Appends content to file only if marker string is not already present.
# Use a unique marker (e.g., a comment or a distinctive config key) to guard
# the append so re-running the script is safe.
append_once() {
    _marker="$1" _file="$2" _content="$3"
    if grep -qF "$_marker" "$_file" 2>/dev/null; then
        log "  $_file: '$_marker' already present, skipping"
        return 0
    fi
    printf '%s\n' "$_content" >> "$_file"
    log "  $_file: appended '$_marker' block"
}

# Usage: sha256_file <path>
# Print the lowercase-hex SHA-256 of a file, portably across our two target
# OSes: FreeBSD base `sha256 -q`, GNU coreutils `sha256sum`.
sha256_file() {
    if command -v sha256 >/dev/null 2>&1; then
        command sha256 -q "$1"                  # FreeBSD base
    elif command -v sha256sum >/dev/null 2>&1; then
        command sha256sum "$1" | cut -d' ' -f1  # GNU coreutils (Ubuntu)
    else
        die "sha256_file: neither sha256 nor sha256sum found in PATH"
    fi
}

# Usage: verify_sha256 <file> <sha256>
# Die unless <file> hashes to <sha256>; remove the bad file first, so a re-run
# re-fetches rather than rebuilding the same bad bytes.
#
# This exists because verifying inside `download` verifies only what we just
# FETCHED.  Every source build.sh guards its fetch on "tarball not already in
# BUILD_ROOT", so a pre-existing file was unpacked and built with no check at
# all -- and BUILD_ROOT is /tmp, mode 1777, where any local user can create a
# path that does not exist yet.  Observed, not theoretical: the 3.11.3 -> 3.11.5
# postfix upgrade on 2026-08-09 logged no `verified sha256:` line, because the
# tarball had been fetched by hand beforehand.  The rule is verify
# UNCONDITIONALLY, download conditionally -- so call this AFTER the fetch guard,
# on both paths.
#
# An absent checksum ("-" or empty) WARNS rather than dying: a source row with no
# pin is a gap in the inventory to fix there, not a reason to fail a build that
# worked yesterday.  Loud, because silence is what this whole helper is fixing.
#
# Locals are `_v`-prefixed on purpose: sh has no scoping, and `download` is
# holding _file/_want/_got when this is called right after it.
verify_sha256() {
    _vfile="$1" _vwant="${2:-}"
    [ -f "$_vfile" ] || die "verify_sha256: no such file: $_vfile"
    if [ -z "$_vwant" ] || [ "$_vwant" = "-" ]; then
        warn "no sha256 pinned for $(basename "$_vfile") -- built WITHOUT verification"
        return 0
    fi
    _vgot=$(sha256_file "$_vfile")
    if [ "$_vgot" != "$_vwant" ]; then
        rm -f "$_vfile"
        die "Checksum mismatch for [$(basename "$_vfile")]: want ${_vwant}, got ${_vgot}"
    fi
    log "  verified sha256: $(basename "$_vfile")"
}

# Usage: download url dest_dir dest_file [sha256]
# Fetches url into dest_dir/dest_file; dies if the HTTP status is not 200.  If a
# sha256 is given (and not "-"), verifies the fetched file against it and, on
# mismatch, removes the bad file before dying -- so a re-run re-fetches rather
# than building tampered/corrupt source.  This is the source-tarball integrity
# gate (ADR-0014: the inventory owns the checksum; dispatch.sh injects it as
# PKG_SHA256, which each source build.sh passes here).
download() {
    _url="$1" _dir="$2" _file="$3" _want="${4:-}"
    _status=$(command curl -s --show-error --fail \
        --output-dir "$_dir" -o "$_file" \
        "$_url" -w '%{http_code}' || true)
    if [ "$_status" -ne 200 ]; then
        die "Download of [$_url] failed (HTTP $_status)"
    fi
    if [ -n "$_want" ] && [ "$_want" != "-" ]; then
        _got=$(sha256_file "${_dir}/${_file}")
        if [ "$_got" != "$_want" ]; then
            rm -f "${_dir}/${_file}"
            die "Checksum mismatch for [${_file}]: want ${_want}, got ${_got}"
        fi
        log "  verified sha256: ${_file}"
    fi
}


# ---------------------------------------------------------------------------
# Service management
# ---------------------------------------------------------------------------

# Usage: restart_if_upgraded <rc-service-name>
# Restart an rc(8) service IFF the dispatcher flagged a package version change
# AND the service is currently running.  This is how an in-place package upgrade
# (ADR-0014) actually loads the new binary: build.sh/pkg lay down new bits, but a
# reload or a no-op leaves the OLD daemon running in memory -- only a restart
# swaps it.
#
# PKG_VERSION_CHANGED is exported by lib/dispatch.sh's probe-before-build step
# (installed version captured BEFORE the build/pkg mutates it, compared to the
# target).  It defaults to 1 (restart) when unset -- e.g. install.sh run
# standalone -- or when the probe was inconclusive, so we FAIL TOWARD restarting
# rather than silently running stale bits (a needless restart is cheap; a missed
# one is a latent bug).
#
# A service that is NOT running is left untouched: greenfield builds and the
# deliberate deferred/cutover starts (secret-gated milters, DNS zone data, etc.)
# own when a daemon first comes up; the freshly-installed binary runs when it is
# next started.  service(8) exists on both FreeBSD and Ubuntu.
restart_if_upgraded() {
    _svc="$1"
    [ "${PKG_VERSION_CHANGED:-1}" = 1 ] || return 0
    if service "$_svc" status >/dev/null 2>&1; then
        log "  ${_svc}: version changed -- restarting to load the new build"
        service "$_svc" restart
    fi
}


# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------

# Usage: require_nfs_mount path
# Dies if path is not currently mounted as an NFS filesystem.
# Use this to assert that manual pre-steps (e.g. zfs destroy zroot/home) were
# completed before running the script.
# Note: uses plain "mount" (no -t) to avoid ambiguity in how mount -t behaves
# without arguments across OS versions.  Filters by mount point, then checks
# for "nfs" in the type field.  Works on both Linux and FreeBSD.
require_nfs_mount() {
    _path="$1"
    if ! mount | grep " on ${_path} " | grep -q "nfs"; then
        die "$_path is not an NFS mount -- complete the pre-steps in the README first"
    fi
}

# Usage: require_known_domain hostname domain...
# Dies if hostname does not end with one of the supplied domains.
# Call with $VALID_DOMAINS unquoted so it word-splits into separate args.
require_known_domain() {
    _host="$1"
    shift
    for _domain in "$@"; do
        case "$_host" in
            *."$_domain") return 0 ;;
        esac
    done
    die "Hostname '$_host' does not match any allowed domain"
}

# Usage: validate_fqdn hostname
# Dies if hostname is not a valid FQDN.
validate_fqdn() {
    _host="$1"
    case "$_host" in
        localhost | localhost.localdomain)
            die "Hostname is set to a default placeholder ($_host)" ;;
        *.*)
            ;;
        *)
            die "Hostname '$_host' is not an FQDN (no domain component)" ;;
    esac
    case "$_host" in
        *[!a-zA-Z0-9.-]*)
            die "Hostname '$_host' contains invalid characters" ;;
    esac
    case "$_host" in
        .* | *. | -* | *-)
            die "Hostname '$_host' cannot start or end with a dot or hyphen" ;;
    esac
}
