#!/usr/bin/env bash
# Node backend. Every step is a command from run/.env, so this works for
# Express, Fastify, NestJS, AdonisJS ... without knowing anything about them.
set -euo pipefail

ROLE="${1:-serve}"
STATE_DIR="/run-state"

# Whatever the repository's lockfile implies, unless BACKEND_INSTALL_CMD says
# otherwise.
detect_install_cmd() {
  local dir
  # In a monorepo the API's own directory holds no lockfile — the workspace
  # root does. Assuming npm there runs "npm install" inside a pnpm workspace
  # and writes a package-lock.json into the repository, so look up as well.
  for dir in . /app; do
    if [ -f "${dir}/pnpm-lock.yaml" ]; then
      echo "pnpm install --frozen-lockfile"
      return 0
    elif [ -f "${dir}/yarn.lock" ]; then
      echo "yarn install --frozen-lockfile"
      return 0
    elif [ -f "${dir}/package-lock.json" ]; then
      echo "npm ci"
      return 0
    fi
  done
  echo "npm install"
}

run_step() {
  local label="$1" cmd="$2"
  [ -n "${cmd}" ] || return 0
  echo "[backend] ${label}: ${cmd}"
  bash -lc "${cmd}"
}

wait_for_db() {
  echo "[backend] waiting for postgres at ${DB_HOST}:${DB_PORT} ..."
  until pg_isready -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_USERNAME}" >/dev/null 2>&1; do
    sleep 1
  done
  echo "[backend] postgres is ready"
}

wait_for_deps() {
  # The `backend` service owns the install; workers just wait for it.
  until [ -n "$(ls -A node_modules 2>/dev/null)" ]; do
    echo "[backend] waiting for node_modules ..."
    sleep 2
  done
}

# Every port this container is listening on, from the kernel rather than from
# anything the project claims.
listening_ports() {
  local f state hex local_addr
  for f in /proc/net/tcp /proc/net/tcp6; do
    [ -r "${f}" ] || continue
    while read -r _ local_addr _ state _; do
      [ "${state}" = "0A" ] || continue
      hex="${local_addr##*:}"
      echo "$((16#${hex}))"
    done < <(tail -n +2 "${f}")
  done | sort -un
}

# The published port maps to $PORT inside the container. An app that ignores
# PORT and hardcodes its own listens somewhere else, the mapping then points at
# nothing, and the API looks dead from the host with no error anywhere. Rather
# than assume, look at what it actually bound and name the fix.
warn_on_port_mismatch() {
  local want="$1" ports waited=0
  # A watch-mode TypeScript build (nest start --watch, tsc -w) can compile for
  # a minute before it binds anything, so keep looking rather than judging the
  # first sample: the port appearing at all is the answer, whenever it lands.
  while [ "${waited}" -lt 120 ]; do
    sleep 10
    waited=$((waited + 10))
    ports="$(listening_ports | tr '\n' ',')"
    case ",${ports}" in
      *",${want},"*) return 0 ;;
    esac
  done
  [ -n "${ports}" ] || return 0
  echo "[backend] warning: nothing is listening on ${want} inside the container"
  echo "[backend] the app bound ${ports%,} instead — the published port reaches nothing"
  echo "[backend] set BACKEND_CONTAINER_PORT to that port in run/.env, then ./run.sh up"
}

# Written only after an install returns 0, so an interrupted one is retried.
INSTALL_MARKER="node_modules/.run-install-complete"

bootstrap() {
  pnpm config set store-dir "${PNPM_STORE_DIR}" --global >/dev/null 2>&1 || true

  # "none" is for a monorepo where another service already installed the
  # workspace. node_modules is a named volume, so test it for content rather
  # than existence — and for the marker, because an install interrupted part
  # way leaves the volume non-empty but the binaries missing, and a content
  # test alone then skips the repair install on every later start.
  if [ "${BACKEND_INSTALL_CMD:-}" = "none" ]; then
    echo "[backend] BACKEND_INSTALL_CMD=none — skipping install"
  elif [ ! -f "${INSTALL_MARKER}" ] || [ -n "${BACKEND_FORCE_INSTALL:-}" ]; then
    run_step "installing dependencies" "${BACKEND_INSTALL_CMD:-$(detect_install_cmd)}"
    touch "${INSTALL_MARKER}"
  fi

  run_step "building" "${BACKEND_BUILD_CMD:-}"
}

case "${ROLE}" in
  serve)
    wait_for_db
    bootstrap

    if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then
      # A migrate command that does not fit the project (a Prisma schema with
      # no datasource url, a missing migrations folder) must not take the API
      # down with it: the container would exit, compose would restart it, and
      # the server would never come up at all. Report it and serve anyway.
      if ! run_step "migrating" "${BACKEND_MIGRATE_CMD:-}"; then
        echo "[backend] migrate failed: ${BACKEND_MIGRATE_CMD}"
        echo "[backend] fix BACKEND_MIGRATE_CMD in run/.env, or set RUN_MIGRATIONS=false"
        echo "[backend] starting the server regardless"
        MIGRATE_FAILED=true
      fi

      # Seeders are rarely idempotent, so only ever run them once per volume.
      # Delete ${STATE_DIR}/seeded (or ./run.sh clean) to seed again.
      if [ "${RUN_SEEDERS:-false}" = "true" ] && [ -n "${BACKEND_SEED_CMD:-}" ]; then
        mkdir -p "${STATE_DIR}"
        if [ "${MIGRATE_FAILED:-false}" = "true" ]; then
          echo "[backend] migrate failed — skipping seeders"
        elif [ -e "${STATE_DIR}/seeded" ]; then
          echo "[backend] already seeded — skipping seeders"
        else
          # A seeder that fails is the same story: worth reporting, not worth
          # a restart loop. It is only marked done when it actually succeeded.
          if run_step "seeding" "${BACKEND_SEED_CMD}"; then
            touch "${STATE_DIR}/seeded"
          else
            echo "[backend] seeding failed — starting the server regardless"
          fi
        fi
      fi
    fi

    echo "[backend] serving on 0.0.0.0:${PORT:-8000}"
    warn_on_port_mismatch "${PORT:-8000}" &
    exec bash -lc "${BACKEND_START_CMD:-npm run dev}"
    ;;

  queue)
    [ -n "${BACKEND_QUEUE_CMD:-}" ] || {
      # Exiting here would trip compose's restart policy and spin forever.
      # Idle instead: visibly present, doing nothing, restarting nothing.
      echo "[backend] BACKEND_QUEUE_CMD is empty — idling (set it in run/.env, or turn this service off)"
      exec sleep infinity
    }
    wait_for_db
    wait_for_deps
    exec bash -lc "${BACKEND_QUEUE_CMD}"
    ;;

  schedule)
    [ -n "${BACKEND_SCHEDULE_CMD:-}" ] || {
      # Exiting here would trip compose's restart policy and spin forever.
      # Idle instead: visibly present, doing nothing, restarting nothing.
      echo "[backend] BACKEND_SCHEDULE_CMD is empty — idling (set it in run/.env, or turn this service off)"
      exec sleep infinity
    }
    wait_for_db
    wait_for_deps
    exec bash -lc "${BACKEND_SCHEDULE_CMD}"
    ;;

  *)
    exec "$@"
    ;;
esac
