package exec

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/resultbudget"
	"github.com/tidbcloud/AgentTeam/desktop/internal/wire/apiclient"
)

// apiWorkflowError builds a *apiclient.APIError carrying the allowlisted
// canonical Workflow contract fields the server emits.
func apiWorkflowError(status int, code string) *apiclient.APIError {
	index := 2
	return &apiclient.APIError{
		StatusCode: status,
		Method:     "POST",
		Path:       "/workflow/step",
		Message:    "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$",
		Code:       code,
		StageName:  "run",
		ErrorPath:  "callback.workItems[2].key",
		Hint:       "Fix the WorkItem key.",
		Retryable:  false,
		Diagnostics: []apiclient.Diagnostic{{
			Severity: "error",
			Code:     "callback.work_items.key_invalid",
			Path:     "callback.workItems[2].key",
			Field:    "key",
			Index:    &index,
			Message:  "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$",
		}},
	}
}

// TestClassifyAPIErrorPreservesAllowlistedWorkflowContract verifies that the
// allowlisted canonical Workflow contract fields survive classifyAPIError and
// the envelope projection.
func TestClassifyAPIErrorPreservesAllowlistedWorkflowContract(t *testing.T) {
	classified := classifyAPIError(apiWorkflowError(http.StatusBadRequest, "step_work_items_invalid"), "workflow step complete failed")
	if classified == nil {
		t.Fatal("classifyAPIError returned nil")
	}
	if classified.Code != CodeValidation {
		t.Fatalf("Code = %q, want validation", classified.Code)
	}
	if classified.ServerCode != "step_work_items_invalid" {
		t.Fatalf("ServerCode = %q, want step_work_items_invalid", classified.ServerCode)
	}
	if classified.Stage != "run" {
		t.Fatalf("Stage = %q, want run", classified.Stage)
	}
	if classified.Path != "callback.workItems[2].key" {
		t.Fatalf("Path = %q", classified.Path)
	}
	if classified.Hint != "Fix the WorkItem key." {
		t.Fatalf("Hint = %q", classified.Hint)
	}
	if len(classified.Diagnostics) != 1 {
		t.Fatalf("Diagnostics len = %d, want 1", len(classified.Diagnostics))
	}
	diagnostic := classified.Diagnostics[0]
	if diagnostic.Field != "key" || diagnostic.Code != "callback.work_items.key_invalid" {
		t.Fatalf("Diagnostic = %+v", diagnostic)
	}
	if diagnostic.Index == nil || *diagnostic.Index != 2 {
		t.Fatalf("Diagnostic.Index = %v, want 2", diagnostic.Index)
	}

	envelope := classified.envelope()
	if envelope.ServerCode != "step_work_items_invalid" ||
		envelope.Stage != "run" ||
		envelope.Path != "callback.workItems[2].key" ||
		len(envelope.Diagnostics) != 1 {
		t.Fatalf("envelope lost allowlisted fields: %+v", envelope)
	}
}

// TestWorkflowOperationErrorTrustedCodeProjectsForEveryAction proves every
// production Workflow action (plan/trigger/run/step) routes a trusted server
// code through the explicit allowlist and projects the canonical contract
// fields. For a TRUSTED canonical code the bounded server-approved public
// message becomes the Exec Problem (#t2608), superseding the generic action text.
func TestWorkflowOperationErrorTrustedCodeProjectsForEveryAction(t *testing.T) {
	actions := []struct {
		action  string
		code    string
		problem string
		message string
	}{
		{"plan", "plan_request_failed", "workflow plan create failed", "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$"},
		{"trigger", "binding_request_failed", "workflow trigger create failed", "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$"},
		{"run", "invocation_request_failed", "workflow run start failed", "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$"},
		{"step", "step_work_items_invalid", "workflow step complete failed", "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$"},
	}
	for _, action := range actions {
		classifiedErr := classifyWorkflowOperationError(
			apiWorkflowError(http.StatusBadRequest, action.code),
			action.problem,
		)
		var classified *Error
		if !errors.As(classifiedErr, &classified) {
			t.Fatalf("%s: classified is %T, want *Error", action.action, classifiedErr)
		}
		if classified.ServerCode != action.code {
			t.Fatalf("%s: ServerCode = %q, want %q", action.action, classified.ServerCode, action.code)
		}
		if classified.Problem != action.message {
			t.Fatalf("%s: Problem = %q, want the bounded server-approved public message %q", action.action, classified.Problem, action.message)
		}
		if classified.Code != CodeValidation {
			t.Fatalf("%s: Code = %q, want validation", action.action, classified.Code)
		}
		envelope := classified.envelope()
		if envelope.ServerCode != action.code {
			t.Fatalf("%s: envelope.ServerCode = %q", action.action, envelope.ServerCode)
		}
	}
}

// TestClassifyAPIErrorRetainsGenericProblemForUnknownCode proves that for an
// unknown (non-trusted) server code the generic action text is retained as the
// Exec Problem and no server message is projected (#t2608).
func TestClassifyAPIErrorRetainsGenericProblemForUnknownCode(t *testing.T) {
	classified := classifyAPIError(
		apiWorkflowError(http.StatusInternalServerError, "totally_unknown_code"),
		"workflow run start failed",
	)
	if classified.Problem != "workflow run start failed" {
		t.Fatalf("Problem = %q, want the generic action text for an unknown code", classified.Problem)
	}
	if classified.ServerCode != "" || classified.Stage != "" || len(classified.Diagnostics) != 0 {
		t.Fatalf("unknown code projected contract fields: %+v", classified)
	}
}

// TestClassifyAPIErrorFallsBackToGenericProblemWithoutServerMessage proves a
// trusted code with an empty server message keeps the generic action text.
func TestClassifyAPIErrorFallsBackToGenericProblemWithoutServerMessage(t *testing.T) {
	apiErr := apiWorkflowError(http.StatusBadRequest, "plan_request_failed")
	apiErr.Message = ""
	classified := classifyAPIError(apiErr, "workflow plan create failed")
	if classified.Problem != "workflow plan create failed" {
		t.Fatalf("Problem = %q, want the generic action text fallback", classified.Problem)
	}
	if classified.ServerCode != "plan_request_failed" {
		t.Fatalf("ServerCode = %q", classified.ServerCode)
	}
}

// TestWorkflowOperationErrorUnknownCodeFailsClosedForEveryAction proves every
// production Workflow action fails closed on an unknown or drifted server code:
// no canonical contract fields are projected.
func TestWorkflowOperationErrorUnknownCodeFailsClosedForEveryAction(t *testing.T) {
	actions := []struct {
		action  string
		problem string
	}{
		{"plan", "workflow plan create failed"},
		{"trigger", "workflow trigger create failed"},
		{"run", "workflow run start failed"},
		{"step", "workflow step complete failed"},
	}
	for _, action := range actions {
		for _, code := range []string{"", "plan_future_unreviewed", "totally_unknown_code"} {
			classifiedErr := classifyWorkflowOperationError(
				apiWorkflowError(http.StatusInternalServerError, code),
				action.problem,
			)
			var classified *Error
			if !errors.As(classifiedErr, &classified) {
				t.Fatalf("%s/%q: classified is %T, want *Error", action.action, code, classifiedErr)
			}
			if classified.ServerCode != "" || classified.Stage != "" || len(classified.Diagnostics) != 0 {
				t.Fatalf("%s/%q: unknown code projected contract fields: %+v", action.action, code, classified)
			}
		}
	}
}

// TestClassifyAPIErrorUnknownServerCodeFailsClosed verifies that an unknown or
// non-allowlisted server code never projects structured contract fields.
func TestClassifyAPIErrorUnknownServerCodeFailsClosed(t *testing.T) {
	for _, code := range []string{"", "totally_unknown_code", "target_scope_not_found"} {
		apiErr := apiWorkflowError(http.StatusBadRequest, code)
		classified := classifyAPIError(apiErr, "workflow step complete failed")
		if classified.ServerCode != "" {
			t.Fatalf("code %q: ServerCode = %q, want empty", code, classified.ServerCode)
		}
		if classified.Stage != "" || classified.Path != "" || len(classified.Diagnostics) != 0 {
			t.Fatalf("code %q: unknown code projected contract fields: %+v", code, classified)
		}
	}
}

// TestClassifyAPIErrorUnknownPrefixedCodeFailsClosed proves the explicit set
// fails closed even for unknown codes that share a trusted workflow prefix
// (registry drift): the former prefix allowlist is gone.
func TestClassifyAPIErrorUnknownPrefixedCodeFailsClosed(t *testing.T) {
	for _, code := range []string{
		"plan_future_unreviewed", "run_secret_path", "step_stack_trace",
		"team_workflow_unknown_future", "workflow_agent_future_code",
	} {
		classified := classifyAPIError(apiWorkflowError(http.StatusBadRequest, code), "workflow step complete failed")
		if classified.ServerCode != "" || classified.Stage != "" || len(classified.Diagnostics) != 0 {
			t.Fatalf("code %q: unknown prefixed code projected contract fields: %+v", code, classified)
		}
	}
}

// TestClassifyAPIErrorTeamWorkflowCodesProject verifies the team_workflow_*
// exposed codes (which the workflow.plan action endpoints reach) now project the
// canonical contract fields.
func TestClassifyAPIErrorTeamWorkflowCodesProject(t *testing.T) {
	for _, code := range []string{
		"team_workflow_request_failed", "team_workflow_invalid_if_match",
		"team_workflow_invalid_limit", "team_workflow_invalid_request",
		"team_workflow_invalid_state",
		"team_workflow_unavailable",
	} {
		classified := classifyAPIError(apiWorkflowError(http.StatusBadRequest, code), "workflow plan failed")
		if classified.ServerCode != code {
			t.Fatalf("team_workflow code %q: ServerCode = %q, want %q", code, classified.ServerCode, code)
		}
		if classified.Stage != "run" {
			t.Fatalf("code %q: Stage = %q, want run", code, classified.Stage)
		}
		if len(classified.Diagnostics) != 1 {
			t.Fatalf("code %q: Diagnostics len = %d, want 1", code, len(classified.Diagnostics))
		}
		envelope := classified.envelope()
		if envelope.ServerCode != code {
			t.Fatalf("code %q: envelope.ServerCode = %q", code, envelope.ServerCode)
		}
	}
}

// TestClassifyAPIErrorWorkflowAgentCodesProject verifies the workflow_agent_*
// exposed codes now project the canonical contract fields.
func TestClassifyAPIErrorWorkflowAgentCodesProject(t *testing.T) {
	for _, code := range []string{
		"workflow_agent_edit_revision_mismatch", "workflow_agent_edit_unavailable",
		"workflow_agent_wake_active",
	} {
		classified := classifyAPIError(apiWorkflowError(http.StatusConflict, code), "workflow plan edit failed")
		if classified.ServerCode != code {
			t.Fatalf("workflow_agent code %q: ServerCode = %q, want %q", code, classified.ServerCode, code)
		}
		if classified.Code != CodeConflict {
			t.Fatalf("code %q: Code = %q, want conflict (428/409)", code, classified.Code)
		}
		if len(classified.Diagnostics) != 1 {
			t.Fatalf("code %q: Diagnostics len = %d, want 1", code, len(classified.Diagnostics))
		}
	}
}

// TestClassifyAPIErrorRequestTooLargeAndPreconditionCategories verifies the
// corrected HTTP categories (413 validation, 428 conflict, 429 rate-limited)
// flow through classifyAPIError for trusted codes. 429 is asserted through
// apiErrorCode because the server's 429 responses carry rate-limit semantics
// without a trusted workflow code.
func TestClassifyAPIErrorRequestTooLargeAndPreconditionCategories(t *testing.T) {
	cases := []struct {
		name   string
		status int
		code   string
		want   ErrorCode
	}{
		{"413 plan_request_too_large", http.StatusRequestEntityTooLarge, "plan_request_too_large", CodeValidation},
		{"413 binding_request_too_large", http.StatusRequestEntityTooLarge, "binding_request_too_large", CodeValidation},
		{"413 run_request_too_large", http.StatusRequestEntityTooLarge, "run_request_too_large", CodeValidation},
		{"428 precondition_required", http.StatusPreconditionRequired, "precondition_required", CodeConflict},
		{"428 binding_invalid_if_match", http.StatusPreconditionRequired, "binding_invalid_if_match", CodeConflict},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			classified := classifyAPIError(apiWorkflowError(tc.status, tc.code), "workflow operation failed")
			if classified.Code != tc.want {
				t.Fatalf("code %q status %d: Code = %q, want %q", tc.code, tc.status, classified.Code, tc.want)
			}
			if classified.ServerCode != tc.code {
				t.Fatalf("code %q: ServerCode = %q", tc.code, classified.ServerCode)
			}
		})
	}
	if got := apiErrorCode(http.StatusTooManyRequests); got != CodeRateLimited {
		t.Fatalf("apiErrorCode(429) = %q, want %q", got, CodeRateLimited)
	}
}

// TestApplyErrorBudgetPreservesTypedMetadataAndNeverLeaksCause verifies that the
// typed contract metadata survives ApplyErrorBudget's deterministic bounded
// projection with explicit truncation reports, and raw causes are never exposed.
func TestApplyErrorBudgetPreservesTypedMetadataAndNeverLeaksCause(t *testing.T) {
	classified := classifyAPIError(apiWorkflowError(http.StatusBadRequest, "step_work_items_invalid"), "workflow step complete failed")
	classified.Cause = errors.New("raw repository: SELECT secret FROM runs WHERE owner=usr_admin")

	envelope := ApplyErrorBudget(classified)
	if envelope.ServerCode != "step_work_items_invalid" {
		t.Fatalf("ServerCode = %q", envelope.ServerCode)
	}
	if envelope.Stage != "run" || envelope.Path != "callback.workItems[2].key" || envelope.Hint != "Fix the WorkItem key." {
		t.Fatalf("envelope lost contract fields: %+v", envelope)
	}
	if len(envelope.Diagnostics) != 1 || envelope.Diagnostics[0].Field != "key" {
		t.Fatalf("envelope lost diagnostics: %+v", envelope.Diagnostics)
	}
	encoded, err := json.Marshal(envelope)
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(encoded), "SELECT") || strings.Contains(string(encoded), "usr_admin") {
		t.Fatalf("raw cause leaked: %s", encoded)
	}
}

// TestApplyErrorBudgetBoundsDiagnosticsWithTruncationReport verifies oversized
// diagnostics are deterministically bounded with an explicit truncation report.
func TestApplyErrorBudgetBoundsDiagnosticsWithTruncationReport(t *testing.T) {
	apiErr := apiWorkflowError(http.StatusBadRequest, "step_work_items_invalid")
	diagnostics := make([]apiclient.Diagnostic, 0, maxDiagnostics+5)
	for i := 0; i < maxDiagnostics+5; i++ {
		diagnostics = append(diagnostics, apiclient.Diagnostic{
			Severity: "error",
			Code:     "callback.work_items.key_invalid",
			Path:     "callback.workItems[i].key",
			Field:    "key",
			Message:  strings.Repeat("m", maxProblemRunes*2),
		})
	}
	apiErr.Diagnostics = diagnostics
	classified := classifyAPIError(apiErr, "workflow step complete failed")
	envelope := ApplyErrorBudget(classified)
	if len(envelope.Diagnostics) > maxDiagnostics {
		t.Fatalf("Diagnostics len = %d, want <= %d", len(envelope.Diagnostics), maxDiagnostics)
	}
	report, ok := envelope.Metadata[errorBudgetMetadataKey].(resultbudget.Report)
	if !ok {
		t.Fatalf("missing error truncation metadata: %+v", envelope.Metadata)
	}
	if !report.Truncated {
		t.Fatalf("report not marked truncated: %+v", report)
	}
	// Every projected diagnostic field is bounded.
	for _, diagnostic := range envelope.Diagnostics {
		if len([]rune(diagnostic.Message)) > maxProblemRunes {
			t.Fatalf("diagnostic message exceeded cap: %d", len([]rune(diagnostic.Message)))
		}
	}
}

// TestClassifyWorkflowOperationErrorPreservesInvocationDiagnosticShape verifies
// the invocation input diagnostic special-case still fires and now prefers the
// structured allowlisted diagnostics.
func TestClassifyWorkflowOperationErrorPreservesInvocationDiagnosticShape(t *testing.T) {
	index := 0
	apiErr := &apiclient.APIError{
		StatusCode: http.StatusUnprocessableEntity,
		Method:     "POST",
		Path:       "/workflow/run",
		Message:    "unknown input field",
		Code:       "invocation_input_invalid",
		Diagnostics: []apiclient.Diagnostic{{
			Severity: "error",
			Code:     "input.unknown_field",
			Path:     "$.input.unknown",
			Field:    "unknown",
			Index:    &index,
			Message:  "unknown input field",
		}},
	}
	classifiedErr := classifyWorkflowOperationError(apiErr, "workflow run start failed")
	var classified *Error
	if !errors.As(classifiedErr, &classified) {
		t.Fatalf("classified is %T, want *Error", classifiedErr)
	}
	if classified.Code != CodeValidation {
		t.Fatalf("Code = %q, want validation", classified.Code)
	}
	if !strings.Contains(classified.Problem, "$.input.unknown") {
		t.Fatalf("Problem missing input path: %q", classified.Problem)
	}
	if classified.Hint == "" {
		t.Fatal("invocation input hint missing")
	}
	if len(classified.Diagnostics) != 1 || classified.Diagnostics[0].Field != "unknown" {
		t.Fatalf("diagnostics not preserved: %+v", classified.Diagnostics)
	}
}

// TestLocalClientCallPreservesEnvelopeContractFields verifies the Exec local
// client reconstructs a typed Error carrying the allowlisted contract fields.
func TestLocalClientCallPreservesEnvelopeContractFields(t *testing.T) {
	index := 2
	envelope := ErrorEnvelope{
		Code:       CodeValidation,
		Problem:    "workflow step complete failed",
		Stage:      "run",
		Path:       "callback.workItems[2].key",
		Retryable:  false,
		ServerCode: "step_work_items_invalid",
		Diagnostics: []Diagnostic{{
			Severity: "error",
			Code:     "callback.work_items.key_invalid",
			Path:     "callback.workItems[2].key",
			Field:    "key",
			Index:    &index,
			Message:  "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$",
		}},
	}
	payload, err := json.Marshal(map[string]any{"error": envelope})
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		_, _ = w.Write(payload)
	}))
	defer srv.Close()
	client := LocalClient{HTTPClient: srv.Client()}
	_, err = client.Call(
		t.Context(),
		srv.URL+"/api/internal/exec/servers/server-1/agents/agent-1",
		"token",
		"workflow",
		"step",
		json.RawMessage(`{}`),
	)
	if err == nil {
		t.Fatal("expected an error")
	}
	var typed *Error
	if !errors.As(err, &typed) {
		t.Fatalf("error is %T, want *Error", err)
	}
	if typed.ServerCode != "step_work_items_invalid" || typed.Stage != "run" || typed.Path != "callback.workItems[2].key" {
		t.Fatalf("local client lost contract fields: %+v", typed)
	}
	if len(typed.Diagnostics) != 1 || typed.Diagnostics[0].Field != "key" {
		t.Fatalf("local client lost diagnostics: %+v", typed.Diagnostics)
	}
}

func TestLocalClientCallPreservesMutationReceiptMetadata(t *testing.T) {
	receipt := &mutationSuccessReceipt{
		Applied: true, Action: "channel update",
		Identifiers: map[string]string{"channelId": "chn_1"}, Revision: "3",
		IdempotencyKey: "req_1", RetrievalPath: `agentteam exec channel get {"channelId":"chn_1"}`,
	}
	payload, err := json.Marshal(map[string]any{"error": ErrorEnvelope{
		Code: CodeResultBudgetExceeded, Problem: "result too large", Hint: "retry is unsafe",
		Next: "agentteam exec channel update", Retryable: true,
		Metadata: map[string]any{mutationAppliedMetadataKey: true, mutationReceiptMetadataKey: receipt},
	}})
	require.NoError(t, err)
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusRequestEntityTooLarge)
		_, _ = w.Write(payload)
	}))
	t.Cleanup(server.Close)

	client := LocalClient{HTTPClient: server.Client()}
	_, err = client.Call(t.Context(), server.URL+"/api/internal/exec/servers/server-1/agents/agent-1", "token", "channel", "update", json.RawMessage(`{"channelId":"chn_1","requestId":"req_1"}`))
	require.Error(t, err)
	var typed *Error
	require.ErrorAs(t, err, &typed)
	require.True(t, typed.mutationApplied)
	require.Equal(t, receipt.Action, typed.mutationReceipt.Action)
	require.Equal(t, receipt.RetrievalPath, typed.mutationReceipt.RetrievalPath)

	envelope := ApplyErrorBudget(err)
	require.Empty(t, envelope.Next)
	require.False(t, envelope.Retryable)
	encoded, marshalErr := json.Marshal(envelope)
	require.NoError(t, marshalErr)
	require.Contains(t, string(encoded), `"mutation_applied":true`)
	require.Contains(t, string(encoded), `channel get`)
	require.NotContains(t, strings.ToLower(string(encoded)), "retry")
	require.NotContains(t, strings.ToLower(string(encoded)), "replay")
}

func TestLocalClientCallDropsMalformedMutationReceiptMetadata(t *testing.T) {
	tests := []struct {
		name     string
		metadata map[string]any
	}{
		{
			name: "unknown metadata key",
			metadata: map[string]any{
				mutationAppliedMetadataKey: true,
				mutationReceiptMetadataKey: map[string]any{
					"applied": true, "action": "channel update", "identifiers": map[string]string{"channelId": "chn_1"}, "secret": "do-not-copy",
				},
			},
		},
		{
			name: "oversized receipt value",
			metadata: map[string]any{
				mutationAppliedMetadataKey: true,
				mutationReceiptMetadataKey: map[string]any{
					"applied": true, "action": "channel update", "revision": strings.Repeat("x", maxMutationReceiptMetadataBytes),
				},
			},
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			payload, err := json.Marshal(map[string]any{"error": ErrorEnvelope{
				Code: CodeResultBudgetExceeded, Problem: "result too large", Metadata: tc.metadata,
			}})
			require.NoError(t, err)
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				w.WriteHeader(http.StatusRequestEntityTooLarge)
				_, _ = w.Write(payload)
			}))
			t.Cleanup(server.Close)
			client := LocalClient{HTTPClient: server.Client()}
			_, err = client.Call(t.Context(), server.URL+"/api/internal/exec/servers/server-1/agents/agent-1", "token", "channel", "update", json.RawMessage(`{"channelId":"chn_1","requestId":"req_1"}`))
			require.Error(t, err)
			var typed *Error
			require.ErrorAs(t, err, &typed)
			require.False(t, typed.mutationApplied)
			require.Nil(t, typed.mutationReceipt)
			encoded, marshalErr := json.Marshal(ApplyErrorBudget(err))
			require.NoError(t, marshalErr)
			require.NotContains(t, string(encoded), "do-not-copy")
			require.NotContains(t, string(encoded), "mutation_receipt")
		})
	}
}

func TestLocalClientDropsSelectorMismatchedMutationReceipts(t *testing.T) {
	tests := []struct {
		name   string
		domain string
		action string
		input  string
		ids    map[string]string
	}{
		{
			name: "scalar channel selector", domain: "channel", action: "update",
			input: `{"requestId":"req-1","channelId":"chn-1","expectedVersion":1}`,
			ids:   map[string]string{"channelId": "chn-other", "requestId": "req-1"},
		},
		{
			name: "composite workflow step run selector", domain: "workflow", action: "step",
			input: `{"action":"complete","runId":"wri-1","stepId":"wfx-1","idempotencyKey":"step-1","output":{}}`,
			ids:   map[string]string{"runId": "wri-other", "stepId": "wfx-1", "idempotencyKey": "step-1"},
		},
		{
			name: "batch order selector", domain: "agent", action: "delete",
			input: `{"requestId":"batch-1","agentIds":["agt-1","agt-2","agt-3"]}`,
			ids:   map[string]string{"agentIds": `["agt-2","agt-1","agt-3"]`, "requestId": "batch-1"},
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			payload, err := json.Marshal(map[string]any{"error": ErrorEnvelope{
				Code: CodeResultBudgetExceeded,
				Metadata: map[string]any{mutationAppliedMetadataKey: true, mutationReceiptMetadataKey: map[string]any{
					"applied": true, "action": tc.domain + " " + tc.action, "identifiers": tc.ids,
					"idempotencyKey": firstReceiptString(decodeReceiptObject(json.RawMessage(tc.input)), "idempotencyKey", "requestId"),
				}},
			}})
			require.NoError(t, err)
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				w.WriteHeader(http.StatusRequestEntityTooLarge)
				_, _ = w.Write(payload)
			}))
			t.Cleanup(server.Close)
			client := LocalClient{HTTPClient: server.Client()}
			_, err = client.Call(t.Context(), server.URL+"/api/internal/exec/servers/server-1/agents/agent-1", "token", tc.domain, tc.action, json.RawMessage(tc.input))
			require.Error(t, err)
			var typed *Error
			require.ErrorAs(t, err, &typed)
			require.False(t, typed.mutationApplied, "selector mismatch must fail closed before mutation_applied projection")
			require.Nil(t, typed.mutationReceipt)
		})
	}
}

func TestLocalClientRebuildsAndValidatesMutationRetrievalPath(t *testing.T) {
	tests := []struct {
		name          string
		retrievalPath string
		wantApplied   bool
		wantCanonical string
	}{
		{
			name:          "canonical path",
			retrievalPath: `agentteam exec channel get {"channelId":"chn_1"}`,
			wantApplied:   true, wantCanonical: `agentteam exec channel get {"channelId":"chn_1"}`,
		},
		{
			name:          "mutation target rejected",
			retrievalPath: `agentteam exec channel delete {"channelId":"chn_1"}`,
		},
		{
			name:          "unknown target rejected",
			retrievalPath: `agentteam exec secret dump {"id":"secret"}`,
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			payload, err := json.Marshal(map[string]any{"error": ErrorEnvelope{
				Code: CodeResultBudgetExceeded,
				Metadata: map[string]any{
					mutationAppliedMetadataKey: true,
					mutationReceiptMetadataKey: map[string]any{
						"applied": true, "action": "channel update",
						"identifiers":    map[string]string{"channelId": "chn_1"},
						"idempotencyKey": "req_1",
						"retrievalPath":  tc.retrievalPath,
					},
				},
			}})
			require.NoError(t, err)
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				w.WriteHeader(http.StatusRequestEntityTooLarge)
				_, _ = w.Write(payload)
			}))
			t.Cleanup(server.Close)
			client := LocalClient{HTTPClient: server.Client()}
			_, err = client.Call(t.Context(), server.URL+"/api/internal/exec/servers/server-1/agents/agent-1", "token", "channel", "update", json.RawMessage(`{"requestId":"req_1","channelId":"chn_1","expectedVersion":1}`))
			require.Error(t, err)
			var typed *Error
			require.ErrorAs(t, err, &typed)
			require.Equal(t, tc.wantApplied, typed.mutationApplied)
			if tc.wantApplied {
				require.Equal(t, tc.wantCanonical, typed.mutationReceipt.RetrievalPath)
			}
		})
	}
}

func TestLocalClientNeverProjectsReceiptForReadOnlyWorkflowOperation(t *testing.T) {
	payload, err := json.Marshal(map[string]any{"error": ErrorEnvelope{
		Code: CodeResultBudgetExceeded,
		Metadata: map[string]any{
			mutationAppliedMetadataKey: true,
			mutationReceiptMetadataKey: map[string]any{
				"applied": true, "action": "workflow plan",
				"identifiers":   map[string]string{"planId": "pln_1"},
				"retrievalPath": `agentteam exec workflow plan {"action":"show","planId":"pln_1"}`,
			},
		},
	}})
	require.NoError(t, err)
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusRequestEntityTooLarge)
		_, _ = w.Write(payload)
	}))
	t.Cleanup(server.Close)
	client := LocalClient{HTTPClient: server.Client()}
	_, err = client.Call(t.Context(), server.URL+"/api/internal/exec/servers/server-1/agents/agent-1", "token", "workflow", "plan", json.RawMessage(`{"action":"show","planId":"pln_1"}`))
	require.Error(t, err)
	var typed *Error
	require.ErrorAs(t, err, &typed)
	require.False(t, typed.mutationApplied)
	require.Nil(t, typed.mutationReceipt)
}

// TestWorkflowStepTransportPreservesCanonicalErrorContract exercises the full
// transport path: the server returns the canonical Workflow error contract,
// apiclient parses it, the workflow handler classifies it, and the envelope
// carries the allowlisted fields.
func TestWorkflowStepTransportPreservesCanonicalErrorContract(t *testing.T) {
	index := 2
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/workflow/step", r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"error": "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$",
			"code":  "step_work_items_invalid",
			"stage": "run",
			"path":  "callback.workItems[2].key",
			"hint":  "Fix the WorkItem key.",
			"diagnostics": []map[string]any{{
				"severity": "error",
				"code":     "callback.work_items.key_invalid",
				"path":     "callback.workItems[2].key",
				"field":    "key",
				"index":    index,
				"message":  "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$",
			}},
		})
	}))
	t.Cleanup(server.Close)

	authorized := runtimeAuthorization{Server: &apiclient.Client{
		ServerURL: server.URL, RouteWorkspaceID: "wsp_1", AgentID: "agt_1", LocalAgentID: "agt_1",
		AuthToken: "token", HTTPClient: server.Client(),
	}}
	_, err := (workflowHandler{}).step(context.Background(), actionScopedJSON[workflowStepCommand]{
		Authorized: authorized,
		Body: workflowStepCommand{
			Action: "complete", RunID: "wri_1", StepID: "wfx_1",
			IdempotencyKey: "k",
		},
	})
	if err == nil {
		t.Fatal("expected an error")
	}
	var typed *Error
	if !errors.As(err, &typed) {
		t.Fatalf("error is %T, want *Error", err)
	}
	if typed.ServerCode != "step_work_items_invalid" {
		t.Fatalf("ServerCode = %q", typed.ServerCode)
	}
	if typed.Stage != "run" || typed.Path != "callback.workItems[2].key" {
		t.Fatalf("stage/path lost: %+v", typed)
	}
	if typed.Hint != "Fix the WorkItem key." {
		t.Fatalf("Hint = %q", typed.Hint)
	}
	if len(typed.Diagnostics) != 1 || typed.Diagnostics[0].Field != "key" {
		t.Fatalf("diagnostics lost: %+v", typed.Diagnostics)
	}
}

// TestWorkflowStepTransportNonJSONFailsClosed verifies a non-JSON server error
// never leaks allowlisted contract fields into the typed Error.
func TestWorkflowStepTransportNonJSONFailsClosed(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html")
		w.WriteHeader(http.StatusBadGateway)
		_, _ = w.Write([]byte(`<html>upstream failure</html>`))
	}))
	t.Cleanup(server.Close)

	authorized := runtimeAuthorization{Server: &apiclient.Client{
		ServerURL: server.URL, RouteWorkspaceID: "wsp_1", AgentID: "agt_1", LocalAgentID: "agt_1",
		AuthToken: "token", HTTPClient: server.Client(),
	}}
	_, err := (workflowHandler{}).step(context.Background(), actionScopedJSON[workflowStepCommand]{
		Authorized: authorized,
		Body:       workflowStepCommand{Action: "get", RunID: "wri_1", StepID: "wfx_1"},
	})
	if err == nil {
		t.Fatal("expected an error")
	}
	var typed *Error
	if !errors.As(err, &typed) {
		t.Fatalf("error is %T, want *Error", err)
	}
	if typed.ServerCode != "" || typed.Stage != "" || typed.Path != "" || len(typed.Diagnostics) != 0 {
		t.Fatalf("non-JSON error leaked contract fields: %+v", typed)
	}
	if typed.Code != CodeInternal {
		t.Fatalf("Code = %q, want internal", typed.Code)
	}
}

// TestCallbackRetryClassificationRetained verifies the exact callback retry and
// terminal classifications survive the apiclient contract extension.
