#!/usr/bin/env bash
# =============================================================================
# onx-cron-write — Write managed cron jobs to /etc/cron.d/onoxsoft-<user>
#
# Purpose:
#   Replaces the entire cron.d file for the account in one atomic write. Each
#   line follows the cron.d schema:  <min> <hour> <day> <mon> <wday> <user> <cmd>
#   The file is owned root:root mode 0644 (the only mode crond will source).
#
# Input (stdin JSON):
#   {
#     "account_id": 1,
#     "username":   "onx_acme01",
#     "cron_jobs":  [
#       {
#         "minute":  "0",
#         "hour":    "3",
#         "day":     "*",
#         "month":   "*",
#         "weekday": "*",
#         "command": "php /home/onx_acme01/laravel/artisan schedule:run",
#         "mailto":  "user@example.com"     -- optional, per-row override
#       }
#     ],
#     "mailto":     "global-fallback@example.com"          -- optional default
#   }
#
# Output (stdout JSON):
#   {
#     "username":     "onx_acme01",
#     "jobs_count":   3,
#     "crontab_path": "/etc/cron.d/onoxsoft-onx_acme01",
#     "reloaded":     true
#   }
#
# Production requirements:
#   - cronie (RHEL) or cron (Debian) installed and running
#   - /etc/cron.d/ writable by root
#   - sudoers entry already covers onx-*
#
# Deployed to: /usr/local/onoxsoft/bin/onx-cron-write
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=_lib/common.sh
source "${SCRIPT_DIR}/_lib/common.sh"

readonly CRON_D_DIR="/etc/cron.d"

require_root

onx_json_input

ACCOUNT_ID=$(onx_json_field "account_id" "0")
USERNAME=$(onx_json_field   "username")
GLOBAL_MAILTO=$(onx_json_field "mailto" "")

[[ "${ACCOUNT_ID}" =~ ^[0-9]+$ ]] && [[ "${ACCOUNT_ID}" -gt 0 ]] \
    || onx_die 1 "account_id must be positive integer"
onx_validate_username "${USERNAME}"

CRON_FILE="${CRON_D_DIR}/onoxsoft-${USERNAME}"

# Defence-in-depth: the cron.d path must resolve under /etc/cron.d (no symlinks)
CRON_REAL_PARENT="$(realpath -m "${CRON_D_DIR}" 2>/dev/null || printf '%s' "${CRON_D_DIR}")"
[[ "${CRON_REAL_PARENT}" == "${CRON_D_DIR}" ]] \
    || onx_die 1 "cron.d directory '${CRON_D_DIR}' resolves outside /etc/cron.d/"

# ── Validate GLOBAL_MAILTO ───────────────────────────────────────────────────
if [[ -n "${GLOBAL_MAILTO}" ]]; then
    onx_validate_email "${GLOBAL_MAILTO}" >/dev/null
    GLOBAL_MAILTO="${ONX_EMAIL}"
fi

# ── Parse cron_jobs array ────────────────────────────────────────────────────
JOBS_JSON=$(printf '%s' "${INPUT}" | jq -c '.cron_jobs // []')
JOBS_COUNT=$(printf '%s' "${JOBS_JSON}" | jq 'length')

[[ "${JOBS_COUNT}" =~ ^[0-9]+$ ]] || JOBS_COUNT=0

# If empty array, treat as "clear all jobs for this user"
if [[ "${JOBS_COUNT}" -eq 0 ]]; then
    if [[ "${MOCK_MODE}" == "1" ]]; then
        : # nothing to do
    else
        rm -f "${CRON_FILE}" 2>/dev/null || true
    fi
    onx_audit "onx-cron" "clear user=${USERNAME} (no jobs)"
    jq -nc \
        --arg user "${USERNAME}" \
        --argjson n 0 \
        --arg path "${CRON_FILE}" \
        '{username:$user, jobs_count:$n, crontab_path:$path, reloaded:false}'
    exit 0
fi

# ── Validate every job field ─────────────────────────────────────────────────
# Cron field grammar: any non-empty string of digits, '*', '/', ',', '-'.
# We deliberately accept the full feature set (lists, ranges, steps) and only
# reject characters that could enable shell injection in the OUTPUT file.
validate_cron_field() {
    local f="$1" name="$2"
    [[ -n "$f" ]] || onx_die 1 "cron field '${name}' is empty"
    # Allow only:  0-9  *  /  ,  -  letters (for "JAN", "MON" etc)
    [[ "$f" =~ ^[0-9\*\/,\-A-Za-z]+$ ]] \
        || onx_die 1 "cron field '${name}' contains invalid chars: '${f}'"
}

# Iterate over jobs and pre-validate everything BEFORE writing anything
declare -a VALIDATED_LINES=()

for (( i=0; i<JOBS_COUNT; i++ )); do
    JOB=$(printf '%s' "${JOBS_JSON}" | jq -c ".[$i]")

    MIN=$(echo "${JOB}" | jq -r '.minute // ""')
    HOUR=$(echo "${JOB}" | jq -r '.hour // ""')
    DAY=$(echo "${JOB}" | jq -r '.day // ""')
    MONTH=$(echo "${JOB}" | jq -r '.month // ""')
    WDAY=$(echo "${JOB}" | jq -r '.weekday // ""')
    CMD=$(echo "${JOB}" | jq -r '.command // ""')
    ROW_MAILTO=$(echo "${JOB}" | jq -r '.mailto // ""')

    validate_cron_field "${MIN}"   "minute"
    validate_cron_field "${HOUR}"  "hour"
    validate_cron_field "${DAY}"   "day"
    validate_cron_field "${MONTH}" "month"
    validate_cron_field "${WDAY}"  "weekday"

    [[ -n "${CMD}" ]] || onx_die 1 "cron_jobs[${i}].command is required"

    # Reject newlines / NULs in command (would break cron.d format)
    [[ "${CMD}" == *$'\n'* ]] && onx_die 1 "cron_jobs[${i}].command contains newline"
    [[ "${CMD}" == *$'\0'* ]] && onx_die 1 "cron_jobs[${i}].command contains NUL byte"

    # Per-row MAILTO override is allowed but cron.d only honours one MAILTO at
    # the top of the file; per-row mailto is rendered as a comment for ops eyes.
    if [[ -n "${ROW_MAILTO}" ]]; then
        onx_validate_email "${ROW_MAILTO}" >/dev/null
        VALIDATED_LINES+=("# job-mailto: ${ONX_EMAIL}")
    fi

    # The cron.d format requires a username field between the schedule and the
    # command. We pin it to ${USERNAME} so commands always run unprivileged.
    LINE="${MIN} ${HOUR} ${DAY} ${MONTH} ${WDAY} ${USERNAME} ${CMD}"
    VALIDATED_LINES+=("${LINE}")
done

# ── Write file atomically via tempfile + mv ──────────────────────────────────
TMP_FILE="$(mktemp -t onx-cron.XXXXXX)"
chmod 0644 "${TMP_FILE}"

{
    printf '# Onoxsoft managed — DO NOT EDIT MANUALLY\n'
    printf '# Account: %s (id=%s)\n' "${USERNAME}" "${ACCOUNT_ID}"
    printf '# Written: %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
    printf 'SHELL=/bin/bash\n'
    printf 'PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n'
    if [[ -n "${GLOBAL_MAILTO}" ]]; then
        printf 'MAILTO=%s\n' "${GLOBAL_MAILTO}"
    else
        printf 'MAILTO=""\n'
    fi
    printf '\n'
    printf '%s\n' "${VALIDATED_LINES[@]}"
} > "${TMP_FILE}"

# Syntax check: try `crontab -T` if available (newer cronies support it)
if [[ "${MOCK_MODE}" != "1" ]] && command -v crontab >/dev/null 2>&1; then
    # crontab -T is not supported by all distros; ignore failure (best-effort)
    crontab -T "${TMP_FILE}" 2>/dev/null || \
        onx_log "WARNING: crontab -T not supported or syntax check skipped"
fi

# Atomic install
if [[ "${MOCK_MODE}" == "1" ]]; then
    rm -f "${TMP_FILE}"
    RELOADED="false"
else
    mv -f "${TMP_FILE}" "${CRON_FILE}" || onx_die 3 "failed to install ${CRON_FILE}"
    chown root:root "${CRON_FILE}"
    chmod 0644 "${CRON_FILE}"

    # ── Reload cron daemon ───────────────────────────────────────────────────
    # cron.d files are picked up on the next minute boundary automatically,
    # but we kick the daemon to surface syntax errors immediately. Try cronie
    # then cron then crond unit names; ignore failures.
    RELOADED="true"
    if command -v systemctl >/dev/null 2>&1; then
        systemctl reload-or-restart cronie 2>/dev/null \
            || systemctl reload-or-restart cron 2>/dev/null \
            || systemctl reload-or-restart crond 2>/dev/null \
            || { onx_log "WARNING: cron reload failed (none of cronie/cron/crond)"; RELOADED="false"; }
    fi
fi

onx_audit "onx-cron" "write user=${USERNAME} jobs=${JOBS_COUNT} path=${CRON_FILE}"

jq -nc \
    --arg user "${USERNAME}" \
    --argjson n "${JOBS_COUNT}" \
    --arg path "${CRON_FILE}" \
    --argjson reloaded "${RELOADED}" \
    '{
        username: $user,
        jobs_count: $n,
        crontab_path: $path,
        reloaded: $reloaded
    }'
