#!/bin/sh
# DBenk Hub — pemasang untuk server pelanggan (on-prem). Panduan lengkap: docs/16-panduan-pelanggan.md, docs/09.
#
#   curl -fsSL https://get.dbenk.com | sh
#   curl -fsSL https://get.dbenk.com | sh -s -- --license-claim KODE --license-renew-url https://lisensi.dbenk.com/v1/renew
#
# get.dbenk.com = host distribusi vendor DBenk (image dbenk-license): pemasang ini, kanal rilis bertanda tangan, kanal agen.
#
# Perintah:  sh install.sh [install]   pasang, atau perbarui bila sudah terpasang (aman dijalankan berulang)
#            sh install.sh upgrade     ambil rilis terbaru kanal yang sama dan ganti image Hub (data tetap)
#            sh install.sh status      tampilkan versi, kontainer, dan alamat
#            sh install.sh uninstall [--hapus-data]   hentikan Hub + modul (data tetap kecuali --hapus-data)
#            sh install.sh verify      unduh dan periksa tanda tangan kanal saja (tidak memasang apa pun)
# Pilihan:   --dir D (bawaan ~/dbenk-hub, root: /opt/dbenk-hub)   --port 8080   --bind 0.0.0.0   --project dbenk-hub
#            --channel stable|beta   --release-url URL (bawaan https://get.dbenk.com/release; boleh beberapa, dipisah spasi = cermin)
#            --license-claim KODE    --license-renew-url URL   (Hub mengambil lisensi uji coba/berbayar sendiri)
#            --owner-username admin  --owner-email EMAIL       --install-docker (pasang Docker bila belum ada; root)
#            --force (lewati batas minimum RAM/disk)          --yes (tanpa pertanyaan)
#
# Yang dilakukan: memeriksa syarat (Docker + Compose v2, port, RAM, disk), mengunduh manifest kanal rilis dan MEMERIKSA
# tanda tangan ed25519-nya dengan kunci yang dipatok di skrip ini, menarik image Hub PER DIGEST dari manifest (Docker
# menolak isi yang tidak cocok dengan digest), menulis docker-compose.yml + .env (rahasia acak: kunci master Hub, kata
# sandi database, kata sandi Owner pertama), menjalankan Hub + PostgreSQL, menunggu sehat, lalu MENCETAK alamat masuk +
# nama pengguna + kata sandi SEKALI (kata sandi lalu dihapus dari .env). Image modul (Office, Virtual Office, DevOps) juga
# dari manifest yang sama per digest; modul dinyalakan dari dasbor Hub (menu Modul).
# .env hanya BOOTSTRAP (kunci master, kata sandi DB, Owner pertama); serah terima sekali (image modul, alamat lisensi)
# diimpor Hub ke setelannya lalu dihapus dari .env. Semua setelan lain di Pengaturan Hub (scripts/env-allowlist.json).
# Cadangan otomatis harian masuk ke folder backups/ di folder pemasangan. Simpan salinan .env (kunci master) di tempat aman.
#
# Variabel lama tetap diterima: DBENK_DIR DBENK_PROJECT DBENK_PORT DBENK_BIND DBENK_DOCKER_SOCKET DBENK_RELEASE_URL
# DBENK_CHANNEL HUB_OWNER_USERNAME HUB_OWNER_EMAIL. Khusus tim (tanpa kanal bertanda tangan, bukan untuk pelanggan):
# DBENK_HUB_IMAGE=<image lokal> HUB_MODULE_IMAGES=office=img,...   Uji pasang bersih dengan kanal uji:
# DBENK_RELEASE_PUBKEYS="<base64> ..." mengganti kunci yang dipatok (jangan dipakai di server pelanggan).
set -eu

INSTALLER_VERSION=2
# Kunci publik rilis yang dipatok (sama dengan tools/dbenkctl/pkg/release PinnedKeys; gerbang check-all mencocokkan).
PINNED_KEYS="mG4QxtZHyThbPFkgyk1TVc4o8Cdg8CRfmijz9wOKhco= tQVC093G6pPVvvkGWNluMBvjqlLame0807XqlW4+8tI="
RELEASE_DOMAIN='dbenk-hub-release/1'
POSTGRES_IMAGE='postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea'
OPENSSL_IMAGE='alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a'
MODULES='office vo devops'

say() { printf '%s\n' "$*"; }
warn() { printf 'PERHATIAN: %s\n' "$*" >&2; }
die() { printf 'GAGAL: %s\n' "$*" >&2; exit 1; }

# ---- argumen ----
ACTION=install
case "${1:-}" in install|upgrade|uninstall|status|verify) ACTION=$1; shift ;; esac
if [ "$(id -u)" = 0 ]; then DEF_DIR=/opt/dbenk-hub; else DEF_DIR=$HOME/dbenk-hub; fi
DIR=${DBENK_DIR:-$DEF_DIR}
PROJECT=${DBENK_PROJECT:-}
PORT=${DBENK_PORT:-}
BIND=${DBENK_BIND:-}
SOCK=${DBENK_DOCKER_SOCKET:-/var/run/docker.sock}
CHANNEL=${DBENK_CHANNEL:-}
RELEASE_URLS=${DBENK_RELEASE_URL:-}
OWNER_USER=${HUB_OWNER_USERNAME:-admin}
OWNER_EMAIL=${HUB_OWNER_EMAIL:-}
CLAIM='' RENEW_URL='' INSTALL_DOCKER=0 FORCE=0 PURGE=0
while [ $# -gt 0 ]; do
  case "$1" in
    --dir) DIR=$2; shift 2 ;;
    --port) PORT=$2; shift 2 ;;
    --bind) BIND=$2; shift 2 ;;
    --project) PROJECT=$2; shift 2 ;;
    --channel) CHANNEL=$2; shift 2 ;;
    --release-url) RELEASE_URLS=$2; shift 2 ;;
    --license-claim) CLAIM=$2; shift 2 ;;
    --license-renew-url) RENEW_URL=$2; shift 2 ;;
    --owner-username) OWNER_USER=$2; shift 2 ;;
    --owner-email) OWNER_EMAIL=$2; shift 2 ;;
    --install-docker) INSTALL_DOCKER=1; shift ;;
    --force) FORCE=1; shift ;;
    --yes|-y) shift ;;
    --hapus-data|--purge) PURGE=1; shift ;;
    -h|--help) sed -n '2,33p' "$0" 2>/dev/null || say "lihat docs/16-panduan-pelanggan.md"; exit 0 ;;
    *) die "pilihan tidak dikenal: $1 (lihat --help)" ;;
  esac
done
case "$CLAIM" in *[!A-Za-z0-9_-]*) die "--license-claim hanya huruf, angka, - dan _" ;; esac
case "$OWNER_USER$OWNER_EMAIL" in *[\"\$\`\\]*) die "nama/email Owner berisi karakter yang tidak boleh" ;; esac
if [ -n "$CLAIM" ] && [ -z "$RENEW_URL" ]; then RENEW_URL=https://lisensi.dbenk.com/v1/renew; fi
case "$RENEW_URL" in ''|https://*|http://*) ;; *) die "--license-renew-url harus alamat http(s)" ;; esac
STATE="$DIR/dbenk-install.state"
state_get() { if [ -f "$STATE" ]; then sed -n "s/^$1=//p" "$STATE" | tail -1; fi; }
# pemasangan yang ada: port/alamat/proyek/kanal dari dbenk-install.state kecuali diberikan lagi
[ -n "$PORT" ] || PORT=$(state_get port)
[ -n "$PORT" ] || PORT=8080
[ -n "$BIND" ] || BIND=$(state_get bind)
[ -n "$BIND" ] || BIND=0.0.0.0
[ -n "$PROJECT" ] || PROJECT=$(state_get project)
[ -n "$PROJECT" ] || PROJECT=dbenk-hub
case "$PORT" in ''|*[!0-9]*) die "--port harus angka" ;; esac
case "$PROJECT" in ''|*[!a-z0-9_-]*) die "--project hanya huruf kecil, angka, - dan _" ;; esac
[ -n "$CHANNEL" ] || CHANNEL=$(state_get channel)
[ -n "$CHANNEL" ] || CHANNEL=stable
case "$CHANNEL" in stable|beta) ;; *) die "--channel harus stable atau beta" ;; esac
[ -n "$RELEASE_URLS" ] || RELEASE_URLS=$(state_get release_url)
[ -n "$RELEASE_URLS" ] || RELEASE_URLS="https://get.dbenk.com/release"

# ---- alat bantu ----
fetch() { # url file
  if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 10 --max-time 60 -o "$2" "$1"
  elif command -v wget >/dev/null 2>&1; then wget -q -T 60 -O "$2" "$1"
  else die "butuh curl atau wget"; fi
}
rand() { od -An -N"$1" -tx1 /dev/urandom | tr -d ' \n'; }
# kata sandi mudah dibaca: 4 kelompok huruf/angka (tanpa 0/O/1/l), lolos aturan Hub (>= 12)
readable() { LC_ALL=C tr -dc 'abcdefghijkmnpqrstuvwxyz23456789' </dev/urandom | head -c 20 | sed 's/\(.....\)/\1-/g; s/-$//'; }
TMP=$(mktemp -d 2>/dev/null || mktemp -d -t dbenk)
trap 'rm -rf "$TMP"' EXIT INT TERM

DOCKER=docker
docker_ok() {
  if docker info >/dev/null 2>&1; then DOCKER=docker; return 0; fi
  if command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then DOCKER="sudo -n docker"; return 0; fi
  return 1
}

# ---- tanda tangan kanal ----
# openssl 3 (pkeyutl -rawin) di host; tanpa itu: kontainer alpine yang dipatok digest + paket openssl.
OSSL=''
pick_openssl() {
  if command -v openssl >/dev/null 2>&1 && openssl version 2>/dev/null | grep -q '^OpenSSL [3-9]'; then OSSL=host; return 0; fi
  if docker_ok; then OSSL=docker; return 0; fi
  die "butuh OpenSSL 3 untuk memeriksa tanda tangan rilis (mis. apt install openssl) atau Docker"
}
ossl() { # argumen openssl; berkas relatif terhadap $TMP
  if [ "$OSSL" = host ]; then (cd "$TMP" && openssl "$@")
  else
    $DOCKER run --rm --network none -v "$TMP:/w" -w /w "$OPENSSL_IMAGE" sh -c 'command -v openssl >/dev/null || exit 3; openssl "$@"' sh "$@" 2>/dev/null \
      || $DOCKER run --rm -v "$TMP:/w" -w /w "$OPENSSL_IMAGE" sh -c 'apk add -q --no-cache openssl >/dev/null && openssl "$@"' sh "$@"
  fi
}
b64d() { if base64 -d </dev/null >/dev/null 2>&1; then base64 -d; else base64 --decode; fi; }
verify_sig() { # manifest sig -> 0 bila satu baris sah dengan kunci yang dipatok (SIG_KID = id kunci)
  keys=${DBENK_RELEASE_PUBKEYS:-$PINNED_KEYS}
  if [ -n "${DBENK_RELEASE_PUBKEYS:-}" ]; then warn "DBENK_RELEASE_PUBKEYS dipakai: kanal UJI, bukan kunci rilis resmi"; fi
  { printf '%s\n' "$RELEASE_DOMAIN"; cat "$1"; } >"$TMP/msg"
  i=0
  for k in $keys; do
    i=$((i + 1))
    # SubjectPublicKeyInfo DER untuk Ed25519 = awalan 12 byte + 32 byte kunci mentah
    { printf '\060\052\060\005\006\003\053\145\160\003\041\000'; printf '%s' "$k" | b64d; } >"$TMP/k$i.der"
    [ "$(wc -c <"$TMP/k$i.der" | tr -d ' ')" = 44 ] || continue
    while read -r alg kid sig; do
      [ "$alg" = ed25519 ] || continue
      printf '%s' "$sig" | b64d >"$TMP/sig" 2>/dev/null || continue
      if ossl pkeyutl -verify -pubin -keyform DER -inkey "k$i.der" -rawin -in msg -sigfile sig >/dev/null 2>&1; then
        SIG_KID=$kid
        return 0
      fi
    done <"$2"
  done
  return 1
}

# Manifest JSON dari `dbenkctl release build` (json indent 2): baca "a.b.c" tanpa jq.
mget() { # file path
  awk -v want="$2" '
    function path(n,  i, p) { p = ""; for (i = 1; i <= n; i++) p = p (i > 1 ? "." : "") st[i]; return p }
    /^[[:space:]]*"[^"]+":[[:space:]]*\{/ { match($0, /"[^"]+"/); st[++d] = substr($0, RSTART + 1, RLENGTH - 2); next }
    /^[[:space:]]*\}/ { if (d > 0) d--; next }
    /^[[:space:]]*"[^"]+":/ {
      match($0, /"[^"]+"/); k = substr($0, RSTART + 1, RLENGTH - 2)
      v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); sub(/,[[:space:]]*$/, "", v); gsub(/^"|"$/, "", v)
      p = path(d); if ((p == "" ? k : p "." k) == want) { print v; exit }
    }' "$1"
}

check_ref() { # image digest name
  case "$1" in *@*|'') die "image $3 di manifest tidak sah: $1" ;; esac
  printf '%s' "$2" | grep -Eq '^sha256:[0-9a-f]{64}$' || die "digest $3 di manifest tidak sah: $2"
}

load_release() { # -> BASE SEQ HUB_VER HUB_REF MOD_REFS SIG_KID
  pick_openssl
  BASE=''
  for base in $RELEASE_URLS; do
    base=${base%/}
    if fetch "$base/$CHANNEL/manifest.json" "$TMP/manifest.json" 2>/dev/null && fetch "$base/$CHANNEL/manifest.json.sig" "$TMP/manifest.json.sig" 2>/dev/null; then
      BASE=$base
      break
    fi
    warn "kanal tidak terjangkau: $base/$CHANNEL"
  done
  [ -n "$BASE" ] || die "manifest kanal $CHANNEL tidak bisa diunduh dari: $RELEASE_URLS"
  verify_sig "$TMP/manifest.json" "$TMP/manifest.json.sig" || die "tanda tangan manifest $BASE/$CHANNEL TIDAK SAH: pemasangan dibatalkan (isi diubah atau bukan rilis DBenk)"
  M=$TMP/manifest.json
  [ "$(mget "$M" schema)" = dbenk.release/v2 ] || die "skema manifest tidak dikenal: $(mget "$M" schema)"
  [ "$(mget "$M" channel)" = "$CHANNEL" ] || die "manifest untuk kanal $(mget "$M" channel), bukan $CHANNEL"
  SEQ=$(mget "$M" sequence)
  case "$SEQ" in ''|*[!0-9]*) die "urutan manifest tidak sah: $SEQ" ;; esac
  HUB_VER=$(mget "$M" hub.version)
  hi=$(mget "$M" hub.image) hd=$(mget "$M" hub.digest)
  if [ -z "$hi" ] || [ -z "$hd" ]; then
    die "rilis $CHANNEL #$SEQ (Hub $HUB_VER) belum memuat image Hub yang bisa ditarik (image@digest): tim DBenk belum menerbitkan image untuk kanal ini. Coba lagi nanti atau hubungi dukungan."
  fi
  check_ref "$hi" "$hd" hub
  HUB_REF="$hi@$hd"
  MOD_REFS=''
  for m in $MODULES; do
    mi=$(mget "$M" "modules.$m.image") md=$(mget "$M" "modules.$m.digest")
    if [ -n "$mi" ] && [ -n "$md" ]; then
      check_ref "$mi" "$md" "$m"
      MOD_REFS="${MOD_REFS:+$MOD_REFS,}$m=$mi@$md"
    else
      warn "modul $m tidak ada di rilis ini (image@digest): modul itu belum bisa dinyalakan dari Hub"
    fi
  done
  last=$(state_get sequence)
  if [ -n "$last" ] && [ "$(state_get channel)" = "$CHANNEL" ] && [ "$SEQ" -lt "$last" ]; then
    die "manifest kanal lebih lama (urutan $SEQ) dari yang sudah terpasang ($last): ditolak (perlindungan turun versi)"
  fi
}

# ---- syarat ----
mem_mb() { awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo 2>/dev/null || echo 0; }
free_mb() { df -Pk "$1" 2>/dev/null | awk 'NR == 2 {print int($4 / 1024)}'; }
port_busy() {
  if command -v ss >/dev/null 2>&1; then ss -ltnH 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]$1\$"; return $?; fi
  hex=$(printf '%04X' "$1")
  cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | awk -v p=":$hex" '$4 == "0A" && substr($2, length($2) - 4) == p {f = 1} END {exit !f}'
}
requirements() {
  say "Memeriksa server ..."
  [ "$(uname -s)" = Linux ] || die "hanya Linux (server Anda: $(uname -s))"
  case "$(uname -m)" in
    x86_64|amd64) ;;
    aarch64|arm64) warn "server ARM64: image DBenk saat ini dirilis untuk amd64; pemasangan gagal bila rilis belum memuat arm64" ;;
    *) die "arsitektur $(uname -m) tidak didukung (amd64)" ;;
  esac
  if ! docker_ok; then
    if [ $INSTALL_DOCKER = 1 ]; then
      [ "$(id -u)" = 0 ] || die "--install-docker perlu root (sudo sh -s -- --install-docker)"
      say "Memasang Docker (get.docker.com) ..."
      fetch https://get.docker.com "$TMP/get-docker.sh" && sh "$TMP/get-docker.sh" >/dev/null || die "pemasangan Docker gagal"
      docker_ok || die "Docker terpasang tetapi belum jalan (systemctl start docker)"
    else
      die "Docker belum terpasang atau user ini tidak boleh memakainya. Pilihan: jalankan lagi sebagai root dengan --install-docker, atau pasang Docker (https://docs.docker.com/engine/install/) dan tambahkan user ke grup docker."
    fi
  fi
  $DOCKER compose version >/dev/null 2>&1 || die "Docker Compose v2 (perintah 'docker compose') belum ada: pasang paket docker-compose-plugin"
  [ -S "$SOCK" ] || die "soket Docker $SOCK tidak ada (DBENK_DOCKER_SOCKET)"
  mem=$(mem_mb)
  cpus=$(nproc 2>/dev/null || echo 1)
  root=$($DOCKER info -f '{{.DockerRootDir}}' 2>/dev/null || echo /)
  disk=$(free_mb "$root")
  [ -n "$disk" ] || disk=$(free_mb "$(dirname "$DIR")")
  disk=${disk:-0}
  say "  CPU $cpus, RAM ${mem} MB, ruang kosong Docker ${disk} MB"
  if [ "$mem" -lt 1800 ] || [ "$disk" -lt 5000 ]; then
    msg="minimum RAM 2 GB dan ruang kosong 5 GB (server: RAM ${mem} MB, disk ${disk} MB)"
    if [ $FORCE = 1 ]; then warn "$msg — dilanjutkan karena --force"; else die "$msg; tambah sumber daya atau pakai --force"; fi
  fi
  [ "$mem" -ge 3800 ] || warn "RAM < 4 GB: cukup untuk Hub, tetapi menyalakan beberapa modul sekaligus bisa lambat"
  [ "$cpus" -ge 2 ] || warn "1 CPU: Hub jalan, tetapi disarankan 2 CPU"
  if ! $DOCKER ps -q --filter "label=com.docker.compose.project=$PROJECT" --filter "label=com.docker.compose.service=hub" | grep -q . && port_busy "$PORT"; then
    die "port $PORT sudah dipakai program lain: pilih --port lain (mis. --port 8081)"
  fi
  if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx coolify; then
    say "  Coolify terdeteksi di server ini. Pemasang ini memakai Docker Compose langsung (port $PORT), terpisah dari"
    say "  Coolify. Ingin dikelola Coolify? Ikuti docs/16 §Coolify (resource Docker Compose dengan image dari kanal rilis)."
  fi
}

# ---- tindakan ----
uninstall() {
  docker_ok || die "Docker tidak bisa dipakai user ini"
  [ -f "$DIR/docker-compose.yml" ] || die "tidak ada pemasangan di $DIR (--dir)"
  ids=$($DOCKER ps -aq --filter "label=dbenk.project=$PROJECT")
  [ -z "$ids" ] || $DOCKER rm -f $ids >/dev/null
  if [ $PURGE = 1 ]; then
    (cd "$DIR" && $DOCKER compose -p "$PROJECT" down -v --remove-orphans >/dev/null 2>&1) || true
    vols=$($DOCKER volume ls -q --filter "label=dbenk.project=$PROJECT")
    [ -z "$vols" ] || $DOCKER volume rm $vols >/dev/null
    nets=$($DOCKER network ls -q --filter "label=dbenk.project=$PROJECT")
    [ -z "$nets" ] || $DOCKER network rm $nets >/dev/null 2>&1 || true
    $DOCKER image rm "dbenk-local/$PROJECT-hub:current" >/dev/null 2>&1 || true
    rm -f "$DIR/docker-compose.yml" "$DIR/.env" "$DIR/.env.tmp" "$STATE"
    say "DBenk Hub dan modulnya dihapus BESERTA datanya. Folder cadangan $DIR/backups dibiarkan (hapus sendiri bila tidak perlu)."
  else
    (cd "$DIR" && $DOCKER compose -p "$PROJECT" down --remove-orphans >/dev/null 2>&1) || true
    say "DBenk Hub dan modulnya dihentikan. Data tetap ada; jalankan pemasang lagi untuk menyalakan."
  fi
}

addresses() {
  case "$BIND" in
    0.0.0.0|'')
      ips=$(ip -4 -o addr show scope global 2>/dev/null | awk '$2 !~ /^(docker|br-|veth)/ {sub(/\/.*/, "", $4); print $4}')
      [ -n "$ips" ] || ips=$(hostname -I 2>/dev/null || echo 127.0.0.1) ;;
    *) ips=$BIND ;;
  esac
  for ip in $ips; do case $ip in *:*) continue ;; esac; say "  http://$ip:$PORT/"; done
}

status() {
  docker_ok || die "Docker tidak bisa dipakai user ini"
  [ -f "$DIR/docker-compose.yml" ] || die "tidak ada pemasangan di $DIR (--dir)"
  say "DBenk Hub di $DIR (proyek $PROJECT, kanal $(state_get channel), Hub $(state_get hub_version), rilis #$(state_get sequence))"
  $DOCKER ps -a --filter "label=dbenk.project=$PROJECT" --format '  {{.Names}}  {{.Status}}'
  addresses
}

envset() { grep -v "^$1=" .env >.env.tmp || true; printf '%s=%s\n' "$1" "$2" >>.env.tmp; mv .env.tmp .env; }
envdel() { grep -v "^$1=" .env >.env.tmp || true; mv .env.tmp .env; }

install() {
  requirements
  if [ -n "${DBENK_HUB_IMAGE:-}" ]; then
    warn "DBENK_HUB_IMAGE=$DBENK_HUB_IMAGE: image tim tanpa kanal bertanda tangan (bukan untuk pelanggan)"
    HUB_SRC=$DBENK_HUB_IMAGE HUB_VER=dev SEQ='' BASE=''
    MOD_REFS=${HUB_MODULE_IMAGES:-}
  else
    load_release
    say "Rilis $CHANNEL #$SEQ: Hub $HUB_VER (tanda tangan sah, kunci $SIG_KID, dari $BASE)"
    say "Menarik image Hub per digest ..."
    $DOCKER pull -q "$HUB_REF" >/dev/null || die "image Hub tidak bisa ditarik: $HUB_REF (koneksi internet / registry)"
    HUB_SRC=$HUB_REF
    [ -z "${HUB_MODULE_IMAGES:-}" ] || MOD_REFS=$HUB_MODULE_IMAGES
  fi
  $DOCKER pull -q "$POSTGRES_IMAGE" >/dev/null || die "image PostgreSQL tidak bisa ditarik: $POSTGRES_IMAGE"
  # nama lokal tetap: Hub memindahkan tag ini saat memperbarui dirinya sendiri (Pemeliharaan), jadi compose up berikutnya
  # tetap memakai versi terbaru, bukan kembali ke digest lama
  HUB_LOCAL="dbenk-local/$PROJECT-hub:current"
  $DOCKER tag "$HUB_SRC" "$HUB_LOCAL"

  mkdir -p "$DIR"
  cd "$DIR"
  umask 077
  FIRST=0
  if [ ! -f .env ]; then
    FIRST=1
    OWNER_PW=$(readable)
    cat >.env <<EOF
HUB_SECRET_KEY=$(rand 32)
POSTGRES_PASSWORD=$(rand 16)
HUB_OWNER_PASSWORD=$OWNER_PW
EOF
  elif [ $ACTION = install ]; then
    say "Pemasangan sudah ada di $DIR: diperbarui (rahasia dan data dipertahankan)."
  fi
  # serah terima sekali (bukan bootstrap): Hub mengimpornya ke setelan bila setelan itu masih kosong, lalu baris dihapus
  HANDOFF=0
  if [ -n "$MOD_REFS" ] && { [ $FIRST = 1 ] || [ -n "${HUB_MODULE_IMAGES:-}" ]; }; then envset HUB_MODULE_IMAGES "$MOD_REFS"; HANDOFF=1; fi
  if [ -n "$RENEW_URL" ]; then
    u=$RENEW_URL
    if [ -n "$CLAIM" ]; then case "$u" in *\?*) u="$u&klaim=$CLAIM" ;; *) u="$u?klaim=$CLAIM" ;; esac; fi
    envset HUB_LICENSE_RENEW_URL "$u"
    HANDOFF=1
  fi
  DOCKER_GID=$(stat -c %g "$SOCK" 2>/dev/null || echo 0)
  # folder cadangan (Pemeliharaan → Cadangan): Hub berjalan sebagai uid 65532 dengan grup docker
  mkdir -p backups
  if chgrp "$DOCKER_GID" backups 2>/dev/null; then chmod 2770 backups; else chmod 0777 backups; fi

  cat >docker-compose.yml <<EOF
# Dibuat oleh pemasang DBenk Hub (installer v$INSTALLER_VERSION) — Hub + PostgreSQL. Jangan diubah tangan: jalankan
# pemasang lagi (install/upgrade). Modul (Office, Virtual Office, DevOps) dinyalakan dari dasbor Hub dan berjalan sebagai
# kontainer $PROJECT-<modul> di jaringan yang sama (hanya lewat Hub: <alamat>/office, /vo, /devops).
# Hub dari: $HUB_SRC
name: $PROJECT
services:
  hub:
    image: $HUB_LOCAL
    pull_policy: never
    restart: unless-stopped
    env_file: .env
    environment:
      HUB_DATABASE_URL: postgres://hub:\${POSTGRES_PASSWORD}@db:5432/hub?sslmode=disable
      HUB_LISTEN: 0.0.0.0
      HUB_PORT: "8080"
      HUB_OWNER_USERNAME: "$OWNER_USER"
      HUB_OWNER_EMAIL: "$OWNER_EMAIL"
    group_add: ["$DOCKER_GID"]
    volumes:
      - $SOCK:/var/run/docker.sock
      - ./backups:/var/lib/dbenk-hub/backups
    ports:
      - "$BIND:$PORT:8080"
    mem_limit: 768m
    labels:
      dbenk.project: $PROJECT
    depends_on:
      db:
        condition: service_healthy
  db:
    image: $POSTGRES_IMAGE
    restart: unless-stopped
    environment:
      POSTGRES_DB: hub
      POSTGRES_USER: hub
      POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
    volumes:
      - db:/var/lib/postgresql/data
    labels:
      dbenk.project: $PROJECT
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "hub"]
      interval: 2s
      timeout: 5s
      retries: 30
volumes:
  db:
    labels:
      dbenk.project: $PROJECT
networks:
  default:
    labels:
      dbenk.project: $PROJECT
EOF
  $DOCKER compose -p "$PROJECT" config -q || die "docker-compose.yml tidak sah"

  say "Menjalankan DBenk Hub di $DIR ..."
  $DOCKER compose -p "$PROJECT" up -d --remove-orphans >"$TMP/up.log" 2>&1 || { cat "$TMP/up.log" >&2; die "docker compose up gagal"; }
  i=0
  until $DOCKER compose -p "$PROJECT" exec -T hub /usr/local/bin/dbenk-hub -healthcheck >/dev/null 2>&1; do
    i=$((i + 1))
    [ $i -lt 90 ] || die "Hub belum sehat setelah 3 menit: $DOCKER compose -p $PROJECT logs hub"
    sleep 2
  done
  if [ $FIRST = 1 ] && ! $DOCKER compose -p "$PROJECT" logs hub 2>&1 | grep -q 'DBENK HUB siap'; then
    die "Hub jalan tetapi Owner belum dibuat: lihat $DOCKER compose -p $PROJECT logs hub"
  fi
  if [ $FIRST = 1 ] || [ $HANDOFF = 1 ]; then
    # Owner dibuat dan serah terima diimpor saat Hub menyala: tidak ada yang perlu tersimpan lagi di disk/env kontainer
    envdel HUB_OWNER_PASSWORD
    envdel HUB_MODULE_IMAGES
    envdel HUB_LICENSE_RENEW_URL
    $DOCKER compose -p "$PROJECT" up -d >/dev/null 2>&1
  fi
  {
    printf 'installer=%s\nchannel=%s\nrelease_url=%s\nsequence=%s\nhub_version=%s\nhub_image=%s\n' \
      "$INSTALLER_VERSION" "$CHANNEL" "$BASE" "$SEQ" "$HUB_VER" "$HUB_SRC"
    printf 'modules=%s\nport=%s\nbind=%s\nproject=%s\nupdated_at=%s\n' "$MOD_REFS" "$PORT" "$BIND" "$PROJECT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  } >"$STATE"

  say ""
  say "DBenk Hub $HUB_VER siap. Buka salah satu alamat ini di browser:"
  addresses
  if [ $FIRST = 1 ]; then
    say ""
    say "  Nama pengguna : $OWNER_USER"
    say "  Kata sandi    : $OWNER_PW"
    say ""
    say "Simpan kata sandi ini sekarang: hanya ditampilkan SEKALI dan tidak disimpan di server."
    say "Lalu buka menu Modul di Hub dan nyalakan Office, Virtual Office, atau DevOps."
  else
    say "(Pemasangan diperbarui; masuk dengan akun yang sudah ada.)"
  fi
  if [ -n "$CLAIM" ]; then say "Lisensi: Hub mengambil lisensi untuk kode $CLAIM sendiri dalam ±20 detik (Pengaturan → Lisensi)."; fi
  say "Simpan salinan $DIR/.env (kunci master) di tempat aman. Perbarui: sh install.sh upgrade   Status: sh install.sh status"
}

case "$ACTION" in
  uninstall) uninstall ;;
  status) status ;;
  verify)
    load_release
    say "Kanal $CHANNEL #$SEQ dari $BASE: tanda tangan sah (kunci $SIG_KID). Hub $HUB_VER = $HUB_REF"
    if [ -n "$MOD_REFS" ]; then say "Modul: $MOD_REFS"; fi ;;
  upgrade)
    [ -f "$DIR/.env" ] || die "belum ada pemasangan di $DIR (jalankan tanpa 'upgrade' untuk memasang)"
    install ;;
  install) install ;;
esac
