package publicerr

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

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

func TestBudgetErrorEnvelopeWithinCeilingIsUnchanged(t *testing.T) {
	payload := map[string]any{
		"error": "compile failed", "code": "plan_compile_failed", "stage": "plan",
		"diagnostics": []Diagnostic{{Severity: SeverityError, Code: "plan.test", Message: "m"}},
	}
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: 64 * 1024})
	require.Nil(t, truncation, "within-ceiling envelope must not report truncation")
	require.Equal(t, payload, bounded)
	require.Len(t, bounded["diagnostics"], 1)
}

// TestBudgetErrorEnvelopeCarriesPriorTruncationThroughEmptyDiagnostics verifies
// the transport envelope budget merges a prior diagnostics-level truncation
// report forward even when the envelope carries no diagnostics (an all-dropped
// list), preserving omittedCount/truncatedFields/serializedBytes without
// fabricating diagnostics or double-budgeting.
func TestBudgetErrorEnvelopeCarriesPriorTruncationThroughEmptyDiagnostics(t *testing.T) {
	payload := map[string]any{
		"error": "run failed", "code": "run.plan_version_invalid", "stage": "material",
		"diagnostics": []Diagnostic{},
		"truncation": &Truncation{
			DiagnosticsTruncated: true,
			OmittedCount:         4,
			TruncatedFields:      []string{"path"},
			SerializedBytes:      900,
		},
	}
	// A large problem text forces an envelope-level reduction, so the merged
	// truncation report must combine the prior diagnostics cut with the fresh
	// envelope cut.
	payload["problem"] = strings.Repeat("p", 64*1024)
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: 32 * 1024})
	require.NotNil(t, truncation)
	require.True(t, truncation.EnvelopeTruncated)
	require.True(t, truncation.DiagnosticsTruncated, "prior diagnostics truncation must survive the envelope budget")
	require.GreaterOrEqual(t, truncation.OmittedCount, 4, "prior omitted count must not be recomputed away")
	require.Contains(t, truncation.TruncatedFields, "path")
	require.NotNil(t, bounded["truncation"], "the bounded envelope must carry the merged truncation report")
	// The payload is mutated copy-on-write; the original diagnostics truncation
	// report stays untouched.
	require.NotNil(t, payload["truncation"])
}

func TestBudgetErrorEnvelopeDropsDetailsFirst(t *testing.T) {
	// A details envelope is dropped before diagnostics because the public
	// contract fields are top-level. The details envelope carries a large array
	// that alone exceeds the ceiling, so it is dropped first.
	large := make([]Diagnostic, 0, 400)
	for i := 0; i < 400; i++ {
		large = append(large, Diagnostic{
			Severity: SeverityError, Code: "plan.test", Path: "$.n",
			Message: strings.Repeat("m", maxServerDiagnosticMessageRunes),
		})
	}
	payload := map[string]any{
		"error": "compile failed", "code": "plan_compile_failed", "stage": "plan",
		"details": map[string]any{"diagnostics": large},
	}
	// Ceiling large enough to keep the top-level fields but small enough that the
	// details envelope must be dropped.
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: 60 * 1024})
	require.NotNil(t, truncation)
	require.True(t, truncation.EnvelopeTruncated)
	require.NotContains(t, bounded, "details")
	require.Contains(t, truncation.TruncatedFields, "details")
}

func TestBudgetErrorEnvelopeCopyOnWriteNeverMutatesInput(t *testing.T) {
	diagnostics := make([]Diagnostic, 0, MaxServerDiagnostics)
	for i := 0; i < MaxServerDiagnostics; i++ {
		diagnostics = append(diagnostics, Diagnostic{
			Severity: SeverityError, Code: "plan.test", Path: "$.n",
			Message: strings.Repeat("m", maxServerDiagnosticMessageRunes),
		})
	}
	payload := map[string]any{
		"error": "compile failed", "code": "plan_compile_failed", "stage": "plan",
		"details": map[string]any{"resource": "planDraft"}, "diagnostics": diagnostics,
	}
	detailsBefore := payload["details"].(map[string]any)
	diagnosticsBefore := payload["diagnostics"].([]Diagnostic)
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: 60 * 1024})
	require.NotNil(t, truncation)
	require.NotEqual(t, payload, bounded, "the input payload must never be mutated in place")
	require.Equal(t, "planDraft", payload["details"].(map[string]any)["resource"], "the caller's details map must not be mutated")
	require.Len(t, payload["diagnostics"].([]Diagnostic), len(diagnosticsBefore))
	require.Len(t, detailsBefore, 1)
}

func TestBudgetErrorEnvelopeUTF8SafeAndTruncatesStrings(t *testing.T) {
	// A pathological hint/path that alone exceeds the ceiling must be rune-capped
	// deterministically while remaining valid UTF-8.
	long := strings.Repeat("世", 60*1024)
	payload := map[string]any{"error": "failed", "code": "plan_compile_failed", "hint": long}
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: 60 * 1024})
	require.NotNil(t, truncation)
	require.True(t, truncation.EnvelopeTruncated)
	hint, ok := bounded["hint"].(string)
	require.True(t, ok)
	require.True(t, utf8.ValidString(hint))
	require.LessOrEqual(t, len([]rune(hint)), maxServerDiagnosticFieldRunes)
}

func TestBudgetErrorEnvelopeMinimalFallbackPreservesCode(t *testing.T) {
	// Even a pathological body that cannot be reduced below the ceiling still
	// carries a stable code and an explicit truncation report.
	payload := map[string]any{
		"error": strings.Repeat("x", 10*60*1024),
		"code":  "plan_compile_failed",
	}
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: 60 * 1024, DefaultProblem: "Plan compile failed"})
	require.NotNil(t, truncation)
	require.Equal(t, "plan_compile_failed", bounded["code"])
	require.NotNil(t, bounded["truncation"])
	require.True(t, utf8.ValidString(bounded["error"].(string)))
}

func TestBudgetErrorEnvelopeMergesExistingTruncation(t *testing.T) {
	diagnostics := make([]Diagnostic, 0, MaxServerDiagnostics)
	for i := 0; i < MaxServerDiagnostics; i++ {
		diagnostics = append(diagnostics, Diagnostic{
			Severity: SeverityError, Code: "plan.test", Path: "$.n",
			Message: strings.Repeat("m", maxServerDiagnosticMessageRunes),
		})
	}
	prior := &Truncation{DiagnosticsTruncated: true, OmittedCount: 3, TruncatedFields: []string{"message"}}
	payload := map[string]any{
		"error": "compile failed", "code": "plan_compile_failed", "stage": "plan",
		"diagnostics": diagnostics, "truncation": prior,
	}
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: 60 * 1024})
	require.NotNil(t, bounded)
	require.NotNil(t, truncation)
	require.True(t, truncation.EnvelopeTruncated)
	require.True(t, truncation.DiagnosticsTruncated, "prior diagnostics truncation must survive")
	require.GreaterOrEqual(t, truncation.OmittedCount, 3)
	require.Contains(t, truncation.TruncatedFields, "message")
}

func TestBudgetErrorEnvelopeSerializedBytesIsFinalFixedPoint(t *testing.T) {
	// SerializedBytes must report the byte length of the ACTUAL final JSON
	// envelope, including the truncation metadata object itself and any encoder
	// escaping, never a stale pre-metadata measurement. Re-marshaling the
	// returned envelope must reproduce the reported size exactly.
	diagnostics := make([]Diagnostic, 0, MaxServerDiagnostics)
	for i := 0; i < MaxServerDiagnostics; i++ {
		diagnostics = append(diagnostics, Diagnostic{
			Severity: SeverityError, Code: "plan.test", Path: "$.n",
			Message: strings.Repeat("m", maxServerDiagnosticMessageRunes),
		})
	}
	payload := map[string]any{
		"error": "compile failed", "code": "plan_compile_failed", "stage": "plan",
		"details": map[string]any{"resource": "planDraft"}, "diagnostics": diagnostics,
	}
	for _, ceiling := range []int{32 * 1024, 60 * 1024} {
		bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: ceiling})
		require.NotNil(t, bounded)
		require.NotNil(t, truncation)
		require.True(t, truncation.EnvelopeTruncated)
		encoded, err := json.Marshal(bounded)
		require.NoError(t, err)
		require.Equal(t, len(encoded), truncation.SerializedBytes,
			"SerializedBytes must equal the actual final serialized envelope length")
		require.LessOrEqual(t, len(encoded), ceiling,
			"the final serialized envelope must stay within the ceiling after metadata growth")
	}
}

func TestBudgetErrorEnvelopeExactBoundaryKeepsPayloadWithinCeiling(t *testing.T) {
	// A payload that is exactly at the ceiling without any truncation metadata
	// must be returned unchanged (no cut needed), while a payload one byte over
	// the ceiling must be reduced so the FINAL envelope (with truncation
	// metadata attached) still fits the ceiling.
	ceiling := 100
	// Build the payload so the serialized size is exactly the ceiling. The
	// error text is padded one rune at a time (each 'e' adds exactly one byte to
	// the JSON string) until the marshaled payload reaches the target exactly.
	base := map[string]any{"error": "", "code": "plan_compile_failed"}
	encoded, err := json.Marshal(base)
	require.NoError(t, err)
	for len(encoded) < ceiling {
		base["error"] = base["error"].(string) + "e"
		encoded, err = json.Marshal(base)
		require.NoError(t, err)
	}
	require.Len(t, encoded, ceiling, "payload must serialize to exactly the ceiling")
	bounded, truncation := BudgetErrorEnvelope(base, EnvelopeBudgetOptions{Ceiling: ceiling})
	require.Nil(t, truncation, "a payload exactly at the ceiling needs no envelope cut")
	require.Equal(t, base, bounded)

	// One byte over the ceiling: an envelope-level cut is required and the final
	// envelope (including truncation metadata) must fit.
	base["error"] = base["error"].(string) + "e"
	bounded, truncation = BudgetErrorEnvelope(base, EnvelopeBudgetOptions{Ceiling: ceiling})
	require.NotNil(t, truncation)
	require.True(t, truncation.EnvelopeTruncated)
	encoded, err = json.Marshal(bounded)
	require.NoError(t, err)
	require.LessOrEqual(t, len(encoded), ceiling, "metadata growth must never push the final envelope over the ceiling")
	require.Equal(t, len(encoded), truncation.SerializedBytes)
}

func TestBudgetErrorEnvelopeMetadataPushOverStaysWithinCeiling(t *testing.T) {
	// Regression: a reduced envelope that would fit WITHOUT the truncation
	// metadata object must still fit WITH it attached. The budget must attach
	// truncation first and re-measure, so attaching the metadata can never push
	// a reduced envelope back over the ceiling.
	ceiling := 64 * 1024
	// Build a payload whose details envelope alone exceeds the ceiling, so a
	// details drop is the first cut; then the remaining fields plus the
	// truncation metadata must still fit.
	details := make([]Diagnostic, 0, MaxServerDiagnostics)
	for i := 0; i < MaxServerDiagnostics; i++ {
		details = append(details, Diagnostic{
			Severity: SeverityError, Code: "plan.test", Path: "$.n",
			Message: strings.Repeat("m", maxServerDiagnosticMessageRunes),
		})
	}
	payload := map[string]any{
		"error": "compile failed", "code": "plan_compile_failed", "stage": "plan",
		"details": map[string]any{"diagnostics": details},
	}
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: ceiling})
	require.NotNil(t, bounded)
	require.NotNil(t, truncation)
	require.True(t, truncation.EnvelopeTruncated)
	encoded, err := json.Marshal(bounded)
	require.NoError(t, err)
	require.LessOrEqual(t, len(encoded), ceiling)
	require.Equal(t, len(encoded), truncation.SerializedBytes)
	require.Contains(t, bounded, "truncation", "the final envelope must carry the truncation report")
}

func TestBudgetErrorEnvelopeBoundsHugeFallbackAndCode(t *testing.T) {
	// The minimal fallback must bound and sanitize a pathological DefaultProblem
	// and code so neither can defeat even a tiny ceiling.
	ceiling := 2048
	payload := map[string]any{
		"error": strings.Repeat("x", 10*60*1024),
		"code":  strings.Repeat("c", 10*1024),
	}
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{
		Ceiling: ceiling, DefaultProblem: strings.Repeat("p", 10*1024),
	})
	require.NotNil(t, bounded)
	require.NotNil(t, truncation)
	require.True(t, truncation.EnvelopeTruncated)
	encoded, err := json.Marshal(bounded)
	require.NoError(t, err)
	require.LessOrEqual(t, len(encoded), ceiling)
	require.True(t, utf8.Valid(encoded))
	require.LessOrEqual(t, len([]rune(bounded["error"].(string))), maxPublicProblemRunes)
	if code, ok := bounded["code"].(string); ok {
		require.LessOrEqual(t, len([]rune(code)), maxServerDiagnosticFieldRunes)
	}
	require.Equal(t, len(encoded), truncation.SerializedBytes)
}

func TestBudgetErrorEnvelopeInvalidUTF8Fallback(t *testing.T) {
	// Invalid UTF-8 in DefaultProblem and code must be sanitized in the minimal
	// fallback so the final envelope is always valid UTF-8.
	ceiling := 60 * 1024
	payload := map[string]any{
		"error": strings.Repeat("x", 10*60*1024),
		"code":  "bad\xff\xfe code",
	}
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{
		Ceiling: ceiling, DefaultProblem: "bad\xff\xfe problem",
	})
	require.NotNil(t, bounded)
	require.NotNil(t, truncation)
	encoded, err := json.Marshal(bounded)
	require.NoError(t, err)
	require.True(t, utf8.Valid(encoded), "final envelope must be valid UTF-8")
	require.LessOrEqual(t, len(encoded), ceiling)
	require.True(t, utf8.ValidString(bounded["error"].(string)))
}

func TestBudgetErrorEnvelopeTinyCeilingsStillFit(t *testing.T) {
	// Even absurdly tiny ceilings must produce a serialized envelope that fits
	// (dropping code, then truncation metadata, then shrinking problem text),
	// while still returning a truncation report.
	for _, ceiling := range []int{2, 10, 20, 60, 120} {
		payload := map[string]any{
			"error": strings.Repeat("x", 10*1024),
			"code":  strings.Repeat("c", 512),
		}
		bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: ceiling})
		require.NotNil(t, truncation, "ceiling %d must still report envelope truncation", ceiling)
		encoded, err := json.Marshal(bounded)
		require.NoError(t, err, "ceiling %d", ceiling)
		require.LessOrEqual(t, len(encoded), ceiling, "ceiling %d final body must fit", ceiling)
		require.Equal(t, len(encoded), truncation.SerializedBytes, "ceiling %d SerializedBytes must match actual", ceiling)
	}
}

func TestBudgetErrorEnvelopeTinyCeilingPreservesErrorText(t *testing.T) {
	// For a ceiling large enough to hold the error text, the minimal fallback
	// must preserve it (dropping code/truncation metadata as needed) rather than
	// emitting an empty object.
	ceiling := 40
	payload := map[string]any{
		"error": "compile failed",
		"code":  strings.Repeat("c", 512),
	}
	bounded, truncation := BudgetErrorEnvelope(payload, EnvelopeBudgetOptions{Ceiling: ceiling})
	require.NotNil(t, truncation)
	encoded, err := json.Marshal(bounded)
	require.NoError(t, err)
	require.LessOrEqual(t, len(encoded), ceiling)
	require.Equal(t, "compile failed", bounded["error"], "error text must be preserved when the ceiling allows it")
}
