package handlers

// a2a_proxy_helpers.go — A2A proxy error handling, activity logging,
// caller auth validation, token usage tracking, and SSRF safety checks.

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"strconv"
	"time"

	"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
	"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
	"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
	"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
	"github.com/gin-gonic/gin"
)

// proxyDispatchBuildError is a sentinel wrapper for failures inside
// http.NewRequestWithContext. handleA2ADispatchError unwraps it to emit the
// "failed to create proxy request" 500 instead of the standard 502/503 paths.
type proxyDispatchBuildError struct{ err error }

func (e *proxyDispatchBuildError) Error() string { return e.err.Error() }

// handleA2ADispatchError translates a forward-call failure into a proxyA2AError,
// runs the reactive container-health check, and (when `logActivity` is true)
// schedules a detached LogActivity goroutine for the failed attempt.
func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, err error, durationMs int, logActivity bool) (int, []byte, *proxyA2AError) {
	// Build-time failure (couldn't even create the http.Request) — return
	// a 500 without the reactive-health / busy-retry paths.
	if buildErr, ok := err.(*proxyDispatchBuildError); ok {
		_ = buildErr
		return 0, nil, &proxyA2AError{
			Status:   http.StatusInternalServerError,
			Response: gin.H{"error": "failed to create proxy request"},
		}
	}

	log.Printf("ProxyA2A forward error: %v", err)

	containerDead := h.maybeMarkContainerDead(ctx, workspaceID)

	if logActivity {
		h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
	}
	if containerDead {
		return 0, nil, &proxyA2AError{
			Status:   http.StatusServiceUnavailable,
			Response: gin.H{"error": "workspace agent unreachable — container restart triggered", "restarting": true},
		}
	}
	// Container is alive but upstream Do() failed with a timeout/EOF-
	// shaped error — the agent is most likely mid-synthesis on a
	// previous request (single-threaded main loop). Surface as 503
	// Busy with a Retry-After hint so callers can distinguish this
	// from a real unreachable-agent (502) and retry with backoff.
	// Issue #110.
	//
	// #1870 Phase 1: before returning 503, enqueue the request for drain
	// on next heartbeat. Returning 202 Accepted {queued:true} as a SUCCESS
	// (not an error) means callers record this as "dispatched — queued"
	// not "failed", eliminating the fan-out-storm drop pattern.
	//
	// Critical: must return (status, body, NIL ERROR) so the caller's
	// `if proxyErr != nil` branch doesn't fire. Returning a proxyA2AError
	// with 202 status here was the original cycle 53 bug — callers saw
	// proxyErr != nil and logged "delegation failed: proxy a2a error".
	if isUpstreamBusyError(err) {
		// #1684 / Reno Stars: native_session adapters previously took a
		// 503-no-enqueue path here, on the assumption that the SDK owned
		// an inbound queue and the platform a2a_queue would double-buffer.
		// In practice, the common native_session SDKs (claude-agent-sdk,
		// codex app-server, hermes-agent) do NOT have an inbound queue —
		// new turns can only be pushed via the same HTTP POST that just
		// returned busy. So cron fires (and any A2A retry) bounce 503
		// every tick until the SDK voluntarily yields. Reno Stars #1684
		// observed 12 consecutive `*/30` cron fires lost over 6h while a
		// single native_session held the slot.
		//
		// The original concern — "drain timing has no relationship to SDK
		// readiness" — turns out to be unfounded: heartbeat→drain is
		// gated by `payload.ActiveTasks < maxConcurrent` in
		// registry.go:Heartbeat, so drain only fires when the workspace
		// itself reports spare capacity. That IS the session-ended
		// signal. The native_session SDK reports ActiveTasks=1 while in a
		// turn, ActiveTasks=0 when idle, and the next heartbeat after
		// idle triggers DrainQueueForWorkspace.
		//
		// So we collapse the two branches: both native_session and
		// non-native callers enqueue here. The native_session SDK's own
		// in-flight POST stays unaffected; the queued item drains on the
		// next post-idle heartbeat.
		idempotencyKey := extractIdempotencyKey(body)
		// Honor params.expires_in_seconds when the caller specifies one. Zero
		// (the unset default) → expiresAt = nil → infinite TTL preserved by
		// DequeueNext. RFC #2331 Tier 1.
		var expiresAt *time.Time
		if secs := extractExpiresInSeconds(body); secs > 0 {
			t := time.Now().Add(time.Duration(secs) * time.Second)
			expiresAt = &t
		}
		if qid, depth, qerr := EnqueueA2A(
			ctx, workspaceID, callerID, PriorityTask, body, a2aMethod, idempotencyKey, expiresAt,
		); qerr == nil {
			log.Printf("ProxyA2A: target %s busy — enqueued as %s (depth=%d)", workspaceID, qid, depth)
			respBody, _ := json.Marshal(gin.H{
				"queued":      true,
				"queue_id":    qid,
				"queue_depth": depth,
				"message":     "workspace agent busy — request queued, will dispatch when capacity available",
			})
			return http.StatusAccepted, respBody, nil
		} else {
			// Queue insert failed — fall through to legacy 503 behavior
			// so callers still retry. We don't want a queue DB hiccup to
			// make delegation silently disappear.
			log.Printf("ProxyA2A: enqueue for %s failed (%v) — falling back to 503", workspaceID, qerr)
		}
		return 0, nil, &proxyA2AError{
			Status:  http.StatusServiceUnavailable,
			Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
			Response: gin.H{
				"error":       "workspace agent busy — retry after a short backoff",
				"busy":        true,
				"retry_after": busyRetryAfterSeconds,
			},
		}
	}
	return 0, nil, &proxyA2AError{
		Status:   http.StatusBadGateway,
		Response: gin.H{"error": "failed to reach workspace agent"},
	}
}

// maybeMarkContainerDead runs the reactive health check after a forward error.
// If the workspace's compute (Docker container OR EC2 instance) is no longer
// running (and the workspace isn't external), it marks the workspace offline,
// clears Redis state, broadcasts WORKSPACE_OFFLINE, and triggers an async
// restart. Returns true when the compute was found dead.
//
// Provisioner selection (mutually exclusive in production):
//   - h.provisioner != nil  → local Docker deployment; IsRunning does docker inspect.
//   - h.cpProv != nil       → SaaS / EC2 deployment; IsRunning calls CP's
//     /cp/workspaces/:id/status to read the EC2 state.
//
// Pre-fix this function ONLY consulted h.provisioner — for SaaS tenants
// (h.provisioner=nil, h.cpProv=set) it short-circuited to false on every
// call, so a dead EC2 agent would propagate upstream 502/503/504 to canvas
// with no auto-recovery and Cloudflare in front would mask the response with
// its own error page. The 2026-04-30 hongmingwang.moleculesai.app
// canvas-chat-to-dead-workspace incident traces to exactly this gap.
func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspaceID string) bool {
	var wsRuntime string
	db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsRuntime)
	if isExternalLikeRuntime(wsRuntime) {
		return false
	}
	if !h.HasProvisioner() {
		return false
	}
	// Restart-aware short-circuit: during the 20-30s EC2-pending window of
	// an in-flight restart, the workspace's url='' and IsRunning() returns
	// false → looks indistinguishable from a dead container. Pre-fix this
	// fired a fresh RestartByID for the just-launched instance, which
	// coalesceRestart's pending-flag drained by running ANOTHER full
	// stop+provision cycle (= ec2_stopped of the still-pending instance
	// → re-provision). That's the 4x reprov thrash class. Skip the
	// container-dead path while a restart is in flight; the in-flight
	// restart's own provisionWorkspaceAutoSync will surface a real failure
	// (markProvisionFailed) if the new container never comes up. Issue
	// internal#544.
	if isRestarting(workspaceID) {
		log.Printf("ProxyA2A: maybeMarkContainerDead skipped for %s — restart already in flight (self-fire guard)", workspaceID)
		return false
	}

	var running bool
	var inspectErr error
	if h.provisioner != nil {
		running, inspectErr = h.provisioner.IsRunning(ctx, workspaceID)
	} else {
		// SaaS path: ask the CP about the EC2 state. Same (true, err) on
		// transport errors contract — keeps the caller on the alive path
		// instead of triggering a restart cascade on a flaky CP call.
		running, inspectErr = h.cpProv.IsRunning(ctx, workspaceID)
	}
	if inspectErr != nil {
		// Transient backend error (Docker daemon EOF, CP HTTP 5xx, etc.).
		// IsRunning's contract returns (true, err) in this case so we stay
		// on the alive path without triggering a restart cascade.
		log.Printf("ProxyA2A: IsRunning for %s returned transient error (assuming alive): %v", workspaceID, inspectErr)
	}
	if running {
		return false
	}
	log.Printf("ProxyA2A: container for %s is dead — marking offline and triggering restart", workspaceID)
	if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status NOT IN ('removed', 'provisioning')`, models.StatusOffline, workspaceID); err != nil {
		log.Printf("ProxyA2A: failed to mark workspace %s offline: %v", workspaceID, err)
	}
	db.ClearWorkspaceKeys(ctx, workspaceID)
	h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
	// Tracked via goAsync (not bare `go`) so the asyncWG can be drained
	// before a test swaps the global db.DB. runRestartCycle reads db.DB
	// before its provisioner gate, so an untracked detached goroutine
	// races setupTestDB's t.Cleanup db.DB restore. Matches the already-
	// correct site at a2a_proxy.go:648.
	h.goAsync(func() { h.RestartByID(workspaceID) })
	return true
}

// preflightContainerHealth runs a proactive Provisioner.IsRunning check
// (#36) before dispatching the a2a forward. Routed through provisioner's
// SSOT IsRunning, which itself wraps RunningContainerName — same source
// as findRunningContainer in the plugins handler (#10/#12).
//
// Returns nil when the forward should proceed:
//   - container is running, OR
//   - daemon errored transiently (matches IsRunning's (true, err)
//     "fail-soft as alive" contract — let the optimistic forward run
//     and reactive maybeMarkContainerDead catch a real failure).
//
// Returns a structured 503 + triggers the same async restart that
// maybeMarkContainerDead would produce, when:
//   - container is genuinely not running (NotFound / Exited / Created…).
//
// The point of running this BEFORE the forward is to save the caller
// 2-30s of network-timeout cost when the container is missing — a common
// shape post-EC2-replace (see molecule-controlplane#20 incident
// 2026-05-07) where the reconciler hasn't respawned the agent yet.
func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspaceID string) *proxyA2AError {
	// Restart-aware short-circuit (mirror of maybeMarkContainerDead): if a
	// restart cycle is in flight for this workspace, do not run the
	// IsRunning probe — it would observe the EC2-pending state as "not
	// running" and trigger RestartByID for an already-restarting workspace,
	// closing the self-fire loop. Returning nil lets the optimistic
	// forward proceed; the upstream Do() call will fail with a connection
	// error or 502, and the *post-restart* reactive path can decide what
	// to do once the cycle has actually completed. Issue internal#544.
	if isRestarting(workspaceID) {
		log.Printf("ProxyA2A preflight: %s — skipped, restart already in flight (self-fire guard)", workspaceID)
		return nil
	}
	running, err := h.provisioner.IsRunning(ctx, workspaceID)
	if err != nil {
		// Transient daemon error. Provisioner.IsRunning returns (true, err)
		// in this case — fall through to the optimistic forward, reactive
		// maybeMarkContainerDead handles a real failure later.
		log.Printf("ProxyA2A preflight: IsRunning transient error for %s: %v (proceeding with forward)", workspaceID, err)
		return nil
	}
	if running {
		// Container is running — forward as today.
		return nil
	}
	// Container is genuinely not running. Mark offline + trigger restart
	// (same effect as maybeMarkContainerDead's branch), and return the
	// structured 503 immediately so the caller skips the forward.
	log.Printf("ProxyA2A preflight: container for %s is not running — marking offline and triggering restart (#36)", workspaceID)
	if _, dbErr := db.DB.ExecContext(ctx,
		`UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status NOT IN ('removed', 'provisioning')`,
		models.StatusOffline, workspaceID); dbErr != nil {
		log.Printf("ProxyA2A preflight: failed to mark workspace %s offline: %v", workspaceID, dbErr)
	}
	db.ClearWorkspaceKeys(ctx, workspaceID)
	h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
	// Tracked via goAsync (see maybeMarkContainerDead): preflight's
	// detached restart must be drainable so it doesn't race the global
	// db.DB swap in test cleanup.
	h.goAsync(func() { h.RestartByID(workspaceID) })
	return &proxyA2AError{
		Status: http.StatusServiceUnavailable,
		Response: gin.H{
			"error":      "workspace container not running — restart triggered",
			"restarting": true,
			"preflight":  true, // distinguishes from reactive containerDead path
		},
	}
}

// logA2AFailure records a failed A2A attempt to activity_logs in a detached
// goroutine (the request context may already be done by the time it runs).
func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, err error, durationMs int) {
	errMsg := err.Error()
	var errWsName string
	db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&errWsName)
	if errWsName == "" {
		errWsName = workspaceID
	}
	summary := "A2A request to " + errWsName + " failed: " + errMsg
	parent := ctx
	h.goAsync(func() {
		logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
		defer cancel()
		LogActivity(logCtx, h.broadcaster, ActivityParams{
			WorkspaceID:  workspaceID,
			ActivityType: "a2a_receive",
			SourceID:     nilIfEmpty(callerID),
			TargetID:     &workspaceID,
			Method:       &a2aMethod,
			Summary:      &summary,
			RequestBody:  json.RawMessage(body),
			DurationMs:   &durationMs,
			Status:       "error",
			ErrorDetail:  &errMsg,
		})
	})
}

// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
// 2xx/3xx responses) broadcasts an A2A_RESPONSE event so the frontend can
// receive the reply without polling.
func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, callerID string, body, respBody []byte, a2aMethod string, statusCode, durationMs int) {
	logStatus := "ok"
	if statusCode >= 400 {
		logStatus = "error"
	}
	var wsNameForLog string
	db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsNameForLog)
	if wsNameForLog == "" {
		wsNameForLog = workspaceID
	}

	// #817: track outbound activity on the CALLER so orchestrators can detect
	// silent workspaces. Only update when callerID is a real workspace (not
	// canvas, not a system caller) and the target returned 2xx/3xx.
	if callerID != "" && !isSystemCaller(callerID) && statusCode < 400 {
		h.goAsync(func() {
			bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			defer cancel()
			if _, err := db.DB.ExecContext(bgCtx,
				`UPDATE workspaces SET last_outbound_at = NOW() WHERE id = $1`, callerID); err != nil {
				log.Printf("last_outbound_at update failed for %s: %v", callerID, err)
			}
		})
	}
	summary := a2aMethod + " → " + wsNameForLog
	toolTrace := extractToolTrace(respBody)
	parent := ctx
	h.goAsync(func() {
		logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
		defer cancel()
		LogActivity(logCtx, h.broadcaster, ActivityParams{
			WorkspaceID:  workspaceID,
			ActivityType: "a2a_receive",
			SourceID:     nilIfEmpty(callerID),
			TargetID:     &workspaceID,
			Method:       &a2aMethod,
			Summary:      &summary,
			RequestBody:  json.RawMessage(body),
			ResponseBody: json.RawMessage(respBody),
			ToolTrace:    toolTrace,
			DurationMs:   &durationMs,
			Status:       logStatus,
		})
	})

	if callerID == "" && statusCode < 400 {
		h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
			"response_body": json.RawMessage(respBody),
			"method":        a2aMethod,
			"duration_ms":   durationMs,
		})
	}
}

func nilIfEmpty(s string) *string {
	if s == "" {
		return nil
	}
	return &s
}

// validateCallerToken enforces the Phase 30.5 auth-token contract on the
// caller of an A2A proxy request. Same lazy-bootstrap shape as
// registry.requireWorkspaceToken: if the caller workspace has any live
// token on file, the Authorization header is mandatory and must match;
// if the caller has zero live tokens, they're grandfathered through
// (their next /registry/register will mint their first token, after
// which this branch never fires again for them).
//
// On auth failure this writes the 401 via c and returns an error so the
// handler aborts without running the proxy.
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) error {
	hasLive, err := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
	if err != nil {
		// Fail-open here matches the heartbeat path — A2A caller auth is
		// defense-in-depth on top of access-control hierarchy, not the
		// sole gate on the secret material. A DB hiccup shouldn't take
		// the whole A2A path down.
		log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, err)
		return nil
	}
	if !hasLive {
		return nil // legacy / pre-upgrade caller
	}
	tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
	if tok == "" {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "missing caller auth token"})
		return errInvalidCallerToken
	}
	if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid caller auth token"})
		return err
	}
	return nil
}

// errInvalidCallerToken is a sentinel for validateCallerToken's "missing
// token" branch so the handler-level guard can detect it without string
// matching (the wsauth errors are typed for the invalid case).
var errInvalidCallerToken = errors.New("missing caller auth token")

// extractToolTrace pulls metadata.tool_trace from an A2A JSON-RPC response.
// Returns nil when absent or malformed — callers can pass it straight through.
func extractToolTrace(respBody []byte) json.RawMessage {
	if len(respBody) == 0 {
		return nil
	}
	var top map[string]json.RawMessage
	if err := json.Unmarshal(respBody, &top); err != nil {
		return nil
	}
	rawResult, ok := top["result"]
	if !ok {
		return nil
	}
	var result map[string]json.RawMessage
	if err := json.Unmarshal(rawResult, &result); err != nil {
		return nil
	}
	rawMeta, ok := result["metadata"]
	if !ok {
		return nil
	}
	var meta map[string]json.RawMessage
	if err := json.Unmarshal(rawMeta, &meta); err != nil {
		return nil
	}
	trace, ok := meta["tool_trace"]
	if !ok || len(trace) == 0 || string(trace) == "null" || string(trace) == "[]" {
		return nil
	}
	return trace
}

// extractAndUpsertTokenUsage parses LLM usage from a raw A2A response body
// and persists it via upsertTokenUsage. Safe to call in a goroutine — logs
// errors but never panics. ctx must already be detached from the request.
func extractAndUpsertTokenUsage(ctx context.Context, workspaceID string, respBody []byte) {
	in, out := parseUsageFromA2AResponse(respBody)
	if in > 0 || out > 0 {
		upsertTokenUsage(ctx, workspaceID, in, out)
	}
}

// parseUsageFromA2AResponse extracts input_tokens / output_tokens from an A2A
// JSON-RPC response. Inspects two locations in order of preference:
//  1. result.usage — the JSON-RPC 2.0 result envelope from workspace agents.
//  2. usage — top-level, for non-JSON-RPC or direct Anthropic-shaped payloads.
//
// Returns (0, 0) when no recognisable usage data is found.
func parseUsageFromA2AResponse(body []byte) (inputTokens, outputTokens int64) {
	if len(body) == 0 {
		return 0, 0
	}
	var top map[string]json.RawMessage
	if err := json.Unmarshal(body, &top); err != nil {
		return 0, 0
	}

	// 1. result.usage (JSON-RPC 2.0 wrapper produced by workspace agents).
	if rawResult, ok := top["result"]; ok {
		var result map[string]json.RawMessage
		if err := json.Unmarshal(rawResult, &result); err == nil {
			if in, out, ok := readUsageMap(result); ok {
				return in, out
			}
		}
	}

	// 2. Fallback: top-level usage (direct Anthropic or non-JSON-RPC response).
	if in, out, ok := readUsageMap(top); ok {
		return in, out
	}
	return 0, 0
}

// lookupDeliveryMode returns the workspace's delivery_mode.
//
// internal#497 / RFC#497 fail-closed (SURGICAL scope): the *specific*
// failure mode that hid the ce2db75f regression for 5 days is now
// propagated instead of silently swallowed — a CONTEXT error
// (context.Canceled / context.DeadlineExceeded). Under ce2db75f the
// detached delegation goroutine ran on a cancelled request context, every
// `SELECT delivery_mode` failed `context canceled`, this function returned
// push, the poll-mode short-circuit in proxyA2ARequest was skipped, and
// poll-mode peers (e.g. an operator laptop on molecule-mcp-claude-channel)
// silently never got their a2a_receive inbox row. A transient,
// systematic-once-triggered context cancellation became permanent
// invisible misrouting. Returning that error lets the caller fail loud
// (mark the delegation failed) instead of mis-dispatching.
//
// Scope is deliberately narrow: only ctx errors propagate. Other DB
// errors retain the long-standing documented "fall back to push (today's
// synchronous behavior)" contract — that path is loud + recoverable
// (502 / SSRF reject / restart), unlike the silent poll-mode drop, and
// the surrounding proxy (incl. the sibling checkWorkspaceBudget) is
// intentionally built around that fail-open-to-push behavior. Widening
// further is an RFC#497 follow-up, not part of this P0 fix.
//
// A genuinely *absent* configuration is NOT an error and still resolves to
// push (the safe synchronous default): sql.ErrNoRows, a NULL/empty column,
// or an unrecognised value all return (push, nil).
//
// The function is intentionally lookup-only — it never mutates the row.
// The register handler (registry.go) is the only writer for delivery_mode.
//
// See #2339 PR 1 for the column + register-flow side; this is the
// proxy-side read used for the short-circuit in proxyA2ARequest.
func lookupDeliveryMode(ctx context.Context, workspaceID string) (string, error) {
	var mode sql.NullString
	err := db.DB.QueryRowContext(ctx,
		`SELECT delivery_mode FROM workspaces WHERE id = $1`, workspaceID,
	).Scan(&mode)
	if err != nil {
		// internal#497: a context cancellation/deadline MUST NOT be
		// swallowed into a silent push default — that is the exact 5-day
		// silent-misrouting vector. Propagate so the caller fails closed.
		if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
			log.Printf("ProxyA2A: lookupDeliveryMode(%s) context error (%v) — failing closed (NOT defaulting to push)", workspaceID, err)
			return "", err
		}
		if !errors.Is(err, sql.ErrNoRows) {
			log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push (non-ctx DB error; legacy fail-open-to-push contract)", workspaceID, err)
		}
		return models.DeliveryModePush, nil
	}
	if !mode.Valid || mode.String == "" {
		return models.DeliveryModePush, nil
	}
	if !models.IsValidDeliveryMode(mode.String) {
		log.Printf("ProxyA2A: workspace %s has invalid delivery_mode=%q — defaulting to push", workspaceID, mode.String)
		return models.DeliveryModePush, nil
	}
	return mode.String, nil
}

// logA2AReceiveQueued records a poll-mode "queued" A2A receive into
// activity_logs. Same shape as logA2ASuccess but without ResponseBody
// (there is no response yet — the polling agent will produce one when
// it picks the request up). status="ok" because the request was
// successfully queued; the consume side reports its own outcome.
//
// The activity_logs row is what the polling agent's GET /activity?since_id=
// reads in PR 3 — that's how a poll-mode workspace receives inbound A2A
// without a public URL.
func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string) {
	// DATA-LOSS FIX (internal#471 — poll-mode sibling of #1347/internal#470):
	// this is the ONLY durable write of a poll-mode inbound message,
	// including a canvas_user message (callerID == "") typed in the canvas
	// chat. It MUST be SYNCHRONOUS and complete BEFORE the caller returns
	// the synthetic {status:"queued"} 200 — otherwise the canvas sees the
	// send acknowledged while the activity_logs row is still racing in a
	// detached goroutine, and a workspace-server restart / deploy / OOM /
	// EC2 hibernation between the 200 and the goroutine's commit loses the
	// user's message permanently (chat-history reads activity_logs, so a
	// missing row = message gone on reopen). Hongming's tenant is entirely
	// poll-mode (4 external workspaces, no URL — verified empirically), so
	// his reported loss is THIS path; #1347 (push-mode, persists AFTER the
	// poll short-circuit) structurally cannot cover it.
	//
	// Mirrors persistUserMessageAtIngest's discipline:
	//   - context.WithoutCancel: a client disconnect on chat-exit (which
	//     cancels the inbound request ctx) MUST NOT abort this write.
	//   - SYNCHRONOUS (no goAsync): the row must be durable before the
	//     queued 200 is returned to the caller.
	//   - Best-effort: LogActivity already logs+swallows INSERT errors, so
	//     a hiccup never blocks or fails the user's send (behavior for
	//     that one request is never worse than the pre-fix async path).
	// The post-commit broadcast still fires inside LogActivity; a missed
	// WebSocket event is not data loss (the durable row is the truth the
	// canvas re-reads on reopen).
	insCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
	defer cancel()

	var wsName string
	db.DB.QueryRowContext(insCtx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
	if wsName == "" {
		wsName = workspaceID
	}
	summary := a2aMethod + " → " + wsName + " (queued for poll)"
	LogActivity(insCtx, h.broadcaster, ActivityParams{
		WorkspaceID:  workspaceID,
		ActivityType: "a2a_receive",
		SourceID:     nilIfEmpty(callerID),
		TargetID:     &workspaceID,
		Method:       &a2aMethod,
		Summary:      &summary,
		RequestBody:  json.RawMessage(body),
		Status:       "ok",
	})
}

// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
// Returns (0, 0, false) when the key is absent or contains no non-zero values.
func readUsageMap(m map[string]json.RawMessage) (inputTokens, outputTokens int64, ok bool) {
	rawUsage, has := m["usage"]
	if !has {
		return 0, 0, false
	}
	var usage struct {
		InputTokens  int64 `json:"input_tokens"`
		OutputTokens int64 `json:"output_tokens"`
	}
	if err := json.Unmarshal(rawUsage, &usage); err != nil {
		return 0, 0, false
	}
	if usage.InputTokens == 0 && usage.OutputTokens == 0 {
		return 0, 0, false
	}
	return usage.InputTokens, usage.OutputTokens, true
}
