package cmd

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/api/exec"
	"github.com/tidbcloud/AgentTeam/desktop/internal/token"
	"github.com/tidbcloud/AgentTeam/desktop/internal/wire/apiclient"
)

// TestExecWorkflowStepCrossLayerFailureMatrix exercises the full loop exec
// Workflow failure surface end to end: upstream server (HTTP) returns a
// canonical Workflow error body, the daemon apiclient parses it, the daemon
// workflow handler classifies it, and the CLI renders exactly one stable
// structured stderr JSON envelope with a correct nonzero exit category and the
// bounded diagnostics preserved. Each row is one failure scenario.
func TestExecWorkflowStepCrossLayerFailureMatrix(t *testing.T) {
	gin.SetMode(gin.TestMode)
	completeInput := `{"action":"complete","runId":"wri_1","stepId":"wfx_1","idempotencyKey":"k-1","output":{}}`

	tests := []struct {
		name                        string
		upstream                    func(w http.ResponseWriter)
		deliveryCallsBeforeTerminal int
		wantCode                    string
		wantServer                  string
		wantStage                   string
		wantPath                    string
		wantDiags                   int
		wantExit                    int
	}{
		{
			name: "multi-diagnostics",
			upstream: func(w http.ResponseWriter) {
				writeWorkflowStepError(w, http.StatusBadRequest, "step_work_items_invalid", "run", "callback.workItems[0].key",
					[]map[string]any{
						{"severity": "error", "code": "callback.work_items.key_invalid", "path": "callback.workItems[0].key", "field": "key", "index": 0, "message": "key must match ^[a-z][a-z0-9_-]{0,255}$"},
						{"severity": "error", "code": "callback.work_items.instruction_missing", "path": "callback.workItems[1].instruction", "field": "instruction", "message": "instruction is required"},
						{"severity": "warning", "code": "callback.work_items.depends_on_unknown", "path": "callback.workItems[2].dependsOn[0]", "index": 0, "message": "dependency key is unknown"},
					})
			},
			wantCode: "validation", wantServer: "step_work_items_invalid", wantStage: "run",
			wantPath: "callback.workItems[0].key", wantDiags: 3, wantExit: 64,
		},
		{
			name: "location fields line column nodeId edgeId preserved",
			upstream: func(w http.ResponseWriter) {
				writeWorkflowStepError(w, http.StatusBadRequest, "plan_compile_failed", "run", "$.nodes.entry",
					[]map[string]any{
						{"severity": "error", "code": "plan.node_unreachable", "path": "$.nodes.entry", "line": 12, "column": 3, "nodeId": "entry", "edgeId": "edge_1", "message": "node entry is unreachable"},
						{"severity": "warning", "code": "plan.edge_unreachable", "path": "$.edges.e1", "line": 0, "column": 0, "nodeId": "", "edgeId": "edge_2", "message": "edge has no reachable target"},
					})
			},
			wantCode: "validation", wantServer: "plan_compile_failed", wantStage: "run",
			wantPath: "$.nodes.entry", wantDiags: 2, wantExit: 64,
		},
		{
			name: "server truncation metadata preserved",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusBadRequest)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"error": "Workflow Step failed",
					"code":  "step_work_items_invalid",
					"stage": "run",
					"diagnostics": []map[string]any{
						{"severity": "error", "code": "callback.work_items.key_invalid", "path": "callback.workItems[0].key", "field": "key", "message": "k"},
						{"severity": "error", "code": "callback.work_items.key_invalid", "path": "callback.workItems[1].key", "field": "key", "message": "k2"},
					},
					"truncation": map[string]any{
						"diagnosticsTruncated": true,
						"omittedCount":         3,
						"truncatedFields":      []string{"message"},
						"serializedBytes":      128,
					},
				})
			},
			wantCode: "validation", wantServer: "step_work_items_invalid", wantStage: "run",
			wantDiags: 2, wantExit: 64,
		},
		{
			// Round-7 carry: a server that already applied its diagnostic budget
			// projects truncation metadata even when the diagnostics list is empty
			// (all items dropped / replayed). The cross-layer chain must preserve
			// the prior cut instead of recomputing it away, without fabricating
			// diagnostics. See #t2643 empty-diagnostics truncation carry.
			name: "empty diagnostics truncation carried",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusBadRequest)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"error":       "Workflow Step failed",
					"code":        "step_work_items_invalid",
					"stage":       "run",
					"diagnostics": []map[string]any{},
					"truncation": map[string]any{
						"diagnosticsTruncated": true,
						"omittedCount":         1,
						"truncatedFields":      []string{"message"},
						"serializedBytes":      64,
					},
				})
			},
			wantCode: "validation", wantServer: "step_work_items_invalid", wantStage: "run",
			wantDiags: 0, wantExit: 64,
		},
		{
			name: "oversized error body fails closed to internal",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusBadGateway)
				_, _ = w.Write([]byte(`{"code":"internal_error","diagnostics":[{"message":"` + strings.Repeat("m", 96*1024) + `"}]}`))
			},
			wantCode: "internal", wantServer: "", wantStage: "",
			wantDiags: 0, wantExit: 70,
		},
		{
			name: "workitem key index and dependency paths",
			upstream: func(w http.ResponseWriter) {
				writeWorkflowStepError(w, http.StatusBadRequest, "step_work_items_invalid", "run", "callback.workItems[2].key",
					[]map[string]any{
						{"severity": "error", "code": "callback.work_items.key_invalid", "path": "callback.workItems[2].key", "field": "key", "index": 2, "message": "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$"},
						{"severity": "error", "code": "callback.work_items.dependency_path_invalid", "path": "callback.workItems[2].dependsOn[1]", "index": 1, "message": "dependency index out of range"},
					})
			},
			wantCode: "validation", wantServer: "step_work_items_invalid", wantStage: "run",
			wantPath: "callback.workItems[2].key", wantDiags: 2, wantExit: 64,
		},
		{
			name: "permission non-enumeration",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusNotFound)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"error": "The requested Workflow resource was not found or is not accessible",
					"code":  "step_not_found",
					"stage": "run",
				})
			},
			wantCode: "not_found", wantServer: "step_not_found", wantStage: "run",
			wantDiags: 0, wantExit: 2,
		},
		{
			name: "team workflow protected selector",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusNotFound)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"error": "The requested Workflow resource was not found or is not accessible",
					"code":  "team_workflow_not_found",
					"stage": "team_workflow",
				})
			},
			wantCode: "not_found", wantServer: "team_workflow_not_found", wantStage: "team_workflow",
			wantDiags: 0, wantExit: 2,
		},
		{
			name: "conflict idempotency",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusConflict)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"error": "Workflow Step replay conflicts with the committed result",
					"code":  "idempotency_conflict",
					"stage": "run",
				})
			},
			wantCode: "conflict", wantServer: "idempotency_conflict", wantStage: "run",
			wantDiags: 0, wantExit: 1,
		},
		{
			// Round-8 (#t2647) mixed-sentinel cross-layer proof: the server emits
			// a conflict contract whose declared Kind is conflict while the
			// internal cause wraps a bad-request/not-found sentinel. The server's
			// declared kind stays authoritative on the wire (409 conflict), so
			// the Desktop Exec chain maps it to the conflict category (exit 1)
			// and never lets the internal cause degrade it to validation/not-found.
			name: "conflict declared kind authoritative over internal cause",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusConflict)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"error": "Workflow Run mutation conflicts with the current state",
					"code":  "run_conflict",
					"stage": "run",
				})
			},
			wantCode: "conflict", wantServer: "run_conflict", wantStage: "run",
			wantDiags: 0, wantExit: 1,
		},
		{
			name: "delivery retry then terminal conflict",
			upstream: func(w http.ResponseWriter) {
				panic("unused; delivery row sets its own stateful handler")
			},
			deliveryCallsBeforeTerminal: 2,
			wantCode:                    "conflict", wantServer: "step_already_resolved", wantStage: "run",
			wantDiags: 0, wantExit: 1,
		},
		{
			name: "internal failure",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusInternalServerError)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"error": "Workflow Step failed",
					"code":  "internal_error",
					"stage": "run",
				})
			},
			wantCode: "internal", wantServer: "internal_error", wantStage: "run",
			wantDiags: 0, wantExit: 70,
		},
		{
			name: "non-json fails closed to internal",
			upstream: func(w http.ResponseWriter) {
				w.Header().Set("Content-Type", "text/html")
				w.WriteHeader(http.StatusBadGateway)
				_, _ = w.Write([]byte("<html>upstream broken</html>"))
			},
			wantCode: "internal", wantServer: "", wantStage: "",
			wantDiags: 0, wantExit: 70,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			var calls atomic.Int32
			upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				calls.Add(1)
				require.Equal(t, http.MethodPost, r.Method)
				require.Equal(t, "/api/v1/workspaces/workspace-1/agents/server-agent-1/runtime/workflow/step", r.URL.Path)
				require.Equal(t, "Bearer server-agent-token", r.Header.Get("Authorization"))
				if test.deliveryCallsBeforeTerminal > 0 {
					if calls.Load() <= int32(test.deliveryCallsBeforeTerminal) {
						w.Header().Set("Content-Type", "application/json")
						w.WriteHeader(http.StatusConflict)
						_ = json.NewEncoder(w).Encode(map[string]any{
							"error":     "Workflow Step delivery is not ready",
							"code":      "step_not_ready",
							"stage":     "run",
							"retryable": true,
						})
						return
					}
					w.Header().Set("Content-Type", "application/json")
					w.WriteHeader(http.StatusConflict)
					_ = json.NewEncoder(w).Encode(map[string]any{
						"error": "Workflow Step was already resolved with a different result",
						"code":  "step_already_resolved",
						"stage": "run",
					})
					return
				}
				test.upstream(w)
			}))
			defer upstream.Close()

			keys, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
			require.NoError(t, err)
			authorizations := exec.NewRuntimeAuthorizationStore(keys)
			lease, err := authorizations.Create(exec.RuntimeAuthorizationConfig{
				ServerID:  "server-1",
				AgentID:   "agent-1",
				SessionID: "session-1",
				RunID:     "run-1",
				WorkDir:   t.TempDir(),
				Server: &apiclient.Client{
					ServerURL:        upstream.URL,
					AgentID:          "server-agent-1",
					LocalAgentID:     "server-agent-1",
					AuthToken:        "server-agent-token",
					RouteWorkspaceID: "workspace-1",
					HTTPClient:       upstream.Client(),
				},
				TokenTTL: time.Hour,
			})
			require.NoError(t, err)
			r := gin.New()
			exec.RegisterRoutes(r.Group("/api/internal/exec"), exec.RouteDependencies{Authorizations: authorizations})
			srv := httptest.NewServer(r)
			defer srv.Close()
			setExecCommandTokenFile(t, srv.URL+"/api/internal/exec/servers/server-1/agents/agent-1", lease.Token)

			stdout, stderr, err := executeCobra(newExecCommand(), "workflow", "step", completeInput)
			require.Error(t, err)
			require.Empty(t, stdout, "workflow failure must not write stdout")
			require.True(t, calls.Load() >= 1, "upstream must have been reached")

			exitCode, ok := ExecExitCode(err)
			require.True(t, ok, "workflow failure must carry a rendered exec exit code")
			require.Equal(t, test.wantExit, exitCode)

			require.True(t, json.Valid([]byte(strings.TrimSpace(stderr))), "stderr must be a single stable JSON envelope: %q", stderr)
			var envelope struct {
				Error struct {
					Code        string `json:"code"`
					Problem     string `json:"problem"`
					Stage       string `json:"stage"`
					Path        string `json:"path"`
					Retryable   bool   `json:"retryable"`
					ServerCode  string `json:"serverCode"`
					Diagnostics []struct {
						Severity string `json:"severity"`
						Code     string `json:"code"`
						Path     string `json:"path"`
						Field    string `json:"field"`
						Index    *int   `json:"index"`
						Line     int    `json:"line"`
						Column   int    `json:"column"`
						NodeID   string `json:"nodeId"`
						EdgeID   string `json:"edgeId"`
						Message  string `json:"message"`
					} `json:"diagnostics"`
					Truncation *struct {
						DiagnosticsTruncated bool     `json:"diagnosticsTruncated"`
						OmittedCount         int      `json:"omittedCount"`
						TruncatedFields      []string `json:"truncatedFields"`
						SerializedBytes      int      `json:"serializedBytes"`
					} `json:"truncation"`
					Truncated bool `json:"truncated"`
				} `json:"error"`
			}
			require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stderr)), &envelope))
			require.Equal(t, test.wantCode, envelope.Error.Code)
			require.Equal(t, test.wantServer, envelope.Error.ServerCode)
			require.Equal(t, test.wantStage, envelope.Error.Stage)
			require.Equal(t, test.wantPath, envelope.Error.Path)
			require.Equal(t, test.wantDiags, len(envelope.Error.Diagnostics))
			require.NotContains(t, stderr, "internal error\ninternal")
			require.Less(t, len(stderr), 64*1024)

			if test.name == "location fields line column nodeId edgeId preserved" {
				first := envelope.Error.Diagnostics[0]
				require.Equal(t, 12, first.Line)
				require.Equal(t, 3, first.Column)
				require.Equal(t, "entry", first.NodeID)
				require.Equal(t, "edge_1", first.EdgeID)
				second := envelope.Error.Diagnostics[1]
				require.Equal(t, 0, second.Line)
				require.Equal(t, "edge_2", second.EdgeID)
			}
			if test.name == "server truncation metadata preserved" {
				require.NotNil(t, envelope.Error.Truncation)
				require.True(t, envelope.Error.Truncation.DiagnosticsTruncated)
				require.Equal(t, 3, envelope.Error.Truncation.OmittedCount)
				require.Equal(t, 128, envelope.Error.Truncation.SerializedBytes)
				require.Equal(t, []string{"message"}, envelope.Error.Truncation.TruncatedFields)
				require.False(t, envelope.Error.Truncated, "server-declared truncation must not be confused with client-side read truncation")
			}
			if test.name == "empty diagnostics truncation carried" {
				require.Empty(t, envelope.Error.Diagnostics, "an empty diagnostics list must not fabricate diagnostics")
				require.NotNil(t, envelope.Error.Truncation, "prior truncation metadata must survive an empty diagnostics list")
				require.True(t, envelope.Error.Truncation.DiagnosticsTruncated)
				require.Equal(t, 1, envelope.Error.Truncation.OmittedCount)
				require.Equal(t, 64, envelope.Error.Truncation.SerializedBytes)
				require.Equal(t, []string{"message"}, envelope.Error.Truncation.TruncatedFields)
				require.False(t, envelope.Error.Truncated, "server-declared truncation must not be confused with client-side read truncation")
			}
			if test.name == "oversized error body fails closed to internal" {
				require.True(t, envelope.Error.Truncated, "oversized error body must be flagged truncated")
				require.Nil(t, envelope.Error.Truncation)
			}
		})
	}
}

// TestExecWorkflowStepCrossLayerRetryOnlyWhenDeclared verifies the CLI's
// bounded callback retry is reserved for the server's declared delivery race
// (step_not_ready): a plain conflict is never retried, and a delivery race that
// later becomes terminal surfaces the terminal classification without looping.
func TestExecWorkflowStepCrossLayerRetryOnlyWhenDeclared(t *testing.T) {
	gin.SetMode(gin.TestMode)

	t.Run("non-delivery conflict is not retried", func(t *testing.T) {
		var calls atomic.Int32
		upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
			calls.Add(1)
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusConflict)
			_ = json.NewEncoder(w).Encode(map[string]any{
				"error": "Workflow Step replay conflicts with the committed result",
				"code":  "idempotency_conflict",
				"stage": "run",
			})
		}))
		defer upstream.Close()
		_, stderr, err := runWorkflowStepAgainstUpstream(t, upstream)
		require.Error(t, err)
		require.Equal(t, int32(1), calls.Load(), "plain conflict must never be retried")
		require.Contains(t, stderr, `"serverCode":"idempotency_conflict"`)
		exitCode, ok := ExecExitCode(err)
		require.True(t, ok)
		require.Equal(t, 1, exitCode)
	})

	t.Run("delivery race retries only within the bounded window", func(t *testing.T) {
		var calls atomic.Int32
		upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
			if calls.Add(1) <= 3 {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusConflict)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"error":     "Workflow Step delivery is not ready",
					"code":      "step_not_ready",
					"stage":     "run",
					"retryable": true,
				})
				return
			}
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusConflict)
			_ = json.NewEncoder(w).Encode(map[string]any{
				"error": "Workflow Step was already resolved with a different result",
				"code":  "step_already_resolved",
				"stage": "run",
			})
		}))
		defer upstream.Close()
		_, stderr, err := runWorkflowStepAgainstUpstream(t, upstream)
		require.Error(t, err)
		require.GreaterOrEqual(t, calls.Load(), int32(2), "declared delivery race must be retried")
		require.Contains(t, stderr, `"serverCode":"step_already_resolved"`)
		exitCode, ok := ExecExitCode(err)
		require.True(t, ok)
		require.Equal(t, 1, exitCode)
	})
}

// runWorkflowStepAgainstUpstream wires a daemon exec runtime whose authenticated
// Server client points at the given upstream, then runs the loop exec workflow
// step complete command through the CLI.
func runWorkflowStepAgainstUpstream(t *testing.T, upstream *httptest.Server) (string, string, error) {
	t.Helper()
	keys, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	authorizations := exec.NewRuntimeAuthorizationStore(keys)
	lease, err := authorizations.Create(exec.RuntimeAuthorizationConfig{
		ServerID:  "server-1",
		AgentID:   "agent-1",
		SessionID: "session-1",
		RunID:     "run-1",
		WorkDir:   t.TempDir(),
		Server: &apiclient.Client{
			ServerURL:        upstream.URL,
			AgentID:          "server-agent-1",
			LocalAgentID:     "server-agent-1",
			AuthToken:        "server-agent-token",
			RouteWorkspaceID: "workspace-1",
			HTTPClient:       upstream.Client(),
		},
		TokenTTL: time.Hour,
	})
	require.NoError(t, err)
	r := gin.New()
	exec.RegisterRoutes(r.Group("/api/internal/exec"), exec.RouteDependencies{Authorizations: authorizations})
	srv := httptest.NewServer(r)
	t.Cleanup(srv.Close)
	setExecCommandTokenFile(t, srv.URL+"/api/internal/exec/servers/server-1/agents/agent-1", lease.Token)
	return executeCobra(
		newExecCommand(),
		"workflow", "step",
		`{"action":"complete","runId":"wri_1","stepId":"wfx_1","idempotencyKey":"k-1","output":{}}`,
	)
}

func writeWorkflowStepError(
	w http.ResponseWriter,
	status int,
	code, stage, path string,
	diagnostics []map[string]any,
) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	body := map[string]any{
		"error": "Workflow Step failed",
		"code":  code,
		"stage": stage,
	}
	if path != "" {
		body["path"] = path
	}
	if diagnostics != nil {
		body["diagnostics"] = diagnostics
	}
	_ = json.NewEncoder(w).Encode(body)
}
