#!/bin/bash
# resume-tunnel.sh — Resume the Cloudflare tunnel on brand-service start.
#
# Spawns cloudflared into its own transient systemd SERVICE (cloudflared-<brand>.service)
# under cloudflared.slice, completely decoupled from the brand service's cgroup.
# A brand-service restart can no longer reap the tunnel (Task 602's cgroup
# decoupling). The unit is a Restart=always service (Task 757), so systemd
# resurrects the connector on ANY exit — crash, OOM, manual kill — and the
# connector runs with --no-autoupdate so it never replaces itself; the binary
# version is installer-owned. This is still the single spawner: only the unit
# type changed from a transient --scope (no restart policy) to a supervised
# --service. No second spawner is introduced.
#
# Called as ExecStartPre in maxy.service. All failures exit 0 — this script must
# never prevent maxy.service from starting.
#
# Dependencies: jq, cloudflared, systemd-run, systemctl

set -uo pipefail

# Derive configDir from brand.json. Fall back to .maxy if unreadable.
CONFIG_DIR=".maxy"
if [ -n "${MAXY_PLATFORM_ROOT:-}" ] && [ -f "$MAXY_PLATFORM_ROOT/config/brand.json" ]; then
  BRAND_CONFIG_DIR=$(jq -r '.configDir // empty' "$MAXY_PLATFORM_ROOT/config/brand.json" 2>/dev/null || true)
  if [ -n "$BRAND_CONFIG_DIR" ]; then
    CONFIG_DIR="$BRAND_CONFIG_DIR"
  fi
fi

STATE_FILE="$HOME/$CONFIG_DIR/cloudflared/tunnel.state"
LOG_DIR="$HOME/$CONFIG_DIR/logs"
LOG_FILE="$LOG_DIR/cloudflared.log"

log() {
  mkdir -p "$LOG_DIR"
  echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] resume-tunnel: $1" >> "$LOG_FILE"
}

# Derive brand identifiers + the OAuth cert path up front so every refusal log
# below can name the brand. BRAND strips the leading dot from CONFIG_DIR
# (.maxy-code → cloudflared-maxy-code.service). CERT_PATH is the OAuth account
# cert: it authorises tunnel create/route, NOT tunnel run — a tunnel created via
# the API-token path runs from its credentials-file with no cert at all.
BRAND="${CONFIG_DIR#.}"
SERVICE_UNIT="cloudflared-${BRAND}.service"
CERT_PATH="$HOME/$CONFIG_DIR/cloudflared/cert.pem"

# A configured tunnel (tunnel.state present) that we decline to spawn is logged
# loudly, so a non-resumable tunnel is visible at the next service start rather
# than discovered as an outage. "No state file" stays a silent normal boot.
resume_refused() {
  log "[tunnel-supervise] op=resume-refused brand=${BRAND} tunnel=${TUNNEL_ID:-unknown} reason=$1"
}

# No state file — no tunnel configured. Normal boot.
[ -f "$STATE_FILE" ] || exit 0

log "reading state from $STATE_FILE (configDir=$CONFIG_DIR)"

if ! STATE=$(jq -r '.' "$STATE_FILE" 2>/dev/null); then
  resume_refused "invalid-state-json"
  exit 0
fi

TUNNEL_ID=$(echo "$STATE" | jq -r '.tunnelId // empty')
DOMAIN=$(echo "$STATE" | jq -r '.domain // empty')
CONFIG_PATH=$(echo "$STATE" | jq -r '.configPath // empty')

if [ -z "$TUNNEL_ID" ]; then
  resume_refused "no-tunnel-id"
  exit 0
fi

# Task 833 — single-instance enforcement. cloudflared does not hot-reload
# config.yml; a config change needs a connector restart. resume-tunnel is the
# single spawner but historically managed only its OWN connector, so a connector
# started any other way (an unsupervised process from before Task 757, a manual
# `tunnel run`, a provisioning step that raw-spawned) survived — two connectors
# on one tunnel, one on stale config, and Cloudflare routes some requests to the
# stale one → intermittent 404 for any host added after it started. reap_strays
# guarantees exactly one connector for THIS brand before the spawn/skip decision.
#
# Brand isolation: a candidate is reaped only if its argv carries a brand-scoped
# token — the cert path, config path, or tunnel id, each unique to ~/.<brand>/.
# A co-resident brand's connector matches none and is never touched. If no brand
# token is known we skip the reap entirely rather than risk a match-everything.
KILL_CMD="${RESUME_TUNNEL_KILL_CMD:-kill}"

reap_strays() {
  local survivor_pid="$1"
  local match_args=()
  [ -n "$CERT_PATH" ]   && match_args+=( -e "$CERT_PATH" )
  [ -n "$CONFIG_PATH" ] && match_args+=( -e "$CONFIG_PATH" )
  [ -n "$TUNNEL_ID" ]   && match_args+=( -e "$TUNNEL_ID" )
  if [ "${#match_args[@]}" -eq 0 ]; then
    log "[tunnel-supervise] op=reap brand=$BRAND found=0 killed=0 survivor=${survivor_pid:-none} (no brand token; skipped)"
    return 0
  fi

  local pids
  pids=$(ps -eo pid=,args= 2>/dev/null \
    | grep 'cloudflared' \
    | grep 'tunnel run' \
    | grep -F "${match_args[@]}" \
    | awk '{print $1}')

  local found=0 killed=0 pid
  for pid in $pids; do
    found=$((found + 1))
    if [ -n "$survivor_pid" ] && [ "$pid" = "$survivor_pid" ]; then
      continue
    fi
    local victim_cfg victim_tid
    victim_cfg=$(ps -p "$pid" -o args= 2>/dev/null | sed -n 's/.*--config[ =]\([^ ]*\).*/\1/p')
    victim_tid=$(grep -E '^tunnel:' "$victim_cfg" 2>/dev/null | head -1 | awk '{print $2}')
    [ -n "$victim_tid" ] || victim_tid="unknown"
    log "[tunnel-supervise] op=reap brand=$BRAND target=$pid tunnel=$victim_tid"
    "$KILL_CMD" -TERM "$pid" 2>/dev/null
    local waited=0
    while "$KILL_CMD" -0 "$pid" 2>/dev/null; do
      if [ "$waited" -ge "${RESUME_TUNNEL_REAP_GRACE:-5}" ]; then
        "$KILL_CMD" -KILL "$pid" 2>/dev/null
        break
      fi
      sleep 1
      waited=$((waited + 1))
    done
    killed=$((killed + 1))
  done

  log "[tunnel-supervise] op=reap brand=$BRAND found=$found killed=$killed survivor=${survivor_pid:-none}"
}

# Determine the supervised survivor before reaping. If the unit is active its
# MainPID is the connector we keep; every other brand-matched connector is a
# stray. The service is owned by user-systemd, fully outside the brand service's
# cgroup. Unlike before, an active unit no longer short-circuits the whole
# script: the reap runs first so strays die even when the supervised one is up.
SURVIVOR_PID=""
SERVICE_ACTIVE=0
if systemctl --user is-active "$SERVICE_UNIT" >/dev/null 2>&1; then
  SERVICE_ACTIVE=1
  SURVIVOR_PID=$(systemctl --user show "$SERVICE_UNIT" --property=MainPID --value 2>/dev/null || echo "")
  [ "$SURVIVOR_PID" = "0" ] && SURVIVOR_PID=""
fi

# If the unit is active but its MainPID is unresolved (a RestartSec transition:
# is-active already true while the new MainPID is not yet published), we cannot
# tell the live supervised connector from a stray — reaping now could SIGKILL the
# survivor. Skip the reap this cycle; the next brand-service start reaps once
# MainPID resolves. The connector is up, so there is nothing to spawn either.
if [ "$SERVICE_ACTIVE" -eq 1 ] && [ -z "$SURVIVOR_PID" ]; then
  log "[tunnel-supervise] op=reap brand=$BRAND skipped reason=mainpid-unresolved survivor=unknown"
  log "Tunnel already running (service $SERVICE_UNIT active, MainPID unresolved) — reap skipped, skipping resume"
  exit 0
fi

reap_strays "$SURVIVOR_PID"

if [ "$SERVICE_ACTIVE" -eq 1 ]; then
  log "Tunnel already running (service $SERVICE_UNIT active, MainPID ${SURVIVOR_PID:-unknown}) — strays reaped, skipping resume"
  exit 0
fi

CLOUDFLARED_BIN=$(command -v cloudflared 2>/dev/null || true)

if [ -z "$CLOUDFLARED_BIN" ]; then
  resume_refused "no-cloudflared-binary"
  exit 0
fi

# tunnel run authenticates with the per-tunnel credentials-file declared in
# config.yml (written by `tunnel create`, present in both the OAuth and the
# API-token models). The OAuth account cert (--origincert) is a create/route
# credential; tunnel run never needs it. So a tunnel is resumable whenever its
# config exists AND a usable run credential is present — either cert.pem (OAuth)
# or the credentials-file the config references (API-token). --origincert is
# added to the spawn only when cert.pem exists, so a token-created tunnel with
# no cert resumes too.
if [ -z "$CONFIG_PATH" ] || [ ! -f "$CONFIG_PATH" ]; then
  resume_refused "missing-config"
  exit 0
fi

CRED_FILE=$(sed -n 's/^[[:space:]]*credentials-file:[[:space:]]*//p' "$CONFIG_PATH" 2>/dev/null | head -1 | tr -d '[:space:]')

if [ ! -f "$CERT_PATH" ] && { [ -z "$CRED_FILE" ] || [ ! -f "$CRED_FILE" ]; }; then
  resume_refused "missing-run-credential"
  exit 0
fi

log "Resuming tunnel $TUNNEL_ID for $DOMAIN..."

# Capture log byte offset before spawning so the verification poll only
# matches lines written by THIS invocation, not lines from a prior run.
mkdir -p "$LOG_DIR"

# Task 1706 — hold the log rotator's lock across offset-capture → verify-poll.
# logs-rotate.sh truncates cloudflared.log in place when it exceeds 64 MiB; a
# rotation landing inside this window makes `tail -c "+$LOG_OFFSET"` read past
# EOF and report a tunnel failure that did not happen. Same mkdir primitive as
# the rotator (no flock(1) on darwin, and a lock is only a lock if both ends
# agree). Best-effort: if the lock cannot be taken we proceed anyway rather than
# refuse to bring the tunnel up — a false-alarm log line is a far smaller
# failure than a tunnel that never starts.
ROTATE_LOCK="$LOG_DIR/.rotate.lock"
ROTATE_LOCK_HELD=0
if mkdir "$ROTATE_LOCK" 2>/dev/null; then
  ROTATE_LOCK_HELD=1
  trap 'rmdir "$ROTATE_LOCK" 2>/dev/null || true' EXIT
else
  log "rotate-lock busy — proceeding without it; a concurrent rotation may skew the verify gate"
fi

LOG_OFFSET=$(wc -c < "$LOG_FILE" 2>/dev/null | awk '{print $1+0}' || echo 0)

# Spawn into a SUPERVISED transient service under cloudflared.slice (Task 757).
# Unlike a --scope (no restart policy), a Restart=always service is resurrected
# by systemd on ANY exit — crash, OOM, manual kill. --no-autoupdate stops the
# connector replacing its own binary, so the version stays installer-owned.
# StartLimitIntervalSec/StartLimitBurst trip a genuinely broken binary to
# `failed` instead of looping forever (5 starts within 300s → failed).
# A service's stdout/stderr go to the journal, not this shell, so route them to
# LOG_FILE via StandardOutput/StandardError=append so the verification gate
# below and the operator's cloudflared.log keep working. systemd-run returns
# once the unit is active (it does not block for the process lifetime), so there
# is no shell child to background. --quiet suppresses systemd-run's "Running as
# unit:" line; the trailing redirect only captures any error output it prints.
# Build the connector argv. --origincert is included only for an OAuth tunnel
# (cert.pem present); a token-created tunnel runs from its credentials-file
# alone. The array is always non-empty, so it expands safely under `set -u`.
CONNECTOR_ARGS=( "$CLOUDFLARED_BIN" --no-autoupdate )
[ -f "$CERT_PATH" ] && CONNECTOR_ARGS+=( --origincert "$CERT_PATH" )
CONNECTOR_ARGS+=( --config "$CONFIG_PATH" tunnel run )

systemd-run \
  --user \
  --quiet \
  --unit="$SERVICE_UNIT" \
  --slice=cloudflared \
  -p Restart=always \
  -p RestartSec=2 \
  -p StartLimitIntervalSec=300 \
  -p StartLimitBurst=5 \
  -p TimeoutStopSec=15 \
  -p "StandardOutput=append:$LOG_FILE" \
  -p "StandardError=append:$LOG_FILE" \
  -- \
  "${CONNECTOR_ARGS[@]}" \
  >> "$LOG_FILE" 2>&1

# Log spawn receipt so the operator can distinguish the supervised path from legacy nohup.
SERVICE_PID=$(systemctl --user show "$SERVICE_UNIT" --property=MainPID --value 2>/dev/null || echo "unknown")
log "spawned service=$SERVICE_UNIT slice=cloudflared main-pid=$SERVICE_PID"

# Post-spawn connection verification gate.
# Polls only new log content (from LOG_OFFSET onwards) to avoid false positives
# from "Registered tunnel connection" lines written by a prior run.
# Overridable via RESUME_TUNNEL_VERIFY_DEADLINE for testing.
# On failure: log explicit ERROR and exit 0 — must not block brand-service start.
VERIFY_DEADLINE="${RESUME_TUNNEL_VERIFY_DEADLINE:-20}"
VERIFY_START=$(date +%s)
VERIFIED=0
while true; do
  if tail -c "+$((LOG_OFFSET + 1))" "$LOG_FILE" 2>/dev/null | grep -q "Registered tunnel connection"; then
    VERIFIED=1
    break
  fi
  NOW=$(date +%s)
  ELAPSED=$(( NOW - VERIFY_START ))
  if [ "$ELAPSED" -ge "$VERIFY_DEADLINE" ]; then
    break
  fi
  sleep 1
done

if [ "$VERIFIED" -eq 1 ]; then
  ELAPSED=$(( $(date +%s) - VERIFY_START ))
  CONN_COUNT=$(tail -c "+$((LOG_OFFSET + 1))" "$LOG_FILE" 2>/dev/null | grep -c "Registered tunnel connection" || echo "?")
  log "tunnel verified service=$SERVICE_UNIT connections=$CONN_COUNT ms=$(( ELAPSED * 1000 ))"
else
  log "ERROR: tunnel service started but no edge connection within ${VERIFY_DEADLINE}s — service=$SERVICE_UNIT"
fi

# Task 1706 — release the rotator's lock. The verify gate above is the only
# offset-sensitive window, so holding it past here would make a rotator tick
# skip for no reason.
if [ "$ROTATE_LOCK_HELD" -eq 1 ]; then
  rmdir "$ROTATE_LOCK" 2>/dev/null || true
  ROTATE_LOCK_HELD=0
  trap - EXIT
fi

# Update state file: keep pid/startedAt for observability, add serviceUnit as control signal.
NEW_STATE=$(echo "$STATE" | jq \
  --arg unit "$SERVICE_UNIT" \
  --argjson ts "$(date +%s)000" \
  '.serviceUnit = $unit | .startedAt = $ts' 2>/dev/null || echo "")

if [ -n "$NEW_STATE" ] && echo "$NEW_STATE" > "$STATE_FILE.tmp" 2>/dev/null && mv "$STATE_FILE.tmp" "$STATE_FILE" 2>/dev/null; then
  :
else
  rm -f "$STATE_FILE.tmp" 2>/dev/null
  log "WARNING: tunnel started but failed to update state file"
fi

exit 0
