package exec

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestApplyActionResultBudgetPreservesBoundedResult(t *testing.T) {
	input := actionResult[map[string]any]{
		Text:     "ok",
		Data:     map[string]any{"id": "x"},
		Metadata: map[string]any{"status": "ready"},
	}
	got := applyActionResultBudget(input)
	if got.Text != input.Text {
		t.Fatalf("Text = %q, want %q", got.Text, input.Text)
	}
	if got.Data.(map[string]any)["id"] != "x" || got.Metadata["status"] != "ready" {
		t.Fatalf("bounded result changed: %#v", got)
	}
	if _, ok := got.Metadata["result_budget"]; ok {
		t.Fatalf("unexpected truncation metadata: %#v", got.Metadata)
	}
}

func TestApplyActionResultBudgetNeverPartiallyProjectsTypedData(t *testing.T) {
	items := make([]any, 150)
	for i := range items {
		items[i] = strings.Repeat("data", 2000)
	}
	input := actionResult[map[string]any]{
		Text: strings.Repeat("text", 10000),
		Data: map[string]any{"items": items},
		Metadata: map[string]any{
			"debug": strings.Repeat("meta", 3000),
		},
	}
	got := applyActionResultBudget(input)
	require.Equal(t, input.Text, got.Text)
	require.Equal(t, input.Data, got.Data)
	require.Equal(t, input.Metadata, got.Metadata)

	encoded, err := marshalTypedActionResultForAction("task", "get", input)
	require.Nil(t, encoded)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	require.Equal(t, CodeResultBudgetExceeded, budgetErr.Code)
	require.Contains(t, budgetErr.Hint, "result_policy=narrow")
}

func TestApplyErrorBudgetBoundsTypedAndRawErrors(t *testing.T) {
	typed := &Error{
		Code:    CodeConflict,
		Problem: strings.Repeat("p", 2000),
		Hint:    strings.Repeat("h", 500),
		Next:    strings.Repeat("n", 500),
	}
	got := ApplyErrorBudget(typed)
	if got.Code != CodeConflict {
		t.Fatalf("Code = %q, want conflict", got.Code)
	}
	if len([]rune(got.Problem)) > maxProblemRunes || len([]rune(got.Hint)) > maxHintRunes || len([]rune(got.Next)) > maxNextRunes {
		t.Fatalf("error fields exceeded caps: %#v", got)
	}
	if got.Metadata[errorBudgetMetadataKey] == nil {
		t.Fatalf("missing error truncation metadata: %#v", got)
	}

	raw := ApplyErrorBudget(errors.New(strings.Repeat("secret", 1000)))
	if raw.Code != CodeInternal || raw.Problem != "internal error" || raw.Metadata != nil {
		t.Fatalf("raw error leaked or changed envelope: %#v", raw)
	}
}

func TestApplyErrorBudgetExposesSchemaValidationDetailWithoutCauseField(t *testing.T) {
	got := ApplyErrorBudget(&Error{Code: CodeValidation, Problem: "invalid input", Cause: errors.New("instance fails schema: /channel missing required property")})
	if got.Code != CodeValidation {
		t.Fatalf("Code = %q, want validation", got.Code)
	}
	if !strings.Contains(got.Problem, "/channel") {
		t.Fatalf("validation problem missing field detail: %#v", got)
	}
	encoded, err := json.Marshal(got)
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(encoded), "Cause") || strings.Contains(string(encoded), `"cause"`) {
		t.Fatalf("cause field leaked: %s", encoded)
	}
}

func TestMarshalTypedActionResultRejectsOversizedTypedData(t *testing.T) {
	hugeKey := strings.Repeat("k", 1<<20)
	encoded, err := marshalTypedActionResultForAction("task", "get", actionResult[map[string]any]{
		Data: map[string]any{hugeKey: "value"},
	})
	require.Nil(t, encoded)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	require.Equal(t, CodeResultBudgetExceeded, budgetErr.Code)
}

func TestMarshalTypedActionResultRejectsUnserializableData(t *testing.T) {
	encoded, err := marshalTypedActionResultForAction("task", "get", actionResult[any]{Data: make(chan int)})
	require.Nil(t, encoded)
	var internalErr *Error
	require.ErrorAs(t, err, &internalErr)
	require.Equal(t, CodeInternal, internalErr.Code)
	require.NotContains(t, internalErr.Error(), "chan")
}

func TestResultBudgetFailureIsActionableAndRedacted(t *testing.T) {
	failure := resultBudgetExceededError("workflow", "step", maxSerializedResultBytes+1, errors.New("private payload: secret-token"))
	envelope := ApplyErrorBudget(failure)
	require.Equal(t, CodeResultBudgetExceeded, envelope.Code)
	require.Contains(t, envelope.Hint, "result_policy=workflow")
	require.Contains(t, envelope.Next, "workflow step")
	encoded, err := json.Marshal(envelope)
	require.NoError(t, err)
	require.NotContains(t, string(encoded), "secret-token")
	require.NotContains(t, string(encoded), "private payload")
}

func TestOversizedWorkflowPlanMutationReturnsCompactAppliedReceipt(t *testing.T) {
	layout := workflowValue([]byte(`{"nodes":"` + strings.Repeat("x", maxSerializedResultBytes) + `"}`))
	data := workflowPlanResult{create: &workflowPlanCreateResponse{
		Plan:  workflowPlanResponse{ID: "pln_1", Revision: 7},
		Draft: workflowPlanDraftResponse{PlanID: "pln_1", Revision: 7, Layout: layout},
	}}
	_, err := marshalTypedActionResultForActionWithInput(
		"workflow", "plan", json.RawMessage(`{"action":"create","key":"daily"}`),
		actionResult[workflowPlanResult]{Data: data},
	)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	require.Equal(t, CodeResultBudgetExceeded, budgetErr.Code)
	require.Empty(t, budgetErr.Next)
	require.NotContains(t, strings.ToLower(budgetErr.Hint), "retry")
	envelope := ApplyErrorBudget(budgetErr)
	receipt, ok := envelope.Metadata[mutationReceiptMetadataKey].(*mutationSuccessReceipt)
	require.True(t, ok)
	require.True(t, receipt.Applied)
	require.Equal(t, "pln_1", receipt.Identifiers["planId"])
	require.Equal(t, "7", receipt.Revision)
	require.Contains(t, receipt.RetrievalPath, `workflow plan`)
	encoded, marshalErr := json.Marshal(envelope)
	require.NoError(t, marshalErr)
	require.NotContains(t, string(encoded), strings.Repeat("x", 128))
}

func TestOversizedWorkflowPlanUpdateReturnsDraftReceipt(t *testing.T) {
	layout := workflowValue([]byte(`{"nodes":"` + strings.Repeat("y", maxSerializedResultBytes) + `"}`))
	data := workflowPlanResult{draft: &workflowPlanDraftResponse{
		PlanID: "pln_2", Revision: 9, Layout: layout,
	}}
	_, err := marshalTypedActionResultForActionWithInput(
		"workflow", "plan", json.RawMessage(`{"action":"update","planId":"pln_2"}`),
		actionResult[workflowPlanResult]{Data: data},
	)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	receipt := ApplyErrorBudget(budgetErr).Metadata[mutationReceiptMetadataKey].(*mutationSuccessReceipt)
	require.Equal(t, "pln_2", receipt.Identifiers["planId"])
	require.Equal(t, "9", receipt.Revision)
	require.Contains(t, receipt.RetrievalPath, `workflow plan`)
}

func TestOversizedSkillImportMutationReturnsSkillRetrievalReceipt(t *testing.T) {
	data := skillImportResult{
		Skill:          skill{ID: "skill_1", UpdatedAt: "2026-08-21T15:00:00Z"},
		ImportedSkills: []skillImportedSkill{{Path: "a", Layout: strings.Repeat("x", maxSerializedResultBytes)}},
	}
	_, err := marshalTypedActionResultForActionWithInput(
		"skill", "import", json.RawMessage(`{"skillId":"skill_1","url":"https://example.test/repo"}`),
		actionResult[skillImportResult]{Data: data},
	)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	receipt := ApplyErrorBudget(budgetErr).Metadata[mutationReceiptMetadataKey].(*mutationSuccessReceipt)
	require.Equal(t, "skill_1", receipt.Identifiers["skillId"])
	require.Equal(t, "2026-08-21T15:00:00Z", receipt.Revision)
	require.Contains(t, receipt.RetrievalPath, `skill get`)
}

func TestMutationReceiptUsesDeterministicBatchIdentifiers(t *testing.T) {
	data := map[string]any{"payload": strings.Repeat("x", maxSerializedResultBytes)}
	for index := 0; index < 32; index++ {
		data[fmt.Sprintf("id%02d", index)] = fmt.Sprintf("distractor-%02d", index)
	}
	_, err := marshalTypedActionResultForActionWithInput(
		"agent", "delete", json.RawMessage(`{"requestId":"req_batch","agentIds":["agt_2","agt_1"]}`),
		actionResult[map[string]any]{Data: data},
	)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	receipt := ApplyErrorBudget(budgetErr).Metadata[mutationReceiptMetadataKey].(*mutationSuccessReceipt)
	require.Equal(t, `["agt_2","agt_1"]`, receipt.Identifiers["agentIds"])
	require.NotContains(t, receipt.Identifiers, "id01")
	require.Empty(t, receipt.RetrievalPath, "a batch cannot satisfy the single-agent get selector")
}

func TestMutationReceiptAllowsAppliedMutationWithoutRetrievalSelector(t *testing.T) {
	_, err := marshalTypedActionResultForActionWithInput(
		"profile", "set-avatar", json.RawMessage(`{"imagePath":"/tmp/avatar.png"}`),
		actionResult[map[string]any]{Data: map[string]any{"payload": strings.Repeat("x", maxSerializedResultBytes)}},
	)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	receipt, ok := ApplyErrorBudget(budgetErr).Metadata[mutationReceiptMetadataKey].(*mutationSuccessReceipt)
	require.True(t, ok)
	require.True(t, receipt.Applied)
	require.Empty(t, receipt.RetrievalPath)
}

type failingMutationData struct{}

func (failingMutationData) MarshalJSON() ([]byte, error) {
	return nil, errors.New("private custom marshaler failure")
}

func TestChannelAgentBatchReceiptFitsAtSchemaMaximum(t *testing.T) {
	ids := make([]string, 100)
	agents := make([]map[string]string, len(ids))
	for index := range ids {
		ids[index] = strings.Repeat("a", 64)
		agents[index] = map[string]string{"agentId": ids[index]}
	}
	input, err := json.Marshal(map[string]any{
		"action": "agent-add", "requestId": "req_batch", "channelId": "chn_1", "agents": agents,
	})
	require.NoError(t, err)
	_, err = marshalTypedActionResultForActionWithInput(
		"channel", "agent-add", input,
		actionResult[map[string]any]{Data: map[string]any{"payload": strings.Repeat("x", maxSerializedResultBytes)}},
	)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	receipt, ok := ApplyErrorBudget(budgetErr).Metadata[mutationReceiptMetadataKey].(*mutationSuccessReceipt)
	require.True(t, ok)
	var got []string
	require.NoError(t, json.Unmarshal([]byte(receipt.Identifiers["agentIds"]), &got))
	require.Equal(t, ids, got)
	require.Contains(t, receipt.RetrievalPath, `channel get`)
	envelopeBytes, marshalErr := json.Marshal(ApplyErrorBudget(budgetErr))
	require.NoError(t, marshalErr)
	require.LessOrEqual(t, len(envelopeBytes), maxSerializedResultBytes)
}

func TestChannelAgentBatchOneOverSchemaLimitIsRejected(t *testing.T) {
	catalog, err := DefaultCatalog()
	require.NoError(t, err)
	agents := make([]map[string]string, 101)
	for index := range agents {
		agents[index] = map[string]string{"agentId": fmt.Sprintf("agt_%03d", index)}
	}
	input, err := json.Marshal(map[string]any{
		"action": "agent-add", "requestId": "req_batch", "channelId": "chn_1", "agents": agents,
	})
	require.NoError(t, err)
	require.Error(t, catalog.ValidateInput("channel", "agent-add", input))
}

func TestMutationSerializationFailureDoesNotSetApplied(t *testing.T) {
	encoded, err := marshalTypedActionResultForActionWithInput(
		"channel", "create", json.RawMessage(`{"requestId":"req_1","displayName":"ops"}`),
		actionResult[failingMutationData]{Data: failingMutationData{}},
	)
	require.Nil(t, encoded)
	var typed *Error
	require.ErrorAs(t, err, &typed)
	require.Equal(t, CodeInternal, typed.Code)
	envelope := ApplyErrorBudget(err)
	require.Nil(t, envelope.Metadata)
	require.NotContains(t, strings.ToLower(envelope.Hint), "retry")
	require.NotContains(t, strings.ToLower(envelope.Hint), "replay")
}

func TestOversizedDirectMutationDoesNotSuggestReplay(t *testing.T) {
	_, err := marshalTypedActionResultForActionWithInput(
		"channel", "create", json.RawMessage(`{"requestId":"req_1","displayName":"ops"}`),
		actionResult[map[string]any]{Data: map[string]any{
			"channelId": "chn_1", "version": 3, "payload": strings.Repeat("x", maxSerializedResultBytes),
		}},
	)
	var budgetErr *Error
	require.ErrorAs(t, err, &budgetErr)
	envelope := ApplyErrorBudget(budgetErr)
	require.Empty(t, envelope.Next)
	require.NotContains(t, strings.ToLower(envelope.Hint), "retry")
	receipt := envelope.Metadata[mutationReceiptMetadataKey].(*mutationSuccessReceipt)
	require.Equal(t, "req_1", receipt.IdempotencyKey)
	require.Equal(t, "3", receipt.Revision)
	require.Contains(t, receipt.RetrievalPath, `channel get`)
}
