#!/bin/bash
# =============================================================================
#  m2-supervise -- start the cores in the required order, keep them running,
#                  and shut them down cleanly.
#
#  Why a supervisor at all, and why this one:
#
#  * The cores are not independent services.  db must be listening before any
#    other core starts, because each one dials 127.0.0.1:<db port> during boot
#    and gives up if nothing answers.  A plain "run one process per container"
#    split would need five containers sharing one filesystem to satisfy the
#    relative-symlink layout, plus an external orchestrator for that ordering.
#  * Each core must be started with CWD set to its own directory: it reads
#    CONFIG/conf.txt from the CWD and writes every log and state file there.
#  * A dead core must come back without taking the world down with it.
#
#  Deliberately not supervisord/s6: this needs ~150 lines of bash, and adding a
#  Python runtime to a runtime image whose whole point is being small is a poor
#  trade.  PID 1 is docker-init (compose `init: true'), so zombie reaping is
#  handled and this script only has to forward signals.
#
#  Reads the ordered plan written by m2-render-config:
#      name|dir|argv0|readiness-port|kind
# =============================================================================
set -uo pipefail

VAR_DIR="${M2_VAR_DIR:-/opt/metin2/var}"
PLAN="$VAR_DIR/boot-plan"

M2_DB_SETTLE_SECONDS="${M2_DB_SETTLE_SECONDS:-10}"
M2_CORE_START_DELAY="${M2_CORE_START_DELAY:-3}"
M2_PORT_WAIT_TIMEOUT="${M2_PORT_WAIT_TIMEOUT:-120}"
M2_RESTART_BACKOFF="${M2_RESTART_BACKOFF:-15}"
M2_SHUTDOWN_TIMEOUT="${M2_SHUTDOWN_TIMEOUT:-45}"

declare -A CORE_PID CORE_DIR CORE_BIN CORE_PORT CORE_KIND
declare -a CORE_ORDER=()
SHUTTING_DOWN=0

log() { printf '%s [supervise] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%SZ')" "$*"; }

# -----------------------------------------------------------------------------
# Port readiness.  bash's /dev/tcp keeps netcat out of the runtime image.
# -----------------------------------------------------------------------------
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; }

wait_for_port() { # <port> <label>
  local port="$1" label="$2" deadline=$(( SECONDS + M2_PORT_WAIT_TIMEOUT ))
  while ! port_open "$port"; do
    if [ "$SHUTTING_DOWN" = "1" ]; then return 1; fi
    if [ "$SECONDS" -ge "$deadline" ]; then
      log "WARNING: $label did not open port $port within ${M2_PORT_WAIT_TIMEOUT}s; continuing anyway"
      return 1
    fi
    sleep 1
  done
  log "$label is listening on $port"
  return 0
}

# -----------------------------------------------------------------------------
# Starting a core.
#
# Log handling: each core writes syslog/syserr into its own directory (it has
# no other option -- the paths are relative to CWD and not configurable).  Its
# stdout/stderr, which carry the startup banner and the address decisions, are
# prefixed and forwarded to the container log so `docker compose logs game'
# shows something useful.  syserr is followed separately by m2-tail-syserr.
# -----------------------------------------------------------------------------
start_core() { # <name>
  local name="$1" dir="${CORE_DIR[$1]}" bin="${CORE_BIN[$1]}"

  if [ ! -x "$dir/$bin" ]; then
    log "FATAL: $dir/$bin is missing or not executable"
    return 1
  fi

  # `exec' inside the subshell is what makes this correct: the subshell is
  # replaced by the core itself, so $! is the core's own pid and a later
  # kill/kill -0 addresses the real process.  (Piping the core through `sed'
  # for a log prefix would make $! the pid of sed instead, and wrapping it in
  # setsid would put it in a session this supervisor cannot signal at all.
  # The prefixing is done by m2-tail-syserr on the files instead.)
  #
  # The core redirects its own stdout to ./stdout once it is up; this capture
  # is for everything printed before that -- notably config.cpp's ADDRESSES
  # line and any fatal exit during CONFIG parsing, which are the two things an
  # operator most needs and which would otherwise vanish.
  (
    cd "$dir" || exit 1
    exec ./"$bin" >>"$dir/container.log" 2>&1
  ) &
  local pid=$!

  CORE_PID[$name]=$pid
  log "started $name (pid $pid, cwd $dir, argv0 $bin)"
  return 0
}

stop_core() { # <name> -- SIGTERM is the graceful path: libthecore's hupsig
              #            handles SIGHUP/SIGINT/SIGTERM identically and logs
              #            "signal has been received. shutting down."
  #
  # The two declarations must stay separate. Bash expands every word of a
  # `local' command before performing any of its assignments, so
  #     local name="$1" pid="${CORE_PID[$name]}"
  # looks up CORE_PID under whatever $name held BEFORE the call -- which was
  # the last value of the boot loop's variable. Every core then gets sent the
  # last core's pid: one process receives five SIGTERMs, the other four
  # receive none and are SIGKILLed when the shutdown timeout expires, losing
  # whatever they had not yet flushed.
  local name="$1"
  local pid="${CORE_PID[$name]:-}"

  [ -n "$pid" ] || { log "stopping $name: no pid recorded"; return 0; }
  kill -0 "$pid" 2>/dev/null || return 0
  log "stopping $name (pid $pid)"
  kill -TERM "$pid" 2>/dev/null || true
}

# -----------------------------------------------------------------------------
# Stopping every core: reverse boot order, so the channel cores flush their
# players to db while db is still up.  Killing db first would lose everything
# not yet saved.
#
# Split out of shutdown_all because a rate change is a stop and a start, and it
# has to be exactly this stop -- same order, same grace period, same fallback.
# Returns 0 when every core went down on its own, 1 when SIGKILL was needed.
# -----------------------------------------------------------------------------
stop_cores() {
  local i
  for (( i=${#CORE_ORDER[@]}-1; i>=0; i-- )); do
    stop_core "${CORE_ORDER[$i]}"
    # Give each core a moment to begin flushing before signalling the next.
    # Reverse order only helps if the channel cores are actually ahead of db
    # in getting their players written out.
    sleep 1
  done

  # Loop variables are local so they cannot leak into the global `name' that
  # bit stop_core above.
  local deadline=$(( SECONDS + M2_SHUTDOWN_TIMEOUT )) alive n p
  while [ "$SECONDS" -lt "$deadline" ]; do
    alive=0
    for n in "${CORE_ORDER[@]}"; do
      p="${CORE_PID[$n]:-}"
      [ -n "$p" ] && kill -0 "$p" 2>/dev/null && alive=$(( alive + 1 ))
    done
    if [ "$alive" = "0" ]; then
      log "all cores stopped cleanly"
      for n in "${CORE_ORDER[@]}"; do CORE_PID[$n]=""; done
      return 0
    fi
    sleep 1
  done

  log "WARNING: ${alive} core(s) still alive after ${M2_SHUTDOWN_TIMEOUT}s -- sending SIGKILL"
  for n in "${CORE_ORDER[@]}"; do
    p="${CORE_PID[$n]:-}"
    [ -n "$p" ] && kill -KILL "$p" 2>/dev/null
    CORE_PID[$n]=""
  done
  return 1
}

shutdown_all() {
  [ "$SHUTTING_DOWN" = "0" ] || return
  SHUTTING_DOWN=1
  log "shutdown requested -- stopping cores in reverse order"
  stop_cores
  exit 0
}

trap shutdown_all TERM INT

# -----------------------------------------------------------------------------
# Read the plan
# -----------------------------------------------------------------------------
[ -r "$PLAN" ] || { log "FATAL: no boot plan at $PLAN"; exit 1; }

while IFS='|' read -r name dir bin port kind; do
  [ -n "$name" ] || continue
  CORE_ORDER+=("$name")
  CORE_DIR[$name]="$dir"
  CORE_BIN[$name]="$bin"
  CORE_PORT[$name]="$port"
  CORE_KIND[$name]="$kind"
done < "$PLAN"

log "boot plan: ${CORE_ORDER[*]}"

# -----------------------------------------------------------------------------
# Boot
#
# A function rather than a top-level loop because a rate change starts the
# cores again through exactly this path.  Return code:
#     0  every core started and opened its port
#     1  a core started but its port did not open in time (a warning at boot;
#        after a rate change it is what turns the panel's "done" into
#        "restart it yourself")
#     2  a core could not be started at all
# -----------------------------------------------------------------------------
boot_cores() {
  local name rc=0

  for name in "${CORE_ORDER[@]}"; do
    [ "$SHUTTING_DOWN" = "0" ] || return "$rc"

    start_core "$name" || { log "FATAL: could not start $name"; return 2; }

    case "${CORE_KIND[$name]}" in
      db)
        # The db core opens its listening socket early but keeps loading protos
        # and initialising tables afterwards; a core that connects during that
        # window gets "Got packets out of order" from MariaDB. The port check
        # plus a settle pause is what the bring-up scripts converged on.
        wait_for_port "${CORE_PORT[$name]}" "$name" || rc=1
        log "letting db settle for ${M2_DB_SETTLE_SECONDS}s before starting the rest"
        sleep "$M2_DB_SETTLE_SECONDS"
        ;;
      *)
        wait_for_port "${CORE_PORT[$name]}" "$name" || rc=1
        sleep "$M2_CORE_START_DELAY"
        ;;
    esac
  done

  log "all cores up"
  touch "$VAR_DIR/.booted"
  return "$rc"
}

# -----------------------------------------------------------------------------
# Server-wide experience / item drop / yang rates.
#
# The panel runs in its own container and can neither rewrite the tables in
# this one nor restart these cores, so it leaves a request in a spool directory
# shared between the two containers.  m2-rates does the reading and the
# rewriting; the stopping and starting has to happen here, because the cores
# read the rate tables once, while they boot, and for no other reason.
#
# See m2-rates for the whole picture.
# -----------------------------------------------------------------------------
HAVE_RATES=0
command -v m2-rates >/dev/null 2>&1 && HAVE_RATES=1

# Game master ranks the panel hands over the same way. Absent on an image built
# before this existed, hence the same guard rather than an assumption.
HAVE_GM=0
command -v m2-gm >/dev/null 2>&1 && HAVE_GM=1

# And the language the server speaks, which is the rates story exactly: files
# that live in the image, a wanted value on the state volume, and cores that
# only read them while they boot.
HAVE_LANG=0
command -v m2-lang >/dev/null 2>&1 && HAVE_LANG=1

# Map respawn settings use the same restricted spool boundary as rates. The
# game container alone rewrites regen files and restarts the cores.
HAVE_MAP_REGENS=0
command -v m2-map-regens >/dev/null 2>&1 && HAVE_MAP_REGENS=1

HAVE_SERVER_SETTINGS=0
command -v m2-server-settings >/dev/null 2>&1 && HAVE_SERVER_SETTINGS=1

server_settings_change() {
  local rc
  m2-server-settings publish running 30 'Zatrzymywanie rdzeni gry'
  stop_cores
  m2-server-settings publish running 60 'Uruchamianie rdzeni gry'
  boot_cores; rc=$?
  if [ "$rc" = 0 ]; then
    m2-rates publish
    m2-map-regens publish
    m2-server-settings complete
  else m2-server-settings failed; fi
}

lang_change() {
  local rc
  if ! m2-lang apply; then
    # The files were left as they were, so a restart would pick up nothing.
    # m2-lang has already told the panel why.
    log "the language was NOT changed -- the cores were left running"
    return 0
  fi

  log "language files replaced -- restarting the cores so the game reads them"
  stop_cores
  boot_cores; rc=$?
  if [ "$rc" = "0" ]; then
    [ "$HAVE_SERVER_SETTINGS" = 0 ] || m2-server-settings record classic_panel
    m2-lang publish
  else
    log "not every core came back after the language change"
    m2-lang publish no_restart "some cores did not come back up"
  fi
  return 0
}

rates_change() {
  local rc
  if ! m2-rates apply; then
    # The tables were left exactly as they were, so there is nothing for a
    # restart to pick up. m2-rates has already told the panel why.
    log "the new rates were NOT applied -- the cores were left running"
    return 0
  fi

  log "rates applied -- restarting the cores so the game reads them"
  stop_cores
  boot_cores; rc=$?
  if [ "$rc" = "0" ]; then
    [ "$HAVE_SERVER_SETTINGS" = 0 ] || m2-server-settings record classic_panel
    m2-rates publish            # -> whatever the apply concluded, normally ok
  else
    log "not every core came back after the rate change"
    m2-rates publish no_restart "some cores did not come back up"
  fi
  return 0
}

map_regens_change() {
  local rc
  if ! m2-map-regens apply; then
    log "the map respawn settings were NOT applied -- the cores were left running"
    return 0
  fi

  log "map respawn settings applied -- restarting the cores so the game reads them"
  stop_cores
  boot_cores; rc=$?
  if [ "$rc" = "0" ]; then
    [ "$HAVE_SERVER_SETTINGS" = 0 ] || m2-server-settings record classic_panel
    m2-map-regens publish
  else
    log "not every core came back after the map respawn change"
    m2-map-regens publish no_restart "some cores did not come back up"
  fi
  return 0
}

# Rates the operator asked for earlier are re-applied before the cores read
# anything: the tables live in the image and come back shipped-as-new whenever
# the container is recreated, while what was wanted is kept on the state
# volume.  A server whose rates were never touched does nothing here.
settings_boot_rc=0
if [ "$HAVE_SERVER_SETTINGS" = 1 ]; then
  m2-server-settings heartbeat
  m2-server-settings poll; settings_boot_rc=$?
fi

if [ "$HAVE_RATES" = "1" ]; then
  # A request the panel left while this container was down is picked up first,
  # so it is folded into this boot instead of costing a second restart five
  # seconds after the cores come up.
  m2-rates poll || true
  m2-rates apply || log "the wanted rates could not be applied -- see /var/log/m2rates.log"
fi

# The language, for the same reason and in the same place: the four files come
# back shipped-as-new whenever this container is recreated, so what the operator
# chose is put back before any core reads them.
if [ "$HAVE_LANG" = "1" ]; then
  m2-lang poll || true
  m2-lang apply || log "the wanted language could not be applied -- see /var/log/m2lang.log"
fi

# A saved map setting is reapplied before the cores begin to read regen.txt.
if [ "$HAVE_MAP_REGENS" = "1" ]; then
  m2-map-regens poll || true
  m2-map-regens apply || log "the wanted map respawn settings could not be applied"
fi

boot_cores; boot_rc=$?
if [ "$HAVE_SERVER_SETTINGS" = 1 ]; then
  if [ "$boot_rc" = 0 ]; then
    if [ "$settings_boot_rc" = 10 ]; then m2-server-settings complete
    else m2-server-settings record external_unknown; fi
  else m2-server-settings failed; fi
fi
[ "$boot_rc" != "2" ] || exit 1

# Now that the cores are actually up, the rates that were just applied really
# are live -- say so. Does nothing when no rates were ever set.
if [ "$HAVE_RATES" = "1" ]; then
  if [ "$boot_rc" = "0" ]; then
    m2-rates publish
  else
    # Applied, but this boot did not fully succeed. Claiming the rates are live
    # would be a guess; this is the state that says "they are saved, the server
    # needs looking at".
    m2-rates publish no_restart "not every core came up on this start"
  fi
fi

# And the language, on every start rather than only after a change: this is
# where the panel learns which one is live. Without it a panel that has never
# seen a switch has nothing to read and would have to guess.
if [ "$HAVE_LANG" = "1" ]; then
  if [ "$boot_rc" = "0" ]; then
    m2-lang publish
  else
    m2-lang publish no_restart "not every core came up on this start"
  fi
fi

if [ "$HAVE_MAP_REGENS" = "1" ]; then
  if [ "$boot_rc" = "0" ]; then
    m2-map-regens publish
  else
    m2-map-regens publish no_restart "not every core came up on this start"
  fi
fi

# Follow every core's syserr into the container log.  This is the file that
# actually says what is wrong, and an operator who has to exec into a container
# to find it will not find it.
m2-tail-syserr &

# -----------------------------------------------------------------------------
# Watch loop
# -----------------------------------------------------------------------------
while :; do
  sleep 5
  [ "$SHUTTING_DOWN" = "0" ] || break

  if [ "$HAVE_SERVER_SETTINGS" = 1 ]; then
    m2-server-settings heartbeat
    m2-server-settings poll; settings_rc=$?
    [ "$settings_rc" != 10 ] || server_settings_change
    [ "$SHUTTING_DOWN" = 0 ] || break
  fi

  # Did somebody save the panel's rates page?  `m2-rates poll' exits 10 when a
  # request the panel left in the spool has not been dealt with yet.  It is
  # checked here, before the core watch below, so that the restart it triggers
  # happens with a clean view of which pids are current.
  if [ "$HAVE_RATES" = "1" ]; then
    m2-rates poll; rates_rc=$?
    [ "$rates_rc" = "10" ] && rates_change
    [ "$SHUTTING_DOWN" = "0" ] || break
  fi

  if [ "$HAVE_MAP_REGENS" = "1" ]; then
    m2-map-regens poll; map_regens_rc=$?
    [ "$map_regens_rc" = "10" ] && map_regens_change
    [ "$SHUTTING_DOWN" = "0" ] || break
  fi

  # Did somebody change a game master rank in the panel?  Unlike the rates this
  # needs no restart -- it is one text command on a core's admin socket -- so
  # m2-gm does the whole job itself and there is nothing to react to here.
  [ "$HAVE_GM" = "1" ] && m2-gm poll || true

  # Did somebody change the language?  This one does need a restart: the cores
  # read the four files while they boot and never again.
  if [ "$HAVE_LANG" = "1" ]; then
    m2-lang poll; lang_rc=$?
    [ "$lang_rc" = "10" ] && lang_change
    [ "$SHUTTING_DOWN" = "0" ] || break
  fi

  for name in "${CORE_ORDER[@]}"; do
    pid="${CORE_PID[$name]:-}"
    [ -n "$pid" ] || continue
    kill -0 "$pid" 2>/dev/null && continue

    wait "$pid" 2>/dev/null; rc=$?
    log "CORE DIED: $name (pid $pid) exited with status $rc"

    if [ "${CORE_KIND[$name]}" = "db" ]; then
      # db is built without -DNDEBUG, so its assert()s are live and it aborts
      # rather than warns.  An abort here is nearly always a data or schema
      # problem, and restarting into the same data just aborts again.  Taking
      # the container down surfaces it as a restart loop the operator can see
      # instead of hiding it in a log nobody reads.
      log "db is the foundation of the stack and it aborted."
      log "  status 134 (SIGABRT) means a failed assert: a data/schema problem,"
      log "  not a crash in the port. Look for the assert line above and in"
      log "  $VAR_DIR/db/syserr. Restarting into the same data will not help."
      shutdown_all
      exit 1
    fi

    log "restarting $name in ${M2_RESTART_BACKOFF}s"
    sleep "$M2_RESTART_BACKOFF"
    [ "$SHUTTING_DOWN" = "0" ] || break
    start_core "$name" || log "WARNING: restart of $name failed"
    if wait_for_port "${CORE_PORT[$name]}" "$name"; then
      [ "$HAVE_SERVER_SETTINGS" = 0 ] || m2-server-settings record automatic "$name"
    fi
  done
done

wait
