#!/usr/bin/env bash
# Claude Code transcript backup client.
#
#   curl -fsSL https://claude-code-backup.44444444.xyz/backup.sh | bash
#
# Token: put it in ~/.config/claude-backup/token (preferred) or CLAUDE_BACKUP_TOKEN.
# Optional env: CLAUDE_BACKUP_MACHINE (name for this box), CLAUDE_PROJECTS_DIR,
#               CLAUDE_BACKUP_URL, CLAUDE_BACKUP_TOKEN_FILE,
#               CLAUDE_BACKUP_KEEP_IMAGES=1 (keep screenshots in slimmed transcripts).
# Needs: bash, curl, jq, zstd, and sha256sum or shasum. If any is missing, it prints
# the install command for this machine's package manager and exits.
# .jsonl transcripts are slimmed with jq before upload (see SLIM_JQ); if jq fails on a
# file, the full file is uploaded. Everything is compressed with zstd.

# Everything is inside main() so a half-downloaded script never runs partially.
main() {
  set -euo pipefail
  export LC_ALL=C

  local url="${CLAUDE_BACKUP_URL:-https://claude-code-backup.44444444.xyz}"
  local src="${CLAUDE_PROJECTS_DIR:-$HOME/.claude/projects}"
  local token_file="${CLAUDE_BACKUP_TOKEN_FILE:-$HOME/.config/claude-backup/token}"
  local token="${CLAUDE_BACKUP_TOKEN:-}"
  local max_bytes=$((99 * 1024 * 1024))   # Workers request body limit is 100 MB
  local keep_images=false
  [ "${CLAUDE_BACKUP_KEEP_IMAGES:-}" = 1 ] && keep_images=true

  # Slimmed transcript = readable chat history, not a resumable session. Denylist, so
  # line types Claude Code adds later are kept. Lines that aren't valid JSON (e.g. a
  # half-written last line of a live session) pass through untouched.
  local SLIM_JQ='
    def omit_image: {type, omitted: true, media_type: .source.media_type};
    def slim:
      select((.type // "") | IN("api-request", "api-request-blob", "api-request-shape",
        "atis-latch", "mode", "permission-mode", "cost-state", "last-prompt", "tools-used",
        "queue-operation", "file-history-snapshot", "file-history-delta") | not)
      | select(.type != "attachment" or ((.attachment.type // "") | IN("prompt_snapshot",
        "skill_listing", "total_tokens_reminder", "deferred_tools_delta",
        "deferred_tools_record", "mcp_instructions_delta") | not))
      | del(.toolUseResult, .mcpMeta)   # UI duplicate of the tool_result in message.content
      | if (.message.content | type) == "array" then
          .message.content |= map(
            if .type == "thinking" then del(.signature)
            elif .type == "redacted_thinking" then {type, omitted: true}
            elif .type == "image" and ($keep_images | not) then omit_image
            elif .type == "tool_result" and (.content | type) == "array" and ($keep_images | not)
              then .content |= map(if .type == "image" then omit_image else . end)
            else . end)
        else . end;
    . as $line | (try fromjson catch null) as $j
    | if ($j | type) == "object" then ($j | slim | tojson) else $line end'

  die() { echo "claude-backup: $*" >&2; exit 1; }

  # Dependencies. Print how to install rather than installing: under `curl | bash`
  # there's no stdin to answer a prompt, and hooks/cron can't type a sudo password.
  local missing=() c
  for c in curl jq zstd; do command -v "$c" >/dev/null || missing+=("$c"); done
  if [ "${#missing[@]}" -gt 0 ]; then
    local how
    if command -v brew >/dev/null; then how="brew install ${missing[*]}"
    elif command -v apt-get >/dev/null; then how="sudo apt-get install -y ${missing[*]}"
    elif command -v dnf >/dev/null; then how="sudo dnf install -y ${missing[*]}"
    elif command -v apk >/dev/null; then how="sudo apk add ${missing[*]}"
    elif command -v pacman >/dev/null; then how="sudo pacman -S --needed ${missing[*]}"
    else how="your package manager"; fi
    die "missing required tool(s): ${missing[*]}. Install with: $how  (then re-run)"
  fi

  if [ -z "$token" ] && [ -r "$token_file" ]; then
    token=$(tr -d '[:space:]' < "$token_file")
  fi
  [ -n "$token" ] || die "no token. Put it in $token_file or set CLAUDE_BACKUP_TOKEN."
  [ -d "$src" ] || { echo "claude-backup: no $src here, nothing to do."; exit 0; }

  local sha_cmd
  if command -v sha256sum >/dev/null; then sha_cmd="sha256sum"
  elif command -v shasum >/dev/null; then sha_cmd="shasum -a 256"
  else die "sha256sum or shasum is required"; fi
  sha() { $sha_cmd "$1" | cut -d' ' -f1; }
  fsize() { wc -c < "$1" | tr -d ' '; }

  # Machine name only namespaces the archive; session files have unique IDs,
  # so even two boxes with the same name won't overwrite each other.
  local machine="${CLAUDE_BACKUP_MACHINE:-${CODESPACE_NAME:-$(hostname 2>/dev/null || uname -n)}}"
  machine=$(printf '%s' "$machine" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-64)

  CLAUDE_BACKUP_TMP=$(mktemp -d)
  local tmp="$CLAUDE_BACKUP_TMP"
  trap 'rm -rf "${CLAUDE_BACKUP_TMP:-}"' EXIT
  chmod 700 "$tmp"

  # Token goes in a header file so it doesn't show up in `ps` on shared hosts.
  (umask 077; printf 'Authorization: Bearer %s\n' "$token" > "$tmp/auth")

  urlencode() {
    local s="$1" out="" c i
    for ((i = 0; i < ${#s}; i++)); do
      c=${s:i:1}
      case "$c" in
        [A-Za-z0-9._~/-]) out+="$c" ;;
        *) out+=$(printf '%%%02X' "'$c") ;;
      esac
    done
    printf '%s' "$out"
  }

  # 1) Build manifest: sha256 <TAB> size <TAB> relative path
  local rel n=0
  : > "$tmp/manifest"
  while IFS= read -r rel; do
    rel=${rel#./}
    case "$rel" in *$'\t'*) continue ;; esac
    [ -f "$src/$rel" ] || continue
    printf '%s\t%s\t%s\n' "$(sha "$src/$rel")" "$(fsize "$src/$rel")" "$rel" >> "$tmp/manifest"
    n=$((n + 1))
  done < <(cd "$src" && find . -type f)

  # 2) Ask the Worker which files it doesn't have (or has an older version of)
  local need
  need=$(curl -fsS -X POST -H @"$tmp/auth" -H "X-Machine: $machine" \
    --data-binary @"$tmp/manifest" "$url/manifest") || die "manifest request failed"

  if [ -z "$need" ]; then
    echo "claude-backup [$machine]: all $n file(s) already backed up."
    exit 0
  fi

  # 3) Upload each one: snapshot first, since live sessions keep writing
  local ok=0 fail=0 skipped=0 h s body slim
  while IFS= read -r rel; do
    [ -n "$rel" ] || continue
    cp "$src/$rel" "$tmp/snap" 2>/dev/null || continue   # file vanished meanwhile
    # Hash and size are of the original file, so /manifest dedup and the
    # never-shrink check work the same whether or not the body is slimmed.
    h=$(sha "$tmp/snap")
    s=$(fsize "$tmp/snap")
    body="$tmp/snap" slim=0
    if [[ "$rel" == *.jsonl ]] &&
        jq -rR --argjson keep_images "$keep_images" "$SLIM_JQ" "$tmp/snap" > "$tmp/slim" 2>/dev/null; then
      body="$tmp/slim" slim=1
    fi
    zstd -q -19 -T0 -f -o "$tmp/snap.zst" "$body"   # zstd frames store no file name
    if [ "$(fsize "$tmp/snap.zst")" -gt "$max_bytes" ]; then
      echo "claude-backup: skipping $rel (over 99 MB even compressed)" >&2
      skipped=$((skipped + 1))
      continue
    fi
    if curl -fsS -X PUT -H @"$tmp/auth" -H "X-Machine: $machine" \
        -H "X-Sha256: $h" -H "X-Size: $s" -H "X-Slim: $slim" \
        -H "X-Compression: zstd" -H "Content-Type: application/zstd" \
        --data-binary @"$tmp/snap.zst" "$url/upload?path=$(urlencode "$rel")" > /dev/null; then
      ok=$((ok + 1))
    else
      echo "claude-backup: failed to upload $rel" >&2
      fail=$((fail + 1))
    fi
  done <<< "$need"

  echo "claude-backup [$machine]: uploaded $ok, failed $fail, skipped $skipped (of $n local file(s))."
  [ "$fail" -eq 0 ]
}

main "$@"
