package pipeline

import (
	"context"
	"fmt"
	"strings"
	"testing"

	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)

func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) {
	tests := []struct {
		name           string
		retries        int
		operation      func(int) erasedTypedResult
		validator      *preparedValidator
		wantCalls      int
		wantItem       string
		wantMessages   []string
		wantRejected   int
		wantDebug      []string
		wantCheckpoint int
	}{
		{
			name:    "accepts zero-retry fallback",
			retries: 0,
			operation: func(int) erasedTypedResult {
				return retryableNormalizeResult("fallback", "ordinary", "fallback-warning")
			},
			wantCalls:      1,
			wantItem:       "fallback",
			wantMessages:   []string{"ordinary", "fallback-warning"},
			wantDebug:      []string{`"another_attempt":false`, `"fallback_accepted":true`},
			wantCheckpoint: 1,
		},
		{
			name:    "retries before accepting ordinary result",
			retries: 1,
			operation: func(attempt int) erasedTypedResult {
				if attempt == 1 {
					return retryableNormalizeResult("discarded", "discarded-ordinary", "discarded-fallback")
				}
				return erasedTypedResult{Value: codecNotes{Items: []string{"accepted"}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("accepted", "accepted-warning")}}
			},
			wantCalls:      2,
			wantItem:       "accepted",
			wantMessages:   []string{"accepted-warning"},
			wantDebug:      []string{`"another_attempt":true`, `"fallback_accepted":false`},
			wantCheckpoint: 1,
		},
		{
			name:    "accepts final fallback after exhaustion",
			retries: 1,
			operation: func(attempt int) erasedTypedResult {
				return retryableNormalizeResult(fmt.Sprintf("fallback-%d", attempt), fmt.Sprintf("ordinary-%d", attempt), fmt.Sprintf("fallback-warning-%d", attempt))
			},
			wantCalls:      2,
			wantItem:       "fallback-2",
			wantMessages:   []string{"ordinary-2", "fallback-warning-2"},
			wantDebug:      []string{`"another_attempt":false`, `"fallback_accepted":true`},
			wantCheckpoint: 1,
		},
		{
			name:    "keeps final fallback rejection terminal",
			retries: 1,
			operation: func(int) erasedTypedResult {
				return retryableNormalizeResult("rejected", "ordinary", "fallback-warning")
			},
			validator: &preparedValidator{
				resolved: ResolvedValidator{Binding: Binding("reject-final-fallback"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
				typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
					return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "reject fallback", CorrectionGuidance: "return an acceptable normalized artifact"}, nil
				},
			},
			wantCalls:      2,
			wantRejected:   1,
			wantDebug:      []string{`"fallback_accepted":true`, `"rejection"`},
			wantCheckpoint: 0,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			prepared := preparedAttemptDebugPipeline(t)
			lane := &prepared.Steps[0].lanes[0]
			lane.resolved.Normalize.Retries = tc.retries
			lane.resolved.NormalizeValidationPolicy.SemanticRejection = SemanticRejectionRejectOutput
			if tc.validator != nil {
				lane.normalizeValidators.validators = []preparedValidator{*tc.validator}
			}
			calls := 0
			lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
				calls++
				return tc.operation(calls), nil
			}
			debug := newCapturedDebugRecorder()
			checkpoints := &candidateCheckpointRecorder{CheckpointRecorder: NoopCheckpointRecorder()}
			output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug, Checkpoints: checkpoints})
			if err != nil {
				t.Fatalf("Run() error = %v", err)
			}
			if calls != tc.wantCalls {
				t.Fatalf("normalize calls = %d, want %d", calls, tc.wantCalls)
			}
			if checkpoints.normalizeSucceeded != tc.wantCheckpoint {
				t.Fatalf("normalize checkpoints = %d, want %d", checkpoints.normalizeSucceeded, tc.wantCheckpoint)
			}
			if len(output.Rejected) != tc.wantRejected {
				t.Fatalf("rejected outputs = %#v, want %d", output.Rejected, tc.wantRejected)
			}
			var retryDebug strings.Builder
			for _, name := range debug.names() {
				if strings.HasPrefix(name, "normalize/notes/attempt-") && strings.HasSuffix(name, ".json") {
					retryDebug.Write(debug.json[name])
				}
			}
			for _, fragment := range tc.wantDebug {
				if !strings.Contains(retryDebug.String(), fragment) {
					t.Fatalf("retry debug = %s, want %q", retryDebug.String(), fragment)
				}
			}
			if tc.wantRejected != 0 {
				if output.Rejected[0].AttemptCount != tc.wantCalls {
					t.Fatalf("rejection attempt count = %d, want %d", output.Rejected[0].AttemptCount, tc.wantCalls)
				}
				return
			}
			if len(output.NormalizeOutputs) != 1 {
				t.Fatalf("normalize outputs = %#v, want one", output.NormalizeOutputs)
			}
			decoded, err := lane.typed.codec.decode(output.NormalizeOutputs[0].Artifact.Content)
			if err != nil {
				t.Fatalf("decode normalized output: %v", err)
			}
			normalized, ok := decoded.(codecNotes)
			if !ok {
				t.Fatalf("decoded normalized output = %T, want codecNotes", decoded)
			}
			if got := firstNote(normalized); got != tc.wantItem {
				t.Fatalf("normalized item = %q, want %q", got, tc.wantItem)
			}
			var gotMessages []string
			for _, group := range output.Diagnostics.Groups {
				for _, sample := range group.Samples {
					gotMessages = append(gotMessages, sample.Message)
				}
			}
			if strings.Join(gotMessages, "|") != strings.Join(tc.wantMessages, "|") {
				t.Fatalf("durable diagnostics = %#v, want %#v", gotMessages, tc.wantMessages)
			}
		})
	}
}

func TestRunnerForwardsModuleRequestedNormalizeCorrection(t *testing.T) {
	const (
		defective = `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}`
		guidance  = "Duplicate group 1 must use only supplied candidate numbers. Return one complete corrected response."
	)
	prepared := preparedAttemptDebugPipeline(t)
	lane := &prepared.Steps[0].lanes[0]
	lane.resolved.Normalize.Retries = 1
	var observed *contracts.SemanticCorrection
	calls := 0
	lane.typed.normalize = func(_ context.Context, _ any, request contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
		calls++
		if request.Correction != nil {
			clone, err := contracts.CloneSemanticCorrection(request.Correction)
			if err != nil {
				return erasedTypedResult{}, err
			}
			observed = clone
		}
		if calls == 1 {
			return erasedTypedResult{
				Value:          codecNotes{Items: []string{"safe fallback"}},
				ModelCandidate: attemptCandidate(t, defective),
				Retry: &contracts.NormalizeRetry{
					ReasonCode:         "semantic_proposal_invalid",
					Message:            "operator-facing proposal diagnostic",
					CorrectionGuidance: guidance,
				},
			}, nil
		}
		return erasedTypedResult{Value: codecNotes{Items: []string{"corrected"}}}, nil
	}
	debug := newCapturedDebugRecorder()
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if calls != 2 || observed == nil || string(observed.AssistantResponse) != defective || observed.UserGuidance != guidance {
		t.Fatalf("normalize calls = %d correction = %#v", calls, observed)
	}
	if len(output.NormalizeOutputs) != 1 || len(output.Rejected) != 0 {
		t.Fatalf("run output = %#v, want corrected accepted output", output)
	}
	var retryDebug strings.Builder
	for _, name := range debug.names() {
		if strings.HasPrefix(name, "normalize/notes/attempt-") && strings.HasSuffix(name, ".json") {
			retryDebug.Write(debug.json[name])
		}
	}
	if !strings.Contains(retryDebug.String(), `"correction_available":true`) || strings.Contains(retryDebug.String(), guidance) || strings.Contains(retryDebug.String(), defective) {
		t.Fatalf("retry debug = %s, want safe correction metadata without content", retryDebug.String())
	}
}

func TestRunnerValidatesNormalizeRetryDiagnostics(t *testing.T) {
	const (
		reasonSentinel  = "reason-diagnostic-sentinel"
		messageSentinel = "message-diagnostic-sentinel"
	)
	reasonOverLimit := strings.Repeat("r", contracts.MaxNormalizeRetryReasonCodeBytes-len(reasonSentinel)) + reasonSentinel + "x"
	messageOverLimit := strings.Repeat("m", contracts.MaxNormalizeRetryMessageBytes-len(messageSentinel)) + messageSentinel + "x"
	guidanceOverLimit := strings.Repeat("g", contracts.MaxNormalizeRetryCorrectionGuidanceBytes-len(messageSentinel)) + messageSentinel + "x"
	tests := []struct {
		name         string
		retry        contracts.NormalizeRetry
		candidate    *contracts.ModelCandidate
		wantError    string
		hiddenValues []string
	}{
		{
			name: "accepts byte limits",
			retry: contracts.NormalizeRetry{
				ReasonCode: strings.Repeat("r", contracts.MaxNormalizeRetryReasonCodeBytes),
				Message:    strings.Repeat("m", contracts.MaxNormalizeRetryMessageBytes),
			},
		},
		{
			name: "accepts correction guidance byte limit with candidate",
			retry: contracts.NormalizeRetry{
				ReasonCode:         reasonSentinel,
				Message:            messageSentinel,
				CorrectionGuidance: strings.Repeat("g", contracts.MaxNormalizeRetryCorrectionGuidanceBytes),
			},
			candidate: attemptCandidate(t, `{"duplicate_groups":[]}`),
		},
		{
			name: "rejects oversized reason code",
			retry: contracts.NormalizeRetry{
				ReasonCode: reasonOverLimit,
				Message:    messageSentinel,
			},
			wantError:    "reason code exceeds maximum length",
			hiddenValues: []string{reasonSentinel, messageSentinel},
		},
		{
			name: "rejects oversized message",
			retry: contracts.NormalizeRetry{
				ReasonCode: reasonSentinel,
				Message:    messageOverLimit,
			},
			wantError:    "message exceeds maximum length",
			hiddenValues: []string{reasonSentinel, messageSentinel},
		},
		{
			name: "rejects invalid reason code UTF-8",
			retry: contracts.NormalizeRetry{
				ReasonCode: reasonSentinel + string([]byte{0xff}),
				Message:    messageSentinel,
			},
			wantError:    "reason code has invalid UTF-8",
			hiddenValues: []string{reasonSentinel, messageSentinel},
		},
		{
			name: "rejects invalid message UTF-8",
			retry: contracts.NormalizeRetry{
				ReasonCode: reasonSentinel,
				Message:    messageSentinel + string([]byte{0xff}),
			},
			wantError:    "message has invalid UTF-8",
			hiddenValues: []string{reasonSentinel, messageSentinel},
		},
		{
			name: "rejects oversized correction guidance",
			retry: contracts.NormalizeRetry{
				ReasonCode:         reasonSentinel,
				Message:            messageSentinel,
				CorrectionGuidance: guidanceOverLimit,
			},
			wantError:    "correction guidance exceeds maximum length",
			hiddenValues: []string{reasonSentinel, messageSentinel},
		},
		{
			name: "rejects invalid correction guidance UTF-8",
			retry: contracts.NormalizeRetry{
				ReasonCode:         reasonSentinel,
				Message:            messageSentinel,
				CorrectionGuidance: messageSentinel + string([]byte{0xff}),
			},
			wantError:    "correction guidance has invalid UTF-8",
			hiddenValues: []string{reasonSentinel, messageSentinel},
		},
		{
			name: "rejects blank correction guidance",
			retry: contracts.NormalizeRetry{
				ReasonCode:         reasonSentinel,
				Message:            messageSentinel,
				CorrectionGuidance: " \t\n ",
			},
			wantError:    "correction guidance is blank",
			hiddenValues: []string{reasonSentinel, messageSentinel},
		},
		{
			name: "rejects correction guidance without candidate",
			retry: contracts.NormalizeRetry{
				ReasonCode:         reasonSentinel,
				Message:            messageSentinel,
				CorrectionGuidance: "Return one complete corrected response.",
			},
			wantError:    "requires a model candidate",
			hiddenValues: []string{reasonSentinel, messageSentinel},
		},
		{
			name: "rejects blank reason code",
			retry: contracts.NormalizeRetry{
				ReasonCode: " \t\n ",
				Message:    messageSentinel,
			},
			wantError:    "reason code is blank",
			hiddenValues: []string{messageSentinel},
		},
		{
			name: "rejects blank message",
			retry: contracts.NormalizeRetry{
				ReasonCode: reasonSentinel,
				Message:    " \t\n ",
			},
			wantError:    "message is blank",
			hiddenValues: []string{reasonSentinel},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			prepared := preparedAttemptDebugPipeline(t)
			prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
				return erasedTypedResult{Value: codecNotes{Items: []string{"safe"}}, Retry: &tc.retry, ModelCandidate: tc.candidate}, nil
			}
			debug := newCapturedDebugRecorder()
			output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug})
			if tc.wantError == "" {
				if err != nil {
					t.Fatalf("Run() error = %v, want accepted retry fallback", err)
				}
				if len(output.NormalizeOutputs) != 1 {
					t.Fatalf("normalize outputs = %#v, want one accepted fallback", output.NormalizeOutputs)
				}
				return
			}
			if err == nil || !strings.Contains(err.Error(), tc.wantError) {
				t.Fatalf("Run() error = %v, want fixed %q error", err, tc.wantError)
			}
			var debugOutput strings.Builder
			for _, name := range debug.names() {
				debugOutput.Write(debug.json[name])
			}
			for _, value := range tc.hiddenValues {
				if strings.Contains(err.Error(), value) || strings.Contains(debugOutput.String(), value) {
					t.Fatalf("retry diagnostic leaked %q", value)
				}
			}
		})
	}
}

func retryableNormalizeResult(item, ordinary, fallback string) erasedTypedResult {
	return erasedTypedResult{
		Value:       codecNotes{Items: []string{item}},
		Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("attempt", ordinary)},
		Retry: &contracts.NormalizeRetry{
			ReasonCode:          "retryable_normalization",
			Message:             "safe fallback is available",
			FallbackDiagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("fallback", fallback)},
		},
	}
}
