#!/usr/bin/env bash
# Shared helpers for the run/ scripts. Sourced, not executed.
set -euo pipefail

if [ -z "${RUN_DIR:-}" ]; then
  RUN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fi
# host-open (and other callers) export RUN_PACKAGE_DIR + RUN_DIR; PACKAGE_DIR
# must not fall back to RUN_DIR alone or config-layout workspaces look for
# scripts under .run/scripts/.
if [ -z "${PACKAGE_DIR:-}" ]; then
  PACKAGE_DIR="${RUN_PACKAGE_DIR:-}"
fi
if [ -z "${PACKAGE_DIR}" ] || [ ! -f "${PACKAGE_DIR}/scripts/ports.sh" ]; then
  PACKAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fi
if [ -z "${WORKSPACE_DIR:-}" ]; then
  WORKSPACE_DIR="${RUN_DIR}"
fi
if [ -z "${ENV_FILE:-}" ]; then
  ENV_FILE="${RUN_DIR}/.env"
fi

# shellcheck source=./ports.sh
source "${PACKAGE_DIR}/scripts/ports.sh"
# shellcheck source=./extra-apps.sh
source "${PACKAGE_DIR}/scripts/extra-apps.sh"

# Resolve a path from config: absolute wins, else relative to workspace root.
resolve_dir() {
  local base="${WORKSPACE_DIR:-${RUN_DIR}}"
  case "$1" in
    /*) echo "$1" ;;
    *)  echo "${base}/$1" ;;
  esac
}

# Compose interpolates from the process environment, so a stray export in the
# caller's shell silently beats run/.env and survives every recreate. Drop every
# key run/.env declares (it is re-sourced right after) plus the values derived
# below, so the file is the single source of truth.
clear_env_overrides() {
  local key
  while IFS= read -r key; do
    unset "${key}" 2>/dev/null || true
  done < <(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=.*/\2/p' "${ENV_FILE}")
  unset EXPO_PUBLIC_API_BASE_URL REACT_NATIVE_PACKAGER_HOSTNAME 2>/dev/null || true
  unset DATABASE_URL DB_CONNECTION DB_HOST DB_INTERNAL_PORT S3_ENDPOINT 2>/dev/null || true
}

load_env() {
  if [ -f "${ENV_FILE}" ]; then
    clear_env_overrides
    set -a
    # shellcheck disable=SC1090,SC1091
    source "${ENV_FILE}"
    set +a
  fi
  PROJECT_NAME="${COMPOSE_PROJECT_NAME:-myapp}"
  BACKEND_STACK="${BACKEND_STACK:-laravel}"
  BACKEND_DIR="$(resolve_dir "${BACKEND_DIR:-../backend}")"
  FRONTEND_DIR="$(resolve_dir "${FRONTEND_DIR:-../frontend}")"
  BACKEND_APP_DIR="${BACKEND_DIR%/}/${BACKEND_SUBDIR:-}"
  BACKEND_ENV_FILE="$(resolve_dir "${BACKEND_ENV_FILE:-${BACKEND_APP_DIR%/}/.env}")"
  HOST_OPEN_LABEL="local.${PROJECT_NAME}.host-open"
  export PROJECT_NAME BACKEND_STACK BACKEND_DIR BACKEND_APP_DIR BACKEND_ENV_FILE \
         FRONTEND_DIR HOST_OPEN_LABEL
  apply_mobile_host
  apply_database
  apply_storage
  apply_deploy_commands
}

# Point the backend at whichever engine DB_ENGINE selects. Compose reads these
# through ${DB_HOST} / ${DATABASE_URL} defaults, so an unset engine keeps
# working exactly as before.
apply_database() {
  local user="${DB_USERNAME:-myapp}" pass="${DB_PASSWORD:-secret}" db="${DB_DATABASE:-myapp}"
  case "${DB_ENGINE:-postgres}" in
    mysql)
      DB_CONNECTION=mysql
      DB_HOST=mysql
      DB_INTERNAL_PORT=3306
      DATABASE_URL="mysql://${user}:${pass}@mysql:3306/${db}"
      ;;
    none)
      DB_CONNECTION=sqlite
      DB_HOST=
      DB_INTERNAL_PORT=
      DATABASE_URL=""
      ;;
    *)
      DB_CONNECTION=pgsql
      DB_HOST=postgres
      DB_INTERNAL_PORT=5432
      DATABASE_URL="postgresql://${user}:${pass}@postgres:5432/${db}"
      ;;
  esac
  export DB_CONNECTION DB_HOST DB_INTERNAL_PORT DATABASE_URL
}

apply_storage() {
  if is_true "${RUN_MINIO:-false}"; then
    S3_ENDPOINT="http://minio:9000"
  else
    S3_ENDPOINT=""
  fi
  export S3_ENDPOINT
}

# Fill DEPLOY_*_CMD from the chosen target, unless the .env sets one explicitly.
apply_deploy_commands() {
  local app
  if [ -z "${DEPLOY_WEB_CMD:-}" ]; then
    app="${WEB_APP:-web}"
    case "${DEPLOY_WEB_TARGET:-none}" in
      vercel)  DEPLOY_WEB_CMD="pnpm --filter ${app} exec vercel deploy --yes" ;;
      netlify) DEPLOY_WEB_CMD="pnpm --filter ${app} exec netlify deploy --dir=dist" ;;
      docker)  DEPLOY_WEB_CMD="pnpm --filter ${app} build && docker build -t \${DEPLOY_WEB_IMAGE} apps/${app} && docker push \${DEPLOY_WEB_IMAGE}" ;;
      ssh)     DEPLOY_WEB_CMD="pnpm --filter ${app} build && rsync -az --delete apps/${app}/dist/ \${DEPLOY_WEB_SSH_DEST}" ;;
      *)       DEPLOY_WEB_CMD="" ;;
    esac
  fi
  if [ -z "${DEPLOY_MOBILE_CMD:-}" ]; then
    app="${MOBILE_APP:-mobile-client}"
    case "${DEPLOY_MOBILE_TARGET:-none}" in
      eas) DEPLOY_MOBILE_CMD="pnpm --filter ${app} exec eas build --non-interactive --platform \${DEPLOY_MOBILE_PLATFORM} --profile \${DEPLOY_ENV}" ;;
      *)   DEPLOY_MOBILE_CMD="" ;;
    esac
  fi
  if [ -z "${DEPLOY_DESKTOP_CMD:-}" ]; then
    app="${DESKTOP_APP:-desktop}"
    case "${DEPLOY_DESKTOP_TARGET:-none}" in
      electron-builder) DEPLOY_DESKTOP_CMD="pnpm --filter ${app} build && pnpm --filter ${app} exec electron-builder --publish never" ;;
      tauri)            DEPLOY_DESKTOP_CMD="pnpm --filter ${app} exec tauri build" ;;
      *)                DEPLOY_DESKTOP_CMD="" ;;
    esac
  fi
  export DEPLOY_WEB_CMD DEPLOY_MOBILE_CMD DEPLOY_DESKTOP_CMD
}

require_docker() {
  if ! command -v docker >/dev/null 2>&1; then
    echo "error: docker is not installed or not on PATH" >&2
    exit 1
  fi
  if ! docker compose version >/dev/null 2>&1; then
    echo "error: 'docker compose' (v2) is required" >&2
    exit 1
  fi
  if ! docker info >/dev/null 2>&1; then
    echo "error: the Docker daemon is not running" >&2
    exit 1
  fi
}

ensure_env() {
  if [ ! -f "${ENV_FILE}" ]; then
    echo "[run] creating ${ENV_FILE} from package defaults"
    mkdir -p "${RUN_DIR}"
    cp "${PACKAGE_DIR}/.env.example" "${ENV_FILE}"
    echo "[run] edit ${WORKSPACE_CONFIG:-run.config.toml} (or ${ENV_FILE}), then run ${RUN_CMD:-run-stack} init"
    exit 1
  fi
  load_env
  ensure_essential_config
  local script
  for script in "${PACKAGE_DIR}"/docker/frontend/entrypoint.sh \
                "${PACKAGE_DIR}"/docker/backend/*/entrypoint.sh; do
    [ -f "${script}" ] && [ ! -x "${script}" ] && chmod +x "${script}"
  done
  if [ ! -f "${BACKEND_ENV_FILE}" ] && [ -d "${BACKEND_APP_DIR}" ]; then
    if [ -f "${BACKEND_APP_DIR%/}/.env.example" ]; then
      echo "[run] creating ${BACKEND_ENV_FILE} from .env.example"
      cp "${BACKEND_APP_DIR%/}/.env.example" "${BACKEND_ENV_FILE}"
    else
      # The dashboard bind-mounts this file; without it Docker would create a
      # directory in its place.
      touch "${BACKEND_ENV_FILE}"
    fi
  fi
}

# Fail early on Laravel-only commands when the backend is a Node app.
require_laravel() {
  if [ "${BACKEND_STACK}" != "laravel" ]; then
    echo "error: '$1' needs BACKEND_STACK=laravel (current: ${BACKEND_STACK})" >&2
    echo "       for a Node backend use: ${RUN_CMD:-run-stack} backend <command>" >&2
    exit 1
  fi
}

check_layout() {
  local d
  for d in "${BACKEND_APP_DIR}" "${FRONTEND_DIR}"; do
    if [ ! -d "${d}" ]; then
      echo "error: repository not found: ${d}" >&2
      echo "       run ${RUN_CMD:-run-stack} init to detect the layout, or set BACKEND_DIR /" >&2
      echo "       FRONTEND_DIR (and BACKEND_SUBDIR for an API inside a monorepo)" >&2
      echo "       in run.config.json" >&2
      exit 1
    fi
  done
}

# Compose services listed under run.config.toml → essential (object of objects).
# Keys starting with _ are comments and are skipped.
essential_service_names() {
  [ -f "${WORKSPACE_CONFIG:-}" ] || return 0
  python3 - "${WORKSPACE_CONFIG}" <<'PY'
import json, sys

path = sys.argv[1]
try:
    with open(path, encoding="utf-8") as fh:
        text = fh.read()
except OSError:
    sys.exit(0)

section = None
if path.endswith(".json"):
    try:
        data = json.loads(text)
    except ValueError:
        sys.exit(0)
    section = data.get("essential") if isinstance(data, dict) else None
else:
    try:
        import tomllib
    except ImportError:
        sys.exit(0)
    try:
        data = tomllib.loads(text)
    except Exception:
        sys.exit(0)
    section = data.get("essential") if isinstance(data, dict) else None

if not isinstance(section, dict):
    sys.exit(0)
for name in section:
    if not str(name).startswith("_"):
        print(name)
PY
}

# Older configs have no essential block. Add backend + db + redis once.
# Always rewrite as TOML (and drop a sibling JSON if present).
ensure_essential_config() {
  local config="${WORKSPACE_CONFIG:-}"
  [ -n "${config}" ] || return 0
  local toml="${RUN_DIR:-$(dirname "${config}")}/run.config.toml"
  python3 - "${config}" "${toml}" <<'PY'
import json, os, sys

src, toml_path = sys.argv[1], sys.argv[2]
if not os.path.isfile(src) and not os.path.isfile(toml_path):
    sys.exit(0)

path = toml_path if os.path.isfile(toml_path) else src
try:
    with open(path, encoding="utf-8") as fh:
        text = fh.read()
except OSError:
    sys.exit(0)

data = None
if path.endswith(".json"):
    try:
        data = json.loads(text)
    except ValueError:
        sys.exit(0)
else:
    try:
        import tomllib
        data = tomllib.loads(text)
    except Exception:
        sys.exit(0)

if not isinstance(data, dict):
    sys.exit(0)
if isinstance(data.get("essential"), dict):
    # Already has essentials; still ensure we are on TOML.
    if path.endswith(".json"):
        pass
    else:
        sys.exit(0)

infra = data["infrastructure"] if isinstance(data.get("infrastructure"), dict) else {}

def setting(key, default=None):
    if key in infra:
        return infra[key]
    value = data.get(key)
    if value is not None and not isinstance(value, dict):
        return value
    return default

def is_true(value, default=True):
    if value is None:
        return default
    if isinstance(value, bool):
        return value
    return str(value).lower() in ("true", "1", "yes", "y", "on")

if not isinstance(data.get("essential"), dict):
    essential = {"backend": {}}
    engine = str(setting("DB_ENGINE", "postgres") or "postgres")
    if engine == "mysql":
        essential["mysql"] = {}
    elif engine != "none":
        essential["postgres"] = {}
    if is_true(setting("RUN_REDIS", True)):
        essential["redis"] = {}
    data["essential"] = essential

def emit_value(value):
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, int) and not isinstance(value, bool):
        return str(value)
    if isinstance(value, float):
        return repr(value)
    if value is None:
        return '""'
    text = str(value)
    escaped = text.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{escaped}"'

lines = []
order = [
    "project", "repositories", "backend", "apps", "infrastructure",
    "essential", "deploy", "ports", "urls", "docker", "other",
]
seen = set()
for name in order:
    if name not in data:
        continue
    seen.add(name)
    section = data[name]
    if name == "essential" and isinstance(section, dict):
        for svc, opts in section.items():
            if str(svc).startswith("_"):
                continue
            lines.append(f"[essential.{svc}]")
            if isinstance(opts, dict):
                for ok, ov in opts.items():
                    if str(ok).startswith("_"):
                        continue
                    lines.append(f"{ok} = {emit_value(ov)}")
            lines.append("")
        continue
    if not isinstance(section, dict):
        continue
    lines.append(f"[{name}]")
    for key, value in section.items():
        if str(key).startswith("_"):
            continue
        lines.append(f"{key} = {emit_value(value)}")
    lines.append("")

for name, section in data.items():
    if name in seen or name.startswith("_") or not isinstance(section, dict):
        continue
    lines.append(f"[{name}]")
    for key, value in section.items():
        lines.append(f"{key} = {emit_value(value)}")
    lines.append("")

tmp = toml_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    fh.write("\n".join(lines).rstrip() + "\n")
os.replace(tmp, toml_path)
json_sibling = os.path.join(os.path.dirname(toml_path), "run.config.json")
if os.path.isfile(json_sibling):
    os.remove(json_sibling)
print(f"Wrote essential services to {toml_path}", file=sys.stderr)
PY
  WORKSPACE_CONFIG="${toml}"
  export WORKSPACE_CONFIG
}

is_true() {
  case "${1:-true}" in
    false|FALSE|0|no|NO|off|OFF) return 1 ;;
    *) return 0 ;;
  esac
}

# shellcheck source=../docker/frontend/mobile-stack.sh
source "${PACKAGE_DIR}/docker/frontend/mobile-stack.sh"
desktop_enabled() { is_true "${RUN_DESKTOP:-false}"; }
is_macos()       { [ "$(uname -s)" = "Darwin" ]; }

detect_lan_ip() {
  if is_macos; then
    ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true
  else
    hostname -I 2>/dev/null | awk '{print $1}'
  fi
}

# Expo in Docker advertises this host to Simulator / Expo Go. localhost is the
# phone itself on a physical device, and can fail in the Simulator too.
apply_mobile_host() {
  case "${REACT_NATIVE_PACKAGER_HOSTNAME:-localhost}" in
    localhost|127.0.0.1|"")
      REACT_NATIVE_PACKAGER_HOSTNAME="$(detect_lan_ip)"
      [ -n "${REACT_NATIVE_PACKAGER_HOSTNAME}" ] || REACT_NATIVE_PACKAGER_HOSTNAME="127.0.0.1"
      ;;
  esac
  export REACT_NATIVE_PACKAGER_HOSTNAME
  case "${EXPO_PUBLIC_API_BASE_URL:-http://localhost:8000/api}" in
    http://localhost:*|http://127.0.0.1:*|"")
      EXPO_PUBLIC_API_BASE_URL="http://${REACT_NATIVE_PACKAGER_HOSTNAME}:${BACKEND_PORT:-8000}/api"
      ;;
  esac
  export EXPO_PUBLIC_API_BASE_URL
}

dc() {
  local -a args=(-f "${PACKAGE_DIR}/docker-compose.yml")
  local f
  for f in docker-compose.packages.yml docker-compose.extra.yml docker-compose.resources.yml docker-compose.override.yml; do
    if [ -f "${RUN_DIR}/${f}" ]; then
      args+=(-f "${RUN_DIR}/${f}")
    elif [ -f "${PACKAGE_DIR}/${f}" ]; then
      args+=(-f "${PACKAGE_DIR}/${f}")
    fi
  done
  if is_true "${RUN_QUEUE:-true}"; then args+=(--profile queue); fi
  if is_true "${RUN_SCHEDULER:-true}"; then args+=(--profile scheduler); fi
  if is_true "${RUN_ADMIN:-true}"; then args+=(--profile admin); fi
  if is_true "${RUN_LANDING:-true}"; then args+=(--profile landing); fi
  if desktop_enabled; then args+=(--profile desktop); fi
  case "${DB_ENGINE:-postgres}" in
    none) ;;
    mysql) args+=(--profile mysql) ;;
    *) args+=(--profile postgres) ;;
  esac
  if is_true "${RUN_REDIS:-true}"; then args+=(--profile redis); fi
  if is_true "${RUN_MAILPIT:-true}"; then args+=(--profile mailpit); fi
  if is_true "${RUN_MINIO:-false}"; then args+=(--profile minio); fi
  # One-shot deps (pnpm install + package build) routinely take longer than
  # Compose's 60s HTTP client timeout; the wait is then aborted and the
  # container is SIGKILL'd (exit 137).
  export COMPOSE_HTTP_TIMEOUT="${COMPOSE_HTTP_TIMEOUT:-3600}"
  export DOCKER_CLIENT_TIMEOUT="${DOCKER_CLIENT_TIMEOUT:-3600}"
  docker compose --project-directory "${PACKAGE_DIR}" "${args[@]}" "$@"
}

# Resolve a frontend workspace package name to a directory under FRONTEND_DIR.
frontend_app_dir() {
  local app="$1"
  local root="${FRONTEND_DIR}"
  local dir pkg

  [ -n "${app}" ] || return 0
  [ -d "${root}" ] || return 0

  if [ ! -f "${root}/pnpm-workspace.yaml" ] && ! grep -q '"workspaces"' "${root}/package.json" 2>/dev/null; then
    # Single-package frontend repo.
    if [ -f "${root}/package.json" ]; then
      printf '%s' "${root}"
    fi
    return 0
  fi

  for dir in "${root}"/apps/* "${root}"/packages/*; do
    [ -f "${dir}/package.json" ] || continue
    pkg="$(node -pe "try{JSON.parse(require('fs').readFileSync('${dir}/package.json','utf8')).name}catch(e){''}" 2>/dev/null || true)"
    if [ "${pkg}" = "${app}" ] || [ "$(basename "${dir}")" = "${app}" ]; then
      printf '%s' "${dir}"
      return 0
    fi
  done

  if [ -d "${root}/apps/${app}" ]; then
    printf '%s/apps/%s' "${root}" "${app}"
  elif [ -d "${root}/${app}" ]; then
    printf '%s/%s' "${root}" "${app}"
  fi
}

# ---- port preflight ---------------------------------------------------------
# Docker only reports a taken port as "Bind for 0.0.0.0:X failed: port is
# already allocated", and can leave the container running but attached to no
# network — which then surfaces as an unrelated failure further down the stack
# (a backend that cannot resolve its database). Check first and say so plainly.

# Ports this stack's own containers already publish: a second `up` on a running
# stack must not report its own listeners as a conflict.
own_published_ports() {
  docker ps \
    --filter "label=com.docker.compose.project=${PROJECT_NAME}" \
    --format '{{.Ports}}' 2>/dev/null |
    tr ',' '\n' |
    sed -nE 's/.*:([0-9]+)->.*/\1/p'
}

# The host ports this config actually publishes, as "KEY=port" pairs. Mirrors
# the profiles dc() selects, so a switched-off service is not checked.
configured_ports() {
  local -a pairs=(
    "BACKEND_PORT=${BACKEND_PORT:-8000}"
    "WEB_PORT=${WEB_PORT:-5173}"
    "DASHBOARD_PORT=${DASHBOARD_PORT:-8090}"
  )
  is_true "${RUN_ADMIN:-true}"   && pairs+=("ADMIN_PORT=${ADMIN_PORT:-5174}")
  is_true "${RUN_LANDING:-true}" && pairs+=("LANDING_PORT=${LANDING_PORT:-5175}")
  desktop_enabled                && pairs+=("DESKTOP_PORT=${DESKTOP_PORT:-5176}")
  is_true "${RUN_MAILPIT:-true}" && pairs+=("MAILPIT_UI_PORT=${MAILPIT_UI_PORT:-8025}")
  is_true "${RUN_REDIS:-true}"   && pairs+=("REDIS_PORT=${REDIS_PORT:-6380}")
  case "${DB_ENGINE:-postgres}" in
    none) ;;
    mysql) pairs+=("MYSQL_PORT=${MYSQL_PORT:-3307}") ;;
    *) pairs+=("POSTGRES_PORT=${POSTGRES_PORT:-5434}") ;;
  esac
  if is_true "${RUN_MINIO:-false}"; then
    pairs+=("MINIO_PORT=${MINIO_PORT:-9000}" "MINIO_CONSOLE_PORT=${MINIO_CONSOLE_PORT:-9001}")
  fi
  local extra_name
  while IFS= read -r extra_name; do
    pairs+=("$(extra_app_key "${extra_name}")_PORT=$(extra_app_port "${extra_name}")")
  done < <(extra_app_names)
  # HOST_OPEN_PORT is deliberately absent: the opener is a host LaunchAgent, not
  # a docker publish, so it cannot fail a bind here. start_host_open checks it.
  if mobile_enabled; then
    pairs+=("MOBILE_CLIENT_PORT=${MOBILE_CLIENT_PORT:-8081}")
  fi
  printf '%s\n' "${pairs[@]}"
}

preflight_ports() {
  local pair key port owner mine problems=0
  load_reservations
  mine="$(own_published_ports)"
  while read -r pair; do
    [ -n "${pair}" ] || continue
    key="${pair%%=*}"; port="${pair#*=}"
    case "${port}" in ''|*[!0-9]*) continue ;; esac
    printf '%s\n' "${mine}" | grep -qx "${port}" && continue
    if owner="$(reserved_by "${port}")"; then
      echo "  ! ${key}=${port} is reserved by ${owner} on this machine" >&2
      problems=$((problems + 1))
    elif port_in_use "${port}"; then
      owner="$(port_holder "${port}" || true)"
      echo "  ! ${key}=${port} is already in use${owner:+ by ${owner}}" >&2
      problems=$((problems + 1))
    fi
  done < <(configured_ports)

  [ "${problems}" -eq 0 ] && return 0
  echo >&2
  echo "Refusing to start: docker cannot bind the ports above, and a failed bind" >&2
  echo "can leave a container running with no network attached." >&2
  echo "Edit run/.env to free ports, or stop whatever is holding them." >&2
  return 1
}

# ---- pruning ----------------------------------------------------------------
# Turning a capability off (RUN_ADMIN=false, RUN_MOBILE=false ...) drops the
# service's profile, which stops compose from starting it but leaves any
# container from when it was on still running — holding its published ports and
# appearing in Docker Desktop as part of a stack that no longer declares it.
#
# `up --remove-orphans` does not cover this: to compose the service is still
# defined, merely not selected, so it is not an orphan. Remove by comparing the
# containers that exist against the services the current profiles enable.
prune_disabled() {
  local enabled name service
  enabled="$(dc config --services 2>/dev/null)" || return 0
  [ -n "${enabled}" ] || return 0
  while IFS=$'\t' read -r name service; do
    [ -n "${service}" ] || continue
    case "${service}" in
      mobile-client|mobile-packages|mobile-deps)
        if ! mobile_enabled; then
          echo "[run] removing ${name}: RUN_MOBILE=false"
          docker rm -f "${name}" >/dev/null 2>&1 || true
          continue
        fi
        ;;
    esac
    if ! printf '%s\n' "${enabled}" | grep -qx "${service}"; then
      echo "[run] removing ${name}: ${service} is switched off in run/.env"
      docker rm -f "${name}" >/dev/null 2>&1 || true
    fi
  done < <(docker ps -a \
    --filter "label=com.docker.compose.project=${PROJECT_NAME}" \
    --format '{{.Names}}\t{{.Label "com.docker.compose.service"}}' 2>/dev/null)
}

# ---- failure reporting ------------------------------------------------------
# Compose reports "service X didn't complete successfully: exit 1" and leaves
# the reason in the container's log. Print it, so a failed start explains itself
# instead of sending you to a second command.

# Host ports + the env URLs that must stay aligned with them (Vite / Expo).
# Renders as an aligned terminal table.
_print_tsv_table() {
  local title="$1" body="$2" formatted width=0 line sep=""
  echo "${title}"
  formatted="$(printf '%s\n' "${body}" | column -t -s $'\t')"
  while IFS= read -r line; do
    [ "${#line}" -gt "${width}" ] && width="${#line}"
  done <<< "${formatted}"
  [ "${width}" -lt 24 ] && width=24
  while [ "${#sep}" -lt "${width}" ]; do sep="${sep}-"; done
  printf '%s\n' "${formatted}" | {
    IFS= read -r line || true
    printf '%s\n' "${line}"
    printf '%s\n' "${sep}"
    cat
  }
  echo
}

print_ports_recap() {
  local bp="${BACKEND_PORT:-8000}" wp="${WEB_PORT:-5173}"
  local ports env_rows

  ports="$(printf '%s\t%s\t%s' "VARIABLE" "PORT" "URL")"
  ports="${ports}$(printf '\n%s\t%s\t%s' "BACKEND_PORT" "${bp}" "http://localhost:${bp}")"
  ports="${ports}$(printf '\n%s\t%s\t%s' "WEB_PORT" "${wp}" "http://localhost:${wp}")"
  if is_true "${RUN_ADMIN:-true}"; then
    ports="${ports}$(printf '\n%s\t%s\t%s' "ADMIN_PORT" "${ADMIN_PORT:-5174}" "http://localhost:${ADMIN_PORT:-5174}")"
  fi
  if is_true "${RUN_LANDING:-true}"; then
    ports="${ports}$(printf '\n%s\t%s\t%s' "LANDING_PORT" "${LANDING_PORT:-5175}" "http://localhost:${LANDING_PORT:-5175}")"
  fi
  if desktop_enabled; then
    ports="${ports}$(printf '\n%s\t%s\t%s' "DESKTOP_PORT" "${DESKTOP_PORT:-5176}" "http://localhost:${DESKTOP_PORT:-5176}")"
  fi
  if mobile_enabled; then
    ports="${ports}$(printf '\n%s\t%s\t%s' "MOBILE_CLIENT_PORT" "${MOBILE_CLIENT_PORT:-8081}" "http://localhost:${MOBILE_CLIENT_PORT:-8081}/status")"
    ports="${ports}$(printf '\n%s\t%s\t%s' "HOST_OPEN_PORT" "${HOST_OPEN_PORT:-8091}" "-")"
  fi
  local extra_name extra_port
  while IFS= read -r extra_name; do
    extra_port="$(extra_app_port "${extra_name}")"
    ports="${ports}$(printf '\n%s\t%s\t%s' "$(extra_app_key "${extra_name}")_PORT" "${extra_port}" "http://localhost:${extra_port}")"
  done < <(extra_app_names)
  ports="${ports}$(printf '\n%s\t%s\t%s' "DASHBOARD_PORT" "${DASHBOARD_PORT:-8090}" "http://localhost:${DASHBOARD_PORT:-8090}")"
  case "${DB_ENGINE:-postgres}" in
    postgres)
      ports="${ports}$(printf '\n%s\t%s\t%s' "POSTGRES_PORT" "${POSTGRES_PORT:-5434}" "localhost:${POSTGRES_PORT:-5434}")"
      ;;
    mysql)
      ports="${ports}$(printf '\n%s\t%s\t%s' "MYSQL_PORT" "${MYSQL_PORT:-3307}" "localhost:${MYSQL_PORT:-3307}")"
      ;;
  esac
  if is_true "${RUN_REDIS:-true}"; then
    ports="${ports}$(printf '\n%s\t%s\t%s' "REDIS_PORT" "${REDIS_PORT:-6380}" "localhost:${REDIS_PORT:-6380}")"
  fi
  if is_true "${RUN_MAILPIT:-true}"; then
    ports="${ports}$(printf '\n%s\t%s\t%s' "MAILPIT_UI_PORT" "${MAILPIT_UI_PORT:-8025}" "http://localhost:${MAILPIT_UI_PORT:-8025}")"
  fi
  if is_true "${RUN_MINIO:-false}"; then
    ports="${ports}$(printf '\n%s\t%s\t%s' "MINIO_PORT" "${MINIO_PORT:-9000}" "http://localhost:${MINIO_PORT:-9000}")"
    ports="${ports}$(printf '\n%s\t%s\t%s' "MINIO_CONSOLE_PORT" "${MINIO_CONSOLE_PORT:-9001}" "http://localhost:${MINIO_CONSOLE_PORT:-9001}")"
  fi

  env_rows="$(printf '%s\t%s' "VARIABLE" "VALUE")"
  env_rows="${env_rows}$(printf '\n%s\t%s' "VITE_API_BASE_URL" "${VITE_API_BASE_URL:-http://localhost:${bp}/api}")"
  env_rows="${env_rows}$(printf '\n%s\t%s' "VITE_WEB_APP_URL" "${VITE_WEB_APP_URL:-http://localhost:${wp}}")"
  if mobile_enabled; then
    env_rows="${env_rows}$(printf '\n%s\t%s' "EXPO_PUBLIC_API_BASE_URL" "${EXPO_PUBLIC_API_BASE_URL:-http://localhost:${bp}/api}")"
    env_rows="${env_rows}$(printf '\n%s\t%s' "REACT_NATIVE_PACKAGER_HOSTNAME" "${REACT_NATIVE_PACKAGER_HOSTNAME:-}")"
  fi

  _print_tsv_table "Ports" "${ports}"
  _print_tsv_table "Env (keep these in sync when you change a port)" "${env_rows}"
  if mobile_enabled; then
    echo "Metro check:  curl -s \"http://localhost:${MOBILE_CLIENT_PORT:-8081}/status\""
    echo
  fi
}

# Containers of this project that exited non-zero or are stuck restarting.
failed_containers() {
  docker ps -a \
    --filter "label=com.docker.compose.project=${PROJECT_NAME}" \
    --format '{{.Names}}\t{{.Status}}' 2>/dev/null |
    while IFS=$'\t' read -r name status; do
      case "${status}" in
        "Exited (0)"*) ;;
        Exited*|Restarting*) echo "${name}" ;;
      esac
    done
}

report_failures() {
  local name found=false tail_lines="${FAIL_LOG_LINES:-40}"
  while IFS= read -r name; do
    [ -n "${name}" ] || continue
    if [ "${found}" = false ]; then
      echo
      echo "──────── something failed to start ────────"
      found=true
    fi
    echo
    echo "── ${name} (last ${tail_lines} lines) ──"
    docker logs --tail "${tail_lines}" "${name}" 2>&1 | sed 's/^/  /'
  done < <(failed_containers)

  if [ "${found}" = true ]; then
    echo
    echo "Full logs:  ${RUN_CMD:-run-stack} logs <service>"
    echo "Retry:      ${RUN_CMD:-run-stack} up        (or ${RUN_CMD:-run-stack} rebuild to build images again)"
    return 1
  fi
  return 0
}

# ---- macOS host helper ------------------------------------------------------
# Docker cannot launch the iOS Simulator or an Android emulator, so a tiny
# LaunchAgent on the host does it on request from the dashboard.

# Healthy means *this* workspace's opener. Another stack that picked the same
# port answers /healthz too, and treating that as ours makes start_host_open a
# silent no-op while the dashboard talks to the wrong workspace.
host_open_healthy() {
  python3 - "${HOST_OPEN_PORT:-8091}" "${RUN_DIR}" <<'PY' >/dev/null 2>&1
import json, sys, urllib.request

port, run_dir = sys.argv[1:3]
with urllib.request.urlopen(f"http://127.0.0.1:{port}/healthz", timeout=1) as r:
    body = json.load(r)
sys.exit(0 if body.get("run_dir") == run_dir else 1)
PY
}

host_open_plist_path() {
  echo "${HOME}/Library/LaunchAgents/${HOST_OPEN_LABEL}.plist"
}

write_host_open_plist() {
  local plist python3_bin
  plist="$(host_open_plist_path)"
  python3_bin="$(command -v python3)"
  mkdir -p "${HOME}/Library/LaunchAgents"
  cat >"${plist}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>${HOST_OPEN_LABEL}</string>
  <key>ProgramArguments</key>
  <array>
    <string>${python3_bin}</string>
    <string>${PACKAGE_DIR}/scripts/host-open.py</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>HOST_OPEN_PORT</key>
    <string>${HOST_OPEN_PORT:-8091}</string>
    <key>MOBILE_CLIENT_PORT</key>
    <string>${MOBILE_CLIENT_PORT:-8081}</string>
    <key>RUN_WORKSPACE_DIR</key>
    <string>${WORKSPACE_DIR}</string>
    <key>RUN_PACKAGE_DIR</key>
    <string>${PACKAGE_DIR}</string>
    <key>RUN_DIR</key>
    <string>${RUN_DIR}</string>
  </dict>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>StandardOutPath</key>
  <string>${RUN_DIR}/.host-open.log</string>
  <key>StandardErrorPath</key>
  <string>${RUN_DIR}/.host-open.log</string>
</dict>
</plist>
EOF
}

start_host_open() {
  is_macos || return 0
  host_open_healthy && return 0
  # Someone answers on that port but it is not us: launching ours would fail to
  # bind, and the dashboard would drive the other workspace's simulator.
  if port_in_use "${HOST_OPEN_PORT:-8091}"; then
    echo "  ! HOST_OPEN_PORT=${HOST_OPEN_PORT:-8091} is held by another process$( \
      holder="$(port_holder "${HOST_OPEN_PORT:-8091}" || true)"; \
      [ -n "${holder}" ] && echo " (${holder})")" >&2
    echo "    the Start / iOS / Android buttons will not work until run/.env picks a free port" >&2
    return 0
  fi
  local uid domain
  uid="$(id -u)"
  domain="gui/${uid}/${HOST_OPEN_LABEL}"
  write_host_open_plist
  launchctl bootout "${domain}" >/dev/null 2>&1 || true
  launchctl bootstrap "gui/${uid}" "$(host_open_plist_path)"
  launchctl enable "${domain}" >/dev/null 2>&1 || true
  launchctl kickstart -k "${domain}" >/dev/null 2>&1 || true
}

stop_host_open() {
  is_macos || return 0
  local uid domain
  uid="$(id -u)"
  domain="gui/${uid}/${HOST_OPEN_LABEL}"
  launchctl bootout "${domain}" >/dev/null 2>&1 || true
}

if [ "${RUN_SKIP_LOAD_ENV:-}" != "1" ]; then
  load_env
fi
