#!/usr/bin/env bash
# CloneAI for Agents — run-survey.sh（v4・人間が流す台本）
#
# 読むだけの AI（GET しかできない AI）が作った依頼の JSON を、人間が 1 回流して、受付 → 待ち → 結果の要約 → 公開の同意 まで進める。
# A human runs this once with the request JSON written by a read-only AI: submit, wait, show the summary, ask about publishing.
#
#   bash run-survey.sh request.json                    新しい依頼（new request）
#   bash run-survey.sh --resume .cloneai/<key>.json    途中から（resume: same key, same body）
#   bash run-survey.sh --with-key .cloneai/<key>.secret request.json
#                                                      受付されなかった（201 job=null）後、保存した鍵と新しいキーで送り直す
#   options: --base https://ai.cloneai.jp（既定） --dir .cloneai（既定） --max-wait 1200（秒・既定 20 分）
#
# 必要な物：bash・curl と、node か python3 のどちらか（JSON を読むため。jq は要らない）。Windows は Git Bash。
# 秘密（api_key.secret・operator_secret・result_secret）は画面に出さず、--dir の file（0600）に置く。AI には渡さない。
# 会話に貼ってよいのは、最後に出る public_url か、保存した .md・.csv の file だけ。
#
# 終了コード（exit codes）：
#   0  完了（public_url を表示した／非公開の file を保存した）
#   10 公開の申請は受け付けたが URL はまだ無い（運営の確認待ち。未完了。後で --resume）
#   2  使い方の誤り   3 道具が無い（curl・node か python3）
#   20 受付されていない（201 job=null）   21 依頼の誤り（400・422 など。details を表示）
#   22 応答が無い・再送の上限   23 そのほかの HTTP の拒否
#   30 待ちの上限   31 ジョブが成功しなかった（failed・canceled・expired）
#   32 結果を取れなかった・保存できなかった（途中で切れた応答・保存の失敗・読めない JSON。完了とは表示しない）
#   40 公開の失敗
set -u
umask 077

BASE="https://ai.cloneai.jp"
DIR=".cloneai"
MAX_WAIT=1200
HTTP_TIMEOUT=60
MAX_SEND=5
MODE="new"
REQUEST_FILE=""
RESUME_FILE=""
KEY_FILE=""
BASE_SET=0

say() { printf '%s\n' "$*"; }
fail() { local code=$1; shift; printf 'NG: %s\n' "$*" >&2; exit "$code"; }

usage() {
  sed -n '2,25p' "$0" >&2
  exit 2
}

while [ $# -gt 0 ]; do
  case "$1" in
    --base) [ $# -ge 2 ] || usage; BASE=$2; BASE_SET=1; shift 2 ;;
    --dir) [ $# -ge 2 ] || usage; DIR=$2; shift 2 ;;
    --max-wait) [ $# -ge 2 ] || usage; MAX_WAIT=$2; shift 2 ;;
    --resume) [ $# -ge 2 ] || usage; MODE="resume"; RESUME_FILE=$2; shift 2 ;;
    --with-key) [ $# -ge 2 ] || usage; KEY_FILE=$2; shift 2 ;;
    -h|--help) usage ;;
    -*) say "unknown option: $1" >&2; usage ;;
    *) [ -z "$REQUEST_FILE" ] || usage; REQUEST_FILE=$1; shift ;;
  esac
done

command -v curl >/dev/null 2>&1 || fail 3 "curl がありません（curl is required）"
case "$MAX_WAIT" in ''|*[!0-9]*) fail 2 "--max-wait は秒の数（seconds）" ;; esac

# ---------------------------------------------------------------- JSON の扱い（node か python3。jq は使わない）
# 操作は jx_node と jx_py に同じ名前で 2 通り書く（どちらで動いても同じ出力）。RUN_SURVEY_JSON_ENGINE=node|python3 で選べる（試験用）。
JX_NODE='
const fs = require("fs");
const [op, ...a] = process.argv.slice(1);
const rd = (f) => JSON.parse(fs.readFileSync(f, "utf8"));
const out = (s) => process.stdout.write(s);
const at = (o, p) => p.split(".").reduce((v, k) => (v == null ? undefined : v[k]), o);
const str = (v) => (v == null ? "" : typeof v === "string" ? v : JSON.stringify(v));
const w = (f, o) => fs.writeFileSync(f, JSON.stringify(o), { mode: 0o600 });
const t = (v) => (v == null ? "?" : typeof v === "string" ? v : JSON.stringify(v));
switch (op) {
  case "get": { let v; try { v = at(rd(a[0]), a[1]); } catch { v = undefined; } out(str(v)); break; }
  case "isobj": { let v; try { v = rd(a[0]); } catch { process.exit(1); } process.exit(v && typeof v === "object" && !Array.isArray(v) ? 0 : 1); }
  case "state-new": w(a[0], { idempotency_key: a[1], base: a[2], created_at: a[3], with_key: a[4] || null, body: rd(a[5]) }); break;
  case "body": fs.writeFileSync(a[1], JSON.stringify(rd(a[0]).body)); break;
  case "set": { const o = rd(a[0]); o[a[1]] = JSON.parse(a[2]); w(a[0], o); break; }
  case "seed": { const k = rd(a[0]); w(a[1], { api_key: k.api_key ?? null, api_key_id: k.api_key_id ?? null, agent_id: k.agent_id ?? null }); break; }
  case "secret": {
    const r = rd(a[0]);
    const n = { job_id: at(r, "job.job_id"), api_key: at(r, "api_key.secret"), api_key_id: at(r, "api_key.id"), agent_id: at(r, "agent.id"), operator_secret: r.operator_secret, result_secret: at(r, "job.result_secret"), visibility: at(r, "job.visibility") };
    let o = {}; try { o = rd(a[1]); } catch {}
    for (const [k, v] of Object.entries(n)) { if (v != null) o[k] = v; else if (!(k in o)) o[k] = null; }
    w(a[1], o); break;
  }
  case "has-job": { let r; try { r = rd(a[0]); } catch { out("bad"); break; } out(r.job == null ? "null" : "job"); break; }
  case "header": { const v = at(rd(a[0]), a[1]); if (v == null || v === "") process.exit(1); fs.writeFileSync(a[3], a[2] + ": " + v + "\n", { mode: 0o600 }); break; }
  case "error": {
    let r; try { r = rd(a[0]); } catch { break; }
    const e = r.error || {};
    const L = [e.message, e.message_en, e.details ? "details: " + JSON.stringify(e.details) : null, r.request_id ? "request_id: " + r.request_id : null].filter(Boolean);
    out(L.map((x) => "  " + x + "\n").join("")); break;
  }
  case "joberr": { let r; try { r = rd(a[0]); } catch { break; } const e = r.job_error || {}; out([e.message, e.message_en].filter(Boolean).map((x) => "  " + x + "\n").join("")); break; }
  case "progress": { const r = rd(a[0]); out((at(r, "progress.done") ?? 0) + "/" + (at(r, "progress.total") ?? 0)); break; }
  case "brief": {
    const r = rd(a[0]); const L = [];
    if (r.supported === false) L.push("要約はありません（no brief for this result）: " + (r.full || ""));
    else {
      L.push("job: " + t(r.job_id) + "  state: " + t(r.state));
      L.push("respondents: " + t(at(r, "respondents.requested")) + " requested / " + t(at(r, "respondents.matched")) + " matched");
      for (const q of r.questions || []) {
        L.push("", "Q" + t(q.index) + ": " + t(q.text));
        // 件数（依頼・成立・不成立の内訳）と割合の分母は、brief の欄をそのまま出す（A Astra R-02。要約より先に読む物）
        if (q.counts && typeof q.counts === "object") L.push("  counts: " + t(q.counts));
        if (q.percent_base != null) L.push("  percent_base: " + t(q.percent_base));
        if (q.distribution) for (const d of q.distribution) L.push("  - " + t(d.label) + ": " + t(d.count) + " (" + t(d.percent) + "%)");
        else for (const v of q.voices || []) if (v && v.text) L.push("  > " + v.text);
        if (q.summary) L.push("  summary: " + t(q.summary));
      }
      if ((r.limits || []).length) { L.push("", "limits:"); for (const l of r.limits) L.push("  - " + t(l.message ?? l.code)); }
      L.push(""); if (r.note) L.push(String(r.note));
    }
    out(L.join("\n") + "\n"); break;
  }
  case "public": {
    const r = rd(a[0]);
    if (!r.public_url) process.exit(1);
    const L = ["", "公開 URL（public_url）: " + r.public_url];
    if (r.public_url_mirror) L.push("写し（mirror, for AIs that cannot open the URL above）: " + r.public_url_mirror);
    if (r.public_files && typeof r.public_files === "object") L.push("file: " + Object.entries(r.public_files).map(([k, v]) => k + " " + v).join("  "));
    out(L.join("\n") + "\n"); break;
  }
  default: process.stderr.write("jx: unknown op " + op + "\n"); process.exit(2);
}
'
JX_PY='
import json, os, sys
op, a = sys.argv[1], sys.argv[2:]
def rd(f):
    with open(f, encoding="utf-8") as h: return json.load(h)
def out(s): sys.stdout.write(s)
def at(o, p):
    for k in p.split("."):
        if isinstance(o, dict): o = o.get(k)
        elif isinstance(o, list) and k.isdigit() and int(k) < len(o): o = o[int(k)]
        else: return None
    return o
def js(v): return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
def s(v): return "" if v is None else v if isinstance(v, str) else js(v)
def t(v): return "?" if v is None else v if isinstance(v, str) else js(v)
def w(f, o, text=None):
    fd = os.open(f, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as h: h.write(js(o) if text is None else text)
if op == "get":
    try: v = at(rd(a[0]), a[1])
    except Exception: v = None
    out(s(v))
elif op == "isobj":
    try: v = rd(a[0])
    except Exception: sys.exit(1)
    sys.exit(0 if isinstance(v, dict) else 1)
elif op == "state-new": w(a[0], {"idempotency_key": a[1], "base": a[2], "created_at": a[3], "with_key": a[4] or None, "body": rd(a[5])})
elif op == "body":
    with open(a[1], "w", encoding="utf-8") as h: h.write(js(rd(a[0])["body"]))
elif op == "set":
    o = rd(a[0]); o[a[1]] = json.loads(a[2]); w(a[0], o)
elif op == "seed":
    k = rd(a[0]); w(a[1], {"api_key": k.get("api_key"), "api_key_id": k.get("api_key_id"), "agent_id": k.get("agent_id")})
elif op == "secret":
    r = rd(a[0])
    n = {"job_id": at(r, "job.job_id"), "api_key": at(r, "api_key.secret"), "api_key_id": at(r, "api_key.id"), "agent_id": at(r, "agent.id"), "operator_secret": r.get("operator_secret"), "result_secret": at(r, "job.result_secret"), "visibility": at(r, "job.visibility")}
    try: o = rd(a[1])
    except Exception: o = {}
    for k, v in n.items():
        if v is not None: o[k] = v
        elif k not in o: o[k] = None
    w(a[1], o)
elif op == "has-job":
    try: r = rd(a[0])
    except Exception: out("bad"); sys.exit(0)
    out("null" if r.get("job") is None else "job")
elif op == "header":
    v = at(rd(a[0]), a[1])
    if v is None or v == "": sys.exit(1)
    w(a[3], None, a[2] + ": " + s(v) + "\n")
elif op == "error":
    try: r = rd(a[0])
    except Exception: sys.exit(0)
    e = r.get("error") or {}
    L = [x for x in [e.get("message"), e.get("message_en"), ("details: " + js(e["details"])) if e.get("details") else None, ("request_id: " + r["request_id"]) if r.get("request_id") else None] if x]
    out("".join("  " + x + "\n" for x in L))
elif op == "joberr":
    try: r = rd(a[0])
    except Exception: sys.exit(0)
    e = r.get("job_error") or {}
    out("".join("  " + x + "\n" for x in [e.get("message"), e.get("message_en")] if x))
elif op == "progress":
    r = rd(a[0]); d = at(r, "progress.done"); n = at(r, "progress.total")
    out(s(0 if d is None else d) + "/" + s(0 if n is None else n))
elif op == "brief":
    r = rd(a[0]); L = []
    if r.get("supported") is False: L.append("要約はありません（no brief for this result）: " + (r.get("full") or ""))
    else:
        L.append("job: " + t(r.get("job_id")) + "  state: " + t(r.get("state")))
        L.append("respondents: " + t(at(r, "respondents.requested")) + " requested / " + t(at(r, "respondents.matched")) + " matched")
        for q in r.get("questions") or []:
            L += ["", "Q" + t(q.get("index")) + ": " + t(q.get("text"))]
            if isinstance(q.get("counts"), dict): L.append("  counts: " + t(q.get("counts")))
            if q.get("percent_base") is not None: L.append("  percent_base: " + t(q.get("percent_base")))
            if q.get("distribution"):
                for d in q["distribution"]: L.append("  - " + t(d.get("label")) + ": " + t(d.get("count")) + " (" + t(d.get("percent")) + "%)")
            else:
                for v in q.get("voices") or []:
                    if isinstance(v, dict) and v.get("text"): L.append("  > " + v["text"])
            if q.get("summary"): L.append("  summary: " + t(q.get("summary")))
        if r.get("limits"):
            L += ["", "limits:"] + ["  - " + t(l.get("message") if l.get("message") is not None else l.get("code")) for l in r["limits"]]
        L.append("")
        if r.get("note"): L.append(str(r["note"]))
    out("\n".join(L) + "\n")
elif op == "public":
    r = rd(a[0])
    if not r.get("public_url"): sys.exit(1)
    L = ["", "公開 URL（public_url）: " + r["public_url"]]
    if r.get("public_url_mirror"): L.append("写し（mirror, for AIs that cannot open the URL above）: " + r["public_url_mirror"])
    if isinstance(r.get("public_files"), dict): L.append("file: " + "  ".join(k + " " + s(v) for k, v in r["public_files"].items()))
    out("\n".join(L) + "\n")
else:
    sys.stderr.write("jx: unknown op " + op + "\n"); sys.exit(2)
'
JX_ENGINE=""
case "${RUN_SURVEY_JSON_ENGINE:-}" in
  node) JX_ENGINE=node ;;
  python3) JX_ENGINE=python3 ;;
  "")
    if command -v node >/dev/null 2>&1 && node -e '' >/dev/null 2>&1; then JX_ENGINE=node
    elif command -v python3 >/dev/null 2>&1 && python3 -c 'import json' >/dev/null 2>&1; then JX_ENGINE=python3
    fi ;;
esac
[ -n "$JX_ENGINE" ] || fail 3 "node か python3 が要ります（node or python3 is required to read JSON）"

# Git Bash（MSYS）の curl・node・python は Windows の道を読むので、file の道を変換する。
native() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi; }
# jx 操作 引数…（file の引数は呼ぶ側で native に通す）
jx() {
  if [ "$JX_ENGINE" = node ]; then node -e "$JX_NODE" "$@"
  else PYTHONIOENCODING=utf-8 python3 -c "$JX_PY" "$@"
  fi
}
jget() { jx get "$(native "$1")" "$2"; }

# 送り先は https だけ（鍵を平文で送らない）。試験のための手元（127.0.0.1・localhost）の http だけ認める。
# 文字列の先頭でなく、URL を scheme・ホスト・port・道に分けて検める（A Astra AA-IMPL-02：http://localhost:80@outside.invalid は
# 実際のホストが outside.invalid）。userinfo（@）・query（?）・fragment（#）・壊れた port・知らない文字は、鍵を付けて送る前に止める。
check_base() {
  BASE=${BASE%/}
  local scheme rest auth host port="" path
  case "$BASE" in
    https://*) scheme=https; rest=${BASE#https://} ;;
    http://*) scheme=http; rest=${BASE#http://} ;;
    *) fail 2 "--base は https:// の URL（got: $BASE）" ;;
  esac
  auth=${rest%%/*}
  path=${rest#"$auth"}
  host=$auth
  case "$auth" in
    *:*) host=${auth%%:*}; port=${auth#*:}
      case "$port" in ''|*[!0-9]*) fail 2 "--base の port が正しくありません（got: $BASE）" ;; esac
      { [ ${#port} -le 5 ] && [ $((10#$port)) -ge 1 ] && [ $((10#$port)) -le 65535 ]; } || fail 2 "--base の port が正しくありません（got: $BASE）" ;;
  esac
  [[ $host =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$ ]] || fail 2 "--base のホストが正しくありません（userinfo・空白などは使えない。got: $BASE）"
  [[ $path =~ ^(/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*$ ]] || fail 2 "--base の道が正しくありません（query・# などは使えない。got: $BASE）"
  if [ "$scheme" = http ]; then
    case "$(printf '%s' "$host" | tr 'A-Z' 'a-z')" in
      127.0.0.1|localhost) ;;
      *) fail 2 "--base は https:// の URL（http は手元の 127.0.0.1・localhost だけ。got: $BASE）" ;;
    esac
  fi
}

new_uuid() {
  local u="" h
  if command -v uuidgen >/dev/null 2>&1; then u=$(uuidgen 2>/dev/null || true); fi
  if [ -z "$u" ] && [ -r /proc/sys/kernel/random/uuid ]; then u=$(cat /proc/sys/kernel/random/uuid); fi
  if [ -z "$u" ]; then
    h=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
    u="${h:0:8}-${h:8:4}-4${h:13:3}-$(printf '%x' $(( (0x${h:16:1} & 3) | 8 )))${h:17:3}-${h:20:12}"
  fi
  u=$(printf '%s' "$u" | tr 'A-F' 'a-f' | tr -d '\r\n')
  printf '%s' "$u" | grep -Eq '^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' || fail 3 "UUID を作れません（cannot create a UUID）"
  printf '%s' "$u"
}

mkdir -p "$DIR" || fail 2 "置き場を作れません: $DIR"
chmod 700 "$DIR" 2>/dev/null || true
RESP="$DIR/.resp.$$"
RESP_H="$DIR/.resp-h.$$"
CURL_ERR="$DIR/.curl-err.$$"
AUTH_H="$DIR/.auth.$$"
BODY_F="$DIR/.body.$$"
RS_F="$DIR/.rs.$$"
cleanup() { rm -f "$RESP" "$RESP_H" "$CURL_ERR" "$AUTH_H" "$BODY_F" "$RS_F"; }
trap cleanup EXIT

HTTP_CODE=000
TRUNCATED=0
EXTRA_H=()
# http METHOD URL [本文の file]：応答の本文は $RESP、見出しは $RESP_H、状態は $HTTP_CODE（応答なしは 000）。転送（redirect）は追わない。
http() {
  local method=$1 url=$2 body=${3:-}
  local args=(-sS -X "$method" -o "$(native "$RESP")" -D "$(native "$RESP_H")" -w '%{http_code}' --max-time "$HTTP_TIMEOUT" -H "Accept: application/json" -H "User-Agent: cloneai-run-survey/1")
  [ -s "$AUTH_H" ] && args+=(-H "@$(native "$AUTH_H")")
  if [ ${#EXTRA_H[@]} -gt 0 ]; then local h; for h in "${EXTRA_H[@]}"; do args+=(-H "$h"); done; fi
  [ -n "$body" ] && args+=(-H "Content-Type: application/json" --data-binary "@$(native "$body")")
  : > "$RESP"; : > "$RESP_H"
  local rc=0 cl got
  TRUNCATED=0
  HTTP_CODE=$(curl "${args[@]}" "$url" 2>"$CURL_ERR") || rc=$?
  HTTP_CODE=$(printf '%s' "$HTTP_CODE" | tr -cd '0-9')
  [ -n "$HTTP_CODE" ] || HTTP_CODE=000
  # 通信の終了コードと HTTP の状態を別に見る（A Astra AA-IMPL-01）。見出しの後に切れた本文（curl 18 途中で切れた・28 時間切れ・56 受信の失敗など）と、
  # 見出しの Content-Length と受けたバイト数が違う本文は、状態コードがあっても応答なし（000・TRUNCATED=1）として扱う。
  # 呼ぶ側は 000 を、同じキー・同じ本文・同じ認証で送り直すか、非 0 で止める（不完全な本文を使わない。結果の取得は切れたら 1 回だけ取り直す）
  if [ "$rc" -ne 0 ]; then
    if [ "$HTTP_CODE" != 000 ]; then TRUNCATED=1; say "  通信が途中で切れました（curl $rc・HTTP $HTTP_CODE）。不完全な応答は使いません。" >&2; fi
    HTTP_CODE=000
  elif [ "$HTTP_CODE" != 000 ]; then
    cl=$(grep -i '^content-length:' "$RESP_H" 2>/dev/null | tail -1 | tr -d '\r' | awk '{print $2}')
    case "$cl" in
      ''|*[!0-9]*) ;;
      *) got=$(wc -c < "$RESP" | tr -d ' ')
         if [ "$got" != "$((10#$cl))" ]; then
           TRUNCATED=1; say "  応答の本文が Content-Length（$cl バイト）と違います（受けたのは $got バイト）。不完全な応答は使いません。" >&2
           HTTP_CODE=000
         fi ;;
    esac
  fi
}

retry_after() { # 見出しの Retry-After（秒・1〜60）。無ければ $1
  local v
  v=$(grep -i '^retry-after:' "$RESP_H" 2>/dev/null | tail -1 | tr -d '\r' | awk '{print $2}')
  case "$v" in ''|*[!0-9]*) v=$1 ;; esac
  [ "$v" -lt 1 ] && v=1
  [ "$v" -gt 60 ] && v=60
  printf '%s' "$v"
}
err_code() { jget "$RESP" error.code; }
show_error() { # 状態コード・code・message・details（秘密は入らない）
  local code; code=$(err_code)
  say "  HTTP $HTTP_CODE ${code:-（本文なし / no body）}"
  jx error "$(native "$RESP")"
}

set_auth_from() { # 秘密の file の api_key で Authorization の見出しの file を作る（コマンド行に鍵を出さない）
  jx header "$(native "$1")" api_key "Authorization" "$(native "$AUTH_H.tmp")" || fail 32 "鍵の file に api_key がありません: $1"
  sed 's/^Authorization: /Authorization: Bearer /' "$AUTH_H.tmp" > "$AUTH_H"; rm -f "$AUTH_H.tmp"
}

save_secret() { # 受付の応答（$RESP）から秘密を $SECRET_FILE へ（前の値は、新しい値が無い欄だけ残す）
  jx secret "$(native "$RESP")" "$(native "$SECRET_FILE")" || fail 32 "応答を読めません（invalid JSON）"
  chmod 600 "$SECRET_FILE" 2>/dev/null || true
}

state_set() { # 状態の file の欄を 1 つ書く
  jx set "$(native "$STATE_FILE")" "$1" "$2" || fail 2 "状態の file を書けません: $STATE_FILE"
  chmod 600 "$STATE_FILE" 2>/dev/null || true
}

# ---------------------------------------------------------------- 依頼の準備
if [ "$MODE" = "resume" ]; then
  [ -s "$RESUME_FILE" ] || fail 2 "状態の file がありません: $RESUME_FILE"
  STATE_FILE=$RESUME_FILE
  KEY=$(jget "$STATE_FILE" idempotency_key)
  [ -n "$KEY" ] || fail 2 "状態の file に idempotency_key がありません"
  [ "$BASE_SET" = 1 ] || BASE=$(jget "$STATE_FILE" base)
  SECRET_FILE="${STATE_FILE%.json}.secret"
  [ -n "$KEY_FILE" ] || KEY_FILE=$(jget "$STATE_FILE" with_key)
else
  [ -n "$REQUEST_FILE" ] || usage
  [ -s "$REQUEST_FILE" ] || fail 2 "依頼の file がありません: $REQUEST_FILE"
  jx isobj "$(native "$REQUEST_FILE")" || fail 21 "依頼の file が JSON のオブジェクトではありません（must be a JSON object）"
  check_base
  KEY=$(new_uuid)
  STATE_FILE="$DIR/$KEY.json"
  SECRET_FILE="$DIR/$KEY.secret"
  jx state-new "$(native "$STATE_FILE")" "$KEY" "${BASE%/}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$KEY_FILE" "$(native "$REQUEST_FILE")" || fail 2 "状態の file を書けません"
  chmod 600 "$STATE_FILE" 2>/dev/null || true
fi
check_base
if [ -n "$KEY_FILE" ]; then
  set_auth_from "$KEY_FILE"
  # Bearer の受付の応答には鍵が入らないので、使う鍵をこの依頼の秘密の file に写しておく
  [ -s "$SECRET_FILE" ] || { jx seed "$(native "$KEY_FILE")" "$(native "$SECRET_FILE")" && chmod 600 "$SECRET_FILE" 2>/dev/null; }
fi
say "状態の file（state）: $STATE_FILE  ※途中で止まったら: bash run-survey.sh --resume $STATE_FILE"

# ---------------------------------------------------------------- 1. 受付（同じキー・同じ本文で再送）
JOB_ID=""
[ -s "$SECRET_FILE" ] && JOB_ID=$(jget "$SECRET_FILE" job_id)
if [ -z "$JOB_ID" ]; then
  jx body "$(native "$STATE_FILE")" "$(native "$BODY_F")" || fail 2 "本文を読めません"
  EXTRA_H=("Idempotency-Key: $KEY")
  sent=0
  while :; do
    sent=$((sent + 1))
    http POST "$BASE/api/v1/start" "$BODY_F"
    code=$(err_code)
    case "$HTTP_CODE" in
      200|202|201)
        hj=$(jx has-job "$(native "$RESP")")
        [ "$hj" = bad ] && { say "応答を読めません（HTTP $HTTP_CODE）"; exit 23; }
        save_secret
        [ "$hj" = job ] && [ "$HTTP_CODE" != 201 ] && break
        say "受付されていません（not accepted: $HTTP_CODE job=null）。$(jget "$RESP" job_error.code)"
        jx joberr "$(native "$RESP")"
        say "登録はできたので、鍵は $SECRET_FILE に保存しました（画面には出しません）。時間をおいて、鍵と新しいキーで送り直してください："
        say "  bash run-survey.sh --with-key $SECRET_FILE ${REQUEST_FILE:-request.json}"
        exit 20 ;;
      000|5??)
        wait_s=$(retry_after 5)
        say "応答なし・一時的な失敗（HTTP $HTTP_CODE）。${wait_s} 秒後に同じキー・同じ本文で送り直します（$sent/$MAX_SEND）"
        ;;
      409)
        if [ "$code" != "request_in_progress" ]; then say "受付で止まりました（refused）:"; show_error; exit 23; fi
        wait_s=$(retry_after 5)
        say "処理中（409 request_in_progress）。${wait_s} 秒後に同じキー・同じ本文で送り直します（$sent/$MAX_SEND）"
        ;;
      429)
        wait_s=$(grep -i '^retry-after:' "$RESP_H" | tail -1 | tr -d '\r' | awk '{print $2}')
        case "$wait_s" in ''|*[!0-9]*) say "回数の枠で止まりました（429）:"; show_error; exit 23 ;; esac
        [ "$wait_s" -gt 60 ] && { say "回数の枠で止まりました（429・Retry-After ${wait_s}s）:"; show_error; exit 23; }
        say "回数の枠（429）。${wait_s} 秒後に送り直します（$sent/$MAX_SEND）"
        ;;
      400|422)
        say "依頼の誤り（request error）。依頼の JSON を直して、新しい依頼として流してください:"; show_error; exit 21 ;;
      *)
        say "受付で止まりました（refused）:"; show_error; exit 23 ;;
    esac
    [ "$sent" -ge "$MAX_SEND" ] && fail 22 "送り直しの上限（$MAX_SEND 回）。後で同じキーで: bash run-survey.sh --resume $STATE_FILE"
    sleep "$wait_s"
  done
  JOB_ID=$(jget "$SECRET_FILE" job_id)
  say "受け付けました（accepted）: job $JOB_ID"
else
  say "受付済み（already accepted）: job $JOB_ID"
fi
case "$JOB_ID" in ''|*[!A-Za-z0-9_-]*) fail 32 "job_id が読めません" ;; esac
set_auth_from "$SECRET_FILE"
# X-Result-Secret（サーバが鍵を作る完全非公開の結果）も、見出しの file から渡す（コマンド行に出さない）
RESULT_SECRET_H=()
if jx header "$(native "$SECRET_FILE")" result_secret "X-Result-Secret" "$(native "$RS_F")"; then
  RESULT_SECRET_H=("@$(native "$RS_F")")
fi

# ---------------------------------------------------------------- 公開を申請済みなら、URL を読むだけ（--resume）
show_public() { # $RESP は GET /jobs/{id} か publish の応答。URL があれば表示して 0、無ければ 1
  jx public "$(native "$RESP")" || return 1
  say "この URL を AI に渡してください（鍵や state の file は渡さない）。Give this URL to the AI."
  return 0
}

if [ "$(jget "$STATE_FILE" publish_requested)" = "true" ]; then
  EXTRA_H=()
  http GET "$BASE/api/v1/jobs/$JOB_ID"
  [ "$HTTP_CODE" = 200 ] || { say "ジョブを読めません:"; show_error; exit 32; }
  show_public && exit 0
  pub=$(jget "$RESP" publication)
  case "$pub" in
    pending_review) say "公開の確認待ちです（publication=pending_review。まだ URL はありません。未完了）。後でもう一度: bash run-survey.sh --resume $STATE_FILE"; exit 10 ;;
    *) say "公開されていません（publication=${pub:-none}）。"; exit 40 ;;
  esac
fi

# ---------------------------------------------------------------- 2. 待ち
deadline=$(( $(date +%s) + MAX_WAIT ))
fails=0
STATE=""
while :; do
  EXTRA_H=("Prefer: wait=30")
  http GET "$BASE/api/v1/jobs/$JOB_ID"
  case "$HTTP_CODE" in
    200)
      fails=0
      STATE=$(jget "$RESP" state)
      case "$STATE" in succeeded|partially_succeeded|failed|canceled|expired) break ;; esac
      wait_s=$(jget "$RESP" retry_after_seconds)
      case "$wait_s" in ''|*[!0-9]*) wait_s=5 ;; esac
      say "待っています（state=$STATE, $(jx progress "$(native "$RESP")")）"
      ;;
    000|5??|429)
      fails=$((fails + 1))
      [ "$fails" -gt "$MAX_SEND" ] && fail 32 "ジョブを読めません（HTTP $HTTP_CODE が続いた）。後で: bash run-survey.sh --resume $STATE_FILE"
      wait_s=$(retry_after 5)
      ;;
    *) say "ジョブを読めません:"; show_error; exit 23 ;;
  esac
  now=$(date +%s)
  if [ "$now" -ge "$deadline" ]; then
    fail 30 "待ちの上限（${MAX_WAIT} 秒）。ジョブは続いているかもしれません。後で: bash run-survey.sh --resume $STATE_FILE"
  fi
  [ $((now + wait_s)) -gt "$deadline" ] && wait_s=$((deadline - now))
  [ "$wait_s" -lt 1 ] && wait_s=1
  sleep "$wait_s"
done

# ---------------------------------------------------------------- 3. 終端
case "$STATE" in
  succeeded|partially_succeeded) ;;
  *)
    code=$(jget "$RESP" error.code); [ -n "$code" ] || code=$(jget "$RESP" stop_reason.code)
    msg=$(jget "$RESP" error.message); [ -n "$msg" ] || msg=$(jget "$RESP" stop_reason.message)
    say "ジョブは成功していません（not successful）: state=$STATE $code"
    [ -n "$msg" ] && say "  $msg"
    exit 31 ;;
esac

fetch_result() { # fetch_result FORMAT 出力の file（空なら $RESP のまま）。保存は一時の file に写して大きさを照らしてから置き換える（失敗は 1。前の file は壊さない）
  local fmt=$1 out=${2:-} tries=0 trunc=0 tmp
  while :; do
    EXTRA_H=()
    [ ${#RESULT_SECRET_H[@]} -gt 0 ] && EXTRA_H=("${RESULT_SECRET_H[@]}")
    http GET "$BASE/api/v1/jobs/$JOB_ID/result?format=$fmt"
    case "$HTTP_CODE" in
      200)
        [ -n "$out" ] || return 0
        [ -s "$RESP" ] || { say "結果が空です（format=$fmt）。保存しません。"; return 1; }
        tmp="$out.part.$$"
        if cp "$RESP" "$tmp" && [ "$(wc -c < "$tmp")" = "$(wc -c < "$RESP")" ]; then
          chmod 600 "$tmp" 2>/dev/null || true
          mv -f "$tmp" "$out" && return 0
        fi
        rm -f "$tmp"
        say "保存できません（format=$fmt）: $out"
        return 1 ;;
      409|000|5??)
        if [ "$TRUNCATED" = 1 ]; then # 途中で切れた本文は、同じ URL・同じ認証ですぐ 1 回だけ取り直す。2 回目も切れたら止める
          trunc=$((trunc + 1))
          [ "$trunc" -gt 1 ] && { say "結果が 2 回とも途中で切れました（format=$fmt）。保存しません。"; return 1; }
          continue
        fi
        tries=$((tries + 1))
        [ "$tries" -gt "$MAX_SEND" ] && { say "結果を取れません（format=$fmt）:"; show_error; return 1; }
        sleep "$(retry_after 5)" ;;
      *) say "結果を取れません（format=$fmt）:"; show_error; return 1 ;;
    esac
  done
}

fetch_result brief || exit 32
# 要約が読めない時は、公開の同意へ進まない（読めない結果を公開しない。A Astra AA-IMPL-01）
jx isobj "$(native "$RESP")" || fail 32 "結果の要約を JSON として読めません（公開へは進みません）。後で: bash run-survey.sh --resume $STATE_FILE"
say ""
if [ "$STATE" = "partially_succeeded" ]; then say "一部だけ成功しました（partially_succeeded）。回答のそろっていない設問があります。"; else say "成功しました（succeeded）。"; fi
jx brief "$(native "$RESP")" 2>/dev/null || fail 32 "結果の要約を表示できません（公開へは進みません）。後で: bash run-survey.sh --resume $STATE_FILE"

# ---------------------------------------------------------------- 4. 公開の同意（1 回だけ聞く）
private_files() {
  local md="$DIR/$JOB_ID.md" csv="$DIR/$JOB_ID.csv"
  fetch_result md "$md" || exit 32
  fetch_result csv "$csv" || exit 32
  say ""
  say "保存しました（saved, private）: $md  $csv"
  say "この file を AI に渡してください（非公開のまま。鍵や state の file は渡さない）。Give these files to the AI."
  exit 0
}

VIS=$(jget "$SECRET_FILE" visibility)
if [ "$VIS" = "sealed" ] || [ ${#RESULT_SECRET_H[@]} -gt 0 ]; then
  say "完全非公開（sealed）のジョブは公開できません。file を保存します。"
  private_files
fi

say ""
say "この結果を公開 OK にしますか？（URL を知る人は誰でも読める。運営は内容を確認しない。いつでも取り下げられる）[y/N]"
say "Publish this result? (anyone with the URL can read it; the operator does not review it; you can withdraw it any time) [y/N]"
ans=""
# 改行の無い最後の行（printf 'y' | …）でも答えを捨てない（read は 1 を返すが ans は入る）。入力が無ければ N
IFS= read -r ans || true
ans=$(printf '%s' "$ans" | tr -d '\r[:space:]')
case "$ans" in
  y|Y|yes|YES|Yes) ;;
  *) private_files ;;
esac

PUB_KEY=$(jget "$STATE_FILE" publish_key)
if [ -z "$PUB_KEY" ]; then PUB_KEY=$(new_uuid); state_set publish_key "\"$PUB_KEY\""; fi
printf '{}' > "$BODY_F"
sent=0
while :; do
  sent=$((sent + 1))
  EXTRA_H=("Idempotency-Key: $PUB_KEY")
  http POST "$BASE/api/v1/jobs/$JOB_ID/publish" "$BODY_F"
  case "$HTTP_CODE" in
    200) break ;;
    000|5??)
      [ "$sent" -ge "$MAX_SEND" ] && fail 40 "公開の申請に応答がありません。後で: bash run-survey.sh --resume $STATE_FILE"
      sleep "$(retry_after 5)" ;;
    *) say "公開できませんでした（publish failed）:"; show_error; exit 40 ;;
  esac
done
state_set publish_requested true
show_public && exit 0
pub=$(jget "$RESP" publication)
if [ "$pub" = "pending_review" ]; then
  say "公開の申請は受け付けられました。まだ URL はありません（運営の確認待ち。未完了 / not finished）。"
  say "確認の後にもう一度: bash run-survey.sh --resume $STATE_FILE"
  exit 10
fi
say "公開の状態が読めません（publication=${pub:-none}）。"
exit 40
