#!/usr/bin/env bash
# ===== goose: one-box Gooseplicant deployment -----
# Caddy (automatic HTTPS via Cloudflare DNS-01) → Authelia (local users, 2FA) → Gooseplicant, all internal;
# optional public intake through a Cloudflare Tunnel that reaches only the intake-only port.
# Every command is idempotent: re-run it after fixing whatever it told you to fix.
# Requires bash ≥ 3.2, docker + docker compose v2, openssl, curl. Reference: deploy/README.md.
# ===== #
set -euo pipefail

GOOSE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
KIT_DIR="$(dirname "$GOOSE_DIR")"
ENV_FILE="$GOOSE_DIR/.env"
CONFIG_DIR="$GOOSE_DIR/config"
SECRETS_DIR="$GOOSE_DIR/secrets"
TEMPLATES="$GOOSE_DIR/templates"
USERS_FILE="$CONFIG_DIR/authelia/users_database.yml"
AUTHELIA_IMAGE="authelia/authelia:4.39.28"
AUTHELIA_VERSION="4.39.28"
CADDY_IMAGE="gooseplicant-caddy:2.11.4-cf0.2.4"
NODE_IMAGE="node:26-slim" # password hashing fallback when the host has no Node ≥ 24.7 (same base as the app image)
CF_API="${GOOSE_CF_API:-https://api.cloudflare.com/client/v4}"
DOWNLOAD_BASE="${GOOSE_DOWNLOAD_BASE:-https://gooseplicant.goosenotes.dev/downloads}"
TUNNEL_NAME_DEFAULT="gooseplicant-intake"
SECRET_FILES="authelia_session_secret authelia_storage_encryption_key authelia_reset_password_jwt_secret"
umask 077
export COPYFILE_DISABLE=1 # macOS tar: no ._ AppleDouble files in backups

# ---------- output ----------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then
  C_R=$'\033[31m' C_G=$'\033[32m' C_Y=$'\033[33m' C_B=$'\033[34m' C_M=$'\033[35m' C_BOLD=$'\033[1m' C_DIM=$'\033[2m' C_0=$'\033[0m'
else
  C_R="" C_G="" C_Y="" C_B="" C_M="" C_BOLD="" C_DIM="" C_0=""
fi
say() { printf '%s\n' "$*"; }
step() { printf '\n%s==> %s%s\n' "$C_B$C_BOLD" "$*" "$C_0"; }
ok() { printf '  %sok%s    %s\n' "$C_G" "$C_0" "$*"; }
note() { printf '  %s-%s     %s\n' "$C_DIM" "$C_0" "$*"; }
warn() { printf '  %swarn%s  %s\n' "$C_Y" "$C_0" "$*" >&2; }
die() {
  printf '%serror:%s %s\n' "$C_R$C_BOLD" "$C_0" "$*" >&2
  exit 1
}

# Numbered walkthrough for the steps only a human can do (dashboard clicks, DNS records, tokens).
WT_N=0
wt_begin() {
  WT_N=0
  printf '\n%s┌─ ACTION NEEDED: %s%s\n' "$C_M$C_BOLD" "$*" "$C_0"
}
wt() {
  WT_N=$((WT_N + 1))
  printf '%s│%s %2d. %s\n' "$C_M" "$C_0" "$WT_N" "$1"
  shift
  local line
  for line in "$@"; do printf '%s│%s       %s\n' "$C_M" "$C_0" "$line"; done
}
wt_text() { printf '%s│%s     %s\n' "$C_M" "$C_0" "$*"; }
wt_end() { printf '%s└─%s\n' "$C_M" "$C_0"; }

# The only places a secret is printed: values the person must copy somewhere else.
secret_box() {
  printf '\n%s┌─ SECRET: %s%s\n' "$C_Y$C_BOLD" "$1" "$C_0"
  printf '%s│%s  %s\n' "$C_Y" "$C_0" "$2"
  printf '%s└─ %s%s\n' "$C_Y" "${3:-Store it in a password manager. It is not shown again.}" "$C_0"
}

# ---------- small helpers ----------
have() { command -v "$1" >/dev/null 2>&1; }
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
gen_hex() { openssl rand -hex "${1:-32}"; }
gen_password() { openssl rand -base64 30 | tr -d '/+=\n' | cut -c1-24; }
now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
sha256_of() { openssl dgst -sha256 -r "$1" | cut -d' ' -f1 | tr -d '*'; }
TMP_ROOT=""
tmpdir() {
  if [ -z "$TMP_ROOT" ]; then
    TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/goose.XXXXXX")"
    trap 'rm -rf "$TMP_ROOT"' EXIT
  fi
  printf '%s' "$TMP_ROOT"
}

RE_HOST='^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$'
RE_IPV4='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
RE_EMAIL='^[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}$'
RE_USER='^[a-z0-9][a-z0-9._-]{0,63}$'
is_host() { [[ $1 =~ $RE_HOST ]]; }
is_ipv4() { [[ $1 =~ $RE_IPV4 ]]; }
is_email() { [[ $1 =~ $RE_EMAIL ]]; }
is_user() { [[ $1 =~ $RE_USER ]]; }
is_project() { [[ $1 =~ ^[a-z0-9][a-z0-9_-]*$ ]]; }
is_edition() { [ "$1" = full ] || [ "$1" = lite ]; }
is_port() { [[ $1 =~ ^[0-9]{1,5}$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; }
is_any() { :; }
is_nonempty() { [ -n "$1" ]; }
is_yesno() { case "$(lower "$1")" in y | yes | n | no | true | false | 1 | 0) return 0 ;; *) return 1 ;; esac; }
truthy() { case "$(lower "${1:-}")" in y | yes | true | 1) return 0 ;; *) return 1 ;; esac; }
under() { [ "$1" = "$2" ] || case "$1" in *."$2") return 0 ;; *) return 1 ;; esac; } # host under domain
parent_of() { printf '%s' "${1#*.}"; }
is_private_ip() {
  case "$1" in
    10.* | 192.168.* | 127.*) return 0 ;;
    172.1[6-9].* | 172.2[0-9].* | 172.3[01].*) return 0 ;;
    100.6[4-9].* | 100.[7-9][0-9].* | 100.1[01][0-9].* | 100.12[0-7].*) return 0 ;; # CGNAT / Tailscale
    *) return 1 ;;
  esac
}
# JSON string literal (for the small request bodies built here).
json_str() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"; }
# YAML double-quoted scalar.
yaml_str() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"; }
urlencode() {
  local s=$1 out="" c i
  for ((i = 0; i < ${#s}; i++)); do
    c=${s:i:1}
    case "$c" in [A-Za-z0-9._~-]) out="$out$c" ;; *) out="$out$(printf '%%%02X' "'$c")" ;; esac
  done
  printf '%s' "$out"
}

# ---------- JSON reading (jq, python3, node or a node container: whichever exists) ----------
# json_get <path> < json   path: dotted keys / array indexes, e.g. result.0.id ; a trailing "#" gives a length.
json_get() {
  local path=$1 use=${GOOSE_JSON:-} # GOOSE_JSON=jq|python3|node|docker forces one (tests)
  if { [ -z "$use" ] && have jq; } || [ "$use" = jq ]; then
    local filter="" seg rest=$path len=0
    while [ -n "$rest" ]; do
      seg=${rest%%.*}
      if [ "$seg" = "$rest" ]; then rest=""; else rest=${rest#*.}; fi
      if [ "$seg" = "#" ]; then len=1; elif [[ $seg =~ ^[0-9]+$ ]]; then filter="${filter}[$seg]?"; else filter="${filter}[\"$seg\"]?"; fi
    done
    if [ "$len" = 1 ]; then
      jq -r "(.$filter) | if . == null then 0 else length end" 2>/dev/null || printf '\n'
    else
      jq -r "(.$filter) | if . == null then \"\" elif type == \"object\" or type == \"array\" then tojson else tostring end" 2>/dev/null || printf '\n'
    fi
    return 0
  fi
  local prog='const p=process.argv[1]?process.argv[1].split("."):[];let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let v;try{v=JSON.parse(s)}catch{v=null}for(const k of p){if(k==="#"){v=v==null?0:(v.length??Object.keys(v).length);break}v=v==null?null:(Array.isArray(v)?v[+k]:v[k]);if(v===undefined)v=null}console.log(v==null?"":typeof v==="object"?JSON.stringify(v):String(v))})'
  if { [ -z "$use" ] && have python3; } || [ "$use" = python3 ]; then
    python3 -c 'import json,sys
p=sys.argv[1].split(".") if sys.argv[1] else []
try: v=json.load(sys.stdin)
except Exception: v=None
for k in p:
    if k=="#":
        v=len(v) if isinstance(v,(list,dict,str)) else 0
        break
    try: v=v[int(k)] if isinstance(v,list) else v[k]
    except Exception:
        v=None
        break
if v is None: print("")
elif isinstance(v,bool): print("true" if v else "false")
elif isinstance(v,(dict,list)): print(json.dumps(v))
else: print(v)' "$path"
  elif { [ -z "$use" ] && have node; } || [ "$use" = node ]; then
    node -e "$prog" "$path"
  else
    docker run --rm -i "$NODE_IMAGE" node -e "$prog" "$path"
  fi
}

# ---------- deploy/.env (settings + secrets; Compose reads it for ${...}) ----------
env_get() {
  [ -f "$ENV_FILE" ] || return 0
  sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1
}
env_set() {
  local key=$1 val=$2
  case "$val" in *[[:space:]\#\$\"\'\`\\]*) die "internal: refusing to write $key with whitespace, #, \$, quotes or backslashes to .env" ;; esac
  [ -f "$ENV_FILE" ] || : >"$ENV_FILE"
  local tmp="$ENV_FILE.tmp.$$"
  # Values travel in the environment, not in awk's argv (visible to `ps`).
  GOOSE_K=$key GOOSE_V=$val awk 'BEGIN { done = 0; k = ENVIRON["GOOSE_K"]; v = ENVIRON["GOOSE_V"] }
    index($0, k "=") == 1 { if (!done) { print k "=" v; done = 1 } ; next }
    { print }
    END { if (!done) print k "=" v }' "$ENV_FILE" >"$tmp"
  chmod 600 "$tmp"
  mv "$tmp" "$ENV_FILE"
}
cur() { env_get "$1"; }
require_setup() {
  [ -f "$ENV_FILE" ] && [ -f "$CONFIG_DIR/caddy/Caddyfile" ] || die "not set up yet: run ./goose setup first (in $GOOSE_DIR)"
  load_settings
}

load_settings() {
  GOOSE_MODE=$(env_get GOOSE_MODE)
  GOOSE_MODE=${GOOSE_MODE:-production}
  BASE_DOMAIN=$(env_get BASE_DOMAIN)
  APP_HOST=$(env_get APP_HOST)
  AUTH_HOST=$(env_get AUTH_HOST)
  INTAKE_HOST=$(env_get INTAKE_HOST)
  EXTRA_DOMAINS=$(env_get EXTRA_DOMAINS)
  BIND_IP=$(env_get BIND_IP)
  HTTPS_PORT=$(env_get HTTPS_PORT)
  HTTPS_PORT=${HTTPS_PORT:-443}
  HTTP_PORT=$(env_get HTTP_PORT)
  HTTP_PORT=${HTTP_PORT:-80}
  EDITION=$(env_get EDITION)
  EDITION=${EDITION:-full}
  ADMIN_USER=$(env_get ADMIN_USER)
  ADMIN_EMAIL=$(env_get ADMIN_EMAIL)
  ACME_EMAIL=$(env_get ACME_EMAIL)
  AUTH_POLICY=$(env_get AUTH_POLICY)
  AUTH_POLICY=${AUTH_POLICY:-two_factor}
  SMTP_HOST=$(env_get SMTP_HOST)
  SMTP_PORT=$(env_get SMTP_PORT)
  SMTP_USER=$(env_get SMTP_USER)
  SMTP_SENDER=$(env_get SMTP_SENDER)
  APP_MAIL=$(env_get APP_MAIL)
  CF_API_TOKEN=$(env_get CF_API_TOKEN)
  CF_ACCOUNT_ID=$(env_get CF_ACCOUNT_ID)
  TUNNEL_ID=$(env_get TUNNEL_ID)
  TUNNEL_TOKEN=$(env_get TUNNEL_TOKEN)
  INGEST_SECRET=$(env_get INGEST_SECRET)
  PROXY_SECRET=$(env_get PROXY_SECRET)
  GOOSEPLICANT_VERSION=$(env_get GOOSEPLICANT_VERSION)
  COMPOSE_PROJECT_NAME=$(env_get COMPOSE_PROJECT_NAME)
  COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-gooseplicant}
}

kit_version() {
  sed -n 's/^  "version": "\([^"]*\)".*/\1/p' "$KIT_DIR/package.json" 2>/dev/null | head -n 1
}

# ---------- hostnames ----------
# EXTRA_DOMAINS = comma-separated host/authhost/cookiedomain entries added by `goose add-domain`.
app_hosts() {
  printf '%s\n' "$APP_HOST"
  local e
  for e in $(printf '%s' "$EXTRA_DOMAINS" | tr ',' ' '); do printf '%s\n' "${e%%/*}"; done
}
# cookie|authhost|first app host, one line per cookie domain, primary first.
cookie_domains() {
  printf '%s|%s|%s\n' "$BASE_DOMAIN" "$AUTH_HOST" "$APP_HOST"
  local e host auth cookie seen="|$BASE_DOMAIN|"
  for e in $(printf '%s' "$EXTRA_DOMAINS" | tr ',' ' '); do
    host=${e%%/*}
    auth=${e#*/}
    auth=${auth%%/*}
    cookie=${e##*/}
    case "$seen" in *"|$cookie|"*) continue ;; esac
    seen="$seen$cookie|"
    printf '%s|%s|%s\n' "$cookie" "$auth" "$host"
  done
}
auth_hosts() { cookie_domains | cut -d'|' -f2; }
internal_hosts() { { app_hosts; auth_hosts; } | awk '!seen[$0]++'; }
port_suffix() { [ "$HTTPS_PORT" = 443 ] || printf ':%s' "$HTTPS_PORT"; }
https_url() { printf 'https://%s%s' "$1" "$(port_suffix)"; }
join_by() { local sep=$1 out="" x; while IFS= read -r x; do [ -n "$x" ] && out="${out:+$out$sep}$x"; done; printf '%s' "$out"; }

# ---------- answers (interactive prompts or --config answers.env) ----------
INTERACTIVE=0
ANSWERS_FILE=""
ans_has() { [ -n "$ANSWERS_FILE" ] && grep -q "^$1=" "$ANSWERS_FILE"; }
ans_get() {
  local v
  v=$(sed -n "s/^$1=//p" "$ANSWERS_FILE" | tail -n 1)
  v=${v%$'\r'}
  case "$v" in \"*\") v=${v#\"} v=${v%\"} ;; \'*\') v=${v#\'} v=${v%\'} ;; esac
  printf '%s' "$v"
}
# ask KEY "Question" default validator "what is valid"  → prints the answer (prompts go to stderr)
ask() {
  local key=$1 q=$2 def=${3-} check=${4:-is_any} what=${5:-a valid value} v
  if ans_has "$key"; then
    v=$(ans_get "$key")
    [ -n "$v" ] || v=$def
    "$check" "$v" || die "answers file: $key=$v is not $what"
    printf '%s' "$v"
    return 0
  fi
  if [ "$INTERACTIVE" != 1 ]; then
    "$check" "$def" || die "answers file needs $key ($q)"
    printf '%s' "$def"
    return 0
  fi
  while :; do
    printf '%s?%s %s%s: ' "$C_BOLD" "$C_0" "$q" "${def:+ [$def]}" >&2
    IFS= read -r v </dev/tty || v=""
    [ -n "$v" ] || v=$def
    if "$check" "$v"; then printf '%s' "$v"; return 0; fi
    printf '  %sPlease enter %s.%s\n' "$C_Y" "$what" "$C_0" >&2
  done
}
ask_yn() { # ask_yn KEY "Question" y|n → exit status
  local v
  v=$(ask "$1" "$2 (y/n)" "$3" is_yesno "yes or no")
  truthy "$v"
}
ask_secret() { # ask_secret KEY "Question" → prints value (may be empty)
  local key=$1 q=$2 v
  if ans_has "$key"; then ans_get "$key"; return 0; fi
  [ "$INTERACTIVE" = 1 ] || return 0
  printf '%s?%s %s: ' "$C_BOLD" "$C_0" "$q" >&2
  IFS= read -r -s v </dev/tty || v=""
  printf '\n' >&2
  printf '%s' "$v"
}
# A new password: typed twice (hidden), or empty → generated. Sets NEW_PASSWORD and NEW_PASSWORD_GENERATED.
read_new_password() { # read_new_password KEY label
  local key=$1 label=$2 a b
  NEW_PASSWORD="" NEW_PASSWORD_GENERATED=0
  if ans_has "$key"; then
    NEW_PASSWORD=$(ans_get "$key")
  elif [ "$INTERACTIVE" = 1 ]; then
    while :; do
      printf '%s?%s Password for %s (min. 12 characters; leave empty to generate one): ' "$C_BOLD" "$C_0" "$label" >&2
      IFS= read -r -s a </dev/tty || a=""
      printf '\n' >&2
      [ -n "$a" ] || break
      if [ "${#a}" -lt 12 ]; then printf '  %sAt least 12 characters, please.%s\n' "$C_Y" "$C_0" >&2; continue; fi
      printf '%s?%s Repeat it: ' "$C_BOLD" "$C_0" >&2
      IFS= read -r -s b </dev/tty || b=""
      printf '\n' >&2
      [ "$a" = "$b" ] && { NEW_PASSWORD=$a; break; }
      printf '  %sThey differ; try again.%s\n' "$C_Y" "$C_0" >&2
    done
  fi
  if [ -z "$NEW_PASSWORD" ]; then
    NEW_PASSWORD=$(gen_password)
    NEW_PASSWORD_GENERATED=1
  fi
  [ "${#NEW_PASSWORD}" -ge 12 ] || die "the password for $label must have at least 12 characters"
}

# ---------- docker ----------
dc() { docker compose --project-directory "$GOOSE_DIR" -f "$GOOSE_DIR/compose.yml" --env-file "$ENV_FILE" "$@"; }
docker_ok() { [ -z "${GOOSE_NO_DOCKER:-}" ] && have docker && docker info >/dev/null 2>&1; }
need_docker() {
  have docker || die "docker is not installed (https://docs.docker.com/engine/install/)"
  docker info >/dev/null 2>&1 || die "docker is installed but the daemon isn't reachable (is it running? are you in the docker group?)"
  docker compose version >/dev/null 2>&1 || die "docker compose v2 is missing (install the docker-compose-plugin package)"
}
running() { [ -n "$(dc ps -q --status running "$1" 2>/dev/null)" ]; }

# ---------- passwords (argon2id, the format Authelia expects) ----------
# The password goes in on stdin only, never on a command line (where `ps` would show it).
ARGON2_JS='const c=require("node:crypto");let d="";process.stdin.on("data",x=>d+=x).on("end",()=>{const nonce=c.randomBytes(16);const tag=c.argon2Sync("argon2id",{message:Buffer.from(d,"utf8"),nonce,parallelism:4,tagLength:32,memory:65536,passes:3});const b=x=>x.toString("base64").replace(/=+$/,"");process.stdout.write("$argon2id$v=19$m=65536,t=3,p=4$"+b(nonce)+"$"+b(tag)+"\n")})'
hash_password() { # stdin: password → stdout: $argon2id$...
  local out
  if have node && node -e 'process.exit(typeof require("node:crypto").argon2Sync==="function"?0:1)' 2>/dev/null; then
    out=$(node -e "$ARGON2_JS")
  else
    need_docker
    out=$(docker run --rm -i "$NODE_IMAGE" node -e "$ARGON2_JS")
  fi
  case "$out" in '$argon2id$v=19$'*) printf '%s' "$out" ;; *) die "could not hash the password" ;; esac
}

# ---------- Authelia users file ----------
users_indent() { # indentation of the user keys under `users:` (2 by default)
  awk '/^users:[[:space:]]*$/ { u = 1; next }
    u && /^[[:space:]]*(#|$)/ { next }
    u { match($0, /^ */); print RLENGTH; exit }' "$USERS_FILE" 2>/dev/null | grep . || printf '2\n'
}
users_list() {
  [ -f "$USERS_FILE" ] || return 0
  local n
  n=$(users_indent)
  awk -v n="$n" '/^users:[[:space:]]*$/ { u = 1; next }
    /^[^[:space:]#]/ { u = 0 }
    u { match($0, /^ */); if (RLENGTH == n && $0 ~ /:[[:space:]]*$/) { s = substr($0, n + 1); sub(/:[[:space:]]*$/, "", s); gsub(/["'"'"']/, "", s); print s } }' "$USERS_FILE"
}
user_exists() { users_list | grep -qx "$1"; }
users_write() { # stdin → users file, replaced in place (same inode) so Authelia's watch and bind mounts see it
  local tmp="$USERS_FILE.tmp.$$"
  cat >"$tmp"
  grep -q '^users:' "$tmp" || { rm -f "$tmp"; die "internal: users file would lose its users: key"; }
  if [ -f "$USERS_FILE" ]; then cat "$tmp" >"$USERS_FILE"; rm -f "$tmp"; else mv "$tmp" "$USERS_FILE"; fi
  chmod 600 "$USERS_FILE"
}
user_block() { # user_block name displayname email groups(comma) hash indent
  local n=$6 p="" q="" g
  p=$(printf '%*s' "$n" '')
  q=$(printf '%*s' "$((n * 2))" '')
  printf '%s%s:\n' "$p" "$1"
  printf '%sdisabled: false\n' "$q"
  printf '%sdisplayname: %s\n' "$q" "$(yaml_str "$2")"
  printf '%spassword: %s\n' "$q" "$(yaml_str "$5")"
  printf '%semail: %s\n' "$q" "$(yaml_str "$3")"
  if [ -n "$4" ]; then
    printf '%sgroups:\n' "$q"
    for g in $(printf '%s' "$4" | tr ',' ' '); do printf '%s  - %s\n' "$q" "$(yaml_str "$g")"; done
  else
    printf '%sgroups: []\n' "$q"
  fi
}
user_add() { # name displayname email groups hash
  local n
  [ -f "$USERS_FILE" ] || printf '# Authelia users (local accounts). Managed by goose add-user / passwd / remove-user.\nusers:\n' >"$USERS_FILE"
  n=$(users_indent)
  if grep -Eq '^users:[[:space:]]*\{\}[[:space:]]*$' "$USERS_FILE"; then
    { sed 's/^users:.*/users:/' "$USERS_FILE"; user_block "$1" "$2" "$3" "$4" "$5" "$n"; } | users_write
  else
    { cat "$USERS_FILE"; user_block "$1" "$2" "$3" "$4" "$5" "$n"; } | users_write
  fi
}
user_remove() {
  local n
  n=$(users_indent)
  awk -v n="$n" -v name="$1" '
    /^users:[[:space:]]*$/ { u = 1; print; next }
    /^[^[:space:]#]/ { u = 0; skip = 0 }
    u { match($0, /^ */); ind = RLENGTH
        if ($0 !~ /^[[:space:]]*$/ && ind <= n) {
          s = substr($0, n + 1); sub(/:[[:space:]]*$/, "", s); gsub(/["'"'"']/, "", s)
          skip = (ind == n && s == name) } }
    !skip { print }' "$USERS_FILE" | users_write
}
user_set_password() { # name hash
  local n
  n=$(users_indent)
  GOOSE_HASH=$2 awk -v n="$n" -v name="$1" 'BEGIN { hash = ENVIRON["GOOSE_HASH"] }
    /^users:[[:space:]]*$/ { u = 1; print; next }
    /^[^[:space:]#]/ { u = 0; inuser = 0 }
    u { match($0, /^ */); ind = RLENGTH
        if ($0 !~ /^[[:space:]]*$/ && ind <= n) { s = substr($0, n + 1); sub(/:[[:space:]]*$/, "", s); gsub(/["'"'"']/, "", s); inuser = (ind == n && s == name) }
        else if (inuser && ind == 2 * n && $0 ~ /^[[:space:]]*password:/) { printf "%*spassword: \"%s\"\n", 2 * n, "", hash; done = 1; next } }
    { print }
    END { if (!done) exit 3 }' "$USERS_FILE" | users_write || die "user $1 has no password line to replace"
}
user_email() {
  local n
  n=$(users_indent)
  awk -v n="$n" -v name="$1" '
    /^users:[[:space:]]*$/ { u = 1; next }
    /^[^[:space:]#]/ { u = 0 }
    u { match($0, /^ */); ind = RLENGTH
        if ($0 !~ /^[[:space:]]*$/ && ind <= n) { s = substr($0, n + 1); sub(/:[[:space:]]*$/, "", s); gsub(/["'"'"']/, "", s); inuser = (ind == n && s == name) }
        else if (inuser && $0 ~ /^[[:space:]]*email:/) { v = $0; sub(/^[[:space:]]*email:[[:space:]]*/, "", v); gsub(/["'"'"']/, "", v); print v; exit } }' "$USERS_FILE"
}
# After the users file changed: Authelia reloads it by itself (watch: true); confirm that, else restart it.
authelia_reload_users() {
  docker_ok && [ -f "$ENV_FILE" ] && running authelia || {
    note "Authelia isn't running; it reads the users file when it starts (./goose up)."
    return 0
  }
  local since i
  since=$1
  for i in 1 2 3 4 5 6 7 8 9 10; do
    if dc logs --no-log-prefix --since "$since" authelia 2>/dev/null | grep -q 'Reloaded successfully.*users_database'; then
      ok "Authelia reloaded the users file"
      return 0
    fi
    sleep 1
  done
  warn "Authelia didn't report a reload within 10s (file events don't always cross bind mounts); restarting it"
  dc restart authelia >/dev/null
  wait_healthy authelia 60 && ok "Authelia restarted with the new users file"
}
wait_healthy() { # service seconds
  local cid s i=0
  while [ "$i" -lt "$2" ]; do
    cid=$(dc ps -q "$1" 2>/dev/null || true)
    if [ -n "$cid" ]; then
      s=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid" 2>/dev/null || true)
      case "$s" in healthy | running) return 0 ;; esac
    fi
    sleep 1
    i=$((i + 1))
  done
  warn "$1 is not healthy after $2s: ./goose logs $1"
  return 1
}

# ---------- rendering ----------
# render <template> <output>: {{NAME}} ← $T_NAME (exported). Unknown placeholders fail loudly.
render() {
  local tmp="$2.tmp.$$"
  awk '{ line = $0; out = ""
      while (match(line, /\{\{[A-Z0-9_]+\}\}/)) {
        key = substr(line, RSTART + 2, RLENGTH - 4)
        if (!(("T_" key) in ENVIRON)) { printf "unrendered placeholder {{%s}} in %s\n", key, FILENAME > "/dev/stderr"; exit 3 }
        out = out substr(line, 1, RSTART - 1) ENVIRON["T_" key]; line = substr(line, RSTART + RLENGTH) }
      print out line }' "$1" >"$tmp" || { rm -f "$tmp"; die "rendering $1 failed"; }
  chmod 600 "$tmp"
  mv "$tmp" "$2"
}

smtp_scheme() { case "$1" in 465) printf 'submissions' ;; 25) printf 'smtp' ;; *) printf 'submission' ;; esac; }

render_all() {
  load_settings
  mkdir -p "$CONFIG_DIR/authelia" "$CONFIG_DIR/caddy" "$SECRETS_DIR"
  local hosts cookies notifier line cookie auth first
  export T_GENERATED_AT T_GOOSE_MODE T_ACME_EMAIL T_GLOBAL_EXTRA T_TLS_BLOCK T_APP_SITES T_AUTH_SITES T_INTERNAL_INGEST
  export T_AUTHELIA_VERSION T_ACCESS_RULES T_SESSION_COOKIES T_NOTIFIER T_PUBLIC_URL T_EDITION T_INGEST_SECRET
  export T_PROXY_SECRET T_ADMIN_EMAIL T_MAIL_ENV T_SMTP_PASSWORD_ENV
  T_GENERATED_AT=$(now_utc)
  T_GOOSE_MODE=$GOOSE_MODE
  T_ACME_EMAIL=${ACME_EMAIL:-$ADMIN_EMAIL}
  T_AUTHELIA_VERSION=$AUTHELIA_VERSION
  if [ "$GOOSE_MODE" = test ]; then
    T_GLOBAL_EXTRA=$'\t# test mode: certificates from Caddy\'s own local CA, nothing asks Cloudflare\n\tskip_install_trust'
    T_TLS_BLOCK=$'\ttls internal'
  else
    T_GLOBAL_EXTRA=$'\t# production: Let\'s Encrypt, DNS-01 at Cloudflare (CF_API_TOKEN: Zone.Zone Read + Zone.DNS Edit)'
    T_TLS_BLOCK=$'\ttls {\n\t\tdns cloudflare {env.CF_API_TOKEN}\n\t\tresolvers 1.1.1.1 1.0.0.1\n\t}'
  fi
  T_APP_SITES=$(app_hosts | join_by ', ')
  T_AUTH_SITES=$(auth_hosts | awk '!seen[$0]++' | join_by ', ')
  if [ -n "$INTAKE_HOST" ]; then
    T_INTERNAL_INGEST=$'\t\t# /ats/ingest is not served here: applications arrive through the Cloudflare Tunnel\n\t\t# (https://'"$INTAKE_HOST"$'/ats/ingest → cloudflared → gooseplicant:3001).'
  else
    T_INTERNAL_INGEST=$'\t\t# No public intake hostname: a website on your network posts signed applications here, without\n\t\t# a login (the HMAC signature is the check). Only the intake-only listener (3001) answers it.\n\t\t@ingest {\n\t\t\tmethod POST\n\t\t\tpath /ats/ingest\n\t\t}\n\t\treverse_proxy @ingest gooseplicant:3001'
  fi
  hosts=""
  while IFS= read -r line; do hosts="$hosts        - '$line'"$'\n'; done <<EOF
$(app_hosts)
EOF
  T_ACCESS_RULES="    - domain:"$'\n'"${hosts}      policy: '$AUTH_POLICY'"
  cookies=""
  while IFS='|' read -r cookie auth first; do
    [ -n "$cookie" ] || continue
    cookies="$cookies    - domain: '$cookie'"$'\n'"      authelia_url: '$(https_url "$auth")'"$'\n'"      default_redirection_url: '$(https_url "$first")/ats/'"$'\n'
  done <<EOF
$(cookie_domains)
EOF
  T_SESSION_COOKIES=${cookies%$'\n'}
  T_SMTP_PASSWORD_ENV="# no SMTP password (filesystem notifier or SMTP without login)"
  if [ -n "$SMTP_HOST" ]; then
    notifier="  smtp:"$'\n'"    address: '$(smtp_scheme "$SMTP_PORT")://$SMTP_HOST:$SMTP_PORT'"$'\n'"    sender: $(yaml_str "Gooseplicant <$SMTP_SENDER>")"$'\n'"    subject: '[Gooseplicant] {title}'"
    if [ -n "$SMTP_USER" ]; then
      notifier="$notifier"$'\n'"    username: $(yaml_str "$SMTP_USER")"$'\n'"    # password: AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE"
      T_SMTP_PASSWORD_ENV="AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/run/secrets/authelia_smtp_password"
    fi
  else
    notifier="  # No SMTP: one-time codes and reset links are written here; read them with ./goose code"$'\n'"  filesystem:"$'\n'"    filename: '/var/lib/authelia/notification.txt'"
  fi
  T_NOTIFIER=$notifier
  T_PUBLIC_URL="$(https_url "$APP_HOST")"
  T_EDITION=$EDITION
  T_INGEST_SECRET=$INGEST_SECRET
  T_PROXY_SECRET=$PROXY_SECRET
  T_ADMIN_EMAIL=$ADMIN_EMAIL
  T_MAIL_ENV="# No outgoing mail configured for Gooseplicant (re-run ./goose setup to add SMTP)."
  if [ -n "$SMTP_HOST" ] && truthy "$APP_MAIL"; then
    local sender_addr=$SMTP_SENDER cred="" pw
    if [ -n "$SMTP_USER" ]; then
      pw=$(cat "$SECRETS_DIR/authelia_smtp_password" 2>/dev/null || true)
      cred="$(urlencode "$SMTP_USER"):$(urlencode "$pw")@"
    fi
    local scheme=smtp
    [ "$SMTP_PORT" = 465 ] && scheme=smtps
    T_MAIL_ENV="MAIL_FROM=$sender_addr"$'\n'"SMTP_URL=$scheme://$cred$SMTP_HOST:$SMTP_PORT"
  fi
  # Directories are bind-mounted (not single files): a file replaced by rename stays stale in a container.
  render "$TEMPLATES/Caddyfile.tmpl" "$CONFIG_DIR/caddy/Caddyfile"
  render "$TEMPLATES/authelia/configuration.yml.tmpl" "$CONFIG_DIR/authelia/configuration.yml"
  render "$TEMPLATES/gooseplicant.env.tmpl" "$CONFIG_DIR/gooseplicant.env"
  render "$TEMPLATES/authelia.env.tmpl" "$CONFIG_DIR/authelia.env"
  # Compose only starts cloudflared (profile public-intake) when there is a tunnel token.
  if [ -n "$INTAKE_HOST" ] && [ -n "$TUNNEL_TOKEN" ]; then env_set COMPOSE_PROFILES public-intake; else env_set COMPOSE_PROFILES ""; fi
}

ensure_secrets() {
  mkdir -p "$SECRETS_DIR"
  chmod 700 "$SECRETS_DIR"
  local f
  for f in $SECRET_FILES; do
    if [ ! -s "$SECRETS_DIR/$f" ]; then
      gen_hex 32 | tr -d '\n' >"$SECRETS_DIR/$f"
      note "generated secrets/$f"
    fi
    chmod 600 "$SECRETS_DIR/$f"
  done
  [ -f "$SECRETS_DIR/authelia_smtp_password" ] || : >"$SECRETS_DIR/authelia_smtp_password"
  chmod 600 "$SECRETS_DIR/authelia_smtp_password"
  local k
  for k in INGEST_SECRET PROXY_SECRET; do
    if [ -z "$(env_get "$k")" ]; then
      env_set "$k" "$(gen_hex 32)"
      note "generated $k"
    fi
  done
}

validate_configs() {
  docker_ok || { note "docker not reachable: skipped authelia validate-config (./goose up validates on start)"; return 0; }
  local out
  if out=$(docker run --rm -v "$CONFIG_DIR/authelia:/config:ro" -v "$SECRETS_DIR:/run/secrets:ro" \
    --env-file "$CONFIG_DIR/authelia.env" "$AUTHELIA_IMAGE" authelia validate-config --config /config/configuration.yml 2>&1); then
    ok "Authelia configuration valid ($AUTHELIA_IMAGE validate-config)"
  else
    printf '%s\n' "$out" >&2
    die "Authelia rejected config/authelia/configuration.yml (see above)"
  fi
  if docker image inspect "$CADDY_IMAGE" >/dev/null 2>&1; then
    if out=$(docker run --rm -e CF_API_TOKEN=x -e ATS_PROXY_SECRET=x -v "$CONFIG_DIR/caddy:/etc/caddy:ro" "$CADDY_IMAGE" \
      caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1); then
      ok "Caddyfile valid (caddy validate)"
    else
      printf '%s\n' "$out" >&2
      die "Caddy rejected config/caddy/Caddyfile (see above)"
    fi
  fi
}

# ---------- Cloudflare API ----------
# cf METHOD PATH [json] → CF_STATUS (HTTP code), CF_BODY. The token goes in a curl config file, not argv.
CF_STATUS="" CF_BODY=""
cf() {
  local t auth
  t=$(tmpdir)
  auth="$t/cf-auth"
  printf 'header = "Authorization: Bearer %s"\n' "$CF_API_TOKEN" >"$auth"
  if [ -n "${3:-}" ]; then
    printf '%s' "$3" >"$t/cf-req.json"
    CF_BODY=$(curl -sS --max-time 30 -K "$auth" -X "$1" -H 'Content-Type: application/json' --data-binary "@$t/cf-req.json" -w '\n%{http_code}' "$CF_API$2" 2>"$t/cf-err") || CF_BODY=$'\n000'
  else
    CF_BODY=$(curl -sS --max-time 30 -K "$auth" -X "$1" -w '\n%{http_code}' "$CF_API$2" 2>"$t/cf-err") || CF_BODY=$'\n000'
  fi
  CF_STATUS=${CF_BODY##*$'\n'}
  CF_BODY=${CF_BODY%$'\n'*}
  [ "$CF_STATUS" != 000 ] || CF_BODY="{\"errors\":[{\"message\":$(json_str "$(head -c 300 "$t/cf-err")")}]}"
  case "$CF_STATUS" in 2*) [ "$(printf '%s' "$CF_BODY" | json_get success)" = true ] ;; *) return 1 ;; esac
}
cf_get() { printf '%s' "$CF_BODY" | json_get "$1"; }
cf_error() {
  local m c
  m=$(cf_get errors.0.message)
  c=$(cf_get errors.0.code)
  printf 'HTTP %s%s%s' "$CF_STATUS" "${c:+, code $c}" "${m:+: $m}"
}
CF_ZONES="" ZONE_ID="" CF_ZONE_NAME="" # cache: ;name=id;
cf_zone_for() { # host → ZONE_ID + CF_ZONE_NAME (walking up the labels); status 1 when none is visible
  local cand=$1 id
  ZONE_ID="" CF_ZONE_NAME=""
  while :; do
    case "$CF_ZONES" in *";$cand="*)
      id=${CF_ZONES#*;"$cand"=}
      ZONE_ID=${id%%;*}
      CF_ZONE_NAME=$cand
      return 0
      ;;
    esac
    if cf GET "/zones?name=$cand&status=active"; then
      id=$(cf_get result.0.id)
      if [ -n "$id" ]; then
        CF_ZONES="$CF_ZONES;$cand=$id;"
        [ -n "$CF_ACCOUNT_ID" ] || CF_ACCOUNT_ID=$(cf_get result.0.account.id)
        ZONE_ID=$id
        CF_ZONE_NAME=$cand
        return 0
      fi
    fi
    case "$cand" in *.*.*) cand=${cand#*.} ;; *) return 1 ;; esac
  done
}

# ----- walkthroughs (the website guide reuses these texts) -----
wt_cloudflare_token() {
  wt_begin "Create a Cloudflare API token"
  wt "Open https://dash.cloudflare.com/profile/api-tokens and click \"Create Token\"."
  wt "Next to \"Edit zone DNS\" click \"Use template\" (or \"Create Custom Token\" → \"Get started\")."
  wt "Permissions: keep or add these rows:" \
    "Zone · DNS · Edit" \
    "Zone · Zone · Read" \
    "For the public intake tunnel (optional) also add:" \
    "Account · Cloudflare Tunnel · Edit"
  wt "Zone Resources: Include · Specific zone · ${1:-example.com}" \
    "(add every zone that holds a Gooseplicant hostname)." \
    "Account Resources (only needed with the tunnel row): Include · your account."
  wt "Optional: Client IP Address Filtering → this server's public IP address."
  wt "\"Continue to summary\" → \"Create Token\". Copy the token: Cloudflare shows it only once."
  wt "Run ./goose setup again and paste it when asked (non-interactive: CF_API_TOKEN=... in the answers file)."
  wt_end
}
wt_dns_records() { # lines "type|name|content|proxy"
  local zone=$1 rows=$2 t n c p
  wt_begin "Add DNS records in Cloudflare (zone $zone)"
  wt "Cloudflare dashboard → $zone → DNS → Records → \"Add record\", once per row:"
  while IFS='|' read -r t n c p; do
    [ -n "$t" ] || continue
    wt_text "      Type $t   Name $n   ${t} content $c   Proxy status: $p   TTL: Auto"
  done <<EOF
$rows
EOF
  wt "Proxy status must be \"DNS only\" (grey cloud) for the internal names: staff connect straight to this" \
    "server, and Cloudflare can't proxy to a private address. The address is private, so nobody outside your" \
    "network or tailnet can connect even though the name is public."
  wt "Prefer not to publish private addresses? Skip this and add the same names to your local DNS instead" \
    "(router, Pi-hole, Tailscale split DNS). Certificates still work: DNS-01 only needs a TXT record at Cloudflare."
  wt "Check with: ./goose doctor"
  wt_end
}
wt_tunnel() {
  local host=$1 sub zone
  sub=${host%%.*}
  zone=${host#*.}
  wt_begin "Create the Cloudflare Tunnel for the public intake hostname $host"
  wt "Open https://one.dash.cloudflare.com → Networks → Tunnels → \"Create a tunnel\"."
  wt "Connector type \"Cloudflared\" → Next. Name it $TUNNEL_NAME_DEFAULT → \"Save tunnel\"."
  wt "Under \"Install and run connectors\" pick \"Docker\". The command shown ends with --token eyJ... :" \
    "copy only that token (the long string after --token). Don't run the command: goose runs cloudflared."
  wt "Next → Public hostname (\"Route traffic\"):" \
    "Subdomain: $sub   Domain: $zone   Path: ^/ats/(ingest|api/health)\$" \
    "Service type: HTTP   URL: gooseplicant:3001" \
    "→ \"Save tunnel\". Cloudflare adds the proxied CNAME $host → <tunnel-id>.cfargotunnel.com itself."
  wt "Paste the token when ./goose setup asks for it (or run ./goose setup again later)." \
    "Then ./goose up starts cloudflared, and ./goose doctor checks the tunnel."
  wt_end
}
cf_dns_plan_and_apply() { # $1 = space-separated hostnames (A → BIND_IP, DNS only)
  local hosts=$1 h zone plan="" manual="" i n typ content proxied rid changes=0 manual_zone=""
  for h in $hosts; do
    cf_zone_for "$h" && zone=$ZONE_ID || {
      warn "no Cloudflare zone for $h visible to this token"
      manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'
      manual_zone=${manual_zone:-$(parent_of "$h")}
      continue
    }
    if ! cf GET "/zones/$zone/dns_records?name=$h&per_page=50"; then
      warn "can't read DNS records of $CF_ZONE_NAME ($(cf_error)): the token needs Zone · DNS · Edit"
      manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'
      manual_zone=${manual_zone:-$CF_ZONE_NAME}
      continue
    fi
    n=$(cf_get result.#)
    rid="" typ=""
    i=0
    while [ "$i" -lt "${n:-0}" ]; do
      typ=$(cf_get "result.$i.type")
      if [ "$typ" = A ]; then
        rid=$(cf_get "result.$i.id")
        content=$(cf_get "result.$i.content")
        proxied=$(cf_get "result.$i.proxied")
        break
      fi
      i=$((i + 1))
    done
    if [ -n "$rid" ]; then
      if [ "$content" = "$BIND_IP" ] && [ "$proxied" = false ]; then
        ok "DNS $h A $BIND_IP (DNS only) already in place"
      else
        plan="${plan}update|$zone|$rid|$h|A $content$([ "$proxied" != true ] || printf ' (proxied)') → A $BIND_IP (DNS only)"$'\n'
      fi
    elif [ "${n:-0}" -gt 0 ]; then
      warn "$h already has a $typ record in Cloudflare; goose won't delete it. Remove it, then re-run (or keep it if it's right)."
      manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'
      manual_zone=${manual_zone:-$CF_ZONE_NAME}
    else
      plan="${plan}create|$zone||$h|A $BIND_IP (DNS only)"$'\n'
    fi
  done
  if [ -n "$plan" ]; then
    say "  goose can make these DNS changes in Cloudflare:"
    printf '%s' "$plan" | while IFS='|' read -r a z r h d; do [ -n "$a" ] && printf '      %-7s %-34s %s\n' "$a" "$h" "$d"; done
    is_private_ip "$BIND_IP" && note "$BIND_IP is a private address: the names become public, the server does not."
    if ask_yn CF_CREATE_DNS "Apply these DNS changes via the Cloudflare API?" n; then
      while IFS='|' read -r a z r h d; do
        [ -n "$a" ] || continue
        local body
        body="{\"type\":\"A\",\"name\":$(json_str "$h"),\"content\":$(json_str "$BIND_IP"),\"ttl\":1,\"proxied\":false,\"comment\":\"Gooseplicant internal hostname (goose)\"}"
        if [ "$a" = create ]; then cf POST "/zones/$z/dns_records" "$body" && ok "created A $h → $BIND_IP" && changes=$((changes + 1)) && continue
        else cf PUT "/zones/$z/dns_records/$r" "$body" && ok "updated A $h → $BIND_IP" && changes=$((changes + 1)) && continue; fi
        warn "Cloudflare refused the change for $h ($(cf_error)); the token needs Zone · DNS · Edit for that zone"
        manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'
        manual_zone=${manual_zone:-$(parent_of "$h")}
      done <<EOF
$plan
EOF
    else
      while IFS='|' read -r a z r h d; do [ -n "$a" ] && manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'; done <<EOF
$plan
EOF
      manual_zone=${manual_zone:-$BASE_DOMAIN}
    fi
  fi
  [ -z "$manual" ] || wt_dns_records "${manual_zone:-$BASE_DOMAIN}" "$manual"
}

cf_tunnel_setup() {
  local name acc id tok zone body rec n i typ rid content
  name=$(ask TUNNEL_NAME "Tunnel name" "$TUNNEL_NAME_DEFAULT" is_user "lowercase letters, digits, . _ -")
  acc=$CF_ACCOUNT_ID
  if [ -z "$acc" ] || ! cf GET "/accounts/$acc/cfd_tunnel?is_deleted=false&name=$name"; then
    warn "this token can't manage tunnels${acc:+ ($(cf_error))}: it needs Account · Cloudflare Tunnel · Edit"
    return 1
  fi
  id=$(cf_get result.0.id)
  say "  goose can set up the public intake in Cloudflare:"
  if [ -n "$id" ]; then printf '      reuse   tunnel %-26s id %s\n' "$name" "$id"; else printf '      create  tunnel %-26s (remotely managed, cloudflared runs here)\n' "$name"; fi
  printf '      set     ingress %s (path ^/ats/(ingest|api/health)$) → http://gooseplicant:3001, anything else → 404\n' "$INTAKE_HOST"
  printf '      upsert  CNAME %s → <tunnel-id>.cfargotunnel.com (proxied)\n' "$INTAKE_HOST"
  ask_yn CF_CREATE_TUNNEL "Create/update the tunnel via the Cloudflare API?" n || return 1
  if [ -z "$id" ]; then
    cf POST "/accounts/$acc/cfd_tunnel" "{\"name\":$(json_str "$name"),\"config_src\":\"cloudflare\"}" ||
      { warn "creating the tunnel failed ($(cf_error))"; return 1; }
    id=$(cf_get result.id)
    ok "created tunnel $name ($id)"
  else
    ok "reusing tunnel $name ($id)"
  fi
  body="{\"config\":{\"ingress\":[{\"hostname\":$(json_str "$INTAKE_HOST"),\"path\":\"^/ats/(ingest|api/health)\$\",\"service\":\"http://gooseplicant:3001\",\"originRequest\":{}},{\"service\":\"http_status:404\"}]}}"
  cf PUT "/accounts/$acc/cfd_tunnel/$id/configurations" "$body" || { warn "setting the tunnel ingress failed ($(cf_error))"; return 1; }
  ok "tunnel ingress: $INTAKE_HOST → http://gooseplicant:3001 (everything else 404)"
  cf_zone_for "$INTAKE_HOST" || { warn "no Cloudflare zone for $INTAKE_HOST"; return 1; }
  zone=$ZONE_ID
  rec="{\"type\":\"CNAME\",\"name\":$(json_str "$INTAKE_HOST"),\"content\":\"$id.cfargotunnel.com\",\"ttl\":1,\"proxied\":true,\"comment\":\"Gooseplicant public intake (goose)\"}"
  cf GET "/zones/$zone/dns_records?name=$INTAKE_HOST&per_page=50" || { warn "reading DNS records failed ($(cf_error))"; return 1; }
  n=$(cf_get result.#)
  rid="" i=0
  while [ "$i" -lt "${n:-0}" ]; do
    typ=$(cf_get "result.$i.type")
    if [ "$typ" = CNAME ]; then rid=$(cf_get "result.$i.id"); content=$(cf_get "result.$i.content"); break; fi
    i=$((i + 1))
  done
  if [ -n "$rid" ] && [ "$content" = "$id.cfargotunnel.com" ]; then
    ok "DNS $INTAKE_HOST CNAME → tunnel already in place"
  elif [ -n "$rid" ]; then
    cf PUT "/zones/$zone/dns_records/$rid" "$rec" || { warn "updating the CNAME failed ($(cf_error))"; return 1; }
    ok "updated CNAME $INTAKE_HOST → $id.cfargotunnel.com (proxied)"
  elif [ "${n:-0}" -gt 0 ]; then
    warn "$INTAKE_HOST already has a $(cf_get result.0.type) record; delete it in the dashboard, then re-run ./goose setup"
    return 1
  else
    cf POST "/zones/$zone/dns_records" "$rec" || { warn "creating the CNAME failed ($(cf_error))"; return 1; }
    ok "created CNAME $INTAKE_HOST → $id.cfargotunnel.com (proxied)"
  fi
  cf GET "/accounts/$acc/cfd_tunnel/$id/token" || { warn "fetching the tunnel token failed ($(cf_error))"; return 1; }
  tok=$(cf_get result)
  [ -n "$tok" ] || { warn "Cloudflare returned no tunnel token"; return 1; }
  env_set TUNNEL_ID "$id"
  env_set TUNNEL_TOKEN "$tok"
  env_set CF_ACCOUNT_ID "$acc"
  ok "tunnel token stored in deploy/.env (TUNNEL_TOKEN)"
  return 0
}

tunnel_id_from_token() { # a tunnel token is base64 JSON {"a":account,"t":tunnel,"s":secret}
  printf '%s' "$1" | openssl base64 -d -A 2>/dev/null | sed -n 's/.*"t":"\([^"]*\)".*/\1/p'
}
manual_tunnel_token() {
  local tok id
  wt_tunnel "$INTAKE_HOST"
  tok=$(ask_secret TUNNEL_TOKEN "Paste the tunnel token (leave empty to do this later)")
  tok=$(printf '%s' "$tok" | tr -d '[:space:]')
  tok=${tok#--token}
  if [ -z "$tok" ]; then
    warn "no tunnel token yet: the public intake stays offline until you re-run ./goose setup with one"
    return 0
  fi
  id=$(tunnel_id_from_token "$tok")
  [ -n "$id" ] || die "that doesn't look like a tunnel token (expected the eyJ... string after --token)"
  env_set TUNNEL_TOKEN "$tok"
  env_set TUNNEL_ID "$id"
  ok "tunnel token stored (tunnel $id)"
}

cloudflare_setup() {
  step "Cloudflare: certificates, DNS and the public intake tunnel"
  local tok
  CF_API_TOKEN=$(env_get CF_API_TOKEN)
  if [ -n "$CF_API_TOKEN" ] && ! ans_has CF_API_TOKEN; then
    note "a Cloudflare API token is already stored (deploy/.env CF_API_TOKEN)"
    if [ "$INTERACTIVE" = 1 ] && ask_yn CF_REPLACE_TOKEN "Replace it?" n; then CF_API_TOKEN=""; fi
  fi
  if [ -z "$CF_API_TOKEN" ] || ans_has CF_API_TOKEN; then
    say "  Caddy gets certificates with the ACME DNS-01 challenge at Cloudflare, so the hostnames can stay internal."
    say "  It needs an API token with Zone · Zone · Read + Zone · DNS · Edit (and optionally Account · Cloudflare Tunnel · Edit)."
    tok=$(ask_secret CF_API_TOKEN "Cloudflare API token (leave empty to see how to create one)")
    tok=$(printf '%s' "$tok" | tr -d '[:space:]')
    if [ -z "$tok" ]; then
      wt_cloudflare_token "$BASE_DOMAIN"
      warn "no token: Caddy can't get certificates until you add one (re-run ./goose setup)"
      wt_dns_records "$BASE_DOMAIN" "$(for h in $(internal_hosts); do printf 'A|%s|%s|DNS only\n' "$h" "$BIND_IP"; done)"
      [ -z "$INTAKE_HOST" ] || [ -n "$(env_get TUNNEL_TOKEN)" ] || manual_tunnel_token
      return 0
    fi
    case "$tok" in *[!A-Za-z0-9_-]*) die "that token has characters Cloudflare tokens don't use" ;; esac
    CF_API_TOKEN=$tok
    env_set CF_API_TOKEN "$tok"
  fi
  if cf GET /user/tokens/verify && [ "$(cf_get result.status)" = active ]; then
    ok "token is valid and active"
  else
    warn "token verification failed ($(cf_error)); trying to use it anyway"
  fi
  CF_ACCOUNT_ID=$(env_get CF_ACCOUNT_ID)
  if cf_zone_for "$BASE_DOMAIN"; then
    env_set CF_ZONE_ID "$ZONE_ID"
    ok "zone $CF_ZONE_NAME ($ZONE_ID)"
    [ -z "$CF_ACCOUNT_ID" ] || env_set CF_ACCOUNT_ID "$CF_ACCOUNT_ID"
  else
    warn "the token can't see a zone for $BASE_DOMAIN ($(cf_error)); certificates need Zone · Zone · Read + Zone · DNS · Edit on it"
    wt_cloudflare_token "$BASE_DOMAIN"
  fi
  cf_dns_plan_and_apply "$(internal_hosts | tr '\n' ' ')"
  if [ -n "$INTAKE_HOST" ]; then
    if [ -n "$(env_get TUNNEL_TOKEN)" ] && ! ans_has CF_CREATE_TUNNEL && ! ask_yn CF_RECONFIGURE_TUNNEL "A tunnel is already configured. Check/update it in Cloudflare?" n; then
      ok "keeping the configured tunnel ($(env_get TUNNEL_ID))"
    elif ! cf_tunnel_setup; then
      [ -n "$(env_get TUNNEL_TOKEN)" ] && ! ans_has TUNNEL_TOKEN || manual_tunnel_token
    fi
  fi
}

detect_ips() { # prints "ip label" lines
  local ts=""
  if have tailscale; then ts=$(tailscale ip -4 2>/dev/null | head -n 1 || true); fi
  {
    if have ip; then ip -4 -o addr show scope global 2>/dev/null | awk '{ split($4, a, "/"); print a[1] }'
    elif have ifconfig; then ifconfig 2>/dev/null | awk '$1 == "inet" && $2 !~ /^127\./ { print $2 }'; fi
  } | awk '!seen[$0]++' | while IFS= read -r ip; do
    [ -n "$ip" ] || continue
    if [ "$ip" = "$ts" ]; then continue; fi
    case "$ip" in 172.1[7-9].* | 172.2[0-9].* | 172.3[01].*) continue ;; esac # docker bridges
    printf '%s LAN\n' "$ip"
  done
  [ -z "$ts" ] || printf '%s Tailscale\n' "$ts"
}

# ---------- commands ----------
usage() {
  cat <<EOF
${C_BOLD}goose${C_0}: one-box Gooseplicant (Caddy + Authelia + Gooseplicant [+ Cloudflare Tunnel])

  ./goose setup [--config answers.env] [--import-users users_database.yml] [--test | --production]
  ./goose up [--build] | down [--volumes [--yes]] | restart [service] | logs [service] [-f] | status
  ./goose add-user [username] [--name N] [--email E] [--groups a,b] [--password-stdin]
  ./goose passwd <username> [--password-stdin] | remove-user <username> [--yes] | reset-2fa <username>
  ./goose add-domain <hostname> [--auth-host H] [--cookie-domain D] [--yes]
  ./goose doctor | code | connect | render
  ./goose backup [dir] | restore <file> [--yes]
  ./goose upgrade [version|latest] [--files-only] [--yes]
  ./goose upgrade-finish   (run by upgrade in the new kit; by hand after --files-only)

Settings and secrets: deploy/.env (mode 600). Generated config: deploy/config/. Reference: deploy/README.md
EOF
}

cmd_setup() {
  local import_users="" test_mode=0 production=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --config) ANSWERS_FILE=${2:?--config needs a file}; shift 2 ;;
      --import-users) import_users=${2:?--import-users needs a file}; shift 2 ;;
      --test) test_mode=1; shift ;;
      --production) production=1; shift ;; # turn a test install into a real one (keeps secrets and users)
      -h | --help) usage; return 0 ;;
      *) die "setup: unknown option $1" ;;
    esac
  done
  if [ -n "$ANSWERS_FILE" ]; then
    [ -f "$ANSWERS_FILE" ] || die "answers file not found: $ANSWERS_FILE"
    ANSWERS_FILE="$(cd "$(dirname "$ANSWERS_FILE")" && pwd -P)/$(basename "$ANSWERS_FILE")"
  elif [ -t 0 ] && [ -r /dev/tty ]; then
    INTERACTIVE=1
  else
    die "setup needs a terminal, or --config answers.env for a non-interactive run"
  fi
  for t in openssl curl; do have "$t" || die "$t is required"; done
  local existing=0
  [ -s "$ENV_FILE" ] && existing=1
  [ "$(cur GOOSE_MODE)" = test ] && [ "$production" = 0 ] && test_mode=1

  say "${C_BOLD}Gooseplicant one-box setup${C_0}$([ "$test_mode" = 1 ] && printf ' (TEST MODE: local CA, no Cloudflare)')"
  [ "$existing" = 1 ] && say "  Existing install found: answers default to the current settings; secrets and users are kept."
  touch "$ENV_FILE"
  chmod 600 "$ENV_FILE"

  step "Hostnames"
  local base app auth intake def_base
  def_base=$(cur BASE_DOMAIN)
  [ "$test_mode" = 1 ] && def_base=${def_base:-goose.test}
  base=$(lower "$(ask BASE_DOMAIN "Your domain (managed in Cloudflare), e.g. example.com" "$def_base" is_host "a domain like example.com")")
  app=$(lower "$(ask APP_HOST "Hostname staff use for Gooseplicant" "$(cur APP_HOST | grep . || echo "hire.$base")" is_host "a hostname")")
  under "$app" "$base" || die "$app is not under $base: Authelia's sign-in cookie lives on $base (use ./goose add-domain later for other domains)"
  auth=$(lower "$(ask AUTH_HOST "Hostname of the sign-in portal (Authelia)" "$(cur AUTH_HOST | grep . || echo "auth.$base")" is_host "a hostname")")
  under "$auth" "$base" || die "$auth must be under $base (the cookie domain shared with $app)"
  [ "$auth" != "$app" ] || die "the sign-in portal needs its own hostname"
  local intake_def
  intake_def=$(cur INTAKE_HOST)
  if [ "$existing" = 1 ] && [ -z "$intake_def" ]; then intake_def=none; fi
  [ -n "$intake_def" ] || intake_def="apply.$base"
  [ "$test_mode" = 1 ] && [ -z "$(cur INTAKE_HOST)" ] && intake_def=none
  intake=$(lower "$(ask INTAKE_HOST "Public intake hostname for your website's applications (none = no public intake)" "$intake_def" is_any)")
  if [ "$intake" = none ] || [ -z "$intake" ]; then
    intake=""
  else
    is_host "$intake" || die "$intake is not a hostname"
    [ "$intake" != "$app" ] && [ "$intake" != "$auth" ] || die "the intake hostname must differ from the internal ones"
  fi
  env_set BASE_DOMAIN "$base"
  env_set APP_HOST "$app"
  env_set AUTH_HOST "$auth"
  env_set INTAKE_HOST "$intake"
  ok "app https://$app  ·  sign-in https://$auth  ·  intake ${intake:-(none)}"

  step "Network"
  local ips def_ip ip
  if [ "$test_mode" = 1 ]; then
    def_ip=$(cur BIND_IP | grep . || echo 127.0.0.1)
  else
    ips=$(detect_ips || true)
    def_ip=$(cur BIND_IP)
    if [ -n "$ips" ]; then
      say "  Addresses on this machine (Caddy listens on ONE of them, never all):"
      printf '%s\n' "$ips" | awk '{ printf "      %-16s %s\n", $1, $2 }'
      [ -n "$def_ip" ] || def_ip=$(printf '%s\n' "$ips" | awk '$2 == "Tailscale" { t = $1 } NR == 1 { f = $1 } END { print (t != "" ? t : f) }')
    fi
  fi
  ip=$(ask BIND_IP "Internal IP staff reach this server on (LAN or Tailscale)" "$def_ip" is_ipv4 "an IPv4 address")
  [ "$ip" != 0.0.0.0 ] || warn "0.0.0.0 publishes Caddy on every interface; prefer the LAN or Tailscale address"
  env_set BIND_IP "$ip"
  local hp p80
  hp=$(ask HTTPS_PORT "HTTPS port" "$(cur HTTPS_PORT | grep . || { [ "$test_mode" = 1 ] && echo 18443 || echo 443; })" is_port "a port number")
  p80=$(ask HTTP_PORT "HTTP port (redirects to HTTPS)" "$(cur HTTP_PORT | grep . || { [ "$test_mode" = 1 ] && echo 18080 || echo 80; })" is_port "a port number")
  env_set HTTPS_PORT "$hp"
  env_set HTTP_PORT "$p80"
  ok "Caddy will listen on $ip:$hp (and :$p80 for redirects)"

  step "Gooseplicant"
  local edition
  edition=$(ask EDITION "Edition: full (Gooseplicant) or lite (Gosling, the file browser)" "$(cur EDITION | grep . || echo full)" is_edition "full or lite")
  env_set EDITION "$edition"
  env_set GOOSEPLICANT_VERSION "$(kit_version)"
  env_set GOOSE_MODE "$([ "$test_mode" = 1 ] && echo test || echo production)"
  env_set COMPOSE_PROJECT_NAME "$(ask COMPOSE_PROJECT_NAME "Docker Compose project name" "$(cur COMPOSE_PROJECT_NAME | grep . || { [ "$test_mode" = 1 ] && echo goose-test || echo gooseplicant; })" is_project "lowercase letters, digits, - and _")"
  ensure_secrets
  ok "edition $edition, image gooseplicant:$(kit_version)"

  step "Sign-in (Authelia, local users)"
  mkdir -p "$CONFIG_DIR/authelia"
  if [ -n "$import_users" ]; then
    [ -f "$import_users" ] || die "--import-users: $import_users not found"
    grep -q '^users:' "$import_users" || die "$import_users has no top-level users: key (is it an Authelia users_database.yml?)"
    if [ -f "$USERS_FILE" ]; then
      cp "$USERS_FILE" "$USERS_FILE.bak-$(date -u +%Y%m%d%H%M%S)"
      note "previous users file kept as $(basename "$USERS_FILE").bak-*"
    fi
    users_write <"$import_users"
    ok "imported $(users_list | wc -l | tr -d ' ') user(s): $(users_list | join_by ', ')"
    note "invite each of them in Gooseplicant too (by email), or they'll see \"not invited\" after signing in"
  fi
  local user name email
  user=$(lower "$(ask ADMIN_USER "Admin username" "$(cur ADMIN_USER | grep . || echo admin)" is_user "lowercase letters, digits, . _ -")")
  if user_exists "$user"; then
    email=$(user_email "$user")
    ok "user $user exists${email:+ ($email)}; keeping it"
    if ans_has ADMIN_EMAIL; then email=$(ans_get ADMIN_EMAIL); fi
    email=${email:-$(cur ADMIN_EMAIL)}
    is_email "$email" || email=$(ask ADMIN_EMAIL "Admin email (the Gooseplicant admin)" "" is_email "an email address")
    if ans_has ADMIN_PASSWORD || { [ "$INTERACTIVE" = 1 ] && ask_yn ADMIN_RESET "Set a new password for $user?" n; }; then
      read_new_password ADMIN_PASSWORD "$user"
      user_set_password "$user" "$(printf '%s' "$NEW_PASSWORD" | hash_password)"
      ok "password of $user changed"
      [ "$NEW_PASSWORD_GENERATED" = 0 ] || secret_box "password for Authelia user $user" "$NEW_PASSWORD"
    fi
  else
    name=$(ask ADMIN_NAME "Admin display name" "Administrator" is_nonempty "a name")
    email=$(ask ADMIN_EMAIL "Admin email (becomes the first Gooseplicant admin)" "$(cur ADMIN_EMAIL)" is_email "an email address")
    read_new_password ADMIN_PASSWORD "$user"
    user_add "$user" "$name" "$(lower "$email")" admins "$(printf '%s' "$NEW_PASSWORD" | hash_password)"
    ok "created Authelia user $user <$email> (argon2id)"
    [ "$NEW_PASSWORD_GENERATED" = 0 ] || secret_box "password for Authelia user $user" "$NEW_PASSWORD"
  fi
  email=$(lower "$email")
  env_set ADMIN_USER "$user"
  env_set ADMIN_EMAIL "$email"
  env_set ACME_EMAIL "$(ask ACME_EMAIL "Email for Let's Encrypt expiry notices" "$(cur ACME_EMAIL | grep . || echo "$email")" is_email "an email address")"
  local def2fa=y
  [ "$(cur AUTH_POLICY)" = one_factor ] && def2fa=n
  if ask_yn REQUIRE_2FA "Require two-factor sign-in (authenticator app or passkey)?" "$def2fa"; then
    env_set AUTH_POLICY two_factor
  else
    env_set AUTH_POLICY one_factor
  fi
  ok "policy $(env_get AUTH_POLICY)"

  step "Email for Authelia (one-time codes, password resets)"
  local smtp_def=n
  [ -n "$(cur SMTP_HOST)" ] && smtp_def=y
  if ask_yn SMTP "Send them by SMTP? (no: write them to a file, read with ./goose code)" "$smtp_def"; then
    local sh sp su ss pw
    sh=$(ask SMTP_HOST "SMTP server" "$(cur SMTP_HOST)" is_host "a hostname")
    sp=$(ask SMTP_PORT "SMTP port (587 STARTTLS, 465 TLS)" "$(cur SMTP_PORT | grep . || echo 587)" is_port "a port number")
    su=$(ask SMTP_USER "SMTP username (empty: no login)" "$(cur SMTP_USER)" is_any)
    ss=$(ask SMTP_SENDER "Sender address (mails come from \"Gooseplicant <address>\")" "$(cur SMTP_SENDER)" is_email "an email address")
    env_set SMTP_HOST "$sh"
    env_set SMTP_PORT "$sp"
    env_set SMTP_USER "$(printf '%s' "$su" | tr -d '[:space:]')"
    env_set SMTP_SENDER "$ss"
    if [ -n "$su" ]; then
      pw=$(ask_secret SMTP_PASSWORD "SMTP password (empty: keep the stored one)")
      [ -z "$pw" ] || printf '%s' "$pw" >"$SECRETS_DIR/authelia_smtp_password"
      [ -s "$SECRETS_DIR/authelia_smtp_password" ] || warn "no SMTP password stored"
    fi
    if ask_yn APP_MAIL "Use the same SMTP server for Gooseplicant's own emails?" "$([ "$(cur APP_MAIL)" = no ] && echo n || echo y)"; then env_set APP_MAIL yes; else env_set APP_MAIL no; fi
    ok "SMTP $sh:$sp"
  else
    env_set SMTP_HOST ""
    env_set SMTP_PORT ""
    env_set SMTP_USER ""
    env_set SMTP_SENDER ""
    ok "filesystem notifier: ./goose code shows the latest one-time code or reset link"
  fi

  load_settings
  if [ "$test_mode" = 1 ]; then
    step "Cloudflare"
    note "test mode: skipped (Caddy uses its internal CA; no DNS records, no tunnel)"
  else
    cloudflare_setup
  fi

  step "Rendering configuration"
  render_all
  ok "config/caddy/Caddyfile, config/authelia/configuration.yml, config/gooseplicant.env, config/authelia.env"
  validate_configs

  connect_website
  step "Next"
  say "  ./goose up        build the images and start everything (first build: a few minutes)"
  say "  ./goose doctor    check DNS, certificates, sign-in, the app and the intake"
  say "  ./goose add-user  more staff accounts (then invite them in Gooseplicant → Settings → Users)"
  say "  Then open $(https_url "$APP_HOST")/ats/ and sign in as $(env_get ADMIN_USER)."
  [ "$(env_get AUTH_POLICY)" = two_factor ] && say "  First sign-in asks you to register an authenticator app; Authelia sends a confirmation code first$([ -z "$SMTP_HOST" ] && printf ': read it with ./goose code')."
  if ask_yn START "Start it now?" n; then cmd_up; fi
}

connect_website() {
  load_settings
  local base_url
  step "Connect your website"
  if [ -n "$INTAKE_HOST" ]; then
    base_url="https://$INTAKE_HOST/ats"
    say "  Your website's server posts each application, HMAC-signed, to $base_url/ingest"
    say "  (through Cloudflare and the tunnel; nothing else of Gooseplicant is reachable from the internet)."
    [ -n "$TUNNEL_TOKEN" ] || warn "the tunnel isn't configured yet, so $INTAKE_HOST doesn't answer until it is"
  else
    base_url="$(https_url "$APP_HOST")/ats"
    say "  No public intake hostname: only a website running inside your network can deliver applications,"
    say "  to $base_url/ingest (signed, no login)."
  fi
  say "  Set two values on the website (server-side only; never in the browser or in git):"
  say "      ATS_URL           = $base_url"
  secret_box "ATS_INGEST_SECRET (the website signs applications with it)" "$INGEST_SECRET" "Shown by ./goose connect whenever you need it again."
  say "  Example, a Cloudflare Pages site (run in the website's project):"
  say "      npx wrangler pages secret put ATS_INGEST_SECRET --project-name <your-pages-project>   # paste the secret"
  say "      # wrangler.toml"
  say "      [vars]"
  say "      ATS_URL = \"$base_url\""
  say "  Request format and a ~40-line reference client: https://gooseplicant.goosenotes.dev/docs/connect-a-form/"
}

cmd_connect() {
  require_setup
  connect_website
}

cmd_render() {
  require_setup
  render_all
  ok "configuration re-rendered from deploy/.env"
  validate_configs
  note "apply it with ./goose up"
}

cmd_up() {
  require_setup
  need_docker
  local build=""
  [ "${1:-}" = --build ] && build="--build"
  [ "$GOOSE_MODE" = test ] || [ -n "$CF_API_TOKEN" ] || warn "no Cloudflare API token: Caddy can't get certificates (re-run ./goose setup)"
  [ -z "$INTAKE_HOST" ] || [ -n "$TUNNEL_TOKEN" ] || warn "public intake $INTAKE_HOST has no tunnel token yet: cloudflared stays off"
  step "Starting (docker compose up -d $build)"
  # Compose recreates containers whose definition changed, but not for a changed mounted file: remember what
  # Caddy and Authelia were last started with and restart them when setup / add-domain / render changed it.
  local was_running=0 applied now
  running caddy && was_running=1
  applied=$(cat "$CONFIG_DIR/.applied" 2>/dev/null || true)
  now=$(cat "$CONFIG_DIR/caddy/Caddyfile" "$CONFIG_DIR/authelia/configuration.yml" "$CONFIG_DIR/authelia.env" | openssl dgst -sha256 -r | cut -c1-64)
  dc up -d $build --remove-orphans
  if [ "$was_running" = 1 ] && [ "$applied" != "$now" ]; then
    dc restart caddy authelia >/dev/null
    ok "configuration changed: restarted caddy and authelia"
  fi
  printf '%s\n' "$now" >"$CONFIG_DIR/.applied"
  wait_healthy gooseplicant 180 && ok "gooseplicant healthy"
  wait_healthy authelia 90 && ok "authelia healthy"
  running caddy && ok "caddy running on $BIND_IP:$HTTPS_PORT"
  [ -z "$TUNNEL_TOKEN" ] || { running cloudflared && ok "cloudflared running"; }
  say "  Open $(https_url "$APP_HOST")/ats/   ·   check with ./goose doctor"
}

cmd_down() {
  require_setup
  need_docker
  if [ "${1:-}" = --volumes ]; then
    [ "${2:-}" = --yes ] || ask_yn_tty "Delete ALL data (applications, files, Authelia 2FA registrations, certificates)?" || die "cancelled"
    dc down --volumes --remove-orphans
  else
    dc down --remove-orphans
  fi
}
ask_yn_tty() {
  [ -t 0 ] || return 1
  local v
  printf '%s?%s %s (y/N): ' "$C_BOLD" "$C_0" "$1" >&2
  IFS= read -r v </dev/tty || v=""
  truthy "$v"
}
cmd_restart() { require_setup; need_docker; dc restart "$@"; }
cmd_logs() { require_setup; need_docker; dc logs --tail 200 "$@"; }
cmd_status() {
  require_setup
  need_docker
  dc ps
  say ""
  say "  app      $(https_url "$APP_HOST")/ats/   (on $BIND_IP:$HTTPS_PORT)"
  say "  sign-in  $(https_url "$AUTH_HOST")/"
  say "  intake   ${INTAKE_HOST:+https://$INTAKE_HOST/ats/ingest}${INTAKE_HOST:-(none)}"
  say "  mode     $GOOSE_MODE, edition $EDITION, gooseplicant:$GOOSEPLICANT_VERSION"
}

# ---------- users ----------
parse_user_flags() {
  U_NAME="" U_EMAIL="" U_GROUPS="" U_STDIN=0 U_YES=0 U_POS=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --name) U_NAME=${2:?}; shift 2 ;;
      --email) U_EMAIL=${2:?}; shift 2 ;;
      --groups) U_GROUPS=${2:?}; shift 2 ;;
      --password-stdin) U_STDIN=1; shift ;;
      --yes | -y) U_YES=1; shift ;;
      -*) die "unknown option $1" ;;
      *) U_POS=$1; shift ;;
    esac
  done
}
password_from_stdin() {
  IFS= read -r NEW_PASSWORD || true
  NEW_PASSWORD_GENERATED=0
  [ "${#NEW_PASSWORD}" -ge 12 ] || die "the password on stdin must have at least 12 characters"
}
users_url() {
  if [ "$EDITION" = lite ]; then printf '%s/ats/lite/#/admin/users' "$(https_url "$APP_HOST")"; else printf '%s/ats/#/settings/users' "$(https_url "$APP_HOST")"; fi
}
cmd_add_user() {
  require_setup
  parse_user_flags "$@"
  [ -t 0 ] && [ "$U_STDIN" = 0 ] && INTERACTIVE=1
  local user name email since
  user=$(lower "${U_POS:-$(ask _ "Username" "" is_user "lowercase letters, digits, . _ -")}")
  is_user "$user" || die "invalid username $user"
  user_exists "$user" && die "user $user already exists (change its password with ./goose passwd $user)"
  name=${U_NAME:-$(ask _ "Display name" "" is_nonempty "a name")}
  email=$(lower "${U_EMAIL:-$(ask _ "Email (must match the address you invite in Gooseplicant)" "" is_email "an email address")}")
  is_email "$email" || die "invalid email $email"
  [ -n "$U_GROUPS" ] || [ "$INTERACTIVE" != 1 ] || U_GROUPS=$(ask _ "Groups (comma-separated, optional)" "staff" is_any)
  if [ "$U_STDIN" = 1 ]; then password_from_stdin; else read_new_password _ "$user"; fi
  since=$(now_utc)
  user_add "$user" "$name" "$email" "$U_GROUPS" "$(printf '%s' "$NEW_PASSWORD" | hash_password)"
  ok "added Authelia user $user <$email>"
  [ "$NEW_PASSWORD_GENERATED" = 0 ] || secret_box "password for $user (give it to them; they can change it at the sign-in portal)" "$NEW_PASSWORD"
  authelia_reload_users "$since"
  wt_begin "Give $email access inside Gooseplicant"
  wt "Signing in now works, but Gooseplicant answers \"not invited\" until an admin adds the person:" \
    "$(users_url)  → Invite → $email → role (viewer, recruiter, admin) and sites."
  wt_end
}
cmd_passwd() {
  require_setup
  parse_user_flags "$@"
  [ -t 0 ] && [ "$U_STDIN" = 0 ] && INTERACTIVE=1
  local user=$U_POS since
  [ -n "$user" ] || die "usage: ./goose passwd <username> [--password-stdin]"
  user_exists "$user" || die "no such user: $user"
  if [ "$U_STDIN" = 1 ]; then password_from_stdin; else read_new_password _ "$user"; fi
  since=$(now_utc)
  user_set_password "$user" "$(printf '%s' "$NEW_PASSWORD" | hash_password)"
  ok "password of $user changed"
  [ "$NEW_PASSWORD_GENERATED" = 0 ] || secret_box "new password for $user" "$NEW_PASSWORD"
  authelia_reload_users "$since"
}
cmd_remove_user() {
  require_setup
  parse_user_flags "$@"
  local user=$U_POS since email
  [ -n "$user" ] || die "usage: ./goose remove-user <username> [--yes]"
  user_exists "$user" || die "no such user: $user"
  [ "$user" != "$ADMIN_USER" ] || [ "$U_YES" = 1 ] || warn "$user is the admin set up by goose setup"
  [ "$U_YES" = 1 ] || ask_yn_tty "Remove Authelia user $user?" || die "cancelled"
  email=$(user_email "$user")
  since=$(now_utc)
  user_remove "$user"
  user_exists "$user" && die "internal: $user is still in the users file"
  ok "removed Authelia user $user"
  authelia_reload_users "$since"
  note "they can't sign in any more; also remove ${email:-them} in Gooseplicant: $(users_url)"
}

cmd_reset_2fa() { # a lost phone: delete the user's TOTP and WebAuthn registrations; they register again
  require_setup
  need_docker
  local user=${1:-} out
  [ -n "$user" ] || die "usage: ./goose reset-2fa <username>"
  user_exists "$user" || warn "$user is not in the users file (resetting anyway)"
  running authelia || die "Authelia isn't running: ./goose up"
  out=$(dc exec -T authelia authelia storage user totp delete "$user" --config /config/configuration.yml 2>&1) &&
    ok "deleted the authenticator app registration of $user" || note "no authenticator app registered for $user (${out##*$'\n'})"
  out=$(dc exec -T authelia authelia storage user webauthn delete "$user" --all --config /config/configuration.yml 2>&1) &&
    ok "deleted the passkeys/security keys of $user" || note "no passkeys for $user (${out##*$'\n'})"
  say "  At the next sign-in $user registers a new second factor (one-time code first$([ -n "$SMTP_HOST" ] || printf ': ./goose code'))."
}

# ---------- add-domain ----------
cmd_add_domain() {
  require_setup
  local host="" authh="" cookie="" a
  while [ $# -gt 0 ]; do
    case "$1" in
      --auth-host) authh=$(lower "${2:?}"); shift 2 ;;
      --cookie-domain) cookie=$(lower "${2:?}"); shift 2 ;;
      --config) ANSWERS_FILE=${2:?}; shift 2 ;;
      --yes | -y) # apply the DNS records via the API without asking
        ANSWERS_FILE="$(tmpdir)/add-domain.env"
        printf 'CF_CREATE_DNS=yes\n' >"$ANSWERS_FILE"
        shift ;;
      -*) die "unknown option $1" ;;
      *) host=$(lower "$1"); shift ;;
    esac
  done
  [ -t 0 ] && [ -z "$ANSWERS_FILE" ] && INTERACTIVE=1
  [ -n "$host" ] || die "usage: ./goose add-domain <hostname> [--auth-host auth.other.com] [--cookie-domain other.com]"
  is_host "$host" || die "$host is not a hostname"
  app_hosts | grep -qx "$host" && { ok "$host is already an app hostname"; return 0; }
  auth_hosts | grep -qx "$host" && die "$host is a sign-in portal hostname"
  [ "$host" != "$INTAKE_HOST" ] || die "$host is the public intake hostname"
  # Same cookie domain as an existing entry → reuse its sign-in portal; otherwise a new cookie domain + portal.
  local c ah fh
  if [ -z "$cookie" ]; then
    while IFS='|' read -r c ah _; do
      if [ -n "$c" ] && under "$host" "$c"; then cookie=$c authh=${authh:-$ah}; break; fi
    done <<EOF
$(cookie_domains)
EOF
  fi
  if [ -z "$cookie" ]; then
    cookie=$(lower "$(ask COOKIE_DOMAIN "Cookie domain for $host (its parent domain)" "$(parent_of "$host")" is_host "a domain")")
  fi
  under "$host" "$cookie" || die "$host is not under $cookie"
  if [ -z "$authh" ]; then
    while IFS='|' read -r c ah _; do [ "$c" = "$cookie" ] && authh=$ah; done <<EOF
$(cookie_domains)
EOF
  fi
  [ -n "$authh" ] || authh=$(lower "$(ask AUTH_HOST_NEW "Sign-in portal hostname on $cookie" "auth.$cookie" is_host "a hostname")")
  under "$authh" "$cookie" || die "the sign-in portal $authh must be under $cookie"
  local new_cookie=1
  cookie_domains | cut -d'|' -f1 | grep -qx "$cookie" && new_cookie=0
  a="$host/$authh/$cookie"
  env_set EXTRA_DOMAINS "${EXTRA_DOMAINS:+$EXTRA_DOMAINS,}$a"
  load_settings
  step "Adding $host"
  ok "app hostnames: $(app_hosts | join_by ', ')"
  if [ "$new_cookie" = 1 ]; then ok "new sign-in cookie domain $cookie with its portal https://$authh"; else ok "uses the existing sign-in cookie on $cookie (portal $authh)"; fi
  render_all
  validate_configs
  local dns_hosts="$host"
  [ "$new_cookie" = 0 ] || dns_hosts="$host $authh"
  if [ "$GOOSE_MODE" = test ]; then
    note "test mode: no DNS (use curl --resolve $host:$HTTPS_PORT:$BIND_IP)"
  elif [ -n "$CF_API_TOKEN" ]; then
    CF_ACCOUNT_ID=$(env_get CF_ACCOUNT_ID)
    cf_dns_plan_and_apply "$dns_hosts"
    cf_zone_for "$host" || {
      wt_begin "Let Caddy get a certificate for $host"
      wt "The zone holding $host must be in your Cloudflare account, and the API token must include it:" \
        "https://dash.cloudflare.com/profile/api-tokens → your token → Edit → Zone Resources → add the zone."
      wt "Then: ./goose restart caddy && ./goose doctor"
      wt_end
    }
  else
    wt_dns_records "$cookie" "$(for h in $dns_hosts; do printf 'A|%s|%s|DNS only\n' "$h" "$BIND_IP"; done)"
  fi
  if docker_ok && running caddy; then
    dc exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>"$(tmpdir)/reload.err" ||
      { cat "$(tmpdir)/reload.err" >&2; die "Caddy refused the new configuration (it keeps running the old one)"; }
    ok "Caddy reloaded (certificates for the new names are requested now)"
    if running authelia; then
      dc restart authelia >/dev/null
      wait_healthy authelia 60 && ok "Authelia restarted with the new cookie domain and access rule (signed-in sessions end)"
    fi
  else
    note "not running: ./goose up applies it"
  fi
  note "Gooseplicant's PUBLIC_URL stays $(https_url "$APP_HOST"): links in emails always use the primary hostname."
}

# ---------- code ----------
cmd_code() {
  require_setup
  need_docker
  if [ -n "$SMTP_HOST" ]; then note "Authelia sends codes by email (SMTP $SMTP_HOST); this shows the file notifier only."; fi
  local txt
  txt=$(dc exec -T authelia cat /var/lib/authelia/notification.txt 2>/dev/null || true)
  if [ -z "$txt" ]; then
    say "No notification yet. Start the step in the browser (register 2FA, reset password), then run ./goose code again."
    return 0
  fi
  step "Latest Authelia notification"
  # The file holds the most recent message (older ones may precede it): print from its last "Date:" line.
  txt=$(printf '%s\n' "$txt" | awk '/^Date: / { buf = "" } { buf = buf $0 "\n" } END { printf "%s", buf }')
  printf '%s\n' "$txt" | grep -v '^Powered by Authelia'
  local code link
  code=$(printf '%s\n' "$txt" | grep -E '^[[:space:]]*[A-Z0-9]{8}[[:space:]]*$' | tail -n 1 | tr -d '[:space:]' || true)
  link=$(printf '%s\n' "$txt" | grep -Eo 'https://[^ ]*/reset-password/step2[^ ]*' | tail -n 1 || true)
  [ -z "$code" ] || say "${C_BOLD}One-time code: $code${C_0}"
  [ -z "$link" ] || say "${C_BOLD}Password reset link: $link${C_0}"
}

# ---------- doctor ----------
D_FAIL=0 D_WARN=0
d_pass() { printf '  %sPASS%s  %s\n' "$C_G" "$C_0" "$1"; }
d_warn() { D_WARN=$((D_WARN + 1)); printf '  %sWARN%s  %s\n' "$C_Y" "$C_0" "$1"; [ -z "${2:-}" ] || printf '        %sfix:%s %s\n' "$C_DIM" "$C_0" "$2"; }
d_fail() { D_FAIL=$((D_FAIL + 1)); printf '  %sFAIL%s  %s\n' "$C_R" "$C_0" "$1"; [ -z "${2:-}" ] || printf '        %sfix:%s %s\n' "$C_DIM" "$C_0" "$2"; }
d_skip() { printf '  %sSKIP%s  %s\n' "$C_DIM" "$C_0" "$1"; }
resolve4() { # host → IPv4 addresses (system resolver), one per line
  local h=$1
  if have getent; then getent ahostsv4 "$h" 2>/dev/null | awk '{ print $1 }' | awk '!s[$0]++'
  elif have dscacheutil; then dscacheutil -q host -a name "$h" 2>/dev/null | awk '/^ip_address:/ { print $2 }'
  elif have dig; then dig +short A "$h" | grep -E "$RE_IPV4"
  elif have host; then host -t A "$h" 2>/dev/null | awk '/has address/ { print $4 }'
  fi
}
curl_in() { # curl to Caddy for one of our hostnames, without relying on DNS
  local host=$1
  shift
  local k=""
  [ "$GOOSE_MODE" != test ] || k=-k
  curl -sS --max-time 15 --resolve "$host:$HTTPS_PORT:$BIND_IP" ${k:+"$k"} "$@"
}
cmd_doctor() {
  require_setup
  D_FAIL=0 D_WARN=0
  step "goose doctor ($GOOSE_MODE mode)"
  # config
  local m
  m=$(ls -l "$ENV_FILE" | cut -c1-10)
  if [ "$m" = "-rw-------" ]; then d_pass "deploy/.env is private (600)"; else d_fail "deploy/.env is $m" "chmod 600 $ENV_FILE"; fi
  local f missing=""
  for f in $SECRET_FILES; do [ -s "$SECRETS_DIR/$f" ] || missing="$missing $f"; done
  if [ -z "$missing" ]; then d_pass "Authelia secrets present"; else d_fail "missing secrets:$missing" "./goose setup (keeps everything else)"; fi
  if [ "$GOOSE_MODE" = test ]; then d_skip "Cloudflare API token (test mode)"
  elif [ -n "$CF_API_TOKEN" ]; then d_pass "Cloudflare API token configured"
  else d_fail "no Cloudflare API token: no certificates" "./goose setup (it shows how to create one)"; fi

  need_docker
  # containers
  local s want="gooseplicant authelia caddy"
  [ -z "$TUNNEL_TOKEN" ] || want="$want cloudflared"
  for s in $want; do
    if running "$s"; then d_pass "$s is running"; else d_fail "$s is not running" "./goose up, then ./goose logs $s"; fi
  done
  # health inside the network
  if running gooseplicant; then
    local h
    for p in 3000 3001; do
      h=$(dc exec -T gooseplicant node -e "fetch('http://127.0.0.1:$p/ats/api/health').then(async r=>{console.log(r.status+' '+await r.text())},e=>console.log('ERR '+e.message))" 2>/dev/null || true)
      case "$h" in 200*) d_pass "Gooseplicant :$p health $h" ;; *) d_fail "Gooseplicant :$p health: ${h:-no answer}" "./goose logs gooseplicant" ;; esac
    done
  fi
  if running authelia; then
    h=$(dc exec -T authelia wget -qO- http://127.0.0.1:9091/api/health 2>/dev/null || true)
    case "$h" in *OK*) d_pass "Authelia health $h" ;; *) d_fail "Authelia health: ${h:-no answer}" "./goose logs authelia" ;; esac
  fi
  # published ports
  local cid binds
  for s in gooseplicant authelia cloudflared; do
    cid=$(dc ps -q "$s" 2>/dev/null || true)
    [ -n "$cid" ] || continue
    binds=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}' "$cid" 2>/dev/null || true)
    if [ -z "$binds" ]; then d_pass "$s publishes no ports on the host"; else d_fail "$s publishes $binds" "remove ports: from $s in deploy/compose.yml"; fi
  done
  cid=$(dc ps -q caddy 2>/dev/null || true)
  if [ -n "$cid" ]; then
    binds=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}}:{{.HostPort}} {{end}}{{end}}' "$cid" 2>/dev/null | tr ' ' '\n' | awk 'NF && !s[$0]++' | tr '\n' ' ' || true)
    case " $binds" in *" 0.0.0.0:"* | *" :"* | *" [::]:"*) d_warn "caddy is published on all interfaces: $binds" "set BIND_IP in deploy/.env, then ./goose up" ;; *) d_pass "caddy only on $binds" ;; esac
  fi
  # DNS
  local hst ips
  for hst in $(internal_hosts); do
    if [ "$GOOSE_MODE" = test ]; then d_skip "DNS $hst (test mode: curl --resolve)"; continue; fi
    ips=$(resolve4 "$hst" | tr '\n' ' ')
    if printf ' %s ' "$ips" | grep -q " $BIND_IP "; then d_pass "DNS $hst → $BIND_IP"
    elif [ -z "$ips" ]; then d_fail "DNS $hst doesn't resolve here" "add A $hst → $BIND_IP (DNS only) in Cloudflare or your local DNS; ./goose setup can do it"
    else d_fail "DNS $hst → $ips (expected $BIND_IP)" "point the A record at $BIND_IP; if your router drops private answers (DNS rebinding protection), allow $BASE_DOMAIN or use local DNS"; fi
  done
  # certificates
  local cert issuer
  for hst in $(internal_hosts); do
    cert=$(printf '' | openssl s_client -connect "$BIND_IP:$HTTPS_PORT" -servername "$hst" 2>/dev/null | openssl x509 2>/dev/null || true)
    if [ -z "$cert" ]; then d_fail "no certificate for $hst on $BIND_IP:$HTTPS_PORT" "./goose logs caddy (look for 'obtain' / 'challenge'; check the token's zone permissions)"; continue; fi
    issuer=$(printf '%s\n' "$cert" | openssl x509 -noout -issuer 2>/dev/null | sed 's/^issuer= *//')
    if ! printf '%s\n' "$cert" | openssl x509 -noout -checkend 0 >/dev/null 2>&1; then d_fail "certificate for $hst has expired ($issuer)" "./goose logs caddy"
    elif [ "$GOOSE_MODE" = test ]; then d_pass "certificate for $hst from $issuer (test mode)"
    elif printf '%s' "$issuer" | grep -qi 'caddy local'; then d_fail "certificate for $hst is self-signed ($issuer)" "Caddy couldn't use Let's Encrypt; ./goose logs caddy"
    elif ! printf '%s\n' "$cert" | openssl x509 -noout -checkend 1209600 >/dev/null 2>&1; then d_warn "certificate for $hst expires within 14 days" "Caddy renews automatically; if not: ./goose logs caddy"
    else d_pass "certificate for $hst ($issuer)"; fi
  done
  # sign-in wiring through Caddy
  local code loc
  if running caddy; then
    loc=$(curl_in "$APP_HOST" -o /dev/null -w '%{http_code} %{redirect_url}' "$(https_url "$APP_HOST")/ats/" 2>/dev/null || true)
    case "$loc" in
      30[0-9]*"$(https_url "$AUTH_HOST")"*) d_pass "unauthenticated /ats/ redirects to the sign-in portal" ;;
      *) d_fail "unauthenticated /ats/ answered '$loc' (expected a redirect to $(https_url "$AUTH_HOST"))" "./goose logs caddy authelia" ;;
    esac
    code=$(curl_in "$APP_HOST" -o /dev/null -w '%{http_code}' -H 'Remote-Email: someone@example.com' -H 'Remote-User: someone' -H 'X-Forwarded-Email: someone@example.com' "$(https_url "$APP_HOST")/ats/api/me" 2>/dev/null || true)
    case "$code" in 200) d_fail "a forged Remote-Email header got through to Gooseplicant" "check config/caddy/Caddyfile: forward_auth must run before reverse_proxy" ;; *) d_pass "forged identity headers are refused ($code)" ;; esac
    code=$(curl_in "$AUTH_HOST" -o /dev/null -w '%{http_code}' "$(https_url "$AUTH_HOST")/api/health" 2>/dev/null || true)
    if [ "$code" = 200 ]; then d_pass "sign-in portal answers through Caddy"; else d_fail "sign-in portal through Caddy: $code" "./goose logs caddy authelia"; fi
  fi
  # tunnel + public intake
  if [ -z "$INTAKE_HOST" ]; then
    d_skip "public intake (none configured)"
  elif [ -z "$TUNNEL_TOKEN" ]; then
    d_warn "public intake $INTAKE_HOST has no tunnel token" "./goose setup (Cloudflare step)"
  else
    h=$(dc exec -T caddy wget -qO- -S http://cloudflared:2000/ready 2>&1 | head -n 1 || true)
    case "$h" in *200*) d_pass "cloudflared tunnel connected" ;; *) d_fail "cloudflared not connected (${h:-no answer})" "./goose logs cloudflared (token wrong? outbound 7844 blocked?)" ;; esac
    ips=$(resolve4 "$INTAKE_HOST" | tr '\n' ' ')
    if [ -z "$ips" ]; then d_fail "DNS $INTAKE_HOST doesn't resolve" "proxied CNAME $INTAKE_HOST → $TUNNEL_ID.cfargotunnel.com"
    elif printf ' %s ' "$ips" | grep -q " $BIND_IP "; then d_fail "$INTAKE_HOST points at this server ($ips)" "it must be the proxied CNAME to the tunnel, not an A record"
    else d_pass "DNS $INTAKE_HOST → $ips (Cloudflare)"; fi
    local pub="https://$INTAKE_HOST/ats"
    code=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' "$pub/api/health" 2>/dev/null || true)
    if [ "$code" = 200 ]; then d_pass "public $pub/api/health 200"; else d_fail "public $pub/api/health: $code" "tunnel ingress must send $INTAKE_HOST to http://gooseplicant:3001"; fi
    local bad=""
    for path in / /lite/ /api/me /api/applications; do
      code=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' "$pub$path" 2>/dev/null || true)
      [ "$code" = 404 ] || bad="$bad $path=$code"
    done
    code=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' "$pub/ingest" 2>/dev/null || true)
    [ "$code" = 404 ] || [ "$code" = 405 ] || bad="$bad GET/ingest=$code"
    if [ -z "$bad" ]; then d_pass "public intake host serves nothing but intake (404 for /ats/, /ats/lite/, the API)"; else d_fail "public intake host answers:$bad" "the tunnel must point at :3001 (intake-only), never :3000"; fi
    code=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' -X POST -F payload='{}' "$pub/ingest" 2>/dev/null || true)
    if [ "$code" = 401 ] || [ "$code" = 400 ]; then d_pass "unsigned POST /ats/ingest refused ($code)"; else d_fail "unsigned POST /ats/ingest: $code (expected 401)" "./goose logs gooseplicant"; fi
  fi
  # clock
  local remote now skew
  remote=$(curl -sSI --max-time 10 https://www.cloudflare.com/ 2>/dev/null | tr -d '\r' | sed -n 's/^[Dd]ate: //p' | head -n 1)
  now=$(date -u +%s)
  if [ -n "$remote" ]; then
    local rs
    rs=$(date -u -d "$remote" +%s 2>/dev/null || date -u -j -f '%a, %d %b %Y %H:%M:%S GMT' "$remote" +%s 2>/dev/null || true)
    if [ -n "$rs" ]; then
      skew=$((now - rs))
      [ "$skew" -lt 0 ] && skew=$((-skew))
      if [ "$skew" -le 30 ]; then d_pass "clock within ${skew}s of Cloudflare"
      elif [ "$skew" -le 120 ]; then d_warn "clock is ${skew}s off" "enable NTP (timedatectl set-ntp true): 2FA codes and signed intake allow little drift"
      else d_fail "clock is ${skew}s off" "enable NTP: signed applications older than 5 minutes are refused, TOTP codes fail"; fi
    fi
  else
    d_warn "couldn't read the time from https://www.cloudflare.com (offline?)" "check outbound HTTPS"
  fi
  if running gooseplicant; then
    rs=$(dc exec -T gooseplicant date -u +%s 2>/dev/null || true)
    if [ -n "$rs" ]; then
      skew=$((now - rs)); [ "$skew" -lt 0 ] && skew=$((-skew))
      if [ "$skew" -le 5 ]; then d_pass "container clock matches the host"; else d_warn "container clock differs from the host by ${skew}s" "restart Docker (Docker Desktop's VM clock drifts after sleep)"; fi
    fi
  fi
  say ""
  if [ "$D_FAIL" -gt 0 ]; then say "${C_R}${C_BOLD}$D_FAIL failed${C_0}, $D_WARN warning(s)"; return 1; fi
  say "${C_G}${C_BOLD}all checks passed${C_0}$([ "$D_WARN" -gt 0 ] && printf ', %s warning(s)' "$D_WARN")"
}

# ---------- backup / restore ----------
vol() { printf '%s_%s' "$COMPOSE_PROJECT_NAME" "$1"; }
app_image() { printf 'gooseplicant:%s' "$GOOSEPLICANT_VERSION"; }
SNAPSHOT_JS='const {DatabaseSync}=require("node:sqlite");const [src,dst]=process.argv.slice(1);const db=new DatabaseSync(src);db.exec("VACUUM INTO "+JSON.stringify(dst).replace(/^"|"$/g,"\u0027"));db.close()'
cmd_backup() {
  require_setup
  need_docker
  local dest=${1:-$GOOSE_DIR/backups} stamp work name img
  mkdir -p "$dest"
  dest=$(cd "$dest" && pwd -P)
  stamp=$(date -u +%Y%m%d-%H%M%S)
  name="gooseplicant-backup-$stamp.tar.gz"
  img=$(app_image)
  docker image inspect "$img" >/dev/null 2>&1 || die "image $img not found: ./goose up --build first"
  work="$(tmpdir)/backup-$stamp"
  mkdir -p "$work/gooseplicant" "$work/authelia" "$work/deploy"
  step "Backup → $dest/$name"
  # SQLite: VACUUM INTO gives a consistent copy while the app runs (WAL mode; never copy ats.db alone).
  docker run --rm --user 0 --entrypoint sh -v "$(vol gooseplicant-data):/data" "$img" -c \
    'set -e; mkdir -p /tmp/b; if [ -f /data/ats.db ]; then node -e "$0" /data/ats.db /tmp/b/ats.db; fi; cd /tmp/b; [ -d /data/files ] && cp -a /data/files files; tar -cf - .' "$SNAPSHOT_JS" |
    tar -xf - -C "$work/gooseplicant"
  [ -f "$work/gooseplicant/ats.db" ] && ok "Gooseplicant database (consistent snapshot) + $(find "$work/gooseplicant/files" -type f 2>/dev/null | wc -l | tr -d ' ') file(s)" || note "no Gooseplicant database yet"
  docker run --rm --user 0 --entrypoint sh -v "$(vol authelia-data):/a" "$img" -c \
    'set -e; mkdir -p /tmp/b; if [ -f /a/db.sqlite3 ]; then node -e "$0" /a/db.sqlite3 /tmp/b/db.sqlite3; fi; cd /tmp/b; tar -cf - .' "$SNAPSHOT_JS" |
    tar -xf - -C "$work/authelia"
  [ -f "$work/authelia/db.sqlite3" ] && ok "Authelia database (2FA registrations)" || note "no Authelia database yet"
  cp "$ENV_FILE" "$work/deploy/.env"
  cp -R "$CONFIG_DIR" "$work/deploy/config"
  cp -R "$SECRETS_DIR" "$work/deploy/secrets"
  printf 'gooseplicant-backup 1\nversion %s\ncreated %s\nproject %s\n' "$GOOSEPLICANT_VERSION" "$(now_utc)" "$COMPOSE_PROJECT_NAME" >"$work/MANIFEST"
  (cd "$work" && tar -czf "$dest/$name" MANIFEST gooseplicant authelia deploy)
  chmod 600 "$dest/$name"
  ok "deploy/.env, config/, secrets/ (the archive holds secrets: mode 600, keep it safe)"
  say "  $dest/$name  ($(du -h "$dest/$name" | cut -f1 | tr -d ' '))"
  BACKUP_FILE="$dest/$name"
}
cmd_restore() {
  local file="" yes=0
  while [ $# -gt 0 ]; do case "$1" in --yes | -y) yes=1; shift ;; *) file=$1; shift ;; esac; done
  [ -f "$file" ] || die "usage: ./goose restore <gooseplicant-backup-*.tar.gz> [--yes]"
  need_docker
  local work
  work="$(tmpdir)/restore"
  mkdir -p "$work"
  tar -xzf "$file" -C "$work"
  grep -q '^gooseplicant-backup 1' "$work/MANIFEST" 2>/dev/null || die "$file is not a goose backup"
  [ "$yes" = 1 ] || ask_yn_tty "Replace the current data and settings with $(basename "$file")? The stack restarts." || die "cancelled"
  step "Restore from $(basename "$file")"
  [ ! -f "$ENV_FILE" ] || cp "$ENV_FILE" "$ENV_FILE.before-restore"
  cp "$work/deploy/.env" "$ENV_FILE"
  chmod 600 "$ENV_FILE"
  rm -rf "$CONFIG_DIR" "$SECRETS_DIR"
  cp -R "$work/deploy/config" "$CONFIG_DIR"
  cp -R "$work/deploy/secrets" "$SECRETS_DIR"
  ok "deploy/.env, config/, secrets/ restored"
  load_settings
  env_set GOOSEPLICANT_VERSION "$(kit_version)"
  load_settings
  dc down --remove-orphans >/dev/null 2>&1 || true
  local img
  img=$(app_image)
  docker image inspect "$img" >/dev/null 2>&1 || dc build gooseplicant
  docker volume create "$(vol gooseplicant-data)" >/dev/null
  docker volume create "$(vol authelia-data)" >/dev/null
  (cd "$work/gooseplicant" && tar -cf - .) | docker run --rm -i --user 0 --entrypoint sh -v "$(vol gooseplicant-data):/data" "$img" -c \
    'set -e; rm -rf /data/ats.db /data/ats.db-wal /data/ats.db-shm /data/files; tar -xf - -C /data; chown -R node:node /data'
  ok "Gooseplicant data restored"
  (cd "$work/authelia" && tar -cf - .) | docker run --rm -i --user 0 --entrypoint sh -v "$(vol authelia-data):/a" "$img" -c \
    'set -e; rm -f /a/db.sqlite3 /a/db.sqlite3-wal /a/db.sqlite3-shm; tar -xf - -C /a'
  ok "Authelia data restored"
  cmd_up
}

# ---------- upgrade ----------
fetch() { curl -fsSL --max-time 300 -o "$2" "$1" || die "download failed: $1"; }
cmd_upgrade() {
  require_setup
  local want="latest" files_only=0 yes=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --files-only) files_only=1; shift ;;
      --yes | -y) yes=1; shift ;;
      -*) die "unknown option $1" ;;
      *) want=$1; shift ;;
    esac
  done
  local t base ver tarname sum1 sum2 got cur target
  t=$(tmpdir)
  base=${DOWNLOAD_BASE%/}
  step "Upgrade ($base)"
  if [ "$want" = latest ]; then
    fetch "$base/latest/manifest.json" "$t/latest.json"
    ver=$(json_get version <"$t/latest.json")
  else
    ver=${want#v}
  fi
  [[ $ver =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || die "not a version: $ver"
  cur=$(kit_version)
  [ "$ver" != "$cur" ] || { ok "already on $cur"; return 0; }
  fetch "$base/$ver/manifest.json" "$t/manifest.json"
  fetch "$base/$ver/SHA256SUMS" "$t/SHA256SUMS"
  [ "$(json_get version <"$t/manifest.json")" = "$ver" ] || die "manifest.json is not for $ver"
  local i=0 kind
  tarname="" sum1=""
  while [ "$i" -lt "$(json_get files.# <"$t/manifest.json")" ]; do
    kind=$(json_get "files.$i.kind" <"$t/manifest.json")
    if [ "$kind" = tar.gz ]; then
      tarname=$(json_get "files.$i.name" <"$t/manifest.json")
      sum1=$(json_get "files.$i.sha256" <"$t/manifest.json")
    fi
    i=$((i + 1))
  done
  case "$tarname" in gooseplicant-"$ver".tar.gz) ;; *) die "manifest.json lists no gooseplicant-$ver.tar.gz" ;; esac
  sum2=$(awk -v n="$tarname" '$2 == n || $2 == "*" n { print $1 }' "$t/SHA256SUMS")
  [ -n "$sum2" ] && [ "$sum1" = "$sum2" ] || die "manifest.json and SHA256SUMS disagree about $tarname"
  fetch "$base/$ver/$tarname" "$t/$tarname"
  got=$(sha256_of "$t/$tarname")
  [ "$got" = "$sum1" ] || die "checksum mismatch for $tarname (expected $sum1, got $got): not installing it"
  ok "downloaded $tarname, sha256 verified"
  target="$(dirname "$KIT_DIR")/gooseplicant-$ver"
  [ ! -e "$target" ] || die "$target already exists; move it away and re-run"
  mkdir -p "$t/unpack"
  tar -xzf "$t/$tarname" -C "$t/unpack"
  [ -x "$t/unpack/gooseplicant-$ver/deploy/goose" ] || die "$tarname has no deploy/goose"
  [ "$yes" = 1 ] || [ ! -t 0 ] || ask_yn_tty "Upgrade $cur → $ver (installs into $target; this kit stays for rollback)?" || die "cancelled"
  if [ "$files_only" = 0 ]; then
    need_docker
    cmd_backup
  fi
  mv "$t/unpack/gooseplicant-$ver" "$target"
  cp "$ENV_FILE" "$target/deploy/.env"
  cp -R "$CONFIG_DIR" "$target/deploy/config"
  cp -R "$SECRETS_DIR" "$target/deploy/secrets"
  chmod 600 "$target/deploy/.env"
  ok "unpacked to $target; carried over deploy/.env, config/, secrets/"
  if [ "$files_only" = 1 ]; then
    note "files only: run  $target/deploy/goose upgrade-finish  to rebuild and restart"
    return 0
  fi
  "$target/deploy/goose" upgrade-finish
  note "rollback: cd $GOOSE_DIR && ./goose up --build   (the backup is $BACKUP_FILE)"
}
cmd_upgrade_finish() { # runs from the NEW kit
  require_setup
  need_docker
  env_set GOOSEPLICANT_VERSION "$(kit_version)"
  render_all
  validate_configs
  dc build gooseplicant caddy
  cmd_up
  cmd_doctor || warn "doctor found problems after the upgrade (see above)"
}

# ---------- main ----------
main() {
  tmpdir >/dev/null
  local cmd=${1:-help}
  [ $# -gt 0 ] && shift
  case "$cmd" in
    setup) cmd_setup "$@" ;;
    up) cmd_up "$@" ;;
    down) cmd_down "$@" ;;
    restart) cmd_restart "$@" ;;
    logs) cmd_logs "$@" ;;
    status | ps) cmd_status ;;
    add-user) cmd_add_user "$@" ;;
    remove-user) cmd_remove_user "$@" ;;
    passwd) cmd_passwd "$@" ;;
    reset-2fa) cmd_reset_2fa "$@" ;;
    add-domain) cmd_add_domain "$@" ;;
    doctor) cmd_doctor ;;
    code) cmd_code ;;
    connect) cmd_connect ;;
    render) cmd_render ;;
    backup) cmd_backup "$@" ;;
    restore) cmd_restore "$@" ;;
    upgrade) cmd_upgrade "$@" ;;
    upgrade-finish) cmd_upgrade_finish ;;
    version | --version) say "goose (Gooseplicant kit $(kit_version), Authelia $AUTHELIA_VERSION, $CADDY_IMAGE)" ;;
    _json) json_get "$1" ;; # test hook: JSON path lookup on stdin
    help | -h | --help) usage ;;
    *) usage >&2; die "unknown command: $cmd" ;;
  esac
}
main "$@"
