#!/bin/sh
# PulseWatch crontab audit — find the scheduled jobs nobody is watching.
#
#   curl -fsSL https://pulsewatcher.vercel.app/audit.sh | sh
#
# READ-ONLY, AND NOTHING ABOUT YOUR MACHINE IS SENT ANYWHERE. This script never
# contacts PulseWatch or any other server. It reads your crontabs, prints a
# report, and exits. It writes no files unless you pass --apply.
#
# One honest caveat, because "no network calls" would be a lie: when `kubectl`
# or `docker` are installed, this asks them to LIST scheduled jobs, and kubectl
# in turn queries whatever cluster you are already pointed at. That is your own
# infrastructure, it is a read, and nothing is sent to us — but it is a socket,
# so it is stated rather than glossed over. `--no-cluster` skips both.
#
# Source: https://github.com/walid-zeroual/crontab-audit-script
# Licence: MIT
#
# You are being asked to pipe a script into a shell. Read it first. The copy on
# GitHub is this file. Nothing in it sends anything to PulseWatch.

set -eu

VERSION="1.0.0"
PING_BASE="https://pulsewatcher.up.railway.app/ping"
APPLY=0
KEY=""
COLOR=1
QUIET=0
NO_CLUSTER=0

# Overridable so the test suite can point the collectors at a fixture tree
# instead of the real /etc. Defaults to /etc, which is what everyone gets.
ETC="${PW_AUDIT_ETC:-/etc}"
# Same idea for the launchd trees, so macOS collection is testable from a
# fixture rather than only on a Mac.
LAUNCHD_HOME="${PW_AUDIT_LAUNCHD_HOME:-${HOME:-}}"
LAUNCHD_ROOT="${PW_AUDIT_LAUNCHD_ROOT:-}"

# Unit separator: the field delimiter for the internal job table. Cannot occur
# in a crontab line, which tab and pipe both can.
US=$(printf '\037')

# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------

usage() {
  cat <<'USAGE'
PulseWatch crontab audit

  audit.sh [options]

Finds every scheduled job on this machine and tells you which ones are not
monitored. Read-only and offline unless you ask otherwise.

Options:
  --key <pwa_...>   Embed your auto-provision key in the suggested lines.
                    Without it the lines carry a <your-key> placeholder.
  --apply           Rewrite your user crontab, after a timestamped backup, a
                    diff, and an explicit yes. Never touches /etc.
  --url <base>      Point the generated pings elsewhere (self-hosting).
  --no-cluster      Skip kubectl and docker. Those two are the only things here
                    that open a socket at all — to your own cluster or daemon,
                    to list jobs. Use this if you want zero sockets, full stop.
  --no-color        Plain output.
  --quiet           Suppress the report; print only what needs acting on.
  --version         Print the version and exit.
  --help            This.

Exit codes:
  0  every scheduled job is monitored, or there are none
  1  unmonitored jobs were found        <- useful as a CI or config check
  2  the script could not run
USAGE
}

parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --apply) APPLY=1 ;;
      --key)
        shift
        if [ $# -eq 0 ]; then echo "audit.sh: --key needs a value" >&2; exit 2; fi
        KEY="$1"
        ;;
      --key=*) KEY="${1#--key=}" ;;
      --url)
        shift
        if [ $# -eq 0 ]; then echo "audit.sh: --url needs a value" >&2; exit 2; fi
        PING_BASE="${1%/}/ping"
        ;;
      --url=*) PING_BASE="${1#--url=}"; PING_BASE="${PING_BASE%/}/ping" ;;
      --no-cluster) NO_CLUSTER=1 ;;
      --no-color) COLOR=0 ;;
      --quiet|-q) QUIET=1 ;;
      --version|-V) echo "$VERSION"; exit 0 ;;
      --help|-h) usage; exit 0 ;;
      *) echo "audit.sh: unknown option $1 (try --help)" >&2; exit 2 ;;
    esac
    shift
  done
}

# ---------------------------------------------------------------------------
# Naming
# ---------------------------------------------------------------------------

# A monitor name derived from the command. Skips wrappers and interpreters, so
# `/usr/bin/python3 /opt/etl/nightly_export.py` is called nightly_export rather
# than python3 — which matters when six jobs on a box are all python3.
derive_name() {
  printf '%s\n' "$1" | awk '
    function base(p,  n) { n = p; sub(/^.*\//, "", n); sub(/\.[A-Za-z0-9]+$/, "", n); return n }
    {
      skip = 0
      for (i = 1; i <= NF; i++) {
        tok = $i
        if (tok ~ /^[A-Za-z_][A-Za-z0-9_]*=/) continue      # VAR=value
        if (tok ~ /^[<>|&(;]/) continue                      # redirection, grouping, chaining
        if (tok ~ /^-/) continue                             # a flag
        # A wrapper that takes an argument of its own: cd takes a directory,
        # flock a lock file, timeout a duration. Without this, `flock -n
        # /tmp/lock /opt/backup/run.sh` gets called "lock" and `cd /srv &&
        # ./deploy.sh` gets called "srv" — both named after scaffolding rather
        # than after the job, which is the one thing the name has to get right.
        if (skip > 0) { skip--; continue }
        b = base(tok)
        if (b ~ /^(cd|flock|timeout|chroot|su|chrt)$/) { skip = 1; continue }
        if (b ~ /^(env|nice|ionice|time|nohup|sudo|chronic|setsid|xargs)$/) continue
        if (b ~ /^(sh|bash|dash|zsh|ksh|python[0-9.]*|perl|ruby|node|nodejs|php|java|Rscript|pwsh)$/) continue
        print b
        exit
      }
    }
  '
}

# Lowercase, keep [a-z0-9_-], collapse separators, trim, cap at 64 — the shape
# the /ping/<key>/<slug> endpoint accepts.
slugify() {
  printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
    | sed -e 's/[^a-z0-9_-][^a-z0-9_-]*/-/g' -e 's/--*/-/g' -e 's/^[-_][-_]*//' -e 's/[-_][-_]*$//' \
    | cut -c1-64
}

# `fail` and `start` are reserved: /ping/<key>/fail is a failure signal, not a
# monitor named fail. Renaming beats silently pinging the wrong meaning.
safe_slug() {
  s=$(slugify "$1")
  if [ -z "$s" ]; then s="cron-job"; fi
  case "$s" in
    fail|start) s="${s}-job" ;;
  esac
  case "$s" in
    [a-z0-9]*) : ;;
    *) s="job-$s" ;;
  esac
  printf '%s' "$s"
}

ping_url() { printf '%s/%s/%s' "$PING_BASE" "${KEY:-<your-key>}" "$1"; }

# ---------------------------------------------------------------------------
# Is it already monitored?
# ---------------------------------------------------------------------------

# yes    already pings PulseWatch
# other  pings another monitoring service — watched, just not by us. Counting
#        these as unmonitored would be a lie told to sell something.
# no     nothing is watching this
monitored_state() {
  case "$1" in
    *pulsewatcher*|*pulsewatch*) printf 'yes'; return 0 ;;
  esac
  case "$1" in
    *hc-ping.com*|*healthchecks.io*|*cronitor.link*|*cronitor.io*|\
    *betteruptime.com*|*uptime.betterstack.com*|*nosnch.in*|\
    *deadmanssnitch.com*|*cronhub.io*|*newrelic.com*)
      printf 'other'; return 0 ;;
  esac
  printf 'no'
}

# ---------------------------------------------------------------------------
# Ranking
# ---------------------------------------------------------------------------

# Most consequential first. Two ideas: what the job DOES, and how rarely it
# runs. A weekly invoice run that fails is invisible for seven days and you get
# one chance a week to notice; a five-minute cache warm that misses a beat is
# self-healing by the time you read about it.
score_job() {
  sched="$1"; cmd="$2"
  s=0

  lower=$(printf '%s' "$cmd" | tr '[:upper:]' '[:lower:]')
  case "$lower" in *backup*|*dump*|*snapshot*|*archive*) s=$((s + 120)) ;; esac
  case "$lower" in *invoice*|*billing*|*payout*|*charge*|*reconcil*) s=$((s + 120)) ;; esac
  case "$lower" in *sync*|*export*|*import*|*replicat*|*etl*) s=$((s + 100)) ;; esac
  case "$lower" in *report*|*digest*|*statement*) s=$((s + 90)) ;; esac
  case "$lower" in *cleanup*|*prune*|*rotate*|*vacuum*|*purge*) s=$((s + 80)) ;; esac

  case "$sched" in
    @yearly|@annually) s=$((s + 70)) ;;
    @monthly) s=$((s + 65)) ;;
    @weekly) s=$((s + 60)) ;;
    @daily|@midnight) s=$((s + 50)) ;;
    @reboot) s=$((s + 30)) ;;
    @hourly) s=$((s + 20)) ;;
    *)
      min=$(printf '%s' "$sched" | awk '{print $1}')
      hour=$(printf '%s' "$sched" | awk '{print $2}')
      dom=$(printf '%s' "$sched" | awk '{print $3}')
      dow=$(printf '%s' "$sched" | awk '{print $5}')
      case "$min" in
        '*'|*/*|*,*|*-*) s=$((s + 5)) ;;
        *)
          if [ "$hour" = "*" ]; then
            s=$((s + 20))
          elif [ "$dom" != "*" ]; then
            s=$((s + 65))
          elif [ "$dow" != "*" ]; then
            s=$((s + 60))
          else
            s=$((s + 50))
          fi
          ;;
      esac
      # Between midnight and 06:00 nobody is awake to notice it failing.
      case "$hour" in
        0|1|2|3|4|5|00|01|02|03|04|05) s=$((s + 15)) ;;
      esac
      ;;
  esac
  printf '%d' "$s"
}

# ---------------------------------------------------------------------------
# The suggested replacement line
# ---------------------------------------------------------------------------

# `&& curl` rather than `; curl`, because a heartbeat means the job SUCCEEDED.
# A semicolon would ping after a failure too — reporting health that is not
# there, which is worse than no monitoring at all.
#
# The ping is appended rather than the line being wrapped, because in
# `cmd >> log 2>&1 && ping` the redirection binds to cmd alone. Wrapping would
# change where the job's own output goes.
suggested_command() {
  printf '%s && curl -fsS -m 10 --retry 5 %s' "$1" "$(ping_url "$2")"
}

# What makes the naive append wrong.
#
# Every one of these is a shape where `<command> && curl` is not merely ugly but
# WRONG — it either breaks the job or reports health that is not there. A
# crontab line that looks right and is not is a job that stops running, or
# worse, a green monitor over a job that has been failing for weeks.
#
# This function is the single gate: the report refuses to print a pasteable line
# when it returns anything, and --apply refuses to rewrite. Adding a shape here
# fixes both.
line_warnings() {
  cmd="$1"; sched="$2"; src="$3"
  case "$cmd" in
    *'%'*)
      printf '%s\n' 'contains %, which cron turns into a newline — escape it as \% or the job breaks'
      ;;
  esac
  case "$cmd" in
    *'&')
      printf '%s\n' 'ends with & (backgrounded) — the ping would fire before the job finished'
      ;;
  esac
  # cron hands the whole line to a shell, and the shell discards everything
  # after a `#` that starts a word. The appended ping would land inside the
  # comment and never run — while the job keeps working perfectly, so the
  # monitor goes red and stays red. Monitoring that reports the opposite of
  # reality is the worst thing this script could produce.
  case "$cmd" in
    *' #'*)
      printf '%s\n' 'has a trailing # comment — the shell discards everything after it, including the ping'
      ;;
  esac
  # `&&` binds to the LAST command of a list. `a || b && ping` pings whenever
  # the fallback succeeds, which is precisely when the job failed. A trailing
  # `;` is worse still: `…; && curl` is a syntax error and the job never runs.
  case "$cmd" in
    *';'*|*'||'*)
      printf '%s\n' 'contains ; or || — && binds to the last command, so the ping would fire even when the job failed'
      ;;
  esac
  # A pipeline's exit status is its last stage, and cron's /bin/sh has no
  # pipefail. `mysqldump | gzip && ping` pings whenever gzip succeeds — which it
  # does even when the dump it compressed is truncated.
  case "$(printf '%s' "$cmd" | sed 's/||/ /g')" in
    *'|'*)
      printf '%s\n' 'contains a pipeline — only the last stage decides success, so a failing first stage would still ping'
      ;;
  esac
  case "$src" in
    */cron.hourly|*/cron.daily|*/cron.weekly|*/cron.monthly)
      printf '%s\n' 'run-parts executes this file directly — put the ping as the last line INSIDE it'
      ;;
  esac
  case "$sched" in
    @reboot)
      printf '%s\n' '@reboot has no cadence — a heartbeat monitor expects a regular interval'
      ;;
  esac
  return 0
}

# ---------------------------------------------------------------------------
# Crontab parsing
# ---------------------------------------------------------------------------

# A cron line is a comment, a blank, an environment assignment, an @shorthand,
# or five schedule fields. System files (/etc/crontab, /etc/cron.d/*) insert a
# USER between the schedule and the command; `has_user` says which shape.
parse_cron_stream() {
  _src="$1"; _has_user="$2"
  _need=5
  if [ "$_has_user" = 1 ]; then _need=6; fi

  while IFS= read -r line || [ -n "$line" ]; do
    line=$(printf '%s' "$line" | tr -d '\r')
    case "$line" in
      ''|'#'*) continue ;;
    esac

    # An environment assignment is not a job. Tested on the FIRST token so that
    # `FOO=bar /opt/job.sh` inside a real command line is not mistaken for one.
    _first=$(printf '%s' "$line" | awk '{print $1}')
    case "$line" in
      [A-Za-z_]*)
        case "$_first" in
          *=*)
            _rest=$(printf '%s' "$line" | awk '{$1=""; sub(/^[ \t]+/, ""); print}')
            if [ -z "$_rest" ]; then continue; fi
            # `FOO=bar` alone is an assignment; `FOO=bar cmd` is not a crontab
            # job either (cron has no such form), so skip it as well.
            continue
            ;;
        esac
        ;;
    esac

    case "$line" in
      @*)
        _sched=$(printf '%s' "$line" | awk '{print $1}')
        if [ "$_has_user" = 1 ]; then
          _who=$(printf '%s' "$line" | awk '{print $2}')
          _cmd=$(printf '%s' "$line" | awk '{$1=""; $2=""; sub(/^[ \t]+/, ""); print}')
          add_job "$_src" "$_sched" "$_cmd" "user=$_who" "$line"
        else
          _cmd=$(printf '%s' "$line" | awk '{$1=""; sub(/^[ \t]+/, ""); print}')
          add_job "$_src" "$_sched" "$_cmd" "" "$line"
        fi
        ;;
      *)
        _fields=$(printf '%s' "$line" | awk '{print NF}')
        if [ "$_fields" -le "$_need" ]; then continue; fi
        _sched=$(printf '%s' "$line" | awk '{print $1, $2, $3, $4, $5}')
        if [ "$_has_user" = 1 ]; then
          _who=$(printf '%s' "$line" | awk '{print $6}')
          _cmd=$(printf '%s' "$line" | awk '{for (i=1;i<=6;i++) $i=""; sub(/^[ \t]+/, ""); print}')
          add_job "$_src" "$_sched" "$_cmd" "user=$_who" "$line"
        else
          _cmd=$(printf '%s' "$line" | awk '{for (i=1;i<=5;i++) $i=""; sub(/^[ \t]+/, ""); print}')
          add_job "$_src" "$_sched" "$_cmd" "" "$line"
        fi
        ;;
    esac
  done
  return 0
}

# ---------------------------------------------------------------------------
# The job table
# ---------------------------------------------------------------------------

# The RAW line is carried alongside the parsed fields, and it is the raw line
# the suggestion is built from.
#
# The parsed `cmd` comes out of an awk field rebuild, which normalises every run
# of whitespace to a single space — so a command containing a literal tab, like
# `cut -d'<TAB>' -f1,3`, comes back with the tab turned into a space. Suggesting
# that line would be wrong; --apply WRITING it would silently corrupt a working
# job in a way nobody would trace back here. So the parsed command is used only
# for naming, scoring and warnings, and never to reconstruct anything.
add_job() {
  _ajsrc="$1"; _ajsched="$2"; _ajcmd="$3"; _ajnote="$4"; _ajraw="${5:-}"
  if [ -z "$_ajcmd" ]; then return 0; fi
  _ajstate=$(monitored_state "$_ajcmd")
  _ajscore=$(score_job "$_ajsched" "$_ajcmd")
  printf '%s%s%s%s%s%s%s%s%s%s%s%s%s\n' \
    "$_ajscore" "$US" "$_ajstate" "$US" "$_ajsrc" "$US" \
    "$_ajsched" "$US" "$_ajcmd" "$US" "$_ajnote" "$US" "$_ajraw" >> "$JOBS"
  return 0
}

# ---------------------------------------------------------------------------
# Collectors — every one of them read-only
# ---------------------------------------------------------------------------

have() { command -v "$1" >/dev/null 2>&1; }

collect_user_crontab() {
  if ! have crontab; then return 0; fi
  # `crontab -l` exits non-zero when there is no crontab, which is not an error.
  if crontab -l > "$USER_CRONTAB" 2>/dev/null; then
    HAVE_USER_CRONTAB=1
    parse_cron_stream "crontab" 0 < "$USER_CRONTAB"
  fi
  return 0
}

collect_system_crontabs() {
  if [ -r "$ETC/crontab" ]; then
    parse_cron_stream "$ETC/crontab" 1 < "$ETC/crontab"
  fi
  if [ -d "$ETC/cron.d" ]; then
    for f in "$ETC"/cron.d/*; do
      if [ ! -f "$f" ] || [ ! -r "$f" ]; then continue; fi
      # run-parts ignores names containing a dot or a tilde, and so does cron.
      case "${f##*/}" in *.*|*'~'*) continue ;; esac
      parse_cron_stream "$f" 1 < "$f"
    done
  fi
  return 0
}

collect_periodic_dirs() {
  # cron.<period> is the Linux convention; periodic/<period> is the macOS one.
  # Both run every script in the directory on that cadence.
  for dir in "$ETC/cron.hourly" "$ETC/cron.daily" "$ETC/cron.weekly" "$ETC/cron.monthly" \
             "$ETC/periodic/daily" "$ETC/periodic/weekly" "$ETC/periodic/monthly"; do
    if [ ! -d "$dir" ]; then continue; fi
    period="${dir##*[./]}"
    for f in "$dir"/*; do
      if [ ! -f "$f" ] || [ ! -r "$f" ]; then continue; fi
      case "${f##*/}" in *'~'*) continue ;; esac
      # run-parts ignores dotted names; macOS periodic numbers its scripts
      # (100.clean-logs), so a dot there is normal and must not be skipped.
      case "$dir" in
        */cron.*) case "${f##*/}" in *.*) continue ;; esac ;;
      esac
      # No crontab line exists for these: the runner executes the file itself,
      # so the ping belongs inside the script rather than beside it.
      add_job "$dir" "@$period" "$f" "run-parts"
    done
  done
  return 0
}

# ---------------------------------------------------------------------------
# launchd — how macOS actually schedules things
# ---------------------------------------------------------------------------

# Without this the script is nearly useless on a Mac. cron still exists there and
# still works, but almost nothing uses it: scheduled work lives in launchd
# plists, and a report that says "no scheduled jobs found" on a machine running
# six LaunchAgents is worse than no report at all.
#
# /System/Library and /usr/lib are Apple's own and are skipped — the same
# reasoning as skipping vendor updaters on Windows. A hundred Apple daemons
# would bury the three jobs the reader actually owns.
collect_launchd() {
  if ! have plutil; then return 0; fi

  for dir in "$LAUNCHD_HOME/Library/LaunchAgents" "$LAUNCHD_ROOT/Library/LaunchAgents" \
             "$LAUNCHD_ROOT/Library/LaunchDaemons"; do
    if [ ! -d "$dir" ]; then continue; fi
    for f in "$dir"/*.plist; do
      if [ ! -f "$f" ] || [ ! -r "$f" ]; then continue; fi
      case "${f##*/}" in com.apple.*) continue ;; esac

      label=$(plutil -extract Label raw -o - "$f" 2>/dev/null || true)
      if [ -z "$label" ]; then label="${f##*/}"; fi

      # ProgramArguments is the usual form; Program is the single-binary one.
      args=$(plutil -extract ProgramArguments json -o - "$f" 2>/dev/null \
        | tr -d '[]"' | tr ',' ' ' | sed 's/^ *//; s/ *$//' || true)
      if [ -z "$args" ]; then
        args=$(plutil -extract Program raw -o - "$f" 2>/dev/null || true)
      fi
      if [ -z "$args" ]; then continue; fi

      # StartInterval is seconds between runs; StartCalendarInterval is a
      # cron-like dictionary. Either one means "scheduled"; a plist with
      # neither is a daemon that stays running, not a job that recurs.
      interval=$(plutil -extract StartInterval raw -o - "$f" 2>/dev/null || true)
      cal=$(plutil -extract StartCalendarInterval json -o - "$f" 2>/dev/null || true)
      if [ -n "$interval" ]; then
        cadence="every ${interval}s"
      elif [ -n "$cal" ]; then
        cadence="calendar"
      else
        continue
      fi

      add_job "launchd" "$cadence" "$args" "$label" ""
    done
  done
  return 0
}

collect_systemd_timers() {
  if ! have systemctl; then return 0; fi
  systemctl list-timers --all --no-pager --no-legend 2>/dev/null \
    | awk '{ for (i=1;i<=NF;i++) if ($i ~ /\.timer$/) { print $i; break } }' \
    | sort -u > "$TMP/timers" || true
  while IFS= read -r timer; do
    if [ -z "$timer" ]; then continue; fi
    unit=$(printf '%s' "$timer" | sed 's/\.timer$/.service/')
    exec_line=$(systemctl show -p ExecStart --value "$unit" 2>/dev/null \
      | sed -n 's/.*argv\[\]=\([^;]*\).*/\1/p' | head -n1)
    if [ -z "$exec_line" ]; then exec_line="$unit"; fi
    cadence=$(systemctl show -p TimersCalendar --value "$timer" 2>/dev/null \
      | sed -n 's/.*OnCalendar=\([^;}]*\).*/\1/p' | head -n1)
    if [ -z "$cadence" ]; then cadence="timer"; fi
    add_job "systemd" "$cadence" "$exec_line" "$timer"
  done < "$TMP/timers"
  return 0
}

collect_kubernetes() {
  if [ "$NO_CLUSTER" = 1 ]; then return 0; fi
  if ! have kubectl; then return 0; fi
  # --request-timeout so an unreachable cluster costs two seconds, not a hang.
  kubectl get cronjobs --all-namespaces --no-headers --request-timeout=2s 2>/dev/null \
    > "$TMP/k8s" || true
  while IFS= read -r row; do
    if [ -z "$row" ]; then continue; fi
    ns=$(printf '%s' "$row" | awk '{print $1}')
    name=$(printf '%s' "$row" | awk '{print $2}')
    sched=$(printf '%s' "$row" | awk '{print $3}')
    if [ -z "$name" ]; then continue; fi
    add_job "kubernetes" "$sched" "cronjob $ns/$name" "k8s"
  done < "$TMP/k8s"
  return 0
}

collect_docker() {
  if [ "$NO_CLUSTER" = 1 ]; then return 0; fi
  if ! have docker; then return 0; fi
  docker ps --format '{{.Names}}|{{.Command}}' 2>/dev/null > "$TMP/docker" || true
  while IFS= read -r row; do
    if [ -z "$row" ]; then continue; fi
    name=${row%%|*}
    cmd=${row#*|}
    case "$cmd" in
      *cron*|*crond*|*supercronic*|*ofelia*|*yacron*)
        add_job "docker" "container" "$name: $cmd" "container runs a scheduler"
        ;;
    esac
  done < "$TMP/docker"
  return 0
}

# ---------------------------------------------------------------------------
# Sourced for tests: everything above is definitions, nothing below runs.
# ---------------------------------------------------------------------------

if [ "${PW_AUDIT_LIB:-0}" = 1 ]; then
  TMP="${TMP:-$(mktemp -d 2>/dev/null || mktemp -d -t pwaudit)}"
  JOBS="${JOBS:-$TMP/jobs}"
  : > "$JOBS"
  return 0 2>/dev/null || exit 0
fi

# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------

parse_args "$@"

if [ -n "$KEY" ]; then
  case "$KEY" in
    pwa_*) : ;;
    *)
      echo "audit.sh: --key should look like pwa_ followed by 32 hex characters." >&2
      echo "          Find yours under Settings -> Create monitors from a ping." >&2
      exit 2
      ;;
  esac
fi

# Colour only for a terminal: output piped into a file or a pager should not
# carry escape codes.
if [ "$COLOR" = 1 ] && [ -t 1 ]; then
  B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); R=$(printf '\033[31m')
  G=$(printf '\033[32m'); Y=$(printf '\033[33m'); C=$(printf '\033[36m')
  Z=$(printf '\033[0m')
else
  B=""; DIM=""; R=""; G=""; Y=""; C=""; Z=""
fi

say() { if [ "$QUIET" != 1 ]; then printf '%s\n' "$*"; fi; }

TMP=$(mktemp -d 2>/dev/null || mktemp -d -t pwaudit)
trap 'rm -rf "$TMP"' EXIT INT TERM
JOBS="$TMP/jobs"
USER_CRONTAB="$TMP/user-crontab"
HAVE_USER_CRONTAB=0
: > "$JOBS"
: > "$USER_CRONTAB"

collect_user_crontab
collect_system_crontabs
collect_periodic_dirs
collect_launchd
collect_systemd_timers
collect_kubernetes
collect_docker

TOTAL=$(wc -l < "$JOBS" | tr -d ' ')
MONITORED=$(grep -c "^[0-9][0-9]*${US}yes${US}" "$JOBS" 2>/dev/null || true)
OTHER=$(grep -c "^[0-9][0-9]*${US}other${US}" "$JOBS" 2>/dev/null || true)
MONITORED=${MONITORED:-0}
OTHER=${OTHER:-0}
UNMONITORED=$((TOTAL - MONITORED - OTHER))

say ""
say "${B}PulseWatch crontab audit${Z} ${DIM}v${VERSION}${Z}"
say "${DIM}Read-only. Nothing about this machine was sent anywhere.${Z}"
if [ "$NO_CLUSTER" != 1 ] && { have kubectl || have docker; }; then
  # Said out loud rather than buried in --help. Claiming "no network calls" on a
  # machine where this just asked a Kubernetes API server for a job list would
  # be the kind of small lie that makes the rest of the output untrustworthy.
  say "${DIM}kubectl/docker were asked to list jobs — your own cluster, read-only. --no-cluster skips it.${Z}"
fi
say ""

if [ "$TOTAL" -eq 0 ]; then
  say "No scheduled jobs found."
  say ""
  say "${DIM}Checked: user crontab, $ETC/crontab, $ETC/cron.d, $ETC/cron.{hourly,daily,weekly,monthly},${Z}"
  say "${DIM}$ETC/periodic (macOS), launchd agents and daemons, systemd timers,${Z}"
  say "${DIM}kubernetes cronjobs, docker containers running a scheduler.${Z}"
  exit 0
fi

_plural=""
if [ "$TOTAL" != 1 ]; then _plural="s"; fi
say "${B}${TOTAL}${Z} scheduled job${_plural} found."
say "  ${G}${MONITORED}${Z} already ping PulseWatch"
if [ "$OTHER" -gt 0 ]; then
  say "  ${C}${OTHER}${Z} ping another monitoring service"
fi
if [ "$UNMONITORED" -gt 0 ]; then
  say "  ${R}${UNMONITORED}${Z} unmonitored — if these stop, nothing tells you"
else
  say "  ${G}0${Z} unmonitored"
fi
say ""

if [ "$UNMONITORED" -le 0 ]; then
  say "${G}Everything scheduled here is watched.${Z}"
  exit 0
fi

say "${B}Unmonitored, most consequential first${Z}"
say ""

# -k1,1nr on the score. LC_ALL=C so the separator sorts predictably everywhere.
grep -v "^[0-9][0-9]*${US}yes${US}" "$JOBS" 2>/dev/null \
  | grep -v "^[0-9][0-9]*${US}other${US}" \
  | LC_ALL=C sort -t"$US" -k1,1nr > "$TMP/unmonitored" || true

while IFS="$US" read -r score state src sched cmd note raw; do
  if [ -z "${cmd:-}" ]; then continue; fi
  slug=$(safe_slug "$(derive_name "$cmd")")
  say "  ${Y}${sched}${Z}  ${DIM}${src}${Z}"
  # The raw line, not the parsed command. The parse normalises whitespace, so
  # showing it would display a job subtly different from the one on disk —
  # directly above a suggested replacement built from the real thing. Two
  # slightly different renderings of the same job is how a reader loses trust
  # in both.
  say "  ${raw:-$cmd}"
  say ""

  line_warnings "$cmd" "$sched" "$src" > "$TMP/warn" || true

  if [ "$note" = "run-parts" ]; then
    say "  ${DIM}add this as the last line inside that script (monitor name: ${slug})${Z}"
    say "    ${C}curl -fsS -m 10 --retry 5 $(ping_url "$slug")${Z}"
  elif [ -s "$TMP/warn" ]; then
    # No pasteable line for a shape where appending is wrong. Printing one
    # anyway — with a warning underneath — invites exactly the paste the
    # warning is trying to prevent, and half of readers do not get to the
    # second line.
    say "  ${Y}needs a human — do not append blindly${Z}"
    say "    ${DIM}the ping for this job would be:${Z}"
    say "    ${C}curl -fsS -m 10 --retry 5 $(ping_url "$slug")${Z}"
    say "    ${DIM}put it where the job's own success is what decides it${Z}"
  else
    # Built from the RAW line, never from the parsed command: the parse
    # normalises whitespace, so a literal tab inside the command would come
    # back as a space and the "suggestion" would be a different job.
    say "  ${DIM}suggested replacement (monitor name: ${slug})${Z}"
    say "    ${C}$(suggested_command "$raw" "$slug")${Z}"
  fi

  while IFS= read -r w; do
    if [ -n "$w" ]; then say "    ${Y}!${Z} ${DIM}${w}${Z}"; fi
  done < "$TMP/warn"
  say ""
done < "$TMP/unmonitored"

if [ -z "$KEY" ]; then
  say "${DIM}Replace <your-key> with your auto-provision key —${Z}"
  say "${DIM}Settings -> Create monitors from a ping. Or re-run with --key pwa_...${Z}"
  say ""
fi
say "${DIM}The monitor is created by its first ping. Nothing to set up beforehand.${Z}"
say "${DIM}https://pulsewatcher.vercel.app${Z}"
say ""

# ---------------------------------------------------------------------------
# --apply
# ---------------------------------------------------------------------------

if [ "$APPLY" = 1 ]; then
  if [ "$HAVE_USER_CRONTAB" != 1 ]; then
    echo "audit.sh: --apply rewrites your USER crontab, and you do not have one." >&2
    echo "          System crontabs under $ETC are left alone deliberately." >&2
    exit 2
  fi
  if [ -z "$KEY" ]; then
    echo "audit.sh: --apply needs --key, or every line it writes would ping <your-key>." >&2
    exit 2
  fi
  # Confirmation must come from the terminal, not stdin: under
  # `curl … | sh -s -- --apply`, stdin IS the script, and reading from it would
  # consume the rest of the program and answer its own question.
  if [ ! -r /dev/tty ]; then
    echo "audit.sh: --apply needs a terminal to confirm on." >&2
    echo "          Download the script and run it directly rather than piping it." >&2
    exit 2
  fi

  REWRITTEN="$TMP/rewritten"
  : > "$REWRITTEN"
  changed=0
  skipped=0

  while IFS= read -r line || [ -n "$line" ]; do
    out="$line"
    stripped=$(printf '%s' "$line" | tr -d '\r')
    case "$stripped" in
      ''|'#'*) printf '%s\n' "$out" >> "$REWRITTEN"; continue ;;
    esac
    first=$(printf '%s' "$stripped" | awk '{print $1}')
    case "$first" in
      *=*) printf '%s\n' "$out" >> "$REWRITTEN"; continue ;;
    esac
    if [ "$(monitored_state "$stripped")" != "no" ]; then
      printf '%s\n' "$out" >> "$REWRITTEN"; continue
    fi

    case "$stripped" in
      @*)
        sched=$(printf '%s' "$stripped" | awk '{print $1}')
        cmd=$(printf '%s' "$stripped" | awk '{$1=""; sub(/^[ \t]+/, ""); print}')
        ;;
      *)
        if [ "$(printf '%s' "$stripped" | awk '{print NF}')" -le 5 ]; then
          printf '%s\n' "$out" >> "$REWRITTEN"; continue
        fi
        sched=$(printf '%s' "$stripped" | awk '{print $1, $2, $3, $4, $5}')
        cmd=$(printf '%s' "$stripped" | awk '{for (i=1;i<=5;i++) $i=""; sub(/^[ \t]+/, ""); print}')
        ;;
    esac
    if [ -z "${cmd:-}" ]; then
      printf '%s\n' "$out" >> "$REWRITTEN"; continue
    fi

    # Refuse to touch a line the naive append would break. Leaving it and
    # saying so is the only honest option — a silently wrong crontab line is a
    # job that stops running, which is exactly what this script exists to stop.
    if [ -n "$(line_warnings "$cmd" "$sched" "crontab")" ]; then
      printf '%s\n' "$out" >> "$REWRITTEN"
      skipped=$((skipped + 1))
      echo "  skipped (needs a human): $cmd" >&2
      continue
    fi

    # Appended to the ORIGINAL line, byte for byte. Rebuilding it from the
    # parsed fields would collapse literal tabs and runs of spaces — writing a
    # subtly different command into a working crontab, which is the single most
    # damaging thing this script could do.
    slug=$(safe_slug "$(derive_name "$cmd")")
    printf '%s\n' "$(suggested_command "$stripped" "$slug")" >> "$REWRITTEN"
    changed=$((changed + 1))
  done < "$USER_CRONTAB"

  if [ "$changed" -eq 0 ]; then
    say "Nothing in your user crontab could be rewritten automatically."
    exit 1
  fi

  BACKUP="$HOME/crontab.backup.$(date +%Y%m%d-%H%M%S)"
  cp "$USER_CRONTAB" "$BACKUP"
  say ""
  say "${B}Backup written:${Z} $BACKUP"
  say ""
  say "${B}Diff${Z}"
  if have diff; then
    diff -u "$USER_CRONTAB" "$REWRITTEN" || true
  else
    say "${DIM}(no diff available — the proposed crontab in full)${Z}"
    cat "$REWRITTEN"
  fi
  say ""
  printf '%s' "Install this crontab? Type yes to confirm: "
  read -r answer < /dev/tty || answer=""
  case "$answer" in
    yes|YES|Yes)
      crontab "$REWRITTEN"
      _cplural=""
      if [ "$changed" != 1 ]; then _cplural="s"; fi
      say "${G}Installed.${Z} ${changed} line${_cplural} now ping PulseWatch."
      say "Restore with: crontab $BACKUP"
      exit 0
      ;;
    *)
      say "Left unchanged. Your backup is still at $BACKUP."
      exit 1
      ;;
  esac
fi

# Non-zero while anything is unwatched, so this works as a CI or config check.
exit 1
