#!/usr/bin/env bash
# tinyhost.win CLI — the only client of the three-step Publish HTTP API.
#
# Flow (mirrors here.now's publish.sh):
#   create/replace  ->  POST /v1/sites  |  POST /v1/sites/{slug}
#   upload files    ->  PUT  /v1/sites/{slug}/files?path=...
#   finalize        ->  POST /v1/sites/{slug}/finalize
#   delete          ->  DELETE /v1/sites/{slug}
#
# A Site is not live until finalize succeeds. The Site secret is returned
# once at create and stored in .tinyhost/state.json — never print it.
set -euo pipefail

BASE_URL="${TINYHOST_API_BASE:-https://api.tinyhost.win}"
SLUG=""
DELETE_SLUG=""
SITE_SECRET="${TINYHOST_SITE_SECRET:-}"
TARGET=""

# Keep in sync with src/limits.ts and src/paths.ts; the API is authoritative.
MAX_FILE_BYTES=$((10 * 1024 * 1024))
MAX_SITE_BYTES=$((50 * 1024 * 1024))
MAX_SITE_FILES=500

usage() {
  cat <<'USAGE'
Usage: publish.sh <file-or-dir> [options]
       publish.sh --delete <slug> [options]

Publish a folder (or single file) as a Site at {slug}.tinyhost.win.

Options:
  --slug <slug>         Replace an existing Site (secret from state file)
  --delete <slug>       Delete a Site
  --site-secret <key>   Site secret override (or set $TINYHOST_SITE_SECRET)
  --base-url <url>      API base (default: https://api.tinyhost.win,
                        or set $TINYHOST_API_BASE)
USAGE
  exit 1
}

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

for cmd in curl jq; do
  command -v "$cmd" >/dev/null 2>&1 || die "requires $cmd"
done

while [[ $# -gt 0 ]]; do
  case "$1" in
    --slug)        SLUG="$2"; shift 2 ;;
    --delete)      DELETE_SLUG="$2"; shift 2 ;;
    --site-secret) SITE_SECRET="$2"; shift 2 ;;
    --base-url)    BASE_URL="$2"; shift 2 ;;
    --help|-h)     usage ;;
    -*)            die "unknown option: $1" ;;
    *)             [[ -z "$TARGET" ]] && { TARGET="$1"; shift; } || die "unexpected argument: $1" ;;
  esac
done

BASE_URL="${BASE_URL%/}"
STATE_DIR=".tinyhost"
STATE_FILE="$STATE_DIR/state.json"

# --- state file -------------------------------------------------------------
# Shape: { "publishes": { "<slug>": { siteUrl, siteSecret, expiresAt } } }
# Internal cache only: never a URL to share, never something to commit.

load_secret() {
  local slug="$1"
  [[ -f "$STATE_FILE" ]] || return 0
  jq -r --arg s "$slug" '.publishes[$s].siteSecret // empty' "$STATE_FILE" 2>/dev/null || true
}

save_entry() {
  local slug="$1" site_url="$2" secret="$3" expires_at="$4"
  mkdir -p "$STATE_DIR"
  local state
  if [[ -f "$STATE_FILE" ]]; then state=$(cat "$STATE_FILE"); else state='{"publishes":{}}'; fi
  local entry
  entry=$(jq -n --arg u "$site_url" --arg k "$secret" --arg e "$expires_at" \
    '{siteUrl: $u, siteSecret: $k, expiresAt: $e}')
  echo "$state" | jq --arg s "$slug" --argjson e "$entry" '.publishes[$s] = $e' > "$STATE_FILE"
}

drop_entry() {
  local slug="$1"
  [[ -f "$STATE_FILE" ]] || return 0
  jq --arg s "$slug" 'del(.publishes[$s])' "$STATE_FILE" > "$STATE_FILE.tmp" \
    && mv "$STATE_FILE.tmp" "$STATE_FILE"
}

# --- API helpers ------------------------------------------------------------
# Errors are JSON { code, message } plus HTTP status.

SECRET_ARGS=()
SECRET_HEADER="X-Tinyhost-Site-Secret"

api_call() { # method url
  local method="$1" url="$2"
  local response
  response=$(curl -sS -X "$method" "$url" ${SECRET_ARGS[@]+"${SECRET_ARGS[@]}"})
  if [[ -n "$(echo "$response" | jq -r '.code // empty' 2>/dev/null)" ]]; then
    die "$(echo "$response" | jq -r '.code'): $(echo "$response" | jq -r '.message // ""')"
  fi
  echo "$response"
}

require_secret() { # slug
  local slug="$1"
  if [[ -z "$SITE_SECRET" ]]; then
    SITE_SECRET=$(load_secret "$slug")
  fi
  [[ -n "$SITE_SECRET" ]] || die "no Site secret for $slug (not in $STATE_FILE; pass --site-secret)"
  SECRET_ARGS=(-H "$SECRET_HEADER: $SITE_SECRET")
}

urlencode() { jq -rn --arg v "$1" '$v | @uri'; }

# --- delete -----------------------------------------------------------------

if [[ -n "$DELETE_SLUG" ]]; then
  [[ -z "$TARGET" && -z "$SLUG" ]] || die "--delete does not take a file or --slug"
  require_secret "$DELETE_SLUG"
  echo "deleting $DELETE_SLUG..." >&2
  api_call DELETE "$BASE_URL/v1/sites/$DELETE_SLUG" >/dev/null
  drop_entry "$DELETE_SLUG"
  echo "" >&2
  echo "publish_result.action=delete" >&2
  echo "publish_result.slug=$DELETE_SLUG" >&2
  echo "deleted $DELETE_SLUG" >&2
  exit 0
fi

# --- gather files -----------------------------------------------------------

[[ -n "$TARGET" ]] || usage
[[ -e "$TARGET" ]] || die "path does not exist: $TARGET"

FILES=() # relative paths, as Visitors will address them
TOTAL_BYTES=0

add_file() { # abs rel
  local size
  size=$(wc -c < "$1" | tr -d ' ')
  [[ "$size" -le "$MAX_FILE_BYTES" ]] || die "file over 10 MB: $2"
  FILES+=("$2")
  TOTAL_BYTES=$((TOTAL_BYTES + size))
}

if [[ -f "$TARGET" ]]; then
  add_file "$TARGET" "$(basename "$TARGET")"
elif [[ -d "$TARGET" ]]; then
  TARGET="${TARGET%/}"
  while IFS= read -r -d '' f; do
    rel="${f#"$TARGET"/}"
    [[ "$(basename "$rel")" == ".DS_Store" ]] && continue
    # Local Publish state holds the Site secret — never Site content.
    [[ "$rel" == .tinyhost/* ]] && continue
    add_file "$f" "$rel"
  done < <(find "$TARGET" -type f -print0 | sort -z)
else
  die "not a file or directory: $TARGET"
fi

[[ "${#FILES[@]}" -gt 0 ]] || die "no files found"
[[ "${#FILES[@]}" -le "$MAX_SITE_FILES" ]] || die "a Site may hold at most $MAX_SITE_FILES files"
[[ "$TOTAL_BYTES" -le "$MAX_SITE_BYTES" ]] || die "a Site may be at most 50 MB"

local_file_for() { # rel
  if [[ -f "$TARGET" ]]; then echo "$TARGET"; else echo "$TARGET/$1"; fi
}

# --- step 1: create or replace ----------------------------------------------

ACTION="create"
EXPIRES_AT=""
if [[ -n "$SLUG" ]]; then
  ACTION="replace"
  require_secret "$SLUG"
  echo "starting replace of $SLUG (${#FILES[@]} files)..." >&2
  RESPONSE=$(api_call POST "$BASE_URL/v1/sites/$SLUG")
else
  echo "creating Site (${#FILES[@]} files)..." >&2
  RESPONSE=$(api_call POST "$BASE_URL/v1/sites")
  SLUG=$(echo "$RESPONSE" | jq -r '.slug')
  NEW_SECRET=$(echo "$RESPONSE" | jq -r '.siteSecret')
  EXPIRES_AT=$(echo "$RESPONSE" | jq -r '.expiresAt')
  [[ "$SLUG" != "null" && -n "$SLUG" ]] || die "unexpected response: $RESPONSE"
  # Store the Site secret immediately — it is returned only once, and a
  # failed upload must not strand it.
  save_entry "$SLUG" "$(echo "$RESPONSE" | jq -r '.siteUrl')" "$NEW_SECRET" "$EXPIRES_AT"
  SITE_SECRET="$NEW_SECRET"
  SECRET_ARGS=(-H "$SECRET_HEADER: $SITE_SECRET")
fi

SITE_URL=$(echo "$RESPONSE" | jq -r '.siteUrl')

# --- step 2: upload files ----------------------------------------------------

echo "uploading ${#FILES[@]} files..." >&2
upload_errors=0
for rel in "${FILES[@]}"; do
  http_code=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \
    "$BASE_URL/v1/sites/$SLUG/files?path=$(urlencode "$rel")" \
    ${SECRET_ARGS[@]+"${SECRET_ARGS[@]}"} \
    -H "Content-Type: application/octet-stream" \
    --data-binary @"$(local_file_for "$rel")")
  if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then
    echo "warning: upload failed for $rel (HTTP $http_code)" >&2
    upload_errors=$((upload_errors + 1))
  fi
done
[[ "$upload_errors" -eq 0 ]] || die "$upload_errors file(s) failed to upload; the Site keeps its previous live tree"

# --- step 3: finalize ---------------------------------------------------------

echo "finalizing..." >&2
FIN_RESPONSE=$(api_call POST "$BASE_URL/v1/sites/$SLUG/finalize")
VERSION=$(echo "$FIN_RESPONSE" | jq -r '.version // empty')

# --- output -------------------------------------------------------------------

echo "$SITE_URL"

echo "" >&2
echo "publish_result.site_url=$SITE_URL" >&2
echo "publish_result.slug=$SLUG" >&2
echo "publish_result.action=$ACTION" >&2
echo "publish_result.version=$VERSION" >&2
if [[ "$ACTION" == "create" ]]; then
  echo "publish_result.expires_at=$EXPIRES_AT" >&2
  echo "Site secret stored in $STATE_FILE (never commit it); the Site expires at $EXPIRES_AT" >&2
else
  echo "replace live; Expiry is unchanged" >&2
fi
