#!/bin/bash

# ── Runtime state ─────────────────────────────────────────────────────────────
DOMAIN_FILES=()
ENTRIES=()          # <type>|<env>|<domain>|<keylength>|<valid-to>|<valid-from>
ACTION=""           # install | uninstall | register | revoke | custom
                    # | list-profiles | list-envs | list-cas | list-groups
EMAIL=""
REVOKE_TYPE=""
REVOKE_DOMAIN=""
REVOKE_REASON=""
CUSTOM_ARGS=""
NO_LIMIT_CHECK=""
SELECT_GROUPS=()    # --group: only issue these certificates
RETRIES=2           # retries when the CA answers with a temporary error
NET_ARGS=()         # --request-v4 / --request-v6 passed on to acme.sh

CERT_HOME="/home/certs"
ACME_BIN="/home/ACME/acme.sh"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_DIR="$SCRIPT_DIR/env"

# ── Configuration ─────────────────────────────────────────────────────────────
# Set by .env / env/.<name>; the CLI_* values below are layered on top.
SERVER=""           # ACME CA: shorthand or full directory URL; empty = acme.sh default (ZeroSSL)
CERT_PROFILE=""     # CA certificate profile (classic | tlsserver | shortlived | ...)
DNS_PROVIDER=""
DNS_KEY=""
DNS_SECRET=""
KEYLENGTH=""
VALID_TO=""
VALID_FROM=""
ACTIVE_ENV=""       # name of the env profile currently loaded (empty = base .env only)
ACTIVE_ENV_FILE=""  # path of that profile, used in error messages

# Command line overrides. Empty means "not given on the command line" - they are
# re-applied after every env file so a flag always beats a file.
CLI_ENV=""
CLI_SERVER=""
CLI_PROFILE=""
CLI_DNS_PROVIDER=""
CLI_DNS_KEY=""
CLI_DNS_SECRET=""
CLI_KEYLENGTH=""
CLI_VALID_TO=""
CLI_VALID_FROM=""
CLI_RETRIES=""

show_help() {
    cat <<'EOF'
Usage: ACME.sh [options] [-s <domain>]... [-w <domain>]...

Domain flags:
  -s, --standalone <domain>   Issue this domain via HTTP-01 (standalone, port 80)
  -w, --wildcard   <domain>   Issue *.domain + domain via DNS-01
                              Both can be repeated and combined in one call.
  -f, --file       <path>     Read domains from a file (repeatable).

Domain file format - one entry per line:
    <type>:<env>:<domain>[:<keylength>[:<valid-to>[:<valid-from>]]]

  type      s | standalone    HTTP-01
            w | wildcard      DNS-01, issues *.domain + domain
  env       env profile name or its number from --list-envs.
            Empty = the profile chosen with -e (or plain .env).
  domain    domain name, or an IP address (standalone only, see below)

  The last three fields are optional; an empty field inherits from the env/CLI.
  Dates must be written without colons here: 2026-12-31, +90d, +12h.
  Legacy short form is still accepted:  <type>:<domain>
  Comment lines starting with # are ignored and never split a certificate.

  An EMPTY LINE separates certificates: each block of consecutive entries turns
  into one certificate, and a block may mix s and w entries - every domain is
  validated with the method its own line asks for. env, keylength, valid-to and
  valid-from must not contradict each other inside a block. A file without any
  empty line is therefore a single certificate.

  Example - three certificates:
    # 1: two wildcards in one certificate
    w:prod:example.com
    w:prod:example1.com

    # 2: mixed validation in one certificate
    s:prod:www.example4.com
    w:prod:example5.com

    # 3: staging profile by number, own key length, 30 days
    s:2:staging.example.com:ec-384:+30d

Selecting certificates:
      --list-groups           Number and show the certificates that would be issued
  -g, --group <#|domain>      Only issue these certificates. Repeatable and comma
                              separated; numbers come from --list-groups, and a
                              domain name selects the certificate containing it.

Environments (env/.<EnvName>):
  -e, --env <name|number>     Env profile for this run and for file entries
                              that leave the env field empty.
      --list-envs             List the env profiles and their numbers.
  Load order, later wins:  base  ->  env/.<name>  ->  CLI flags  ->  file fields.
  The base file is .env next to the script, or env/.env if you keep everything
  in env/ - whichever is used is not listed as a profile of its own.

Other actions:
  -i,  --install              Install acme.sh
  -u,  --uninstall            Uninstall acme.sh
  -re, --register <email>     Register ACME account (at the selected CA)
  -rv, --revoke <type> <domain> <reason>
                              Revoke a certificate
  -c,  --custom <args>        Pass custom arguments directly to acme.sh
       --list-profiles        List the certificate profiles the CA offers
       --list-cas             List the known CAs with lifetimes and limits
       --list-groups          List the certificates a domain file describes
  -h,  --help                 Show this help
EOF
    cat <<'EOF'

CA selection:
      --server <ca|url>       ACME CA. Accepts a shorthand or a full directory URL.
                              Shorthands:
                                letsencrypt, le          Let's Encrypt (production)
                                letsencrypt_test, staging, le_test
                                                         Let's Encrypt (staging)
                                zerossl                  ZeroSSL (acme.sh default)
                                sslcom                   SSL.com (RSA/ECC by -k)
                                sslcom_rsa, sslcom_ecc   SSL.com (forced)
                                google, google_test      Google Trust Services
                                actalis                  Actalis
                              Hyphens and underscores are interchangeable
                              (le-staging == le_staging).

Certificate profile:
  -p, --profile <name>        Request a CA certificate profile. Let's Encrypt:
                                classic     90 days (default)
                                tlsserver   45 days
                                shortlived  ~6 days, only profile allowing IPs
                              Run --list-profiles for what the CA advertises.

Certificate options:
  -k, --keylength <length>    Key length (default: ec-256; options: ec-384, 2048, 4096)
      --valid-to   <date>     Validity end   (2026-12-31 or +90d / +12h)
      --valid-from <date>     Validity start (e.g. 2026-01-01)
      --no-limit-check        Skip the local validity plausibility check

Flaky CAs:
      --retry <n>             Repeat a certificate when the CA answers with a
                              temporary error - "502 Bad Gateway", "Could not get
                              nonce", timeouts (default: 2, --retry 0 disables).
                              Failed validations are never retried.
      --request-v4            Force acme.sh to talk to the CA over IPv4 ...
      --request-v6            ... or over IPv6. Helps when one of the two routes
                              is broken and requests time out at random.

Validity limits (checked locally before anything is issued):
  Only Google Trust Services honours --valid-to/--valid-from (1-90 days, 3+
  recommended). Let's Encrypt, ZeroSSL, SSL.com and Actalis reject the ACME
  notBefore/notAfter fields - pick a shorter lifetime with -p instead.
  On top of that the CA/Browser Forum caps every publicly trusted TLS cert at
  200 days, dropping to 100 on 2027-03-15 and to 47 on 2029-03-15.

IP addresses:
  An IP target switches to Let's Encrypt + profile 'shortlived' automatically -
  the only public CA/profile combination that issues IP certificates (~6 days).
  An explicit --server / -p (or the matching file field) is never overridden.
  Wildcards and DNS-01 are not possible for IPs. IPv6 only via -s, because the
  domain file separator is ':'.

DNS options (required when -w is used):
  --dns        <provider>     DNS API provider (e.g. dns_cf, dns_gd, dns_dp, dns_he, dns_aws)
  --dns-key    <key>          API key / token
  --dns-secret <secret>       API secret / account-ID

Fixed settings (always applied):
  --cert-home /home/certs
  --ecc

Examples:
  # Standalone only
  bash ACME.sh -s example.com -s www.example.com

  # Wildcard only  ->  issues *.example.com + example.com via DNS-01
  bash ACME.sh -w example.com --dns dns_cf --dns-key <tok> --dns-secret <id>

  # Everything from a list, each line picking its own env profile
  bash ACME.sh -f domains.txt

  # Check the grouping first, then issue only the second certificate
  bash ACME.sh -f domains.txt --list-groups
  bash ACME.sh -f domains.txt -g 2

  # One env profile for the whole run
  bash ACME.sh -e prod -s example.com

  # Let's Encrypt with a 45-day profile
  bash ACME.sh -s example.com --server letsencrypt -p tlsserver
EOF
}

# ── Env profiles ──────────────────────────────────────────────────────────────

# The shared base file: .env next to the script, or env/.env if everything was
# moved into env/. Whichever is used is not an env profile of its own.
base_env_file() {
    if [[ -f "$SCRIPT_DIR/.env" ]]; then
        printf '%s' "$SCRIPT_DIR/.env"
    elif [[ -f "$ENV_DIR/.env" ]]; then
        printf '%s' "$ENV_DIR/.env"
    fi
    return 0
}

# All env profiles in listing order (env/.<Name>, dotfiles only).
list_env_files() {
    [[ -d "$ENV_DIR" ]] || return 0
    local f base
    base="$(base_env_file)"
    for f in "$ENV_DIR"/.[!.]*; do
        [[ -f "$f" ]] || continue
        [[ "$f" == "$base" ]] && continue
        printf '%s\n' "$f"
    done
    return 0
}

# An env file that does not parse would stop `source` halfway and silently leave
# the rest of the settings unset, so check it before loading.
check_env_file() {
    local file="$1" what="$2" err
    [[ -z "$file" ]] && return 0
    # tr/cmp instead of grep: grep strips CR itself on some platforms
    if ! tr -d '\r' < "$file" | cmp -s - "$file"; then
        echo "Error: $what ($file) has Windows line endings (CRLF)." >&2
        echo "       Every value would keep a trailing CR. Fix it with:" >&2
        echo "         sed -i 's/\r\$//' '$file'" >&2
        return 1
    fi
    if ! err="$(bash -n "$file" 2>&1)"; then
        echo "Error: $what ($file) is not valid shell, so it would only be loaded" >&2
        echo "       up to the broken line:" >&2
        echo "         $err" >&2
        return 1
    fi
    return 0
}

env_name_of() {
    local f="${1##*/}"
    printf '%s' "${f#.}"
}

# Read a single assignment out of an env file without sourcing it.
env_peek() {
    grep -m1 -E "^[[:space:]]*$2=" "$1" 2>/dev/null | cut -d= -f2- | tr -d '"'"'"'\r'
}

# Resolve an env profile name or its number to a file path.
env_file_for() {
    local want="$1" f
    local -a envs=()
    mapfile -t envs < <(list_env_files)
    if [[ "$want" =~ ^[0-9]+$ ]]; then
        if (( want < 1 || want > ${#envs[@]} )); then
            echo "No env profile number $want (found ${#envs[@]} in $ENV_DIR). Try --list-envs." >&2
            return 1
        fi
        printf '%s' "${envs[want - 1]}"
        return 0
    fi
    for f in "${envs[@]}"; do
        [[ "$(env_name_of "$f")" == "$want" ]] && { printf '%s' "$f"; return 0; }
    done
    echo "Unknown env profile: $want (expected $ENV_DIR/.$want). Try --list-envs." >&2
    return 1
}

list_envs() {
    local -a envs=()
    mapfile -t envs < <(list_env_files)
    if [[ ${#envs[@]} -eq 0 ]]; then
        echo "No env profiles in $ENV_DIR (expected files named env/.<EnvName>)."
        local base; base="$(base_env_file)"
        [[ -n "$base" ]] && echo "Only the base file $base is in use."
        return 0
    fi
    printf "%-4s %-16s %-26s %s\n" "#" "name" "server" "dns"
    printf -- "----------------------------------------------------------------------\n"
    local i=0 f
    for f in "${envs[@]}"; do
        i=$((i + 1))
        printf "%-4s %-16s %-26s %s\n" "$i" "$(env_name_of "$f")" \
            "$(env_peek "$f" SERVER)" "$(env_peek "$f" DNS_PROVIDER)"
    done
    echo ""
    echo "base file: $(base_env_file)"
    echo "Numbers follow this listing and shift when profiles are added - names are stable."
    return 0
}

# Load base .env, then the selected profile, then re-apply the CLI overrides.
load_env() {
    local want="$1" file="" base
    ACTIVE_ENV=""
    ACTIVE_ENV_FILE=""
    if [[ -n "$want" ]]; then
        file="$(env_file_for "$want")" || return 1
    fi
    base="$(base_env_file)"
    check_env_file "$base" "base env file"                        || return 1
    check_env_file "$file" "env profile '$(env_name_of "$file")'" || return 1

    set -a
    # shellcheck source=.env
    [[ -n "$base" ]] && source "$base"
    if [[ -n "$file" ]]; then
        # shellcheck disable=SC1090
        source "$file"
        ACTIVE_ENV="$(env_name_of "$file")"
        ACTIVE_ENV_FILE="$file"
    fi
    set +a
    apply_cli_overrides
    return 0
}

apply_cli_overrides() {
    [[ -n "$CLI_SERVER" ]]       && SERVER="$CLI_SERVER"
    [[ -n "$CLI_PROFILE" ]]      && CERT_PROFILE="$CLI_PROFILE"
    [[ -n "$CLI_DNS_PROVIDER" ]] && DNS_PROVIDER="$CLI_DNS_PROVIDER"
    [[ -n "$CLI_DNS_KEY" ]]      && DNS_KEY="$CLI_DNS_KEY"
    [[ -n "$CLI_DNS_SECRET" ]]   && DNS_SECRET="$CLI_DNS_SECRET"
    [[ -n "$CLI_KEYLENGTH" ]]    && KEYLENGTH="$CLI_KEYLENGTH"
    [[ -n "$CLI_VALID_TO" ]]     && VALID_TO="$CLI_VALID_TO"
    [[ -n "$CLI_VALID_FROM" ]]   && VALID_FROM="$CLI_VALID_FROM"
    [[ -n "$CLI_RETRIES" ]]      && RETRIES="$CLI_RETRIES"
    return 0
}

# Normalize the provider slug and turn the CA into the args passed to acme.sh.
SERVER_ARGS=()
resolve_config() {
    [[ -n "$DNS_PROVIDER" && "$DNS_PROVIDER" != dns_* ]] && DNS_PROVIDER="dns_${DNS_PROVIDER}"
    SERVER_ARGS=()
    if [[ -n "$SERVER" ]]; then
        SERVER="$(resolve_server "$SERVER")" || return 1
        SERVER_ARGS=(--server "$SERVER")
    fi
    return 0
}

# ── CAs ───────────────────────────────────────────────────────────────────────

# Resolve a CA shorthand to its ACME directory URL.
# Full URLs are passed through unchanged; unknown names abort instead of being
# forwarded to acme.sh, which would treat them as a (broken) directory URL.
resolve_server() {
    local raw="$1"
    local s="${raw,,}"      # lowercase
    s="${s//-/_}"           # treat "le-staging" and "le_staging" alike
    case "$s" in
        http://*|https://*)  printf '%s' "$raw" ;;
        letsencrypt|le)      printf '%s' "https://acme-v02.api.letsencrypt.org/directory" ;;
        letsencrypt_test|letsencrypttest|le_test|le_staging|staging)
                             printf '%s' "https://acme-staging-v02.api.letsencrypt.org/directory" ;;
        zerossl|zero)        printf '%s' "https://acme.zerossl.com/v2/DV90" ;;
        sslcom)
            # acme.sh only auto-picks the RSA/ECC endpoint when the shorthand is
            # passed through, so mirror that choice here based on the key length.
            if [[ -z "$KEYLENGTH" || "$KEYLENGTH" == ec-* ]]; then
                printf '%s' "https://acme.ssl.com/sslcom-dv-ecc"
            else
                printf '%s' "https://acme.ssl.com/sslcom-dv-rsa"
            fi
            ;;
        sslcom_rsa)          printf '%s' "https://acme.ssl.com/sslcom-dv-rsa" ;;
        sslcom_ecc)          printf '%s' "https://acme.ssl.com/sslcom-dv-ecc" ;;
        google)              printf '%s' "https://dv.acme-v02.api.pki.goog/directory" ;;
        google_test|googletest)
                             printf '%s' "https://dv.acme-v02.test-api.pki.goog/directory" ;;
        actalis)             printf '%s' "https://acme-api.actalis.com/acme/directory" ;;
        *)
            echo "Unknown CA: $raw" >&2
            echo "Use a full https:// URL or one of: letsencrypt (le), letsencrypt_test (staging)," >&2
            echo "zerossl, sslcom, sslcom_rsa, sslcom_ecc, google, google_test, actalis" >&2
            exit 1
            ;;
    esac
}

# Directory URL -> internal CA id (empty URL = acme.sh default = ZeroSSL).
ca_id_of() {
    case "$1" in
        "")                                     echo "zerossl" ;;
        *acme-staging-v02.api.letsencrypt.org*) echo "letsencrypt_test" ;;
        *acme-v02.api.letsencrypt.org*)         echo "letsencrypt" ;;
        *acme.zerossl.com*)                     echo "zerossl" ;;
        *test-api.pki.goog*)                    echo "google_test" ;;
        *pki.goog*)                             echo "google" ;;
        *acme.ssl.com*)                         echo "sslcom" ;;
        *acme-api.actalis.com*)                 echo "actalis" ;;
        *)                                      echo "unknown" ;;
    esac
}

list_cas() {
    cat <<'EOF'
CA                 free lifetime            --valid-to / --valid-from
------------------------------------------------------------------------------
letsencrypt        90 d  (classic)          not supported - use -p <profile>
                   45 d  (tlsserver)
                   ~6 d  (shortlived, IPs)
zerossl            90 d                     not supported
google             90 d default, 1-90 d     supported (3 d or more recommended)
sslcom             90 d                     not supported
actalis            90 d                     not supported

On top of the CA limit the CA/Browser Forum caps publicly trusted TLS
certificates at 200 days; 100 days from 2027-03-15, 47 days from 2029-03-15.
Buypass is gone: no new ACME accounts since 2025-09-15, no issuance since
2025-10-15, last certificates expired 2026-04-15.
EOF
    return 0
}

# ── Validity limits ───────────────────────────────────────────────────────────

_epoch() { date -d "$1" +%s 2>/dev/null; }

# Requested lifetime in days; fails when the date cannot be parsed.
validity_days() {
    local from="$1" to="$2" start end f
    [[ -z "$to" ]] && return 1
    if [[ "$to" =~ ^\+([0-9]+)([hd])$ ]]; then
        if [[ "${BASH_REMATCH[2]}" == h ]]; then
            printf '%s' "$(( (BASH_REMATCH[1] + 23) / 24 ))"
        else
            printf '%s' "${BASH_REMATCH[1]}"
        fi
        return 0
    fi
    end="$(_epoch "$to")"
    [[ -z "$end" ]] && return 1
    start="$(date +%s)"
    if [[ -n "$from" && "$from" != +* ]]; then
        f="$(_epoch "$from")"
        [[ -n "$f" ]] && start="$f"
    fi
    printf '%s' "$(( (end - start + 86399) / 86400 ))"
    return 0
}

# CA/Browser Forum ballot SC-081v3 schedule.
cab_max_days() {
    local now d2027 d2029
    now="$(date +%s)"
    d2027="$(_epoch 2027-03-15)"
    d2029="$(_epoch 2029-03-15)"
    if [[ -z "$d2027" || -z "$d2029" ]]; then echo 200; return 0; fi
    if   (( now < d2027 )); then echo 200
    elif (( now < d2029 )); then echo 100
    else echo 47
    fi
    return 0
}

# Refuse validity windows the CA is known to reject, before anything is ordered.
check_validity_limits() {
    [[ -n "$NO_LIMIT_CHECK" ]] && return 0
    [[ -z "$VALID_TO" && -z "$VALID_FROM" ]] && return 0

    local ca max cab days
    ca="$(ca_id_of "$SERVER")"
    cab="$(cab_max_days)"

    case "$ca" in
        letsencrypt|letsencrypt_test)
            echo "Error: Let's Encrypt does not support --valid-to/--valid-from" >&2
            echo "       (it rejects the ACME notBefore/notAfter fields)." >&2
            echo "       Pick the lifetime via profile instead: -p classic (90 d)," >&2
            echo "       -p tlsserver (45 d) or -p shortlived (~6 d)." >&2
            echo "       Override with --no-limit-check." >&2
            return 1
            ;;
        zerossl|sslcom|actalis)
            echo "Error: $ca does not support --valid-to/--valid-from and always issues" >&2
            echo "       90-day certificates. Use --server google for custom lifetimes," >&2
            echo "       or --no-limit-check to send it anyway." >&2
            return 1
            ;;
        google|google_test)
            max=90
            ;;
        *)
            max="$cab"
            echo "Note: validity limits of '$SERVER' are unknown here - only the" >&2
            echo "      CA/Browser Forum cap of $cab days is checked." >&2
            ;;
    esac

    (( max > cab )) && max="$cab"
    if ! days="$(validity_days "$VALID_FROM" "$VALID_TO")"; then
        echo "Note: cannot parse valid-to '$VALID_TO' - skipping the limit check." >&2
        return 0
    fi
    if (( days > max )); then
        echo "Error: requested validity of $days days exceeds the $max day limit of" >&2
        echo "       '$ca' (valid-to: $VALID_TO). Override with --no-limit-check." >&2
        return 1
    fi
    if (( days < 1 )); then
        echo "Error: valid-to '$VALID_TO' is not in the future." >&2
        return 1
    fi
    if [[ "$ca" == google* ]] && (( days < 3 )); then
        echo "Warning: Google recommends at least 3 days (clock skew); requested $days." >&2
    fi
    return 0
}

is_ip() {
    [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0
    [[ "$1" =~ ^[0-9A-Fa-f]*:[0-9A-Fa-f:]*:[0-9A-Fa-f.:]*$ ]] && return 0
    return 1
}

# ── Command line ──────────────────────────────────────────────────────────────
# @CLI@ marks entries whose env follows -e / .env.

while [[ $# -gt 0 ]]; do
    case "$1" in
        -s|--standalone)
            [[ -z "$2" || "$2" == -* ]] && { echo "Missing domain for -s"; exit 1; }
            ENTRIES+=("@CLI-S@|s|@CLI@|$2|||")
            shift 2
            ;;
        -w|--wildcard)
            [[ -z "$2" || "$2" == -* ]] && { echo "Missing domain for -w"; exit 1; }
            ENTRIES+=("@CLI-W@|w|@CLI@|$2|||")
            shift 2
            ;;
        -f|--file)
            [[ -z "$2" || "$2" == -* ]] && { echo "Missing path for --file"; exit 1; }
            DOMAIN_FILES+=("$2")
            shift 2
            ;;
        -e|--env)
            [[ -z "$2" || "$2" == -* ]] && { echo "Missing name for --env"; exit 1; }
            CLI_ENV="$2"
            shift 2
            ;;
        -h|--help)        show_help; exit 0 ;;
        -i|--install)     ACTION="install";        shift ;;
        -u|--uninstall)   ACTION="uninstall";      shift ;;
        -g|--group)
            [[ -z "$2" || "$2" == -* ]] && { echo "Missing value for --group"; exit 1; }
            IFS=',' read -r -a _sel <<< "$2"
            SELECT_GROUPS+=("${_sel[@]}")
            shift 2
            ;;
        --list-groups)    ACTION="list-groups";    shift ;;
        --list-envs)      ACTION="list-envs";      shift ;;
        --list-cas)       ACTION="list-cas";       shift ;;
        --list-profiles)  ACTION="list-profiles";  shift ;;
        --no-limit-check) NO_LIMIT_CHECK=1;        shift ;;
        --request-v4)     NET_ARGS=(--request-v4);  shift ;;
        --request-v6)     NET_ARGS=(--request-v6);  shift ;;
        --retry)
            [[ "$2" =~ ^[0-9]+$ ]] || { echo "--retry needs a number"; exit 1; }
            CLI_RETRIES="$2"
            shift 2
            ;;
        --dns)
            [[ -z "$2" || "$2" == -* ]] && { echo "Missing value for --dns"; exit 1; }
            CLI_DNS_PROVIDER="$2"
            shift 2
            ;;
        --server)
            [[ -z "$2" || "$2" == -* ]] && { echo "Missing value for --server"; exit 1; }
            CLI_SERVER="$2"
            shift 2
            ;;
        -p|--profile)
            [[ -z "$2" || "$2" == -* ]] && { echo "Missing value for --profile"; exit 1; }
            CLI_PROFILE="$2"
            shift 2
            ;;
        --dns-key)
            CLI_DNS_KEY="$2"
            shift 2
            ;;
        --dns-secret)
            CLI_DNS_SECRET="$2"
            shift 2
            ;;
        -k|--keylength)
            CLI_KEYLENGTH="$2"
            shift 2
            ;;
        --valid-to)
            CLI_VALID_TO="$2"
            shift 2
            ;;
        --valid-from)
            CLI_VALID_FROM="$2"
            shift 2
            ;;
        -re|--register)
            ACTION="register"
            EMAIL="$2"
            shift 2
            ;;
        -rv|-rev|--revoke)
            ACTION="revoke"
            REVOKE_TYPE="$2"
            REVOKE_DOMAIN="$3"
            REVOKE_REASON="$4"
            shift 4
            ;;
        -c|--custom)
            ACTION="custom"
            CUSTOM_ARGS="$2"
            shift 2
            ;;
        *)
            echo "Unknown argument: $1"
            echo "Run with --help for usage."
            exit 1
            ;;
    esac
done

# Map --dns-key / --dns-secret CLI overrides to the provider-specific env vars.
# When credentials come from .env, this function is a no-op (vars already exported).
setup_dns_env() {
    [[ -z "$DNS_KEY" && -z "$DNS_SECRET" ]] && return
    case "$DNS_PROVIDER" in
        dns_cf)       export CF_Token="$DNS_KEY";              export CF_Account_ID="$DNS_SECRET" ;;
        dns_gd)       export GD_Key="$DNS_KEY";                export GD_Secret="$DNS_SECRET" ;;
        dns_dp)       export DP_Id="$DNS_KEY";                 export DP_Key="$DNS_SECRET" ;;
        dns_ali)      export Ali_Key="$DNS_KEY";               export Ali_Secret="$DNS_SECRET" ;;
        dns_aws)      export AWS_ACCESS_KEY_ID="$DNS_KEY";     export AWS_SECRET_ACCESS_KEY="$DNS_SECRET" ;;
        dns_azure)    export AZUREDNS_APPID="$DNS_KEY";        export AZUREDNS_CLIENTSECRET="$DNS_SECRET" ;;
        dns_dgon)     export DO_API_KEY="$DNS_KEY" ;;
        dns_hetzner)  export HETZNER_Token="$DNS_KEY" ;;
        dns_ionos)    export IONOS_PREFIX="$DNS_KEY";          export IONOS_SECRET="$DNS_SECRET" ;;
        dns_he)       export HE_Username="$DNS_KEY";           export HE_Password="$DNS_SECRET" ;;
        dns_linode_v4) export LINODE_V4_API_KEY="$DNS_KEY" ;;
        dns_netlify)  export NETLIFY_ACCESS_TOKEN="$DNS_KEY" ;;
        dns_vercel)   export VERCEL_TOKEN="$DNS_KEY" ;;
        dns_vultr)    export VULTR_API_KEY="$DNS_KEY" ;;
        dns_bunny)    export BUNNY_API_KEY="$DNS_KEY" ;;
        dns_porkbun)  export PORKBUN_API_KEY="$DNS_KEY";       export PORKBUN_SECRET_API_KEY="$DNS_SECRET" ;;
        dns_scaleway) export SCALEWAY_API_TOKEN="$DNS_KEY" ;;
        dns_nsone)    export NS1_Key="$DNS_KEY" ;;
        dns_desec)    export DEDYN_TOKEN="$DNS_KEY" ;;
        dns_duckdns)  export DuckDNS_Token="$DNS_KEY" ;;
        dns_dynu)     export Dynu_ClientId="$DNS_KEY";         export Dynu_Secret="$DNS_SECRET" ;;
        dns_easydns)  export EASYDNS_Token="$DNS_KEY";         export EASYDNS_Key="$DNS_SECRET" ;;
        dns_gandi_livedns) export GANDI_LIVEDNS_TOKEN="$DNS_KEY" ;;
        dns_namecom)  export Namecom_Username="$DNS_KEY";      export Namecom_Token="$DNS_SECRET" ;;
        dns_namesilo) export Namesilo_Key="$DNS_KEY" ;;
        dns_pdns)     export PDNS_Token="$DNS_KEY" ;;
        dns_infomaniak) export INFOMANIAK_API_TOKEN="$DNS_KEY" ;;
        dns_dnsimple) export DNSimple_OAUTH_TOKEN="$DNS_KEY" ;;
        dns_cloudns)  export CLOUDNS_AUTH_ID="$DNS_KEY";       export CLOUDNS_AUTH_PASSWORD="$DNS_SECRET" ;;
        dns_inwx)     export INWX_User="$DNS_KEY";             export INWX_Password="$DNS_SECRET" ;;
        dns_loopia)   export LOOPIA_User="$DNS_KEY";           export LOOPIA_Password="$DNS_SECRET" ;;
        dns_lua)      export LUA_Key="$DNS_KEY";               export LUA_Email="$DNS_SECRET" ;;
        dns_me)       export ME_Key="$DNS_KEY";                 export ME_Secret="$DNS_SECRET" ;;
        dns_websupport) export WS_ApiKey="$DNS_KEY";           export WS_ApiSecret="$DNS_SECRET" ;;
        dns_tencent)  export Tencent_SecretId="$DNS_KEY";      export Tencent_SecretKey="$DNS_SECRET" ;;
        dns_njalla)   export NJALLA_Token="$DNS_KEY" ;;
        dns_arvan)    export Arvan_Token="$DNS_KEY" ;;
        dns_ipv64)    export IPv64_Token="$DNS_KEY" ;;
        dns_gcore)    export GCORE_Key="$DNS_KEY" ;;
        dns_rcode0)   export RCODE0_API_TOKEN="$DNS_KEY" ;;
        dns_misaka)   export Misaka_Key="$DNS_KEY" ;;
        dns_variomedia) export VARIOMEDIA_API_TOKEN="$DNS_KEY" ;;
        dns_doapi)    export DO_LETOKEN="$DNS_KEY" ;;
        dns_timeweb)  export TW_Token="$DNS_KEY" ;;
        dns_selectel) export SL_Login_ID="$DNS_KEY" ;;
        dns_regru)    export REGRU_API_Username="$DNS_KEY";    export REGRU_API_Password="$DNS_SECRET" ;;
        dns_anx)      export ANX_Token="$DNS_KEY" ;;
        dns_namecheap) export NAMECHEAP_API_KEY="$DNS_KEY";    export NAMECHEAP_USERNAME="$DNS_SECRET" ;;
        dns_domainoffensive) export DO_LETOKEN="$DNS_KEY" ;;
        dns_dnsservices) export DnsServices_Username="$DNS_KEY"; export DnsServices_Password="$DNS_SECRET" ;;
        dns_internetbs) export INTERNETBS_API_KEY="$DNS_KEY";  export INTERNETBS_API_PASSWORD="$DNS_SECRET" ;;
        dns_vultr)    export VULTR_API_KEY="$DNS_KEY" ;;
        dns_vscale)   export VSCALE_API_KEY="$DNS_KEY" ;;
        dns_omglol)   export OMG_ApiKey="$DNS_KEY";            export OMG_Address="$DNS_SECRET" ;;
        dns_hosttech) export Hosttech_Key="$DNS_KEY" ;;
        dns_aurora)   export AURORA_Key="$DNS_KEY";            export AURORA_Secret="$DNS_SECRET" ;;
        dns_mythic_beasts) export MB_AK="$DNS_KEY";            export MB_AS="$DNS_SECRET" ;;
        dns_wedos)    export WEDOS_Username="$DNS_KEY";        export WEDOS_Wapipass="$DNS_SECRET" ;;
        dns_freedns)  export FREEDNS_User="$DNS_KEY";          export FREEDNS_Password="$DNS_SECRET" ;;
        dns_dynv6)    export DYNV6_TOKEN="$DNS_KEY" ;;
        dns_ddnss)    export DDNSS_Token="$DNS_KEY" ;;
        dns_nanelo)   export NANELO_TOKEN="$DNS_KEY" ;;
        dns_nodion)   export NODION_API_KEY="$DNS_KEY" ;;
        dns_anikeen)  export ANIKEEN_API_KEY="$DNS_KEY" ;;
        dns_bunny)    export BUNNY_API_KEY="$DNS_KEY" ;;
        dns_fornex)   export FORNEX_API_KEY="$DNS_KEY" ;;
        dns_kappernet) export KAPPERNETDNS_Key="$DNS_KEY";     export KAPPERNETDNS_Secret="$DNS_SECRET" ;;
        dns_sdns)     export SDNS_ZONE_KEY="$DNS_KEY" ;;
        dns_acmedns)  export ACMEDNS_USERNAME="$DNS_KEY";      export ACMEDNS_PASSWORD="$DNS_SECRET" ;;
        dns_dreamhost) export DH_API_KEY="$DNS_KEY" ;;
        dns_joker)    export JOKER_USERNAME="$DNS_KEY";        export JOKER_PASSWORD="$DNS_SECRET" ;;
        dns_rage4)    export RAGE4_TOKEN="$DNS_KEY";           export RAGE4_USERNAME="$DNS_SECRET" ;;
        dns_durabledns) export DD_API_User="$DNS_KEY";         export DD_API_Key="$DNS_SECRET" ;;
        dns_exoscale) export EXOSCALE_API_KEY="$DNS_KEY";      export EXOSCALE_SECRET_KEY="$DNS_SECRET" ;;
        dns_linode_v4) export LINODE_V4_API_KEY="$DNS_KEY" ;;
        dns_geodns)   export GeoDNS_Key="$DNS_KEY" ;;
        dns_da)       export DA_Api="$DNS_KEY" ;;
        dns_cpanel)   export cPanel_Apitoken="$DNS_KEY";       export cPanel_Username="$DNS_SECRET" ;;
        dns_neodigit) export NEODIGIT_API_TOKEN="$DNS_KEY" ;;
        dns_rackcorp) export RACKCORP_APIUUID="$DNS_KEY";      export RACKCORP_APISECRET="$DNS_SECRET" ;;
        dns_beget)    export Beget_Username="$DNS_KEY";        export Beget_Password="$DNS_SECRET" ;;
        dns_cyon)     export CY_Username="$DNS_KEY";           export CY_Password="$DNS_SECRET" ;;
        dns_1984hosting) export One984HOSTING_Username="$DNS_KEY"; export One984HOSTING_Password="$DNS_SECRET" ;;
        dns_aruba)    export ARUBA_TK="$DNS_KEY";              export ARUBA_AK="$DNS_SECRET" ;;
        dns_simply)   export SIMPLY_AccountName="$DNS_KEY";    export SIMPLY_ApiKey="$DNS_SECRET" ;;
        dns_hostingukraine) export HostingUkraine_Login="$DNS_KEY"; export HostingUkraine_Token="$DNS_SECRET" ;;
        dns_dynadot)  export DYNADOTAPI_Token="$DNS_KEY" ;;
        dns_zoneedit) export ZONEEDIT_ID="$DNS_KEY";           export ZONEEDIT_Token="$DNS_SECRET" ;;
        dns_udr)      export UDR_USER="$DNS_KEY";              export UDR_PASS="$DNS_SECRET" ;;
        dns_world4you) export WORLD4YOU_USERNAME="$DNS_KEY";   export WORLD4YOU_PASSWORD="$DNS_SECRET" ;;
        dns_alviy)    export Alviy_token="$DNS_KEY" ;;
        dns_cn)       export CN_User="$DNS_KEY";               export CN_Password="$DNS_SECRET" ;;
        dns_hostline) export HOSTLINE_Token="$DNS_KEY";        export HOSTLINE_Url="$DNS_SECRET" ;;
        dns_veesp)    export VEESP_User="$DNS_KEY";            export VEESP_Password="$DNS_SECRET" ;;
        dns_geoscaling) export GEOSCALING_Username="$DNS_KEY"; export GEOSCALING_Password="$DNS_SECRET" ;;
        dns_azion)    export AZION_Email="$DNS_KEY";           export AZION_Password="$DNS_SECRET" ;;
        dns_he_ddns)  export HE_DDNS_KEY="$DNS_KEY" ;;
        dns_freemyip) export FREEMYIP_Token="$DNS_KEY" ;;
        dns_ad)       export AD_API_KEY="$DNS_KEY" ;;
        dns_zonomi)   export ZM_Key="$DNS_KEY" ;;
        dns_zilore)   export Zilore_Key="$DNS_KEY" ;;
        dns_pointhq)  export PointHQ_Key="$DNS_KEY";           export PointHQ_Email="$DNS_SECRET" ;;
        dns_constellix) export CONSTELLIX_Key="$DNS_KEY";      export CONSTELLIX_Secret="$DNS_SECRET" ;;
        dns_domeneshop) export DOMENESHOP_Token="$DNS_KEY";    export DOMENESHOP_Secret="$DNS_SECRET" ;;
        dns_openprovider_rest) export OPENPROVIDER_REST_USERNAME="$DNS_KEY"; export OPENPROVIDER_REST_PASSWORD="$DNS_SECRET" ;;
        dns_hostingde) export HOSTINGDE_APIKEY="$DNS_KEY" ;;
        dns_netcup)   export NC_Apikey="$DNS_KEY";             export NC_Apipw="$DNS_SECRET" ;;
        dns_conoha)   export CONOHA_Username="$DNS_KEY";       export CONOHA_Password="$DNS_SECRET" ;;
        dns_kinghost) export KINGHOST_Username="$DNS_KEY";     export KINGHOST_Password="$DNS_SECRET" ;;
        dns_euserv)   export EUSERV_Username="$DNS_KEY";       export EUSERV_Password="$DNS_SECRET" ;;
        dns_one)      export ONECOM_User="$DNS_KEY";           export ONECOM_Password="$DNS_SECRET" ;;
        dns_dnshome)  export DNSHOME_Subdomain="$DNS_KEY";     export DNSHOME_SubdomainPassword="$DNS_SECRET" ;;
        dns_limacity) export LIMACITY_APIKEY="$DNS_KEY" ;;
        dns_mijnhost) export MIJNHOST_API_KEY="$DNS_KEY" ;;
        dns_leaseweb) export LSW_Key="$DNS_KEY" ;;
        dns_curanet)  export CURANET_AUTHCLIENTID="$DNS_KEY";  export CURANET_AUTHSECRET="$DNS_SECRET" ;;
        dns_variomedia) export VARIOMEDIA_API_TOKEN="$DNS_KEY" ;;
        dns_jd)       export JD_ACCESS_KEY_ID="$DNS_KEY";      export JD_ACCESS_KEY_SECRET="$DNS_SECRET" ;;
        dns_yandex360) export YANDEX360_CLIENT_ID="$DNS_KEY";  export YANDEX360_CLIENT_SECRET="$DNS_SECRET" ;;
        dns_huaweicloud) export HUAWEICLOUD_Username="$DNS_KEY"; export HUAWEICLOUD_Password="$DNS_SECRET" ;;
        dns_tele3)    export TELE3_Key="$DNS_KEY";             export TELE3_Secret="$DNS_SECRET" ;;
        dns_mydnsjp)  export MYDNSJP_MasterID="$DNS_KEY";      export MYDNSJP_Password="$DNS_SECRET" ;;
        dns_nic)      export NIC_Username="$DNS_KEY";          export NIC_Password="$DNS_SECRET" ;;
        dns_mybindns) export MYBINDNS_Url="$DNS_KEY";          export MYBINDNS_Key="$DNS_SECRET" ;;
        dns_infoblox) export Infoblox_Creds="$DNS_KEY";        export Infoblox_Server="$DNS_SECRET" ;;
        dns_pdnsmanager) export PDNS_MANAGER_RECORDID="$DNS_KEY"; export PDNS_MANAGER_PASSWORD="$DNS_SECRET" ;;
        dns_acmeproxy) export ACMEPROXY_USERNAME="$DNS_KEY";   export ACMEPROXY_PASSWORD="$DNS_SECRET" ;;
        dns_dyn)      export DYN_Username="$DNS_KEY";          export DYN_Password="$DNS_SECRET" ;;
        dns_ultra)    export ULTRA_USR="$DNS_KEY";             export ULTRA_PWD="$DNS_SECRET" ;;
        dns_pleskxml) export pleskxml_user="$DNS_KEY";         export pleskxml_pass="$DNS_SECRET" ;;
        dns_clouddns) export CLOUDDNS_EMAIL="$DNS_KEY";        export CLOUDDNS_PASSWORD="$DNS_SECRET" ;;
        dns_schlundtech) export SCHLUNDTECH_USER="$DNS_KEY";   export SCHLUNDTECH_PASSWORD="$DNS_SECRET" ;;
        dns_autodns)  export AUTODNS_USER="$DNS_KEY";          export AUTODNS_PASSWORD="$DNS_SECRET" ;;
        dns_nw)       export NW_API_TOKEN="$DNS_KEY";          export NW_API_ENDPOINT="$DNS_SECRET" ;;
        dns_ispman)   export ISPMan_Username="$DNS_KEY";       export ISPMan_Password="$DNS_SECRET" ;;
        *)
            # For providers not listed: set generic vars and let the user
            # configure provider-specific vars directly in .env
            [[ -n "$DNS_KEY" ]]    && export DNS_Key="$DNS_KEY"
            [[ -n "$DNS_SECRET" ]] && export DNS_Secret="$DNS_SECRET"
            ;;
    esac
}

# ── Domain entries ────────────────────────────────────────────────────────────

# Parse one domain file line into an ENTRIES record.
#   <type>:<env>:<domain>[:<keylength>[:<valid-to>[:<valid-from>]]]
#   <type>:<domain>                                    (legacy short form)
parse_entry() {
    local raw="$1" where="$2" block="$3"
    local -a f=()
    IFS=':' read -r -a f <<< "$raw"
    local type env domain kl vt vf
    # Count the separators instead of the fields: `read` drops a trailing empty
    # field, so "s:prod:" would otherwise look like the legacy two-field form.
    local seps="${raw//[^:]/}"
    local n=${#seps}

    if (( n < 1 )); then
        echo "$where: not a domain entry: $raw" >&2
        return 1
    fi
    if (( n > 5 )); then
        echo "$where: too many ':' separated fields: $raw" >&2
        echo "  Dates in a domain file must be colon-free (2026-12-31, +90d, +12h)," >&2
        echo "  and IPv6 addresses can only be passed via -s." >&2
        return 1
    fi

    type="${f[0]}"
    if (( n == 1 )); then
        env=""; domain="${f[1]}"
    else
        env="${f[1]}"; domain="${f[2]}"; kl="${f[3]}"; vt="${f[4]}"; vf="${f[5]}"
    fi

    case "${type,,}" in
        s|standalone) type="s" ;;
        w|wildcard)   type="w" ;;
        *) echo "$where: unknown type '${f[0]}' (expected s/standalone or w/wildcard)" >&2; return 1 ;;
    esac
    if [[ -z "$domain" ]]; then
        echo "$where: missing domain: $raw" >&2
        return 1
    fi
    if [[ "$type" == w ]] && is_ip "$domain"; then
        echo "$where: '$domain' is an IP - wildcards and DNS-01 are not possible for IPs." >&2
        return 1
    fi
    [[ -z "$env" ]] && env="@CLI@"

    ENTRIES+=("$block|$type|$env|$domain|$kl|$vt|$vf")
    return 0
}

load_domains_file() {
    local file="$1" line lineno=0 block=0 filled=0
    [[ ! -f "$file" ]] && { echo "Domain file not found: $file"; exit 1; }
    while IFS= read -r line || [[ -n "$line" ]]; do
        lineno=$((lineno + 1))
        # strip a trailing CR and leading/trailing whitespace
        line="${line%$'\r'}"
        line="${line#"${line%%[![:space:]]*}"}"
        line="${line%"${line##*[![:space:]]}"}"
        # An empty line ends the current certificate. Runs of empty lines and
        # empty lines before the first entry are not boundaries of their own.
        if [[ -z "$line" ]]; then
            (( filled )) && filled=0
            continue
        fi
        # comments never split a certificate
        [[ "$line" == \#* ]] && continue
        (( filled )) || block=$((block + 1))
        parse_entry "$line" "$file:$lineno" "$file#$block" || exit 1
        filled=1
    done < "$file"
    return 0
}

build_extra_args() {
    local args=()
    [[ -n "$KEYLENGTH" ]]     && args+=(--keylength "$KEYLENGTH")
    [[ -n "$VALID_TO" ]]      && args+=(--valid-to   "$VALID_TO")
    [[ -n "$VALID_FROM" ]]    && args+=(--valid-from  "$VALID_FROM")
    [[ -n "$CERT_PROFILE" ]]  && args+=(--certificate-profile "$CERT_PROFILE")
    # Without this guard `printf '%s\n'` would still print one empty line,
    # which mapfile turns into an empty argument for acme.sh -> "Unknown parameter:"
    [[ ${#args[@]} -eq 0 ]] && return 0
    printf '%s\n' "${args[@]}"
}

# Directory acme.sh stores this certificate in. Two things decide it, and the
# wrapper got both wrong before: it is $CERT_HOME (not /root/.acme.sh, which is
# only the default when --cert-home is absent), and the _ecc suffix depends on
# the *key length* alone - acme.sh appends it when --keylength is an EC value
# and uses the plain name for RSA values and for a missing --keylength, because
# _isEccKey "" is false ([acme.sh] _initpath / _isEccKey). The key itself still
# defaults to ec-256 in that case, so "no -k" means an ECC cert in a plain dir.
cert_dir_for() {
    case "$KEYLENGTH" in
        1024|2048|3072|4096|8192)    printf '%s' "$CERT_HOME/$1" ;;
        *)                           printf '%s' "$CERT_HOME/${1}_ecc" ;;
    esac
}

# The directory the *other* key type would use, for a helpful note
cert_dir_alt() {
    case "$KEYLENGTH" in
        1024|2048|3072|4096|8192)    printf '%s' "$CERT_HOME/${1}_ecc" ;;
        *)                           printf '%s' "$CERT_HOME/$1" ;;
    esac
}

# Check whether a cert already exists for main_domain.
# If yes: prompt the user to renew or start fresh.
#   fresh -> deletes the directory acme.sh would write to
#   renew -> sets global RENEW_CMD="--force"
RENEW_CMD=""
check_renew() {
    local main_domain="$1" dir alt
    RENEW_CMD=""
    [[ -z "$main_domain" || -z "$CERT_HOME" ]] && return 0

    dir="$(cert_dir_for "$main_domain")"
    alt="$(cert_dir_alt "$main_domain")"

    if [[ ! -d "$dir" ]]; then
        if [[ -d "$alt" ]]; then
            echo ""
            echo "Note: '$main_domain' already has a certificate with the other key type"
            echo "      ($alt)."
            echo "      This run writes to $dir and leaves that one untouched."
        fi
        return 0
    fi

    echo ""
    echo "Certificate for '${main_domain}' already exists (${dir})."
    if [[ ! -t 0 ]]; then
        echo "  Non-interactive run - renewing it."
        RENEW_CMD="--force"
        return 0
    fi
    echo "  [r] Renew  - update the existing certificate"
    echo "  [f] Fresh  - delete the existing cert and start over"
    read -r -p "  Choice [r/f, default: r]: " _choice
    echo ""
    case "$_choice" in
        f|F)
            if [[ "$dir" != "$CERT_HOME/"?* ]]; then
                echo "Refusing to delete '$dir': not below $CERT_HOME" >&2
                return 1
            fi
            echo "Removing ${dir} ..."
            rm -rf "${dir}"
            RENEW_CMD=""
            ;;
        *)
            RENEW_CMD="--force"
            ;;
    esac
    return 0
}

# CA side hiccups - "502 Bad Gateway", "Could not get nonce" - are common enough
# that a whole run dies on them. acme.sh already retries a missing nonce 20 times
# internally; this catches the case where the CA stays broken for longer.
# Only transient errors are repeated: a failed validation must not be retried,
# it would just burn the CA rate limit.
_TRANSIENT_RE='502 Bad Gateway|503 Service|504 Gateway|Bad Gateway|Service Unavailable|Gateway Time-?out|Could not get nonce|Cannot connect to|Connection reset|Connection refused|Could not resolve host|Temporary failure in name resolution|Operation timed out|timed out|urn:ietf:params:acme:error:serverInternal|internalError'

run_acme() {
    local attempt=0 rc out wait
    out="$(mktemp)" || { echo "Cannot create temp file" >&2; return 1; }
    while :; do
        bash "$ACME_BIN" "$@" 2>&1 | tee "$out"
        rc=${PIPESTATUS[0]}
        (( rc == 0 )) && break
        (( attempt >= RETRIES )) && break
        grep -qEi "$_TRANSIENT_RE" "$out" || break
        attempt=$((attempt + 1))
        wait=$((attempt * 30))
        echo "" >&2
        echo "The CA answered with a temporary error - retry $attempt/$RETRIES in ${wait}s." >&2
        echo "(disable with --retry 0)" >&2
        sleep "$wait"
    done
    rm -f "$out"
    return "$rc"
}

# Issue one certificate from a list of "<type>|<domain>" pairs. acme.sh maps the
# validation methods positionally onto the domains, so a single certificate can
# mix HTTP-01 and DNS-01 entries.
issue_group() {
    local -a pairs=("$@") d_args=() e_args=()
    local p t d main=""

    for p in "${pairs[@]}"; do
        t="${p%%|*}"; d="${p#*|}"
        if [[ "$t" == s ]]; then
            d_args+=(--domain "$d" --standalone)
            [[ -z "$main" ]] && main="$d"
        else
            d_args+=(--domain "*.$d" --dns "$DNS_PROVIDER" --domain "$d" --dns "$DNS_PROVIDER")
            [[ -z "$main" ]] && main="*.$d"
        fi
    done
    mapfile -t e_args < <(build_extra_args)

    # The first domain is the one acme.sh names the cert directory after
    check_renew "$main"

    run_acme "${SERVER_ARGS[@]}" "${NET_ARGS[@]}" --issue "${d_args[@]}" \
        --ecc --cert-home "$CERT_HOME" "${e_args[@]}" $RENEW_CMD
}

# ── Load domain files ─────────────────────────────────────────────────────────

for _f in "${DOMAIN_FILES[@]}"; do
    load_domains_file "$_f"
done

# ── Actions that need no env profile ──────────────────────────────────────────

case "$ACTION" in
    list-envs) list_envs; exit 0 ;;
    list-cas)  list_cas;  exit 0 ;;
    install)
        if [[ -d /root/.acme.sh ]]; then
            echo "ACME.sh is already installed"
            exit 1
        fi
        wget https://cdn.jsdelivr.net/gh/acmesh-official/acme.sh@master/acme.sh
        bash acme.sh --install
        exit 0
        ;;
    uninstall)
        rm -r /home/ACME
        exit 0
        ;;
esac

# ── Non-issue actions (single env profile) ────────────────────────────────────

if [[ -n "$ACTION" ]]; then
    load_env "$CLI_ENV" || exit 1
    resolve_config      || exit 1
    case "$ACTION" in
        register)
            # Accounts are per-CA, so the account must be created at the selected server
            run_acme "${SERVER_ARGS[@]}" "${NET_ARGS[@]}" --register-account -m "$EMAIL"
            exit $?
            ;;
        revoke)
            run_acme "${SERVER_ARGS[@]}" "${NET_ARGS[@]}" --revoke "$REVOKE_TYPE" \
                --domain "$REVOKE_DOMAIN" --revoke-reason "$REVOKE_REASON"
            exit $?
            ;;
        list-profiles)
            run_acme "${SERVER_ARGS[@]}" "${NET_ARGS[@]}" --list-profiles
            exit $?
            ;;
        custom)
            # shellcheck disable=SC2086  # CUSTOM_ARGS is intentionally word-split
            run_acme "${SERVER_ARGS[@]}" "${NET_ARGS[@]}" $CUSTOM_ARGS
            exit $?
            ;;
    esac
fi

# ── Issue actions ─────────────────────────────────────────────────────────────

if [[ ${#ENTRIES[@]} -eq 0 ]]; then
    show_help
    exit 1
fi

# Collect one per-certificate setting, rejecting conflicting values within a
# certificate (two key lengths in one cert cannot both be honoured).
merge_field() {
    local cur="$1" new="$2" label="$3"
    if [[ -z "$new" ]]; then printf '%s' "$cur"; return 0; fi
    if [[ -n "$cur" && "$cur" != "$new" ]]; then
        echo "  conflicting $label inside one certificate: '$cur' vs '$new'" >&2
        printf '%s' "$cur"
        return 1
    fi
    printf '%s' "$new"
    return 0
}

# Bundle entries into certificates. A blank line in a domain file closes the
# current certificate; -s and -w each form one of their own.
declare -A _block_recs=()
_block_order=()
for _entry in "${ENTRIES[@]}"; do
    _blk="${_entry%%|*}"
    if [[ -z "${_block_recs[$_blk]+x}" ]]; then
        _block_order+=("$_blk")
        _block_recs[$_blk]="${_entry#*|}"
    else
        _block_recs[$_blk]+=$'\n'"${_entry#*|}"
    fi
done

# Resolve every certificate: shared settings + its "<type>|<domain>" pairs
_g_env=(); _g_kl=(); _g_vt=(); _g_vf=(); _g_pairs=(); _g_ip=(); _g_w=(); _g_src=()
_broken=0
for _i in "${!_block_order[@]}"; do
    _blk="${_block_order[$_i]}"
    mapfile -t _recs <<< "${_block_recs[$_blk]}"
    b_env=""; b_kl=""; b_vt=""; b_vf=""; b_ip=0; b_w=0; b_pairs=""; b_ok=1

    for _r in "${_recs[@]}"; do
        IFS='|' read -r r_type r_env r_domain r_kl r_vt r_vf <<< "$_r"
        [[ "$r_env" == "@CLI@" ]] && r_env="$CLI_ENV"
        if [[ -n "$r_env" ]]; then
            # Resolve names and numbers to the profile name, so that "prod" and
            # "2" pointing at the same file do not look like a conflict.
            if r_file="$(env_file_for "$r_env")"; then
                r_env="$(env_name_of "$r_file")"
            else
                b_ok=0
                continue
            fi
        fi
        b_env="$(merge_field "$b_env" "$r_env" "env")"          || b_ok=0
        b_kl="$(merge_field "$b_kl" "$r_kl" "keylength")"       || b_ok=0
        b_vt="$(merge_field "$b_vt" "$r_vt" "valid-to")"        || b_ok=0
        b_vf="$(merge_field "$b_vf" "$r_vf" "valid-from")"      || b_ok=0
        [[ "$r_type" == w ]] && b_w=1
        is_ip "$r_domain" && b_ip=1
        b_pairs+="${b_pairs:+ }$r_type|$r_domain"
    done

    if (( ! b_ok )); then
        echo "  ...in certificate $((_i + 1)) ($_blk)" >&2
        _broken=1
    fi
    _g_env[_i]="$b_env"; _g_kl[_i]="$b_kl"; _g_vt[_i]="$b_vt"; _g_vf[_i]="$b_vf"
    _g_pairs[_i]="$b_pairs"; _g_ip[_i]="$b_ip"; _g_w[_i]="$b_w"
    case "$_blk" in
        @CLI-S@) _g_src[_i]="-s flags" ;;
        @CLI-W@) _g_src[_i]="-w flags" ;;
        *)       _g_src[_i]="$_blk" ;;
    esac
done
(( _broken )) && exit 1

# ── Certificate selection (--group / --list-groups) ───────────────────────────

# Human readable domain list of one certificate: "a.com *.b.com+b.com"
group_domains() {
    local pairs="$1" p out=""
    for p in $pairs; do
        if [[ "${p%%|*}" == w ]]; then
            out+="${out:+ }*.${p#*|}+${p#*|}"
        else
            out+="${out:+ }${p#*|}"
        fi
    done
    printf '%s' "$out"
}

list_groups() {
    printf "%-4s %-14s %-26s %s\n" "#" "env" "source" "domains"
    printf -- "------------------------------------------------------------------------------\n"
    local i
    for i in "${!_block_order[@]}"; do
        printf "%-4s %-14s %-26s %s\n" "$((i + 1))" "${_g_env[$i]:-.env}" \
            "${_g_src[$i]}" "$(group_domains "${_g_pairs[$i]}")"
    done
    echo ""
    echo "Issue a single one with:  -g <#>   (repeatable, or comma separated,"
    echo "and a domain name works as a selector too)"
    return 0
}

if [[ "$ACTION" == "list-groups" ]]; then
    list_groups
    exit 0
fi

# Translate --group selectors into indices
_selected=()
if (( ${#SELECT_GROUPS[@]} > 0 )); then
    for _sel in "${SELECT_GROUPS[@]}"; do
        _hit=0
        if [[ "$_sel" =~ ^[0-9]+$ ]]; then
            if (( _sel >= 1 && _sel <= ${#_block_order[@]} )); then
                _selected+=("$((_sel - 1))")
                _hit=1
            fi
        else
            for _i in "${!_block_order[@]}"; do
                for _p in ${_g_pairs[$_i]}; do
                    if [[ "${_p#*|}" == "$_sel" ]]; then
                        _selected+=("$_i")
                        _hit=1
                        break
                    fi
                done
            done
        fi
        if (( ! _hit )); then
            echo "No certificate matches --group '$_sel'. Try --list-groups." >&2
            exit 1
        fi
    done
else
    for _i in "${!_block_order[@]}"; do _selected+=("$_i"); done
fi

# ── Issue ─────────────────────────────────────────────────────────────────────

_failed=0
_done=""
for _i in "${_selected[@]}"; do
    # --group may name the same certificate twice
    [[ " $_done " == *" $_i "* ]] && continue
    _done+=" $_i"

    g_env="${_g_env[$_i]}"; g_kl="${_g_kl[$_i]}"
    g_vt="${_g_vt[$_i]}";   g_vf="${_g_vf[$_i]}"
    g_ip="${_g_ip[$_i]}";   g_w="${_g_w[$_i]}"
    read -r -a g_pairs <<< "${_g_pairs[$_i]}"

    # Each certificate runs in a subshell, so credentials from one env profile
    # can never leak into the next one.
    (
        load_env "$g_env" || exit 1

        # Fields from the domain file are the most specific setting there is
        [[ -n "$g_kl" ]] && KEYLENGTH="$g_kl"
        [[ -n "$g_vt" ]] && VALID_TO="$g_vt"
        [[ -n "$g_vf" ]] && VALID_FROM="$g_vf"

        # Let's Encrypt + profile 'shortlived' is the only public CA/profile
        # pair that issues IP certificates; an explicit choice always wins.
        [[ "$g_ip" == 1 && -z "$CLI_SERVER" ]] && SERVER="letsencrypt"

        resolve_config        || exit 1

        # 'shortlived' only exists at Let's Encrypt, so never push it onto a CA
        # the user picked explicitly.
        if [[ "$g_ip" == 1 && -z "$CLI_PROFILE" ]]; then
            case "$(ca_id_of "$SERVER")" in
                letsencrypt|letsencrypt_test) CERT_PROFILE="shortlived" ;;
            esac
        fi

        check_validity_limits || exit 1

        if [[ "$g_w" == 1 ]]; then
            if [[ -z "$DNS_PROVIDER" ]]; then
                echo "No DNS provider for the wildcard entries of this certificate." >&2
                echo "  certificate: $(group_domains "${_g_pairs[$_i]}")" >&2
                if [[ -n "$ACTIVE_ENV_FILE" ]]; then
                    echo "  env profile: $ACTIVE_ENV ($ACTIVE_ENV_FILE)" >&2
                else
                    echo "  env profile: none - only the base file $(base_env_file) was read" >&2
                fi
                echo "  Set DNS_PROVIDER=dns_<provider> there (uncommented, no quotes)," >&2
                echo "  or pass --dns <provider> on the command line." >&2
                exit 1
            fi
            setup_dns_env
        fi

        echo ""
        echo "── certificate $((_i + 1))/${#_block_order[@]} · ${ACTIVE_ENV:-.env} ·"\
             "$(ca_id_of "$SERVER") · $(group_domains "${_g_pairs[$_i]}") ──"
        issue_group "${g_pairs[@]}"
    ) || _failed=1
done

exit "$_failed"