#!/usr/bin/env bash
# E2E for delivery_mode=poll + since_id cursor (#2339).
#
# Round-trip: register a workspace as poll-mode (no URL) → POST A2A to it →
# verify the proxy short-circuits to {status:"queued"} → verify the message
# appears in /activity → verify the since_id cursor returns ONLY new events
# in ASC order → verify a stale cursor returns 410.
#
# Requires: platform running on localhost:8080 with migrations applied.
#   bash workspace-server/scripts/dev-start.sh
#   bash workspace-server/scripts/run-migrations.sh
#
# Idempotent: each run uses fresh per-script workspace ids so reruns don't
# collide. Does NOT call e2e_cleanup_all_workspaces — see
# `feedback_never_run_cluster_cleanup_tests_on_live_platform.md`.

set -euo pipefail

source "$(dirname "$0")/_lib.sh"

PASS=0
FAIL=0
TIMEOUT="${A2A_TIMEOUT:-30}"

# Per-run unique ids — workspaces.id is a UUID column, so we generate
# real v4 UUIDs. A "ws-<tag>" string fails the pq UUID cast and surfaces
# as opaque "registration failed" (caught against this very test in CI
# before merge — the failure mode that motivates the helper).
gen_uuid() {
  if command -v uuidgen >/dev/null 2>&1; then
    uuidgen | tr '[:upper:]' '[:lower:]'
  else
    python3 -c 'import uuid; print(uuid.uuid4())'
  fi
}
POLL_WS_ID="$(gen_uuid)"
CALLER_WS_ID="$(gen_uuid)"
# Phase 2 uses a separate UUID for its invalid-mode probe so a rerun
# can't poison POLL_WS_ID's row with a bad upsert (the 400 path doesn't
# touch DB, but defense in depth).
INVALID_PROBE_ID="$(gen_uuid)"

cleanup() {
  local rc=$?
  # Best-effort delete; non-fatal if the row was never created.
  curl -s -X DELETE "$BASE/workspaces/$POLL_WS_ID" >/dev/null || true
  curl -s -X DELETE "$BASE/workspaces/$CALLER_WS_ID" >/dev/null || true
  exit $rc
}
trap cleanup EXIT

check() {
  local desc="$1"
  local expected="$2"
  local actual="$3"
  if echo "$actual" | grep -qF -- "$expected"; then
    echo "PASS: $desc"
    PASS=$((PASS + 1))
  else
    echo "FAIL: $desc"
    echo "  expected to contain: $expected"
    echo "  got: $(echo "$actual" | head -10)"
    FAIL=$((FAIL + 1))
  fi
}

check_eq() {
  local desc="$1"
  local expected="$2"
  local actual="$3"
  if [ "$actual" = "$expected" ]; then
    echo "PASS: $desc"
    PASS=$((PASS + 1))
  else
    echo "FAIL: $desc"
    echo "  expected: $expected"
    echo "  got:      $actual"
    FAIL=$((FAIL + 1))
  fi
}

check_not_contains() {
  local desc="$1"
  local unexpected="$2"
  local actual="$3"
  if echo "$actual" | grep -qF -- "$unexpected"; then
    echo "FAIL: $desc"
    echo "  should NOT contain: $unexpected"
    FAIL=$((FAIL + 1))
  else
    echo "PASS: $desc"
    PASS=$((PASS + 1))
  fi
}

echo "=== Poll-Mode + since_id Cursor E2E (#2339) ==="
echo "  base: $BASE"
echo "  poll workspace: $POLL_WS_ID"
echo "  caller workspace: $CALLER_WS_ID"
echo ""

# ---------- Phase 1: register as poll-mode ----------
echo "--- Phase 1: Register poll-mode workspace (no URL) ---"

# A poll-mode workspace registers WITHOUT a URL — that's the contract from
# PR 1 (#2348). The agent_card is required; everything else is optional.
REG_RESP=$(curl -s -X POST "$BASE/registry/register" \
  -H "Content-Type: application/json" \
  -d "{
    \"id\": \"$POLL_WS_ID\",
    \"delivery_mode\": \"poll\",
    \"agent_card\": {\"name\": \"poll-mode-test\"}
  }")

check "register accepts poll mode without URL" '"status":"registered"' "$REG_RESP"
check "register response echoes delivery_mode=poll"  '"delivery_mode":"poll"' "$REG_RESP"

# Capture the auth token for subsequent /activity reads (Phase 30.1).
POLL_TOKEN=$(echo "$REG_RESP" | e2e_extract_token || true)
if [ -z "$POLL_TOKEN" ]; then
  echo "WARN: no auth_token in register response — token-required reads will fail"
fi

# ---------- Phase 2: invalid mode rejected ----------
echo ""
echo "--- Phase 2: Invalid delivery_mode rejected ---"

INVALID_RESP=$(curl -s -w '\n%{http_code}' -X POST "$BASE/registry/register" \
  -H "Content-Type: application/json" \
  -d "{
    \"id\": \"$INVALID_PROBE_ID\",
    \"delivery_mode\": \"webhook\",
    \"agent_card\": {\"name\": \"bad\"}
  }")
INVALID_CODE=$(printf '%s' "$INVALID_RESP" | tail -n1)
INVALID_BODY=$(printf '%s' "$INVALID_RESP" | sed '$d')

check_eq "register rejects unknown delivery_mode (HTTP 400)" "400" "$INVALID_CODE"
check "error mentions delivery_mode" "delivery_mode" "$INVALID_BODY"

# ---------- Phase 3: A2A short-circuits to {status:"queued"} ----------
echo ""
echo "--- Phase 3: A2A to poll-mode workspace short-circuits ---"

A2A_RESP=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$POLL_WS_ID/a2a" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": "msg-1",
    "method": "message/send",
    "params": {
      "message": {
        "role": "user",
        "parts": [{"type": "text", "text": "hello-from-e2e-1"}]
      }
    }
  }')

check "poll-mode A2A returns queued status" '"status":"queued"' "$A2A_RESP"

# ---------- Phase 3.5: Python parser classifies queued envelope correctly ----------
# (#2967) — server emits the queued envelope, the wheel's a2a_response.parse()
# MUST classify it as the Queued variant, not Malformed. Pre-#2967 the bare
# message/send parser in a2a_client.py:587 misclassified this and returned
# "[A2A_ERROR] unexpected response shape", which broke external↔external A2A
# on poll-mode peers.
#
# This phase exercises the actual on-the-wire response from a real
# workspace-server (NOT a mocked dict) through the same module the production
# wheel ships, so a regression in either the server emit shape OR the client
# parser fails this E2E.

echo ""
echo "--- Phase 3.5: Python parser classifies real server response (#2967) ---"

# Pipe the queued response captured above through a2a_response.parse and
# assert the classification. WORKSPACE_ID is required at module import
# time but irrelevant to this parsing call (any UUID is fine).
PARSE_RESULT=$(WORKSPACE_ID="00000000-0000-0000-0000-000000000001" \
  python3 -c "
import json, sys
sys.path.insert(0, '$(cd "$(dirname "$0")/../../workspace" && pwd)')
import a2a_response
data = json.loads(r'''$A2A_RESP''')
v = a2a_response.parse(data)
print(type(v).__name__)
if isinstance(v, a2a_response.Queued):
    print(f'method={v.method} delivery_mode={v.delivery_mode}')
")

check_eq "Python parser classifies real server response as Queued" \
  "Queued" "$(printf '%s' "$PARSE_RESULT" | head -n1)"
check "Queued variant captures method=message/send" \
  "method=message/send" "$PARSE_RESULT"
check "Queued variant captures delivery_mode=poll" \
  "delivery_mode=poll" "$PARSE_RESULT"
check "queued response echoes delivery_mode=poll" '"delivery_mode":"poll"' "$A2A_RESP"
check "queued response echoes the JSON-RPC method" '"method":"message/send"' "$A2A_RESP"

# ---------- Phase 4: queued message appears in /activity ----------
echo ""
echo "--- Phase 4: Queued message visible via /activity ---"

# The activity_logs INSERT runs in a goroutine — give it a moment.
sleep 1

# Use bearer token if we got one; some platforms require it on /activity.
ACTIVITY_AUTH=()
[ -n "${POLL_TOKEN:-}" ] && ACTIVITY_AUTH=(-H "Authorization: Bearer $POLL_TOKEN")

ACT_RESP=$(curl -s --max-time "$TIMEOUT" "${ACTIVITY_AUTH[@]}" \
  "$BASE/workspaces/$POLL_WS_ID/activity?type=a2a_receive&limit=10")

check "activity feed has the queued message text" "hello-from-e2e-1" "$ACT_RESP"
check "activity_type is a2a_receive"             '"activity_type":"a2a_receive"' "$ACT_RESP"
check "method preserved on the activity row"     '"method":"message/send"' "$ACT_RESP"

# Pull the most-recent activity_id for use as a cursor.
FIRST_ACTIVITY_ID=$(echo "$ACT_RESP" | python3 -c "
import json, sys
rows = json.load(sys.stdin)
if not rows:
    print('')
else:
    # Default ordering is DESC (newest-first) when no since_id is set.
    print(rows[0]['id'])
")

if [ -z "$FIRST_ACTIVITY_ID" ]; then
  echo "FAIL: could not extract activity_id from /activity response"
  FAIL=$((FAIL + 1))
  exit 1
fi
echo "  cursor candidate: $FIRST_ACTIVITY_ID"

# ---------- Phase 5: since_id returns only events strictly after ----------
echo ""
echo "--- Phase 5: since_id cursor returns ASC, strictly-after ---"

# Send a SECOND A2A message; it must appear in the cursor-filtered feed,
# the FIRST message must NOT (cursor is strictly-after).
A2A_RESP2=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$POLL_WS_ID/a2a" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": "msg-2",
    "method": "message/send",
    "params": {
      "message": {
        "role": "user",
        "parts": [{"type": "text", "text": "hello-from-e2e-2"}]
      }
    }
  }')
check "second A2A also queues" '"status":"queued"' "$A2A_RESP2"

sleep 1

CURSOR_RESP=$(curl -s --max-time "$TIMEOUT" "${ACTIVITY_AUTH[@]}" \
  "$BASE/workspaces/$POLL_WS_ID/activity?type=a2a_receive&since_id=$FIRST_ACTIVITY_ID&limit=10")

check              "since_id feed includes the new message"          "hello-from-e2e-2" "$CURSOR_RESP"
check_not_contains "since_id feed excludes the cursor row itself"  "hello-from-e2e-1" "$CURSOR_RESP"

# Verify ASC ordering: in a fresh cursor window with two new events the
# array's first element must be the OLDER one (the test only sends one
# event after the cursor, so this case is trivially "exactly one row";
# the next sub-phase strengthens this with a second event).
A2A_RESP3=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$POLL_WS_ID/a2a" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": "msg-3",
    "method": "message/send",
    "params": {
      "message": {
        "role": "user",
        "parts": [{"type": "text", "text": "hello-from-e2e-3"}]
      }
    }
  }')
check "third A2A queues" '"status":"queued"' "$A2A_RESP3"

sleep 1

ASC_RESP=$(curl -s --max-time "$TIMEOUT" "${ACTIVITY_AUTH[@]}" \
  "$BASE/workspaces/$POLL_WS_ID/activity?type=a2a_receive&since_id=$FIRST_ACTIVITY_ID&limit=10")

# rows[0] should be msg-2 (older), rows[-1] should be msg-3 (newer) — that's
# ASC. If the server still defaulted to DESC, rows[0] would be msg-3.
ASC_FIRST=$(echo "$ASC_RESP" | python3 -c "
import json, sys
rows = json.load(sys.stdin)
def text_of(r):
    body = r.get('request_body') or {}
    parts = (body.get('params') or {}).get('message', {}).get('parts') or []
    return ''.join(p.get('text','') for p in parts if p.get('type')=='text')
if len(rows) < 2:
    print('NEED2_GOT_'+str(len(rows)))
else:
    print(text_of(rows[0]) + '|' + text_of(rows[-1]))
")
check_eq "since_id feed orders ASC (oldest-new first, newest-new last)" \
  "hello-from-e2e-2|hello-from-e2e-3" "$ASC_FIRST"

# ---------- Phase 6: stale cursor returns 410 ----------
echo ""
echo "--- Phase 6: Stale / unknown cursor returns 410 ---"

GONE_RESP=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" "${ACTIVITY_AUTH[@]}" \
  "$BASE/workspaces/$POLL_WS_ID/activity?since_id=00000000-0000-0000-0000-000000000000")
GONE_CODE=$(printf '%s' "$GONE_RESP" | tail -n1)
GONE_BODY=$(printf '%s' "$GONE_RESP" | sed '$d')

check_eq "unknown since_id returns HTTP 410 Gone" "410" "$GONE_CODE"
check "410 body explains how to recover" "since_id" "$GONE_BODY"

# ---------- Phase 7: cross-workspace cursor isolation ----------
echo ""
echo "--- Phase 7: Cross-workspace cursor isolation ---"

# Register a SECOND poll-mode workspace and try to read its activity
# feed using a cursor from the FIRST workspace. Must 410 — the cursor
# is workspace-scoped to prevent UUID-guessing peeks.
REG2=$(curl -s -X POST "$BASE/registry/register" \
  -H "Content-Type: application/json" \
  -d "{
    \"id\": \"$CALLER_WS_ID\",
    \"delivery_mode\": \"poll\",
    \"agent_card\": {\"name\": \"poll-cross-test\"}
  }")
check "second poll-mode workspace registers" '"status":"registered"' "$REG2"
CALLER_TOKEN=$(echo "$REG2" | e2e_extract_token || true)
CROSS_AUTH=()
[ -n "${CALLER_TOKEN:-}" ] && CROSS_AUTH=(-H "Authorization: Bearer $CALLER_TOKEN")

CROSS_RESP=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" "${CROSS_AUTH[@]}" \
  "$BASE/workspaces/$CALLER_WS_ID/activity?since_id=$FIRST_ACTIVITY_ID")
CROSS_CODE=$(printf '%s' "$CROSS_RESP" | tail -n1)
check_eq "cross-workspace cursor blocked with 410 (no info leak)" "410" "$CROSS_CODE"

# ---------- Results ----------
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
[ "$FAIL" -eq 0 ]
