#!/usr/bin/env bash
set -euo pipefail

# The instance id is used to namespace the dev environment to allow for multiple instances to run
# without conflicts. e.g:
#   npm run dev start
#   UMBREL_DEV_INSTANCE='apps' npm run dev start
#
# Will spin up two separate umbrel-dev instances accessible at:
#   http://umbrel-dev.local
#   http://umbrel-dev-apps.local
INSTANCE_ID_PREFIX="umbrel-dev"
INSTANCE_ID="${INSTANCE_ID_PREFIX}${UMBREL_DEV_INSTANCE:+-$UMBREL_DEV_INSTANCE}"
INSTANCE_OPTIONS="${UMBREL_DEV_OPTIONS:-}"

# The container needs a few writable paths inside the read-only /umbrel-dev source mount:
# installed dependencies, generated assets and production ui builds. Each of these is bind
# mounted from persistent writable state in the /data volume at boot (see container-init).
WRITABLE_STATE_DIR="/data/umbrel-dev-writable"
WRITABLE_DIRS=(
  "packages/umbreld/node_modules"
  "packages/umbreld/ui"
  "packages/umbreld/data"
  "packages/ui/node_modules"
  "packages/ui/dist"
  "packages/ui/public/generated-tabler-icons"
)

PRODUCTION_MODE_FLAG_FILE="${WRITABLE_STATE_DIR}/.production-mode"

# The writable directories must exist in the host checkout to be usable as mount points
# inside the read-only source mount. (fresh clones don't have them)
ensure_mount_points() {
  for directory in "${WRITABLE_DIRS[@]}"
  do
    # Some tooling symlinks these paths to another checkout, e.g. coding agents sharing
    # node_modules between git worktrees. A symlink can't back a bind mount inside the
    # container because its target isn't inside the source mount, so replace it with a
    # real directory. The container shadows these paths with its own writable state
    # anyway so nothing of the host's is used.
    if [[ -L "${PWD}/${directory}" ]]
    then
      echo "Replacing symlink '${directory}' with a real directory (required as a container mount point)..."
      rm "${PWD}/${directory}"
    fi
    mkdir -p "${PWD}/${directory}"
  done
}

# Install a package's dependencies inside the container if the last completed install
# doesn't match its lockfile. npm can't self-heal a partially written node_modules, an
# interrupted install leaves packages fully extracted but never re-runs their native
# build scripts, so installs are all-or-nothing: wipe and reinstall whenever the
# previous install didn't complete or is stale, and stamp the result only on success.
install_dependencies() {
  local package_directory="${1}"
  local node_modules="${package_directory}/node_modules"
  local stamp_file="${node_modules}/.umbrel-dev-install-stamp"
  local stamp
  stamp="$(node --version) $(sha256sum "${package_directory}/package-lock.json" | awk '{print $1}')"

  # Skip the install entirely when the last one completed against the same lockfile
  if [[ -f "${stamp_file}" ]] && [[ "$(cat "${stamp_file}")" = "${stamp}" ]]
  then
    return 0
  fi

  # Clear any stale or partially installed state
  find "${node_modules}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +

  # --no-save because the source tree is read-only, the container must never modify
  # package.json or package-lock.json.
  npm --prefix "${package_directory}" install --no-save
  echo "${stamp}" > "${stamp_file}"
}

show_help() {
  cat << EOF
umbrel-dev

Automatically initialize and manage an umbrelOS development environment.

Usage: npm run dev <command> [-- <args>]

Commands:
    help                      Show this help message
    start                     Either start an existing dev environment or create and start a new one
    logs                      Stream umbreld logs
    shell                     Get a shell inside the running dev environment
    exec -- <command>         Execute a command inside the running dev environment
    exec:noninteractive       Execute a command without interactive mode (for CI)
    client -- <rpc> [<args>]  Query the umbreld RPC server via a CLI client
    rebuild                   Rebuild the operating system image from source and reboot the dev environment into it
    recreate                  Recreate the dev environment using the existing operating system image
    restart                   Restart the dev environment
    stop                      Stop the dev environment
    reset                     Reset the dev environment to a fresh state
    destroy                   Destroy the dev environment
    production-mode           Disables dev server, live reload and umbreld > ui proxy. Resembles close to production behaviour.
    development-mode          Disables production mode.

Environment Variables:
    UMBREL_DEV_INSTANCE       The instance id of the dev environment. Allows running multiple instances of
                              umbrel-dev in different namespaces.
    UMBREL_DEV_OPTIONS        Optional custom parameters passed to 'docker run' when creating the dev environment.

Note: umbrel-dev requires a Docker environment that exposes container IPs to the host. This is how Docker
natively works on Linux and can be done with OrbStack on macOS. On Windows this should work with WSL 2.

EOF
}

build_os_image() {
  # CI runs many integration test jobs in parallel that each build this image.
  # The dedicated cache job has already exported identical layers, and mass
  # concurrent re-exports regularly fail the build with cache backend errors
  # (429/504/not_found), so allow CI to skip the redundant export.
  cache_to="--cache-to type=gha,mode=max,scope=umbrelos-amd64"
  if [[ "${UMBREL_DEV_SKIP_CACHE_EXPORT:-false}" = "true" ]]
  then
    cache_to=""
  fi
  docker buildx build --cache-from type=gha,scope=umbrelos-amd64 ${cache_to} --load --file packages/os/umbrelos.Dockerfile --tag "${INSTANCE_ID}" .
}

create_instance() {
  ensure_mount_points

  # --network host is used when running Docker within WSL, effectively undoing one
  # level of encapsulation, with umbrelOS accessible at `wsl.exe hostname -i`.
  if grep --quiet "WSL" /proc/sys/kernel/osrelease 2> /dev/null
  then
    INSTANCE_OPTIONS="${INSTANCE_OPTIONS:-"--network host"}"
  fi

  # --privileged is needed for systemd to work inside the container.
  #
  # We mount a named volume namespaced to the instance id at /data to immitate
  # the data partition of a physical install.
  #
  # We mount the monorepo inside the container at /umbrel-dev as readonly. Writable
  # directories for dependencies and build output are bind mounted over it from the
  # /data volume at boot so the container never modifies the hosts source code dir.
  #
  # --label "dev.orbstack.http-port=80" stops OrbStack from trying to guess which port
  # we're trying to expose which causes some weirdness since it often gets it wrong.
  #
  # --label "dev.orbstack.domains=${INSTANCE_ID}.local" makes the instance accessble at
  # umbrel-dev.local on OrbStack installs.
  #
  # /sbin/init kicks of systemd as the container entrypoint.
  docker run \
    --detach \
    --interactive \
    --tty \
    --privileged \
    --name "${INSTANCE_ID}" \
    --hostname "${INSTANCE_ID}" \
    --volume "${INSTANCE_ID}:/data" \
    --volume "${PWD}:/umbrel-dev:ro" \
    --label "dev.orbstack.http-port=80" \
    --label "dev.orbstack.domains=${INSTANCE_ID}.local" \
    ${INSTANCE_OPTIONS} \
    "${INSTANCE_ID}" \
    /sbin/init
}

start_instance() {
  ensure_mount_points

  docker start "${INSTANCE_ID}"
}

exec_in_instance() {
  docker exec --interactive --tty "${INSTANCE_ID}" "${@}"
}

exec_in_instance_noninteractive() {
  docker exec "${INSTANCE_ID}" "${@}"
}

stop_instance() {
  # We first need to execute poweroff inside the instance so systemd gracefully stops services before we kill the container
  exec_in_instance poweroff
  docker stop "${INSTANCE_ID}"
}

restart_instance() {
  stop_instance
  start_instance
}

remove_instance() {
  docker rm --force "${INSTANCE_ID}"
}

remove_volume() {
  docker volume rm "${INSTANCE_ID}"
}

get_instance_image_id() {
  local image_id

  # Prefer the image used by the existing container. During a rebuild the tag is
  # moved to the new image before the old container is removed.
  if image_id="$(docker container inspect --format '{{.Image}}' "${INSTANCE_ID}" 2> /dev/null)"
  then
    echo "${image_id}"
    return
  fi

  docker image inspect --format '{{.Id}}' "${INSTANCE_ID}" 2> /dev/null
}

remove_superseded_image() {
  local previous_image_id="${1}"
  local current_image_id

  current_image_id="$(docker image inspect --format '{{.Id}}' "${INSTANCE_ID}" 2> /dev/null || true)"
  if [[ -n "${previous_image_id}" ]] && [[ "${previous_image_id}" != "${current_image_id}" ]]
  then
    echo "Removing superseded operating system image..."
    # Do not force removal: if another container or tag still uses this image,
    # preserving it is safer than interrupting that instance.
    docker image rm "${previous_image_id}" > /dev/null || true
  fi
}

get_instance_ip() {
  if [[ "$(docker inspect --format '{{ .HostConfig.NetworkMode }}' "${INSTANCE_ID}")" = "host" ]]
  then
    hostname -I | awk '{print $1}'
  else
    docker inspect --format '{{ .NetworkSettings.IPAddress }}' "${INSTANCE_ID}"
  fi
}

# Get the command
if [ -z ${1+x} ]; then
  command=""
else
  command="$1"
fi

if [[ "${command}" = "start" ]] || [[ "${command}" = "" ]]
then
  echo "Starting umbrel-dev instance..."
  if ! start_instance > /dev/null
  then
    echo "Instance not found, creating a new one..."
    if ! docker image inspect "${INSTANCE_ID}" > /dev/null
    then
      build_os_image
    fi
    create_instance
  fi
  echo
  echo "umbrel-dev instance is booting up..."

  # Stream systemd logs until boot has completed
  docker logs --tail 100 --follow "${INSTANCE_ID}" 2> /dev/null &
  logs_pid=$!
  exec_in_instance systemctl is-active --wait multi-user.target > /dev/null|| true
  sleep 2
  kill "${logs_pid}" || true
  wait

  # Stream umbreld logs until web server is up
  docker exec "${INSTANCE_ID}" journalctl --unit umbrel --follow --lines 100 --output cat 2> /dev/null &
  logs_pid=$!
  docker exec "${INSTANCE_ID}" curl --silent --retry 300 --retry-delay 1 --retry-connrefused http://localhost > /dev/null 2>&1 || true
  sleep 0.1
  kill "${logs_pid}" || true
  wait

  # Done!
  cat << 'EOF'


            ,;###GGGGGGGGGGl#Sp
         ,##GGGlW""^'  '`""%GGGG#S,
       ,#GGG"                  "lGG#o
      #GGl^                      '$GG#
    ,#GGb                          \GGG,
    lGG"                            "GGG
   #GGGlGGGl##p,,p##lGGl##p,,p###ll##GGGG
  !GGGlW"""*GGGGGGG#""""WlGGGGG#W""*WGGGGS
   ""          "^          '"          ""

EOF
  echo "  Your umbrel-dev instance is ready at:"
  echo
  echo "    http://${INSTANCE_ID}.local"
  echo "    http://$(get_instance_ip)"

  exit
fi

if [[ "${command}" = "help" ]]
then
    show_help

    exit
fi

if [[ "${command}" = "shell" ]]
then
    exec_in_instance bash

    exit
fi

if [[ "${command}" = "exec" ]]
then
    shift
    exec_in_instance "${@}"

    exit
fi

if [[ "${command}" = "exec:noninteractive" ]]
then
    shift
    exec_in_instance_noninteractive "${@}"

    exit
fi

if [[ "${command}" = "logs" ]]
then
    exec_in_instance journalctl --unit umbrel --follow --lines 100 --output cat

    exit
fi

if [[ "${command}" = "client" ]]
then
    shift
    exec_in_instance npm --prefix /umbrel-dev/packages/umbreld run start -- client ${@}

    exit
fi

if [[ "${command}" = "rebuild" ]]
then
    previous_image_id="$(get_instance_image_id || true)"
    echo "Rebuilding the operating system image from source..."
    build_os_image
    echo "Restarting the dev environment with the new image..."
    stop_instance || true
    remove_instance || true
    create_instance
    remove_superseded_image "${previous_image_id}"

    exit
fi

if [[ "${command}" = "recreate" ]]
then
    echo "Recreating the dev environment with the existing image..."
    stop_instance || true
    remove_instance || true
    create_instance

    exit
fi

if [[ "${command}" = "destroy" ]]
then
    echo "Destroying the dev environment..."
    remove_instance || true
    remove_volume || true

    exit
fi

if [[ "${command}" = "reset" ]]
then
    echo "Resetting the dev environment state..."
    stop_instance || true
    remove_instance || true
    remove_volume || true
    create_instance

    exit
fi

if [[ "${command}" = "restart" ]]
then
    echo "Restarting the dev environment..."
    restart_instance

    exit
fi

if [[ "${command}" = "production-mode" ]]
then
    echo "Enabling production mode..."
    exec_in_instance mkdir -p "${WRITABLE_STATE_DIR}"
    exec_in_instance touch "${PRODUCTION_MODE_FLAG_FILE}"
    restart_instance

    exit
fi

if [[ "${command}" = "development-mode" ]]
then
    echo "Disabling production mode..."
    exec_in_instance rm -f "${PRODUCTION_MODE_FLAG_FILE}"
    restart_instance

    exit
fi

if [[ "${command}" = "stop" ]]
then
    echo "Stopping the dev environment..."
    stop_instance

    exit
fi

# This is a special command that runs directly inside the container to setup the environment
# It is not intended to be run on the host machine!
if [[ "${command}" = "container-init" ]]
then
    # The host source code is mounted read-only at /umbrel-dev so the container can never
    # modify it. The paths the container does need to write are bind mounted over it from
    # persistent writable state in the /data volume. This also shadows any node_modules
    # inherited from the host so we always get fresh Linux deps instead of macos ones.
    #
    # We previously solved this with an fs overlay across the whole source tree, but
    # overlayfs doesn't support the lower layer changing underneath it. Tools that save
    # files via rename (git checkout, Claude Code, some editors) replace the file's inode
    # which made those paths permanently return ESTALE through the overlay, killing live
    # reload until the instance was rebooted.
    echo "Setting up writable directories..."

    # Unmount fs overlays left over from previous boots of older umbrel-dev versions.
    # Fall back to a lazy unmount if the mount is busy, it's unused once we bind our
    # writable directories over the source mount below.
    while [[ "$(findmnt --noheadings --output FSTYPE --mountpoint /umbrel-dev)" = "overlay" ]]
    do
        umount /umbrel-dev || umount --lazy /umbrel-dev || break
    done

    # Migrate the production mode flag from the legacy overlay state and clean it up.
    # Only touch the legacy state once no overlay is mounted from it, otherwise we'd
    # delete the backing directories of a live mount. If an unmount failed above we
    # leave it for a future boot.
    if [[ "$(findmnt --noheadings --output FSTYPE --mountpoint /umbrel-dev)" != "overlay" ]]
    then
        if [[ -f "/data/umbrel-dev-overlay/upperdir/.production-mode" ]]
        then
            mkdir -p "${WRITABLE_STATE_DIR}"
            touch "${PRODUCTION_MODE_FLAG_FILE}"
        fi
        rm -rf /data/umbrel-dev-overlay
    fi

    for directory in "${WRITABLE_DIRS[@]}"
    do
        # The mount point must be a real directory inside the read-only source mount.
        # A symlink (e.g. node_modules linked to another checkout on the host) either
        # dangles inside the container or resolves to the wrong place, and we can't
        # fix it from in here since the source mount is read-only.
        if [[ -L "/umbrel-dev/${directory}" ]] || [[ ! -d "/umbrel-dev/${directory}" ]]
        then
            echo "Error: '${directory}' must be a real directory in the host checkout to be mountable inside the container."
            echo "Run 'npm run dev' on the host to fix this automatically, this instance will then recover on its own."
            exit 1
        fi
        mkdir -p "${WRITABLE_STATE_DIR}/${directory}"
        mountpoint --quiet "/umbrel-dev/${directory}" || mount --bind "${WRITABLE_STATE_DIR}/${directory}" "/umbrel-dev/${directory}"
    done

    # Install dependencies
    echo "Installing dependencies..."
    install_dependencies /umbrel-dev/packages/umbreld
    install_dependencies /umbrel-dev/packages/ui

    # Check if we're in production mode
    if [[ ! -f "${PRODUCTION_MODE_FLAG_FILE}" ]]
    then
        # Run umbreld and ui in development mode with live reload
        echo "Starting umbreld and ui..."
        npm --prefix /umbrel-dev/packages/umbreld run dev &
        CHOKIDAR_USEPOLLING=true npm --prefix /umbrel-dev/packages/ui run dev &
        wait
    else
        # Build static production ui bundle and serve from umbreld
        echo "Building production ui..."
        npm --prefix /umbrel-dev/packages/ui run build
        # Copy contents instead of moving the directory because both paths are mount points.
        # The dashboard and app-auth page are selected from this one build at runtime.
        rm -rf /umbrel-dev/packages/umbreld/ui/*
        cp --archive /umbrel-dev/packages/ui/dist/. /umbrel-dev/packages/umbreld/ui/
        echo "Starting umbreld in production mode..."
        npm --prefix /umbrel-dev/packages/umbreld run dev:production-mode
    fi

    exit
fi

show_help
exit
