package orchestrator

import (
	"context"
	"encoding/json"
	"errors"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

// TestDispatchToPersistenceToProjectionCarriesNormalizedPublicSafeFailure
// drives a failing effect dispatch through persistence and verifies that the
// persisted error_json carries only the normalized public-safe contract
// (code/stage/path/hint/retryable + WorkItem context), never an arbitrary
// cause, and that the store boundary accepts the exact payload.
func TestDispatchToPersistenceToProjectionCarriesNormalizedPublicSafeFailure(t *testing.T) {
	t.Parallel()

	now := time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)
	effect := claimedMessageEffect(t, now, false)
	effect.AttemptCount = 3 // terminal: max attempts already reached
	request, err := DecodeEffectRequest(effect.RequestJSON)
	require.NoError(t, err)
	request.Intent.StartThread = true
	request.Message.StartThread = true
	effect.RequestJSON, err = EncodeEffectRequest(request)
	require.NoError(t, err)

	parentScope := domain.MessageScopeValue{
		Kind: domain.MessageScopeKindChannel, ScopeID: "chn_abcdefghijklmn",
	}
	repository := &effectRepositoryStub{effect: effect}
	messages := &messageWriterStub{result: MessageResult{
		MessageID: "msg_root", DeliveryScope: &parentScope,
	}}
	dispatcher, err := NewEffectDispatcher(
		repository,
		messages,
		nil,
		nil,
		EffectDispatcherOptions{WorkerID: "effects", Now: func() time.Time { return now }, Responders: &responderResolverStub{agentID: "agt_a"}},
	)
	require.NoError(t, err)

	_, err = dispatcher.DispatchOnce(context.Background())
	require.NoError(t, err)
	require.Len(t, repository.resolutions, 1)
	resolved := repository.resolutions[0]
	require.Equal(t, run.EffectStatusFailed, resolved.Status)
	require.Equal(t, "message.delivery_failed", resolved.ErrorCode)
	require.NotNil(t, resolved.ErrorJSON)

	// Persistence boundary: the payload must survive the store's normalized
	// object validation and carry only the normalized safe shape.
	deterministic, err := run.DeterministicPayloadJSON("persisted workflow Run effect error", resolved.ErrorJSON, true)
	require.NoError(t, err)
	require.JSONEq(t, string(resolved.ErrorJSON), string(deterministic))

	var persisted struct {
		Code      string `json:"code"`
		Stage     string `json:"stage"`
		Path      string `json:"path"`
		Problem   string `json:"problem"`
		Hint      string `json:"hint"`
		Retryable bool   `json:"retryable"`
		Message   string `json:"message"`
	}
	require.NoError(t, json.Unmarshal(resolved.ErrorJSON, &persisted))
	require.Equal(t, "message.delivery_failed", persisted.Code)
	require.Equal(t, "dispatch", persisted.Stage)
	require.Equal(t, "notify", persisted.Path)
	require.Equal(t, runkernel.FailureProblem("message.delivery_failed"), persisted.Problem)
	require.Equal(t, runkernel.FailureHint("message.delivery_failed"), persisted.Hint)
	require.True(t, persisted.Retryable)
	require.Empty(t, persisted.Message, "arbitrary cause text must never be persisted")
	// The terminal dispatch failure must never leak the writer/validation cause
	// into the persisted error boundary.
	require.NotContains(t, string(resolved.ErrorJSON), "threadScope")
	require.NotContains(t, string(resolved.ErrorJSON), "required")
	// The durable effect ResultJSON carries the same stable hint and never the
	// arbitrary cause text.
	decoded, err := DecodeEffectResult(resolved.ResultJSON)
	require.NoError(t, err)
	require.Equal(t, runkernel.FailureHint("message.delivery_failed"), decoded.FailureMessage)
	require.Equal(t, runkernel.FailureHint("message.delivery_failed"), decoded.FailureHint)
	require.NotContains(t, string(resolved.ResultJSON), "threadScope")
}

// TestUnrecoverableFailureSnapshotPersistsNormalizedPublicSafeError ensures the
// deterministic failure snapshot never persists the arbitrary cause text.
func TestUnrecoverableFailureSnapshotPersistsNormalizedPublicSafeError(t *testing.T) {
	t.Parallel()

	run := run.Run{
		InputJSON: json.RawMessage(`{}`),
		StateJSON: json.RawMessage(`{"status":"running","input":{},"variables":{},"activations":[],"scopes":[],"nextActivation":1,"nextScope":0}`),
	}
	_, errorJSON, err := unrecoverableFailureSnapshot(run, "run.plan_version_invalid", sqlCause())
	require.NoError(t, err)
	require.NotNil(t, errorJSON)

	var persisted struct {
		Code    string `json:"code"`
		Stage   string `json:"stage"`
		Problem string `json:"problem"`
		Hint    string `json:"hint"`
		Message string `json:"message"`
	}
	require.NoError(t, json.Unmarshal(errorJSON, &persisted))
	require.Equal(t, "run.plan_version_invalid", persisted.Code)
	require.Equal(t, "material", persisted.Stage)
	require.Equal(t, runkernel.FailureProblem("run.plan_version_invalid"), persisted.Problem)
	require.Equal(t, "The exact Workflow PlanVersion is invalid", persisted.Hint)
	require.Empty(t, persisted.Message)
	require.NotContains(t, string(errorJSON), "SELECT")
	require.NotContains(t, string(errorJSON), "password")
	require.NotContains(t, string(errorJSON), "tenant")
}

func sqlCause() error {
	return errors.New("SELECT * FROM workflow_plan_versions WHERE tenant_id='acme' AND password='hunter2'")
}

// TestPersistedFailureTruncationMetadataAtExactLimit verifies that a persisted
// failure with exactly maxFailureDiagnostics diagnostics carries no truncation
// metadata (no cut fired), while an over-limit list carries count truncation
// metadata.
func TestPersistedFailureTruncationMetadataAtExactAndOverLimit(t *testing.T) {
	t.Parallel()

	exact := make([]PersistedDiagnostic, 0, maxFailureDiagnostics)
	for index := 0; index < maxFailureDiagnostics; index++ {
		exact = append(exact, PersistedDiagnostic{Code: "message.target_invalid", Field: "target", Message: "safe diagnostic"})
	}
	_, truncation := boundPersistedDiagnosticsCarried(exact, nil)
	require.Nil(t, truncation, "exactly at the count cap must not report a cut")

	over := append([]PersistedDiagnostic(nil), exact...)
	over = append(over, PersistedDiagnostic{Code: "extra", Message: "extra diagnostic"})
	bounded, truncation := boundPersistedDiagnosticsCarried(over, nil)
	require.NotNil(t, truncation, "over the count cap must report a cut")
	require.True(t, truncation.DiagnosticsTruncated)
	require.Equal(t, 1, truncation.OmittedCount)
	require.Len(t, bounded, maxFailureDiagnostics)
}

// TestPersistedFailureTruncationMetadataFieldCut verifies per-field rune cuts
// are reported with the truncated field names.
func TestPersistedFailureTruncationMetadataFieldCut(t *testing.T) {
	t.Parallel()
	longMessage := strings.Repeat("x", maxFailureMessageRunes+10)
	diagnostics := []PersistedDiagnostic{{Code: "message.target_invalid", Field: "target", Message: longMessage}}
	bounded, truncation := boundPersistedDiagnosticsCarried(diagnostics, nil)
	require.NotNil(t, truncation)
	require.True(t, truncation.DiagnosticsTruncated)
	require.Contains(t, truncation.TruncatedFields, "message")
	require.Len(t, bounded, 1)
	require.Equal(t, maxFailureMessageRunes, len([]rune(bounded[0].Message)))
}

// TestPersistedFailureTruncationMetadataSerializedCut verifies the serialized
// byte budget drops the tail of an over-large diagnostics list and reports the
// cut count.
func TestPersistedFailureTruncationMetadataSerializedCut(t *testing.T) {
	t.Parallel()
	// Each diagnostic is large; the combined serialized payload exceeds the
	// 32 KiB budget, so the encoder drops the tail.
	var diagnostics []PersistedDiagnostic
	for index := 0; index < 40; index++ {
		message := strings.Repeat("d", 2048) + strconv.Itoa(index)
		diagnostics = append(diagnostics, PersistedDiagnostic{Code: "message.target_invalid", Message: message})
	}
	bounded, truncation := boundPersistedDiagnosticsCarried(diagnostics, nil)
	require.NotNil(t, truncation)
	require.True(t, truncation.DiagnosticsTruncated)
	require.Greater(t, truncation.OmittedCount, 0)
	require.Less(t, len(bounded), len(diagnostics))
	require.Greater(t, truncation.SerializedBytes, 0)
	require.LessOrEqual(t, truncation.SerializedBytes, maxFailureSerializedBytes)
}

// TestPersistedFailureTruncationMetadataCarriesThroughProjection verifies the
// truncation metadata persisted in error_json survives the Run/effect/activation
// projection boundary via safeOperationalJSON.
func TestPersistedFailureTruncationMetadataCarriesThroughProjection(t *testing.T) {
	t.Parallel()
	over := make([]PersistedDiagnostic, 0, maxFailureDiagnostics+1)
	for index := 0; index < maxFailureDiagnostics+1; index++ {
		over = append(over, PersistedDiagnostic{Code: "message.target_invalid", Field: "target", Message: "safe diagnostic"})
	}
	payload, err := encodePersistedFailure(newPersistedFailure(
		"message.target_invalid", "dispatch", "notify",
		runkernel.FailureProblem("message.target_invalid"), runkernel.FailureHint("message.target_invalid"),
		false, over, nil, nil,
	))
	require.NoError(t, err)

	deterministic, err := run.DeterministicPayloadJSON("persisted workflow Run effect error", payload, true)
	require.NoError(t, err)
	require.JSONEq(t, string(payload), string(deterministic))

	var persisted struct {
		Truncation *struct {
			DiagnosticsTruncated bool `json:"diagnosticsTruncated"`
			OmittedCount         int  `json:"omittedCount"`
		} `json:"truncation"`
	}
	require.NoError(t, json.Unmarshal(deterministic, &persisted))
	require.NotNil(t, persisted.Truncation)
	require.True(t, persisted.Truncation.DiagnosticsTruncated)
	require.Equal(t, 1, persisted.Truncation.OmittedCount)
}

// TestProjectFailureDiagnosticsCarriesCompleteList verifies the projection no
// longer pre-caps the kernel diagnostics list: the complete list reaches the
// single budget at the persistence boundary, which reports the exact omitted
// count and serialized bytes instead of silently dropping the tail.
func TestProjectFailureDiagnosticsCarriesCompleteList(t *testing.T) {
	t.Parallel()
	kernelDiagnostics := make([]runkernel.FailureDiagnostic, 0, maxFailureDiagnostics+5)
	for index := 0; index < maxFailureDiagnostics+5; index++ {
		kernelDiagnostics = append(kernelDiagnostics, runkernel.FailureDiagnostic{
			Code: "message.target_invalid", Field: "target", Message: "safe diagnostic",
		})
	}
	projected := projectFailureDiagnostics(kernelDiagnostics)
	require.Len(t, projected, len(kernelDiagnostics), "projection must carry the complete kernel list without a count pre-cap")

	bounded, truncation := boundPersistedDiagnosticsCarried(projected, nil)
	require.Len(t, bounded, maxFailureDiagnostics)
	require.NotNil(t, truncation)
	require.True(t, truncation.DiagnosticsTruncated)
	require.Equal(t, 5, truncation.OmittedCount, "the single budget must report the true omitted count from the complete list")
	require.Greater(t, truncation.SerializedBytes, 0)
	require.LessOrEqual(t, truncation.SerializedBytes, maxFailureSerializedBytes)
}

// TestKernelFailureDiagnosticsCarriesCompletePersistedList verifies the reverse
// conversion also carries the complete persisted list (no count pre-cap), so
// replay round trips are lossless and deterministic.
func TestKernelFailureDiagnosticsCarriesCompletePersistedList(t *testing.T) {
	t.Parallel()
	persisted := make([]PersistedDiagnostic, 0, maxFailureDiagnostics)
	for index := 0; index < maxFailureDiagnostics; index++ {
		persisted = append(persisted, PersistedDiagnostic{Code: "message.target_invalid", Field: "target", Message: "safe diagnostic"})
	}
	converted := kernelFailureDiagnostics(persisted)
	require.Len(t, converted, len(persisted), "the reverse conversion must carry the complete persisted list")
	for index := range persisted {
		require.Equal(t, persisted[index].Code, converted[index].Code)
		require.Equal(t, persisted[index].Field, converted[index].Field)
		require.Equal(t, persisted[index].Message, converted[index].Message)
	}
}

// TestEffectResultEmptyDiagnosticsCarriesPriorTruncationThroughResultJSON
// verifies a durable EffectResult whose diagnostics list is empty (all-items
// dropped at an earlier persistence boundary) still carries the prior truncation
// metadata through EncodeEffectResult/DecodeEffectResult so recovery/replay
// preserves omittedCount/truncatedFields/serializedBytes instead of recomputing
// them away, without fabricating diagnostics.
func TestEffectResultEmptyDiagnosticsCarriesPriorTruncationThroughResultJSON(t *testing.T) {
	t.Parallel()
	prior := &PersistedTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         7,
		TruncatedFields:      []string{"message", "path"},
		SerializedBytes:      1500,
	}
	result := EffectResult{
		FailureCode:        "run.plan_version_invalid",
		FailureDiagnostics: nil,
		FailureTruncation:  prior,
	}
	encoded, err := EncodeEffectResult(result)
	require.NoError(t, err)
	require.NotContains(t, string(encoded), `"failureDiagnostics":[`, "empty diagnostics must not fabricate a diagnostics array")

	decoded, err := DecodeEffectResult(encoded)
	require.NoError(t, err)
	require.Empty(t, decoded.FailureDiagnostics, "empty diagnostics round-trip must stay empty")
	require.NotNil(t, decoded.FailureTruncation, "prior truncation must survive the ResultJSON round trip")
	require.Equal(t, 7, decoded.FailureTruncation.OmittedCount)
	require.Equal(t, []string{"message", "path"}, decoded.FailureTruncation.TruncatedFields)
	require.Equal(t, 1500, decoded.FailureTruncation.SerializedBytes)
}

// TestEffectResultEmptyDiagnosticsCarriesPriorTruncationThroughErrorJSON
// verifies the persisted ErrorJSON (failureJSON/newPersistedFailure) preserves
// prior truncation metadata when the diagnostics list is empty, without
// fabricating diagnostics.
func TestEffectResultEmptyDiagnosticsCarriesPriorTruncationThroughErrorJSON(t *testing.T) {
	t.Parallel()
	prior := &PersistedTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         4,
		TruncatedFields:      []string{"field"},
		SerializedBytes:      900,
	}
	result := EffectResult{
		FailureCode:        "message.target_invalid",
		FailureDiagnostics: nil,
		FailureTruncation:  prior,
	}
	errorJSON := failureJSON(result)
	require.NotNil(t, errorJSON)
	require.NotContains(t, string(errorJSON), `"diagnostics":[`, "empty diagnostics must not fabricate a diagnostics array")

	var persisted struct {
		Code       string `json:"code"`
		Truncation *struct {
			DiagnosticsTruncated bool     `json:"diagnosticsTruncated"`
			OmittedCount         int      `json:"omittedCount"`
			TruncatedFields      []string `json:"truncatedFields"`
			SerializedBytes      int      `json:"serializedBytes"`
		} `json:"truncation"`
	}
	require.NoError(t, json.Unmarshal(errorJSON, &persisted))
	require.Equal(t, "message.target_invalid", persisted.Code)
	require.NotNil(t, persisted.Truncation, "ErrorJSON must carry prior truncation for an empty diagnostics list")
	require.True(t, persisted.Truncation.DiagnosticsTruncated)
	require.Equal(t, 4, persisted.Truncation.OmittedCount)
	require.Equal(t, []string{"field"}, persisted.Truncation.TruncatedFields)
	require.Equal(t, 900, persisted.Truncation.SerializedBytes)
}

// TestPersistedFailureEmptyDiagnosticsCarriesPriorTruncation verifies the
// persistence boundary (newPersistedFailure) carries a prior truncation report
// forward when the diagnostics list is empty, without double-budgeting or
// fabricating diagnostics.
func TestPersistedFailureEmptyDiagnosticsCarriesPriorTruncation(t *testing.T) {
	t.Parallel()
	prior := &PersistedTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         5,
		TruncatedFields:      []string{"path"},
		SerializedBytes:      640,
	}
	payload, err := encodePersistedFailure(newPersistedFailure(
		"message.target_invalid", "dispatch", "notify",
		runkernel.FailureProblem("message.target_invalid"), runkernel.FailureHint("message.target_invalid"),
		false, nil, nil, prior,
	))
	require.NoError(t, err)

	var persisted struct {
		Diagnostics []PersistedDiagnostic `json:"diagnostics"`
		Truncation  *PersistedTruncation  `json:"truncation"`
	}
	require.NoError(t, json.Unmarshal(payload, &persisted))
	require.Empty(t, persisted.Diagnostics, "empty diagnostics must not fabricate diagnostics")
	require.NotNil(t, persisted.Truncation, "prior truncation must survive an empty diagnostics list")
	require.Equal(t, 5, persisted.Truncation.OmittedCount)
	require.Equal(t, []string{"path"}, persisted.Truncation.TruncatedFields)
	require.Equal(t, 640, persisted.Truncation.SerializedBytes)
}

// TestFailureEventAndCompletionRoundTripPreservesEmptyListTruncation verifies
// the kernel FailureEvent and Completion round-trip both carry prior truncation
// metadata forward when the diagnostics list is empty, so the reducer Event and
// the effect completion/replay surfaces agree.
func TestFailureEventAndCompletionRoundTripPreservesEmptyListTruncation(t *testing.T) {
	t.Parallel()
	prior := &runkernel.FailureTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         2,
		TruncatedFields:      []string{"message"},
		SerializedBytes:      300,
	}
	failure := &runkernel.Failure{
		Code:        "run.plan_version_invalid",
		Stage:       "material",
		Diagnostics: nil,
		Truncation:  prior,
	}
	event := runkernel.FailureEvent("run_failed", failure, "", "", "")
	require.Empty(t, event.Diagnostics)
	require.NotNil(t, event.Truncation)
	require.Equal(t, 2, event.Truncation.OmittedCount)

	// Completion round-trip: a kernel Failure with empty diagnostics and prior
	// truncation projects into EffectResult then back through the budget.
	completion := runkernel.Completion{
		ActivationID: "wra_1",
		Failure:      failure,
	}
	require.Equal(t, "run.plan_version_invalid", completion.Failure.Code)

	result := EffectResult{
		FailureCode:        "run.plan_version_invalid",
		FailureDiagnostics: nil,
		FailureTruncation:  &PersistedTruncation{OmittedCount: 2, TruncatedFields: []string{"message"}, SerializedBytes: 300},
	}
	encoded, err := EncodeEffectResult(result)
	require.NoError(t, err)
	decoded, err := DecodeEffectResult(encoded)
	require.NoError(t, err)
	require.Empty(t, decoded.FailureDiagnostics)
	require.NotNil(t, decoded.FailureTruncation)
	require.Equal(t, 2, decoded.FailureTruncation.OmittedCount)
}

// TestDispatcherAndCallbackEventsCarryEmptyListTruncation verifies the
// dispatcher and callback event projections both carry prior truncation metadata
// forward when the diagnostics list is empty, preserving omittedCount/
// truncatedFields/serializedBytes without fabricating diagnostics.
func TestDispatcherAndCallbackEventsCarryEmptyListTruncation(t *testing.T) {
	t.Parallel()
	now := time.Date(2026, 7, 22, 9, 0, 0, 0, time.UTC)
	effect := claimedMessageEffect(t, now, false)
	request, err := DecodeEffectRequest(effect.RequestJSON)
	require.NoError(t, err)
	result := EffectResult{
		FailureCode:        "message.target_invalid",
		FailureStage:       "dispatch",
		FailurePath:        "notify",
		FailureProblem:     runkernel.FailureProblem("message.target_invalid"),
		FailureHint:        runkernel.FailureHint("message.target_invalid"),
		FailureDiagnostics: nil,
		FailureTruncation: &PersistedTruncation{
			DiagnosticsTruncated: true,
			OmittedCount:         6,
			TruncatedFields:      []string{"message"},
			SerializedBytes:      1100,
		},
	}
	event := failureEffectEvent("effect_failed", effect, request, result, nil)
	var payload struct {
		Truncation *PersistedTruncation `json:"truncation"`
	}
	require.NoError(t, json.Unmarshal(event.PayloadJSON, &payload))
	require.NotNil(t, payload.Truncation, "dispatcher event must carry prior truncation for empty diagnostics")
	require.Equal(t, 6, payload.Truncation.OmittedCount)
	require.Equal(t, []string{"message"}, payload.Truncation.TruncatedFields)
	require.Equal(t, 1100, payload.Truncation.SerializedBytes)
	require.NotContains(t, string(event.PayloadJSON), `"diagnostics":`, "dispatcher event must not fabricate diagnostics")
}

// TestBoundPersistedDiagnosticsCarriedEmptyListPreservesPrior verifies the
// persisted shared carried-budget primitive preserves prior truncation for an
// empty diagnostics list and returns nil for a nil prior.
func TestBoundPersistedDiagnosticsCarriedEmptyListPreservesPrior(t *testing.T) {
	t.Parallel()
	prior := &PersistedTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         3,
		TruncatedFields:      []string{"field"},
		SerializedBytes:      512,
	}
	bounded, truncation := boundPersistedDiagnosticsCarried(nil, prior)
	require.Empty(t, bounded)
	require.NotNil(t, truncation, "prior truncation must survive an empty persisted list")
	require.Equal(t, 3, truncation.OmittedCount)

	bounded, truncation = boundPersistedDiagnosticsCarried(nil, nil)
	require.Empty(t, bounded)
	require.Nil(t, truncation, "no diagnostics and no prior must stay nil,nil")
}
