#!/usr/bin/env bash
set -Eeuo pipefail
umask 027

export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"

SYNCWERK_USER="${SYNCWERK_USER:-syncwerk}"
CONFIG_DIR="${CONFIG_DIR:-/etc/syncwerk}"
CCNET_CONF_DIR="${CCNET_CONF_DIR:-${CONFIG_DIR}}"
SYNCWERK_CONF_DIR="${SYNCWERK_CONF_DIR:-${CONFIG_DIR}}"
SYNCWERK_CENTRAL_CONF_DIR="${SYNCWERK_CENTRAL_CONF_DIR:-${CONFIG_DIR}}"
RESTAPI_DIR="${RESTAPI_DIR:-/usr/share/python/syncwerk/restapi}"
RESTAPI_PYTHON="${RESTAPI_PYTHON:-/usr/bin/python3}"
RESTAPI_MANAGE="${RESTAPI_MANAGE:-${RESTAPI_DIR}/manage.py}"
RESTAPI_LOG_DIR="${RESTAPI_LOG_DIR:-/var/log/syncwerk}"
OBJECT_STORAGE_PATH="${OBJECT_STORAGE_PATH:-/var/lib/syncwerk}"
RUN_DIR="${RUN_DIR:-/run/syncwerk}"
SYNCWERK_SHARE_DIR="${SYNCWERK_SHARE_DIR:-/usr/share/syncwerk}"
SYNCWERK_PYTHON_ROOT="${SYNCWERK_PYTHON_ROOT:-/usr/share/python/syncwerk}"
LIBEVENT_ROOT="${LIBEVENT_ROOT:-/usr/lib/syncwerk/libevent}"
DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-restapi.settings}"
SYNCWERK_RESTAPI_PYTHONPATH="${SYNCWERK_RESTAPI_PYTHONPATH:-${RESTAPI_DIR}:${SYNCWERK_PYTHON_ROOT}}"
PYTHONNOUSERSITE="${PYTHONNOUSERSITE:-1}"
TIMESTAMP="${TIMESTAMP:-$(date +"%Y-%m-%d_%H-%M-%S")}"
HOSTNAME="${SYNCWERK_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
if [[ -n "${SYNCWERK_NGINX_SERVER_NAMES:-}" ]]; then
    NGINX_SERVER_NAMES="${SYNCWERK_NGINX_SERVER_NAMES}"
else
    NGINX_SERVER_NAMES="${HOSTNAME}"
    for ip in $(hostname -I 2>/dev/null || true); do
        case "$ip" in
            127.*|169.254.*|*:*)
                ;;
            *)
                NGINX_SERVER_NAMES="${NGINX_SERVER_NAMES} ${ip}"
                ;;
        esac
    done
fi

CCNET_DB="${CCNET_DB:-syncwerk-ccnet}"
SERVER_DB="${SERVER_DB:-syncwerk-server}"
RESTAPI_DB="${RESTAPI_DB:-syncwerk-restapi}"
DB_USER="${DB_USER:-syncwerk}"
DB_HOST="${DB_HOST:-127.0.0.1}"
DB_PORT="${DB_PORT:-3306}"
MYSQL_CHARACTER_SET="utf8mb3"
MYSQL_COLLATION="utf8mb3_general_ci"

SYNCWERK_SETUP_BACKUP_DATABASES="${SYNCWERK_SETUP_BACKUP_DATABASES:-1}"
SYNCWERK_SETUP_CONFIGURE_NGINX="${SYNCWERK_SETUP_CONFIGURE_NGINX:-1}"
SYNCWERK_SETUP_RESTART_NGINX="${SYNCWERK_SETUP_RESTART_NGINX:-0}"
SYNCWERK_SETUP_ENABLE_SERVICE="${SYNCWERK_SETUP_ENABLE_SERVICE:-0}"
SYNCWERK_SETUP_START_SERVICES="${SYNCWERK_SETUP_START_SERVICES:-0}"
SYNCWERK_SETUP_STOP_SERVICES="${SYNCWERK_SETUP_STOP_SERVICES:-0}"
SYNCWERK_SETUP_CREATE_ADMIN="${SYNCWERK_SETUP_CREATE_ADMIN:-0}"
SYNCWERK_SETUP_CREATE_SUPER_ADMIN="${SYNCWERK_SETUP_CREATE_SUPER_ADMIN:-$SYNCWERK_SETUP_CREATE_ADMIN}"
SYNCWERK_SETUP_MIGRATION_MODE="${SYNCWERK_SETUP_MIGRATION_MODE:-strict}"
SYNCWERK_SETUP_FORCE_MIGRATIONS="${SYNCWERK_SETUP_FORCE_MIGRATIONS:-0}"
SYNCWERK_SETUP_CHOWN_OBJECT_STORAGE="${SYNCWERK_SETUP_CHOWN_OBJECT_STORAGE:-0}"
SYNCWERK_SETUP_RUN_MANAGE_CHECK="${SYNCWERK_SETUP_RUN_MANAGE_CHECK:-1}"
SYNCWERK_SETUP_COLLECTSTATIC="${SYNCWERK_SETUP_COLLECTSTATIC:-1}"
SYNCWERK_SETUP_COMPILEMESSAGES="${SYNCWERK_SETUP_COMPILEMESSAGES:-1}"
SYNCWERK_SETUP_RECONCILE_ADMIN_RBAC="${SYNCWERK_SETUP_RECONCILE_ADMIN_RBAC:-apply}"
SYNCWERK_SETUP_RECONCILE_LICENSE="${SYNCWERK_SETUP_RECONCILE_LICENSE:-check}"
SYNCWERK_SETUP_LICENSE_CANDIDATE="${SYNCWERK_SETUP_LICENSE_CANDIDATE:-}"
SYNCWERK_SETUP_LICENSE_NOTIFY="${SYNCWERK_SETUP_LICENSE_NOTIFY:-0}"
SYNCWERK_SETUP_RECONCILE_STRICT="${SYNCWERK_SETUP_RECONCILE_STRICT:-0}"
SYNCWERK_SETUP_RECONCILE_ONLYOFFICE="${SYNCWERK_SETUP_RECONCILE_ONLYOFFICE:-1}"
SYNCWERK_SETUP_ONLYOFFICE_READY_TIMEOUT="${SYNCWERK_SETUP_ONLYOFFICE_READY_TIMEOUT:-180}"
SYNCWERK_SETUP_ONLYOFFICE_READY_INTERVAL="${SYNCWERK_SETUP_ONLYOFFICE_READY_INTERVAL:-3}"
SYNCWERK_SETUP_ONLYOFFICE_PUBLIC_TLS_TIMEOUT="${SYNCWERK_SETUP_ONLYOFFICE_PUBLIC_TLS_TIMEOUT:-10}"
SYNCWERK_SETUP_ONLYOFFICE_JWT_HEADER="${SYNCWERK_SETUP_ONLYOFFICE_JWT_HEADER:-Authorization}"
SYNCWERK_SETUP_RUNTIME_READY_TIMEOUT="${SYNCWERK_SETUP_RUNTIME_READY_TIMEOUT:-90}"
SYNCWERK_SETUP_RUNTIME_READY_INTERVAL="${SYNCWERK_SETUP_RUNTIME_READY_INTERVAL:-2}"
SYNCWERK_SETUP_PREFER_EXISTING_LETSENCRYPT_CERT="${SYNCWERK_SETUP_PREFER_EXISTING_LETSENCRYPT_CERT:-1}"
SYNCWERK_SETUP_LE_CERT_MIN_VALID_SECONDS="${SYNCWERK_SETUP_LE_CERT_MIN_VALID_SECONDS:-0}"
ACTION_STATE_DIR="${ACTION_STATE_DIR:-${OBJECT_STORAGE_PATH}/admin-action-state}"
ADMIN_FINAL_FILE="${CONFIG_DIR}/admin.txt"
ADMIN_PENDING_FILE="${CONFIG_DIR}/admin.json"

export CONFIG_DIR CCNET_CONF_DIR SYNCWERK_CONF_DIR SYNCWERK_CENTRAL_CONF_DIR
export RESTAPI_DIR RESTAPI_LOG_DIR OBJECT_STORAGE_PATH LIBEVENT_ROOT DJANGO_SETTINGS_MODULE
export PYTHONNOUSERSITE

log() { printf '[syncwerk-admin-setup] %s\n' "$*" >&2; }
fail() { printf '[syncwerk-admin-setup] ERROR: %s\n' "$*" >&2; exit 1; }

on_error() {
    local ec="$1" line_no="$2" cmd="$3"
    printf '[syncwerk-admin-setup] ERROR at line %s: %s exited with %s\n' "$line_no" "$cmd" "$ec" >&2
    exit "$ec"
}
trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR

bool_true() {
    case "${1:-}" in
        1|yes|true|on|y|Y|YES|TRUE|ON) return 0 ;;
        *) return 1 ;;
    esac
}

usage() {
    cat <<'EOF_USAGE'
Usage: syncwerk-server-admin setup [options]
       syncwerk-server-admin reconcile-admin-rbac --check|--apply [--json]
       syncwerk-server-admin reconcile-license --check|--apply [--candidate PATH] [--json]
       syncwerk-server-admin-setup [options]

Options:
  --start                 Start Syncwerk services after setup.
  --no-start              Do not start Syncwerk services. Default for the Trixie port.
  --enable-service        Enable/write systemd service integration.
  --no-enable-service     Do not enable systemd service integration. Default.
  --restart-nginx         Run nginx -t and restart nginx after rendering config.
  --no-restart-nginx      Do not restart nginx. Default.
  --skip-nginx            Do not render nginx config.
  --create-super-admin    Create or reconcile the bootstrap superadmin account. Requires runtime services.
  --no-create-super-admin Do not create or reconcile the bootstrap superadmin account. Default.
  --create-admin          Compatibility alias for --create-super-admin.
  --no-create-admin       Compatibility alias for --no-create-super-admin.
  --migration-mode=MODE   strict | legacy-fake | skip. Default: strict.
  --force-migrations      Run Django migrations even if /tmp/syncwerk_restapi_sql.md5 matches.
  --reconcile-admin-rbac=MODE
                          apply | check | skip. Default: apply.
  --reconcile-license=MODE
                          check | apply | skip. Default: check.
  --license-candidate=PATH
                          Candidate authorization.key used only with --reconcile-license=apply.
  -h, --help              Show this help.

Important environment switches:
  SYNCWERK_SETUP_START_SERVICES=0|1
  SYNCWERK_SETUP_ENABLE_SERVICE=0|1
  SYNCWERK_SETUP_RESTART_NGINX=0|1
  SYNCWERK_SETUP_CREATE_SUPER_ADMIN=0|1
  SYNCWERK_SETUP_CREATE_ADMIN=0|1        Legacy alias for SYNCWERK_SETUP_CREATE_SUPER_ADMIN.
  SYNCWERK_SETUP_MIGRATION_MODE=strict|legacy-fake|skip
  SYNCWERK_SETUP_RECONCILE_ADMIN_RBAC=apply|check|skip
  SYNCWERK_SETUP_RECONCILE_LICENSE=check|apply|skip
  SYNCWERK_SETUP_LICENSE_CANDIDATE=/root/new.authorization.key
  SYNCWERK_SETUP_LICENSE_NOTIFY=0|1
  SYNCWERK_SETUP_RECONCILE_STRICT=0|1
  SYNCWERK_SETUP_RECONCILE_ONLYOFFICE=0|1
  SYNCWERK_SETUP_RUNTIME_READY_TIMEOUT=90
  SYNCWERK_SETUP_RUNTIME_READY_INTERVAL=2
  SYNCWERK_SETUP_PREFER_EXISTING_LETSENCRYPT_CERT=1
  SYNCWERK_SETUP_LE_CERT_MIN_VALID_SECONDS=0
  SYNCWERK_SETUP_COLLECTSTATIC=0|1
  SYNCWERK_SETUP_COMPILEMESSAGES=0|1
  SYNCWERK_RESTAPI_PYTHONPATH=/usr/share/python/syncwerk/restapi:/usr/share/python/syncwerk
  SYNCWERK_NGINX_SERVER_NAMES="fqdn.example 192.0.2.10"
EOF_USAGE
}

reconcile_admin_rbac_usage() {
    cat <<'EOF_USAGE'
Usage: syncwerk-server-admin reconcile-admin-rbac --check|--apply [--json]

Checks or applies legacy admin RBAC reconciliation after a Trixie upgrade.

Options:
  --check     Report missing AdminRole assignments without changing data.
  --apply     Preserve legacy staff administration by assigning missing roles.
  --json      Emit a machine-readable summary without secret material.
  -h, --help  Show this help.

Policy:
  The bootstrap admin from /etc/syncwerk/admin.txt is reconciled as
  superadmin. Active legacy staff users without an explicit AdminRole are
  reconciled as default_admin. Existing explicit roles are left unchanged.
EOF_USAGE
}

reconcile_license_usage() {
    cat <<'EOF_USAGE'
Usage: syncwerk-server-admin reconcile-license --check|--apply [--candidate PATH] [--json]

Checks or applies authorization.key feature-matrix reconciliation.

Options:
  --check            Validate the active license and report missing Trixie features.
  --apply            Install a validated candidate key. Requires --candidate.
  --candidate PATH   Candidate authorization.key for --apply.
  --json             Emit a machine-readable summary without license contents.
  -h, --help         Show this help.

Policy:
  Setup defaults to license check only. Packages never generate customer
  licenses and never replace authorization.key without an explicit candidate.
EOF_USAGE
}

parse_args() {
    if [[ "${1:-}" == "setup" ]]; then
        shift
    fi
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --start) SYNCWERK_SETUP_START_SERVICES=1 ;;
            --no-start) SYNCWERK_SETUP_START_SERVICES=0 ;;
            --enable-service) SYNCWERK_SETUP_ENABLE_SERVICE=1 ;;
            --no-enable-service) SYNCWERK_SETUP_ENABLE_SERVICE=0 ;;
            --restart-nginx) SYNCWERK_SETUP_RESTART_NGINX=1 ;;
            --no-restart-nginx) SYNCWERK_SETUP_RESTART_NGINX=0 ;;
            --skip-nginx) SYNCWERK_SETUP_CONFIGURE_NGINX=0 ;;
            --create-super-admin|--create-admin)
                SYNCWERK_SETUP_CREATE_SUPER_ADMIN=1
                SYNCWERK_SETUP_CREATE_ADMIN=1
                ;;
            --no-create-super-admin|--no-create-admin)
                SYNCWERK_SETUP_CREATE_SUPER_ADMIN=0
                SYNCWERK_SETUP_CREATE_ADMIN=0
                ;;
            --migration-mode=*) SYNCWERK_SETUP_MIGRATION_MODE="${1#*=}" ;;
            --force-migrations) SYNCWERK_SETUP_FORCE_MIGRATIONS=1 ;;
            --reconcile-admin-rbac=*) SYNCWERK_SETUP_RECONCILE_ADMIN_RBAC="${1#*=}" ;;
            --reconcile-license=*) SYNCWERK_SETUP_RECONCILE_LICENSE="${1#*=}" ;;
            --license-candidate=*) SYNCWERK_SETUP_LICENSE_CANDIDATE="${1#*=}" ;;
            -h|--help) usage; exit 0 ;;
            *) fail "unknown argument: $1" ;;
        esac
        shift
    done
    case "$SYNCWERK_SETUP_MIGRATION_MODE" in
        strict|legacy-fake|skip) ;;
        *) fail "invalid migration mode: ${SYNCWERK_SETUP_MIGRATION_MODE}" ;;
    esac
    case "$SYNCWERK_SETUP_RECONCILE_ADMIN_RBAC" in
        apply|check|skip) ;;
        *) fail "invalid admin RBAC reconcile mode: ${SYNCWERK_SETUP_RECONCILE_ADMIN_RBAC}" ;;
    esac
    case "$SYNCWERK_SETUP_RECONCILE_LICENSE" in
        check|apply|skip) ;;
        *) fail "invalid license reconcile mode: ${SYNCWERK_SETUP_RECONCILE_LICENSE}" ;;
    esac
}

require_root() {
    [[ "$(id -u)" -eq 0 ]] || fail "setup must run as root"
}

require_cmd() {
    command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"
}

require_file() {
    [[ -f "$1" ]] || fail "missing required file: $1"
}

syncwerk_group() {
    id -gn "$SYNCWERK_USER"
}

runtime_group() {
    if getent group www-data >/dev/null 2>&1; then
        printf '%s\n' "www-data"
    else
        syncwerk_group
    fi
}

random_alnum() {
    local length="${1:-32}"
    "$RESTAPI_PYTHON" - "$length" <<'PY'
import secrets
import string
import sys
length = int(sys.argv[1])
alphabet = string.ascii_letters + string.digits
print(''.join(secrets.choice(alphabet) for _ in range(length)))
PY
}

random_hex() {
    local length="${1:-40}"
    "$RESTAPI_PYTHON" - "$length" <<'PY'
import secrets
import sys
length = int(sys.argv[1])
print(secrets.token_hex((length + 1) // 2)[:length])
PY
}

random_secret_key() {
    "$RESTAPI_PYTHON" - <<'PY'
import secrets
print(secrets.token_urlsafe(50))
PY
}

export_restapi_pythonpath() {
    export PYTHONPATH="${SYNCWERK_RESTAPI_PYTHONPATH}${PYTHONPATH:+:${PYTHONPATH}}"
}

as_syncwerk() {
    export_restapi_pythonpath
    sudo -E -u "$SYNCWERK_USER" env \
        CONFIG_DIR="$CONFIG_DIR" \
        CCNET_CONF_DIR="$CCNET_CONF_DIR" \
        SYNCWERK_CONF_DIR="$SYNCWERK_CONF_DIR" \
        SYNCWERK_CENTRAL_CONF_DIR="$SYNCWERK_CENTRAL_CONF_DIR" \
        RESTAPI_DIR="$RESTAPI_DIR" \
        RESTAPI_LOG_DIR="$RESTAPI_LOG_DIR" \
        OBJECT_STORAGE_PATH="$OBJECT_STORAGE_PATH" \
        LIBEVENT_ROOT="$LIBEVENT_ROOT" \
        DJANGO_SETTINGS_MODULE="$DJANGO_SETTINGS_MODULE" \
        PYTHONNOUSERSITE="$PYTHONNOUSERSITE" \
        PYTHONPATH="$PYTHONPATH" \
        "$@"
}

run_manage() {
    as_syncwerk "$RESTAPI_PYTHON" "$RESTAPI_MANAGE" "$@"
}

run_manage_package_root() {
    export_restapi_pythonpath
    (
        umask 022
        env \
            CONFIG_DIR="$CONFIG_DIR" \
            CCNET_CONF_DIR="$CCNET_CONF_DIR" \
            SYNCWERK_CONF_DIR="$SYNCWERK_CONF_DIR" \
            SYNCWERK_CENTRAL_CONF_DIR="$SYNCWERK_CENTRAL_CONF_DIR" \
            RESTAPI_DIR="$RESTAPI_DIR" \
            RESTAPI_LOG_DIR="$RESTAPI_LOG_DIR" \
            OBJECT_STORAGE_PATH="$OBJECT_STORAGE_PATH" \
            LIBEVENT_ROOT="$LIBEVENT_ROOT" \
            DJANGO_SETTINGS_MODULE="$DJANGO_SETTINGS_MODULE" \
            PYTHONNOUSERSITE="$PYTHONNOUSERSITE" \
            PYTHONPATH="$PYTHONPATH" \
            "$RESTAPI_PYTHON" "$RESTAPI_MANAGE" "$@"
    )
}

json_field() {
    local json="$1" field="$2"
    "$RESTAPI_PYTHON" - "$json" "$field" <<'PY'
import json
import sys

data = json.loads(sys.argv[1])
value = data
for part in sys.argv[2].split('.'):
    value = value.get(part, {}) if isinstance(value, dict) else {}
if isinstance(value, bool):
    print('true' if value else 'false')
elif value is None:
    print('')
else:
    print(value)
PY
}

redact_license_featurematrix_json() {
    local json="$1"
    "$RESTAPI_PYTHON" - "$json" <<'PY'
import json
import sys

data = json.loads(sys.argv[1])
data.pop('owner_email', None)
print(json.dumps(data, sort_keys=True))
PY
}

write_action_state_json() {
    local name="$1" json="$2" tmp group
    group="$(syncwerk_group)"
    install -d -m 0750 -o "$SYNCWERK_USER" -g "$group" "$ACTION_STATE_DIR"
    tmp="$(mktemp "${ACTION_STATE_DIR}/${name}.XXXXXX")"
    printf '%s\n' "$json" >"$tmp"
    chown "$SYNCWERK_USER:$group" "$tmp"
    chmod 0640 "$tmp"
    mv "$tmp" "${ACTION_STATE_DIR}/${name}.json"
}

notify_license_contact_if_configured() {
    local json="$1" owner_email complete valid
    bool_true "$SYNCWERK_SETUP_LICENSE_NOTIFY" || return 0
    command -v sendmail >/dev/null 2>&1 || {
        log "license notification requested, but sendmail is not available"
        return 0
    }
    valid="$(json_field "$json" valid)"
    complete="$(json_field "$json" complete_featurematrix)"
    owner_email="$(json_field "$json" owner_email)"
    [[ "$valid" == "true" && "$complete" != "true" && -n "$owner_email" ]] || return 0
    {
        printf 'To: %s\n' "$owner_email"
        printf 'Subject: Syncwerk Lizenz muss fuer Trixie aktualisiert werden\n'
        printf '\n'
        printf 'Die aktive Syncwerk-Lizenz ist gueltig, enthaelt aber nicht die vollstaendige Featurematrix der installierten Trixie-Version.\n'
        printf 'Bitte stellen Sie eine aktualisierte Lizenzdatei bereit und pruefen Sie diese vor Aktivierung mit syncwerk-server check-authorization-key.\n'
    } | sendmail -t
    log "sent generic license featurematrix notification to configured license contact"
}

reconcile_admin_rbac_mode() {
    local mode="$1" json_flag="${2:-0}" args=() output
    case "$mode" in
        apply) ;;
        check) args+=(--dry-run) ;;
        skip)
            log "admin RBAC reconcile skipped"
            return 0
            ;;
        *) fail "invalid admin RBAC reconcile mode: ${mode}" ;;
    esac
    args+=(--update-system-roles --assign-existing-users --reconcile-legacy-admin-roles)
    args+=(--bootstrap-admin-file "${CONFIG_DIR}/admin.txt")
    if [[ "$json_flag" == "1" ]]; then
        args+=(--json)
    fi
    output="$(run_manage import_default_rbac_roles "${args[@]}")"
    if [[ "$json_flag" == "1" ]]; then
        printf '%s\n' "$output"
    else
        log "$output"
    fi
}

inspect_license_featurematrix_json() {
    local candidate="${1:-}"
    if [[ -n "$candidate" ]]; then
        run_manage_package_root inspect_license_featurematrix --candidate "$candidate" --json
    else
        run_manage_package_root inspect_license_featurematrix --json
    fi
}

install_license_candidate() {
    local candidate="$1" active_key backup_file group
    [[ -n "$candidate" ]] || fail "license apply requires --candidate"
    [[ -f "$candidate" ]] || fail "license candidate does not exist: ${candidate}"
    syncwerk-server check-authorization-key "$candidate" >/dev/null

    active_key="${CONFIG_DIR}/authorization.key"
    group="$(syncwerk_group)"
    if [[ -f "$active_key" ]]; then
        backup_file="${active_key}.${TIMESTAMP}.bak"
        cp -a "$active_key" "$backup_file"
        chmod 0600 "$backup_file"
        log "backed up active authorization key to ${backup_file}"
    fi
    install -m 0640 -o "$SYNCWERK_USER" -g "$group" "$candidate" "$active_key"
    syncwerk-server check-authorization-key "$active_key" >/dev/null
}

reconcile_license_mode() {
    local mode="$1" candidate="${2:-}" json_flag="${3:-0}" output redacted complete valid
    case "$mode" in
        check)
            output="$(inspect_license_featurematrix_json)"
            ;;
        apply)
            [[ -n "$candidate" ]] || fail "license reconcile apply requires --candidate"
            output="$(inspect_license_featurematrix_json "$candidate")"
            valid="$(json_field "$output" valid)"
            complete="$(json_field "$output" complete_featurematrix)"
            [[ "$valid" == "true" ]] || fail "license candidate is not valid"
            [[ "$complete" == "true" ]] || fail "license candidate does not contain the full Trixie featurematrix"
            install_license_candidate "$candidate"
            output="$(inspect_license_featurematrix_json)"
            ;;
        skip)
            log "license featurematrix reconcile skipped"
            return 0
            ;;
        *) fail "invalid license reconcile mode: ${mode}" ;;
    esac
    redacted="$(redact_license_featurematrix_json "$output")"
    write_action_state_json "license-featurematrix" "$redacted"
    notify_license_contact_if_configured "$output"
    complete="$(json_field "$output" complete_featurematrix)"
    if [[ "$json_flag" == "1" ]]; then
        printf '%s\n' "$redacted"
    elif [[ "$complete" == "true" ]]; then
        log "license featurematrix complete"
    else
        log "license featurematrix incomplete; admin action state written to ${ACTION_STATE_DIR}/license-featurematrix.json"
    fi
}

run_restapi_python() {
    as_syncwerk "$RESTAPI_PYTHON" "$@"
}

mysql_root() {
    mysql --default-character-set=utf8 "$@"
}

mysql_force_db() {
    local db="$1"
    shift
    mysql --default-character-set=utf8 --force "$db" "$@"
}

mysql_quote() {
    local value="$1"
    value="${value//\\/\\\\}"
    value="${value//\'/\\\'}"
    printf "'%s'" "$value"
}

database_exists() {
    local db="$1"
    local quoted_db count
    quoted_db="$(mysql_quote "$db")"
    count="$(mysql_root --batch --skip-column-names -e "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ${quoted_db};" 2>/dev/null | awk 'NR == 1 { print $1 }')"
    [[ "$count" == "1" ]]
}

table_exists() {
    local db="$1" table="$2"
    local quoted_db quoted_table count
    quoted_db="$(mysql_quote "$db")"
    quoted_table="$(mysql_quote "$table")"
    count="$(mysql_root --batch --skip-column-names -e "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ${quoted_db} AND TABLE_NAME = ${quoted_table};" 2>/dev/null | awk 'NR == 1 { print $1 }')"
    [[ "$count" == "1" ]]
}

read_mysql_value() {
    local key="$1"
    awk -F= -v key="$key" '
        $1 == key {
            value=$2
            sub(/^[[:space:]]+/, "", value)
            sub(/[[:space:]]+$/, "", value)
            print value
            exit
        }' "${CONFIG_DIR}/mysql.txt"
}

write_mysql_client_file() {
    local password="$1"
    local tmp
    tmp="$(mktemp "${CONFIG_DIR}/mysql.txt.XXXXXX")"
    cat >"$tmp" <<EOF_MYSQL
[client]
user=${DB_USER}
password=${password}
EOF_MYSQL
    chmod 0600 "$tmp"
    mv "$tmp" "${CONFIG_DIR}/mysql.txt"
}

template_replace() {
    local file="$1"
    shift
    "$RESTAPI_PYTHON" - "$file" "$@" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
pairs = []
for item in sys.argv[2:]:
    key, value = item.split('=', 1)
    pairs.append((key, value))
text = path.read_text(encoding='utf-8')
new = text
for key, value in pairs:
    new = new.replace(key, value)
if new != text:
    path.write_text(new, encoding='utf-8')
PY
}

preflight() {
    require_root
    require_cmd adduser
    require_cmd awk
    require_cmd chmod
    require_cmd chown
    require_cmd date
    require_cmd grep
    require_cmd hostname
    require_cmd install
    require_cmd mysql
    require_cmd mysqladmin
    require_cmd openssl
    require_cmd sed
    require_cmd sudo
    require_cmd "$RESTAPI_PYTHON"
    if bool_true "$SYNCWERK_SETUP_BACKUP_DATABASES"; then
        require_cmd mysqldump
        require_cmd pigz
        require_cmd sha256sum
    fi
    if bool_true "$SYNCWERK_SETUP_CONFIGURE_NGINX"; then
        require_cmd nginx
    fi
    if bool_true "$SYNCWERK_SETUP_COMPILEMESSAGES"; then
        require_cmd msgfmt
    fi
    require_file "$RESTAPI_MANAGE"
    require_file "${RESTAPI_DIR}/sql/latest-restapi.sql"
    require_file "${RESTAPI_DIR}/sql/latest-ccnet.sql"
    require_file "${RESTAPI_DIR}/sql/latest-server.sql"

    "$RESTAPI_PYTHON" - <<'PY'
import importlib.util
import sys
missing = [m for m in ('django', 'MySQLdb', 'drf_yasg', 'six', 'PIL', 'pymemcache') if importlib.util.find_spec(m) is None]
if missing:
    sys.stderr.write('missing Python module(s): %s\n' % ', '.join(missing))
    sys.stderr.write('Install via Debian/Syncwerk packages; setup does not perform Python package bootstrapping or create Python environments.\n')
    sys.exit(1)
PY
}

restapi_command_preflight() {
    require_root
    require_cmd "$RESTAPI_PYTHON"
    require_file "$RESTAPI_MANAGE"
}

database_command_preflight() {
    restapi_command_preflight
    require_cmd mysql
    require_cmd mysqladmin
    require_cmd sudo
    start_database
}

setup_user() {
    if id -u "$SYNCWERK_USER" >/dev/null 2>&1; then
        log "user ${SYNCWERK_USER} exists"
    else
        adduser --system --gecos "syncwerk" --home "$SYNCWERK_SHARE_DIR" "$SYNCWERK_USER"
    fi
}

prepare_runtime_dirs() {
    local group
    local run_group
    group="$(syncwerk_group)"
    run_group="$(runtime_group)"
    install -d -m 0750 "$CONFIG_DIR" "$RESTAPI_LOG_DIR"
    install -d -m 2750 -o "$SYNCWERK_USER" -g "$run_group" "$RUN_DIR"
    install -d -m 0711 "$OBJECT_STORAGE_PATH"
    install -d -m 0750 "${OBJECT_STORAGE_PATH}/template" "${OBJECT_STORAGE_PATH}/thumbnails"
    install -d -m 0755 "${OBJECT_STORAGE_PATH}/avatars"
    install -d -m 0755 "$SYNCWERK_SHARE_DIR"
    touch "${RESTAPI_LOG_DIR}/restapi.log" "${RESTAPI_LOG_DIR}/restapi-proxy.log" "${RESTAPI_LOG_DIR}/webdav.log" "${RESTAPI_LOG_DIR}/webapp.log" "${RESTAPI_LOG_DIR}/server.log"
    chown -R "$SYNCWERK_USER:$group" "$CONFIG_DIR" "$RESTAPI_LOG_DIR"
    chown "$SYNCWERK_USER:$run_group" "$RUN_DIR"
    chown "$SYNCWERK_USER:$group" "$OBJECT_STORAGE_PATH" "${OBJECT_STORAGE_PATH}/template" "${OBJECT_STORAGE_PATH}/avatars" "${OBJECT_STORAGE_PATH}/thumbnails"
    chmod 0711 "$OBJECT_STORAGE_PATH"
    chmod 0755 "${OBJECT_STORAGE_PATH}/avatars"
    chmod 0750 "$CONFIG_DIR" "$RESTAPI_LOG_DIR"
    chmod 2750 "$RUN_DIR"
    chmod 0640 "${RESTAPI_LOG_DIR}"/*.log
}

stop_services_safe() {
    if ! bool_true "$SYNCWERK_SETUP_STOP_SERVICES"; then
        return 0
    fi
    log "stopping existing Syncwerk services if present"
    systemctl stop syncwerk-server.service >/dev/null 2>&1 || true
    service syncwerk-server stop >/dev/null 2>&1 || true
    if command -v syncwerk-server >/dev/null 2>&1; then
        syncwerk-server stop >/dev/null 2>&1 || true
    fi
}

start_database() {
    if mysqladmin ping --silent >/dev/null 2>&1; then
        return 0
    fi
    log "starting database service"
    systemctl start mariadb.service >/dev/null 2>&1 || systemctl start mysql.service >/dev/null 2>&1 || service mariadb start >/dev/null 2>&1 || service mysql start >/dev/null 2>&1 || true
    mysqladmin ping --silent >/dev/null 2>&1 || fail "MariaDB/MySQL is not reachable via mysqladmin ping"
}

create_or_update_databases() {
    local password
    if [[ -f "${CONFIG_DIR}/mysql.txt" ]]; then
        password="$(read_mysql_value password)"
        [[ -n "$password" ]] || fail "${CONFIG_DIR}/mysql.txt exists but password is empty"
    else
        password="$(random_alnum 28)"
    fi

    mysql_root <<EOF_SQL
CREATE DATABASE IF NOT EXISTS \`${CCNET_DB}\` CHARACTER SET ${MYSQL_CHARACTER_SET} COLLATE ${MYSQL_COLLATION};
CREATE DATABASE IF NOT EXISTS \`${SERVER_DB}\` CHARACTER SET ${MYSQL_CHARACTER_SET} COLLATE ${MYSQL_COLLATION};
CREATE DATABASE IF NOT EXISTS \`${RESTAPI_DB}\` CHARACTER SET ${MYSQL_CHARACTER_SET} COLLATE ${MYSQL_COLLATION};
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${password}';
ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${password}';
GRANT ALL PRIVILEGES ON \`${CCNET_DB}\`.* TO '${DB_USER}'@'localhost';
GRANT ALL PRIVILEGES ON \`${SERVER_DB}\`.* TO '${DB_USER}'@'localhost';
GRANT ALL PRIVILEGES ON \`${RESTAPI_DB}\`.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
EOF_SQL
    write_mysql_client_file "$password"
    chown "$SYNCWERK_USER:$(syncwerk_group)" "${CONFIG_DIR}/mysql.txt"
    chmod 0600 "${CONFIG_DIR}/mysql.txt"
}

backup_databases_if_existing() {
    if ! bool_true "$SYNCWERK_SETUP_BACKUP_DATABASES"; then
        log "database backup disabled"
        return 0
    fi
    if database_exists "$CCNET_DB" && database_exists "$SERVER_DB" && database_exists "$RESTAPI_DB"; then
        local backup_dir backup_file
        backup_dir="${OBJECT_STORAGE_PATH}/backup"
        backup_file="${backup_dir}/${TIMESTAMP}_syncwerk-databases.sql.gz"
        install -d -m 0750 "$backup_dir"
        log "backing up existing databases to ${backup_file}"
        mysqldump --single-transaction --routines --events --databases "$CCNET_DB" "$SERVER_DB" "$RESTAPI_DB" | pigz >"$backup_file"
        sha256sum "$backup_file" >"${backup_file}.sha256.txt"
    else
        log "one or more Syncwerk databases do not exist yet; skipping database backup"
    fi
}

render_ccnet_conf() {
    local file server_id password current_id backup_file
    file="${CONFIG_DIR}/ccnet.conf"
    require_file "$file"
    password="$(read_mysql_value password)"
    if grep -q 'SERVERID\|HOSTNAME\|DBUSER\|DBPASS\|DBNAME' "$file"; then
        server_id="$(random_hex 40)"
        template_replace "$file" \
            "SERVERID=${server_id}" \
            "HOSTNAME=${HOSTNAME}" \
            "DBUSER=${DB_USER}" \
            "DBPASS=${password}" \
            "DBNAME=${CCNET_DB}"
    else
        current_id="$(awk -F= '/^[[:space:]]*ID[[:space:]]*=/{gsub(/[[:space:]]/, "", $2); print $2; exit}' "$file")"
        if [[ "$current_id" =~ ^[[:xdigit:]]{40}$ ]]; then
            log "${file} contains no setup placeholders and has a valid ID; leaving unchanged"
        else
            backup_file="${file}.${TIMESTAMP}.bak"
            cp -a "$file" "$backup_file"
            server_id="$(random_hex 40)"
            "$RESTAPI_PYTHON" - "$file" "$server_id" <<'PY'
import pathlib
import re
import sys

path = pathlib.Path(sys.argv[1])
server_id = sys.argv[2]
text = path.read_text()
text = re.sub(r'(?m)^([ \t]*ID[ \t]*=[ \t]*).*$',
              r'\g<1>' + server_id,
              text,
              count=1)
path.write_text(text)
PY
            log "replaced invalid ccnet ID in ${file}; backup: ${backup_file}"
        fi
    fi
}

render_server_conf() {
    local file password
    file="${CONFIG_DIR}/server.conf"
    require_file "$file"
    password="$(read_mysql_value password)"
    if grep -q 'DBUSER\|DBPASS\|DBNAME' "$file"; then
        template_replace "$file" \
            "DBUSER=${DB_USER}" \
            "DBPASS=${password}" \
            "DBNAME=${SERVER_DB}"
    else
        log "${file} contains no setup placeholders; leaving unchanged"
    fi
}

render_restapi_settings() {
    local file secret password
    file="${CONFIG_DIR}/restapi_settings.py"
    require_file "$file"
    password="$(read_mysql_value password)"
    if grep -q 'SECRETKEY\|APIDBNAME\|CCNETDBNAME\|SERVERDBNAME\|DBUSER\|DBPASS\|HOSTNAME' "$file"; then
        secret="$(random_secret_key)"
        template_replace "$file" \
            "SECRETKEY=${secret}" \
            "APIDBNAME=${RESTAPI_DB}" \
            "CCNETDBNAME=${CCNET_DB}" \
            "SERVERDBNAME=${SERVER_DB}" \
            "DBUSER=${DB_USER}" \
            "DBPASS=${password}" \
            "HOSTNAME=${HOSTNAME}"
    else
        log "${file} contains no setup placeholders; leaving unchanged"
    fi
    if grep -q 'default_admin' "$file"; then
        sed -i 's/default_admin/superadmin/g' "$file"
    fi
    grep -q 'sql_mode=STRICT_TRANS_TABLES' "$file" || sed -i 's/storage_engine=INNODB/storage_engine=INNODB, sql_mode=STRICT_TRANS_TABLES/' "$file"
}

setup_my_key_peer() {
    if [[ ! -f "${CONFIG_DIR}/mykey.peer" ]]; then
        openssl genrsa -out "${CONFIG_DIR}/mykey.peer" 2048
    fi
    chown "$SYNCWERK_USER:$(syncwerk_group)" "${CONFIG_DIR}/mykey.peer"
    chmod 0600 "${CONFIG_DIR}/mykey.peer"
}

setup_gunicorn_conf() {
    if [[ -f "${CONFIG_DIR}/gunicorn.conf" ]]; then
        if ! grep -Eq '^[[:space:]]*umask[[:space:]]*=' "${CONFIG_DIR}/gunicorn.conf"; then
            local backup_file
            backup_file="${CONFIG_DIR}/gunicorn.conf.${TIMESTAMP}.bak"
            cp -a "${CONFIG_DIR}/gunicorn.conf" "$backup_file"
            printf '\numask = 0o007\n' >>"${CONFIG_DIR}/gunicorn.conf"
            chown "$SYNCWERK_USER:$(syncwerk_group)" "${CONFIG_DIR}/gunicorn.conf"
            chmod 0640 "${CONFIG_DIR}/gunicorn.conf"
            log "added restrictive gunicorn socket umask to ${CONFIG_DIR}/gunicorn.conf; backup: ${backup_file}"
        fi
        log "${CONFIG_DIR}/gunicorn.conf exists; leaving unchanged"
        return 0
    fi
    cat >"${CONFIG_DIR}/gunicorn.conf" <<'EOF_GUNICORN'
import multiprocessing

daemon = False
workers = multiprocessing.cpu_count()
threads = 5
timeout = 1200
pid = "/run/syncwerk/restapi.pid"
bind = "unix:/run/syncwerk/restapi.sock"
umask = 0o007
EOF_GUNICORN
    chown "$SYNCWERK_USER:$(syncwerk_group)" "${CONFIG_DIR}/gunicorn.conf"
    chmod 0640 "${CONFIG_DIR}/gunicorn.conf"
}

certificate_matches_hostname() {
    local cert_path="$1"
    local expected_name="$2"
    "$RESTAPI_PYTHON" - "$cert_path" "$expected_name" <<'PY_CERT_MATCH'
import ssl
import sys

cert_path, expected_name = sys.argv[1], sys.argv[2]

def dnsname_match(pattern, hostname):
    pattern = pattern.rstrip(".").lower()
    hostname = hostname.rstrip(".").lower()
    if "*" not in pattern:
        return pattern == hostname

    pattern_parts = pattern.split(".")
    hostname_parts = hostname.split(".")
    if len(pattern_parts) != len(hostname_parts):
        return False
    if pattern_parts[0] != "*":
        return False
    if len(pattern_parts) < 3:
        return False
    return pattern_parts[1:] == hostname_parts[1:]

try:
    decoded = ssl._ssl._test_decode_cert(cert_path)
    san_dns_names = [
        value
        for kind, value in decoded.get("subjectAltName", ())
        if kind == "DNS"
    ]
    candidate_names = san_dns_names
    if not candidate_names:
        candidate_names = [
            value
            for subject_part in decoded.get("subject", ())
            for key, value in subject_part
            if key == "commonName"
        ]
    if not any(dnsname_match(candidate, expected_name) for candidate in candidate_names):
        raise ValueError("certificate does not match expected hostname")
except Exception:
    raise SystemExit(1)
raise SystemExit(0)
PY_CERT_MATCH
}

try_letsencrypt_certificate_dir() {
    local name
    local live_dir
    local fullchain
    local privkey
    local verbose

    name="$1"
    live_dir="$2"
    verbose="${3:-0}"
    fullchain="${live_dir}/fullchain.pem"
    privkey="${live_dir}/privkey.pem"

    if [[ ! -s "$fullchain" || ! -s "$privkey" ]]; then
        return 1
    fi
    if ! openssl x509 -in "$fullchain" -noout -checkend "$SYNCWERK_SETUP_LE_CERT_MIN_VALID_SECONDS" >/dev/null 2>&1; then
        if bool_true "$verbose"; then
            log "found Let's Encrypt certificate for ${name}, but it is expired or below the configured validity window"
        fi
        return 1
    fi
    if ! certificate_matches_hostname "$fullchain" "$name" >/dev/null 2>&1; then
        if bool_true "$verbose"; then
            log "found Let's Encrypt certificate in ${live_dir}, but it does not match ${name}"
        fi
        return 1
    fi
    printf '%s\t%s\t%s\n' "$name" "$fullchain" "$privkey"
    return 0
}

find_valid_letsencrypt_certificate() {
    local name
    local live_dir
    local candidate

    if ! bool_true "$SYNCWERK_SETUP_PREFER_EXISTING_LETSENCRYPT_CERT"; then
        return 1
    fi

    for name in $NGINX_SERVER_NAMES; do
        case "$name" in
            ""|localhost|127.*|10.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*|192.168.*|*:*|*[!A-Za-z0-9._-]*)
                continue
                ;;
        esac
        live_dir="/etc/letsencrypt/live/${name}"
        if try_letsencrypt_certificate_dir "$name" "$live_dir" 1; then
            return 0
        fi
        for candidate in /etc/letsencrypt/live/*; do
            [[ -d "$candidate" ]] || continue
            [[ "$candidate" == "$live_dir" ]] && continue
            if try_letsencrypt_certificate_dir "$name" "$candidate" 0; then
                return 0
            fi
        done
    done
    return 1
}

ensure_self_signed_nginx_certificate() {
    install -d -m 0755 /etc/nginx/ssl
    if [[ ! -f /etc/nginx/ssl/syncwerk.key ]]; then
        openssl genrsa -out /etc/nginx/ssl/syncwerk.key 4096
        chmod 0600 /etc/nginx/ssl/syncwerk.key
    fi
    if [[ ! -f /etc/nginx/ssl/syncwerk.crt ]]; then
        openssl req -new -x509 -sha256 -days 3650 \
            -key /etc/nginx/ssl/syncwerk.key \
            -out /etc/nginx/ssl/syncwerk.crt \
            -subj "/CN=${HOSTNAME}"
    fi
}

detect_existing_nginx_tls_mode() {
    local nginx_conf="/etc/nginx/conf.d/syncwerk.conf"
    if [[ ! -f "$nginx_conf" ]]; then
        printf 'missing'
        return 0
    fi
    if grep -Eq '^[[:space:]]*ssl_certificate[[:space:]]+/etc/letsencrypt/live/' "$nginx_conf"; then
        printf 'existing-config-letsencrypt'
        return 0
    fi
    if grep -Eq '^[[:space:]]*ssl_certificate[[:space:]]+/etc/nginx/ssl/syncwerk\.crt;' "$nginx_conf"; then
        printf 'existing-config-self-signed'
        return 0
    fi
    printf 'existing-config-custom'
}

setup_ccnet_database_minimal() {
    mysql_root <<EOF_SQL
CREATE TABLE IF NOT EXISTS \`${CCNET_DB}\`.\`EmailUser\` (
  \`id\` int(11) NOT NULL AUTO_INCREMENT,
  \`email\` varchar(255) DEFAULT NULL,
  \`passwd\` varchar(256) DEFAULT NULL,
  \`language\` varchar(255) DEFAULT NULL,
  \`is_staff\` tinyint(1) NOT NULL,
  \`is_active\` tinyint(1) NOT NULL,
  \`ctime\` bigint(20) DEFAULT NULL,
  PRIMARY KEY (\`id\`),
  UNIQUE KEY \`email\` (\`email\`)
) ENGINE=InnoDB DEFAULT CHARSET=${MYSQL_CHARACTER_SET} COLLATE=${MYSQL_COLLATION};
CREATE TABLE IF NOT EXISTS \`${CCNET_DB}\`.\`LDAPUsers\` (
  \`id\` bigint(20) NOT NULL AUTO_INCREMENT,
  \`email\` varchar(255) NOT NULL,
  \`password\` varchar(255) NOT NULL,
  \`language\` varchar(255) DEFAULT NULL,
  \`is_staff\` tinyint(1) NOT NULL,
  \`is_active\` tinyint(1) NOT NULL,
  \`ctime\` bigint(20) DEFAULT NULL,
  \`extra_attrs\` text DEFAULT NULL,
  \`reference_id\` varchar(255) DEFAULT NULL,
  PRIMARY KEY (\`id\`),
  UNIQUE KEY \`email\` (\`email\`),
  UNIQUE KEY \`reference_id\` (\`reference_id\`)
) ENGINE=InnoDB DEFAULT CHARSET=${MYSQL_CHARACTER_SET} COLLATE=${MYSQL_COLLATION};
EOF_SQL
}

normalize_ccnet_auth_collations() {
    log "normalizing ccnet auth table collations to ${MYSQL_CHARACTER_SET}/${MYSQL_COLLATION}"
    mysql_root <<EOF_SQL
ALTER TABLE \`${CCNET_DB}\`.\`EmailUser\` CONVERT TO CHARACTER SET ${MYSQL_CHARACTER_SET} COLLATE ${MYSQL_COLLATION};
ALTER TABLE \`${CCNET_DB}\`.\`LDAPUsers\` CONVERT TO CHARACTER SET ${MYSQL_CHARACTER_SET} COLLATE ${MYSQL_COLLATION};
EOF_SQL
    if table_exists "$CCNET_DB" "UserRole"; then
        mysql_root <<EOF_SQL
ALTER TABLE \`${CCNET_DB}\`.\`UserRole\` CONVERT TO CHARACTER SET ${MYSQL_CHARACTER_SET} COLLATE ${MYSQL_COLLATION};
EOF_SQL
    fi
}

setup_nginx() {
    if ! bool_true "$SYNCWERK_SETUP_CONFIGURE_NGINX"; then
        log "nginx setup disabled"
        return 0
    fi
    install -d -m 0755 /etc/nginx/conf.d /etc/nginx/ssl
    if [[ ! -f /etc/nginx/conf.d/syncwerk.conf ]]; then
        local tls_mode="self-signed"
        local ssl_certificate="/etc/nginx/ssl/syncwerk.crt"
        local ssl_certificate_key="/etc/nginx/ssl/syncwerk.key"
        local le_match=""
        local le_name=""

        if le_match="$(find_valid_letsencrypt_certificate)"; then
            IFS=$'\t' read -r le_name ssl_certificate ssl_certificate_key <<<"$le_match"
            tls_mode="existing-letsencrypt"
            log "using existing Let's Encrypt certificate for ${le_name}; nginx TLS mode=${tls_mode}"
        else
            ensure_self_signed_nginx_certificate
            log "no valid existing Let's Encrypt certificate found for ${HOSTNAME}; nginx TLS mode=${tls_mode}"
        fi
        cat > /etc/nginx/conf.d/syncwerk.conf <<EOF_NGINX
upstream syncwerk-server-restapi {
    server unix:/run/syncwerk/restapi.sock fail_timeout=0;
}

server {
    listen 80;
    listen [::]:80;
    server_name ${NGINX_SERVER_NAMES};

    # syncwerk-acme-challenge: allow Let's Encrypt HTTP-01 before HTTPS redirect
    location ^~ /.well-known/acme-challenge/ {
        root /var/lib/syncwerk/acme-challenges;
        default_type text/plain;
        try_files \$uri =404;
    }

    location / {
        return 301 https://\$host\$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name ${NGINX_SERVER_NAMES};

    ssl_certificate ${ssl_certificate};
    ssl_certificate_key ${ssl_certificate_key};

    client_max_body_size 0;
    proxy_set_header X-Real-IP \$remote_addr;
    proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto \$scheme;
    proxy_max_temp_file_size 0;

    # syncwerk-acme-challenge: also serve HTTP-01 files on HTTPS if a CA follows redirects
    location ^~ /.well-known/acme-challenge/ {
        root /var/lib/syncwerk/acme-challenges;
        default_type text/plain;
        try_files \$uri =404;
    }

    # syncwerk-dotfile-hardening: never serve SCM or dotfile paths via SPA fallback
    location ~ /\\.(?!well-known/acme-challenge(?:/|$)) {
        return 404;
        access_log off;
        log_not_found off;
    }

    location / {
        try_files \$uri \$uri/ /index.html;
        root /usr/share/syncwerk/webapp/;
        access_log ${RESTAPI_LOG_DIR}/webapp.log;
        error_log ${RESTAPI_LOG_DIR}/webapp.log;
    }

    location /notification/list {
        return 301 /notifications;
    }

    location /seafhttp {
        rewrite ^/seafhttp(.*)\$ \$1 break;
        proxy_pass http://127.0.0.1:8082;
        client_max_body_size 0;
        proxy_set_header Host \$http_host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_connect_timeout 36000s;
        proxy_read_timeout 36000s;
        proxy_send_timeout 36000s;
        proxy_request_buffering off;
    }

    location /api2 {
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Host \$http_host;
        proxy_redirect off;
        proxy_pass http://syncwerk-server-restapi;
        client_max_body_size 0;
        access_log ${RESTAPI_LOG_DIR}/restapi-proxy.log;
        error_log ${RESTAPI_LOG_DIR}/restapi-proxy.log;
    }

    location /api/v2.1 {
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Host \$http_host;
        proxy_redirect off;
        proxy_pass http://syncwerk-server-restapi;
        client_max_body_size 0;
        proxy_connect_timeout 120s;
        proxy_read_timeout 120s;
        proxy_send_timeout 120s;
        access_log ${RESTAPI_LOG_DIR}/restapi-proxy.log;
        error_log ${RESTAPI_LOG_DIR}/restapi-proxy.log;
    }

    location /api3 {
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Host \$http_host;
        proxy_redirect off;
        proxy_pass http://syncwerk-server-restapi;
        client_max_body_size 0;
        proxy_connect_timeout 120s;
        proxy_read_timeout 120s;
        proxy_send_timeout 120s;
        access_log ${RESTAPI_LOG_DIR}/restapi-proxy.log;
        error_log ${RESTAPI_LOG_DIR}/restapi-proxy.log;
    }

    location /client-login {
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Host \$http_host;
        proxy_redirect off;
        proxy_pass http://syncwerk-server-restapi;
        client_max_body_size 0;
        access_log ${RESTAPI_LOG_DIR}/restapi-proxy.log;
        error_log ${RESTAPI_LOG_DIR}/restapi-proxy.log;
    }

    location /media {
        root /usr/share/python/syncwerk/restapi/;
        access_log ${RESTAPI_LOG_DIR}/webapp.log;
        error_log ${RESTAPI_LOG_DIR}/webapp.log;
    }

    location /webdav {
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Host \$http_host;
        proxy_redirect off;
        proxy_pass http://127.0.0.1:8080;
        proxy_request_buffering off;
        proxy_buffering off;
        client_max_body_size 0;
        proxy_connect_timeout 120s;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        access_log ${RESTAPI_LOG_DIR}/webdav.log;
        error_log ${RESTAPI_LOG_DIR}/webdav.log;
    }
}
EOF_NGINX
        log "rendered /etc/nginx/conf.d/syncwerk.conf; nginx TLS mode=${tls_mode}"
    else
        log "/etc/nginx/conf.d/syncwerk.conf exists; leaving unchanged"
        log "nginx TLS mode=$(detect_existing_nginx_tls_mode)"
    fi
    if bool_true "$SYNCWERK_SETUP_RESTART_NGINX"; then
        nginx -t
        systemctl restart nginx.service >/dev/null 2>&1 || service nginx restart
    else
        log "nginx restart disabled; run 'nginx -t' manually if required"
    fi
}

legacy_schema_fixes() {
    log "applying legacy schema compatibility fixes"
    mysql_root --force <<EOF_SQL >/dev/null 2>&1 || true
RENAME TABLE \`${RESTAPI_DB}\`.\`institutions_institution\` TO \`${RESTAPI_DB}\`.\`tenants_tenant\`;
RENAME TABLE \`${RESTAPI_DB}\`.\`institutions_institutionadmin\` TO \`${RESTAPI_DB}\`.\`tenants_tenantadmin\`;
RENAME TABLE \`${RESTAPI_DB}\`.\`institutions_institutionquota\` TO \`${RESTAPI_DB}\`.\`tenants_tenantquota\`;
ALTER TABLE \`${RESTAPI_DB}\`.\`tenants_tenantadmin\` CHANGE \`institution_id\` \`tenant_id\` INT(11) NOT NULL;
ALTER TABLE \`${RESTAPI_DB}\`.\`tenants_tenantquota\` CHANGE \`institution_id\` \`tenant_id\` INT(11) NOT NULL;
ALTER TABLE \`${RESTAPI_DB}\`.\`profile_profile\` CHANGE \`institution\` \`tenant\` VARCHAR(225) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL;
UPDATE \`${RESTAPI_DB}\`.\`django_migrations\` SET \`app\` = Replace(app, 'institution', 'tenant') WHERE \`app\` like "%institution%";
UPDATE \`${RESTAPI_DB}\`.\`django_migrations\` SET \`name\` = Replace(name, 'institution', 'tenant') WHERE \`name\` like "%institution%";
UPDATE \`${RESTAPI_DB}\`.\`auth_permission\` SET \`name\` = Replace(name, 'institution', 'tenant') WHERE \`name\` like "%institution%";
UPDATE \`${RESTAPI_DB}\`.\`auth_permission\` SET \`codename\` = Replace(codename, 'institution', 'tenant') WHERE \`name\` like "%institution%";
ALTER TABLE \`${SERVER_DB}\`.\`SharedRepo\` ADD \`allow_view_history\` BOOLEAN DEFAULT True;
ALTER TABLE \`${SERVER_DB}\`.\`SharedRepo\` ADD \`allow_view_snapshot\` BOOLEAN DEFAULT False;
ALTER TABLE \`${SERVER_DB}\`.\`SharedRepo\` ADD \`allow_restore_snapshot\` BOOLEAN DEFAULT False;
ALTER TABLE \`${RESTAPI_DB}\`.\`AuditLog\` CHANGE COLUMN \`recepient\` \`recipient\` longtext;
ALTER TABLE \`${CCNET_DB}\`.\`EmailUser\` ADD \`language\` varchar(255) AFTER \`passwd\`;
ALTER TABLE \`${CCNET_DB}\`.\`LDAPUsers\` ADD \`language\` varchar(255) AFTER \`password\`;
ALTER TABLE \`${CCNET_DB}\`.\`LDAPUsers\` ADD \`ctime\` BIGINT AFTER \`is_active\`;
EOF_SQL
    mysql_root <<EOF_SQL >/dev/null 2>&1 || true
INSERT INTO \`${RESTAPI_DB}\`.\`django_site\` (\`id\`, \`domain\`, \`name\`)
VALUES (1, 'example.com', 'example.com')
ON DUPLICATE KEY UPDATE \`domain\` = VALUES(\`domain\`), \`name\` = VALUES(\`name\`);
EOF_SQL
    mysql_root <<EOF_SQL >/dev/null 2>&1 || true
INSERT INTO \`${RESTAPI_DB}\`.\`api3_tokenv2\`
SELECT * FROM \`${RESTAPI_DB}\`.\`api2_tokenv2\` AS \`tmp\`
WHERE NOT EXISTS (
    SELECT * FROM \`${RESTAPI_DB}\`.\`api3_tokenv2\`
    WHERE \`${RESTAPI_DB}\`.\`api3_tokenv2\`.user = \`tmp\`.user
      AND \`${RESTAPI_DB}\`.\`api3_tokenv2\`.device_id = \`tmp\`.device_id
);
EOF_SQL
    mysql_root <<EOF_SQL >/dev/null 2>&1 || true
UPDATE \`${RESTAPI_DB}\`.\`constance_config\`
SET \`value\` = '{"__type__": "default", "__value__": 7}'
WHERE \`key\` = 'LOGIN_REMEMBER_DAYS'
  AND (
      LOWER(TRIM(\`value\`)) IN ('true', '"true"')
      OR \`value\` REGEXP '"__value__"[[:space:]]*:[[:space:]]*true'
  );
EOF_SQL
}

reconcile_legacy_api3_migration_state() {
    log "reconciling legacy api3 migration state"
    mysql_root --force <<EOF_SQL >/dev/null 2>&1 || true
USE \`${RESTAPI_DB}\`;

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0009_auto_20200213_0822', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0008_auto_20200213_0256'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 'AuditLog'
      AND COLUMN_NAME = 'name'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0009_auto_20200213_0822'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0010_auto_20200213_1058', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0009_auto_20200213_0822'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 'AuditLog'
      AND COLUMN_NAME = 'user_id'
      AND DATA_TYPE = 'varchar'
      AND CHARACTER_MAXIMUM_LENGTH >= 255
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0010_auto_20200213_1058'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0011_auto_20200220_0326', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0010_auto_20200213_1058'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'user_id'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'action_type'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'ip_address'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'permissions'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'updated_at'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0011_auto_20200220_0326'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0012_add_audit_log_fulltext_index', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0011_auto_20200220_0326'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'name' AND INDEX_TYPE = 'FULLTEXT'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'folder' AND INDEX_TYPE = 'FULLTEXT'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'sub_folder_file' AND INDEX_TYPE = 'FULLTEXT'
)
AND EXISTS (
    SELECT 1 FROM information_schema.STATISTICS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'recipient' AND INDEX_TYPE = 'FULLTEXT'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0012_add_audit_log_fulltext_index'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0013_auto_20200313_0650', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0012_add_audit_log_fulltext_index'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 'AuditLog'
      AND COLUMN_NAME = 'ip_address'
      AND DATA_TYPE = 'varchar'
      AND CHARACTER_MAXIMUM_LENGTH >= 45
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0013_auto_20200313_0650'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0014_auto_20200313_0820', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0013_auto_20200313_0650'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 'AuditLog'
      AND COLUMN_NAME = 'folder_id'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0014_auto_20200313_0820'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0015_auto_20200409_0824', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0014_auto_20200313_0820'
)
AND EXISTS (
    SELECT 1 FROM information_schema.TABLES
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 'UserActivity'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0015_auto_20200409_0824'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0016_auto_20200416_0500', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0015_auto_20200409_0824'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'device_name'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'UserActivity' AND COLUMN_NAME = 'device_name'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0016_auto_20200416_0500'
);

UPDATE \`AuditLog\`
SET \`device_name\` = 'WebApp'
WHERE \`device_name\` IS NULL;

UPDATE \`UserActivity\`
SET \`device_name\` = 'WebApp'
WHERE \`device_name\` IS NULL;

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0017_migrate_null_device_name', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0016_auto_20200416_0500'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'device_name'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'UserActivity' AND COLUMN_NAME = 'device_name'
)
AND NOT EXISTS (
    SELECT 1 FROM \`AuditLog\` WHERE \`device_name\` IS NULL
)
AND NOT EXISTS (
    SELECT 1 FROM \`UserActivity\` WHERE \`device_name\` IS NULL
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0017_migrate_null_device_name'
);
EOF_SQL
}

reconcile_legacy_api3_meeting_migration_state() {
    log "reconciling legacy api3 meeting migration state"
    mysql_root --force <<EOF_SQL >/dev/null 2>&1 || true
USE \`${RESTAPI_DB}\`;

UPDATE \`EmailChangingRequest\`
SET \`request_token_expire_time\` = '1970-01-01 00:00:00'
WHERE \`request_token_expire_time\` IS NULL
   OR \`request_token_expire_time\` = '';

UPDATE \`BBBPrivateSettings\`
SET \`user_id\` = ''
WHERE \`user_id\` IS NULL;

UPDATE \`MeetingRoomShares\`
SET \`group_id\` = 0
WHERE \`group_id\` IS NULL;

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0018_create_meeting_room_table', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0017_migrate_null_device_name'
)
AND EXISTS (
    SELECT 1 FROM information_schema.TABLES
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'b3_meeting_id'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0018_create_meeting_room_table'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0019_add_column_to_meeting_room', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0018_create_meeting_room_table'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'share_token'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0019_add_column_to_meeting_room'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0020_add_more_info_to_meeting_room', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0019_add_column_to_meeting_room'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'mute_participants_on_join'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'require_mod_approval'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'allow_any_user_start'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'all_users_join_as_mod'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'allow_recording'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'max_number_of_participants'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'welcome_message'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0020_add_more_info_to_meeting_room'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0021_add_private_bbb_server_config', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0020_add_more_info_to_meeting_room'
)
AND EXISTS (
    SELECT 1 FROM information_schema.TABLES
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BBBPrivateSettings'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BBBPrivateSettings' AND COLUMN_NAME = 'bbb_server'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BBBPrivateSettings' AND COLUMN_NAME = 'bbb_secret'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0021_add_private_bbb_server_config'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0022_add_meeting_room_private_share', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0021_add_private_bbb_server_config'
)
AND EXISTS (
    SELECT 1 FROM information_schema.TABLES
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRoomShares'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRoomShares' AND COLUMN_NAME = 'meeting_room_id'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0022_add_meeting_room_private_share'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0023_add_share_to_group_columns_to_meeting_private_share', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0022_add_meeting_room_private_share'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRoomShares' AND COLUMN_NAME = 'group_id'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRoomShares' AND COLUMN_NAME = 'share_type'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0023_add_share_to_group_columns_to_meeting_private_share'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0024_add_meeting_setting_id_to_meeting_rooms', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0023_add_share_to_group_columns_to_meeting_private_share'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'private_setting_id'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0024_add_meeting_setting_id_to_meeting_rooms'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0025_add_option_to_force_user_to_provide_meeting_key_before_joining', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0024_add_meeting_setting_id_to_meeting_rooms'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'require_meeting_password'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0025_add_option_to_force_user_to_provide_meeting_key_before_joining'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0026_manipulate_bbb_private_setting_table', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0025_add_option_to_force_user_to_provide_meeting_key_before_joining'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BBBPrivateSettings' AND COLUMN_NAME = 'setting_name'
)
AND NOT EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BBBPrivateSettings' AND COLUMN_NAME = 'group_id'
)
AND NOT EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BBBPrivateSettings' AND COLUMN_NAME = 'tenant_id'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0026_manipulate_bbb_private_setting_table'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0027_add_profile_setting_with_max_meetings', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0026_manipulate_bbb_private_setting_table'
)
AND EXISTS (
    SELECT 1 FROM information_schema.TABLES
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ProfileSetting'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ProfileSetting' AND COLUMN_NAME = 'max_meetings'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0027_add_profile_setting_with_max_meetings'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0028_add_presentation_file_to_meeting_room', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0027_add_profile_setting_with_max_meetings'
)
AND EXISTS (
    SELECT 1 FROM information_schema.TABLES
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRoomFile'
)
AND NOT EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'presentation_file'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0028_add_presentation_file_to_meeting_room'
);

INSERT INTO \`django_migrations\` (\`app\`, \`name\`, \`applied\`)
SELECT 'api3', '0029_add_multi_file_to_meeting_room', NOW(6)
WHERE EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0028_add_presentation_file_to_meeting_room'
)
AND EXISTS (
    SELECT 1 FROM information_schema.TABLES
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRoomFile'
)
AND EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRoomFile' AND COLUMN_NAME = 'presentation_file'
)
AND NOT EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MeetingRooms' AND COLUMN_NAME = 'presentation_file'
)
AND NOT EXISTS (
    SELECT 1 FROM \`django_migrations\`
    WHERE \`app\` = 'api3' AND \`name\` = '0029_add_multi_file_to_meeting_room'
);
EOF_SQL
}

schema_changed_or_forced() {
    if bool_true "$SYNCWERK_SETUP_FORCE_MIGRATIONS"; then
        return 0
    fi
    if [[ ! -f /tmp/syncwerk_restapi_sql.md5 ]]; then
        return 0
    fi
    ! md5sum --check /tmp/syncwerk_restapi_sql.md5 >/dev/null 2>&1
}

list_migration_apps() {
    run_restapi_python - <<'PY'
import django
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'restapi.settings')
django.setup()
from django.apps import apps
labels = []
for app in apps.get_app_configs():
    mig_dir = os.path.join(app.path, 'migrations')
    if os.path.isdir(mig_dir):
        labels.append(app.label)
for label in sorted(set(labels)):
    print(label)
PY
}

run_django_migrations() {
    if [[ "$SYNCWERK_SETUP_MIGRATION_MODE" == "skip" ]]; then
        log "Django migrations skipped by SYNCWERK_SETUP_MIGRATION_MODE=skip"
        return 0
    fi
    if ! schema_changed_or_forced; then
        log "RESTAPI schema checksum unchanged; skipping Django migrations"
        rm -f /tmp/syncwerk_restapi_sql.md5
        return 0
    fi

    log "running Django migrations mode=${SYNCWERK_SETUP_MIGRATION_MODE}"
    if run_manage migrate --noinput; then
        rm -f /tmp/syncwerk_restapi_sql.md5
        return 0
    fi

    if [[ "$SYNCWERK_SETUP_MIGRATION_MODE" != "legacy-fake" ]]; then
        fail "Django migrations failed in strict mode. Use SYNCWERK_SETUP_MIGRATION_MODE=legacy-fake only for controlled recovery."
    fi

    log "strict migration failed; entering explicit legacy-fake recovery mode"
    local app
    while IFS= read -r app; do
        [[ -n "$app" ]] || continue
        log "migrating app ${app} with fake fallback"
        run_manage migrate "$app" --noinput || run_manage migrate "$app" --fake --noinput
    done < <(list_migration_apps)
    rm -f /tmp/syncwerk_restapi_sql.md5
}

update_database() {
    export_restapi_pythonpath
    log "importing latest RESTAPI SQL schema"
    mysql_force_db "$RESTAPI_DB" < "${RESTAPI_DIR}/sql/latest-restapi.sql"
    legacy_schema_fixes
    reconcile_legacy_api3_migration_state
    reconcile_legacy_api3_meeting_migration_state

    log "running api3 migration pre-pass"
    run_manage migrate api3 --noinput >/dev/null 2>&1 || log "api3 migration pre-pass failed; continuing to full migration path"
    run_django_migrations

    log "importing latest ccnet SQL schema"
    mysql_force_db "$CCNET_DB" < "${RESTAPI_DIR}/sql/latest-ccnet.sql" >/dev/null 2>&1
    log "importing latest server SQL schema"
    mysql_force_db "$SERVER_DB" < "${RESTAPI_DIR}/sql/latest-server.sql" >/dev/null 2>&1
}

migrate_avatars() {
    local target link
    target="${OBJECT_STORAGE_PATH}/avatars"
    link="${RESTAPI_DIR}/media/avatars"
    install -d -m 0755 "$target"
    if [[ -L "$link" ]]; then
        log "avatars symlink already exists"
    else
        if [[ -d "$link" ]]; then
            find "$link" -mindepth 1 -maxdepth 1 -exec mv -t "$target" -- {} + 2>/dev/null || true
            rm -rf "$link"
        fi
        ln -s "$target" "$link"
    fi
    chown -R "$SYNCWERK_USER:$(syncwerk_group)" "$target" "$link"
    chmod 0711 "$OBJECT_STORAGE_PATH"
    chmod 0755 "$target"
    seed_default_avatars "$target"
}

seed_default_avatars() {
    local target group source candidate
    target="$1"
    group="$(syncwerk_group)"
    source=""
    for candidate in \
        "${SYNCWERK_SHARE_DIR}/webapp/assets/images/placeholder-profile.png" \
        "${RESTAPI_DIR}/media/avatars/default.png"
    do
        if [[ -f "$candidate" && "$candidate" != "${target}/default.png" ]]; then
            source="$candidate"
            break
        fi
    done
    if [[ -z "$source" ]]; then
        log "no packaged default avatar source found; skipping default avatar seed"
        return 0
    fi
    install -d -m 0755 -o "$SYNCWERK_USER" -g "$group" "$target" "${target}/groups"
    if [[ ! -f "${target}/default.png" ]]; then
        install -m 0644 -o "$SYNCWERK_USER" -g "$group" "$source" "${target}/default.png"
    fi
    if [[ ! -f "${target}/groups/default.png" ]]; then
        install -m 0644 -o "$SYNCWERK_USER" -g "$group" "$source" "${target}/groups/default.png"
    fi
}

fix_permissions() {
    local group
    local run_group
    group="$(syncwerk_group)"
    run_group="$(runtime_group)"
    prepare_runtime_dirs
    chown -R "$SYNCWERK_USER:$group" "$CONFIG_DIR" "$RESTAPI_LOG_DIR"
    chown "$SYNCWERK_USER:$run_group" "$RUN_DIR"
    chmod 2750 "$RUN_DIR"
    if [[ -d "$SYNCWERK_PYTHON_ROOT" ]]; then
        log "leaving package-managed Python runtime ownership unchanged: ${SYNCWERK_PYTHON_ROOT}"
    fi
    if [[ -d "$SYNCWERK_SHARE_DIR" ]]; then
        log "leaving package-managed web/runtime share ownership unchanged: ${SYNCWERK_SHARE_DIR}"
    fi
    if bool_true "$SYNCWERK_SETUP_CHOWN_OBJECT_STORAGE"; then
        log "recursively fixing object-storage ownership under ${OBJECT_STORAGE_PATH}"
        chown -R "$SYNCWERK_USER:$group" "$OBJECT_STORAGE_PATH"
    fi
    chmod 0711 "$OBJECT_STORAGE_PATH"
    chmod 0755 "${OBJECT_STORAGE_PATH}/avatars"
    seed_default_avatars "${OBJECT_STORAGE_PATH}/avatars"
    chmod 0600 "${CONFIG_DIR}/mysql.txt"
}

update_static_files() {
    if bool_true "$SYNCWERK_SETUP_COLLECTSTATIC"; then
        log "collecting static files as root for package-managed RESTAPI runtime"
        run_manage_package_root collectstatic --no-input --verbosity 2
    else
        log "collectstatic disabled by SYNCWERK_SETUP_COLLECTSTATIC=0"
    fi

    if bool_true "$SYNCWERK_SETUP_COMPILEMESSAGES"; then
        log "compiling translations as root for package-managed RESTAPI runtime"
        run_manage_package_root compilemessages --verbosity 2 --locale en
        run_manage_package_root compilemessages --verbosity 2 --locale de
    else
        log "compilemessages disabled by SYNCWERK_SETUP_COMPILEMESSAGES=0"
    fi
}

manage_check() {
    if bool_true "$SYNCWERK_SETUP_RUN_MANAGE_CHECK"; then
        log "running manage.py check"
        run_manage check
    fi
}

setup_service_integration() {
    if ! bool_true "$SYNCWERK_SETUP_ENABLE_SERVICE"; then
        log "service enable/write disabled"
        return 0
    fi
    if [[ -d /run/systemd/system ]]; then
        cat >/etc/systemd/system/syncwerk-server.service <<'EOF_SYSTEMD'
[Unit]
Description=Syncwerk Server
After=network.target mariadb.service mysql.service

[Service]
Type=simple
ExecStart=/usr/bin/syncwerk-server start --foreground
ExecStop=/usr/bin/syncwerk-server stop

[Install]
WantedBy=multi-user.target
EOF_SYSTEMD
        systemctl daemon-reload
        systemctl enable syncwerk-server.service
    else
        log "systemd is not active; skipping service enable"
    fi
}

start_services_optional() {
    if ! bool_true "$SYNCWERK_SETUP_START_SERVICES"; then
        log "Syncwerk service start disabled"
        return 0
    fi
    setup_service_integration
    systemctl start syncwerk-server.service >/dev/null 2>&1 || service syncwerk-server start >/dev/null 2>&1 || syncwerk-server start
}

syncwerk_runtime_active() {
    if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet syncwerk-server.service; then
        return 0
    fi
    command -v pgrep >/dev/null 2>&1 && pgrep -u "$SYNCWERK_USER" -f 'user-framework|ccnet' >/dev/null 2>&1
}

syncwerk_service_restart_best_effort() {
    if command -v systemctl >/dev/null 2>&1; then
        systemctl restart syncwerk-server.service >/dev/null 2>&1 || true
        return 0
    fi
    service syncwerk-server restart >/dev/null 2>&1 || syncwerk-server restart >/dev/null 2>&1 || true
}

onlyoffice_service_restart_best_effort() {
    if command -v systemctl >/dev/null 2>&1; then
        systemctl restart ds-converter.service ds-docservice.service >/dev/null 2>&1 || true
        return 0
    fi
    service ds-converter restart >/dev/null 2>&1 || true
    service ds-docservice restart >/dev/null 2>&1 || true
}

onlyoffice_documentserver_installed() {
    dpkg-query -W -f='${db:Status-Abbrev}' onlyoffice-documentserver 2>/dev/null | grep -q '^ii'
}

restapi_onlyoffice_enabled() {
    local settings_file="${CONFIG_DIR}/restapi_settings.py"
    [[ -f "$settings_file" ]] || return 1
    grep -Eq '^[[:space:]]*ENABLE_ONLYOFFICE[[:space:]]*=[[:space:]]*True|# Syncwerk ONLYOFFICE managed block begin' "$settings_file"
}

wait_onlyoffice_health() {
    local ds_port="${1:-9090}" timeout="${SYNCWERK_SETUP_ONLYOFFICE_READY_TIMEOUT}" interval="${SYNCWERK_SETUP_ONLYOFFICE_READY_INTERVAL}" elapsed=0
    require_cmd curl
    while [[ "$elapsed" -lt "$timeout" ]]; do
        if curl -fsS --max-time 5 "http://127.0.0.1:${ds_port}/healthcheck" 2>/dev/null | grep -qi 'true'; then
            return 0
        fi
        sleep "$interval"
        elapsed=$((elapsed + interval))
    done
    fail "ONLYOFFICE healthcheck did not become ready within ${timeout}s"
}

public_site_url_from_settings() {
    local settings_file="${CONFIG_DIR}/restapi_settings.py"
    [[ -f "$settings_file" ]] || fail "${settings_file} not found"
    "$RESTAPI_PYTHON" - "$settings_file" <<'PY_SITE_URL'
import ast
import sys

settings_file = sys.argv[1]
values = {}

try:
    with open(settings_file, "r", encoding="utf-8") as handle:
        tree = ast.parse(handle.read(), filename=settings_file)
except (OSError, SyntaxError) as exc:
    raise SystemExit("cannot read public Syncwerk URL from %s: %s" % (settings_file, exc))

for node in tree.body:
    if not isinstance(node, ast.Assign):
        continue
    if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
        continue
    name = node.targets[0].id
    if name not in ("SITE_BASE", "SERVICE_URL", "FILE_SERVER_ROOT"):
        continue
    if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
        values[name] = node.value.value
    elif isinstance(node.value, ast.Str):
        values[name] = node.value.s

url = values.get("SITE_BASE") or values.get("SERVICE_URL") or values.get("FILE_SERVER_ROOT")
if not url:
    raise SystemExit("SITE_BASE, SERVICE_URL or FILE_SERVER_ROOT is required for ONLYOFFICE public TLS validation")
if url.endswith("/seafhttp"):
    url = url[:-len("/seafhttp")]
print(url.rstrip("/"))
PY_SITE_URL
}

validate_onlyoffice_public_tls() {
    local public_url="${1:-}" timeout="${SYNCWERK_SETUP_ONLYOFFICE_PUBLIC_TLS_TIMEOUT}"
    [[ -n "$public_url" ]] || fail "public Syncwerk URL is empty; cannot validate ONLYOFFICE callback TLS"
    "$RESTAPI_PYTHON" - "$public_url" "$timeout" <<'PY_PUBLIC_TLS'
import http.client
import ssl
import sys
import urllib.parse

public_url = sys.argv[1].strip()
timeout = float(sys.argv[2])
parsed = urllib.parse.urlparse(public_url)

if parsed.scheme != "https" or not parsed.hostname:
    raise SystemExit("ONLYOFFICE requires a public https:// Syncwerk URL with a valid host")

port = parsed.port or 443
base_path = parsed.path.rstrip("/")
health_path = base_path + "/onlyofficeds/healthcheck"
context = ssl.create_default_context()
connection = None
try:
    connection = http.client.HTTPSConnection(parsed.hostname, port, timeout=timeout, context=context)
    connection.connect()
    cert = connection.sock.getpeercert()
    if cert.get("subject") and cert.get("subject") == cert.get("issuer"):
        raise SystemExit("public Syncwerk TLS certificate is self-signed; ONLYOFFICE callback/download would fail closed")
    connection.request("GET", health_path, headers={"Host": parsed.netloc})
    response = connection.getresponse()
    body = response.read(128).decode("utf-8", "replace").strip().lower()
except ssl.SSLCertVerificationError as exc:
    raise SystemExit("public ONLYOFFICE healthcheck TLS verification failed: %s" % exc.verify_message)
except OSError as exc:
    raise SystemExit("public ONLYOFFICE healthcheck is not reachable through trusted HTTPS: %s" % exc)
finally:
    if connection is not None:
        connection.close()

if response.status != 200 or "true" not in body:
    raise SystemExit("public ONLYOFFICE healthcheck via trusted HTTPS failed: HTTP %s" % response.status)
PY_PUBLIC_TLS
}

ensure_onlyoffice_jwt_secret_file() {
    local jwt_file="${CONFIG_DIR}/onlyoffice-jwt.secret" tmp
    install -d -m 0750 -o "$SYNCWERK_USER" -g "$(syncwerk_group)" "$CONFIG_DIR"
    if [[ -s "$jwt_file" ]]; then
        chown "$SYNCWERK_USER:$(syncwerk_group)" "$jwt_file"
        chmod 0600 "$jwt_file"
        return 0
    fi
    tmp="$(mktemp "${jwt_file}.XXXXXX")"
    random_hex 64 >"$tmp"
    chown "$SYNCWERK_USER:$(syncwerk_group)" "$tmp"
    chmod 0600 "$tmp"
    mv "$tmp" "$jwt_file"
}

write_onlyoffice_documentserver_jwt_config() {
    local local_json="/etc/onlyoffice/documentserver/local.json"
    local jwt_file="${CONFIG_DIR}/onlyoffice-jwt.secret"
    [[ -f "$local_json" ]] || fail "ONLYOFFICE local.json not found"
    "$RESTAPI_PYTHON" - "$local_json" "$jwt_file" "$SYNCWERK_SETUP_ONLYOFFICE_JWT_HEADER" <<'PY_ONLYOFFICE_LOCAL'
import json
import os
import pathlib
import sys
import tempfile

path = pathlib.Path(sys.argv[1])
jwt_file = pathlib.Path(sys.argv[2])
header = sys.argv[3] or "Authorization"
secret = jwt_file.read_text(encoding="utf-8").strip()
if not secret:
    raise SystemExit("ONLYOFFICE JWT secret file is empty")

with path.open("r", encoding="utf-8") as fh:
    data = json.load(fh)

def ensure_dict(parent, key):
    value = parent.get(key)
    if not isinstance(value, dict):
        value = {}
        parent[key] = value
    return value

services = ensure_dict(data, "services")
coauthoring = ensure_dict(services, "CoAuthoring")
token = ensure_dict(coauthoring, "token")
token_enable = ensure_dict(token, "enable")
token_request = ensure_dict(token_enable, "request")
token_request["inbox"] = True
token_request["outbox"] = True
token_enable["browser"] = True

secret_block = ensure_dict(coauthoring, "secret")
for name in ("browser", "inbox", "outbox", "session"):
    ensure_dict(secret_block, name)["string"] = secret

ensure_dict(token, "inbox")["header"] = header
ensure_dict(token, "outbox")["header"] = header

stat = path.stat()
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True)
try:
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        json.dump(data, fh, indent=2, sort_keys=False)
        fh.write("\n")
    os.chown(tmp_name, stat.st_uid, stat.st_gid)
    os.chmod(tmp_name, stat.st_mode & 0o777)
    os.replace(tmp_name, path)
finally:
    try:
        os.unlink(tmp_name)
    except FileNotFoundError:
        pass
PY_ONLYOFFICE_LOCAL
}

write_onlyoffice_restapi_settings() {
    local settings_file="${CONFIG_DIR}/restapi_settings.py"
    local jwt_file="${CONFIG_DIR}/onlyoffice-jwt.secret"
    [[ -f "$settings_file" ]] || fail "${settings_file} not found"
    "$RESTAPI_PYTHON" - "$settings_file" "$jwt_file" <<'PY_ONLYOFFICE_SETTINGS'
import os
import pathlib
import re
import sys
import tempfile

settings_path = pathlib.Path(sys.argv[1])
jwt_file = pathlib.Path(sys.argv[2])
secret = jwt_file.read_text(encoding="utf-8").strip()
if not secret:
    raise SystemExit("ONLYOFFICE JWT secret file is empty")

text = settings_path.read_text(encoding="utf-8")
text = re.sub(
    r"\n?# Syncwerk ONLYOFFICE managed block begin.*?# Syncwerk ONLYOFFICE managed block end\n?",
    "\n",
    text,
    flags=re.S,
).rstrip()
block = f"""

# Syncwerk ONLYOFFICE managed block begin
ENABLE_ONLYOFFICE = True
VERIFY_ONLYOFFICE_CERTIFICATE = True
ONLYOFFICE_APIJS_URL = '/onlyofficeds/web-apps/apps/api/documents/api.js'
ONLYOFFICE_JWT_SECRET = {secret!r}
ONLYOFFICE_FILE_EXTENSION = ('doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'odt', 'fodt', 'odp', 'fodp', 'ods', 'fods', 'csv')
ONLYOFFICE_EDIT_FILE_EXTENSION = ('doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'odt', 'fodt', 'odp', 'fodp', 'ods', 'fods', 'csv')
# Syncwerk ONLYOFFICE managed block end
"""
stat = settings_path.stat()
fd, tmp_name = tempfile.mkstemp(prefix=f".{settings_path.name}.", suffix=".tmp", dir=str(settings_path.parent), text=True)
try:
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        fh.write(text)
        fh.write(block)
        fh.write("\n")
    os.chown(tmp_name, stat.st_uid, stat.st_gid)
    os.chmod(tmp_name, stat.st_mode & 0o777)
    os.replace(tmp_name, settings_path)
finally:
    try:
        pathlib.Path(tmp_name).unlink()
    except FileNotFoundError:
        pass
PY_ONLYOFFICE_SETTINGS
}

reconcile_onlyoffice_for_setup() {
    if ! bool_true "$SYNCWERK_SETUP_RECONCILE_ONLYOFFICE"; then
        log "ONLYOFFICE reconcile skipped"
        return 0
    fi
    onlyoffice_documentserver_installed || return 0
    restapi_onlyoffice_enabled || {
        log "ONLYOFFICE Document Server installed, but Syncwerk ONLYOFFICE is not enabled; leaving unchanged"
        return 0
    }

    log "reconciling existing ONLYOFFICE Document Server configuration"
    ensure_onlyoffice_jwt_secret_file
    write_onlyoffice_documentserver_jwt_config
    write_onlyoffice_restapi_settings
    onlyoffice_service_restart_best_effort
    syncwerk_service_restart_best_effort
    wait_onlyoffice_health 9090
    validate_onlyoffice_public_tls "$(public_site_url_from_settings)"
    log "existing ONLYOFFICE Document Server reconciled"
}

syncwerk_runtime_rpc_ready() {
    run_restapi_python - <<'PY' >/dev/null
import os
import sys

try:
    import ccnet

    ccnet_dir = os.environ['CCNET_CONF_DIR']
    central_config_dir = os.environ['SYNCWERK_CENTRAL_CONF_DIR']
    rpc_client = ccnet.CcnetThreadedRpcClient(
        ccnet.ClientPool(ccnet_dir, central_config_dir=central_config_dir)
    )
    rpc_client.get_emailusers('DB', 0, 1)
except Exception:
    sys.exit(1)
PY
}

wait_for_syncwerk_runtime_ready() {
    local timeout interval deadline now
    timeout="${SYNCWERK_SETUP_RUNTIME_READY_TIMEOUT}"
    interval="${SYNCWERK_SETUP_RUNTIME_READY_INTERVAL}"
    [[ "$timeout" =~ ^[0-9]+$ ]] || fail "invalid SYNCWERK_SETUP_RUNTIME_READY_TIMEOUT: ${timeout}"
    [[ "$interval" =~ ^[0-9]+$ ]] || fail "invalid SYNCWERK_SETUP_RUNTIME_READY_INTERVAL: ${interval}"
    [[ "$interval" -gt 0 ]] || fail "SYNCWERK_SETUP_RUNTIME_READY_INTERVAL must be greater than zero"

    deadline=$(( $(date +%s) + timeout ))
    while true; do
        if syncwerk_runtime_rpc_ready; then
            log "Syncwerk runtime RPC is ready"
            return 0
        fi
        now=$(date +%s)
        if [[ "$now" -ge "$deadline" ]]; then
            fail "Syncwerk runtime RPC did not become ready within ${timeout}s"
        fi
        sleep "$interval"
    done
}

read_admin_bootstrap_fields() {
    local admin_file="${1:-${ADMIN_FINAL_FILE}}"
    [[ -f "$admin_file" ]] || return 1
    "$RESTAPI_PYTHON" - "$admin_file" <<'PY'
import json
import re
import sys
from pathlib import Path

path = Path(sys.argv[1])
text = path.read_text(encoding='utf-8').strip()
email = ''
password = ''
fmt = 'legacy'
if text.startswith('{'):
    fmt = 'json'
    data = json.loads(text)
    email = str(data.get('email') or '').strip()
    password = str(data.get('password') or '')
else:
    for line in text.splitlines():
        match = re.match(r'^\s*(Mail|Email|E-Mail|Pass|Password)\s*[:=]\s*(.*?)\s*$', line, re.I)
        if not match:
            continue
        key = match.group(1).lower()
        value = match.group(2)
        if key in ('mail', 'email', 'e-mail'):
            email = value.strip()
        elif key in ('pass', 'password'):
            password = value
if not email:
    sys.exit(1)
print('%s	%s	%s' % (fmt, email.lower(), password))
PY
}

ensure_bootstrap_super_admin_role() {
    local admin_user="$1"
    [[ -n "$admin_user" ]] || fail "bootstrap superadmin email is empty"
    run_restapi_python - "$admin_user" <<'PY'
import os
import sys

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'restapi.settings')

import django
django.setup()

from restapi.base.accounts import User
from restapi.constants import SUPERADMIN
from restapi.role_permissions.models import AdminRole

email = sys.argv[1].strip().lower()
try:
    user = User.objects.get(email=email)
except User.DoesNotExist:
    raise SystemExit('bootstrap superadmin user does not exist in ccnet')
if not getattr(user, 'is_staff', False):
    raise SystemExit('bootstrap superadmin user is not marked as staff in ccnet')

_, created = AdminRole.objects.update_or_create(
    email=email,
    defaults={'role': SUPERADMIN},
)
print('RESTAPI AdminRole ensured: role=%s created=%s' % (SUPERADMIN, created))
PY
}

validate_or_create_bootstrap_super_admin() {
    local admin_user="$1" admin_file="$2"
    [[ -n "$admin_user" ]] || fail "bootstrap superadmin email is empty"
    [[ -f "$admin_file" ]] || fail "bootstrap superadmin credential file does not exist"
    wait_for_syncwerk_runtime_ready
    run_restapi_python - "$admin_user" "$admin_file" <<'PY'
import json
import os
import re
import sys
from pathlib import Path

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'restapi.settings')

import ccnet
import django

django.setup()

from restapi.base.accounts import User
from django.db import connection

def django_auth_user_exists(user_email):
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                "SELECT 1 FROM auth_user WHERE username = %s OR email = %s LIMIT 1",
                [user_email, user_email],
            )
            return cursor.fetchone() is not None
    except Exception:
        return False

def final_bootstrap_password_recovery_allowed(user_email):
    if django_auth_user_exists(user_email):
        return False
    staff_users = [
        user for user in (rpc_client.get_emailusers('DB', -1, -1, 'active') or [])
        if getattr(user, 'is_staff', False)
    ]
    if len(staff_users) > 1:
        return False
    if staff_users and staff_users[0].email.lower() != user_email:
        return False
    return True

email = sys.argv[1].strip().lower()
admin_file = Path(sys.argv[2])
text = admin_file.read_text(encoding='utf-8').strip()
password = ''
is_pending_bootstrap = text.startswith('{')
if is_pending_bootstrap:
    password = str(json.loads(text).get('password') or '')
else:
    for line in text.splitlines():
        match = re.match(r'^\s*(Pass|Password)\s*[:=]\s*(.*?)\s*$', line, re.I)
        if match:
            password = match.group(2)
            break
if not password:
    raise SystemExit('bootstrap superadmin password is empty')

ccnet_dir = os.environ['CCNET_CONF_DIR']
central_config_dir = os.environ['SYNCWERK_CENTRAL_CONF_DIR']
rpc_client = ccnet.CcnetThreadedRpcClient(
    ccnet.ClientPool(ccnet_dir, central_config_dir=central_config_dir)
)

try:
    user = User.objects.get(email=email)
except User.DoesNotExist:
    if rpc_client.add_emailuser(email, password, 1, 1) < 0:
        raise SystemExit('failed to create bootstrap superadmin user')
    user = User.objects.get(email=email)

needs_pending_repair = False
if not getattr(user, 'is_active', False):
    if not is_pending_bootstrap:
        raise SystemExit('bootstrap superadmin user is not active')
    needs_pending_repair = True
if not getattr(user, 'is_staff', False):
    if is_pending_bootstrap:
        needs_pending_repair = True
    elif rpc_client.update_role_emailuser(email, 'superadmin') < 0:
        raise SystemExit('failed to mark bootstrap superadmin as staff')
    else:
        user = User.objects.get(email=email)

if rpc_client.validate_emailuser(email, password) < 0:
    if not is_pending_bootstrap:
        if not final_bootstrap_password_recovery_allowed(email):
            raise SystemExit('bootstrap superadmin password validation failed')
        needs_pending_repair = True
    else:
        if django_auth_user_exists(email):
            raise SystemExit('pending bootstrap superadmin password conflicts with existing RESTAPI auth user')
        needs_pending_repair = True
elif not is_pending_bootstrap and not django_auth_user_exists(email):
    if final_bootstrap_password_recovery_allowed(email):
        needs_pending_repair = True

if needs_pending_repair:
    if getattr(user, 'source', 'DB') != 'DB':
        raise SystemExit('pending bootstrap superadmin conflicts with a non-DB account')
    user.set_password(password)
    user.is_active = True
    user.is_staff = True
    if user.save() < 0:
        raise SystemExit('failed to repair pending bootstrap superadmin user')
    user = User.objects.get(email=email)
    if rpc_client.validate_emailuser(email, password) < 0:
        raise SystemExit('pending bootstrap superadmin password repair failed')

if not getattr(user, 'is_active', False):
    raise SystemExit('bootstrap superadmin user is not active')
if not getattr(user, 'is_staff', False):
    if rpc_client.update_role_emailuser(email, 'superadmin') < 0:
        raise SystemExit('failed to mark bootstrap superadmin as staff')
PY
}

write_final_admin_file() {
    local admin_user="$1" admin_password="$2" tmp
    tmp="$(mktemp "${ADMIN_FINAL_FILE}.XXXXXX")"
    cat >"$tmp" <<EOF_ADMIN_TXT
Mail = ${admin_user}
Pass = ${admin_password}
EOF_ADMIN_TXT
    chmod 0600 "$tmp"
    chown "$SYNCWERK_USER:$(syncwerk_group)" "$tmp"
    mv "$tmp" "$ADMIN_FINAL_FILE"
}

finalize_bootstrap_super_admin_from_file() {
    local admin_file="$1" fields admin_format admin_user admin_password
    [[ -f "$admin_file" ]] || fail "bootstrap superadmin credential file does not exist"
    fields="$(read_admin_bootstrap_fields "$admin_file")" || fail "${admin_file} exists but no bootstrap admin email could be parsed"
    IFS=$'	' read -r admin_format admin_user admin_password <<<"$fields"
    [[ -n "$admin_password" ]] || fail "${admin_file} exists but no bootstrap admin password could be parsed"
    log "validating pending bootstrap superadmin credential"
    validate_or_create_bootstrap_super_admin "$admin_user" "$admin_file"
    ensure_bootstrap_super_admin_role "$admin_user"
    write_final_admin_file "$admin_user" "$admin_password"
    if [[ "$admin_file" != "$ADMIN_FINAL_FILE" ]]; then
        rm -f "$admin_file"
    fi
    if [[ "$ADMIN_PENDING_FILE" != "$admin_file" && -f "$ADMIN_PENDING_FILE" ]]; then
        rm -f "$ADMIN_PENDING_FILE"
    fi
    log "bootstrap superadmin credential finalized in ${ADMIN_FINAL_FILE}"
}

final_admin_bootstrap_recovery_needed() {
    local admin_user="$1" admin_file="$2"
    run_restapi_python - "$admin_user" "$admin_file" <<'PY'
import os
import re
import sys
from pathlib import Path

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'restapi.settings')

import ccnet
import django

django.setup()

from django.db import connection
from restapi.base.accounts import User

def django_auth_user_exists(user_email):
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                "SELECT 1 FROM auth_user WHERE username = %s OR email = %s LIMIT 1",
                [user_email, user_email],
            )
            return cursor.fetchone() is not None
    except Exception:
        return False

email = sys.argv[1].strip().lower()
admin_file = Path(sys.argv[2])
text = admin_file.read_text(encoding='utf-8').strip()
if text.startswith('{'):
    sys.exit(1)
password = ''
for line in text.splitlines():
    match = re.match(r'^\s*(Pass|Password)\s*[:=]\s*(.*?)\s*$', line, re.I)
    if match:
        password = match.group(2)
        break
if not password or django_auth_user_exists(email):
    sys.exit(1)

ccnet_dir = os.environ['CCNET_CONF_DIR']
central_config_dir = os.environ['SYNCWERK_CENTRAL_CONF_DIR']
rpc_client = ccnet.CcnetThreadedRpcClient(
    ccnet.ClientPool(ccnet_dir, central_config_dir=central_config_dir)
)
active_staff = [
    user for user in (rpc_client.get_emailusers('DB', -1, -1, 'active') or [])
    if getattr(user, 'is_staff', False)
]
if len(active_staff) > 1:
    sys.exit(1)
if active_staff and active_staff[0].email.lower() != email:
    sys.exit(1)
sys.exit(0)
PY
}

recover_pending_bootstrap_super_admin_for_setup() {
    local fields admin_format admin_user admin_password
    if [[ -f "$ADMIN_PENDING_FILE" ]]; then
        if ! syncwerk_runtime_active; then
            log "pending bootstrap superadmin credential exists; runtime is not active, deferring recovery"
            return 0
        fi
        finalize_bootstrap_super_admin_from_file "$ADMIN_PENDING_FILE"
        return 0
    fi
    [[ -f "$ADMIN_FINAL_FILE" ]] || return 0
    fields="$(read_admin_bootstrap_fields "$ADMIN_FINAL_FILE")" || return 0
    IFS=$'	' read -r admin_format admin_user admin_password <<<"$fields"
    if [[ "$admin_format" != "json" || -z "$admin_password" ]]; then
        if [[ "$admin_format" == "legacy" && -n "$admin_password" ]]; then
            if ! syncwerk_runtime_active; then
                log "final bootstrap superadmin credential exists; runtime is not active, deferring recovery check"
                return 0
            fi
            if final_admin_bootstrap_recovery_needed "$admin_user" "$ADMIN_FINAL_FILE"; then
                log "recoverable final bootstrap superadmin credential found in ${ADMIN_FINAL_FILE}; repairing incomplete bootstrap account"
                finalize_bootstrap_super_admin_from_file "$ADMIN_FINAL_FILE"
            fi
        fi
        return 0
    fi
    if ! syncwerk_runtime_active; then
        log "legacy JSON bootstrap superadmin credential exists in ${ADMIN_FINAL_FILE}; runtime is not active, deferring recovery"
        return 0
    fi
    log "legacy JSON bootstrap superadmin credential found in ${ADMIN_FINAL_FILE}; migrating to final operator credential"
    finalize_bootstrap_super_admin_from_file "$ADMIN_FINAL_FILE"
}

create_super_admin_optional() {
    if ! bool_true "$SYNCWERK_SETUP_CREATE_SUPER_ADMIN"; then
        log "bootstrap superadmin creation disabled"
        return 0
    fi
    local admin_user admin_password admin_format tmp fields
    if [[ -f "$ADMIN_PENDING_FILE" ]]; then
        finalize_bootstrap_super_admin_from_file "$ADMIN_PENDING_FILE"
        return 0
    fi
    if [[ -f "$ADMIN_FINAL_FILE" ]]; then
        fields="$(read_admin_bootstrap_fields "$ADMIN_FINAL_FILE")" || fail "${ADMIN_FINAL_FILE} exists but no bootstrap admin email could be parsed"
        IFS=$'	' read -r admin_format admin_user admin_password <<<"$fields"
        if [[ -n "$admin_password" ]]; then
            log "${ADMIN_FINAL_FILE} exists; validating bootstrap superadmin credential"
            validate_or_create_bootstrap_super_admin "$admin_user" "$ADMIN_FINAL_FILE"
            ensure_bootstrap_super_admin_role "$admin_user"
            write_final_admin_file "$admin_user" "$admin_password"
            log "bootstrap superadmin credential finalized in ${ADMIN_FINAL_FILE}"
        else
            log "${ADMIN_FINAL_FILE} exists without password; ensuring bootstrap superadmin role only"
            ensure_bootstrap_super_admin_role "$admin_user"
        fi
        return 0
    fi
    admin_user="admin@${HOSTNAME}"
    admin_password="$(random_alnum 28)"
    tmp="$(mktemp "${ADMIN_PENDING_FILE}.XXXXXX")"
    cat >"$tmp" <<EOF_ADMIN_JSON
{
  "email": "${admin_user}",
  "password": "${admin_password}"
}
EOF_ADMIN_JSON
    chmod 0600 "$tmp"
    chown "$SYNCWERK_USER:$(syncwerk_group)" "$tmp"
    mv "$tmp" "$ADMIN_PENDING_FILE"
    finalize_bootstrap_super_admin_from_file "$ADMIN_PENDING_FILE"
    log "bootstrap superadmin credentials written to ${ADMIN_FINAL_FILE}"
}

run_reconcile_admin_rbac_command() {
    local mode="" json_flag=0
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --check) mode="check" ;;
            --apply) mode="apply" ;;
            --json) json_flag=1 ;;
            -h|--help) reconcile_admin_rbac_usage; exit 0 ;;
            *) fail "unknown reconcile-admin-rbac argument: $1" ;;
        esac
        shift
    done
    [[ -n "$mode" ]] || fail "reconcile-admin-rbac requires --check or --apply"
    database_command_preflight
    reconcile_admin_rbac_mode "$mode" "$json_flag"
}

run_reconcile_license_command() {
    local mode="" candidate="" json_flag=0
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --check) mode="check" ;;
            --apply) mode="apply" ;;
            --candidate)
                [[ -n "${2:-}" ]] || fail "--candidate requires a path"
                candidate="$2"
                shift
                ;;
            --candidate=*) candidate="${1#*=}" ;;
            --json) json_flag=1 ;;
            -h|--help) reconcile_license_usage; exit 0 ;;
            *) fail "unknown reconcile-license argument: $1" ;;
        esac
        shift
    done
    [[ -n "$mode" ]] || fail "reconcile-license requires --check or --apply"
    restapi_command_preflight
    reconcile_license_mode "$mode" "$candidate" "$json_flag"
}

reconcile_admin_rbac_for_setup() {
    if [[ "$SYNCWERK_SETUP_RECONCILE_ADMIN_RBAC" == "skip" ]]; then
        log "admin RBAC reconcile skipped"
        return 0
    fi
    if ! syncwerk_runtime_active; then
        log "admin RBAC reconcile deferred because Syncwerk runtime is not active; run 'syncwerk-server-admin reconcile-admin-rbac --apply' after service start"
        return 0
    fi
    if reconcile_admin_rbac_mode "$SYNCWERK_SETUP_RECONCILE_ADMIN_RBAC" 0; then
        return 0
    fi
    if bool_true "$SYNCWERK_SETUP_RECONCILE_STRICT"; then
        fail "admin RBAC reconcile failed in strict mode"
    fi
    log "admin RBAC reconcile failed; continuing because SYNCWERK_SETUP_RECONCILE_STRICT=0"
}

reconcile_license_for_setup() {
    reconcile_license_mode "$SYNCWERK_SETUP_RECONCILE_LICENSE" "$SYNCWERK_SETUP_LICENSE_CANDIDATE" 0
}

main() {
    case "${1:-}" in
        reconcile-admin-rbac)
            shift
            run_reconcile_admin_rbac_command "$@"
            return 0
            ;;
        reconcile-license)
            shift
            run_reconcile_license_command "$@"
            return 0
            ;;
    esac
    parse_args "$@"
    log "starting dedicated Trixie setup"
    log "host=${HOSTNAME} migration_mode=${SYNCWERK_SETUP_MIGRATION_MODE} start_services=${SYNCWERK_SETUP_START_SERVICES} create_super_admin=${SYNCWERK_SETUP_CREATE_SUPER_ADMIN}"
    preflight
    setup_user
    prepare_runtime_dirs
    stop_services_safe
    start_database
    backup_databases_if_existing
    create_or_update_databases
    render_ccnet_conf
    setup_my_key_peer
    setup_ccnet_database_minimal
    normalize_ccnet_auth_collations
    render_server_conf
    setup_gunicorn_conf
    render_restapi_settings
    setup_nginx
    update_database
    normalize_ccnet_auth_collations
    migrate_avatars
    fix_permissions
    update_static_files
    manage_check
    setup_service_integration
    start_services_optional
    reconcile_onlyoffice_for_setup
    recover_pending_bootstrap_super_admin_for_setup
    create_super_admin_optional
    reconcile_admin_rbac_for_setup
    reconcile_license_for_setup
    log "setup completed"
}

main "$@"
