// Package publicerr defines the public Workflow error/diagnostic
// contract shared by every workflow management domain (plan, binding,
// invocation, run) and their HTTP and model-facing transports.
//
// The contract deliberately separates a stable, machine-readable `code` from
// a bounded, display-safe `problem` and optional `hint`. Raw repository error
// text is never projected into the public surface: unknown failures fail
// closed to a generic internal code, and 404/403 responses are non-enumerating
// (they do not reveal whether a resource exists).
package publicerr

import (
	"encoding/json"
	"errors"
	"net/http"
	"sort"
	"strings"

	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

// Stage is the workflow management domain that produced the failure.
type Stage string

const (
	StagePlan         Stage = "plan"
	StageBinding      Stage = "binding"
	StageInvocation   Stage = "invocation"
	StageRun          Stage = "run"
	StageTeamWorkflow Stage = "team_workflow"
)

// Severity mirrors the public diagnostic severity shared by Plan compiler
// diagnostics and per-domain validation diagnostics.
type Severity string

const (
	SeverityError   Severity = "error"
	SeverityWarning Severity = "warning"
)

// Diagnostic is the public path-aware diagnostic. Field and Index
// are populated only when the underlying failure names a concrete input field
// or array element; they never carry operational identifiers. Line, Column,
// NodeID, and EdgeID preserve the deterministic compiler/validator source
// location and graph identity of a Plan diagnostic so callers can map a finding
// back to exact source and node/edge.
type Diagnostic struct {
	Severity Severity `json:"severity"`
	Code     string   `json:"code"`
	Path     string   `json:"path,omitempty"`
	Field    string   `json:"field,omitempty"`
	Index    *int     `json:"index,omitempty"`
	Line     int      `json:"line,omitempty"`
	Column   int      `json:"column,omitempty"`
	NodeID   string   `json:"nodeId,omitempty"`
	EdgeID   string   `json:"edgeId,omitempty"`
	Message  string   `json:"message"`
}

// Truncation reports the deterministic budget cuts the server applied to the
// public error contract so callers can see exactly what was omitted instead of
// silently receiving a partial list. It is populated only when a cut occurred
// (count cap, serialized-size cap, or per-field rune cap), so its presence is
// itself the "this payload was truncated" signal. EnvelopeTruncated reports that
// the complete serialized error envelope (not just the diagnostics) was reduced
// below the transport ceiling.
//
// SerializedBytes semantics: it reports the exact byte length of the final
// serialized payload that the cut applied to, measured AFTER all budget cuts.
// At the diagnostics level it is the serialized bounded diagnostics array; once
// the whole-envelope budget runs (EnvelopeTruncated), it is the byte length of
// the complete final serialized error envelope as actually written to the
// transport — INCLUDING the truncation metadata object itself and any encoder
// escaping. Because the field is part of the JSON it measures, the envelope
// value is a serialization fixed point: BudgetErrorEnvelope attaches the
// truncation first, then iterates marshal/measure until the reported length
// matches the true final length. A nil report (no cut) carries no
// SerializedBytes.
type Truncation struct {
	DiagnosticsTruncated bool     `json:"diagnosticsTruncated,omitempty"`
	EnvelopeTruncated    bool     `json:"envelopeTruncated,omitempty"`
	OmittedCount         int      `json:"omittedCount,omitempty"`
	TruncatedFields      []string `json:"truncatedFields,omitempty"`
	SerializedBytes      int      `json:"serializedBytes,omitempty"`
}

// Error is the public Workflow error contract. Code is stable and
// machine-readable; Problem is always a bounded, display-safe public message;
// Hint is optional guidance; Retryable tells a caller whether re-issuing the
// same request may succeed. Diagnostics carry typed, path-aware findings.
// Truncation reports the deterministic budget cuts applied to Diagnostics when
// any were made, so a caller can distinguish a full list from a bounded one.
type Error struct {
	Code        string       `json:"code"`
	Stage       Stage        `json:"stage,omitempty"`
	Path        string       `json:"path,omitempty"`
	Problem     string       `json:"problem"`
	Hint        string       `json:"hint,omitempty"`
	Retryable   bool         `json:"retryable,omitempty"`
	Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
	Truncation  *Truncation  `json:"truncation,omitempty"`
}

// Classified is the centralized safe projection of one domain error: the
// public contract plus the transport HTTP status and the redacted
// structured details a caller is allowed to see.
type Classified struct {
	Status  int
	Error   Error
	Details any
	// Collapsed is true when a protected-resource selector was collapsed to the
	// identical not-found contract. Callers must not re-project domain details
	// or codes onto a collapsed response.
	Collapsed bool
	// InternalCode is the true domain code before protected-selector collapse.
	// It is never projected into the public contract; server-side audit uses it
	// to distinguish a missing selector from a cross-tenant/forbidden one while
	// clients see the collapsed not-found contract.
	InternalCode string
}

// Options configures the centralized classifier for one workflow domain.
type Options struct {
	// Stage identifies the owning workflow management domain.
	Stage Stage
	// DefaultCode is the public code used when the underlying error carries no
	// stable domain code (for example a raw store error).
	DefaultCode string
	// StatusForCode optionally remaps the derived HTTP status for a stable
	// domain code (for example a plan revision conflict to 412 Precondition
	// Failed).
	StatusForCode func(code string, status int) int
	// HintFor optionally attaches a bounded public hint for a stable domain
	// code.
	HintFor func(code string) string
	// Unredacted preserves distinct statuses, raw problem text, and domain
	// details. Internal causes remain available only to structured observation.
	Unredacted bool
	// ProtectedSelectors maps the real domain codes that select a protected
	// Workflow resource (for example "binding_not_found" and
	// "binding_forbidden") to the public external code emitted when the
	// selector is missing or cross-tenant/forbidden. When a domain error
	// carries one of these codes and resolves to 404 or 403, Classify collapses
	// the response to an identical not-found contract (same 404 status, same
	// code, one bounded problem, no details, no public fields) so a caller
	// cannot enumerate protected resources across tenants. The original error
	// and its true code are retained for internal audit.
	ProtectedSelectors map[string]string
	// ProtectedNotFoundCode is the public external not-found code emitted for
	// a RAW store NotFound/Forbidden selector (an error with no domain code, for
	// example a storeerr.NotFound or storeerr.Forbidden surfaced directly by the
	// store layer). It must equal the not-found code the wrapped domain selectors
	// collapse to, so a missing selector and a cross-tenant/forbidden one project
	// one byte-identical external contract across raw, wrapped, and domain
	// failures. When empty, raw store selectors keep their generic non-enumerating
	// projection.
	ProtectedNotFoundCode string
	// ProtectedNotFoundProblem is the single bounded problem emitted for a
	// collapsed protected selector. Omit to use the shared non-enumerating
	// problem used for raw store 404/403.
	ProtectedNotFoundProblem string
}

// DomainError is implemented by every workflow management domain's RequestError
// so the centralized classifier can extract the stable code and structured
// details without importing the domain package.
type DomainError interface {
	error
	WorkflowPublicError() (code string, details any)
}

// WorkflowError is the public API-layer domain error. Workflow API packages
// construct one for strict-JSON, precondition, idempotency, request-validation,
// and transport-context failures so every public and Agent action projects the
// same public contract with a stable code, bounded problem, and optional
// hint, path, retryability, and typed diagnostics.
//
// Kind selects the HTTP status through the centralized store mapping; Status
// optionally overrides it for statuses outside the store vocabulary (for
// example 412 Precondition Failed, 413 Payload Too Large, or 428 Precondition
// Required). Diagnostics are the public typed findings a caller may see.
type WorkflowError struct {
	Kind        error
	Code        string
	Problem     string
	Hint        string
	Path        string
	Retryable   bool
	Status      int
	Details     any
	Diagnostics []Diagnostic
}

func (err WorkflowError) Error() string { return BoundedProblem(err.Problem) }
func (err WorkflowError) Unwrap() error { return err.Kind }

// WorkflowPublicError implements DomainError so the centralized classifier
// projects the public code and structured details without importing the
// calling API package. Typed diagnostics are folded into the details envelope
// under the well-known "diagnostics" key.
func (err WorkflowError) WorkflowPublicError() (string, any) {
	if len(err.Diagnostics) == 0 {
		return err.Code, err.Details
	}
	return err.Code, map[string]any{"diagnostics": err.Diagnostics}
}

// Classify projects any error into the public Workflow error
// contract using centralized safe redaction:
//
//   - a known domain RequestError keeps its stable code and typed details;
//   - a protected-resource selector that is missing or cross-tenant/forbidden
//     collapses to an identical 404 not-found contract so callers cannot
//     enumerate protected resources across tenants;
//   - a known store error keeps its HTTP status but is never projected with raw
//     repository text;
//   - unknown failures fail closed to a generic internal code with no details;
//   - 404 and 403 responses are non-enumerating: the same bounded problem is
//     returned for both and no resource-identifying details are projected.
func Classify(err error, options Options) (Classified, bool) {
	if err == nil {
		return Classified{}, false
	}
	code, details, hasDomain := extractDomainError(err)
	var workflowError WorkflowError
	hasWorkflowError := errors.As(err, &workflowError)
	status := StatusForError(err)
	if options.StatusForCode != nil {
		status = options.StatusForCode(code, status)
	}
	if hasWorkflowError && workflowError.Status != 0 {
		status = workflowError.Status
	}
	if options.Unredacted {
		return classifyUnredacted(err, status, code, details, hasDomain, hasWorkflowError, workflowError, options), true
	}
	// Protected-resource selectors must be externally identical for a missing
	// selector and a cross-tenant/forbidden one: same 404 status, public
	// not-found code, one bounded problem, and no details or public fields.
	// The true code is retained in InternalCode for server-side audit so logs
	// still distinguish the two while clients cannot. This applies to the mapped
	// wrapped domain selectors AND to raw store NotFound/Forbidden selectors
	// (which carry no domain code) so a missing resource and an inaccessible
	// resource project one byte-identical external contract.
	if options.ProtectedSelectors != nil &&
		(status == http.StatusNotFound || status == http.StatusForbidden) {
		var notFoundCode string
		if hasDomain {
			if mapped, ok := options.ProtectedSelectors[code]; ok {
				notFoundCode = mapped
			}
		} else {
			notFoundCode = options.ProtectedNotFoundCode
		}
		if notFoundCode != "" {
			problem := strings.TrimSpace(options.ProtectedNotFoundProblem)
			if problem == "" {
				problem = "The requested Workflow resource was not found or is not accessible"
			}
			internalCode := code
			if !hasDomain {
				// Raw store selectors have no domain code; retain a stable
				// audit marker that still distinguishes a missing selector from
				// a cross-tenant/forbidden one.
				if status == http.StatusForbidden {
					internalCode = "store.forbidden"
				} else {
					internalCode = "store.not_found"
				}
			}
			return Classified{
				Status: http.StatusNotFound,
				Error: Error{
					Code: notFoundCode, Stage: options.Stage, Problem: BoundedProblem(problem),
				},
				Collapsed:    true,
				InternalCode: internalCode,
			}, true
		}
	}
	if status == 0 {
		return Classified{
			Status: http.StatusInternalServerError,
			Error: Error{
				Code: defaultCode(code, options), Stage: options.Stage,
				Problem: "Workflow operation failed",
			},
		}, true
	}
	if status == http.StatusNotFound || status == http.StatusForbidden {
		return classifyNonEnumerating(status, err, code, details, hasDomain, hasWorkflowError, workflowError, options), true
	}
	public := Error{
		Code:  defaultCode(code, options),
		Stage: options.Stage,
	}
	if hasWorkflowError {
		public.Hint = workflowError.Hint
		public.Path = workflowError.Path
		public.Retryable = workflowError.Retryable
	} else if options.HintFor != nil {
		public.Hint = options.HintFor(code)
	}
	public.Retryable = public.Retryable || retryableFor(status)
	if hasDomain {
		public.Problem = BoundedProblem(err.Error())
		bounded, truncation := BoundDiagnostics(projectDiagnostics(details))
		public.Diagnostics = bounded
		public.Truncation = truncation
		// The top-level diagnostics field is the public projection;
		// always strip the duplicate from the details envelope (copy-on-write) so
		// the serialized body never carries the diagnostics twice.
		details = withoutDetailsDiagnostics(details)
		if len(bounded) > 0 && public.Path == "" {
			public.Path = bounded[0].Path
		}
	} else {
		public.Problem = defaultProblem(code, options)
	}
	return Classified{Status: status, Error: public, Details: safeDetails(status, details)}, true
}

func classifyUnredacted(
	err error,
	status int,
	code string,
	details any,
	hasDomain bool,
	hasWorkflowError bool,
	workflowError WorkflowError,
	options Options,
) Classified {
	if status == 0 {
		status = http.StatusInternalServerError
	}
	problem := err.Error()
	public := Error{
		Code: defaultCode(code, options), Stage: options.Stage, Problem: problem,
		Retryable: retryableFor(status),
	}
	if hasWorkflowError {
		public.Hint = workflowError.Hint
		public.Path = workflowError.Path
		public.Retryable = public.Retryable || workflowError.Retryable
	} else if options.HintFor != nil {
		public.Hint = options.HintFor(code)
	}
	if hasDomain {
		public.Diagnostics, public.Truncation = BoundDiagnostics(projectDiagnostics(details))
		if len(public.Diagnostics) > 0 && public.Path == "" {
			public.Path = public.Diagnostics[0].Path
		}
	}
	return Classified{Status: status, Error: public, Details: details}
}

// classifyNonEnumerating projects a 404/403 outcome. These must be
// non-enumerating. Known domain errors keep their deliberate code and details
// (the store already projects a single not-found code for both missing and
// inaccessible resources), but raw store failures collapse to one generic code
// and problem with no details so a missing resource and an inaccessible
// resource are indistinguishable.
func classifyNonEnumerating(status int, err error, code string, details any, hasDomain bool, hasWorkflowError bool, workflowError WorkflowError, options Options) Classified {
	if !hasDomain {
		return Classified{
			Status: status,
			Error: Error{
				Code: defaultCode("", options), Stage: options.Stage,
				Problem: "The requested Workflow resource was not found or is not accessible",
			},
		}
	}
	public := Error{
		Code:    defaultCode(code, options),
		Stage:   options.Stage,
		Problem: BoundedProblem(err.Error()),
	}
	if hasWorkflowError {
		public.Path = workflowError.Path
		public.Hint = workflowError.Hint
		public.Retryable = workflowError.Retryable
	} else if options.HintFor != nil {
		public.Hint = options.HintFor(code)
	}
	public.Retryable = public.Retryable || retryableFor(status)
	return Classified{Status: status, Error: public, Details: safeDetails(status, details)}
}

// extractDomainError pulls the stable code and structured details from a
// workflow management domain RequestError when present.
func extractDomainError(err error) (code string, details any, ok bool) {
	var domain DomainError
	if errors.As(err, &domain) && domain != nil {
		code, details = domain.WorkflowPublicError()
		return code, details, true
	}
	return "", nil, false
}

// StatusForError maps the stable store error kinds to their HTTP status. Zero
// means the error is not a recognized domain/store failure. It is exported for
// domain transports that need to map a code-only projection (for example a
// Binding APIErrorDetails adapter) without duplicating the store mapping.
func StatusForError(err error) int {
	switch {
	case errors.Is(err, storeerr.ErrBadRequest):
		return http.StatusBadRequest
	case errors.Is(err, storeerr.ErrForbidden), errors.Is(err, storeerr.ErrUnauthorized):
		return http.StatusForbidden
	case errors.Is(err, storeerr.ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, storeerr.ErrConflict):
		return http.StatusConflict
	case errors.Is(err, storeerr.ErrUnprocessable):
		return http.StatusUnprocessableEntity
	default:
		return 0
	}
}

func defaultCode(code string, options Options) string {
	if strings.TrimSpace(code) != "" {
		return code
	}
	if strings.TrimSpace(options.DefaultCode) != "" {
		return options.DefaultCode
	}
	return "workflow_request_failed"
}

// BoundedProblem bounds a display-safe problem string so a domain cannot push
// unbounded or malformed text into the public envelope. It is exported for
// domain transports that project a code-only error without importing the full
// classifier.
func BoundedProblem(problem string) string {
	problem = strings.TrimSpace(strings.ToValidUTF8(problem, "\uFFFD"))
	if runes := []rune(problem); len(runes) > maxPublicProblemRunes {
		return string(runes[:maxPublicProblemRunes])
	}
	if problem == "" {
		return "Workflow operation failed"
	}
	return problem
}

// maxPublicProblemRunes is the deterministic rune cap for the public problem
// text, shared by BoundedProblem and the whole-envelope budget.
const maxPublicProblemRunes = 512

func defaultProblem(code string, options Options) string {
	if strings.TrimSpace(code) != "" {
		return "Workflow request failed"
	}
	if options.Stage != "" {
		return "Workflow " + string(options.Stage) + " request failed"
	}
	return "Workflow request failed"
}

func retryableFor(status int) bool {
	return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}

// safeDetails returns the structured details a caller may see, or nil for
// non-enumerating/unknown failures.
func safeDetails(status int, details any) any {
	if status == http.StatusNotFound || status == http.StatusForbidden {
		return nil
	}
	return details
}

// projectDiagnostics converts a domain details payload into the public
// typed diagnostic list. Only the well-known "diagnostics" key is projected;
// every other detail stays out of the public diagnostics array. Typed domain
// diagnostic structs (for example planlang.Diagnostic or a manage.Diagnostic)
// are read through their exported fields so the public contract always
// carries the same typed findings a caller can see.
func projectDiagnostics(details any) []Diagnostic {
	values, ok := details.(map[string]any)
	if !ok {
		return nil
	}
	raw, ok := values["diagnostics"]
	if !ok {
		return nil
	}
	switch typed := raw.(type) {
	case []Diagnostic:
		return append([]Diagnostic(nil), typed...)
	case []any:
		diagnostics := make([]Diagnostic, 0, len(typed))
		for _, item := range typed {
			if diagnostic, ok := diagnosticFromAny(item); ok {
				diagnostics = append(diagnostics, diagnostic)
			}
		}
		return diagnostics
	default:
		return diagnosticsFromReflect(raw)
	}
}

// Server diagnostic budget limits. These are the deterministic UTF-8-safe
// bounds applied at classification time so a single failure can never push an
// unbounded diagnostics array into the public error body.
const (
	// MaxServerDiagnostics caps the number of diagnostics projected into one
	// error response.
	MaxServerDiagnostics = 100
	// maxServerDiagnosticFieldRunes caps every individual diagnostic field.
	maxServerDiagnosticFieldRunes = 512
	// maxServerDiagnosticMessageRunes caps the diagnostic message text.
	maxServerDiagnosticMessageRunes = 1024
	// maxServerDiagnosticsSerializedBytes caps the serialized JSON size of the
	// whole diagnostics array so the error body stays within a deterministic
	// budget.
	maxServerDiagnosticsSerializedBytes = 32 * 1024
)

// BoundDiagnostics deterministically bounds a diagnostic list: count capped,
// every field made valid UTF-8/trimmed/rune-capped, and the whole array capped
// by serialized byte size (dropping from the tail until it fits). It returns
// the bounded list and explicit truncation metadata; nil metadata means no cut
// was needed. Truncation metadata is always non-nil when any bound fired.
func BoundDiagnostics(diagnostics []Diagnostic) ([]Diagnostic, *Truncation) {
	if len(diagnostics) == 0 {
		return nil, nil
	}
	truncation := &Truncation{}
	bounded := make([]Diagnostic, 0, min(len(diagnostics), MaxServerDiagnostics))
	truncatedFields := map[string]bool{}
	for _, diagnostic := range diagnostics {
		if len(bounded) >= MaxServerDiagnostics {
			truncation.DiagnosticsTruncated = true
			truncation.OmittedCount++
			continue
		}
		boundedDiagnostic, cut := boundDiagnostic(diagnostic)
		for _, field := range cut {
			truncatedFields[field] = true
		}
		bounded = append(bounded, boundedDiagnostic)
	}
	if len(truncatedFields) > 0 {
		truncation.DiagnosticsTruncated = true
		for field := range truncatedFields {
			truncation.TruncatedFields = append(truncation.TruncatedFields, field)
		}
		sort.Strings(truncation.TruncatedFields)
	}
	for len(bounded) > 0 {
		encoded, err := json.Marshal(bounded)
		if err == nil && len(encoded) <= maxServerDiagnosticsSerializedBytes {
			truncation.SerializedBytes = len(encoded)
			break
		}
		bounded = bounded[:len(bounded)-1]
		truncation.DiagnosticsTruncated = true
		truncation.OmittedCount++
	}
	if len(bounded) == 0 {
		return nil, truncation
	}
	if !truncation.DiagnosticsTruncated {
		return bounded, nil
	}
	return bounded, truncation
}

// boundDiagnostic sanitizes one diagnostic deterministically: every field is
// made valid UTF-8, trimmed, and rune-capped. It returns the bounded diagnostic
// and the set of field names that were cut.
func boundDiagnostic(diagnostic Diagnostic) (Diagnostic, []string) {
	var cut []string
	field := func(value string, max int, name string) string {
		bounded := boundRunes(value, max)
		if bounded != value {
			cut = append(cut, name)
		}
		return bounded
	}
	severity := field(string(diagnostic.Severity), maxServerDiagnosticFieldRunes, "severity")
	diagnostic.Severity = Severity(severity)
	diagnostic.Code = field(diagnostic.Code, maxServerDiagnosticFieldRunes, "code")
	diagnostic.Path = field(diagnostic.Path, maxServerDiagnosticFieldRunes, "path")
	diagnostic.Field = field(diagnostic.Field, maxServerDiagnosticFieldRunes, "field")
	diagnostic.NodeID = field(diagnostic.NodeID, maxServerDiagnosticFieldRunes, "nodeId")
	diagnostic.EdgeID = field(diagnostic.EdgeID, maxServerDiagnosticFieldRunes, "edgeId")
	diagnostic.Message = field(diagnostic.Message, maxServerDiagnosticMessageRunes, "message")
	return diagnostic, cut
}

// boundRunes makes value valid UTF-8, trims it, and caps it to max runes.
func boundRunes(value string, max int) string {
	value = strings.TrimSpace(strings.ToValidUTF8(value, "\uFFFD"))
	if runes := []rune(value); len(runes) > max {
		if max <= 0 {
			return ""
		}
		return string(runes[:max])
	}
	return value
}

// withoutDetailsDiagnostics returns a copy of the domain details envelope with
// the well-known "diagnostics" key removed, because the public top-level
// diagnostics field is the single public projection and keeping a second copy in
// the details envelope would duplicate the serialized payload. The caller's map
// is never mutated: a fresh map is allocated whenever a change is needed.
// Non-map details or details without a diagnostics key are returned unchanged
// (without allocation).
func withoutDetailsDiagnostics(details any) any {
	values, ok := details.(map[string]any)
	if !ok {
		return details
	}
	if _, exists := values["diagnostics"]; !exists {
		return values
	}
	cloned := make(map[string]any, len(values)-1)
	for key, value := range values {
		if key == "diagnostics" {
			continue
		}
		cloned[key] = value
	}
	if len(cloned) == 0 {
		return nil
	}
	return cloned
}

// MaxWorkflowErrorBodyBytes is the deterministic ceiling for the serialized
// public Workflow error envelope written by the shared response writer. It stays
// below the desktop apiclient's error-body read ceiling (64 KiB) so an oversized
// server envelope never causes the apiclient to fail closed on a truncated body.
// The 4 KiB of headroom absorbs JSON escaping, UTF-8, and diagnostics overhead.
const MaxWorkflowErrorBodyBytes = 60 * 1024

// EnvelopeBudgetOptions configures the whole-envelope serialized budget applied
// by BudgetErrorEnvelope.
type EnvelopeBudgetOptions struct {
	// Ceiling is the maximum serialized byte size for the complete envelope.
	Ceiling int
	// DefaultProblem is the minimal fallback problem text when the envelope
	// cannot be reduced any further. It is always bounded and sanitized (valid
	// UTF-8, trimmed, rune-capped) before projection so a pathological caller
	// value can never defeat the budget.
	DefaultProblem string
}

// BudgetErrorEnvelope deterministically reduces a serialized public Workflow
// error envelope below the transport ceiling so the desktop apiclient never has
// to truncate an oversized error body. It is the final writer-side budget on top
// of the per-diagnostics BoundDiagnostics budget: a single failure can push
// unbounded non-diagnostic details, a large problem, or a large path past the
// apiclient ceiling.
//
// The truncation metadata is attached FIRST, and every size measurement below
// is taken over the actual final serialized JSON — including the truncation
// metadata object itself and any encoder escaping — so the reported
// SerializedBytes is a true fixed point and attaching the metadata can never
// push a reduced envelope back over the ceiling. The reduction order is
// deterministic and least-critical-first:
//
//  1. drop the whole details envelope (the public contract fields are
//     top-level; details is supplementary);
//  2. drop the diagnostics array from the tail until the envelope fits;
//  3. truncate hint, path, and then problem text to their rune caps;
//  4. as a last resort, emit a bounded, sanitized minimal {error, code}
//     envelope, rechecking even tiny ceilings by dropping code, then the
//     truncation metadata, then shrinking the problem text until the body fits.
//
// It returns the bounded envelope and an explicit Truncation report; nil report
// means no cut was needed. The input payload is never mutated.
func BudgetErrorEnvelope(payload map[string]any, options EnvelopeBudgetOptions) (map[string]any, *Truncation) {
	ceiling := options.Ceiling
	if ceiling <= 0 {
		ceiling = MaxWorkflowErrorBodyBytes
	}
	// Fast path: the payload already fits the ceiling as-is (with any
	// diagnostics-level truncation it already carries), so no envelope-level cut
	// is needed.
	if encoded, err := json.Marshal(payload); err == nil && len(encoded) <= ceiling {
		return payload, nil
	}
	cloned := make(map[string]any, len(payload)+1)
	for key, value := range payload {
		cloned[key] = value
	}
	truncation := mergeTruncations(existingTruncation(payload), &Truncation{EnvelopeTruncated: true})
	// Attach the truncation metadata FIRST so every fits() measurement below
	// includes the metadata growth itself: a payload that fits without the
	// metadata may still exceed the ceiling once it is attached.
	cloned["truncation"] = truncation
	fits := func() bool {
		size := sealedSize(cloned)
		return size >= 0 && size <= ceiling
	}

	// 1. Drop the details envelope.
	if _, has := cloned["details"]; has {
		delete(cloned, "details")
		truncation.TruncatedFields = append(truncation.TruncatedFields, "details")
	}
	if fits() {
		return sealedEnvelope(cloned, truncation)
	}

	// 2. Drop the diagnostics array from the tail.
	if sealed, ok := dropEnvelopeDiagnostics(cloned, truncation, ceiling); ok {
		return sealed, truncation
	}
	if fits() {
		return sealedEnvelope(cloned, truncation)
	}

	// 3. Truncate hint, path, then problem text to their rune caps.
	truncateField := func(key string, max int) {
		if value, ok := cloned[key].(string); ok && len([]rune(value)) > max {
			cloned[key] = boundRunes(value, max)
			truncation.TruncatedFields = append(truncation.TruncatedFields, key)
		}
	}
	truncateField("hint", maxServerDiagnosticFieldRunes)
	if fits() {
		return sealedEnvelope(cloned, truncation)
	}
	truncateField("path", maxServerDiagnosticFieldRunes)
	if fits() {
		return sealedEnvelope(cloned, truncation)
	}
	truncateField("error", maxPublicProblemRunes)
	if fits() {
		return sealedEnvelope(cloned, truncation)
	}

	// 4. Last resort: a bounded, sanitized minimal {error, code} envelope.
	return minimalEnvelopeFallback(payload, options, truncation, ceiling)
}

// dropEnvelopeDiagnostics removes the diagnostics array from the tail of an
// oversized envelope until it fits the ceiling, marking the report's omitted
// count and truncated flag. It returns the sealed envelope and true when the
// cut made the body fit, and false when diagnostics were absent or could not
// shrink enough.
func dropEnvelopeDiagnostics(cloned map[string]any, truncation *Truncation, ceiling int) (map[string]any, bool) {
	raw, has := cloned["diagnostics"]
	if !has {
		return nil, false
	}
	fits := func() bool {
		size := sealedSize(cloned)
		return size >= 0 && size <= ceiling
	}
	switch diagnostics := raw.(type) {
	case []Diagnostic:
		for len(diagnostics) > 0 {
			cloned["diagnostics"] = diagnostics
			if fits() {
				sealed, _ := sealedEnvelope(cloned, truncation)
				return sealed, true
			}
			diagnostics = diagnostics[:len(diagnostics)-1]
			truncation.OmittedCount++
		}
		delete(cloned, "diagnostics")
		truncation.DiagnosticsTruncated = true
	case []any:
		for len(diagnostics) > 0 {
			cloned["diagnostics"] = diagnostics
			if fits() {
				sealed, _ := sealedEnvelope(cloned, truncation)
				return sealed, true
			}
			diagnostics = diagnostics[:len(diagnostics)-1]
			truncation.OmittedCount++
		}
		delete(cloned, "diagnostics")
		truncation.DiagnosticsTruncated = true
	}
	if fits() {
		sealed, _ := sealedEnvelope(cloned, truncation)
		return sealed, true
	}
	return nil, false
}

// minimalEnvelopeFallback is the bounded, sanitized minimal {error, code}
// envelope returned when every larger budget step failed. The fallback problem
// and code are bounded and sanitized so a pathological caller value can never
// defeat the budget, and even tiny ceilings are rechecked: code, then the
// truncation metadata, then the problem text are dropped/shrunk until the final
// body fits. The truncation report is always returned even when the body is too
// small to carry it. When the payload already carries a bounded public error
// text it is preserved; the configured DefaultProblem is used only as a further
// fallback.
func minimalEnvelopeFallback(payload map[string]any, options EnvelopeBudgetOptions, truncation *Truncation, ceiling int) (map[string]any, *Truncation) {
	problem := sanitizeEnvelopeProblem(payload["error"])
	if problem == "" {
		problem = sanitizeEnvelopeProblem(options.DefaultProblem)
	}
	if problem == "" {
		problem = "Workflow operation failed"
	}
	code := sanitizeEnvelopeCode(payload["code"])
	minimal := map[string]any{"error": problem}
	if code != "" {
		minimal["code"] = code
	}
	// Try the full minimal envelope (error + code + truncation metadata) first.
	minimal["truncation"] = truncation
	if size := sealedSize(minimal); size >= 0 && size <= ceiling {
		return sealedEnvelope(minimal, truncation)
	}
	// Tiny ceiling: drop the code field.
	delete(minimal, "code")
	if size := sealedSize(minimal); size >= 0 && size <= ceiling {
		return sealedEnvelope(minimal, truncation)
	}
	// Tiny ceiling: drop the truncation metadata from the body (the report is
	// still returned) and check the bare error body, then shrink the problem
	// text until the body fits.
	delete(minimal, "truncation")
	minimal["error"] = problem
	encoded, err := json.Marshal(minimal)
	if err == nil && len(encoded) <= ceiling {
		truncation.SerializedBytes = len(encoded)
		return minimal, truncation
	}
	problemRunes := []rune(problem)
	for len(problemRunes) > 0 {
		problemRunes = problemRunes[:len(problemRunes)-1]
		minimal["error"] = string(problemRunes)
		encoded, err := json.Marshal(minimal)
		if err == nil && len(encoded) <= ceiling {
			truncation.SerializedBytes = len(encoded)
			return minimal, truncation
		}
	}
	// Absolute floor: an empty object (the truncation report is still returned).
	truncation.SerializedBytes = sealedSize(map[string]any{})
	return map[string]any{}, truncation
}

// sanitizeEnvelopeProblem bounds and sanitizes the minimal fallback problem
// text: valid UTF-8, trimmed, rune-capped. Empty/blank input yields an empty
// string so the caller can fall back to the configured DefaultProblem; only the
// stable "Workflow operation failed" default is returned for a fully-empty
// final fallback.
func sanitizeEnvelopeProblem(problem any) string {
	if raw, ok := problem.(string); ok {
		return boundRunes(raw, maxPublicProblemRunes)
	}
	return ""
}

// sanitizeEnvelopeCode bounds and sanitizes the minimal fallback code: valid
// UTF-8, trimmed, rune-capped. Non-string values are dropped.
func sanitizeEnvelopeCode(code any) string {
	raw, ok := code.(string)
	if !ok {
		return ""
	}
	return boundRunes(raw, maxServerDiagnosticFieldRunes)
}

// sealedEnvelope attaches the truncation metadata to the bounded envelope and
// computes SerializedBytes as the exact byte length of the ACTUAL final
// serialized JSON (the envelope including the truncation metadata object itself
// and any encoder escaping). Because the field is part of the JSON it measures,
// the value is a serialization fixed point: it is recomputed until stable so
// the reported size is never stale by a metadata-growth or digit-count delta.
func sealedEnvelope(payload map[string]any, truncation *Truncation) (map[string]any, *Truncation) {
	payload["truncation"] = truncation
	sealedSize(payload)
	return payload, truncation
}

// sealedSize returns the exact byte length of the final serialized JSON for
// payload, where payload already carries its truncation metadata. It iterates
// to a fixed point because updating SerializedBytes can change the digit count
// of the value, which changes the serialized length. It returns -1 when the
// payload cannot be serialized.
func sealedSize(payload map[string]any) int {
	truncation, _ := payload["truncation"].(*Truncation)
	if truncation == nil {
		encoded, err := json.Marshal(payload)
		if err != nil {
			return -1
		}
		return len(encoded)
	}
	for i := 0; i < 16; i++ {
		encoded, err := json.Marshal(payload)
		if err != nil {
			return -1
		}
		if len(encoded) == truncation.SerializedBytes {
			return len(encoded)
		}
		truncation.SerializedBytes = len(encoded)
	}
	encoded, err := json.Marshal(payload)
	if err != nil {
		return -1
	}
	return len(encoded)
}

// existingTruncation extracts a *Truncation carried in a payload under the
// well-known "truncation" key, if any.
func existingTruncation(payload map[string]any) *Truncation {
	if truncation, ok := payload["truncation"].(*Truncation); ok {
		return truncation
	}
	return nil
}

// mergeTruncations combines a prior diagnostics-level truncation report with a
// later envelope-level report into one deterministic report. When either side is
// nil the other is returned; when both exist the fields are unioned.
func mergeTruncations(prior, later *Truncation) *Truncation {
	if prior == nil {
		return later
	}
	if later == nil {
		return prior
	}
	merged := *prior
	merged.DiagnosticsTruncated = merged.DiagnosticsTruncated || later.DiagnosticsTruncated
	merged.EnvelopeTruncated = merged.EnvelopeTruncated || later.EnvelopeTruncated
	merged.OmittedCount += later.OmittedCount
	merged.TruncatedFields = append(append([]string(nil), merged.TruncatedFields...), later.TruncatedFields...)
	if later.SerializedBytes > merged.SerializedBytes {
		merged.SerializedBytes = later.SerializedBytes
	}
	return &merged
}
