#!/bin/zsh

setopt extendedglob pipefail
umask 077

typeset -gr PAPAT_API_BASE='https://papat.app'
typeset -gr PAPAT_KEYCHAIN_SERVICE='app.papat.cli'
typeset -gr PAPAT_KEYCHAIN_ACCOUNT='integration-api-key'
typeset -gr PAPAT_STATE_ROOT="$HOME/.local/state/papat-cli"
typeset -gr PAPAT_JOBS_DIR="$PAPAT_STATE_ROOT/jobs"
typeset -gr PAPAT_LOGS_DIR="$PAPAT_STATE_ROOT/logs"
typeset -gr PAPAT_SRGB_PROFILE='/System/Library/ColorSync/Profiles/sRGB Profile.icc'
typeset -gr PAPAT_VERSION='0.1.0'
typeset -gr PAPAT_PUBLIC_BASE='https://papat.app'

typeset -g API_STATUS=''
typeset -g API_BODY=''
typeset -g API_CURL_RC=0
typeset -g API_RETRY_AFTER=''
typeset -g JOB_DIR=''
typeset -g STATE_FILE=''
typeset -g LOG_FILE=''
typeset -g JOB_ID=''
typeset -g JOB_STATUS=''
typeset -g JOB_PID=''
typeset -g SID=''
typeset -g TENANT_ID=''
typeset -g SOURCE_DIR=''
typeset -g TITLE=''
typeset -g DAYS='7'
typeset -g KEY_COLOR=''
typeset -g PASSCODE=''
typeset -g IDEMPOTENCY_KEY=''
typeset -g SHARE_URL=''
typeset -g EXPIRES_AT=''
typeset -ga SOURCE_FILES=()
typeset -ga RELATIVE_FILES=()

function fail() {
  print -u2 -r -- "papat: $*"
  exit 1
}

function usage() {
  cat <<'USAGE'
papat — papat gallery CLI

Usage:
  papat login
  papat status [--json]
  papat upload <folder> [--title <title>] [--days 7|14]
              [--key-color #RRGGBB] [--passcode <value>] [--background] [--json]
  papat jobs [--json]
  papat list [--json]
  papat selections <sid> [--json]
  papat extend <sid>
  papat close <sid>

`papat login` reads the API key through the macOS Keychain prompt. Keys cannot
be supplied as command arguments or environment variables.
USAGE
}

function ensure_state_dirs() {
  command mkdir -p "$PAPAT_JOBS_DIR" "$PAPAT_LOGS_DIR" || fail '状態ディレクトリを作成できません'
  command chmod 700 "$PAPAT_STATE_ROOT" "$PAPAT_JOBS_DIR" "$PAPAT_LOGS_DIR" 2>/dev/null
}

function get_api_key() {
  local key
  key=$(command security find-generic-password -s "$PAPAT_KEYCHAIN_SERVICE" -a "$PAPAT_KEYCHAIN_ACCOUNT" -w 2>/dev/null) \
    || fail '未ログインです。ターミナルで `papat login` を実行してください'
  [[ -n "$key" ]] || fail 'Keychain に API キーがありません。`papat login` を実行してください'
  REPLY="$key"
}

function api_request() {
  local api_key="$1" method="$2" request_path="$3"
  shift 3
  local body_file headers_file response_file
  local -a curl_args
  ensure_state_dirs
  body_file=$(command mktemp "$PAPAT_STATE_ROOT/response.XXXXXX") || fail '一時ファイルを作成できません'
  headers_file=$(command mktemp "$PAPAT_STATE_ROOT/headers.XXXXXX") || fail '一時ファイルを作成できません'
  response_file=$(command mktemp "$PAPAT_STATE_ROOT/status.XXXXXX") || fail '一時ファイルを作成できません'
  curl_args=(--silent --show-error --request "$method" --url "$PAPAT_API_BASE$request_path"
    --dump-header "$headers_file" --output "$body_file" --write-out '%{http_code}' "$@")
  builtin printf 'header = "Authorization: Bearer %s"\nheader = "Accept: application/json"\n' "$api_key" \
    | command curl --config - "${curl_args[@]}" > "$response_file"
  API_CURL_RC=$?
  API_STATUS=$(<"$response_file")
  API_BODY=$(<"$body_file")
  API_RETRY_AFTER=$(command sed -n 's/^[Rr]etry-[Aa]fter:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$headers_file" | command sed -n '$p')
  command rm -f "$body_file" "$response_file" "$headers_file"
  if (( API_CURL_RC != 0 )); then
    return 1
  fi
}

function api_error() {
  local body="$1"
  if [[ "$body" =~ '"error"[[:space:]]*:[[:space:]]*"([^"\\]*(\\.[^"\\]*)*)"' ]]; then
    REPLY="$match[1]"
  else
    REPLY='API request failed'
  fi
}

function require_success() {
  local action="$1"
  if [[ ! "$API_STATUS" == 2[0-9][0-9] ]]; then
    api_error "$API_BODY"
    fail "$action: HTTP ${API_STATUS:-000} — $REPLY"
  fi
}

function json_escape() {
  local input="$1" output='' ch
  local -a chars
  chars=("${(@s::)input}")
  for ch in "${chars[@]}"; do
    case "$ch" in
      '"') output+='\\"' ;;
      '\\') output+='\\\\' ;;
      $'\n') output+='\\n' ;;
      $'\r') output+='\\r' ;;
      $'\t') output+='\\t' ;;
      *) output+="$ch" ;;
    esac
  done
  REPLY="$output"
}

function json_field() {
  local json="$1" field="$2"
  if [[ "$json" =~ "\"${field}\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9_-]+)\"" ]]; then
    REPLY="$match[1]"
  else
    REPLY=''
  fi
}

function load_state() {
  local line key value
  [[ -f "$STATE_FILE" ]] || fail 'アップロードの状態がありません'
  while IFS= read -r line; do
    key="${line%%=*}"
    value="${line#*=}"
    case "$key" in
      job_id) JOB_ID="$value" ;;
      status) JOB_STATUS="$value" ;;
      pid) JOB_PID="$value" ;;
      sid) SID="$value" ;;
      tenant_id) TENANT_ID="$value" ;;
      source_dir) SOURCE_DIR="$value" ;;
      title) TITLE="$value" ;;
      days) DAYS="$value" ;;
      key_color) KEY_COLOR="$value" ;;
      passcode) PASSCODE="$value" ;;
      idempotency_key) IDEMPOTENCY_KEY="$value" ;;
      share_url) SHARE_URL="$value" ;;
      expires_at) EXPIRES_AT="$value" ;;
    esac
  done < "$STATE_FILE"
}

function write_state() {
  local temporary="$JOB_DIR/state.tmp.$$"
  {
    print -r -- "job_id=$JOB_ID"
    print -r -- "status=$JOB_STATUS"
    print -r -- "pid=$JOB_PID"
    print -r -- "sid=$SID"
    print -r -- "tenant_id=$TENANT_ID"
    print -r -- "source_dir=$SOURCE_DIR"
    print -r -- "title=$TITLE"
    print -r -- "days=$DAYS"
    print -r -- "key_color=$KEY_COLOR"
    print -r -- "passcode=$PASSCODE"
    print -r -- "idempotency_key=$IDEMPOTENCY_KEY"
    print -r -- "share_url=$SHARE_URL"
    print -r -- "expires_at=$EXPIRES_AT"
  } > "$temporary" || fail 'アップロード状態を保存できません'
  command chmod 600 "$temporary"
  command mv -f "$temporary" "$STATE_FILE"
}

function collect_sources() {
  local dir="$1" file relative basename stem
  typeset -A seen_stems
  SOURCE_FILES=()
  RELATIVE_FILES=()
  while IFS= read -r -d $'\0' file; do
    [[ "$file" == *$'\n'* ]] && fail '改行を含むファイル名には対応していません'
    SOURCE_FILES+=("$file")
  done < <(command find "$dir" -type f \( -iname '*.jpg' -o -iname '*.jpeg' \) -print0)
  SOURCE_FILES=("${(@o)SOURCE_FILES}")
  (( ${#SOURCE_FILES} > 0 )) || fail 'JPEG ファイルが見つかりません'
  (( ${#SOURCE_FILES} <= 900 )) || fail '1ギャラリーの上限は900枚です'
  for file in "${SOURCE_FILES[@]}"; do
    relative="${file#"$dir"/}"
    basename="${relative:t}"
    stem="${basename%.*}"
    [[ -n "$stem" ]] || fail "ファイル名を確認してください: $relative"
    if [[ -n "${seen_stems[${stem:l}]-}" ]]; then
      fail "同じファイル名 stem が重複しています: $stem"
    fi
    seen_stems[${stem:l}]=1
    RELATIVE_FILES+=("$relative")
  done
}

function folder_job_id() {
  local digest
  digest=$(builtin printf '%s' "$1" | command shasum -a 256) || fail 'フォルダ識別子を生成できません'
  REPLY="${digest%% *}"
}

function create_gallery() {
  local api_key attempt sid_value tenant_value retry_after
  [[ -f "$JOB_DIR/create.json" ]] || fail 'ギャラリー作成の再試行データがありません'
  get_api_key; api_key="$REPLY"
  for attempt in {1..6}; do
    api_request "$api_key" POST '/api/v1/galleries' \
      --header 'Content-Type: application/json' \
      --header "Idempotency-Key: $IDEMPOTENCY_KEY" \
      --data-binary "@$JOB_DIR/create.json"
    if (( API_CURL_RC != 0 )); then fail 'ギャラリー作成の接続に失敗しました。同じフォルダで再実行すると同一要求から再開します'; fi
    if [[ "$API_STATUS" == '429' ]]; then
      retry_after="${API_RETRY_AFTER:-1}"
      [[ "$retry_after" == <-> ]] || retry_after=1
      print -r -- "API 制限中です。${retry_after}秒待って再試行します。"
      command sleep "$retry_after"
      continue
    fi
    require_success 'ギャラリーを作成できませんでした'
    json_field "$API_BODY" sid; sid_value="$REPLY"
    json_field "$API_BODY" tenant_id; tenant_value="$REPLY"
    [[ -n "$sid_value" && -n "$tenant_value" ]] || fail '作成応答に sid または tenant_id がありません'
    SID="$sid_value"; TENANT_ID="$tenant_value"
    SHARE_URL="$PAPAT_PUBLIC_BASE/t/$TENANT_ID/s/$SID/"
    if [[ "$API_BODY" =~ '"expires_at"[[:space:]]*:[[:space:]]*"([^"]+)"' ]]; then EXPIRES_AT="$match[1]"; fi
    JOB_STATUS='queued'; JOB_PID=''; write_state
    command rm -f "$JOB_DIR/create.json"
    return 0
  done
  fail '作成 API のレート制限が続きました。同じフォルダで再実行すると再開します'
}

function print_share_info() {
  local mode="$1" background="${2:-no}" instruction
  instruction="以下のURLから写真を選んでください。
${SHARE_URL}
パスコード：${PASSCODE}"
  if [[ "$mode" == 'yes' ]]; then
    json_escape "$TITLE"; local title_json="$REPLY"
    json_escape "$SHARE_URL"; local url_json="$REPLY"
    json_escape "$PASSCODE"; local code_json="$REPLY"
    json_escape "$instruction"; local instruction_json="$REPLY"
    if [[ "$background" == 'yes' ]]; then
      json_escape "$LOG_FILE"; local log_json="$REPLY"
      print -r -- "{\"ok\":true,\"sid\":\"$SID\",\"title\":\"$title_json\",\"url\":\"$url_json\",\"passcode\":\"$code_json\",\"instruction\":\"$instruction_json\",\"job_id\":\"$JOB_ID\",\"upload_status\":\"running\",\"log\":\"$log_json\"}"
    else
      print -r -- "{\"ok\":true,\"sid\":\"$SID\",\"title\":\"$title_json\",\"url\":\"$url_json\",\"passcode\":\"$code_json\",\"instruction\":\"$instruction_json\",\"job_id\":\"$JOB_ID\"}"
    fi
  else
    print -r -- "ギャラリーを作成しました: ${TITLE:-無題}"
    print -r -- "URL: ${SHARE_URL}"
    print -r -- "パスコード: ${PASSCODE}"
    print -r -- '案内文:'
    print -r -- "以下のURLから写真を選んでください。
${SHARE_URL}
パスコード：${PASSCODE}"
  fi
}

function upload_new_or_resume() {
  local folder="$1" title="$2" days="$3" key_color="$4" passcode="$5" background="$6" json_output="$7"
  local resolved_dir response_dir hash_output
  ensure_state_dirs
  [[ "$folder" != *$'\n'* ]] || fail '改行を含むフォルダパスには対応していません'
  resolved_dir="$(builtin cd -- "$folder" 2>/dev/null && builtin pwd -P)" || fail "フォルダを開けません: $folder"
  collect_sources "$resolved_dir"
  folder_job_id "$resolved_dir"
  JOB_ID="$REPLY"
  JOB_DIR="$PAPAT_JOBS_DIR/$JOB_ID"
  STATE_FILE="$JOB_DIR/state"
  LOG_FILE="$PAPAT_LOGS_DIR/$JOB_ID.log"
  if [[ -f "$STATE_FILE" ]]; then
    load_state
    if [[ "$JOB_STATUS" == 'completed' ]]; then command rm -rf "$JOB_DIR"; fi
  fi
  if [[ -f "$STATE_FILE" ]]; then
    load_state
    if [[ "$JOB_STATUS" == 'queued' || "$JOB_STATUS" == 'running' ]]; then
      if [[ -n "$JOB_PID" ]] && command kill -0 "$JOB_PID" 2>/dev/null; then
        fail "このフォルダはすでにアップロード中です。papat jobs で確認してください"
      fi
    fi
    if [[ "$SOURCE_DIR" != "$resolved_dir" ]]; then fail '保存済みアップロード状態のフォルダが一致しません'; fi
    if [[ -f "$JOB_DIR/files.list" ]]; then
      local -a old_files=()
      while IFS= read -r relative; do old_files+=("$relative"); done < "$JOB_DIR/files.list"
      if (( ${#old_files} != ${#RELATIVE_FILES} )); then fail 'アップロード再開前にフォルダ内のJPEG一覧が変わりました'; fi
      local i
      for (( i=1; i <= ${#old_files}; i++ )); do
        [[ "${old_files[i]}" == "${RELATIVE_FILES[i]}" ]] || fail 'アップロード再開前にフォルダ内のJPEG一覧が変わりました'
      done
    fi
    title="$TITLE"; days="$DAYS"; key_color="$KEY_COLOR"; passcode="$PASSCODE"
    if [[ "$json_output" == 'yes' ]]; then
      print -u2 -r -- "前回のアップロードを再開します: ${JOB_ID[1,12]}"
    else
      print -r -- "前回のアップロードを再開します: ${JOB_ID[1,12]}"
    fi
  else
    TITLE="$title"; DAYS="$days"; KEY_COLOR="$key_color"; PASSCODE="$passcode"
    SOURCE_DIR="$resolved_dir"; SID=''; TENANT_ID=''; SHARE_URL=''; EXPIRES_AT=''
    IDEMPOTENCY_KEY="$(command uuidgen | command tr '[:upper:]' '[:lower:]')"
    JOB_STATUS='creating'; JOB_PID=''
    command mkdir -p "$JOB_DIR" || fail 'アップロード状態を作成できません'
    command chmod 700 "$JOB_DIR"
    : > "$LOG_FILE"; command chmod 600 "$LOG_FILE"
    write_state
    for response_dir in "${RELATIVE_FILES[@]}"; do print -r -- "$response_dir"; done > "$JOB_DIR/files.list"
    command chmod 600 "$JOB_DIR/files.list"
    json_escape "$TITLE"; local title_json="$REPLY"
    json_escape "$PASSCODE"; local passcode_json="$REPLY"
    hash_output=$(builtin printf '%s' "$PASSCODE" | command shasum -a 256) || fail 'パスコードのハッシュを作成できません'
    local passcode_hash="${hash_output%% *}"
    {
      builtin printf '{"title":"%s","days":%s,"passcode_hash":"%s","passcode":"%s","key_color":' "$title_json" "$DAYS" "$passcode_hash" "$passcode_json"
      if [[ -n "$KEY_COLOR" ]]; then builtin printf '"%s"' "$KEY_COLOR"; else builtin printf 'null'; fi
      builtin printf '}\n'
    } > "$JOB_DIR/create.json" || fail 'ギャラリー作成要求を保存できません'
    command chmod 600 "$JOB_DIR/create.json"
  fi
  if [[ -z "$SID" ]]; then create_gallery; fi
  PASSCODE="$passcode"; SID="$SID"; SOURCE_DIR="$resolved_dir"
  if [[ "$background" == 'yes' ]]; then
    JOB_STATUS='queued'; JOB_PID=''; write_state
    "$0" __worker "$JOB_DIR" < /dev/null >> "$LOG_FILE" 2>&1 &
    JOB_PID=$!
    JOB_STATUS='running'
    write_state
    : > "$JOB_DIR/go"
    builtin disown "$JOB_PID" 2>/dev/null
    print_share_info "$json_output" yes
    if [[ "$json_output" != 'yes' ]]; then
      print -r -- "バックグラウンドで送信を開始しました。papat jobs / $LOG_FILE で進捗を確認できます。"
    fi
    return 0
  fi
  print_share_info "$json_output"
  JOB_STATUS='running'; JOB_PID="$$"; write_state
  : > "$JOB_DIR/go"
  if [[ "$json_output" == 'yes' ]]; then run_worker "$JOB_DIR" >&2; else run_worker "$JOB_DIR"; fi
}

function sips_dimensions() {
  local file="$1" info
  info=$(command sips -g pixelWidth -g pixelHeight -g profile "$file" 2>/dev/null) || return 1
  local width=0 height=0 profile=''
  if [[ "$info" =~ 'pixelWidth: ([0-9]+)' ]]; then width="$match[1]"; fi
  if [[ "$info" =~ 'pixelHeight: ([0-9]+)' ]]; then height="$match[1]"; fi
  if [[ "$info" =~ 'profile: (.+)' ]]; then profile="$match[1]"; fi
  REPLY="$width:$height:$profile"
}

function url_encode_path() {
  local input="$1" output='' ch hex
  local LC_ALL=C
  local i
  for (( i=1; i <= ${#input}; i++ )); do
    ch="${input[i]}"
    if [[ "$ch" == [A-Za-z0-9._] || "$ch" == '-' || "$ch" == '~' ]]; then
      output+="$ch"
    elif [[ "$ch" == '/' ]]; then
      output+='/'
    else
      builtin printf -v hex '%%%02X' "'$ch"
      output+="$hex"
    fi
  done
  REPLY="$output"
}

function source_digest() {
  local result
  result=$(command shasum -a 256 "$1") || return 1
  REPLY="${result%% *}"
}

function upload_one() {
  local source="$1" relative="$2" source_hash marker stage_path upload_path logical_path encoded_path filename
  local details width height profile http_status retry_after attempt
  source_digest "$source" || return 1
  source_hash="$REPLY"
  marker="$JOB_DIR/uploaded/$relative.done"
  if [[ -f "$marker" ]] && [[ "$(<"$marker")" == "$source_hash" ]]; then
    print -r -- "skip $relative"
    return 0
  fi
  sips_dimensions "$source" || { print -u2 -r -- "sips failed: $relative"; return 1; }
  details="$REPLY"; width="${details%%:*}"; details="${details#*:}"
  height="${details%%:*}"; profile="${details#*:}"
  upload_path="$source"
  if (( width > 2048 || height > 2048 )) || [[ "$profile" != *sRGB* ]]; then
    stage_path="$JOB_DIR/staging/$relative"
    command mkdir -p "${stage_path:h}" || return 1
    if ! command sips --matchTo "$PAPAT_SRGB_PROFILE" -s format jpeg -Z 2048 "$source" --out "$stage_path" >/dev/null 2>&1; then
      print -u2 -r -- "sips conversion failed: $relative"
      return 1
    fi
    upload_path="$stage_path"
  fi
  filename="${relative:t}"
  logical_path="photos/$relative"
  url_encode_path "$logical_path"; encoded_path="$REPLY"
  for attempt in {1..6}; do
    api_request "$WORKER_API_KEY" PUT "/api/v1/galleries/$SID/files/$encoded_path" \
      --header 'Content-Type: image/jpeg' --data-binary "@$upload_path"
    if (( API_CURL_RC != 0 )); then
      print -u2 -r -- "network error: $filename"
      return 1
    fi
    http_status="$API_STATUS"
    if [[ "$http_status" == '429' ]]; then
      retry_after="$API_RETRY_AFTER"
      [[ "$retry_after" == <-> ]] || retry_after=1
      print -r -- "rate limited; wait ${retry_after}s: $filename"
      command sleep "$retry_after"
      continue
    fi
    if [[ ! "$http_status" == 2[0-9][0-9] ]]; then
      api_error "$API_BODY"
      print -u2 -r -- "upload failed: $filename (HTTP $http_status: $REPLY)"
      return 1
    fi
    command mkdir -p "${marker:h}" || return 1
    builtin printf '%s\n' "$source_hash" > "$marker"
    command chmod 600 "$marker"
    print -r -- "uploaded $filename"
    return 0
  done
  print -u2 -r -- "too many rate limit retries: $filename"
  return 1
}

function write_manifest() {
  local manifest="$JOB_DIR/manifest.json" relative filename stem url digest source
  json_escape "$TITLE"; local title_json="$REPLY"
  {
    builtin printf '{"version":1,"title":"%s","theme":"default","key_color":' "$title_json"
    if [[ -n "$KEY_COLOR" ]]; then builtin printf '"%s"' "$KEY_COLOR"; else builtin printf 'null'; fi
    builtin printf ',"count":%s,"photos":[' "${#RELATIVE_FILES}"
    local first=1
    for relative in "${RELATIVE_FILES[@]}"; do
      source="$SOURCE_DIR/$relative"
      filename="${relative:t}"
      stem="${filename%.*}"
      url_encode_path "photos/$relative"; url="$REPLY"
      digest=$(builtin printf '%s' "$relative" | command shasum -a 256) || return 1
      digest="${digest%% *}"
      json_escape "$filename"; local filename_json="$REPLY"
      json_escape "$stem"; local stem_json="$REPLY"
      json_escape "$url"; local url_json="$REPLY"
      if (( first )); then first=0; else builtin printf ','; fi
      builtin printf '{"source_id":"papat-cli-%s","stem":"%s","filename":"%s","url":"%s","rating":0,"label":"","keywords":[],"datetime":"","camera":"","group":0}' \
        "$digest" "$stem_json" "$filename_json" "$url_json"
    done
    builtin printf '],"credit":"","groupThreshold":3}\n'
  } > "$manifest" || return 1
  command chmod 600 "$manifest"
  REPLY="$manifest"
}

function run_worker() {
  local dir="$1" api_key manifest_path
  JOB_DIR="$dir"; STATE_FILE="$JOB_DIR/state"; LOG_FILE="$PAPAT_LOGS_DIR/${JOB_DIR:t}.log"
  while [[ ! -f "$JOB_DIR/go" ]]; do command sleep 0.1; done
  load_state
  if [[ -z "$SID" ]]; then
    print -u2 -r -- 'ギャラリー作成が未完了です。`papat upload` を再実行してください'
    JOB_STATUS='failed'; write_state; return 1
  fi
  JOB_STATUS='running'; JOB_PID="$$"; write_state
  get_api_key; api_key="$REPLY"; WORKER_API_KEY="$api_key"
  collect_sources "$SOURCE_DIR"
  local -a pids=()
  local relative source rc failed=0
  command mkdir -p "$JOB_DIR/uploaded" "$JOB_DIR/staging" "$JOB_DIR/tmp" || { JOB_STATUS='failed'; write_state; return 1; }
  for relative in "${RELATIVE_FILES[@]}"; do
    source="$SOURCE_DIR/$relative"
    upload_one "$source" "$relative" &
    pids+=("$!")
    if (( ${#pids} == 4 )); then
      for pid in "${pids[@]}"; do wait "$pid"; rc=$?; (( rc == 0 )) || failed=1; done
      pids=()
    fi
  done
  for pid in "${pids[@]}"; do wait "$pid"; rc=$?; (( rc == 0 )) || failed=1; done
  if (( failed )); then
    JOB_STATUS='failed'; JOB_PID=''; write_state
    print -u2 -r -- "アップロードに失敗しました。papat upload ${(q)SOURCE_DIR} で未完了分から再開できます。"
    return 1
  fi
  write_manifest || { JOB_STATUS='failed'; write_state; print -u2 'manifestを作成できません'; return 1; }
  manifest_path="$REPLY"
  api_request "$WORKER_API_KEY" PUT "/api/v1/galleries/$SID/files/manifest.json" \
    --header 'Content-Type: application/json' --data-binary "@$manifest_path"
  if (( API_CURL_RC != 0 )) || [[ ! "$API_STATUS" == 2[0-9][0-9] ]]; then
    api_error "$API_BODY"
    print -u2 -r -- "manifest の送信に失敗しました (HTTP ${API_STATUS:-000}: $REPLY)"
    JOB_STATUS='failed'; JOB_PID=''; write_state; return 1
  fi
  JOB_STATUS='completed'; JOB_PID=''; PASSCODE=''; write_state
  print -r -- "全 ${#RELATIVE_FILES} 枚をアップロードし、manifest を更新しました。ギャラリー: $SHARE_URL"
  command rm -rf "$JOB_DIR/staging" "$JOB_DIR/uploaded" "$JOB_DIR/tmp" "$JOB_DIR/files.list" "$JOB_DIR/create.json" "$JOB_DIR/manifest.json" "$JOB_DIR/go"
  return 0
}

function cmd_login() {
  ensure_state_dirs
  print -r -- 'macOS Keychain のパスワード入力欄に papat API キーを入力してください（チャットやコマンド引数には入力しません）。'
  command security add-generic-password -U -s "$PAPAT_KEYCHAIN_SERVICE" -a "$PAPAT_KEYCHAIN_ACCOUNT" -w \
    || fail 'Keychain への保存に失敗しました'
  get_api_key; local api_key="$REPLY"
  api_request "$api_key" GET '/api/v1/integration/status'
  (( API_CURL_RC == 0 )) || fail '接続を確認できませんでした'
  if [[ "$API_STATUS" == '401' ]]; then fail 'API キーが認証されません。ダッシュボードで再発行して再度ログインしてください'; fi
  require_success '接続を確認できませんでした'
  print -r -- 'papat に接続しました。'
}

function cmd_status() {
  local json_output='no'
  [[ "${2:-}" == '--json' ]] && json_output='yes'
  get_api_key; local api_key="$REPLY"
  api_request "$api_key" GET '/api/v1/integration/status'
  (( API_CURL_RC == 0 )) || fail 'papat に接続できません'
  require_success '接続確認に失敗しました'
  if [[ "$json_output" == 'yes' ]]; then print -r -- "$API_BODY"; else print -r -- 'papat に接続しています。'; print -r -- "$API_BODY"; fi
}

function cmd_list() {
  local json_output='no'
  [[ "${2:-}" == '--json' ]] && json_output='yes'
  get_api_key; local api_key="$REPLY"
  api_request "$api_key" GET '/api/v1/galleries'
  (( API_CURL_RC == 0 )) || fail 'ギャラリー一覧を取得できません'
  require_success 'ギャラリー一覧を取得できません'
  if [[ "$json_output" == 'yes' ]]; then print -r -- "$API_BODY"; else print -r -- 'ギャラリー一覧 (JSON):'; print -r -- "$API_BODY"; fi
}

function cmd_selections() {
  local sid="$1" json_output='no'
  [[ "${2:-}" == '--json' ]] && json_output='yes'
  [[ "$sid" =~ '^[A-Za-z0-9_-]+$' ]] || fail 'sid の形式が正しくありません'
  get_api_key; local api_key="$REPLY"
  api_request "$api_key" GET "/api/v1/galleries/$sid/selections"
  (( API_CURL_RC == 0 )) || fail 'セレクト結果を取得できません'
  require_success 'セレクト結果を取得できません'
  if [[ "$json_output" == 'yes' ]]; then print -r -- "$API_BODY"; else print -r -- 'セレクト結果 (JSON):'; print -r -- "$API_BODY"; fi
}

function cmd_extend() {
  local sid="$1" operation_id payload
  [[ "$sid" =~ '^[A-Za-z0-9_-]+$' ]] || fail 'sid の形式が正しくありません'
  operation_id="$(command uuidgen | command tr '[:upper:]' '[:lower:]')"
  json_escape "$operation_id"; payload="{\"client_op_id\":\"$REPLY\"}"
  get_api_key; local api_key="$REPLY"
  api_request "$api_key" POST "/api/v1/galleries/$sid/extend" \
    --header 'Content-Type: application/json' --header "Idempotency-Key: $operation_id" --data-binary "$payload"
  (( API_CURL_RC == 0 )) || fail '延長 API に接続できません'
  require_success 'ギャラリーを延長できません'
  print -r -- "$API_BODY"
}

function cmd_close() {
  local sid="$1"
  [[ "$sid" =~ '^[A-Za-z0-9_-]+$' ]] || fail 'sid の形式が正しくありません'
  get_api_key; local api_key="$REPLY"
  api_request "$api_key" DELETE "/api/v1/galleries/$sid"
  (( API_CURL_RC == 0 )) || fail '終了 API に接続できません'
  require_success 'ギャラリーを終了できません'
  print -r -- "$API_BODY"
}

function cmd_jobs() {
  local json_output='no' state_file log
  [[ "${2:-}" == '--json' ]] && json_output='yes'
  ensure_state_dirs
  local found=0
  if [[ "$json_output" == 'yes' ]]; then print -n '['; fi
  for state_file in "$PAPAT_JOBS_DIR"/*/state(N); do
    JOB_DIR="${state_file:h}"; STATE_FILE="$state_file"; load_state
    log="$PAPAT_LOGS_DIR/$JOB_ID.log"
    if [[ "$json_output" == 'yes' ]]; then
      (( found )) && print -n ','
      json_escape "$TITLE"; local title_json="$REPLY"
      json_escape "$log"; local log_json="$REPLY"
      print -n "{\"job_id\":\"$JOB_ID\",\"status\":\"$JOB_STATUS\",\"sid\":\"$SID\",\"title\":\"$title_json\",\"log\":\"$log_json\"}"
    else
      print -r -- "$JOB_ID  $JOB_STATUS  ${SID:-gallery-pending}  ${TITLE:-無題}"
      print -r -- "  log: $log"
      [[ "$JOB_STATUS" == 'failed' ]] && print -r -- "  再開: papat upload ${(q)SOURCE_DIR}"
    fi
    found=1
  done
  if [[ "$json_output" == 'yes' ]]; then print ']'; elif (( ! found )); then print -r -- '進行中または再開可能な job はありません。'; fi
}

function cmd_upload() {
  shift
  local folder='' title='' days='7' key_color='' passcode='' background='no' json_output='no'
  (( $# > 0 )) || { usage; exit 2; }
  folder="$1"; shift
  while (( $# > 0 )); do
    case "$1" in
      --title) (( $# >= 2 )) || fail '--title には値が必要です'; title="$2"; shift 2 ;;
      --days) (( $# >= 2 )) || fail '--days には値が必要です'; days="$2"; shift 2 ;;
      --key-color) (( $# >= 2 )) || fail '--key-color には値が必要です'; key_color="$2"; shift 2 ;;
      --passcode) (( $# >= 2 )) || fail '--passcode には値が必要です'; passcode="$2"; shift 2 ;;
      --background) background='yes'; shift ;;
      --json) json_output='yes'; shift ;;
      *) fail "不明な引数です: $1" ;;
    esac
  done
  [[ "$days" == 7 || "$days" == 14 ]] || fail '--days は 7 または 14 です'
  if [[ -n "$key_color" && ! "$key_color" =~ '^#[[:xdigit:]]{6}$' ]]; then fail '--key-color は #RRGGBB 形式です'; fi
  if [[ -n "$title" && ( "$title" == *$'\n'* || "$title" == *$'\r'* ) ]]; then fail 'タイトルに改行は使えません'; fi
  if [[ -n "$passcode" ]] && { (( ${#passcode} < 4 || ${#passcode} > 64 )) || [[ "$passcode" == *$'\n'* || "$passcode" == *$'\r'* ]]; }; then
    fail '--passcode は改行なしの4〜64文字です'
  fi
  if [[ -z "$title" ]]; then title="${folder:t}"; fi
  if [[ -z "$passcode" ]]; then passcode="$(command uuidgen | command tr -d '-' | command cut -c1-8 | command tr '[:lower:]' '[:upper:]')"; fi
  upload_new_or_resume "$folder" "$title" "$days" "$key_color" "$passcode" "$background" "$json_output"
}

function main() {
  local command_name="${1:-}"
  case "$command_name" in
    login) cmd_login ;;
    status) cmd_status "$@" ;;
    upload) cmd_upload "$@" ;;
    jobs) cmd_jobs "$@" ;;
    list) cmd_list "$@" ;;
    selections)
      (( $# >= 2 )) || fail '使い方: papat selections <sid> [--json]'
      cmd_selections "$2" "${3:-}"
      ;;
    extend)
      (( $# == 2 )) || fail '使い方: papat extend <sid>'
      cmd_extend "$2"
      ;;
    close)
      (( $# == 2 )) || fail '使い方: papat close <sid>'
      cmd_close "$2"
      ;;
    __worker)
      (( $# == 2 )) || exit 2
      ensure_state_dirs
      run_worker "$2"
      ;;
    -h|--help|help|'') usage ;;
    *) usage >&2; fail "不明なコマンドです: $command_name" ;;
  esac
}

main "$@"
