package orchestrator

// Adversarial verification of the RecoveryRunner: the recovery message reuses
// the frozen message request field-for-field (only the documented fields are
// changed), the Recovery Prompt is byte-exact across every attempt index, and
// the tick-commit -> send window is documented as a bounded stale-nudge race.

import (
	"context"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/store/workflow/run"
	"github.com/tidbcloud/AgentTeam/internal/workflow/plan"
	runkernel "github.com/tidbcloud/AgentTeam/internal/workflow/run"
)

// richRecoveryEffect builds an awaiting callback whose frozen message request
// carries every frozen-context field the recovery send must preserve verbatim:
// runtime identity, workflow name, frozen Team ID, the callback responder ref,
// and the collaboration fields that recovery must explicitly clear. Delivery
// is by the fixed Task scope so a TaskWatchHandoff is valid on the frozen
// request and the recovery send must clear it.
func richRecoveryEffect(t *testing.T, runID, effectID string) (run.Effect, MessageRequest) {
	t.Helper()
	delivery := &domain.MessageScopeValue{Kind: domain.MessageScopeKindChannel, ScopeID: "chn_game"}
	thread := &domain.MessageScopeValue{Kind: domain.MessageScopeKindThread, ScopeID: "tch_game"}
	task := &domain.MessageScopeValue{Kind: domain.MessageScopeKindTask, ScopeID: "tsk_game"}
	related := []domain.MessageScopeValue{{Kind: domain.MessageScopeKindChannel, ScopeID: "chn_related"}}
	handoff := &runkernel.TaskWatchHandoff{
		TaskScope:                 *task,
		WorkItemID:                "wi_1",
		CausalPredecessorAgentIDs: []string{"agt_prev"},
	}
	request, err := EncodeEffectRequest(EffectRequest{
		Intent: runkernel.Intent{
			Kind: runkernel.IntentSendMessage, ActivationID: "a1", NodeID: "say", Callback: true,
			Responder: "agt_1", Scope: task, WorkScopeMode: runkernel.WorkScopeModeFixed,
			WorkScope: task, RelatedScopes: related, TaskWatchHandoff: handoff,
			Retry: plan.ArtifactRetryPolicy{MaxAttempts: 1, BackoffNanos: int64(time.Second)},
		},
		Message: &MessageRequest{
			WorkspaceID: "wsp_1", PlanID: "wfp_1", PlanVersionID: "wfv_1", RunID: runID,
			ActivationID: "a1", EffectID: effectID, NodeID: "say", SourceKind: "manual",
			WorkflowName: "Recovery Workflow", OwnerUserID: "usr_1", AuthorMemberID: "mem_1",
			TeamID: "team_frozen", Scope: task, Content: "original delivery",
			ClientMessageID: "workflow-run:" + effectID, Callback: true, ResponderAgentRef: "agt_1",
			WorkScopeMode: runkernel.WorkScopeModeFixed, WorkScope: task, RelatedScopes: related,
			TaskWatchHandoff: handoff,
		},
	})
	require.NoError(t, err)
	receipt, err := EncodeEffectResult(EffectResult{
		MessageID: "msg_" + effectID, WakeAgentIDs: []string{"agt_1"},
		CallbackResponderAgentID: "agt_1",
		DeliveryScope:            delivery,
		ThreadScope:              thread,
	})
	require.NoError(t, err)
	return run.Effect{
		ID: effectID, WorkspaceID: "wsp_1", RunID: runID, ActivationID: "wra_1",
		Kind: EffectKindMessage, Status: run.EffectStatusAwaiting,
		CallbackResponderAgentID: "agt_1", RequestJSON: request, ResultJSON: receipt,
	}, *mustDecodeMessageRequest(t, request)
}

func mustDecodeMessageRequest(t *testing.T, requestJSON []byte) *MessageRequest {
	t.Helper()
	decoded, err := DecodeEffectRequest(requestJSON)
	require.NoError(t, err)
	require.NotNil(t, decoded.Message)
	return decoded.Message
}

// TestRecoveryMessagePreservesFrozenContextFieldByField proves that the recovery
// nudge copies every frozen field verbatim and changes exactly and only the
// documented fields: Content, ClientMessageID, TargetRef (cleared), StartThread
// (false), WorkScopeMode/WorkScope/RelatedScopes/TaskWatchHandoff (cleared),
// Scope (frozen ThreadScope ?? DeliveryScope), and the trusted responder fence.
func TestRecoveryMessagePreservesFrozenContextFieldByField(t *testing.T) {
	effect, frozen := richRecoveryEffect(t, "wri_rich", "wfx_rich")
	repository := &fakeRecoveryRepository{
		candidates: []run.Effect{effect},
		tick: func(params run.RecoveryTickParams) (run.RecoveryTickResult, error) {
			return recoverySendTick(effect, 1, 1), nil
		},
	}
	writer := &recordingRecoveryWriter{}
	runner, err := NewRecoveryRunner(repository, writer, RecoveryRunnerOptions{
		WorkerID:       "recovery-1",
		Now:            func() time.Time { return time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) },
		RuntimeWorking: fakeRuntimeWorking{working: false, known: true},
	})
	require.NoError(t, err)

	outcome, err := runner.RecoverOnce(context.Background())
	require.NoError(t, err)
	require.Equal(t, 1, outcome.Sent)
	require.Len(t, writer.writes, 1)
	sent := writer.writes[0]

	// Frozen context preserved verbatim.
	require.Equal(t, frozen.WorkspaceID, sent.WorkspaceID)
	require.Equal(t, frozen.PlanID, sent.PlanID)
	require.Equal(t, frozen.PlanVersionID, sent.PlanVersionID)
	require.Equal(t, frozen.RunID, sent.RunID)
	require.Equal(t, frozen.ActivationID, sent.ActivationID)
	require.Equal(t, frozen.EffectID, sent.EffectID)
	require.Equal(t, frozen.NodeID, sent.NodeID)
	require.Equal(t, frozen.SourceKind, sent.SourceKind)
	require.Equal(t, frozen.WorkflowName, sent.WorkflowName)
	require.Equal(t, frozen.OwnerUserID, sent.OwnerUserID)
	require.Equal(t, frozen.AuthorMemberID, sent.AuthorMemberID)
	require.Equal(t, frozen.TeamID, sent.TeamID)
	require.Equal(t, frozen.Callback, sent.Callback, "the nudge must keep the callback routing path")
	require.Equal(t, frozen.ResponderAgentRef, sent.ResponderAgentRef)

	// Only the documented fields change.
	require.Equal(t, "", sent.TargetRef, "recovery reuses the frozen scope, never a re-resolved target")
	require.False(t, sent.StartThread)
	require.Equal(t, "", string(sent.WorkScopeMode))
	require.Nil(t, sent.WorkScope)
	require.Nil(t, sent.RelatedScopes)
	require.Nil(t, sent.TaskWatchHandoff)
	require.Equal(t, "agt_1", sent.ExpectedCallbackResponderAgentID)
	require.True(t, sent.SkipWorkflowRunMessageBinding, "a recovery nudge must never bind to the awaiting effect")
	require.Equal(t, &domain.MessageScopeValue{Kind: domain.MessageScopeKindThread, ScopeID: "tch_game"}, sent.Scope,
		"the nudge is delivered to the frozen ThreadScope, not the delivery scope")
	require.NotEqual(t, frozen.Content, sent.Content, "content is replaced by the Recovery Prompt")
	require.Equal(t, "workflow-run-recovery:wri_rich:wfx_rich:1", sent.ClientMessageID)
}

// TestRecoveryMessageFallsBackToDeliveryScopeWithoutThreadScope proves the
// ThreadScope ?? DeliveryScope fallback and that a receipt without a thread
// delivers to the frozen delivery scope.
func TestRecoveryMessageFallsBackToDeliveryScopeWithoutThreadScope(t *testing.T) {
	effect := recoveryEffect(t, "wri_flat", "wfx_flat") // receipt has DeliveryScope only
	repository := &fakeRecoveryRepository{
		candidates: []run.Effect{effect},
		tick: func(params run.RecoveryTickParams) (run.RecoveryTickResult, error) {
			return recoverySendTick(effect, 2, 2), nil
		},
	}
	writer := &recordingRecoveryWriter{}
	runner, err := NewRecoveryRunner(repository, writer, RecoveryRunnerOptions{
		WorkerID:       "recovery-1",
		Now:            func() time.Time { return time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) },
		RuntimeWorking: fakeRuntimeWorking{working: false, known: true},
	})
	require.NoError(t, err)
	_, err = runner.RecoverOnce(context.Background())
	require.NoError(t, err)
	require.Len(t, writer.writes, 1)
	require.Equal(t, &domain.MessageScopeValue{Kind: domain.MessageScopeKindChannel, ScopeID: "chn_game"}, writer.writes[0].Scope)
}

// TestRecoveryPromptAllAttemptsByteExact verifies the frozen English Recovery
// Prompt for every attempt index 1..5: only the 1-based attempt number and the
// real selectors change, the mandatory blank line before the attempt line is
// preserved, and no placeholder survives.
func TestRecoveryPromptAllAttemptsByteExact(t *testing.T) {
	runID, stepID := "wri_byte", "wfx_byte"
	rendered := make([]string, 0, 5)
	for index := 1; index <= 5; index++ {
		content := renderRecoveryPrompt(runID, stepID, index)
		require.Contains(t, content, "This is automatic recovery attempt "+strconv.Itoa(index)+" of 5.")
		require.Contains(t, content, "\n\nThis is automatic recovery attempt", "the frozen blank line must precede the attempt line")
		require.Contains(t, content, "`loop exec workflow step '{\"action\":\"get\",\"runId\":\"wri_byte\",\"stepId\":\"wfx_byte\"}'`")
		require.NotContains(t, content, "<run-id>")
		require.NotContains(t, content, "<step-id>")
		require.NotContains(t, content, "{{recoveryIndex}}")
		rendered = append(rendered, content)
	}
	// The 5 renders differ from one another only in the attempt number.
	for i := 1; i < len(rendered); i++ {
		before := strings.Replace(rendered[i-1], "attempt "+strconv.Itoa(i)+" of 5", "attempt N of 5", 1)
		after := strings.Replace(rendered[i], "attempt "+strconv.Itoa(i+1)+" of 5", "attempt N of 5", 1)
		require.Equal(t, before, after, "attempts %d and %d must differ only in the attempt number", i, i+1)
	}
}

// TestRecoveryRunnerStaleNudgeAfterTickDocumentsBoundedRace proves the
// tick-commit -> message-send window: once RecoveryTick has returned Send, the
// runner trusts that decision and writes the nudge even if the callback was
// resolved in the window between the atomic tick and the write. The nudge is an
// ordinary message (no new awaiting effect, no capacity re-gate), so the race
// is bounded to a stale, harmless prompt. This is the documented behavior, not
// a bug: the tick is the durable authority, and the message write is a side
// effect of the committed attempt.
func TestRecoveryRunnerStaleNudgeAfterTickDocumentsBoundedRace(t *testing.T) {
	effect := recoveryEffect(t, "wri_race", "wfx_race")
	repository := &fakeRecoveryRepository{
		candidates: []run.Effect{effect},
		tick: func(params run.RecoveryTickParams) (run.RecoveryTickResult, error) {
			// The tick commits and reports Send while the callback is still
			// awaiting; the responder completes concurrently afterwards.
			return recoverySendTick(effect, 1, 1), nil
		},
	}
	writer := &recordingRecoveryWriter{}
	runner, err := NewRecoveryRunner(repository, writer, RecoveryRunnerOptions{
		WorkerID:       "recovery-1",
		Now:            func() time.Time { return time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) },
		RuntimeWorking: fakeRuntimeWorking{working: false, known: true},
	})
	require.NoError(t, err)

	outcome, err := runner.RecoverOnce(context.Background())
	require.NoError(t, err)
	require.Equal(t, 1, outcome.Sent)
	require.Len(t, writer.writes, 1)
	// The nudge reuses the frozen callback shape but is only a message: no new
	// effect id, no capacity claim — Callback stays true so the wake reaches the
	// frozen responder through the trusted fence.
	require.Equal(t, "wfx_race", writer.writes[0].EffectID)
	require.True(t, writer.writes[0].Callback)
	require.Equal(t, "agt_1", writer.writes[0].ExpectedCallbackResponderAgentID)
}
