#!/usr/bin/env bash
# Independent host-side watchdog for the teamwork-os gateway.
#
# Purpose:
#   The gateway daemon (HTTP on localhost:7463) is launchd-managed with
#   KeepAlive, so a crash-loop is invisible — launchd silently respawns the
#   process forever and nothing alerts the operator. The gateway also cannot
#   alert about its own death. This script runs OUT-OF-PROCESS, polls the
#   gateway's /api/health endpoint, and DMs Nick via SLACK_SELF_DM_URL when
#   the gateway has been unreachable for several consecutive checks.
#
# Installation:
#   The crontab installer (packages/gateway/src/cron/crontab-install.ts and
#   the CLI's packages/cli/src/commands/cron/install.ts) COPIES this script
#   from the repo checkout to ${TEAMWORK_HOME}/bin and writes a crontab line
#   invoking that copy, tagged `# teamwork-os-watchdog`, running every minute.
#   It is NOT a per-job line — installCrontab() writes exactly one watchdog
#   line. The copy lives on the internal volume because macOS TCC blocks cron
#   from executing a script on an external volume (cron lacks Full Disk
#   Access). The line passes the repo root as `$2` so the drift check below
#   still reads the correct checkout.
#
# Behaviour:
#   - On success: if a prior outage alert is active, sends a recovery DM and
#     resets the failure counter / alert flag.
#   - On failure: increments a consecutive-failure counter in the state file.
#     When the counter reaches FAILURE_THRESHOLD and no alert has been sent
#     for this outage, sends one alert DM and sets the alerted flag so it
#     does not spam every minute. One alert per outage, one recovery per
#     outage.
#   - Stale-code drift (on success): reads the running commit from the
#     /api/health body and compares it against the checked-out HEAD of the
#     repo (the repo root from `$2`, or the script's own `dirname/..` when `$2`
#     is absent — `git -C <repo> rev-parse HEAD`). When
#     the gateway is running a different commit than what's on disk (the
#     "pulled but not redeployed" case), sends ONE DM, debounced by the HEAD
#     it's drifting toward, so a fresh pull re-alerts but a persisting drift
#     does not spam. Skipped silently when either commit is unknown (non-git
#     install, git missing, older gateway without the field) — never a false
#     positive. The redeploy that resolves the drift clears the flag.
#   - If SLACK_SELF_DM_URL is unset, logs to stderr and exits 0 — the
#     watchdog must never crash-loop itself.
#
# Usage:
#   gateway-health-watchdog.sh [port] [repo_root]
#     port      — gateway port to probe (defaults to 7463)
#     repo_root — absolute path to the repo checkout, used for the stale-code
#                 drift check. Passed by the crontab installer because the
#                 script is executed from a COPY on the internal volume
#                 (${TEAMWORK_HOME}/bin), where `dirname "$0"/..` points at
#                 ${TEAMWORK_HOME}, NOT the repo. Optional: when absent, the
#                 drift check falls back to the script's own `dirname/..`
#                 (correct only when the script runs from inside the checkout).

set -u

PORT="${1:-7463}"
# Absolute repo-root path for the drift check ($2). Empty when not passed —
# check_drift falls back to `dirname "$0"/..`. See the header for why the
# installer supplies this (the executed script lives on the internal volume,
# not in the checkout, so its own dirname/.. is the wrong repo).
REPO_ROOT_ARG="${2:-}"
FAILURE_THRESHOLD=3
HEALTH_URL="http://localhost:${PORT}/api/health"

# State file lives under ~/.teamwork (TEAMWORK_HOME override respected),
# alongside other gateway state. Format: three lines — "fail_count",
# "alerted" (0 or 1), and "drift_alerted_head" (the HEAD commit we last DM'd a
# drift alert for, or empty). The third line is optional for backward compat:
# state files written by the pre-drift watchdog have only two lines and read
# back with an empty drift signature, so the first drift still alerts.
TEAMWORK_HOME="${TEAMWORK_HOME:-${HOME}/.teamwork}"
STATE_FILE="${TEAMWORK_HOME}/.gateway-watchdog-state"
ENV_FILE="${TEAMWORK_HOME}/.env"

# --- Read prior state ----------------------------------------------------
fail_count=0
alerted=0
drift_alerted_head=''
if [ -f "${STATE_FILE}" ]; then
  # Line 1: consecutive failure count. Line 2: alerted flag (0|1). Line 3:
  # drift-alerted HEAD signature. Read each line separately — `read a b` would
  # split ONE line into two words and leave later flags empty, defeating the
  # once-per-outage / once-per-drift debounce.
  line1=''
  line2=''
  line3=''
  { read -r line1 || true; read -r line2 || true; read -r line3 || true; } < "${STATE_FILE}"
  case "${line1:-}" in
    ''|*[!0-9]*) fail_count=0 ;;
    *) fail_count="${line1}" ;;
  esac
  case "${line2:-}" in
    1) alerted=1 ;;
    *) alerted=0 ;;
  esac
  # Only accept a full 40-char lowercase hex SHA; anything else (truncated
  # file, garbage) resets to empty so a real drift re-alerts rather than being
  # silently suppressed by a corrupt signature.
  case "${line3:-}" in
    *[!0-9a-f]*|'') drift_alerted_head='' ;;
    *) [ "${#line3}" -eq 40 ] && drift_alerted_head="${line3}" || drift_alerted_head='' ;;
  esac
fi

write_state() {
  # $1 = fail_count, $2 = alerted, $3 = drift_alerted_head
  printf '%s\n%s\n%s\n' "$1" "$2" "${3:-}" > "${STATE_FILE}"
}

# --- Send a Slack DM via the webhook-style SLACK_SELF_DM_URL --------------
# Sources ~/.teamwork/.env to read SLACK_SELF_DM_URL. The env file is
# written in canonical single-quoted POSIX form, so `source` is safe.
send_slack() {
  message="$1"
  if [ -f "${ENV_FILE}" ]; then
    # shellcheck disable=SC1090
    . "${ENV_FILE}"
  fi
  if [ -z "${SLACK_SELF_DM_URL:-}" ]; then
    echo "gateway-health-watchdog: SLACK_SELF_DM_URL unset — cannot send alert" >&2
    return 1
  fi
  # Escape backslashes and double-quotes for safe embedding in JSON.
  escaped="${message//\\/\\\\}"
  escaped="${escaped//\"/\\\"}"
  curl -fsS --max-time 10 \
    -X POST -H 'Content-Type: application/json' \
    -d "{\"text\":\"${escaped}\"}" \
    "${SLACK_SELF_DM_URL}" \
    -o /dev/null 2>/dev/null
  return $?
}

# --- Stale-code drift detection ------------------------------------------
# $1 = the /api/health response body. Compares the running commit (parsed from
# the body) against the checked-out HEAD of this script's own repo, and DMs
# once per drift target. Mutates the global drift_alerted_head: set to the HEAD
# we alerted for, or cleared when there's no drift / it can't be determined, so
# a later (or post-pull) drift re-alerts. Never exits non-zero — a parse or git
# failure simply skips the check.
check_drift() {
  body="$1"

  # Running commit: the "commit":"<40hex>" field. Absent or null (older
  # gateway, non-git install) → empty → skip.
  running_commit="$(printf '%s' "${body}" | grep -oE '"commit":"[0-9a-f]{40}"' | grep -oE '[0-9a-f]{40}' | head -n1)"

  # Checked-out HEAD of the repo. Prefer the explicit repo-root arg ($2, passed
  # by the crontab installer) because the executed script lives on the internal
  # volume (${TEAMWORK_HOME}/bin), so its own `dirname "$0"/..` points at
  # ${TEAMWORK_HOME}, not the checkout. Fall back to `dirname "$0"/..` only when
  # $2 was not passed (script invoked directly from inside the checkout). git
  # failure (non-git install, git not on PATH, detached/unborn) → empty → skip.
  repo_root="${REPO_ROOT_ARG}"
  [ -z "${repo_root}" ] && repo_root="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)"
  local_head=''
  if [ -n "${repo_root}" ]; then
    local_head="$(git -C "${repo_root}" rev-parse HEAD 2>/dev/null || true)"
    case "${local_head}" in
      *[!0-9a-f]*) local_head='' ;;
      *) [ "${#local_head}" -eq 40 ] || local_head='' ;;
    esac
  fi

  # Either side unknown → no signal. Clear so a future determinable drift
  # alerts cleanly.
  if [ -z "${running_commit}" ] || [ -z "${local_head}" ]; then
    drift_alerted_head=''
    return 0
  fi

  # Running code matches disk → no drift.
  if [ "${running_commit}" = "${local_head}" ]; then
    drift_alerted_head=''
    return 0
  fi

  # Drift: running ${running_commit} but ${local_head} is checked out. Alert
  # once per target HEAD — a fresh pull moves HEAD and re-alerts; a persisting
  # drift stays quiet.
  if [ "${drift_alerted_head}" != "${local_head}" ]; then
    drift_msg="🔁 teamwork-os gateway STALE — running \`${running_commit:0:7}\` but \`${local_head:0:7}\` is checked out (pulled but not redeployed). Run \`teamos deploy\` to rebuild + restart."
    if send_slack "${drift_msg}"; then
      drift_alerted_head="${local_head}"
    else
      echo "gateway-health-watchdog: drift DM failed to send — will retry next run" >&2
    fi
  fi
  return 0
}

# --- Probe the gateway ---------------------------------------------------
# Capture the body (not -o /dev/null) so we can read the running commit for
# drift detection. `-f` still makes curl exit non-zero on any HTTP >= 400 or
# connection failure, so the liveness semantics are unchanged.
health_body="$(curl -fs --max-time 5 "${HEALTH_URL}" 2>/dev/null)"
probe_status=$?
if [ "${probe_status}" -eq 0 ]; then
  # Gateway is up.
  if [ "${alerted}" -eq 1 ]; then
    send_slack "✅ teamwork-os gateway RECOVERED — :${PORT} is reachable again." \
      || echo "gateway-health-watchdog: recovery DM failed to send" >&2
  fi
  check_drift "${health_body}"
  write_state 0 0 "${drift_alerted_head}"
  exit 0
fi

# Gateway is down — increment the consecutive-failure counter. Preserve the
# drift signature across the outage so a drift alerted before the outage isn't
# re-sent the moment the gateway recovers onto the same stale commit.
fail_count=$((fail_count + 1))

if [ "${fail_count}" -ge "${FAILURE_THRESHOLD}" ] && [ "${alerted}" -eq 0 ]; then
  alert_msg="⚠️ teamwork-os gateway DOWN — :${PORT} unreachable for ${fail_count}+ minutes. Check \`launchctl list com.teamwork-os.gateway\` and \`~/.teamwork/logs/gateway-launchd-error.log\`."
  if send_slack "${alert_msg}"; then
    alerted=1
  else
    echo "gateway-health-watchdog: alert DM failed to send — will retry next run" >&2
  fi
fi

write_state "${fail_count}" "${alerted}" "${drift_alerted_head}"
exit 0
