#!/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}"

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-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.
  -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_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
}

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 ;;
            -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
}

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" "$@"
    )
}

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
}

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"
}

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/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
    if [[ ! -f /etc/nginx/conf.d/syncwerk.conf ]]; then
        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 /etc/nginx/ssl/syncwerk.crt;
    ssl_certificate_key /etc/nginx/ssl/syncwerk.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
    else
        log "/etc/nginx/conf.d/syncwerk.conf exists; leaving unchanged"
    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
}

read_admin_email_from_file() {
    local admin_file="${CONFIG_DIR}/admin.txt"
    [[ -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 = ''
if text.startswith('{'):
    data = json.loads(text)
    email = str(data.get('email') or '').strip()
else:
    for line in text.splitlines():
        match = re.match(r'^\s*(?:Mail|Email|E-Mail)\s*=\s*(.+?)\s*$', line, re.I)
        if match:
            email = match.group(1).strip()
            break
if not email:
    sys.exit(1)
print(email.lower())
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: %s' % email)
if not getattr(user, 'is_staff', False):
    raise SystemExit('bootstrap superadmin user is not marked as staff in ccnet: %s' % email)

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

create_super_admin_optional() {
    if ! bool_true "$SYNCWERK_SETUP_CREATE_SUPER_ADMIN"; then
        log "bootstrap superadmin creation disabled"
        return 0
    fi
    if [[ -f "${CONFIG_DIR}/admin.txt" ]]; then
        local existing_admin_user
        existing_admin_user="$(read_admin_email_from_file)" || fail "${CONFIG_DIR}/admin.txt exists but no admin email could be parsed"
        log "${CONFIG_DIR}/admin.txt exists; not creating a new bootstrap superadmin credential"
        ensure_bootstrap_super_admin_role "$existing_admin_user"
        return 0
    fi
    local admin_user admin_password tmp
    admin_user="admin@${HOSTNAME}"
    admin_password="$(random_alnum 28)"
    tmp="$(mktemp "${CONFIG_DIR}/admin.txt.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" "${CONFIG_DIR}/admin.txt"
    run_restapi_python "${RESTAPI_DIR}/restapi/create-admin.py"
    ensure_bootstrap_super_admin_role "$admin_user"
    cat >"${CONFIG_DIR}/admin.txt" <<EOF_ADMIN_TXT
Mail = ${admin_user}
Pass = ${admin_password}
EOF_ADMIN_TXT
    chmod 0600 "${CONFIG_DIR}/admin.txt"
    chown "$SYNCWERK_USER:$(syncwerk_group)" "${CONFIG_DIR}/admin.txt"
    log "bootstrap superadmin credentials written to ${CONFIG_DIR}/admin.txt"
}

main() {
    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
    create_super_admin_optional
    log "setup completed"
}

main "$@"
