#!/usr/bin/env bash
# =============================================================================
# _lib/common.sh — Shared utilities for onx-* sysapi wrapper scripts
#
# Source this file at the top of every sysapi script:
#   source "$(dirname "$0")/_lib/common.sh"
#
# Provides:
#   onx_validate_username()    — regex guard for onx_<4-12 alnum> pattern
#   onx_validate_domain()      — RFC 1035 lite domain check
#   onx_json_input()           — read + validate stdin JSON, sets $INPUT
#   onx_json_field(key, def)   — extract field from $INPUT via jq
#   onx_rollback_register(cmd) — push command onto rollback stack
#   onx_rollback_run()         — execute rollback stack in LIFO order (trap handler)
#   onx_die(code, msg)         — emit {"error":"..."} to stderr + exit
#   onx_log(msg)               — append to /var/log/onox/sysapi.log
#
# Backward-compatible aliases:
#   json_ok(json_str)          — print JSON string and exit 0
#   json_fail(code, msg)       — onx_die wrapper
#   require_cmd(cmd)           — check command exists
#   require_root()             — check running as root
#
# Exit codes (same across all onx-* scripts):
#   0  — success
#   1  — invalid input (regex / missing field)
#   2  — preflight fail (dependency missing, user already exists, etc.)
#   3  — execution fail
#   4  — execution fail + rollback succeeded
#   5  — execution fail + rollback FAILED (critical)
# =============================================================================

set -euo pipefail

# ─── Dependency check ────────────────────────────────────────────────────────
command -v jq >/dev/null 2>&1 || {
    printf '{"error":"jq is required but not installed"}\n' >&2
    exit 2
}

# ─── Globals (populated by onx_json_input) ───────────────────────────────────
INPUT=""
declare -a _ONX_ROLLBACK_STACK=()

# ─── Logging ─────────────────────────────────────────────────────────────────
readonly _ONX_LOG_FILE="/var/log/onox/sysapi.log"

onx_log() {
    local msg="${1:-}"
    local ts
    ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    local script
    script=$(basename "${BASH_SOURCE[1]:-${0:-unknown}}")
    printf '[%s] [%s] %s\n' "$ts" "$script" "$msg" >> "$_ONX_LOG_FILE" 2>/dev/null || true
}

# Keep old _log alias
_log() { onx_log "$*"; }

# ─── Error exit ──────────────────────────────────────────────────────────────
# onx_die CODE MESSAGE
# Emits {"error": "MESSAGE"} to stderr and exits with CODE.
onx_die() {
    local code="${1:-3}"
    local msg="${2:-unknown error}"
    printf '{"error":%s}\n' "$(printf '%s' "$msg" | jq -Rs '.')" >&2
    onx_log "FATAL(exit=$code): $msg"
    exit "$code"
}

# ─── JSON input ──────────────────────────────────────────────────────────────
# Read all of stdin into $INPUT. Validates that it is a JSON object.
onx_json_input() {
    INPUT=$(cat)
    if [[ -z "$INPUT" ]]; then
        onx_die 1 "empty stdin — JSON input required"
    fi
    echo "$INPUT" | jq -e 'type == "object"' >/dev/null 2>&1 || \
        onx_die 1 "stdin is not a valid JSON object"
}

# ─── Field extraction ────────────────────────────────────────────────────────
# onx_json_field KEY [DEFAULT]
# Prints the field value (or DEFAULT if null/missing). Caller assigns to var.
onx_json_field() {
    local key="$1"
    local default="${2:-}"
    local val
    val=$(echo "$INPUT" | jq -r --arg k "$key" --arg d "$default" \
        'if has($k) and (.[$k] != null) then .[$k] | tostring else $d end')
    printf '%s' "$val"
}

# Internal helper (backward compat)
_json_get() {
    local _input="$1" key="$2"
    echo "$_input" | jq -r --arg k "$key" '.[$k] // empty'
}

# ─── Validation ──────────────────────────────────────────────────────────────
# onx_validate_username USERNAME
onx_validate_username() {
    local u="${1:-}"
    [[ "$u" =~ ^onx_[a-z0-9]{4,12}$ ]] || \
        onx_die 1 "invalid username '${u}': must match ^onx_[a-z0-9]{4,12}\$"
}

# onx_validate_domain DOMAIN — RFC 1035 lite
onx_validate_domain() {
    local d="${1:-}"
    [[ "$d" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$ ]] || \
        onx_die 1 "invalid domain '${d}'"
}

# ─── Rollback stack ──────────────────────────────────────────────────────────
# onx_rollback_register CMD
# Pushes a shell command string onto the rollback stack.
# Wire up in the script:   trap 'onx_rollback_run' ERR
onx_rollback_register() {
    _ONX_ROLLBACK_STACK+=("$1")
}

# Execute all rollback commands in LIFO order.
# Exits with 4 if all rollback steps succeed, 5 if any fail.
onx_rollback_run() {
    local orig_exit=$?
    if [[ ${#_ONX_ROLLBACK_STACK[@]} -eq 0 ]]; then
        exit "${orig_exit:-3}"
    fi

    onx_log "ROLLBACK triggered (orig_exit=$orig_exit), ${#_ONX_ROLLBACK_STACK[@]} step(s)"
    local rb_failed=0

    for (( i=${#_ONX_ROLLBACK_STACK[@]}-1; i>=0; i-- )); do
        local cmd="${_ONX_ROLLBACK_STACK[$i]}"
        onx_log "ROLLBACK step: $cmd"
        if bash -c "$cmd" 2>/dev/null; then
            onx_log "ROLLBACK OK: $cmd"
        else
            onx_log "ROLLBACK FAILED: $cmd"
            rb_failed=1
        fi
    done

    if [[ $rb_failed -eq 0 ]]; then
        exit 4
    else
        exit 5
    fi
}

# ─── onx_require_json ────────────────────────────────────────────────────────
# Validate that a raw JSON string is a non-empty object.
onx_require_json() {
    local raw="${1:-}"
    [[ -n "$raw" ]] || onx_die 1 "empty stdin — JSON input required"
    echo "$raw" | jq -e 'type == "object"' >/dev/null 2>&1 || \
        onx_die 1 "stdin is not a valid JSON object"
}

# ─── onx_json_get ────────────────────────────────────────────────────────────
# onx_json_get JSON_STR KEY [DEFAULT]
onx_json_get() {
    local raw="$1" key="$2" default="${3:-}"
    local val
    val=$(printf '%s' "$raw" | jq -r --arg k "$key" --arg d "$default" \
        'if has($k) and (.[$k] != null) then .[$k] | tostring else $d end')
    printf '%s' "$val"
}

# ─── onx_json_get_bool ───────────────────────────────────────────────────────
# Returns "true" or "false" string.
onx_json_get_bool() {
    local raw="$1" key="$2" default="${3:-false}"
    local val
    val=$(printf '%s' "$raw" | jq -r --arg k "$key" \
        'if has($k) then (if .[$k] then "true" else "false" end) else null end')
    [[ "$val" == "null" ]] && val="$default"
    printf '%s' "$val"
}

# ─── onx_json_array_items ────────────────────────────────────────────────────
# Outputs each element of a JSON array field, one per line (for while-read).
onx_json_array_items() {
    local raw="$1" key="$2"
    printf '%s' "$raw" | jq -r --arg k "$key" \
        'if has($k) and (.[$k] | type == "array") then .[$k][] else empty end'
}

# ─── onx_json_out ────────────────────────────────────────────────────────────
# Build and print a flat JSON object from KEY VALUE KEY VALUE ... pairs.
# Numeric strings (all digits) are printed unquoted; others are quoted.
# Usage: onx_json_out key1 val1 key2 val2 ...
onx_json_out() {
    local args=("$@")
    local count=${#args[@]}
    local json="{"
    local sep=""
    local i=0
    while (( i < count )); do
        local k="${args[$i]}"
        local v="${args[$((i+1))]}"
        i=$(( i + 2 ))
        # Determine value type: bool literals, numerics, or strings
        if [[ "$v" == "true" || "$v" == "false" || "$v" == "null" ]]; then
            json+="${sep}$(printf '"%s":%s' "$k" "$v")"
        elif [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
            json+="${sep}$(printf '"%s":%s' "$k" "$v")"
        else
            json+="${sep}$(printf '"%s":%s' "$k" "$(printf '%s' "$v" | jq -Rs '.')")"
        fi
        sep=","
    done
    json+="}"
    printf '%s\n' "$json"
}

# ─── Backward-compatible aliases ─────────────────────────────────────────────
json_ok()   { printf '%s\n' "$1"; exit 0; }
json_fail() { onx_die "${1:-3}" "${2:-unknown error}"; }
require_cmd() { command -v "$1" &>/dev/null || onx_die 2 "Required command not found: $1"; }
require_root() { [[ $EUID -eq 0 ]] || onx_die 2 "Must be run as root"; }

# ─── Secrets sourcing ────────────────────────────────────────────────────────
# Production secrets live in /etc/onoxsoft/secrets.env (mode 0600, root:root).
# Fields used by mailbox scripts:
#   ONX_FTP_DB, ONX_PDNS_DB, ONX_MAIL_DB
#   ONX_MAIL_DB_HOST, ONX_MAIL_DB_USER, ONX_MAIL_DB_PASS
#   DOVECOT_DB_HOST, DOVECOT_DB_USER, DOVECOT_DB_PASS, DOVECOT_DB_NAME
# Mocking: MOCK_MODE=1 skips actual mysql / doveadm exec for dev runners.
_ONX_SECRETS_FILE="${ONX_SECRETS_FILE:-/etc/onoxsoft/secrets.env}"
if [[ -r "$_ONX_SECRETS_FILE" ]]; then
    # shellcheck disable=SC1090
    source "$_ONX_SECRETS_FILE"
fi

# Provide sane defaults (so mock-mode / dev runs don't blow up on unset vars)
: "${ONX_MAIL_DB:=onx_mail}"
: "${ONX_MAIL_DB_HOST:=127.0.0.1}"
: "${ONX_MAIL_DB_USER:=onx_mail}"
: "${ONX_MAIL_DB_PASS:=}"
: "${DOVECOT_DB_HOST:=${ONX_MAIL_DB_HOST}}"
: "${DOVECOT_DB_USER:=${ONX_MAIL_DB_USER}}"
: "${DOVECOT_DB_PASS:=${ONX_MAIL_DB_PASS}}"
: "${DOVECOT_DB_NAME:=${ONX_MAIL_DB}}"
: "${ONX_FTP_DB:=onx_ftp}"
: "${ONX_PDNS_DB:=onx_pdns}"
: "${MOCK_MODE:=0}"

# ─── _mycnf_tmp ──────────────────────────────────────────────────────────────
# Write a temporary [client] my.cnf so we never expose --password on argv.
# Sets _MYCNF_TMP global; auto-cleanup via EXIT trap.
# Picks MAIL DB creds by default; callers can override DB by passing the
# database name to mysql / mysql_exec.
_mycnf_tmp() {
    _MYCNF_TMP="$(mktemp -t onx-mycnf.XXXXXX)"
    chmod 600 "$_MYCNF_TMP"
    {
        printf '[client]\n'
        printf 'host=%s\n' "${ONX_MAIL_DB_HOST}"
        printf 'user=%s\n' "${ONX_MAIL_DB_USER}"
        printf 'password=%s\n' "${ONX_MAIL_DB_PASS}"
    } > "$_MYCNF_TMP"
    # Lazy-add an EXIT trap to wipe the tmpfile
    trap '[[ -n "${_MYCNF_TMP:-}" ]] && rm -f "$_MYCNF_TMP" 2>/dev/null || true' EXIT
}

# ─── mysql_exec ──────────────────────────────────────────────────────────────
# mysql_exec DB_NAME SQL [SQL2 ...]
# Runs SQL against DB_NAME (empty string = no DB). Honours MOCK_MODE=1 by
# echoing the SQL to syslog and returning success.
mysql_exec() {
    local db="${1:-}"
    shift
    local sql="$*"
    if [[ "${MOCK_MODE}" == "1" ]]; then
        onx_log "MOCK mysql_exec db='${db}' sql='${sql}'"
        printf '\n'
        return 0
    fi
    [[ -n "${_MYCNF_TMP:-}" ]] || _mycnf_tmp
    if [[ -n "$db" ]]; then
        mysql --defaults-extra-file="$_MYCNF_TMP" --batch --silent --raw "$db" -e "$sql"
    else
        mysql --defaults-extra-file="$_MYCNF_TMP" --batch --silent --raw -e "$sql"
    fi
}

# ─── mysql_exec_stdin ────────────────────────────────────────────────────────
# Like mysql_exec but reads SQL from stdin. Useful for multi-statement scripts.
mysql_exec_stdin() {
    local db="${1:-}"
    local sql
    sql="$(cat)"
    if [[ "${MOCK_MODE}" == "1" ]]; then
        onx_log "MOCK mysql_exec_stdin db='${db}' sql_lines=$(echo "$sql" | wc -l)"
        printf '\n'
        return 0
    fi
    [[ -n "${_MYCNF_TMP:-}" ]] || _mycnf_tmp
    if [[ -n "$db" ]]; then
        printf '%s' "$sql" | mysql --defaults-extra-file="$_MYCNF_TMP" --batch --silent --raw "$db"
    else
        printf '%s' "$sql" | mysql --defaults-extra-file="$_MYCNF_TMP" --batch --silent --raw
    fi
}

# ─── onx_validate_email ──────────────────────────────────────────────────────
# RFC 5321-lite check; lowercase only, single @, TLD >= 2.
# Sets globals ONX_EMAIL (lowercased), ONX_EMAIL_LOCAL, ONX_EMAIL_DOMAIN.
# Designed to be invoked WITHOUT command-substitution so the globals survive
# into the caller's scope (subshells eat globals).
#   Usage:  onx_validate_email "$RAW"
#           EMAIL="$ONX_EMAIL"; LOCAL="$ONX_EMAIL_LOCAL"; DOMAIN="$ONX_EMAIL_DOMAIN"
onx_validate_email() {
    local e="${1:-}"
    [[ -n "$e" ]] || onx_die 1 "email is required"
    # Down-case for storage consistency (RFC 5321 local-part is case-sensitive
    # in theory, but virtually no MTA respects that — and Dovecot SQL
    # auth_username_format normalises to lowercase by default).
    local lc
    lc="$(printf '%s' "$e" | tr '[:upper:]' '[:lower:]')"
    [[ "$lc" =~ ^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$ ]] || \
        onx_die 1 "invalid email format: '${e}'"
    ONX_EMAIL="$lc"
    ONX_EMAIL_LOCAL="${lc%@*}"
    ONX_EMAIL_DOMAIN="${lc#*@}"
    # Belt-and-braces: domain must also pass RFC 1035-lite
    onx_validate_domain "$ONX_EMAIL_DOMAIN"
    # Reject path-traversal-friendly local parts (no dot-dot, no leading dot)
    [[ "$ONX_EMAIL_LOCAL" == *..* ]] && onx_die 1 "invalid email local-part (consecutive dots)"
    [[ "$ONX_EMAIL_LOCAL" == .* ]]   && onx_die 1 "invalid email local-part (leading dot)"
    # Still echo for callers that prefer command-substitution capture; just
    # be aware ONX_EMAIL_* globals only survive when this is NOT in $(…).
    printf '%s' "$lc"
}

# ─── onx_validate_vmail_path ─────────────────────────────────────────────────
# Reject anything outside /var/vmail/. Resolves symlinks via realpath when
# the path exists; otherwise prefix-checks the literal string.
onx_validate_vmail_path() {
    local p="${1:-}"
    [[ -n "$p" ]] || onx_die 1 "vmail path empty"
    local resolved
    if [[ -e "$p" ]]; then
        resolved="$(realpath -m "$p" 2>/dev/null || printf '%s' "$p")"
    else
        # realpath -m on the parent so we follow any symlink components
        local parent
        parent="$(dirname "$p")"
        if [[ -e "$parent" ]]; then
            resolved="$(realpath -m "$parent" 2>/dev/null)/$(basename "$p")"
        else
            resolved="$p"
        fi
    fi
    case "$resolved" in
        /var/vmail|/var/vmail/*) : ;;
        *) onx_die 1 "vmail path outside /var/vmail: '${p}'" ;;
    esac
    printf '%s' "$resolved"
}

# ─── onx_audit ───────────────────────────────────────────────────────────────
# Mirror an event to syslog (tag: onx-mailbox by default) for SIEM ingestion.
# onx_audit [TAG] MESSAGE
onx_audit() {
    local tag msg
    if [[ $# -ge 2 ]]; then
        tag="$1"; shift
        msg="$*"
    else
        tag="onx-mailbox"
        msg="${1:-}"
    fi
    if command -v logger >/dev/null 2>&1; then
        logger -t "$tag" -- "$msg" 2>/dev/null || true
    fi
    onx_log "AUDIT[$tag] $msg"
}

# ─── onx_doveadm_pw ──────────────────────────────────────────────────────────
# Hash a plaintext password using Dovecot's SHA512-CRYPT scheme. Honours
# MOCK_MODE=1 to skip the doveadm dependency on developer machines.
onx_doveadm_pw() {
    local plain="${1:-}"
    [[ -n "$plain" ]] || onx_die 1 "password is required"
    if [[ "${MOCK_MODE}" == "1" ]] || ! command -v doveadm >/dev/null 2>&1; then
        # Fall back to openssl (SHA-512 crypt; same scheme tag prefix).
        if command -v openssl >/dev/null 2>&1; then
            local salt
            salt="$(openssl rand -base64 12 | tr -d '/+=' | cut -c1-16)"
            printf '{SHA512-CRYPT}%s' "$(openssl passwd -6 -salt "${salt}" "${plain}")"
            return 0
        fi
        onx_die 2 "doveadm not found and openssl unavailable (cannot hash password)"
    fi
    doveadm pw -s SHA512-CRYPT -p "${plain}"
}
