package run

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

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

// TestFailureEventCarriesNormalizedSafeContract verifies the reducer failure
// event projection carries only the normalized safe failure fields
// (code/stage/path/problem/hint/retryable/workItem/diagnostics) and never the
// raw authoring message, so the persisted event payload and its Run log
// Details projection preserve every field the Agent/CLI needs.
func TestFailureEventCarriesNormalizedSafeContract(t *testing.T) {
	index := 2
	failure := &Failure{
		Code: "run.work_item_dependency_failed", Message: "raw cause password=hunter2 tenant=acme",
		Stage: "reducer", Path: "fanOut.items[2].dependencyPath[0]",
		Problem: "A WorkItem dependency failed", Hint: "Fix the dependency path.",
		Retryable: true,
		WorkItem:  &FailureWorkItem{Key: "item-c", Index: &index, DependencyPath: "fanOut.items[2].dependsOn[0]"},
		Diagnostics: []FailureDiagnostic{{
			Code: "work_item.dependency_unknown", Path: "fanOut.items[2].dependsOn[0]",
			Field: "dependsOn", Index: &index, Message: "dependency key is unknown",
		}},
	}
	event := FailureEvent("fan_out_item_failed", failure, "fanout", "wra_1", "wnd_1")
	require.Equal(t, "fan_out_item_failed", event.Kind)
	require.Equal(t, "fanout", event.ScopeID)
	require.Equal(t, "wra_1", event.ActivationID)
	require.Equal(t, "wnd_1", event.NodeID)
	require.Equal(t, "run.work_item_dependency_failed", event.Code)
	require.Equal(t, "reducer", event.Stage)
	require.Equal(t, "fanOut.items[2].dependencyPath[0]", event.Path)
	require.Equal(t, "A WorkItem dependency failed", event.Problem)
	require.Equal(t, "Fix the dependency path.", event.Hint)
	require.True(t, event.Retryable)
	require.NotNil(t, event.WorkItem)
	require.Equal(t, "item-c", event.WorkItem.Key)
	require.NotNil(t, event.WorkItem.Index)
	require.Equal(t, index, *event.WorkItem.Index)
	require.Equal(t, "fanOut.items[2].dependsOn[0]", event.WorkItem.DependencyPath)
	require.Len(t, event.Diagnostics, 1)
	require.Equal(t, "work_item.dependency_unknown", event.Diagnostics[0].Code)
	require.Equal(t, "dependency key is unknown", event.Diagnostics[0].Message)

	// The raw authoring message never crosses into the event payload; only the
	// safe bounded diagnostic message may appear.
	encoded, err := json.Marshal(event)
	require.NoError(t, err)
	require.NotContains(t, string(encoded), "password=hunter2")
	require.NotContains(t, string(encoded), "tenant=acme")
	require.NotContains(t, string(encoded), "raw cause")
	require.Contains(t, string(encoded), "dependency key is unknown")
	// The safe normalized contract survives verbatim.
	require.Contains(t, string(encoded), `"code":"run.work_item_dependency_failed"`)
	require.Contains(t, string(encoded), `"stage":"reducer"`)
	require.Contains(t, string(encoded), `"path":"fanOut.items[2].dependencyPath[0]"`)
	require.Contains(t, string(encoded), `"workItem":`)
	require.Contains(t, string(encoded), `"diagnostics":`)
}

// TestFailureEventAppliesSingleDiagnosticBudget verifies the kernel FailureEvent
// budgets the complete diagnostics list exactly once at the event public
// boundary (count cap, per-field rune caps, serialized-size cap) and carries the
// deterministic truncation metadata (omittedCount, truncatedFields,
// serializedBytes) on the Event payload instead of unbounded copying, so the
// persisted event and its Run log Details projection report the same cuts as
// error_json.
func TestFailureEventAppliesSingleDiagnosticBudget(t *testing.T) {
	over := make([]FailureDiagnostic, 0, MaxFailureDiagnostics+3)
	for index := 0; index < MaxFailureDiagnostics+3; index++ {
		over = append(over, FailureDiagnostic{Code: "message.target_invalid", Field: "target", Message: "safe diagnostic"})
	}
	failure := &Failure{
		Code:        "message.target_invalid",
		Stage:       "reducer",
		Problem:     "The message target is invalid",
		Hint:        "The message target is invalid",
		Diagnostics: over,
	}
	event := FailureEvent("branch_failed", failure, "wsc_1", "wra_1", "wnd_1")
	require.Len(t, event.Diagnostics, MaxFailureDiagnostics)
	require.NotNil(t, event.Truncation, "over the count cap must report a cut")
	require.True(t, event.Truncation.DiagnosticsTruncated)
	require.Equal(t, 3, event.Truncation.OmittedCount)
	require.Greater(t, event.Truncation.SerializedBytes, 0)
	// The truncated event payload still serializes deterministically and never
	// carries the raw authoring message.
	encoded, err := json.Marshal(event)
	require.NoError(t, err)
	require.Contains(t, string(encoded), `"truncation":`)
	require.Contains(t, string(encoded), `"omittedCount":3`)
	require.NotContains(t, string(encoded), "raw cause")
}

// TestFailureEventCarriesTruncatedFieldsForLongDiagnostic verifies a per-field
// rune cut is reported in the Event truncation metadata.
func TestFailureEventCarriesTruncatedFieldsForLongDiagnostic(t *testing.T) {
	longMessage := strings.Repeat("x", maxFailureDiagnosticMessageRunes+10)
	failure := &Failure{
		Code:        "message.target_invalid",
		Diagnostics: []FailureDiagnostic{{Code: "message.target_invalid", Field: "target", Message: longMessage}},
	}
	event := FailureEvent("run_failed", failure, "", "", "")
	require.Len(t, event.Diagnostics, 1)
	require.NotNil(t, event.Truncation)
	require.True(t, event.Truncation.DiagnosticsTruncated)
	require.Contains(t, event.Truncation.TruncatedFields, "message")
	require.Equal(t, maxFailureDiagnosticMessageRunes, len([]rune(event.Diagnostics[0].Message)))
}

// TestFailureEventCarriesNoTruncationAtExactLimit verifies exactly
// MaxFailureDiagnostics diagnostics (all within per-field and serialized caps)
// report no cut, so the presence of truncation metadata itself signals a cut.
func TestFailureEventCarriesNoTruncationAtExactLimit(t *testing.T) {
	exact := make([]FailureDiagnostic, 0, MaxFailureDiagnostics)
	for index := 0; index < MaxFailureDiagnostics; index++ {
		exact = append(exact, FailureDiagnostic{Code: "message.target_invalid", Field: "target", Message: "safe diagnostic"})
	}
	failure := &Failure{Code: "message.target_invalid", Diagnostics: exact}
	event := FailureEvent("run_failed", failure, "", "", "")
	require.Len(t, event.Diagnostics, MaxFailureDiagnostics)
	require.Nil(t, event.Truncation, "exactly at the count cap with no serialized cut must not report a cut")
}

// TestFailureEventCarriesPriorTruncationAcrossReplay verifies that a kernel
// Failure already carrying truncation metadata (from an earlier persistence
// boundary) preserves the exact omittedCount/truncatedFields/serializedBytes
// when the already-bounded diagnostics are re-projected through FailureEvent,
// instead of recomputing them away to nil because the list is now within budget.
func TestFailureEventCarriesPriorTruncationAcrossReplay(t *testing.T) {
	// Simulate an earlier persistence boundary that bounded 20 diagnostics to
	// MaxFailureDiagnostics and recorded the loss.
	bounded := make([]FailureDiagnostic, 0, MaxFailureDiagnostics)
	for index := 0; index < MaxFailureDiagnostics; index++ {
		bounded = append(bounded, FailureDiagnostic{Code: "message.target_invalid", Field: "target", Message: "safe diagnostic"})
	}
	prior := &FailureTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         4,
		TruncatedFields:      []string{"message"},
		SerializedBytes:      1234,
	}
	failure := &Failure{
		Code:        "message.target_invalid",
		Stage:       "reducer",
		Diagnostics: bounded,
		Truncation:  prior,
	}
	event := FailureEvent("run_failed", failure, "", "", "")
	require.Len(t, event.Diagnostics, MaxFailureDiagnostics)
	require.NotNil(t, event.Truncation, "prior truncation must survive replay")
	require.True(t, event.Truncation.DiagnosticsTruncated)
	require.Equal(t, 4, event.Truncation.OmittedCount, "earlier omitted count must not be recomputed away")
	require.Equal(t, 1234, event.Truncation.SerializedBytes, "earlier serialized bytes must not be recomputed away")
	require.Equal(t, []string{"message"}, event.Truncation.TruncatedFields)
}

// TestFailureEventCarriesPriorAndFreshTruncationMerged verifies that when a
// prior truncation report and a fresh cut both exist, the carried projection
// accumulates the earlier loss (counts summed, fields unioned) rather than
// dropping it.
func TestFailureEventCarriesPriorAndFreshTruncationMerged(t *testing.T) {
	prior := &FailureTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         2,
		TruncatedFields:      []string{"path"},
		SerializedBytes:      500,
	}
	over := make([]FailureDiagnostic, 0, MaxFailureDiagnostics+1)
	for index := 0; index < MaxFailureDiagnostics+1; index++ {
		over = append(over, FailureDiagnostic{Code: "message.target_invalid", Field: "target", Message: "safe diagnostic"})
	}
	failure := &Failure{
		Code:        "message.target_invalid",
		Diagnostics: over,
		Truncation:  prior,
	}
	event := FailureEvent("run_failed", failure, "", "", "")
	require.NotNil(t, event.Truncation)
	require.True(t, event.Truncation.DiagnosticsTruncated)
	require.Equal(t, 3, event.Truncation.OmittedCount, "prior + fresh omitted counts must accumulate")
	require.Contains(t, event.Truncation.TruncatedFields, "path")
}

// TestFailureEventCarriesPriorTruncationWithoutFreshCut verifies a prior
// truncation report survives exactly when the re-projected list is well within
// budget and the fresh bound fires no cut at all.
func TestFailureEventCarriesPriorTruncationWithoutFreshCut(t *testing.T) {
	prior := &FailureTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         9,
		TruncatedFields:      []string{"code"},
		SerializedBytes:      88,
	}
	failure := &Failure{
		Code:        "message.target_invalid",
		Diagnostics: []FailureDiagnostic{{Code: "message.target_invalid", Field: "target", Message: "safe"}},
		Truncation:  prior,
	}
	event := FailureEvent("run_failed", failure, "", "", "")
	require.NotNil(t, event.Truncation)
	require.True(t, event.Truncation.DiagnosticsTruncated)
	require.Equal(t, 9, event.Truncation.OmittedCount)
	require.Equal(t, 88, event.Truncation.SerializedBytes)
	require.Equal(t, []string{"code"}, event.Truncation.TruncatedFields)
}

// TestFailureEventCarriesPriorTruncationThroughEmptyList verifies a prior
// truncation report survives a replayed failure whose diagnostics list is empty
// (for example after an all-items-dropped serialized-size cut or an imported
// payload that carried no diagnostics). The event must preserve the earlier
// omittedCount/truncatedFields/serializedBytes without fabricating diagnostics.
func TestFailureEventCarriesPriorTruncationThroughEmptyList(t *testing.T) {
	prior := &FailureTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         12,
		TruncatedFields:      []string{"path", "message"},
		SerializedBytes:      2048,
	}
	failure := &Failure{
		Code:        "run.plan_version_invalid",
		Stage:       "material",
		Diagnostics: nil,
		Truncation:  prior,
	}
	event := FailureEvent("run_failed", failure, "", "", "")
	require.Empty(t, event.Diagnostics, "empty diagnostics list must not fabricate diagnostics")
	require.NotNil(t, event.Truncation, "prior truncation must survive an empty diagnostics list")
	require.True(t, event.Truncation.DiagnosticsTruncated)
	require.Equal(t, 12, event.Truncation.OmittedCount)
	require.Equal(t, []string{"path", "message"}, event.Truncation.TruncatedFields)
	require.Equal(t, 2048, event.Truncation.SerializedBytes)
}

// TestBoundFailureDiagnosticsCarriedEmptyListPreservesPrior verifies the shared
// carried-budget primitive itself: an empty diagnostics list with a prior report
// yields an empty bounded list plus the prior report, and a fully-empty input
// with no prior yields nil, nil.
func TestBoundFailureDiagnosticsCarriedEmptyListPreservesPrior(t *testing.T) {
	prior := &FailureTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         3,
		TruncatedFields:      []string{"field"},
		SerializedBytes:      512,
	}
	bounded, truncation := BoundFailureDiagnosticsCarried(nil, prior)
	require.Empty(t, bounded)
	require.NotNil(t, truncation, "prior truncation must survive an empty input list")
	require.Equal(t, 3, truncation.OmittedCount)
	require.Equal(t, []string{"field"}, truncation.TruncatedFields)
	require.Equal(t, 512, truncation.SerializedBytes)

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

// TestBoundFailureDiagnosticsAllDroppedToEmptyCarriesPrior verifies the
// carried-budget primitive accumulates a prior truncation report onto the fresh
// cut when the serialized-size budget drops the entire fresh list, preserving
// the earlier omittedCount/truncatedFields without double-budgeting the prior
// loss or fabricating diagnostics.
func TestBoundFailureDiagnosticsAllDroppedToEmptyCarriesPrior(t *testing.T) {
	prior := &FailureTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         2,
		TruncatedFields:      []string{"message"},
		SerializedBytes:      300,
	}
	// A single diagnostic that, even after field caps, serializes larger than the
	// whole-array byte budget forces the serialized-size loop to drop every item.
	oversized := FailureDiagnostic{
		Code:    strings.Repeat("c", maxFailureDiagnosticFieldRunes),
		Path:    strings.Repeat("p", maxFailureDiagnosticPathRunes),
		Field:   strings.Repeat("f", maxFailureDiagnosticFieldRunes),
		Message: strings.Repeat("m", maxFailureDiagnosticMessageRunes),
	}
	// The count cap keeps up to MaxFailureDiagnostics items; a single oversized
	// item cannot exceed the budget alone, so fill the count cap.
	var diagnostics []FailureDiagnostic
	for index := 0; index < MaxFailureDiagnostics; index++ {
		diagnostics = append(diagnostics, oversized)
	}
	bounded, truncation := BoundFailureDiagnosticsCarried(diagnostics, prior)
	require.Less(t, len(bounded), len(diagnostics), "serialized-size budget must drop the tail")
	require.NotNil(t, truncation)
	require.True(t, truncation.DiagnosticsTruncated)
	require.GreaterOrEqual(t, truncation.OmittedCount, 2, "prior omitted count must not be recomputed away")
	require.Contains(t, truncation.TruncatedFields, "message")
}
