#!/bin/bash
# TVU host monitor — external heartbeat for product-demo.tvustream.com
#
# WHY THIS EXISTS
# The host already notifies on deploy failures: deploy-hook/server.py calls Slack
# chat.postMessage from inside a container ON THAT HOST. Two blind spots follow:
#   1. the notifier dies with the host it watches, so a host outage is silent;
#   2. it is event-driven (fires when a deploy runs), not a heartbeat — so a
#      stalled chain is silent too. That is exactly how the docs site sat frozen
#      at 0.10.1 for two weeks (2026-07-22, see the DS repo's docs/DEPLOY.md).
# Since INFRA-F73 moved the npm registry onto the same host, an outage no longer
# just means "stale docs" — every consumer's `pnpm install` breaks. So the watcher
# has to live somewhere else. This is that somewhere else: the owner's laptop.
#
# DESIGN NOTES
# - No credentials. Anonymous probes are enough because this Gitea sets
#   REQUIRE_SIGNIN_VIEW: an anonymous api/v1 hit answers 403 and the npm registry
#   answers 401 — both prove the service is ALIVE. Only a connection failure
#   (curl exit != 0, or HTTP 000) means down. Keeping this credential-free means
#   there is no token here to rotate or leak.
# - Internet baseline first. Without it, closing the lid or switching networks
#   would look identical to "the host died" and cry wolf. If the baseline fails we
#   record SKIP and notify nothing.
# - Edge-triggered with de-dup, mirroring the host hook's own logic: notify on
#   healthy->failing and on failing->healthy, stay quiet while a state persists.
# - Multi-path alerting, because a macOS notification is easy to miss: banner +
#   an ALERT file + an append-only log.
#
# Exit code is 0 even when something is down — this is a reporter, and a non-zero
# exit would just make launchd log noise. Read state/log for verdicts.

set -uo pipefail

STATE_DIR="${STATE_DIR:-$HOME/.claude/host-monitor}"
LOG="$STATE_DIR/monitor.log"
ALERT="$STATE_DIR/ALERT.txt"
STATE="$STATE_DIR/state"
mkdir -p "$STATE_DIR"

TIMEOUT=10
# How stale the deployed docs site may get before it counts as a stalled chain.
# 8h cron + normal release cadence means multiple days of silence is abnormal;
# 5 days avoids flagging an ordinary quiet week.
STALE_DAYS="${STALE_DAYS:-5}"

# Consumer-facing ingress. Since INFRA-F74 (2026-07-30) Gitea sits behind Caddy on
# 443 at /gitea/ and `ROOT_URL` is that URL — it is what every consumer's `.npmrc`
# and every `git push` now resolves. Probe THAT, not the origin port: the reverse
# proxy / TLS layer can fail on its own, and a monitor watching `:3001` would stay
# green while every `pnpm install` in the company fails. (Verified 2026-07-30: both
# answer 403/401, so this swap does not change the healthy signal.)
#
# Overridable so the failure path can be exercised without editing the file (the
# 2026-07-29 test had to copy the script to /tmp and patch it, which tests the copy,
# not this file). Use with STATE_DIR pointed somewhere scratch.
BASE_HTTP="${BASE_HTTP:-https://product-demo.tvustream.com/gitea}"
# The other entrance to the same Gitea, on the origin port. Its `/api/v1/version`
# probe below stays DIAGNOSTIC ONLY (logged, never alerts) — that port could
# legitimately get firewalled off one day and alerting on it would cry wolf.
# ⚠️ It is no longer "Gitea directly": since INFRA-F89 a proxy of ours (`gitea-proxy`)
# sits on :3001 and strips the /gitea prefix. Its *UI* is alerted on — see the asset
# probes below and the reason there.
ORIGIN_DIRECT="${ORIGIN_DIRECT:-http://product-demo.tvustream.com:3001}"
SITE="${SITE:-https://product-demo.tvustream.com/tvu-design-system/playground-dist/index.html}"

now() { date '+%Y-%m-%d %H:%M:%S'; }
log() { printf '%s  %s\n' "$(now)" "$*" >> "$LOG"; }

# --- probe helper -------------------------------------------------------------
# Echoes the HTTP code, or 000 when the connection itself failed.
http_code() {
  # curl already prints 000 on a connection failure AND exits non-zero, so an
  # `|| echo 000` fallback would concatenate into "000000". Capture, then default
  # only if the output was genuinely empty.
  local c
  c="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "$1" 2>/dev/null)"
  printf '%s' "${c:-000}"
}

# --- asset probe (INFRA-F89) --------------------------------------------------
# Why this exists: on 2026-07-30 a ROOT_URL change made Gitea render every asset and
# nav link under a /gitea prefix that does not exist on the :3001 origin. The pages
# went blank for a full day and THIS MONITOR STAYED GREEN THE WHOLE TIME — every
# check above requests an API path, and none of them ever fetches a sub-resource.
# The front page even kept returning 200 with the right <title>, so probing "/" would
# not have caught it either. Only a real asset fetch does.
#
# The asset URL is discovered from the served HTML rather than hardcoded, because a
# hardcoded `/assets/js/index.js` would silently rot the day Gitea renames its bundles
# — and a probe that 404s for the wrong reason is worse than no probe.
resolve_asset() {
  local base="$1" root html asset
  root="$(printf '%s' "$base" | sed -E 's#^(https?://[^/]+).*#\1#')"
  html="$(curl -s --max-time "$TIMEOUT" "$base/" 2>/dev/null)" || return 1
  # Prefer a real js/css bundle over whatever asset happens to come first: a favicon
  # would still catch a broken path prefix (the INFRA-F89 shape), but would sail past
  # a missing script bundle — and the bundle is what decides whether the page works.
  asset="$(printf '%s' "$html" | grep -oE '(src|href)="[^"]*/assets/[^"?]*\.(js|css)' | head -1 | sed -E 's/^(src|href)="//')"
  [ -n "$asset" ] || asset="$(printf '%s' "$html" | grep -oE '(src|href)="[^"]*/assets/[^"?]+' | head -1 | sed -E 's/^(src|href)="//')"
  [ -n "$asset" ] || return 1
  case "$asset" in
    http*) printf '%s' "$asset" ;;
    /*)    printf '%s%s' "$root" "$asset" ;;
    *)     printf '%s/%s' "$root" "$asset" ;;
  esac
}

# Kept out of the CHECKS table because it needs two round-trips (fetch page, then
# fetch what the page asked for), but it participates in the same state/alert machinery.
check_asset() {
  local name="$1" base="$2" desc="$3"
  local url code status prev
  url="$(resolve_asset "$base")" || url=""
  if [ -z "$url" ]; then
    # Page unreachable, or reachable but with no asset link in it. Both are abnormal:
    # a healthy Gitea page always references its bundles.
    code="no-asset-link"; status=down
  else
    code="$(http_code "$url")"
    if [ "$code" = "200" ]; then status=up; else status=down; fi
  fi
  prev="$(get_state "$name")"
  log "$(printf '%-9s %-5s http=%-14s %s' "$name" "$status" "$code" "${url:-(no asset link found in served HTML)}")"
  if [ "$status" = down ] && [ "$prev" != down ]; then
    FAILED+=("$desc (HTTP $code)")
  elif [ "$status" = up ] && [ "$prev" = down ]; then
    RECOVERED+=("$desc")
  fi
  set_state "$name" "$status"
}

# --- state helpers ------------------------------------------------------------
# state file: one "key<TAB>status" line per check. Plain text, no jq dependency.
get_state() {
  [ -f "$STATE" ] || return 0
  awk -F'\t' -v k="$1" '$1==k {print $2}' "$STATE" | tail -1
}
set_state() {
  local key="$1" val="$2" tmp
  tmp="$(mktemp)"
  if [ -f "$STATE" ]; then awk -F'\t' -v k="$key" '$1!=k' "$STATE" > "$tmp"; fi
  printf '%s\t%s\n' "$key" "$val" >> "$tmp"
  mv "$tmp" "$STATE"          # atomic replace
}

# --- credentials for Slack (optional) -----------------------------------------
# Slack is the channel that actually reaches you when the host dies at 3am — a
# macOS banner only works if you are sitting at this Mac. Loaded from, in order:
#   1. the environment
#   2. this monitor's own env file
#   3. claude-work-report's env file, so one Slack app can serve both
# Absent credentials degrade to banner-only; they are never required.
SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN:-}"
SLACK_CHANNEL="${SLACK_CHANNEL:-}"
for envf in "$STATE_DIR/.env" "$HOME/.claude/daily-reports/.env"; do
  [ -r "$envf" ] || continue
  [ -z "$SLACK_BOT_TOKEN" ] && SLACK_BOT_TOKEN="$(sed -n 's/^SLACK_BOT_TOKEN=//p' "$envf" | tr -d '"'"'"' \r' | head -1)"
  [ -z "$SLACK_CHANNEL" ]   && SLACK_CHANNEL="$(sed -n 's/^SLACK_CHANNEL=//p'   "$envf" | tr -d '"'"'"' \r' | head -1)"
done

slack_post() {
  local text="$1"
  [ -n "$SLACK_BOT_TOKEN" ] && [ -n "$SLACK_CHANNEL" ] || return 1
  local resp
  resp="$(curl -s --max-time 15 -X POST https://slack.com/api/chat.postMessage \
    -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
    -H 'Content-Type: application/json; charset=utf-8' \
    --data "$(TEXT="$text" CH="$SLACK_CHANNEL" python3 -c '
import json, os
print(json.dumps({"channel": os.environ["CH"], "text": os.environ["TEXT"]}))')" 2>/dev/null)"
  # Slack answers 200 with {"ok":false,"error":...} on failure, so the HTTP code
  # tells you nothing — parse ok.
  case "$resp" in
    *'"ok":true'*) return 0 ;;
    *) log "WARN slack post failed: $(printf '%s' "$resp" | head -c 160)"; return 1 ;;
  esac
}

notify() {
  local title="$1" body="$2" sound="${3:-Basso}"
  # Banner. Quoting matters: embed via osascript argv, never string-interpolate.
  osascript -e 'on run {t, b, s}' \
            -e 'display notification b with title t sound name s' \
            -e 'end run' "$title" "$body" "$sound" >/dev/null 2>&1 \
    || log "WARN could not post a macOS notification (osascript failed)"

  # Slack mrkdwn, not Markdown: single asterisks for bold, bare URLs (AGENTS.md
  # §Slack mrkdwn in the design-system repo).
  if slack_post "$(printf '*%s*\n%s\n\n_reported by tvu-host-monitor on %s (external to the host)_' \
        "$title" "$body" "$(scutil --get ComputerName 2>/dev/null || hostname)")"; then
    log "      slack: sent to $SLACK_CHANNEL"
  fi
}

# --- checks -------------------------------------------------------------------
# name|url|acceptable codes|human description
CHECKS=(
  "gitea|$BASE_HTTP/api/v1/version|200 403|Gitea 服务（Git 托管 + CI + registry 宿主）"
  # `404` used to count as healthy — back when the package was not published yet, so
  # "registry answers, package absent" was the normal state. It is published since
  # 2026-07-30 (1.1.1), and a 404 injection that day proved the tolerance had become a
  # false-green: a broken reverse proxy answers 404 for every path and this check
  # stayed up. Healthy is now 200 (authenticated/anonymous read allowed) or 401
  # (REQUIRE_SIGNIN_VIEW — service alive, credentials absent, which is expected here).
  "registry|$BASE_HTTP/api/packages/ux-team/npm/@ux-team%2ftvu-design-system|200 401|npm registry（consumer 装包依赖它）"
  "docs|$SITE|200|设计文档站"
)

FAILED=()
RECOVERED=()

# Baseline: is it us or them?
base_code="$(http_code https://1.1.1.1)"
if [ "$base_code" = "000" ]; then
  log "SKIP  no internet from this machine (baseline 1.1.1.1 unreachable) — not judging the host"
  exit 0
fi

for spec in "${CHECKS[@]}"; do
  IFS='|' read -r name url ok_codes desc <<< "$spec"
  code="$(http_code "$url")"
  if [[ " $ok_codes " == *" $code "* ]]; then
    status=up
  else
    status=down
  fi
  prev="$(get_state "$name")"
  log "$(printf '%-9s %-5s http=%s' "$name" "$status" "$code")"

  if [ "$status" = down ] && [ "$prev" != down ]; then
    FAILED+=("$desc (HTTP $code)")
  elif [ "$status" = up ] && [ "$prev" = down ]; then
    RECOVERED+=("$desc")
  fi
  set_state "$name" "$status"
done

# Asset probes — the gap INFRA-F89 exposed. Both entrances get one, and BOTH alert.
#
# Why the :3001 one alerts even though the API probe on that same port does not:
# the 2026-07-30 failure was precisely an *asymmetry* — the domain entrance was fine
# while :3001 was blank. Watching only the domain would have missed it entirely.
# That port is also where our own `git remote` and the deploy-hook still point, so it
# is a real user-facing surface, not a spare. (If :3001 is ever deliberately retired
# or firewalled off, demote this one to diagnostic instead of leaving it to cry wolf.)
#
# Also note both entrances now traverse the same `gitea-proxy` container, so it is a
# shared single point: when it dies, both of these go down together while Gitea itself
# is still alive on 127.0.0.1:3003 — that asymmetry is how you tell the two apart.
check_asset "gitea-ui" "$BASE_HTTP"     "Gitea 网页 UI（域名入口）—— 页面能开但资源 404 这类白屏故障"
check_asset "origin-ui" "$ORIGIN_DIRECT" "Gitea 网页 UI（:3001 入口，经 gitea-proxy）"

# Diagnostic-only probe (see ORIGIN_DIRECT above): logged so that when an alert fires
# you can tell one layer from the other, but it never alerts and never touches state.
diag_code="$(http_code "$ORIGIN_DIRECT/api/v1/version")"
log "$(printf '%-9s %-5s http=%s (diagnostic only — proxy-bypass origin, never alerts)' 'origin' 'info' "$diag_code")"

# Staleness of the deployed site — catches a stalled deploy chain, which is the
# failure mode the host's own event-driven notifier structurally cannot report.
#   grep -i, NOT awk IGNORECASE: that is a gawk extension and macOS ships BSD awk,
#   where it silently does nothing — so `/^last-modified:/` never matched the real
#   `Last-Modified:` header. And `tr -d '\r'` because HTTP headers are CRLF, and a
#   trailing \r makes `date -j -f` fail. Both bugs were silent (the log line used
#   to live inside the success branch), which is why it now logs either way.
lm="$(curl -sI --max-time "$TIMEOUT" "$SITE" 2>/dev/null | grep -i '^last-modified:' | head -1 | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r')"
if [ -n "$lm" ]; then
  lm_epoch="$(date -j -f '%a, %d %b %Y %H:%M:%S %Z' "$lm" '+%s' 2>/dev/null || echo '')"
  if [ -z "$lm_epoch" ]; then
    log "$(printf '%-9s %-5s could not parse Last-Modified: %s' 'freshness' 'warn' "$lm")"
  fi
  if [ -n "$lm_epoch" ]; then
    age_days=$(( ( $(date '+%s') - lm_epoch ) / 86400 ))
    log "$(printf '%-9s %-5s age=%sd (Last-Modified: %s)' 'freshness' 'info' "$age_days" "$lm")"
    if [ "$age_days" -ge "$STALE_DAYS" ]; then
      if [ "$(get_state freshness)" != down ]; then
        FAILED+=("文档站已 ${age_days} 天未更新 — deploy 链可能停了（2026-07-22 曾静默冻结 2 周）")
      fi
      set_state freshness down
    else
      if [ "$(get_state freshness)" = down ]; then RECOVERED+=("文档站更新恢复"); fi
      set_state freshness up
    fi
  fi
fi

# --- alerting -----------------------------------------------------------------
if [ "${#FAILED[@]}" -gt 0 ]; then
  body="$(printf '%s\n' "${FAILED[@]}")"
  log "ALERT newly failing:"
  printf '%s\n' "${FAILED[@]}" | sed 's/^/    /' >> "$LOG"
  {
    echo "TVU host alert — $(now)"
    echo
    printf '%s\n' "${FAILED[@]}"
    echo
    echo "查证与处置见 tvu-design-system/docs/DEPLOY.md §Diagnosing。"
    echo "本文件在下次全部恢复时自动删除。"
  } > "$ALERT"
  notify "⚠️ TVU 主机异常" "$body" "Basso"
fi

if [ "${#RECOVERED[@]}" -gt 0 ]; then
  body="$(printf '%s\n' "${RECOVERED[@]}")"
  log "RECOVERED:"
  printf '%s\n' "${RECOVERED[@]}" | sed 's/^/    /' >> "$LOG"
  notify "✅ TVU 主机已恢复" "$body" "Glass"
fi

# Clear the alert file only when nothing is down at all.
if ! grep -q $'\tdown$' "$STATE" 2>/dev/null; then
  rm -f "$ALERT"
fi

# Keep the log from growing without bound (roughly a year of 15-min checks).
if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt 40000 ]; then
  tail -20000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
  log "(log trimmed)"
fi

exit 0
