#!/usr/bin/env bash
# packaging/deploy-local.sh — deploy overdeck to the LOCAL services from landed main.
# audience: AI agents. Fail-closed: any step failing exits non-zero with a JSON status line;
# nothing is guessed or forced. Idempotent — safe to re-run after a failure.
# Invoked by ship.sh land (postlandcmd) AFTER main is landed+pushed, or directly.
#
# Serves from a standalone deploy clone (~/.local/share/overdeck/deploy), never the dev
# checkout — dev-tree WIP can neither block a deploy nor leak unlanded code into services.
set -uo pipefail

DEPLOY="${OVERDECK_DEPLOY_DIR:-$HOME/.local/share/overdeck/deploy}"
export OVERDECK_DEPLOY_DIR="$DEPLOY"

if [[ "${OVERDECK_DEPLOY_DRY_RUN:-}" == "1" ]]; then
  printf 'install-gptbridge-links:%s/modules/gptbridge/install.sh --links-only\n' "${DEPLOY}"
  printf 'sync-pi-provider:%s/modules/workstation/pi/agent/models.json\n' "${DEPLOY}"
  printf 'install-user-bin:%s/bin/deckctl sync apply bin\n' "${DEPLOY}"
  printf 'install-workstation-bin:%s/bin/deckctl sync apply claude bin\n' "${DEPLOY}"
  printf 'install-workstation-lib:%s/bin/deckctl sync apply claude lib\n' "${DEPLOY}"
  printf 'install-cloudflare-token-registry:%s/bin/deckctl sync apply claude cloudflare-token-targets.json\n' "${DEPLOY}"
  printf 'install-buildbox-registry:%s/bin/deckctl sync apply claude buildbox-hosts.json\n' "${DEPLOY}"
  printf 'install-agent-slice:%s/modules/monitor/systemd/user/agent.slice → $HOME/.config/systemd/user/agent.slice; systemctl --user daemon-reload\n' "${DEPLOY}"
  printf 'install-deploy-queue:%s/packaging/overdeck-deploy.{path,service} → $HOME/.config/systemd/user/; systemctl --user enable --now overdeck-deploy.path\n' "${DEPLOY}"
  printf 'install-systray-commands:python3 %s/modules/systray/install.py\n' "${DEPLOY}"
  printf 'install-controller:%s/packaging/install-controller.sh\n' "${DEPLOY}"
  printf 'install-web:%s/packaging/install-web.sh\n' "${DEPLOY}"
  printf 'install-buildbox-parity:%s/packaging/install-buildbox-parity.sh\n' "${DEPLOY}"
  printf 'brief-canary:%s/collector/scripts/brief-canary.ts\n' "${DEPLOY}"
  printf 'sandbox-image-parity:%s/modules/workstation/claude/bin/sandbox-provision --check --all\n' "${DEPLOY}"
  printf 'restart:overdeck-collector.service overdeck-web.service overdeck-controller.service botmaster-proxy.service overdeck-web-watchdog.service\n'
  exit 0
fi

WEB_URL="http://127.0.0.1:31337"
COLLECTOR_URL="http://127.0.0.1:31338"
SMOKE_READINESS_ATTEMPTS=60
SMOKE_READINESS_DELAY=1
SERVICES=(overdeck-collector.service overdeck-web.service overdeck-controller.service botmaster-proxy.service overdeck-web-watchdog.service)

fail() { printf '{"stage":"deploy-local","status":"%s","detail":"%s"}\n' "$1" "$2" >&2; exit 1; }

# One deploy at a time: every step below mutates the single shared clone and the live
# services, and the land queue releases its conductor lock BEFORE postlandcmd, so lands
# that queued together reach this script at once. The lock sits outside the clone, which
# has to stay pristine for the dirty check below.
LOCK="${OVERDECK_DEPLOY_LOCK:-${DEPLOY}.lock}"
exec {lockfd}>"$LOCK" || fail deploy-lock-failed "cannot open the deploy lock at $LOCK"
LOCK_WAIT="${OVERDECK_DEPLOY_LOCK_WAIT:-1800}"
QUEUE="${OVERDECK_DEPLOY_QUEUE_DIR:-${DEPLOY}-queue}"
mkdir -p "$QUEUE" || fail deploy-queue-failed "cannot create deploy queue at $QUEUE"
if ! flock -n "$lockfd"; then
  request_file="$QUEUE/req-$$-$RANDOM"
  : >"$request_file" || fail deploy-queue-failed "cannot enqueue deploy request at $request_file"
  deadline=$((SECONDS + LOCK_WAIT))
  while [[ -e "$request_file" ]]; do
    if flock -n "$lockfd"; then
      break
    fi
    if (( SECONDS >= deadline )); then
      fail deploy-lock-timeout "no successful deploy drained $request_file within ${LOCK_WAIT}s"
    fi
    sleep 1
  done
  if [[ ! -e "$request_file" ]]; then
    printf '{"stage":"deploy-local","status":"deployed-coalesced","request":"%s"}\n' "$request_file"
    exit 0
  fi
fi
shopt -s nullglob
queue_snapshot=("$QUEUE"/req-*)
shopt -u nullglob

[[ -d "$DEPLOY/.git" ]] || fail no-deploy-clone "deploy clone missing at $DEPLOY — operator: git clone <origin-url> $DEPLOY"
cd "$DEPLOY" || fail cd-failed "cannot cd to $DEPLOY"

# ~/.claude/bin resolves into this clone, so the checkout below swaps the live git shim.
# It refuses without its pin, so the pin is installed BEFORE the swap, not after.
PIN_INSTALLER="${DEPLOY}/modules/workstation/claude/bin/install-git-guard-real"
if [[ -f "$PIN_INSTALLER" ]]; then
  bash "$PIN_INSTALLER" >/dev/null || fail git-guard-pin-failed "install-git-guard-real could not pin a real git binary"
fi

# Deploy ONLY landed origin/main. Detached checkout: the clone is not a dev tree.
git fetch --quiet origin || fail fetch-failed "git fetch origin failed"
# The web service keeps immutable release artifacts inside its deploy checkout.
deploy_exclude="$DEPLOY/.git/info/exclude"
mkdir -p "${deploy_exclude%/*}" || fail exclude-failed "cannot create deploy clone exclude directory"
grep -qxF '/apps/web/.releases/' "$deploy_exclude" 2>/dev/null \
  || printf '/apps/web/.releases/\n' >>"$deploy_exclude" \
  || fail exclude-failed "cannot exclude generated web releases from deploy clone status"
deploy_status=$(git status --porcelain --untracked-files=all) \
  || fail status-failed "git status failed in deploy clone"
if [[ -n "$deploy_status" ]]; then
  pi_settings_rel="modules/workstation/pi/agent/settings.json"
  pi_status=$(printf '%s\n' "$deploy_status" | grep -F " $pi_settings_rel" || true)
  if [[ $(printf '%s\n' "$deploy_status" | wc -l) -eq 1 && -n "$pi_status" ]]; then
    pi_origin=$(mktemp) || fail status-failed "cannot stage origin/main Pi settings comparison"
    git show "origin/main:$pi_settings_rel" >"$pi_origin" \
      || { rm -f "$pi_origin"; fail deploy-clone-dirty "cannot read landed Pi settings from origin/main"; }
    if cmp -s "$pi_origin" "$DEPLOY/$pi_settings_rel"; then
      deploy_status=""
    fi
    rm -f "$pi_origin"
  fi
fi
if [[ -n "$deploy_status" ]]; then
  fail deploy-clone-dirty "deploy clone has local changes — it must stay pristine; NEVER discard them (they are another session's work): $(printf '%s' "$deploy_status" | head -10 | tr '\n"' ' _' | cut -c1-300) — inspect $DEPLOY"
fi
git checkout --quiet --detach origin/main || fail checkout-failed "checkout of origin/main failed"
bash "${DEPLOY}/modules/gptbridge/install.sh" --links-only \
  || fail install-gptbridge-links-failed "gptbridge command linking failed"
"${DEPLOY}/bin/deckctl" sync apply pi || fail sync-pi-provider-failed "Pi provider model sync failed"

pnpm install --prefer-offline --silent || fail deps-failed "pnpm install failed — see output above"

RELEASES_DIR="${OVERDECK_WEB_RELEASES_DIR:-${DEPLOY}/apps/web/.releases}"
discard_release() {
  local target="$1"
  [[ ! -e "$target" ]] || {
    chmod -R u+w "$target" && rm -rf "$target"
  }
}
freeze_dependencies() {
  local target="$1" immutable="$1/node_modules.immutable" self_link
  self_link="$target/node_modules/.pnpm/node_modules/@overdeck/web"
  if [[ -L "$self_link" ]]; then
    rm "$self_link" && ln -s ../../../.. "$self_link" || return 1
  fi
  cp -a --reflink=auto "$target/node_modules" "$immutable" || return 1
  rm -rf "$target/node_modules" || return 1
  mv "$immutable" "$target/node_modules" || return 1
  chmod -R a-w "$target/node_modules" || return 1
  bash "${DEPLOY}/packaging/web-deps-verify.sh" "$target"
}
mkdir -p "$RELEASES_DIR" || fail release-dir-failed "cannot create $RELEASES_DIR"

# Skip the web rebuild entirely when nothing that could affect apps/web's output has
# changed since the release currently being served. Release dirs are named
# .build-<full-sha-at-build-time>-XXXXXX (see mktemp calls below), so the served
# release's own directory name IS the last-deployed commit — no separate state file
# to keep in sync. Absent/unrecognized/unreachable prior commit -> always rebuild
# (fail closed: an inconclusive diff is never a reason to skip).
WEB_BUILD_NEEDED=1
prev_sha=""
candidate_release=""
if [[ -L "$RELEASES_DIR/current" ]]; then
  candidate_release="$(readlink -f "$RELEASES_DIR/current")"
  prev_base="$(basename "$candidate_release")"
  case "$prev_base" in
    .build-*-??????) prev_sha="${prev_base#.build-}"; prev_sha="${prev_sha%-??????}" ;;
  esac
fi
# readlink -f resolves a dangling symlink without error, so the target must be checked
# to actually exist and look like a built release before the skip is allowed to fire —
# otherwise a pruned/removed "current" would silently hand a phantom path downstream.
if [[ -n "$prev_sha" && -d "$candidate_release" && -f "$candidate_release/server/entry.mjs" ]] \
  && git cat-file -e "${prev_sha}^{commit}" 2>/dev/null; then
  web_changed=$(git diff --name-only "$prev_sha" HEAD -- \
    apps/web packages package.json pnpm-lock.yaml pnpm-workspace.yaml '*.npmrc' 'tsconfig*.json') \
    || web_changed="unknown"
  [[ "$web_changed" == "unknown" || -n "$web_changed" ]] || WEB_BUILD_NEEDED=0
fi

if [[ "$WEB_BUILD_NEEDED" == 0 ]]; then
  printf 'deploy-local: apps/web unchanged since %s — reusing the current release, skipping build\n' "$prev_sha" >&2
  release="$candidate_release"
else
release="$(mktemp -d "${RELEASES_DIR}/.build-$(git rev-parse HEAD)-XXXXXX")" \
  || fail release-dir-failed "cannot safely create staged release"
deps_stage="$(mktemp -d "${RELEASES_DIR}/.deps-$(git rev-parse HEAD)-XXXXXX")" \
  || { rm -rf "$release"; fail release-dir-failed "cannot safely create dependency snapshot"; }
if [[ ! -L "$RELEASES_DIR/current" && -f "${DEPLOY}/apps/web/dist/server/entry.mjs" ]]; then
  bootstrap="$(mktemp -d "${RELEASES_DIR}/.bootstrap-$(git rev-parse HEAD)-XXXXXX")" \
    || fail release-bootstrap-failed "cannot safely create bootstrap release"
  cp -a "${DEPLOY}/apps/web/dist/." "$bootstrap/" \
    || fail release-bootstrap-failed "cannot preserve the currently served release"
  pnpm --filter web deploy --prod --legacy --prefer-offline "$deps_stage" >/tmp/overdeck-deploy-deps.log 2>&1 \
    || fail release-bootstrap-failed "cannot snapshot runtime dependencies"
  mv "$deps_stage/node_modules" "$bootstrap/node_modules" \
    || fail release-bootstrap-failed "cannot attach bootstrap dependencies"
  rm -rf "$deps_stage"
  deps_stage="$(mktemp -d "${RELEASES_DIR}/.deps-$(git rev-parse HEAD)-XXXXXX")" \
    || fail release-dir-failed "cannot safely create dependency snapshot"
  freeze_dependencies "$bootstrap" \
    || fail release-bootstrap-failed "cannot make bootstrap dependencies immutable"
  bash "${DEPLOY}/packaging/web-release.sh" promote "$bootstrap" \
    || fail release-bootstrap-failed "cannot record the currently served release"
fi

pnpm --filter web build >/tmp/overdeck-deploy-build.log 2>&1 \
  || { rm -rf "$release" "$deps_stage"; fail build-failed "web build failed — tail: $(tail -c 300 /tmp/overdeck-deploy-build.log | tr '\n\"' ' _')"; }
cp -a --reflink=auto "${DEPLOY}/apps/web/dist/." "$release/" \
  || { rm -rf "$release" "$deps_stage"; fail build-stage-failed "cannot stage the built web release"; }
pnpm --filter web deploy --prod --legacy --prefer-offline "$deps_stage" >/tmp/overdeck-deploy-deps.log 2>&1 \
  || { rm -rf "$release" "$deps_stage"; fail deps-snapshot-failed "runtime dependency snapshot failed — tail: $(tail -c 300 /tmp/overdeck-deploy-deps.log | tr '\n\"' ' _')"; }
mv "$deps_stage/node_modules" "$release/node_modules" \
  || { rm -rf "$release" "$deps_stage"; fail deps-snapshot-failed "cannot attach runtime dependency snapshot"; }
rm -rf "$deps_stage"
if ! freeze_dependencies "$release"; then
  discard_release "$release" || fail release-cleanup-failed "cannot remove a rejected dependency snapshot"
  fail deps-snapshot-failed "runtime dependency snapshot is not immutable and contained"
fi

if ! OVERDECK_WEB_PREFLIGHT_LOG=/tmp/overdeck-web-preflight.log \
  OVERDECK_COLLECTOR_URL="$COLLECTOR_URL" \
  bash "${DEPLOY}/packaging/web-preflight.sh" "$release" "${DEPLOY}/apps/web/src/pages"; then
  discard_release "$release" || fail release-cleanup-failed "cannot remove a rejected web release"
  fail web-preflight-failed "staged release failed its isolated route sweep"
fi
fi

"${DEPLOY}/bin/deckctl" sync apply bin || fail install-user-bin-failed "deckctl sync apply bin failed"
"${DEPLOY}/bin/deckctl" sync apply claude bin || fail install-workstation-bin-failed "deckctl sync apply claude bin failed"
"${DEPLOY}/bin/deckctl" sync apply claude lib || fail install-workstation-lib-failed "deckctl sync apply claude lib failed"
"${DEPLOY}/bin/deckctl" sync apply claude cloudflare-token-targets.json || fail install-cloudflare-token-registry-failed "deckctl sync apply claude cloudflare-token-targets.json failed"
"${DEPLOY}/bin/deckctl" sync apply claude buildbox-hosts.json || fail install-buildbox-registry-failed "deckctl sync apply claude buildbox-hosts.json failed"
install -Dm644 "${DEPLOY}/modules/monitor/systemd/user/agent.slice" "$HOME/.config/systemd/user/agent.slice" \
  || fail install-agent-slice-failed "cannot install agent.slice"
install -Dm644 "${DEPLOY}/packaging/overdeck-deploy.path" "${DEPLOY}/packaging/overdeck-deploy.service" "$HOME/.config/systemd/user/" \
  || fail install-deploy-queue-failed "cannot install deploy queue units"
systemctl --user daemon-reload || fail agent-slice-reload-failed "systemd user manager cannot reload installed units"
systemctl --user enable --now overdeck-deploy.path \
  || fail enable-deploy-queue-failed "cannot enable overdeck-deploy.path"
python3 "${DEPLOY}/modules/systray/install.py" \
  || fail install-systray-commands-failed "systray command installation failed"
bash "${DEPLOY}/modules/workstation/claude/bin/install-git-guard-real" >/dev/null \
  || fail git-guard-pin-failed "install-git-guard-real could not pin a real git binary"
# rc 3 = only non-live checkout copies drift: a property of somebody's working tree, not
# of what was deployed, so it is printed and the deploy continues. rc 1/2 still block.
bash "${DEPLOY}/modules/workstation/claude/bin/shim-drift-check" >/dev/null
drift_rc=$?
case "$drift_rc" in
  0 | 3) ;;
  *) fail shim-drift "the PATH shims this machine executes are not the landed ones (rc=$drift_rc) — run ${DEPLOY}/modules/workstation/claude/bin/shim-drift-check" ;;
esac
# The pre-push land guard and the lander that satisfies it must move together, so the guard is
# re-armed from the same landed commit that supplied the lander.
bash "${DEPLOY}/modules/workstation/claude/workflows/hooks/install-land-guard.sh" "${OVERDECK_DEV_ROOT:-$HOME/Projects/overdeck}" >/dev/null \
  || fail land-guard-failed "could not install the guarded-trunk pre-push hook"
bash "${DEPLOY}/packaging/install-controller.sh" || fail install-controller-failed "install-controller.sh failed"
bash "${DEPLOY}/packaging/install.sh" || fail install-collector-failed "install.sh failed"
bash "${DEPLOY}/packaging/install-botmaster-proxy.sh" || fail install-botmaster-proxy-failed "install-botmaster-proxy.sh failed"
bash "${DEPLOY}/packaging/install-web.sh" || fail install-web-failed "install-web.sh failed"
bash "${DEPLOY}/packaging/install-buildbox-parity.sh" >/dev/null \
  || fail install-buildbox-parity-failed "install-buildbox-parity.sh could not arm buildbox-parity.timer"

# Brief canary: prove the incident dispatch-brief pipeline assembles against the assets
# this deploy just made live. The canary skips itself only while the assets directory is
# entirely absent (phased landing); partial or broken assets fail the deploy.
canary_out=$(cd "${DEPLOY}/collector" && OVERDECK_DEPLOY_DIR="$DEPLOY" bun scripts/brief-canary.ts 2>&1) \
  || fail brief-canary-failed "$(printf '%s' "$canary_out" | tr '\n"' ' _' | cut -c1-300)"
printf '%s\n' "$canary_out"

# The sandbox image is built on each box, so a landed Containerfile only reaches the fleet
# when somebody provisions. The tag is the content digest of the image context, so this
# deploy can name the image the landed tree expects and refuse to call itself deployed
# while a box still serves an older one. rc 4 = a box was unreachable, which says nothing
# about its image.
sandbox_parity=$(bash "${DEPLOY}/modules/workstation/claude/bin/sandbox-provision" --check --all 2>&1)
sandbox_rc=$?
case "$sandbox_rc" in
  0) ;;
  4) printf '%s\n' "$sandbox_parity" >&2 ;;
  *) fail sandbox-image-drift "$(printf '%s' "$sandbox_parity" | tr '\n"' ' _') — run ${DEPLOY}/modules/workstation/claude/bin/sandbox-provision --all" ;;
esac

# The OOM early-warning feed is produced by root timers whose enablement lives outside the
# repo, in /etc. It was lost once and nothing noticed for weeks, leaving the exhaustion ETA
# frozen through a desktop freeze. Re-assert it on every deploy of a machine already armed.
if [[ -f /etc/systemd/system/node-textfile@.timer ]]; then
  deck-sudo bash "${DEPLOY}/modules/monitor/grafana/install-textfile-timers.sh" >/dev/null ||
    fail textfile-producers-blind "node_exporter textfile producers are not armed and producing — the OOM early-warning ETA is blind"
fi

# The notification gate is a root-installed copy, so a deploy that changes its source
# would otherwise leave the armed emitter stale. Only refreshes a machine already armed.
NOTIF_GATE_SRC="${DEPLOY}/modules/security/notif-gate/notif_gate.py"
NOTIF_GATE_LIVE=/usr/local/lib/notif-gate/notif_gate.py
if [[ -e "$NOTIF_GATE_LIVE" ]] && ! cmp -s "$NOTIF_GATE_SRC" "$NOTIF_GATE_LIVE"; then
  deck-sudo bash "${DEPLOY}/modules/security/notif-gate/install.sh" >/dev/null ||
    fail notif-gate-refresh-failed "installed notification gate is stale and reinstall failed: $NOTIF_GATE_LIVE"
fi

# agent-guard runs from a copy of its source at a stable user path, so a deploy that
# changes its notification emitter would otherwise leave the running guard ungated.
# Only refreshes a machine where the guard is already installed.
AGENT_GUARD_SRC="${DEPLOY}/modules/monitor/agent-guard/src/agent_guard"
AGENT_GUARD_LIVE="$HOME/.local/share/system-monitor/lib/agent_guard"
if [[ -d "$AGENT_GUARD_LIVE" ]] &&
   ! diff -rq --exclude=__pycache__ "$AGENT_GUARD_SRC" "$AGENT_GUARD_LIVE" >/dev/null 2>&1; then
  rm -rf "$AGENT_GUARD_LIVE" && cp -a "$AGENT_GUARD_SRC" "$AGENT_GUARD_LIVE" ||
    fail agent-guard-refresh-failed "cannot refresh the installed guard at $AGENT_GUARD_LIVE"
  systemctl --user restart agent-guard.service ||
    fail agent-guard-restart-failed "agent-guard.service failed to restart after refresh"
fi

# A long-lived reaper-notifier holds the old emitter in memory; only cycle a running one.
systemctl --user try-restart reaper-notifier.service ||
  fail reaper-notifier-restart-failed "reaper-notifier.service failed to restart after refresh"

release_restart_cmd="systemctl --user restart ${SERVICES[*]}"
release_verify_cmd="col=\$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 '$COLLECTOR_URL/') && [[ \"\$col\" != 000 ]] && OVERDECK_WEB_URL='$WEB_URL' OVERDECK_WEB_PAGES_DIR='${DEPLOY}/apps/web/src/pages' bash '${DEPLOY}/packaging/web-watchdog.sh' sweep >/dev/null 2>&1"
OVERDECK_WEB_RELEASE_VERIFY_ATTEMPTS="$SMOKE_READINESS_ATTEMPTS" \
  OVERDECK_WEB_RELEASE_VERIFY_DELAY="$SMOKE_READINESS_DELAY" \
  OVERDECK_WEB_RELEASE_RESTART_CMD="$release_restart_cmd" \
  OVERDECK_WEB_RELEASE_VERIFY_CMD="$release_verify_cmd" \
  bash "${DEPLOY}/packaging/web-release.sh" activate "$release" \
  || fail smoke-failed-rolled-back "new release failed switch, restart, or smoke; prior release and service health were restored"
col=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$COLLECTOR_URL/") || col=000

sha=$(git rev-parse --short HEAD)
# Only requests present when this holder started are satisfied by this deploy.
rm -f "${queue_snapshot[@]}" || fail deploy-queue-drain-failed "cannot drain completed deploy requests"
printf '{"stage":"deploy-local","status":"deployed","sha":"%s","web":"%s","collector_http":"%s"}\n' "$sha" "$WEB_URL" "$col"
