#!/usr/bin/env bash
# Bash + curl + jq + base64 + shasum (or sha256sum). Trusted local/server use only.
# No ANIMGEN_APPROVE_CREDITS => quote only. Resume the SAME private state directory.
# The local quote check is NOT a server-enforced spending cap.
set -euo pipefail
umask 077
: "${ANIMGEN_API_KEY:?Set ANIMGEN_API_KEY privately}"
api_base="${ANIMGEN_API_BASE:-https://api.animgen.com/v1}"
api_base="${api_base%/}"
state_dir="${ANIMGEN_STATE_DIR:-animgen-curl-state}"
output_dir="${ANIMGEN_OUTPUT:-animgen-output}"
timeout_seconds="${ANIMGEN_TIMEOUT_SECONDS:-900}"
fail() { printf '%s\n' "$1" >&2; exit 1; }
[[ "$timeout_seconds" =~ ^[1-9][0-9]{0,6}$ ]] || fail "Invalid local timeout."
deadline=$(( $(date +%s) + timeout_seconds ))
[[ "$api_base" != *'@'* && "$api_base" != *'?'* && "$api_base" != *'#'* ]] || fail "Use a clean API base URL."
api_protocols='=https'
local_origin=''
case "$api_base" in
  https://*) ;;
  http://localhost:[0-9]*/v1|http://127.0.0.1:[0-9]*/v1|http://localhost/v1|http://127.0.0.1/v1)
    api_protocols='=http,https'; local_origin="${api_base%/v1}" ;;
  *) fail "Use HTTPS; HTTP is only permitted for a loopback mock." ;;
esac
[[ ! -L "$state_dir" ]] || fail "State directory must not be a symlink."
scratch=$(mktemp -d)
download_part=''
cleanup() {
  rm -f -- "$scratch/auth" "$scratch/reply" "$scratch/headers" "$scratch/image" "$scratch/model" "$scratch/task" "$scratch/download-config"
  if [[ -n "$download_part" ]]; then rm -f -- "$download_part"; fi
  rmdir "$scratch"
}
trap cleanup EXIT
printf 'Authorization: Bearer %s\n' "$ANIMGEN_API_KEY" > "$scratch/auth"
hash_file() {
  if command -v shasum >/dev/null; then shasum -a 256 "$1"; else sha256sum "$1"; fi | awk '{print $1}'
}
file_size() { wc -c < "$1" | tr -d '[:space:]'; }
pause() {
  local delay="${1:-1}" now
  now=$(date +%s)
  [[ "$delay" =~ ^[0-9]+$ ]] || delay=1
  if (( delay < 1 )); then delay=1; fi
  (( now + delay < deadline )) || fail "Local deadline reached. Resume the SAME state; remote work may continue."
  sleep "$delay"
}
retry_after() {
  local raw timestamp
  raw=$(awk 'tolower($1)=="retry-after:" {sub(/^[^:]*:[ \t]*/,""); gsub(/\r/,""); value=$0} END {print value}' "$scratch/headers")
  if [[ "$raw" =~ ^[0-9]+$ ]]; then printf '%s' "$raw"; return; fi
  if [[ -n "$raw" ]]; then
    timestamp=$(LC_ALL=C date -j -f '%a, %d %b %Y %H:%M:%S %Z' "$raw" '+%s' 2>/dev/null || LC_ALL=C date -d "$raw" '+%s' 2>/dev/null || true)
    if [[ "$timestamp" =~ ^[0-9]+$ ]]; then printf '%s' "$(( timestamp - $(date +%s) ))"; return; fi
  fi
  printf '%s' "${1:-5}"
}
api_request() {
  local method="$1" path="$2" body_file="${3:-}" operation_key="${4:-}" attempt status_code error_code request_id retryable remaining_seconds
  [[ "$method" == GET || "$path" == */quote || -n "$operation_key" ]] || fail "Refusing an unkeyed retryable write."
  for (( attempt=0; attempt<5; attempt++ )); do
    remaining_seconds=$(( deadline - $(date +%s) ))
    (( remaining_seconds > 0 )) || fail "Local deadline reached. Resume the SAME state."
    if (( remaining_seconds > 30 )); then remaining_seconds=30; fi
    local args=(-s --proto "$api_protocols" --connect-timeout 10 --max-time "$remaining_seconds" -X "$method" -H "@$scratch/auth" -H 'Accept: application/json' -D "$scratch/headers" -o "$scratch/reply" -w '%{http_code}')
    if [[ -n "$body_file" ]]; then args+=(-H 'Content-Type: application/json' --data-binary "@$body_file"); fi
    if [[ -n "$operation_key" ]]; then args+=(-H "Idempotency-Key: $operation_key"); fi
    : > "$scratch/reply"
    : > "$scratch/headers"
    status_code=$(curl "${args[@]}" "$api_base$path" 2>/dev/null) || status_code=000
    case "$status_code" in 2??) return ;; esac
    error_code=$(jq -r '.error.code // "HTTP_ERROR"' "$scratch/reply" 2>/dev/null || printf UNKNOWN)
    request_id=$(jq -r '.error.request_id // "unknown"' "$scratch/reply" 2>/dev/null || printf unknown)
    retryable=$(jq -r '.error.retryable // false' "$scratch/reply" 2>/dev/null || printf false)
    [[ "$error_code" =~ ^[A-Za-z0-9_.-]{1,100}$ ]] || error_code=UNKNOWN
    [[ "$request_id" =~ ^[A-Za-z0-9_.-]{1,100}$ ]] || request_id=unknown
    if (( attempt == 4 )) || ! { [[ "$status_code" == 000 || "$status_code" == 429 ]] || { [[ "$retryable" == true ]] && { [[ "$status_code" == 503 ]] || [[ "$status_code" == 409 && "$error_code" == IDEMPOTENCY_IN_PROGRESS ]]; }; }; }; then
      fail "API HTTP $status_code: $error_code; request_id=$request_id. Preserve the original state."
    fi
    pause "$(retry_after "$(( 1 << attempt ))")"
  done
}
api_request GET /account
account_id=$(jq -er '.id' "$scratch/reply")
key_id=$(jq -er '.api_key.id' "$scratch/reply")
if [[ ! -e "$state_dir" ]]; then
  : "${IMAGE_PATH:?Set IMAGE_PATH to a PNG image}"
  : "${ANIMGEN_MODEL:?Choose ANIMGEN_MODEL from GET /models}"
  case "$IMAGE_PATH" in *.png|*.PNG) ;; *) fail "This cURL example accepts PNG inputs." ;; esac
  (( $(wc -c < "$IMAGE_PATH") <= 10485760 )) || fail "Inline image exceeds 10 MiB."
  api_request GET /models
  jq -e --arg id "$ANIMGEN_MODEL" '.data[] | select(.id==$id and .supports_first_frame==true and (.modes | index("first_frame")))' "$scratch/reply" > "$scratch/model"
  base64 < "$IMAGE_PATH" | tr -d '\r\n' > "$scratch/image"
  mkdir -m 700 -- "$state_dir"
  jq -n --rawfile image "$scratch/image" --slurpfile model "$scratch/model" --arg prompt "${ANIMGEN_PROMPT:-A character runs in place, side view, fixed camera.}" '{
    input: {first_frame: {type:"base64", media_type:"image/png", data:$image}}, prompt:$prompt,
    video: {model:$model[0].id, duration_seconds:$model[0].default_duration_seconds, resolution:$model[0].default_resolution, ratio:$model[0].default_ratio},
    selection: {mode:"full"}, export: {output_formats:["frames_zip"], frame_count:24, output_width:512, output_height:512}
  }' > "$state_dir/request.json"
  hash_file "$state_dir/request.json" > "$state_dir/request.sha256"
  printf '%s' "$account_id" > "$state_dir/account-id"
  printf '%s' "$key_id" > "$state_dir/key-id"
  printf '%s' "$api_base" > "$state_dir/api-base"
  od -An -N16 -tx1 /dev/urandom | tr -d ' \n' > "$state_dir/operation-id"
fi
[[ -f "$state_dir/request.json" && -f "$state_dir/request.sha256" && -f "$state_dir/operation-id" ]] || fail "Existing directory is not a complete workflow state. Do not overwrite it."
[[ "$(< "$state_dir/account-id")" == "$account_id" && "$(< "$state_dir/api-base")" == "$api_base" && "$(< "$state_dir/request.sha256")" == "$(hash_file "$state_dir/request.json")" ]] || fail "State/account/base/request mismatch. Recover the original task."
if [[ ! -f "$state_dir/task-id" ]]; then
  [[ "$(< "$state_dir/key-id")" == "$key_id" ]] || fail "API key identity changed; idempotency does not cross keys."
  if [[ -f "$state_dir/first-create-at" ]]; then
    first_create_at=$(< "$state_dir/first-create-at")
    [[ "$first_create_at" =~ ^[0-9]+$ ]] || fail "Invalid pending-create marker. Inspect existing tasks."
    (( $(date +%s) - first_create_at < 86400 )) || fail "Uncertain create is older than 24 hours. Inspect existing tasks first."
  else
    api_request POST /animations/quote "$state_dir/request.json"
    quoted=$(jq -er '.credits | select(type=="number" and .>=0 and floor==.)' "$scratch/reply")
    printf 'Quoted credits: %s. Local preflight only, not a server-enforced spending cap.\n' "$quoted"
    if [[ -z "${ANIMGEN_APPROVE_CREDITS+x}" ]]; then printf '%s\n' 'No generation started. Review, set ANIMGEN_APPROVE_CREDITS, and resume the SAME state directory.'; exit 0; fi
    [[ "$ANIMGEN_APPROVE_CREDITS" =~ ^[0-9]{1,9}$ ]] || fail "Use an explicit nonnegative integer approval."
    (( quoted <= 10#$ANIMGEN_APPROVE_CREDITS )) || fail "Quote exceeds local approval. No create sent."
    date +%s > "$state_dir/first-create-at"
  fi
  api_request POST /animations "$state_dir/request.json" "$(< "$state_dir/operation-id")"
  task_id=$(jq -er '.id' "$scratch/reply")
  [[ "$task_id" =~ ^[A-Fa-f0-9]{8}(-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$ ]] || fail "Invalid task ID; preserve the original state."
  printf '%s' "$task_id" > "$state_dir/task-id"
fi
task_id=$(< "$state_dir/task-id")
[[ "$task_id" =~ ^[A-Fa-f0-9]{8}(-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$ ]] || fail "Invalid saved task ID."
while true; do
  api_request GET "/animations/$task_id"
  task_status=$(jq -er '.status' "$scratch/reply")
  case "$task_status" in
    succeeded|failed|cancelled) cp "$scratch/reply" "$scratch/task"; break ;;
    queued|running|cancelling) printf 'Task %s: %s\n' "$task_id" "$task_status" ;;
    *) fail "Unknown task status; preserve the state." ;;
  esac
  pause "$(retry_after 5)"
done
mkdir -p -- "$output_dir"
failed_downloads=0
while IFS= read -r asset_id; do
  [[ "$asset_id" =~ ^[A-Fa-f0-9]{8}(-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$ ]] || fail "Invalid asset ID."
  saved=false
  for refresh in 0 1; do
    api_request GET "/assets/$asset_id"
    download_url=$(jq -er '.download_url' "$scratch/reply")
    [[ "$download_url" != *'@'* && "$download_url" != *'#'* ]] || fail "Unsafe download URL."
    case "$download_url" in
      https://*) ;;
      *) [[ -n "$local_origin" && "$download_url" == "$local_origin/"* ]] || fail "Non-HTTPS download rejected." ;;
    esac
    expected=$(jq -er '.byte_size | select(type=="number" and .>=0 and floor==.)' "$scratch/reply")
    case "$(jq -r '.mime_type' "$scratch/reply")" in
      application/zip) suffix=zip ;; image/png) suffix=png ;; application/json) suffix=json ;;
      video/mp4) suffix=mp4 ;; video/webm) suffix=webm ;; video/quicktime) suffix=mov ;; *) suffix=bin ;;
    esac
    target="$output_dir/$asset_id.$suffix"
    if [[ -e "$target" || -L "$target" ]]; then
      [[ -f "$target" && ! -L "$target" && $(file_size "$target") == "$expected" ]] || fail "Existing output does not match."
      saved=true; break
    fi
    jq -r '"url = " + (.download_url | @json)' "$scratch/reply" > "$scratch/download-config"
    download_part=$(mktemp "$output_dir/.animgen-download.XXXXXX")
    download_seconds=$(( deadline - $(date +%s) ))
    (( download_seconds > 0 )) || fail "Local deadline reached; resume the SAME state."
    if (( download_seconds > 30 )); then download_seconds=30; fi
    # Separate invocation: NO Authorization header, including storage redirects.
    download_status=$(curl -s --location --max-redirs 3 --proto "$api_protocols" --proto-redir '=https' --connect-timeout 10 --max-time "$download_seconds" --config "$scratch/download-config" -o "$download_part" -w '%{http_code}' 2>/dev/null) || download_status=000
    if [[ "$download_status" == 200 && $(file_size "$download_part") == "$expected" ]]; then
      ln "$download_part" "$target"
      rm -f -- "$download_part"; download_part=''
      printf 'Saved asset: %s\n' "$target"; saved=true; break
    fi
    rm -f -- "$download_part"; download_part=''
    case "$download_status" in 401|403|404) ;; *) break ;; esac
  done
  if [[ "$saved" != true ]]; then failed_downloads=1; printf 'Could not save asset %s; resume the SAME state.\n' "$asset_id" >&2; fi
done < <(jq -r '.outputs[].id' "$scratch/task")
if [[ "$task_status" != succeeded ]]; then printf 'Task ended as %s; available outputs were processed.\n' "$task_status" >&2; exit 2; fi
(( $(jq '.outputs | length' "$scratch/task") > 0 && failed_downloads == 0 )) || exit 2
