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

# host0 publish script — two-phase static deploy via the host0 REST API.
# stdout: the live url (only). stderr: progress + publish_result.* contract lines.

DEFAULT_BASE_URL="https://host0.ai"
BASE_URL="$DEFAULT_BASE_URL"
CREDENTIALS_FILE="$HOME/.host0/credentials"
API_KEY=""
API_KEY_SOURCE="none"
ALLOW_NONHOST0_BASE_URL=0
NAME=""
SLUG=""
APP_ID=""
VISIBILITY=""
CLIENT=""
TARGET=""

# set only when *this run* created the app — the cleanup trap deletes it if the
# run then fails before the app goes live. never set for an app resolved via
# --slug / --app-id / the state file.
CREATED_APP_ID=""
CREATED_APP_SLUG=""
CLEANUP_DONE=0

usage() {
  cat <<'USAGE'
usage: publish.sh <dir> [options]

publishes a directory of static files to host0 and prints the live url.

options:
  --name <text>           app name when creating (default: directory basename)
  --slug <slug>           update the existing app with this slug
  --app-id <uuid>         update the existing app with this id
  --visibility <mode>     set visibility after publish: private | link | public
  --client <name>         agent name for attribution (e.g. claude-code)
  --api-key <key>         api key (prefer $HOST0_API_KEY or ~/.host0/credentials)
  --base-url <url>        api base (default: https://host0.ai)
  --allow-nonhost0-base-url
                          allow sending the api key to a non-default base url
  --help                  show this help
USAGE
  exit 1
}

die() { echo "error: $1" >&2; exit 1; }

# on a failed run, delete the app this run created so a failed first publish
# doesn't leave an orphan app (it counts against the account's app cap). only
# ever touches an app created by this run, and only before it went live.
cleanup_orphan_app() {
  local rc=$?
  if [[ $rc -eq 0 || "$CLEANUP_DONE" -eq 1 || -z "$CREATED_APP_ID" ]]; then
    return 0
  fi
  CLEANUP_DONE=1

  local code
  set +e
  code=$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE \
    "$BASE_URL/api/v1/apps/$CREATED_APP_ID" \
    -H "authorization: Bearer $API_KEY" \
    -H "x-host0-client: ${CLIENT_HEADER_VALUE:-host0-publish-sh}" 2>/dev/null)
  set -e

  if [[ "$code" =~ ^2[0-9][0-9]$ ]]; then
    echo "cleaning up: deleted the app this run created ($CREATED_APP_SLUG) — it never went live." >&2
    echo "publish_result.cleanup=deleted" >&2
    echo "publish_result.cleanup_slug=$CREATED_APP_SLUG" >&2
  else
    echo "warning: could not delete the app this run created ($CREATED_APP_SLUG, $CREATED_APP_ID) — delete it from your dashboard (http $code)." >&2
    echo "publish_result.cleanup=failed" >&2
    echo "publish_result.cleanup_slug=$CREATED_APP_SLUG" >&2
  fi
  exit $rc
}
# the signal traps exit rather than clean up directly, so the EXIT trap runs
# once with a non-zero status (on a bare INT trap $? can still be 0).
trap cleanup_orphan_app EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

command -v curl >/dev/null 2>&1 || die "requires curl — install curl and retry"
command -v jq >/dev/null 2>&1 || die "requires jq — install jq (e.g. brew install jq / apt install jq) and retry"

if command -v shasum >/dev/null 2>&1; then
  SHA_TOOL="shasum"
elif command -v sha256sum >/dev/null 2>&1; then
  SHA_TOOL="sha256sum"
else
  die "requires shasum or sha256sum for file hashing"
fi

while [[ $# -gt 0 ]]; do
  case "$1" in
    --name)       NAME="$2"; shift 2 ;;
    --slug)       SLUG="$2"; shift 2 ;;
    --app-id)     APP_ID="$2"; shift 2 ;;
    --visibility) VISIBILITY="$2"; shift 2 ;;
    --client)     CLIENT="$2"; shift 2 ;;
    --api-key)    API_KEY="$2"; API_KEY_SOURCE="flag"; shift 2 ;;
    --base-url)   BASE_URL="$2"; shift 2 ;;
    --allow-nonhost0-base-url) ALLOW_NONHOST0_BASE_URL=1; shift ;;
    --help|-h)    usage ;;
    -*)           die "unknown option: $1" ;;
    *)            [[ -z "$TARGET" ]] && TARGET="$1" || die "unexpected argument: $1"; shift ;;
  esac
done

[[ -n "$TARGET" ]] || usage
[[ -d "$TARGET" ]] || die "not a directory: $TARGET — publish the directory that contains index.html"

if [[ -n "$VISIBILITY" ]]; then
  case "$VISIBILITY" in
    private|link|public) ;;
    *) die "invalid --visibility: $VISIBILITY (must be private, link, or public)" ;;
  esac
fi

# api key resolution: flag > env > credentials file
if [[ -z "$API_KEY" && -n "${HOST0_API_KEY:-}" ]]; then
  API_KEY="$HOST0_API_KEY"
  API_KEY_SOURCE="env"
fi
if [[ -z "$API_KEY" && -f "$CREDENTIALS_FILE" ]]; then
  API_KEY=$(tr -d '[:space:]' < "$CREDENTIALS_FILE")
  [[ -n "$API_KEY" ]] && API_KEY_SOURCE="credentials"
fi

BASE_URL="${BASE_URL%/}"

if [[ -z "$API_KEY" ]]; then
  die "no api key found. get one:
  1. ask the user for their email address
  2. curl -sS $BASE_URL/api/v1/auth/request-code -H 'content-type: application/json' -d '{\"email\":\"user@example.com\"}'
  3. ask the user to paste the 6-digit code from their inbox
  4. curl -sS $BASE_URL/api/v1/auth/verify-code -H 'content-type: application/json' -d '{\"email\":\"user@example.com\",\"code\":\"123456\"}'
  5. save the returned api_key: mkdir -p ~/.host0 && printf '%s' \"\$API_KEY\" > ~/.host0/credentials && chmod 600 ~/.host0/credentials
     (or export HOST0_API_KEY)"
fi

# safety guard: never send the api key to an arbitrary endpoint. this must run
# before any network request.
if [[ "$BASE_URL" != "$DEFAULT_BASE_URL" && "$ALLOW_NONHOST0_BASE_URL" -ne 1 ]]; then
  die "refusing to send api key to non-default base url; pass --allow-nonhost0-base-url to override"
fi

CLIENT_HEADER_VALUE="host0-publish-sh"
if [[ -n "$CLIENT" ]]; then
  normalized_client=$(echo "$CLIENT" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')
  normalized_client="${normalized_client#-}"
  normalized_client="${normalized_client%-}"
  [[ -n "$normalized_client" ]] && CLIENT_HEADER_VALUE="${normalized_client}/publish-sh"
fi

TARGET_ABS=$(cd "$TARGET" && pwd)
STATE_DIR="$TARGET_ABS/.host0"
STATE_FILE="$STATE_DIR/state.json"

# die with the api's message (and any preflight problem lines) from a json
# error response.
check_api_error() {
  local resp="$1" ctx="$2"
  if ! echo "$resp" | jq -e . >/dev/null 2>&1; then
    die "$ctx: unexpected non-json response: $(echo "$resp" | head -c 300)"
  fi
  if echo "$resp" | jq -e 'type == "object" and has("error")' >/dev/null 2>&1; then
    local msg
    msg=$(echo "$resp" | jq -r '.message // .error')
    echo "$resp" | jq -r '.problems // [] | .[] | "  - \(.message)"' >&2
    die "$ctx: $msg"
  fi
}

# probe request whose http status matters (state-file resolution). sets the
# globals API_BODY / API_HTTP_STATUS instead of echoing, because a command
# substitution would run in a subshell and lose the status.
API_BODY=""
API_HTTP_STATUS=""
api_probe() {
  local method="$1" path="$2" out rc
  set +e
  out=$(curl -sS -X "$method" "$BASE_URL$path" \
    -H "authorization: Bearer $API_KEY" \
    -H "x-host0-client: $CLIENT_HEADER_VALUE" \
    -w '\n%{http_code}' 2>/dev/null)
  rc=$?
  set -e
  if [[ $rc -ne 0 ]]; then
    API_BODY=""
    API_HTTP_STATUS="000"
    return 0
  fi
  API_HTTP_STATUS="${out##*$'\n'}"
  API_BODY="${out%$'\n'*}"
}

api() {
  local method="$1" path="$2" body="${3:-}"
  if [[ -n "$body" ]]; then
    curl -sS -X "$method" "$BASE_URL$path" \
      -H "authorization: Bearer $API_KEY" \
      -H "x-host0-client: $CLIENT_HEADER_VALUE" \
      -H "content-type: application/json" \
      -d "$body"
  else
    curl -sS -X "$method" "$BASE_URL$path" \
      -H "authorization: Bearer $API_KEY" \
      -H "x-host0-client: $CLIENT_HEADER_VALUE"
  fi
}

compute_sha256() {
  local f="$1"
  if [[ "$SHA_TOOL" == "shasum" ]]; then
    shasum -a 256 "$f" | cut -d' ' -f1
  else
    sha256sum "$f" | cut -d' ' -f1
  fi
}

# extension → content-type map (mirrors src/lib/hosting/validate.ts)
guess_content_type() {
  local f="$1" ext
  ext=$(echo "${f##*.}" | tr '[:upper:]' '[:lower:]')
  case "$ext" in
    html)        echo "text/html; charset=utf-8" ;;
    css)         echo "text/css; charset=utf-8" ;;
    js|mjs)      echo "text/javascript; charset=utf-8" ;;
    json|map)    echo "application/json" ;;
    svg)         echo "image/svg+xml" ;;
    png)         echo "image/png" ;;
    jpg|jpeg)    echo "image/jpeg" ;;
    gif)         echo "image/gif" ;;
    webp)        echo "image/webp" ;;
    ico)         echo "image/x-icon" ;;
    txt)         echo "text/plain; charset=utf-8" ;;
    xml)         echo "application/xml" ;;
    woff)        echo "font/woff" ;;
    woff2)       echo "font/woff2" ;;
    wasm)        echo "application/wasm" ;;
    webmanifest) echo "application/manifest+json" ;;
    *)           echo "application/octet-stream" ;;
  esac
}

# walk the directory: skip .DS_Store, .host0/, and anything inside a hidden
# directory (.git/, .cache/, …)
MANIFEST_JSON="[]"
FILE_MAP="{}"
while IFS= read -r -d '' f; do
  rel="${f#"$TARGET_ABS"/}"
  [[ "$(basename "$rel")" == ".DS_Store" ]] && continue
  [[ "$rel" =~ (^|/)\.[^/]+/ ]] && continue
  sz=$(wc -c < "$f" | tr -d ' ')
  ct=$(guess_content_type "$f")
  h=$(compute_sha256 "$f")
  MANIFEST_JSON=$(echo "$MANIFEST_JSON" | jq --arg p "$rel" --argjson s "$sz" --arg c "$ct" --arg h "$h" \
    '. + [{"path":$p,"size":$s,"content_type":$c,"sha256":$h}]')
  FILE_MAP=$(echo "$FILE_MAP" | jq --arg p "$rel" --arg a "$f" '. + {($p):$a}')
done < <(find "$TARGET_ABS" -type f -print0 | sort -z)

FILES_TOTAL=$(echo "$MANIFEST_JSON" | jq 'length')
[[ "$FILES_TOTAL" -gt 0 ]] || die "no files found in $TARGET"

# resolve the app: --app-id > --slug > state file (this base url, newest
# first) > create
ACTION="update"
APP_SLUG=""
APP_VISIBILITY=""
# cached state entries found unusable with the active key; pruned on save.
STALE_SLUGS=()

if [[ -n "$APP_ID" ]]; then
  RESP=$(api GET "/api/v1/apps/$APP_ID")
  check_api_error "$RESP" "app lookup"
  APP_SLUG=$(echo "$RESP" | jq -r '.slug')
  APP_VISIBILITY=$(echo "$RESP" | jq -r '.visibility')
elif [[ -n "$SLUG" ]]; then
  RESP=$(api GET "/api/v1/apps")
  check_api_error "$RESP" "app lookup"
  APP_ID=$(echo "$RESP" | jq -r --arg s "$SLUG" '.apps[] | select(.slug == $s) | .app_id' | head -n 1)
  [[ -n "$APP_ID" ]] || die "no app with slug '$SLUG' in this account — drop --slug to create a new app, or list yours with: curl -sS $BASE_URL/api/v1/apps -H 'authorization: bearer …'"
  APP_SLUG="$SLUG"
  APP_VISIBILITY=$(echo "$RESP" | jq -r --arg s "$SLUG" '.apps[] | select(.slug == $s) | .visibility' | head -n 1)
else
  if [[ -f "$STATE_FILE" ]]; then
    # candidates = entries written against this base url (legacy entries have
    # no base_url and are accepted), most recently published first. never
    # "the first entry": one directory can accumulate several, and after an
    # accidental create the newest one is the app to keep updating.
    CANDIDATES=$(jq -r --arg b "$BASE_URL" '
      [ (.apps // {}) | to_entries[]
        | select((.value.base_url // $b) == $b)
        | {slug: .key, app_id: (.value.app_id // ""), updated_at: (.value.updated_at // "")}
        | select(.app_id != "") ]
      | sort_by(.updated_at) | reverse
      | .[] | "\(.app_id)\t\(.slug)"
    ' "$STATE_FILE" 2>/dev/null || true)

    while IFS=$'\t' read -r cand_id cand_slug; do
      [[ -n "$cand_id" ]] || continue
      api_probe GET "/api/v1/apps/$cand_id"
      if [[ "$API_HTTP_STATUS" == "200" ]] &&
         echo "$API_BODY" | jq -e 'type == "object" and has("error") | not' >/dev/null 2>&1; then
        APP_ID="$cand_id"
        APP_SLUG=$(echo "$API_BODY" | jq -r '.slug')
        APP_VISIBILITY=$(echo "$API_BODY" | jq -r '.visibility')
        break
      fi
      case "$API_HTTP_STATUS" in
        401|403|404|410)
          # deleted, or written by a different account's key — drop it rather
          # than let it shadow a usable entry on every future run.
          echo "warning: cached app \"$cand_slug\" ($cand_id) is not available to this api key (http $API_HTTP_STATUS) — dropping it from $STATE_FILE" >&2
          STALE_SLUGS+=("$cand_slug")
          ;;
        *)
          # a transient failure must not turn into a duplicate app.
          die "app lookup for cached app \"$cand_slug\" failed (http $API_HTTP_STATUS) — not creating a duplicate; retry, or name the app explicitly with --slug/--app-id"
          ;;
      esac
    done <<< "$CANDIDATES"
  fi
  if [[ -z "$APP_ID" ]]; then
    if [[ ${#STALE_SLUGS[@]} -gt 0 ]]; then
      echo "warning: no app cached in $STATE_FILE is usable with this api key — creating a new app. if you meant to update an existing one, stop and re-run with --slug <slug>." >&2
    fi
    ACTION="create"
    APP_NAME="${NAME:-$(basename "$TARGET_ABS")}"
    echo "creating app \"$APP_NAME\"..." >&2
    BODY=$(jq -n --arg n "$APP_NAME" '{name: $n}')
    RESP=$(api POST "/api/v1/apps" "$BODY")
    check_api_error "$RESP" "create app"
    APP_ID=$(echo "$RESP" | jq -r '.app_id')
    APP_SLUG=$(echo "$RESP" | jq -r '.slug')
    APP_VISIBILITY=$(echo "$RESP" | jq -r '.visibility')
    [[ "$APP_ID" != "null" && -n "$APP_ID" ]] || die "create app: unexpected response: $RESP"
    # from here on this run owns the app: if anything below fails before the
    # app goes live, the cleanup trap deletes it again.
    CREATED_APP_ID="$APP_ID"
    CREATED_APP_SLUG="$APP_SLUG"
  fi
fi

# step 1: start the deployment from the manifest
echo "creating deployment ($FILES_TOTAL files)..." >&2
BODY=$(echo "$MANIFEST_JSON" | jq '{files: .}')
DEPLOY_RESP=$(api POST "/api/v1/apps/$APP_ID/deployments" "$BODY")
check_api_error "$DEPLOY_RESP" "create deployment"

DEPLOYMENT_ID=$(echo "$DEPLOY_RESP" | jq -r '.deployment_id')
FINALIZE_URL=$(echo "$DEPLOY_RESP" | jq -r '.upload.finalize_url')
TARGET_COUNT=$(echo "$DEPLOY_RESP" | jq '.upload.targets | length')
SKIPPED_COUNT=$(echo "$DEPLOY_RESP" | jq '.upload.skipped | length')
[[ "$DEPLOYMENT_ID" != "null" && -n "$DEPLOYMENT_ID" ]] || die "create deployment: unexpected response: $DEPLOY_RESP"

# scheme://host[:port] of a url — everything before the first path segment.
url_origin() {
  local u="$1" rest
  rest="${u#*://}"
  echo "${u%%://*}://${rest%%/*}"
}

# the server mints upload/finalize urls from its own configured origin. if that
# origin isn't the one we're publishing to, stop here: the bearer key must never
# be sent to a host the caller didn't name, and continuing would fail later with
# a misleading auth error.
BASE_ORIGIN=$(url_origin "$BASE_URL")
while IFS= read -r mint_url; do
  [[ -n "$mint_url" ]] || continue
  mint_origin=$(url_origin "$mint_url")
  if [[ "$mint_origin" != "$BASE_ORIGIN" ]]; then
    die "upload targets are on $mint_origin but --base-url is $BASE_ORIGIN — the server mints these urls from its configured platform origin. re-run with --base-url $mint_origin (or set h0_platform_origin to match)."
  fi
done < <(echo "$DEPLOY_RESP" | jq -r '[(.upload.targets // [])[].url, .upload.finalize_url] | .[] | select(. != null and . != "")')

# step 2: upload each target (unchanged files were hash-skipped server-side)
if [[ "$SKIPPED_COUNT" -gt 0 ]]; then
  echo "uploading $TARGET_COUNT files ($SKIPPED_COUNT unchanged, skipped)..." >&2
else
  echo "uploading $TARGET_COUNT files..." >&2
fi

i=0
while [[ $i -lt $TARGET_COUNT ]]; do
  t_path=$(echo "$DEPLOY_RESP" | jq -r ".upload.targets[$i].path")
  t_url=$(echo "$DEPLOY_RESP" | jq -r ".upload.targets[$i].url")
  local_file=$(echo "$FILE_MAP" | jq -r --arg p "$t_path" '.[$p] // empty')
  [[ -n "$local_file" && -f "$local_file" ]] || die "missing local file for upload target: $t_path"

  header_args=()
  while IFS= read -r hline; do
    [[ -n "$hline" ]] && header_args+=(-H "$hline")
  done < <(echo "$DEPLOY_RESP" | jq -r ".upload.targets[$i].headers // {} | to_entries[] | \"\(.key): \(.value)\"")

  # send the bearer key only to our own api, never to a foreign upload host
  # (forward-compatible with presigned storage urls)
  if [[ "$t_url" == "$BASE_URL"* ]]; then
    header_args+=(-H "authorization: Bearer $API_KEY")
  fi

  PUT_RESP=$(curl -sS -X PUT "$t_url" \
    "${header_args[@]+"${header_args[@]}"}" \
    --data-binary "@$local_file")
  check_api_error "$PUT_RESP" "upload $t_path"
  i=$((i + 1))
done

# step 3: finalize — the app isn't live until this succeeds
echo "finalizing..." >&2
FIN_RESP=$(curl -sS -X POST "$FINALIZE_URL" \
  -H "authorization: Bearer $API_KEY" \
  -H "x-host0-client: $CLIENT_HEADER_VALUE" \
  -H "content-type: application/json" \
  -d '{}')
check_api_error "$FIN_RESP" "finalize"
LIVE_URL=$(echo "$FIN_RESP" | jq -r '.url')
[[ "$LIVE_URL" != "null" && -n "$LIVE_URL" ]] || die "finalize: unexpected response: $FIN_RESP"

# the app is live — it is no longer a cleanup candidate. a failure in the
# steps below (visibility, state file) must never delete a live app.
CREATED_APP_ID=""

# optional: set visibility
if [[ -n "$VISIBILITY" ]]; then
  echo "setting visibility to $VISIBILITY..." >&2
  BODY=$(jq -n --arg v "$VISIBILITY" '{visibility: $v}')
  VIS_RESP=$(api PATCH "/api/v1/apps/$APP_ID" "$BODY")
  check_api_error "$VIS_RESP" "set visibility"
  APP_VISIBILITY=$(echo "$VIS_RESP" | jq -r '.visibility')
fi

# save state (internal cache — never source of truth)
mkdir -p "$STATE_DIR"
if [[ -f "$STATE_FILE" ]]; then
  STATE=$(cat "$STATE_FILE")
else
  STATE='{"apps":{}}'
fi
for stale in ${STALE_SLUGS[@]+"${STALE_SLUGS[@]}"}; do
  STATE=$(echo "$STATE" | jq --arg k "$stale" 'del(.apps[$k])')
done
# base_url + updated_at are what make resolution unambiguous on the next run.
entry=$(jq -n --arg a "$APP_ID" --arg s "$APP_SLUG" --arg u "$LIVE_URL" \
  --arg b "$BASE_URL" --arg t "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  '{app_id: $a, slug: $s, url: $u, base_url: $b, updated_at: $t}')
STATE=$(echo "$STATE" | jq --arg k "$APP_SLUG" --argjson e "$entry" '.apps[$k] = $e')
echo "$STATE" | jq '.' > "$STATE_FILE"

# stdout: the live url, nothing else
echo "$LIVE_URL"

# stderr: machine-readable result contract
echo "" >&2
echo "publish_result.url=$LIVE_URL" >&2
echo "publish_result.app_id=$APP_ID" >&2
echo "publish_result.slug=$APP_SLUG" >&2
echo "publish_result.action=$ACTION" >&2
echo "publish_result.deployment_id=$DEPLOYMENT_ID" >&2
echo "publish_result.visibility=$APP_VISIBILITY" >&2
echo "publish_result.api_key_source=$API_KEY_SOURCE" >&2
echo "publish_result.files_total=$FILES_TOTAL" >&2
echo "publish_result.files_uploaded=$TARGET_COUNT" >&2
echo "publish_result.files_skipped=$SKIPPED_COUNT" >&2
echo "publish_result.state_pruned=${#STALE_SLUGS[@]}" >&2
