#!/bin/bash
# panel-native 7DTD entrypoint.
#
# Contract:
#   /game       — SteamCMD-installed dedicated server (volume, populated by
#                 the panel's SteamCMD updater sidecar). If missing, exit
#                 with a helpful message telling the operator to run Update.
#   /game-saves — save + config writable volume.
#   $TELNET_PASSWORD — injected by the panel via a module secret; rendered
#                 into serverconfig.xml so telnet RCON matches.
#
# On every start we re-render serverconfig.xml from the bind-mounted
# template so operator edits to module values take effect without needing a
# Docker image rebuild. Saves and world data live in /game-saves so a steam
# validate won't ever touch them.

set -euo pipefail

log() { printf '[panel-7dtd] %s\n' "$*"; }

GAME_DIR=/game
SAVE_DIR=/game-saves
CONFIG=${SAVE_DIR}/serverconfig.xml
TEMPLATE=${SAVE_DIR}/serverconfig.xml.rendered

# Allocator-derived ports. Panel sets these via container env (manifest's
# ports[].env: + runtime.docker.env: declarations). Defaults mirror the
# canonical 7DTD numbers so single-instance setups still work without
# the panel allocator.
GAME_PORT="${GAME_PORT:-26900}"
TELNET_PORT="${TELNET_PORT:-8081}"
WEBDASH_PORT="${WEBDASH_PORT:-8080}"

# --- sanity: game files installed? ---
if [[ ! -x "${GAME_DIR}/startserver.sh" && ! -x "${GAME_DIR}/7DaysToDieServer.x86_64" ]]; then
  log "ERROR: /game is empty."
  log "       Game files haven't been downloaded yet."
  log "       Run 'Update' on this server in the panel — that kicks off a"
  log "       SteamCMD sidecar which installs 7DTD into this volume."
  exit 78   # EX_CONFIG
fi

# --- render serverconfig.xml ---
# The panel writes a pre-rendered serverconfig.xml.rendered into the save
# volume at create/update time (see module create hook). We symlink it into
# the game dir so the game binary finds it on the expected path.
if [[ -s "${TEMPLATE}" ]]; then
  log "using panel-rendered config (${TEMPLATE})"
  cp -f "${TEMPLATE}" "${CONFIG}"
elif [[ ! -s "${CONFIG}" ]]; then
  log "no panel config found — using the shipped default in ${GAME_DIR}/serverconfig.xml"
  if [[ -s "${GAME_DIR}/serverconfig.xml" ]]; then
    cp -f "${GAME_DIR}/serverconfig.xml" "${CONFIG}"
  else
    log "ERROR: no default serverconfig.xml shipped with the game either — aborting"
    exit 78
  fi
fi

# Make sure the game reads OUR config (symlink from game dir → save dir).
ln -sf "${CONFIG}" "${GAME_DIR}/serverconfig.xml"

# --- auto-populate TelnetPassword if empty ---
#
# 7DTD is surprising here: if TelnetPassword="", the server *enables* telnet
# but binds it to the container's loopback only, so any host-mapped port is
# unreachable. The panel's RCON cannot connect → every "list players" call
# fails with `telnet auth: read password prompt: EOF`.
#
# Fix: on first boot, if TelnetPassword is empty we stamp a random one into
# the config + persist the plaintext to /game-saves/.panel-telnet-password
# so the Config tab can surface it to the operator. Once set, we never
# rotate it — operator owns it from then on.
PASS_FILE="${SAVE_DIR}/.panel-telnet-password"
if grep -Eq '<property name="TelnetPassword"[[:space:]]+value=""[[:space:]]*/>' "${CONFIG}" 2>/dev/null; then
  if [[ -s "${PASS_FILE}" ]]; then
    NEW_PW=$(cat "${PASS_FILE}")
    log "using persisted TelnetPassword from ${PASS_FILE}"
  else
    NEW_PW=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
    printf '%s' "${NEW_PW}" > "${PASS_FILE}"
    chmod 600 "${PASS_FILE}" || true
    log "generated random TelnetPassword (persisted to ${PASS_FILE})"
  fi
  # Replace the empty-password line in-place. Using | as sed delimiter so
  # the replacement can contain / without escaping.
  sed -i "s|<property name=\"TelnetPassword\"[[:space:]]*value=\"\"[[:space:]]*/>|<property name=\"TelnetPassword\" value=\"${NEW_PW}\"/>|" "${CONFIG}"
fi

# --- patch port properties from env on every boot ---
# Multi-instance safety: the panel allocator picks unique host ports per
# instance, the agent injects them as env vars, and we patch them into
# serverconfig.xml here so 7DTD actually binds them. Without this, two
# 7DTD instances on the same agent would both default to 26900/8081/8080
# and only one would launch successfully.
#
# 7DTD computes the LiteNetLib alt + p2p backup ports as ServerPort+1 and
# ServerPort+2 internally — only ServerPort needs to be patched. We DO
# allocate the +1/+2 slots in module.yaml so the panel knows they're taken.
log "patching ports: ServerPort=${GAME_PORT}, TelnetPort=${TELNET_PORT}, WebDashboardPort/ControlPanelPort=${WEBDASH_PORT}"
# NOTE: 7DTD 1.0 renamed the web port property "ControlPanelPort" → "WebDashboardPort".
# Both are patched so this works across versions; the legacy line is a harmless
# no-op on 1.0 configs (no ControlPanelPort property to match). Without the
# WebDashboardPort line every instance kept the template default 8080 and, on
# this host-network agent, all but the first to boot threw the Allocs WebServer
# "Address already in use" exception.
sed -i -E \
  -e "s|(<property[[:space:]]+name=\"ServerPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${GAME_PORT}\"|" \
  -e "s|(<property[[:space:]]+name=\"TelnetPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${TELNET_PORT}\"|" \
  -e "s|(<property[[:space:]]+name=\"ControlPanelPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${WEBDASH_PORT}\"|" \
  -e "s|(<property[[:space:]]+name=\"WebDashboardPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${WEBDASH_PORT}\"|" \
  "${CONFIG}"

# --- point savegame / userdata into the save volume ---
# 7DTD writes saves + admin files under $HOME/.local/share/7DaysToDie by
# default. We redirect that whole tree into /game-saves so steam validate
# runs against /game can't stomp on world data.
mkdir -p "${SAVE_DIR}/.local/share/7DaysToDie"
export HOME=${SAVE_DIR}

# Surface the user data tree as /game/User so the file browser shows it
# alongside Data/, Mods/, steamapps/ — AMP-style flat root layout. The
# agent's lister uses `ls -lAL` which dereferences symlinks, so this
# renders as a real directory in the Files tab. Guard: only create if
# nothing's there OR what's there is already a symlink we can replace —
# never blow away a real directory an operator might have created.
if [[ ! -e "${GAME_DIR}/User" ]] || [[ -L "${GAME_DIR}/User" ]]; then
  ln -sfn "${SAVE_DIR}/.local/share/7DaysToDie" "${GAME_DIR}/User"
else
  log "WARN: ${GAME_DIR}/User exists as a real directory, leaving it alone"
fi

# --- cluster: share the Player save folder across cluster members ---
# When this instance is in a cluster, CLUSTER_ID is set and /cluster is a
# shared host bind mount (panel mount-override). We symlink this world's
# Player/ subfolder to /cluster/Player so every member reads/writes the same
# player .ttp files (shared progression). World data stays per-server.
if [[ -n "${CLUSTER_ID:-}" ]]; then
  GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true)
  GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true)
  SAVES_ROOT="${SAVE_DIR}/.local/share/7DaysToDie/Saves"
  # WHICH world's Player folder gets shared into /cluster/Player is chosen
  # EXPLICITLY by the operator in the panel's Cluster tab ("Shared player
  # world" picker). 7DTD names the save folder after the WORLD, and for
  # random-gen worlds that name is seed-derived (e.g. "Tefasizi County", not
  # the literal "RWG") and changes whenever a new map is generated — so a
  # fixed GameWorld/GameName path silently orphans the symlink. The picker
  # writes CLUSTER_PLAYER_SAVE = "<World>/<GameName>" and we use it verbatim.
  # Until the operator picks one we fall back to the literal
  # Saves/<GameWorld>/<GameName> (correct when GameWorld is already a concrete
  # world name; an RWG server will just need a one-time pick after first boot).
  if [[ -n "${CLUSTER_PLAYER_SAVE:-}" ]]; then
    SAVE_BASE="${SAVES_ROOT}/${CLUSTER_PLAYER_SAVE}"
    log "cluster ${CLUSTER_ID}: shared player world selected by operator -> ${CLUSTER_PLAYER_SAVE}"
  elif [[ -n "${GW}" && -n "${GN}" ]]; then
    SAVE_BASE="${SAVES_ROOT}/${GW}/${GN}"
    log "cluster ${CLUSTER_ID}: shared player world = ${GW}/${GN} (literal; pick one in the Cluster tab if this server uses RWG)"
  else
    SAVE_BASE=""
    log "WARN: CLUSTER_ID set but no CLUSTER_PLAYER_SAVE and GameWorld/GameName unparseable — skipping Player symlink"
  fi
  if [[ -n "${SAVE_BASE}" ]]; then
    PLAYER_DIR="${SAVE_BASE}/Player"
    mkdir -p "${SAVE_BASE}" /cluster/Player
    # Record the resolved active-world save dir so the agent's auto-provision
    # watcher can locate this world's decoration.7dt (to detect a fresh
    # native-banded world that needs aligning to the cluster canonical).
    printf '%s' "${SAVE_BASE}" > "${SAVE_DIR}/.panel-save-base" 2>/dev/null || true
    # One-time migration: when this server has a real (non-symlink) Player
    # dir from standalone play, seed the shared cluster dir from it so this
    # server's existing progression becomes the cluster source of truth.
    # Hardened for irreplaceable saves (e.g. Season 10):
    #   * only seed when the shared dir is still EMPTY (first member wins —
    #     never copy into an already-seeded cluster),
    #   * snapshot the local Player dir before touching it,
    #   * only delete the local copy once the seeding copy has SUCCEEDED, so
    #     a partial copy can never destroy the server's only Player files.
    do_symlink=1
    if [[ -d "${PLAYER_DIR}" && ! -L "${PLAYER_DIR}" ]]; then
      if [[ -z "$(ls -A /cluster/Player 2>/dev/null)" ]]; then
        snap="${SAVE_BASE}/Player.preseed-${CLUSTER_ID}"
        log "cluster ${CLUSTER_ID}: seeding empty shared Player dir from local saves (snapshot -> ${snap})"
        cp -a "${PLAYER_DIR}" "${snap}" 2>/dev/null || log "WARN: pre-seed snapshot failed (continuing)"
        if cp -a "${PLAYER_DIR}/." /cluster/Player/ 2>/dev/null; then
          rm -rf "${PLAYER_DIR}"
        else
          log "ERROR: failed to seed /cluster/Player from ${PLAYER_DIR}; KEEPING local Player, NOT symlinking (no data lost)"
          do_symlink=0
        fi
      else
        archive="${SAVE_BASE}/Player.local-pre-cluster"
        log "cluster ${CLUSTER_ID}: shared Player dir already populated; archiving local Player -> ${archive}, adopting shared progression"
        rm -rf "${archive}" 2>/dev/null || true
        mv "${PLAYER_DIR}" "${archive}" 2>/dev/null || rm -rf "${PLAYER_DIR}"
      fi
    fi
    if [[ "${do_symlink}" == "1" ]]; then
      ln -sfn /cluster/Player "${PLAYER_DIR}"
      chown -R 1000:1000 /cluster/Player 2>/dev/null || true
      log "cluster ${CLUSTER_ID}: ${PLAYER_DIR} -> /cluster/Player"
    fi

    # --- auto-provision: align a NEW world's native decorations to canonical ---
    # A freshly-generated cluster world bakes decoration.7dt / multiblocks.7dt by
    # NUMERIC block id using the engine's NATIVE id table at generation. The stamp
    # guard below then puts the CANONICAL .nim over the world's .nim, so those
    # native-baked deco ids resolve to a null Block -> DecoManager NRE -> the world
    # loads but never reaches GameStartDone ("online but unjoinable"). So BEFORE
    # stamping, if this world's decorations are still in the NATIVE id band, remap
    # them name-by-name to canonical using THIS WORLD'S OWN native blockmappings.nim
    # (which the engine wrote at generation and is still on disk this boot, before
    # the stamp). Tree-deco band: native < 24000 / canonical >= 24000 (verified).
    # STRICT no-op for already-canon worlds (season10/creative/insane/pvp -> deco in
    # canon band, never enters the branch), non-cluster servers (outside CLUSTER_ID),
    # and steady state (post-remap deco is canon-band). multiblocks.7dt uses the same
    # full native table (NOT a deco subset). See nim-freeze/REMAP_RUNBOOK.md.
    DECO_F="${SAVE_BASE}/decoration.7dt"
    CANON_B="/cluster/canonical/blockmappings.nim"
    NAT_NIM="${SAVE_BASE}/blockmappings.nim"
    REMAP_TOOL="/opt/panel-nimtools/remap_7dt.py"
    MARK="${SAVE_BASE}/.canon-provisioned"
    if [[ -f "${DECO_F}" && -f "${CANON_B}" && -f "${NAT_NIM}" && -f "${REMAP_TOOL}" ]] && command -v python3 >/dev/null 2>&1; then
      # first decoration block id = u16 @ file offset 17 (hdr 5 + record id-offset 12); mask to 15 bits.
      first_raw="$(od -An -tu2 -j17 -N2 "${DECO_F}" 2>/dev/null | tr -d ' ' || true)"
      [[ "${first_raw}" =~ ^[0-9]+$ ]] || first_raw=0
      first_deco=$(( first_raw & 0x7FFF ))
      if [[ "${first_deco}" -gt 0 && "${first_deco}" -lt 24000 ]]; then
        # Native-band deco present. Safety: confirm the on-disk .nim is the world's
        # NATIVE table (count != canonical count), so we remap against the right map
        # rather than a previously-stamped canonical (which would silently morph).
        nat_cnt="$(od -An -tu4 -j4 -N4 "${NAT_NIM}" 2>/dev/null | tr -d ' ' || true)"; [[ "${nat_cnt}" =~ ^[0-9]+$ ]] || nat_cnt=0
        canon_cnt="$(od -An -tu4 -j4 -N4 "${CANON_B}" 2>/dev/null | tr -d ' ' || true)"; [[ "${canon_cnt}" =~ ^[0-9]+$ ]] || canon_cnt=0
        if [[ "${nat_cnt}" -gt 0 && "${nat_cnt}" != "${canon_cnt}" ]]; then
          log "cluster ${CLUSTER_ID}: new world '${SAVE_BASE##*/Saves/}' has native-band decorations (first id ${first_deco}); aligning decorations to cluster (remap native->canonical)…"
          remap_ok=1
          for f in "${DECO_F}" "${SAVE_BASE}/multiblocks.7dt"; do
            [[ -f "${f}" ]] || continue
            if python3 "${REMAP_TOOL}" apply "${NAT_NIM}" "${CANON_B}" "${f}" "${f}.canon-tmp" > "/tmp/decoremap.$$" 2>&1; then
              cp -f "${f}" "${f}.native-bak"
              mv -f "${f}.canon-tmp" "${f}"
              chown 1000:1000 "${f}" 2>/dev/null || true
              log "cluster ${CLUSTER_ID}:   remapped $(basename "${f}") native->canonical ($(grep -oE 'records_rewritten=[0-9]+' "/tmp/decoremap.$$" || true))"
            else
              log "ERROR: cluster ${CLUSTER_ID}: remap of $(basename "${f}") FAILED — refusing to start (would be unjoinable). remap output:"
              while IFS= read -r _l; do log "    ${_l}"; done < "/tmp/decoremap.$$"
              rm -f "${f}.canon-tmp" "/tmp/decoremap.$$"
              remap_ok=0
              break
            fi
          done
          rm -f "/tmp/decoremap.$$" 2>/dev/null || true
          if [[ "${remap_ok}" == "1" ]]; then
            sha256sum "${CANON_B}" 2>/dev/null | cut -d' ' -f1 > "${MARK}" || true
            chown 1000:1000 "${MARK}" 2>/dev/null || true
            log "cluster ${CLUSTER_ID}: decorations aligned to cluster canonical — world is now canon-compatible"
          else
            exit 1
          fi
        else
          log "WARN: cluster ${CLUSTER_ID}: native-band decorations but world .nim count=${nat_cnt} == canonical (${canon_cnt}); cannot identify the native table to remap against — leaving as-is (may NRE; see nim-freeze/REMAP_RUNBOOK.md to fix manually)"
        fi
      fi
    fi

    # --- cluster canonical .nim freeze guard ---
    # Stamp the cluster's canonical block/item id maps over THIS world's .nim
    # on every boot, so every member resolves the shared player inventory
    # through the SAME id table. This eliminates the per-world id divergence
    # that otherwise morphs inventory items (glass block -> car hood) and wipes
    # characters to level 1 on transfer: 7DTD stores inventory by NUMERIC id,
    # each world bakes its own name<->id table (itemmappings.nim/blockmappings.nim),
    # and a shared .ttp decoded through a different table resolves every id to a
    # different name. The canonical is the season10-anchored, registry-COMPLETE
    # table (every block incl. the full base:shape cross-product, every item +
    # every item_modifier), so the engine never has to append a fresh id -> the
    # table stays frozen. It lives ONCE in the shared cluster dir
    # (/cluster/canonical) and is applied only when the world save dir already
    # exists (a freshly-generated world is frozen on its FIRST reboot).
    # Idempotent + self-healing: re-stamped every boot, so a recreate/regen or
    # any stray append can never silently re-diverge the cluster.
    if [[ -f /cluster/canonical/blockmappings.nim && -f /cluster/canonical/itemmappings.nim && -d "${SAVE_BASE}" ]]; then
      cp -f /cluster/canonical/itemmappings.nim "${SAVE_BASE}/itemmappings.nim" || log "WARN: canonical item-id-map stamp failed"
      cp -f /cluster/canonical/blockmappings.nim "${SAVE_BASE}/blockmappings.nim" || log "WARN: canonical block-id-map stamp failed"
      log "cluster ${CLUSTER_ID}: stamped canonical id maps over ${SAVE_BASE} (frozen, divergence-proof)"
    fi
  fi
else
  # Not clustered: if a stale symlink to /cluster lingers from a prior
  # membership, drop it so 7DTD recreates a local Player dir.
  GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true)
  GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true)
  if [[ -n "${GW}" && -n "${GN}" ]]; then
    PLAYER_DIR="${SAVE_DIR}/.local/share/7DaysToDie/Saves/${GW}/${GN}/Player"
    if [[ -L "${PLAYER_DIR}" ]]; then rm -f "${PLAYER_DIR}"; fi
  fi
fi

# --- sync panel-owned mods into /game/Mods/ ---
# /opt/panel-mods/ is baked into the image. We rsync (well, cp -a) each
# subdir into /game/Mods/ on every boot so:
#   - a fresh SteamCMD install picks up our mods automatically
#   - operator-deleted mods are restored
#   - image rebuilds (with newer mod DLLs) propagate without manual work
# We DO NOT remove other operator-installed mods — only overwrite
# panel-prefixed ones (named "Panel*").
if [[ -d /opt/panel-mods ]]; then
  mkdir -p "${GAME_DIR}/Mods"
  for src in /opt/panel-mods/*/; do
    name=$(basename "${src}")
    dest="${GAME_DIR}/Mods/${name}"
    log "panel mod sync: ${name} → ${dest}"
    rm -rf "${dest}"
    cp -a "${src}" "${dest}"
  done
fi

# --- go ---
cd "${GAME_DIR}"
log "starting 7 Days to Die dedicated server"
log "  game dir:     ${GAME_DIR}"
log "  save dir:     ${SAVE_DIR}"
log "  config:       ${CONFIG}"
log "  game port:    ${GAME_PORT}/udp (+1, +2 implicit)"
log "  telnet port:  ${TELNET_PORT}/tcp"
log "  control port: ${WEBDASH_PORT}/tcp"

# IMPORTANT: we deliberately do NOT use ./startserver.sh — the shipped
# script hardcodes `-logfile <file>`, routing all server output to a
# filesystem log that the panel can't tail without extra bind-mount
# gymnastics. Invoking the binary directly with `-logfile -` streams
# every line to stdout, which docker logs picks up, which the panel
# Console tab can surface in real time.
#
# LD_LIBRARY_PATH is what startserver.sh set — the game loads local .so
# files, not system ones, so this is mandatory.
export LD_LIBRARY_PATH=.

exec stdbuf -oL -eL ./7DaysToDieServer.x86_64 \
    -configfile=serverconfig.xml \
    -logfile - \
    -quit -batchmode -nographics -dedicated
