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

# shellcheck source=mobile-stack.sh
source /usr/local/bin/mobile-stack.sh

ROLE="${1:-app}"

WEB_APP="${WEB_APP:-web}"
ADMIN_APP="${ADMIN_APP:-admin}"
LANDING_APP="${LANDING_APP:-landing}"
DESKTOP_APP="${DESKTOP_APP:-desktop}"
MOBILE_APP="${MOBILE_APP:-mobile-client}"

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

# A dev server configured with `open: true` tries to launch a browser that no
# container has, and the spawn error lands in the logs (or kills the server).
export BROWSER=none

# `pnpm run dev -- --port 5174` forwards the literal `--` to the script, and
# both Vite and Expo stop reading flags there — the server then quietly listens
# on its own default port instead of the one compose publishes. pnpm, yarn and
# bun pass trailing arguments through on their own; only npm needs the
# separator.
app_cmd_with() {
  local cmd="$1"; shift
  case "${cmd}" in
    npm\ *|npm) printf '%s -- %s' "${cmd}" "$*" ;;
    *)          printf '%s %s' "${cmd}" "$*" ;;
  esac
}

# A plain single-package repository — a lone Vite or Expo app, no workspace.
# Every --filter matches nothing there ("No projects matched the filters"), so
# the filters have to be dropped rather than narrowed.
single_package_repo() {
  [ ! -f /app/pnpm-workspace.yaml ] && ! grep -q '"workspaces"' /app/package.json 2>/dev/null
}

# The web apps that are switched on, as workspace names. EXTRA_DEPS_APPS adds
# packages that need installing but are not served here — a Node API living in
# the same monorepo, for instance.
web_apps() {
  local extra
  echo "${WEB_APP}"
  for extra in ${EXTRA_DEPS_APPS:-}; do echo "${extra}"; done
  case "${RUN_ADMIN:-true}" in false|FALSE|0|no|NO) ;; *) echo "${ADMIN_APP}" ;; esac
  case "${RUN_LANDING:-true}" in false|FALSE|0|no|NO) ;; *) echo "${LANDING_APP}" ;; esac
  case "${RUN_DESKTOP:-false}" in true|TRUE|1|yes|YES|on|ON) echo "${DESKTOP_APP}" ;; esac
}

# --filter "web..." for each app: the app plus everything it depends on.
# Fills DEPS (and PKGS below) as arrays, so app names are never word split.
deps_filters() {
  local app
  DEPS=()
  while read -r app; do
    DEPS+=(--filter "${app}...")
  done <<<"$(web_apps)"
}

# The same set minus the apps themselves, i.e. the workspace packages only.
package_filters() {
  local app
  deps_filters
  PKGS=("${DEPS[@]}")
  while read -r app; do
    PKGS+=(--filter "!${app}")
  done <<<"$(web_apps)"
}

# The two ways pnpm reports that a watcher has no work: the packages exist but
# define no dev script, or the filters matched no package at all.
nothing_to_watch() {
  grep -qE 'None of the selected packages has a .*dev.* script|No projects matched the filters' "$1"
}

# Vite restarts itself when its own config file changes, and that is the only
# way a running dev server picks up a dependency that failed to resolve when it
# started. Touching the config is a no-op edit that triggers exactly that.
touch_dev_configs() {
  local app config
  while read -r app; do
    for config in /app/apps/"${app}"/vite.config.*; do
      [ -f "${config}" ] && touch "${config}"
    done
  done <<<"$(web_apps)"
}

# A workspace package's dist/ sits outside every app root, so a running Vite
# server never hears about a rebuild: its transform cache keeps serving the old
# module even after a browser refresh. Noticing dist writes here and touching
# each enabled app's config hands the running servers a fresh module graph
# without requiring any app-side watcher configuration.
notify_dist_changes() {
  local stamp="/tmp/.run-dist-stamp"
  local interval="${DIST_RESTART_INTERVAL:-2}"
  local settle="${DIST_SETTLE_SECONDS:-3}"
  : >"${stamp}" 2>/dev/null || touch "${stamp}"
  while sleep "${interval}"; do
    [ -n "$(find /app/packages -type f -path '*/dist/*' -newer "${stamp}" -print -quit 2>/dev/null)" ] || continue
    # Parallel builds cascade (types -> utils -> ui-web); wait out the burst
    # so one edit costs one restart, not one per package.
    sleep "${settle}"
    touch "${stamp}"
    touch_dev_configs || true
    echo "[frontend] workspace dist changed — restarted dev servers to pick it up"
  done
}

# Metro cannot see host edits: it watches with fs.watch (watchman is off, and
# its native watcher is macOS-only), and inotify events do not cross a Docker
# Desktop bind mount. Reads are fine — only the notification is missing.
#
# A write made inside the container does raise inotify, and a bare touch is
# enough, so this re-touches whatever the host changed. It carries no content:
# Metro re-reads the file itself and gets the host's new bytes, which is why a
# metadata-only touch is safe here.
#
# Throttled by interval and settled like notify_dist_changes: an editor writing
# ten files should cost one rebuild, not ten, and poking a file mid-save would
# have Metro read a half-written one.
poke_host_changes() {
  local app="$1"
  local stamp="/tmp/.run-metro-stamp"
  local interval="${METRO_POKE_INTERVAL:-1}"
  local settle="${METRO_POKE_SETTLE:-1}"
  local roots=""
  local dir

  # Only where source actually lives. The watchFolders of a monorepo app cover
  # the whole workspace, and scanning that every second would burn CPU for no
  # benefit — node_modules and build output are not hand-edited.
  for dir in "/app/apps/${app}" /app/packages; do
    [ -d "${dir}" ] && roots="${roots} ${dir}"
  done
  [ -n "${roots}" ] || return 0

  : >"${stamp}" 2>/dev/null || touch "${stamp}"
  echo "[frontend] watching host edits for ${app} every ${interval}s (settle ${settle}s)"

  while sleep "${interval}"; do
    # shellcheck disable=SC2086
    [ -n "$(find ${roots} -type f -newer "${stamp}" \
              -not -path '*/node_modules/*' -not -path '*/.expo/*' \
              -not -path '*/dist/*' -not -path '*/.git/*' \
              -not -path '*/ios/*' -not -path '*/android/*' \
              -print -quit 2>/dev/null)" ] || continue

    # Wait out the burst, then take everything it left behind in one pass.
    sleep "${settle}"
    # shellcheck disable=SC2086
    find ${roots} -type f -newer "${stamp}" \
      -not -path '*/node_modules/*' -not -path '*/.expo/*' \
      -not -path '*/dist/*' -not -path '*/.git/*' \
      -not -path '*/ios/*' -not -path '*/android/*' \
      -print0 2>/dev/null | xargs -0 -r touch

    # Last, so the touches above are older than the stamp and the next pass
    # does not chase them round again.
    touch "${stamp}"
  done
}

# Resolve the compose service name to the workspace name from run/.env.
app_for_role() {
  case "$1" in
    web) echo "${WEB_APP}" ;;
    admin) echo "${ADMIN_APP}" ;;
    landing) echo "${LANDING_APP}" ;;
    desktop) echo "${DESKTOP_APP}" ;;
    mobile) echo "${MOBILE_APP}" ;;
    *) echo "$1" ;;
  esac
}

case "${ROLE}" in
  deps)
    # One-shot: install only what the enabled web apps need, then build the
    # workspace packages the apps import from dist/.
    echo "[frontend] installing workspace dependencies ..."
    if single_package_repo; then
      # Nothing to narrow and no workspace packages to build: install the one
      # package this repository holds.
      pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile
    else
      package_filters
      pnpm install --frozen-lockfile "${DEPS[@]}" \
        || pnpm install --no-frozen-lockfile "${DEPS[@]}"

      echo "[frontend] building workspace packages ..."
      pnpm "${PKGS[@]}" build
    fi
    echo "[frontend] deps ready"
    ;;

  deps-mobile)
    # Runs after `deps` so both installs share root node_modules without racing.
    echo "[frontend] installing ${MOBILE_APP} dependencies ..."
    if single_package_repo; then
      pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile
    else
      pnpm install --frozen-lockfile --filter "${MOBILE_APP}..." \
        || pnpm install --no-frozen-lockfile --filter "${MOBILE_APP}..."

      echo "[frontend] building mobile workspace packages ..."
      pnpm --filter "${MOBILE_APP}..." --filter "!${MOBILE_APP}" build
    fi
    echo "[frontend] mobile deps ready"
    ;;

  watch)
    # Rebuild workspace packages on change so app HMR picks them up.
    package_filters
    # Plenty of workspaces build their packages but never watch them, and an
    # apps-only monorepo has no packages at all. pnpm exits non-zero on both,
    # which compose's restart policy turns into a loop. Idle on those two
    # cases, and keep failing on everything else so a real crash still restarts.
    notify_dist_changes &
    set +e
    pnpm --parallel "${PKGS[@]}" dev --preserveWatchOutput 2>&1 | tee /tmp/watch.log
    code="${PIPESTATUS[0]}"
    set -e
    if nothing_to_watch /tmp/watch.log; then
      echo "[frontend] no workspace package defines a dev script — nothing to watch, idling"
      echo "[frontend] packages were built once by the dependency install"
      exec sleep infinity
    fi
    exit "${code}"
    ;;

  sync)
    # A new dependency edge (app -> @scope/package) only reaches node_modules
    # through an install, and every node_modules path is a named volume the
    # host cannot write to: `pnpm add` on the host updates package.json and the
    # lockfile in the bind mount, but the container's symlink never appears and
    # the dev server fails to resolve the import.
    #
    # Polling rather than inotify, for the same reason vite runs with polling
    # here: on macOS the bind mount does not forward inotify events into the
    # container.
    INTERVAL="${DEPS_SYNC_INTERVAL:-5}"

    manifest_hash() {
      find /app/apps /app/packages -mindepth 2 -maxdepth 2 -name package.json 2>/dev/null \
        | sort | xargs cat 2>/dev/null \
        | cat - /app/pnpm-lock.yaml 2>/dev/null | md5sum
    }

    last="$(manifest_hash)"
    echo "[frontend] watching workspace manifests every ${INTERVAL}s"
    while sleep "${INTERVAL}"; do
      current="$(manifest_hash)"
      [ "${current}" = "${last}" ] && continue
      last="${current}"
      echo "[frontend] manifests changed — reinstalling ..."
      package_filters
      # --no-frozen-lockfile: the host just changed a package.json, which is
      # exactly the case a frozen install refuses to run.
      if pnpm install --no-frozen-lockfile "${DEPS[@]}"; then
        pnpm "${PKGS[@]}" build || true
        touch_dev_configs
        echo "[frontend] workspace back in sync"
      else
        echo "[frontend] install failed — leaving the workspace as it was"
      fi
    done
    ;;

  watch-mobile)
    # Defaults to every package the mobile app depends on. Set
    # MOBILE_WATCH_PACKAGES in run/.env (space separated) to watch a narrower
    # set, e.g. when the web watcher already covers the shared packages.
    if [ -n "${MOBILE_WATCH_PACKAGES:-}" ]; then
      FILTERS=()
      for pkg in ${MOBILE_WATCH_PACKAGES}; do
        FILTERS+=(--filter "${pkg}")
      done
    else
      FILTERS=(--filter "${MOBILE_APP}..." --filter "!${MOBILE_APP}")
    fi
    # Same story as the web watcher above: nothing to watch is a configuration
    # fact, not a crash to retry.
    set +e
    pnpm --parallel "${FILTERS[@]}" dev --preserveWatchOutput 2>&1 | tee /tmp/watch-mobile.log
    code="${PIPESTATUS[0]}"
    set -e
    if nothing_to_watch /tmp/watch-mobile.log; then
      echo "[frontend] no mobile workspace package defines a dev script — idling"
      exec sleep infinity
    fi
    exit "${code}"
    ;;

  app)
    APP="$(app_for_role "${2:?app name required}")"
    PORT="${3:-5173}"
    echo "[frontend] starting ${APP} on 0.0.0.0:${PORT}"
    # APP_CMD is the app's own script when it has one (they differ: "dev" for a
    # Vite app, "start" elsewhere); the port flags are appended here so the
    # command in run/.env never has to mention the port.
    if [ -n "${APP_CMD:-}" ]; then
      exec bash -lc "$(app_cmd_with "${APP_CMD}" --host 0.0.0.0 --port "${PORT}" --strictPort)"
    fi
    if single_package_repo; then
      exec pnpm exec vite --host 0.0.0.0 --port "${PORT}" --strictPort
    fi
    exec pnpm --filter "${APP}" exec vite --host 0.0.0.0 --port "${PORT}" --strictPort
    ;;

  root-app)
    # An app beside the frontend repo, mounted on its own. Its command runs
    # exactly as written: `app` appends --host/--port for a Vite server, and a
    # plain `node server.js` would take those as script arguments.
    APP="${2:?app name required}"
    PORT="${3:-3000}"
    unset CI
    export PORT
    if [ -f /app/package.json ] && [ ! -d /app/node_modules ]; then
      echo "[frontend] installing ${APP} dependencies ..."
      if [ -f /app/pnpm-lock.yaml ]; then
        pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile
      elif [ -f /app/package-lock.json ]; then
        npm ci || npm install
      else
        npm install
      fi
    fi
    echo "[frontend] starting ${APP} on 0.0.0.0:${PORT}"
    exec bash -lc "${APP_CMD:-npm run dev}"
    ;;

  metro)
    APP="$(app_for_role "${2:?app name required}")"
    PORT="${3:-${RCT_METRO_PORT:-${MOBILE_CLIENT_PORT:-8081}}}"
    # The image sets CI=true so installs never prompt. Expo reads it too, and
    # turns watch mode off entirely: "Metro is running in CI mode, reloads are
    # disabled". Nothing this dev server does is a CI run, so drop it here.
    unset CI
    case "${METRO_POKE:-true}" in
      false|FALSE|0|no|NO|off|OFF) ;;
      *) poke_host_changes "${APP}" & ;;
    esac
    STACK="$(mobile_stack_kind "${APP}" /app)"
    echo "[frontend] starting ${APP} metro (${STACK}) on 0.0.0.0:${PORT}"
    export RCT_METRO_PORT="${PORT}"
    export METRO_DISABLE_WATCHMAN="${METRO_DISABLE_WATCHMAN:-1}"
    case "${STACK}" in
      expo)
        if [ -n "${APP_CMD:-}" ]; then
          exec bash -lc "$(app_cmd_with "${APP_CMD}" --host lan --port "${PORT}")"
        fi
        if single_package_repo; then
          exec pnpm exec expo start --dev-client --host lan --port "${PORT}"
        fi
        exec pnpm --filter "${APP}" exec expo start \
          --dev-client \
          --host lan \
          --port "${PORT}"
        ;;
      react-native)
        if [ -n "${APP_CMD:-}" ]; then
          exec bash -lc "$(app_cmd_with "${APP_CMD}" --host 0.0.0.0 --port "${PORT}")"
        fi
        if single_package_repo; then
          exec pnpm exec react-native start --host 0.0.0.0 --port "${PORT}"
        fi
        exec pnpm --filter "${APP}" exec react-native start \
          --host 0.0.0.0 \
          --port "${PORT}"
        ;;
    esac
    ;;

  *)
    exec "$@"
    ;;
esac
