#!/usr/bin/env bash
# Sourced by remote_deploy.sh. Only a proven Certbot lock conflict may be retried.

certbot_lock_busy() {
  grep -Fxq 'Another instance of Certbot is already running.' \
    "$CERTBOT_WORK/stdout" "$CERTBOT_WORK/stderr"
}

# Fixed cause codes for a failed Certbot command. Certbot's own text stays private: only these
# tokens and a three-digit HTTP status from the CA's validation report reach the log.
certbot_failure_causes() {
  local causes="" code pattern http=""
  while IFS='|' read -r code pattern; do
    if grep -Eiq -- "$pattern" "$CERTBOT_WORK/stdout" "$CERTBOT_WORK/stderr"; then
      causes="${causes:+$causes,}$code"
    fi
  done <<'CAUSES'
rate_limited|rateLimited|too many (certificates|new orders|failed authorizations|registrations)
rejected_identifier|rejectedIdentifier|forbidden by policy
caa|CAA record
dns|Type: +dns|acme:error:dns|DNS problem
connection|Type: +connection|acme:error:connection|Timeout during (connect|read)|Connection refused
unauthorized|Type: +unauthorized|acme:error:unauthorized
incorrect_response|Invalid response from|incorrectResponse
secondary_validation|During secondary validation
tls|Type: +tls|acme:error:tls
account|accountDoesNotExist|Account (is not valid|does not exist)
ca_unavailable|serverInternal|Service busy|down for maintenance
nginx_restart|nginx restart failed|Error while running nginx
nginx_vhost|Could not automatically find a matching server block
nginx_parse|Could not parse file
nginx_plugin|nginx plugin is not working|Could not find a usable .nginx. binary
CAUSES
  http="$(grep -Eoh -- 'Invalid response from [^ ]+( \[[^]]+\])?: [1-5][0-9]{2}' "$CERTBOT_WORK/stdout" \
    "$CERTBOT_WORK/stderr" | grep -Eo '[1-5][0-9]{2}$' | head -1)" || http=""
  [ -z "$http" ] || causes="${causes:+$causes,}http_$http"
  printf '%s' "${causes:-unclassified}"
}

certbot_attempt() {
  local phase="$1" attempt="$2" status
  shift 2
  if certbot "$@" > "$CERTBOT_WORK/stdout" 2> "$CERTBOT_WORK/stderr"; then
    log "certificate phase=$phase attempt=$attempt state=ok exit=0"
    return 0
  else
    status=$?
  fi
  if certbot_lock_busy; then
    log "certificate phase=$phase attempt=$attempt state=lock_busy exit=$status"
  else
    log "certificate phase=$phase attempt=$attempt state=error exit=$status cause=$(certbot_failure_causes)"
  fi
  return "$status"
}

# A skipped install still does to nginx what the install did on every deploy: test the configuration, then
# reload it, so a change on disk the running nginx never loaded is loaded now. The commands' own output stays
# private; a failure ends the deploy as a failed install did, with its status and the fixed cause Certbot
# reports for a failed nginx test or reload.
nginx_attempt() {
  local phase="$1" attempt="$2" status
  shift 2
  if "$@" > "$CERTBOT_WORK/stdout" 2> "$CERTBOT_WORK/stderr"; then
    log "certificate phase=$phase attempt=$attempt state=ok exit=0"
    return 0
  else
    status=$?
  fi
  log "certificate phase=$phase attempt=$attempt state=error exit=$status cause=nginx_restart"
  return "$status"
}

certificate_present() {
  # 0=present, 1=confirmed absent, 2=unrecognized/partial inventory; preserve other awk failures.
  awk -v domain="$1" -v emit_name="${2:-}" '
    function valid_san(value, labels, count, i, compressed, groups) {
      if (index(value, ":")) {
        count = split(value, labels, "::"); if (count > 2) return 0
        compressed = count == 2
        if (value ~ /^:[^:]/ || value ~ /[^:]:$/ || value ~ /:::/) return 0
        count = split(value, labels, ":"); groups = 0
        for (i = 1; i <= count; i++) if (labels[i] != "") {
          if (labels[i] !~ /^[0-9A-Fa-f]+$/ || length(labels[i]) > 4) return 0
          groups++
        }
        return compressed ? groups < 8 : groups == 8
      }
      sub(/^[*][.]/, "", value); count = split(value, labels, ".")
      if (count < 2 || length(value) > 253) return 0
      for (i = 1; i <= count; i++)
        if (labels[i] !~ /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/ || length(labels[i]) > 63) return 0
      return 1
    }
    /^[[:space:]-]*$/ { next }
    /^No certificates found[.]$/ { if (mode) bad = 1; mode = 2; next }
    /^Found the following certs:$/ { if (mode) bad = 1; mode = 1; next }
    $1 == "Certificate" && $2 == "Name:" {
      if (mode != 1 || NF < 3 || (records && sans != 1)) bad = 1
      name = $0; sub(/^[[:space:]]*Certificate Name:[[:space:]]*/, "", name)
      if (index(name, "/") || index(name, "\\") || name == "." || name == "..") bad = 1
      records++; sans = 0; next
    }
    $1 == "Domains:" || $1 == "Identifiers:" {
      if (mode != 1 || !records || sans || NF < 2) bad = 1
      sans++
      for (i = 2; i <= NF; i++) {
        if (!valid_san($i)) bad = 1
        if ($i == domain) {
          if (!found || name == domain || (selected != domain && name < selected)) selected = name
          found = 1
        }
      }
      next
    }
    /^[[:space:]]+(Serial Number|Key Type|Expiry Date|Certificate Path|Private Key Path):/ {
      if (mode != 1 || !records) bad = 1
      next
    }
    { bad = 1 }
    END {
      if (bad || !mode || (mode == 1 && (!records || sans != 1))) exit 2
      if (found && emit_name) print selected
      exit !found
    }
  ' "$CERTBOT_WORK/stdout"
}

# Print the lineage's certificate and key paths, one per line, exactly as `certbot install` deploys them.
certificate_paths() {
  awk -v name="$1" '
    $1 == "Certificate" && $2 == "Name:" {
      current = $0; sub(/^[[:space:]]*Certificate Name:[[:space:]]*/, "", current)
      if (current == name) records++
      next
    }
    current != name { next }
    $1 == "Certificate" && $2 == "Path:" { chains++; chain = NF == 3 ? $3 : "" }
    $1 == "Private" && $2 == "Key" && $3 == "Path:" { keys++; key = NF == 4 ? $4 : "" }
    END {
      if (records != 1 || chains != 1 || keys != 1 || chain == "" || key == "") exit 1
      print chain; print key
    }
  ' "$CERTBOT_WORK/stdout"
}

# A repeated `install --nginx` saves a checkpoint of every nginx file on the host even when it changes
# nothing. Only `current` skips it: the vhost holds exactly this lineage behind Certbot's redirect, the
# running nginx presents that certificate for the name, and no other loaded server block names it.
certificate_vhost_state() {
  local domain="$1" name="$2" vhost="$3" paths state
  [ -n "$vhost" ] || { printf 'no_vhost'; return 0; }
  paths="$(certificate_paths "$name")" || { printf 'lineage_unknown'; return 0; }
  state="$(python3 "$(dirname "${BASH_SOURCE[0]}")/certificate_vhost.py" "$vhost" "$domain" \
    "${paths%%$'\n'*}" "${paths#*$'\n'}" 2>/dev/null)" || state=check_failed
  case "$state" in
    current|absent|http_only|other_certificate|no_redirect|lineage_unreadable|duplicate) ;;
    config_unknown|not_served|unreachable|ambiguous|unparsable|unreadable) ;;
    *) state=check_failed ;;
  esac
  printf '%s' "$state"
}

request_certificate() {
  local domain="$1" email="$2" attempt="$3" account
  # Certbot tells the CA to validate one second after reloading nginx by default. A host with
  # hundreds of vhosts and certificates takes longer to load a new configuration, so the CA
  # reached old workers still serving the token-gated edge and HTTP-01 failed with 403
  # (2026-09-24). Certbot also stores this wait for renewals of the certificate.
  set -- --nginx --nginx-sleep-seconds 10 -d "$domain" --non-interactive --agree-tos --email "$email" --redirect
  account="$(select_certbot_account)" || return $?
  if [ -n "$account" ]; then
    log "using an existing Let's Encrypt account"
    set -- "$@" --account "$account"
  fi
  certbot_attempt issuance "$attempt" "$@"
}

ensure_certificate() (
  local domain="$1" email="$2" vhost="${3:-}" delay name vhost_state attempt=0 status=1 CERTBOT_WORK
  CERTBOT_WORK="$(mktemp -d "${TMPDIR:-/tmp}/gowalk-certbot.XXXXXXXX")" || return $?
  trap 'rm -rf -- "$CERTBOT_WORK"' EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
  # Six attempts, 75 seconds total backoff. Commands retain the deployment job's bound.
  for delay in 0 5 10 15 20 25; do
    attempt=$((attempt + 1))
    if [ "$delay" -gt 0 ]; then
      log "certificate state=lock_retry attempt=$attempt backoff_seconds=$delay"
      sleep "$delay" || return $?
    fi
    if certbot_attempt inventory "$attempt" certificates; then
      if name="$(certificate_present "$domain" name)"; then
        log "cert for $domain already present"
        vhost_state="$(certificate_vhost_state "$domain" "$name" "$vhost")"
        if [ "$vhost_state" = current ]; then
          log "certificate phase=installation attempt=$attempt state=skipped vhost=current"
          nginx_attempt nginx_test "$attempt" nginx -t || return $?
          nginx_attempt nginx_reload "$attempt" systemctl reload nginx || return $?
          return 0
        fi
        log "certificate phase=installation attempt=$attempt state=required vhost=$vhost_state"
        # Inventory proves possession, not nginx installation. Repair an HTTP-only legacy vhost too.
        if certbot_attempt installation "$attempt" install --nginx --cert-name "$name" \
            -d "$domain" --non-interactive --redirect; then
          return 0
        else
          status=$?
          certbot_lock_busy && continue
          return "$status"
        fi
      else
        status=$?
        if [ "$status" -ne 1 ]; then
          log "certificate phase=inventory_parse state=error exit=$status"
          return "$status"
        fi
      fi
    else
      status=$?
      certbot_lock_busy && continue
      return "$status"
    fi
    log "requesting LE cert for $domain"
    if request_certificate "$domain" "$email" "$attempt"; then
      return 0
    else
      status=$?
      certbot_lock_busy || return "$status"
    fi
    # A contender may have created this certificate. Re-read before another issuance.
  done
  log "certificate state=lock_retry_exhausted attempts=$attempt exit=$status"
  return "$status"
)
