#!/usr/bin/env bash
#
# Deploy PIG. Run on the host that serves it.
#
#   ./scripts/deploy.sh
#
# Deliberately a script rather than automated push-to-deploy. Automating it
# would mean putting an SSH key with write access to the production host onto
# the CI runner, which is a meaningful escalation for a project this size. CI
# proves the commit is sound; a human decides when it ships.
#
# That constraint still holds under the tag-to-ship flow added later. Nothing
# on the CI runner can reach this host: CI publishes an image, and this host
# PULLS it (scripts/autodeploy.sh). The direction of the credential is the
# whole point — a read-only registry token here, no host credential there. And
# a human still decides when it ships, by choosing to create a `release-*` tag.
#
#   PIG_IMAGE unset   Build from this working tree, as it always has.
#   PIG_IMAGE set     Pull that published image instead of building, and leave
#                     the checkout exactly where the caller put it. The caller
#                     is responsible for having checked out the matching
#                     commit, because the compose file, the migrations and the
#                     image have to agree.
#
# Safe to re-run. Migrations are additive and tracked.

set -euo pipefail

# Resolved BEFORE the re-exec below and carried across it. After the re-exec
# `$0` is a copy in /tmp, so `dirname "$0"` would point at the wrong tree.
PIG_REPO_ROOT="${PIG_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PIG_REPO_ROOT
cd "$PIG_REPO_ROOT"

# Run from a copy, because this script rewrites itself.
#
# `git reset --hard origin/main` below replaces this very file while bash is
# still reading it. bash does not slurp a script: it reads incrementally and
# remembers a byte OFFSET, so after the reset it resumes at that offset into
# different content — which silently skips or splices steps, and at worst
# executes a fragment of a line. The 13dec6b deploy hit exactly this: the new
# public-origin gate was on disk and never ran, because bash was still
# executing the buffered previous version.
#
# It failed harmlessly that time. It is not guaranteed to. scripts/autodeploy.sh
# has always had this guard; deploy.sh needed it for the same reason.
if [ "${PIG_DEPLOY_REEXEC:-}" != '1' ]; then
  _copy=$(mktemp -t pig-deploy.XXXXXX)
  cat "$0" > "$_copy"
  PIG_DEPLOY_REEXEC=1 exec bash "$_copy" "$@"
fi
trap 'rm -f "$0"' EXIT

# What compose will run. Must match the `image:` default in docker-compose.yml,
# because that is the tag a rollback re-points at the previous image.
IMAGE_REF="${PIG_IMAGE:-pig:local}"

# Which compose profiles are active. Set below, once .env has been read; empty
# means none, which is what compose does by default anyway.
COMPOSE_PROFILES=''

# Every compose invocation goes through this. sudo's default `env_reset` drops
# PIG_IMAGE, so a plain `sudo docker compose` interpolates the `pig:local`
# fallback in docker-compose.yml instead of the release tag: the pull then fails
# with "pull access denied for pig", and — worse, if the pull is ever made
# non-fatal — the migrate, the `up` and the rollback all silently run whatever
# `pig:local` happens to be while the log reports the release. Pass it on the
# command line via env(1): `sudo -E` and bare `sudo VAR=val` are both refused by
# the default sudoers policy, `sudo env VAR=val …` is not.
#
# COMPOSE_PROFILES rides along for the same reason and needs it just as badly:
# the piggy service is profile-gated, and compose ignores a profile-gated
# service SILENTLY — `pull`, `build` and `up` behave as though it were not in
# the file at all, with no warning and a zero exit. Carrying the profile here
# rather than at each call site is what stops one forgotten flag leaving the
# agent on the previous release.
dc() {
  sudo env PIG_IMAGE="$IMAGE_REF" COMPOSE_PROFILES="$COMPOSE_PROFILES" docker compose -p pig "$@"
}

# Gate failures come in two kinds and the caller must be able to tell them
# apart: scripts/autodeploy.sh reports to the journal what is serving right now.
#   1  a gate failed and the previous image was restored — the old release is up
#   3  a gate failed and the release under test is STILL LIVE
EXIT_STILL_LIVE=3

# Read one key out of .env WITHOUT sourcing it. Sourcing an environment file
# executes it, and this one holds every secret the deployment has.
env_value() {
  [ -f .env ] || return 0
  sed -n "s/^[[:space:]]*$1=//p" .env | tail -n1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"
}

# The same words the API accepts, from `envBoolean` in apps/api/src/lib/config.ts.
# If this and that ever disagree, the CRM offers a chat surface backed by a
# container this script never started.
is_true() {
  # Compose drops a whitespace-preceded inline comment before the container ever
  # sees the value, so this has to as well: `PIGGY_ENABLED=true  # on` would
  # otherwise read as false here and true there — the agent offered by the CRM
  # and never deployed, which is the exact fault this whole path exists to end.
  local value=${1%%[[:space:]]#*}
  case "$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in
    1 | true | yes | on) return 0 ;;
    *) return 1 ;;
  esac
}

# The database this deployment actually has, with the same defaults
# docker-compose.yml interpolates. Hardcoding `pig` here is survivable only
# while nobody sets POSTGRES_USER: on a host that does, the pre-migration dump
# would fail and take the deploy with it — fail-safe, but it fails the deploy
# instead of backing up the database, which is not the intent.
PG_USER="$(env_value POSTGRES_USER)"
PG_USER="${PG_USER:-pig}"
PG_DB="$(env_value POSTGRES_DB)"
PG_DB="${PG_DB:-pig}"

# Piggy is part of the release or it is not; there is no half-deployed state
# worth having. Left out of the pull, the build, the `up` and the rollback, it
# runs the PREVIOUS image against the schema this deploy just migrated — the
# hazard docker-compose.yml's own comment warns about — or does not run at all
# while the deploy reports success.
DEPLOY_SERVICES=(app)
PIGGY_IN_RELEASE=0
if is_true "$(env_value PIGGY_ENABLED)"; then
  COMPOSE_PROFILES='piggy'
  DEPLOY_SERVICES+=(piggy)
  PIGGY_IN_RELEASE=1
  echo "==> Piggy is enabled; it ships with this release"
fi

if [ -n "${PIG_IMAGE:-}" ]; then
  echo "==> Deploying published image $PIG_IMAGE"
  # No git sync. The caller has already detached this checkout at the tag the
  # image was built from; resetting to origin/main here would silently deploy a
  # compose file and a migration set from a different commit than the image.
  AFTER=$(git rev-parse --short HEAD)
  echo "    tree at $AFTER"
else
  echo "==> Fetching"
  git fetch -q origin
  BEFORE=$(git rev-parse --short HEAD)
  git reset --hard -q origin/main
  AFTER=$(git rev-parse --short HEAD)

  if [ "$BEFORE" = "$AFTER" ]; then
    echo "    Already at $AFTER"
  else
    echo "    $BEFORE -> $AFTER"
    git --no-pager log --oneline "$BEFORE..$AFTER" | sed 's/^/    /'
  fi
fi

echo "==> Starting the database"
# Before the dump, not after it. The backup step below `exec`s into this
# container, so on a host where the stack is down — a reboot, a `compose down`,
# a first-ever deploy — the backup was the step that failed, and it took the
# whole release with it. Fail-safe, but it aborted the deploy instead of
# protecting the data, which is the opposite of the intent.
dc up -d db
for _ in $(seq 1 60); do
  if dc exec -T db pg_isready -U "$PG_USER" -d "$PG_DB" > /dev/null; then break; fi
  sleep 1
done
if ! dc exec -T db pg_isready -U "$PG_USER" -d "$PG_DB" > /dev/null; then
  echo "ERROR: database did not become ready within 60 seconds" >&2
  exit 1
fi

echo "==> Backing up the database first"
# This used to be cheap insurance against a bad migration. It is now the thing
# standing between an agent bug and the book: Piggy can WRITE to the CRM —
# accounts, deals, activity — as the signed-in user, so a release that ships a
# broken write tool can corrupt data no migration ever touched. Restoring from
# here is the only undo that exists.
#
# Taken BEFORE the migration and before the new image starts, which is the only
# ordering that gives a dump of the schema the old code wrote.
#
# How many tables there are, so an empty dump can be told apart from a failed
# one. A first deploy legitimately dumps almost nothing; every later deploy
# dumping almost nothing is a fault.
PUBLIC_TABLES=$(dc exec -T db psql -U "$PG_USER" -d "$PG_DB" -tAc \
  "select count(*) from information_schema.tables where table_schema = 'public'" | tr -cd '0-9')
PUBLIC_TABLES="${PUBLIC_TABLES:-0}"
mkdir -p backups
BACKUP="backups/pig-$(date +%Y%m%d-%H%M%S).sql.gz"
dc exec -T db pg_dump -U "$PG_USER" "$PG_DB" | gzip > "$BACKUP"

# pipefail catches a pg_dump that FAILS. It does not catch a pg_dump that
# succeeds and says nothing — a wrong database name, an empty scratch volume
# adopted by mistake — and gzip turns that silence into a plausible-looking
# 20-byte file. A dump nobody can restore from is worse than a missing one,
# because the deploy log claims a backup was taken. Decompressing the whole
# thing verifies the gzip stream as a side effect, and `wc -c` reads to EOF so
# nothing here dies on SIGPIPE.
BACKUP_BYTES=$(gzip -dc "$BACKUP" | wc -c)
if [ "$PUBLIC_TABLES" -gt 0 ] && [ "$BACKUP_BYTES" -lt 4096 ]; then
  echo "ERROR: $PG_DB has $PUBLIC_TABLES tables but $BACKUP decompresses to only" >&2
  echo "       $BACKUP_BYTES bytes. That is not a usable dump. Refusing to migrate" >&2
  echo "       without one, because this dump is the only rollback the data has." >&2
  exit 1
fi
echo "    $BACKUP ($(du -h "$BACKUP" | cut -f1) compressed, $BACKUP_BYTES bytes of SQL, $PUBLIC_TABLES tables)"
if [ "$PUBLIC_TABLES" -eq 0 ]; then
  echo "    (no tables yet — this looks like a first deploy, so a thin dump is expected)"
fi
# Kept next to the checkout on purpose. backups/ is in .gitignore, so
# scripts/autodeploy.sh's `git checkout --detach --force` leaves it alone — a
# dump that the next deploy deletes is not a backup. Nothing here prunes them
# either; they are small, they are the only copy, and a disk-space problem is a
# better problem than a missing dump. Copy them off this host if the data
# matters as much as the uptime does.

if [ -n "${PIG_IMAGE:-}" ]; then
  echo "==> Pulling"
  dc pull "${DEPLOY_SERVICES[@]}"
else
  echo "==> Building"
  dc build "${DEPLOY_SERVICES[@]}"
fi

echo "==> Migrating before the schema-dependent app starts"
# A release may query a newly introduced table during startup. Running the
# migration from a one-off container prevents that app from crash-looping
# before an `exec`-based migration can reach it.
#
# This ordering matters more with every migration that a NEW surface depends
# on. 0014 adds piggy_conversations and piggy_messages, which the API writes on
# every agent turn — and the agent turn is paid for BEFORE the insert, so a
# release that starts ahead of its migration takes a user's question, calls a
# model, bills for it and then fails to persist the answer. `dc up` starts app
# and piggy together below, so both are behind this line.
#
# The image runs it, not the host: the migration set that runs is the one baked
# into $IMAGE_REF, so a published image and its migrations can never disagree.
# `--no-deps` because the database is already up, and letting compose start
# dependencies here would start the OLD app container against an unmigrated
# schema, which is the exact race this step exists to avoid.
dc run --rm --no-deps app pnpm exec tsx packages/db/src/migrate.ts

# The image the current container is running, captured before it is replaced.
# Without this a failed release stays live: both gates below used to exit 1 and
# leave the broken version serving, which is tolerable when a human is reading
# the terminal and an outage when the poller ran this at 04:00.
PREVIOUS_IMAGE=""
PREVIOUS_CID=$(dc ps -q app 2>/dev/null | head -n1 || true)
if [ -n "$PREVIOUS_CID" ]; then
  PREVIOUS_IMAGE=$(sudo docker inspect -f '{{.Image}}' "$PREVIOUS_CID" 2>/dev/null || true)
fi

# Restore the previous image and exit non-zero. Deliberately NOT a database
# rollback: migrations are additive, so the previous code runs against the new
# schema, and the dump taken above is the escape hatch for the case where it
# does not. The re-tag makes $IMAGE_REF point back at the old image locally; the
# next successful pull moves it forward again.
#
# Leaving the schema ahead of the image is the deliberate half of that, and it
# is safe for exactly the reason above. 0014 is the current case: it CREATES
# piggy_conversations and piggy_messages and alters nothing, so an image that
# predates it never names those tables and cannot notice they exist. The rule
# this relies on is a rule about migrations, not about this script — a
# migration that drops a column, renames one, or tightens a constraint breaks
# the previous image and makes this rollback a partial outage. Write additive
# migrations, or plan the rollback with the dump in hand.
roll_back() {
  echo "    !! $1"
  dc logs app --tail 40 || true

  if [ -z "$PREVIOUS_IMAGE" ]; then
    echo "    NO PREVIOUS IMAGE TO ROLL BACK TO — the broken release is live" >&2
    exit "$EXIT_STILL_LIVE"
  fi

  echo "==> Rolling back to $PREVIOUS_IMAGE"
  sudo docker tag "$PREVIOUS_IMAGE" "$IMAGE_REF"
  # Piggy included: app and piggy are one image running two commands, and a
  # rollback that restores only the app leaves the two halves of the same
  # release on different code.
  dc up -d --no-build "${DEPLOY_SERVICES[@]}"

  for _ in $(seq 1 60); do
    if curl -sf http://127.0.0.1:8920/api/health > /dev/null; then break; fi
    sleep 1
  done
  if curl -sf http://127.0.0.1:8920/api/health | grep -q '"ok":true'; then
    echo "    rolled back, previous release is healthy"
    exit 1
  fi
  # The restore ran but did not come up. Reporting "rolled back" here would tell
  # the on-call the previous release is serving when nothing is.
  echo "    ROLLBACK ALSO UNHEALTHY — this host needs a human" >&2
  exit "$EXIT_STILL_LIVE"
}

echo "==> Starting the app"
dc up -d "${DEPLOY_SERVICES[@]}"

echo "==> Waiting for health"
for _ in $(seq 1 60); do
  if curl -sf http://127.0.0.1:8920/api/health > /dev/null; then break; fi
  sleep 1
done

echo "==> Verifying"
if curl -sf http://127.0.0.1:8920/api/health | grep -q '"ok":true'; then
  echo "    health ok"
else
  echo "    HEALTH CHECK FAILED"
  roll_back "health check failed"
fi

# Authentication must be enforced. A deploy that accidentally serves the CRM
# unauthenticated is the one failure worth blocking on.
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8920/api/dashboard)
if [ "$CODE" != "401" ]; then
  echo "    UNAUTHENTICATED REQUEST RETURNED $CODE, EXPECTED 401"
  roll_back "unauthenticated request returned $CODE, expected 401"
fi
echo "    auth enforced"

if [ "$PIGGY_IN_RELEASE" = '1' ]; then
  echo "==> Verifying Piggy"
  PIGGY_CID=$(dc ps -q piggy 2>/dev/null | head -n1 || true)
  if [ -z "$PIGGY_CID" ]; then
    echo "    PIGGY IS ENABLED IN .env BUT NO PIGGY CONTAINER IS RUNNING"
    roll_back "piggy is enabled but no piggy container is running"
  fi

  # Image IDs, not tags. The failure this catches is a Piggy someone started by
  # hand once and never touched again: it answers to the same tag while running
  # whatever that tag meant on the day, so a tag comparison sees nothing wrong
  # and the old agent goes on writing to a schema three migrations newer.
  WANTED_IMAGE_ID=$(sudo docker image inspect -f '{{.Id}}' "$IMAGE_REF" 2>/dev/null || true)
  PIGGY_IMAGE_ID=$(sudo docker inspect -f '{{.Image}}' "$PIGGY_CID" 2>/dev/null || true)
  if [ -z "$WANTED_IMAGE_ID" ] || [ "$PIGGY_IMAGE_ID" != "$WANTED_IMAGE_ID" ]; then
    echo "    PIGGY IS RUNNING ${PIGGY_IMAGE_ID:-<unknown>}, EXPECTED ${WANTED_IMAGE_ID:-<unknown>}"
    roll_back "piggy is not running $IMAGE_REF"
  fi
  echo "    piggy on $IMAGE_REF"

  # Where the harness is allowed to look at the filesystem, read back from the
  # container that is actually running rather than from the file that was meant
  # to configure it.
  #
  # The harness discovers extensions, skills and context files from its cwd, and
  # Piggy hands it PIGGY_AGENT_DIR as cwd. Inside /app that is the application
  # checkout, so a stray bind mount or a hand-edited compose file would put
  # source code within reach of a CRM agent's prompt — a leak that every other
  # gate here passes cheerfully, because the container is perfectly healthy.
  # Empty is fine: an older compose file simply does not set it, and the
  # in-code default is ~/.pig/piggy-agent.
  PIGGY_AGENT_DIR_LIVE=$(sudo docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' \
    "$PIGGY_CID" 2>/dev/null | sed -n 's/^PIGGY_AGENT_DIR=//p' | tail -n1)
  case "${PIGGY_AGENT_DIR_LIVE:-}" in
    /app | /app/*)
      echo "    PIGGY_AGENT_DIR IS $PIGGY_AGENT_DIR_LIVE, INSIDE THE CHECKOUT" >&2
      echo "    The agent harness treats that directory as its cwd and reads" >&2
      echo "    context files from it, so this puts repository contents inside a" >&2
      echo "    CRM agent's prompt. Point it at /var/lib/piggy-agent, which the" >&2
      echo "    image creates for exactly this, and redeploy. Not rolled back:" >&2
      echo "    the previous image reads the same compose file." >&2
      exit "$EXIT_STILL_LIVE"
      ;;
  esac

  # `starting` until the first probe answers, so this is a wait, not a poll of
  # something already decided. start_period is 20s and the interval 30s, hence
  # the longer budget than the app's.
  PIGGY_HEALTH=''
  for _ in $(seq 1 90); do
    PIGGY_HEALTH=$(sudo docker inspect \
      -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
      "$PIGGY_CID" 2>/dev/null || true)
    [ "$PIGGY_HEALTH" = 'starting' ] || break
    sleep 1
  done

  if [ "$PIGGY_HEALTH" = 'healthy' ]; then
    echo "    piggy healthy"
  elif [ "$PIGGY_HEALTH" = 'none' ]; then
    # Only reachable from a container created by an older compose file, since
    # this one defines a healthcheck for the service. Worth saying rather than
    # passing quietly, because "no result" is not "well".
    echo "    piggy has no healthcheck to consult; recreate it to get one" >&2
  else
    echo "    PIGGY IS ${PIGGY_HEALTH:-UNKNOWN}" >&2
    dc logs piggy --tail 40 || true
    # Deliberately no rollback, for the same reason the empty-body case below
    # does not: the previous image reads this same .env and fails identically,
    # so restoring it churns the CRM without fixing the agent. The CRM is live
    # and well; the agent the operator asked for is not.
    echo "    The CRM is serving. Piggy is not, and a rollback would not help:" >&2
    echo "    the previous image reads the same .env. Usual causes, in order:" >&2
    echo "      - PRIME_API_KEY missing or rejected (PIGGY_INFERENCE_API_KEY is" >&2
    echo "        still accepted as its alias); Piggy exits at boot and" >&2
    echo "        restart: unless-stopped turns that into a crash loop" >&2
    echo "      - PIGGY_INTERNAL_TOKEN shorter than 32 characters" >&2
    echo "      - PIGGY_AGENT_MODEL naming a model that is not in the catalogue" >&2
    echo "        at apps/piggy/src/agent/models.json" >&2
    echo "    The log above says which; the config error names the key." >&2
    exit "$EXIT_STILL_LIVE"
  fi
fi

# Everything above proves the container is well. It proves nothing about what
# the public actually gets, and there is a failure on this host that every
# check so far passes: a Caddy site block missing `bind 10.0.0.2` lands in a
# separate server on :443, wins for traffic arriving on that address, and
# answers with a valid certificate, HTTP 200 and an EMPTY body. The app is
# fine; the request never reached it. So ask the origin, from outside the
# compose network, and insist on seeing something the app actually renders.
PUBLIC_URL="${PIG_DEPLOY_PUBLIC_URL:-$(env_value PIG_PUBLIC_URL)}"
PUBLIC_URL="${PUBLIC_URL:-https://primeintellectgrowth.com}"
PUBLIC_MARKER="${PIG_DEPLOY_PUBLIC_MARKER:-<div id=\"root\">}"

echo "==> Verifying the public origin ($PUBLIC_URL)"
# Three outcomes, and conflating any two of them has already cost a deploy.
# `|| true` used to fold "curl never got an answer" into the empty string, so a
# host where the public name does not hairpin, or where egress to 443 is
# filtered, failed a completely healthy release — and autodeploy.sh then
# blacklists that digest for good. Keep the transport result separate from the
# body.
PUBLIC_ERR=$(mktemp)
trap 'rm -f "$PUBLIC_ERR"' EXIT
if PUBLIC_BODY=$(curl -sS --max-time 20 --location "${PUBLIC_URL%/}/" 2>"$PUBLIC_ERR"); then
  CURL_STATUS=0
else
  CURL_STATUS=$?
fi
PUBLIC_BYTES=${#PUBLIC_BODY}

if [ "$CURL_STATUS" -ne 0 ]; then
  # Could not connect at all: DNS, TLS, no hairpin, filtered egress. This says
  # nothing about the release, and nothing about the proxy either — from this
  # host the two are indistinguishable. Do not fail a deploy that passed every
  # check that can actually be trusted; set PIG_DEPLOY_REQUIRE_PUBLIC=1 on a
  # host where the public name IS reachable from here and this must block.
  echo "    could not reach the public origin (curl exit $CURL_STATUS)" >&2
  sed 's/^/      /' "$PUBLIC_ERR" >&2
  if [ "${PIG_DEPLOY_REQUIRE_PUBLIC:-0}" = '1' ]; then
    echo "    PIG_DEPLOY_REQUIRE_PUBLIC=1, so this is fatal." >&2
    exit "$EXIT_STILL_LIVE"
  fi
  echo "    Not treated as a failure: the container passed every local gate," >&2
  echo "    and this host may simply not be able to route to its own name." >&2
  echo "    Verify $PUBLIC_URL from somewhere else." >&2
elif [ "$PUBLIC_BYTES" -eq 0 ]; then
  echo "    PUBLIC ORIGIN ANSWERED WITH AN EMPTY BODY" >&2
  echo "    Something terminated TLS and answered, so this is the proxy, not" >&2
  echo "    the release. Check that every Caddy site block on this host has" >&2
  echo "    'bind 10.0.0.2'." >&2
  # Deliberately no rollback: the previous image would fail this same check.
  # Rolling back would churn the service and not fix a proxy fault. The release
  # under test therefore stays live, which is what EXIT_STILL_LIVE tells the
  # poller to say.
  exit "$EXIT_STILL_LIVE"
elif ! printf '%s' "$PUBLIC_BODY" | grep -qF "$PUBLIC_MARKER"; then
  echo "    PUBLIC ORIGIN ANSWERED $PUBLIC_BYTES BYTES WITHOUT '$PUBLIC_MARKER'" >&2
  # This one DOES roll back, unlike the empty-body case above. A proxy fault
  # cannot serve a wrong-but-non-empty page for this hostname; a bad release
  # can — a changed Vite `base`, a Dockerfile that stopped copying
  # apps/web/dist. Every earlier gate passes in that state (the API is well,
  # /api/dashboard still 401s) and only this one fires, so if it is the release
  # then refusing to roll back leaves the broken release serving the public.
  # Restoring the previous image is right when it is, and harmless when it is
  # not: the marker check is the last gate, so the rollback costs one restart.
  roll_back "public origin answered without '$PUBLIC_MARKER'"
else
  echo "    public origin serving PIG ($PUBLIC_BYTES bytes)"
fi

echo "==> Deployed $AFTER"
