package run

import (
	"encoding/json"
	"sort"
	"strings"
)

// Failure diagnostic budget limits. These are the deterministic UTF-8-safe
// bounds applied exactly once at the persistence/event public boundary so a
// single failure can never push an unbounded diagnostics array into a durable
// event payload, error_json, or Run log. The same budget is used by the kernel
// FailureEvent and by the runtime persistence boundary so the event and the
// persisted error_json report identical deterministic cuts.
const (
	// MaxFailureDiagnostics caps the number of diagnostics carried by one
	// failure event or persisted failure.
	MaxFailureDiagnostics = 16
	// maxFailureDiagnosticFieldRunes caps every scalar diagnostic field.
	maxFailureDiagnosticFieldRunes = 128
	// maxFailureDiagnosticPathRunes caps the diagnostic path.
	maxFailureDiagnosticPathRunes = 1024
	// maxFailureDiagnosticMessageRunes caps the diagnostic message.
	maxFailureDiagnosticMessageRunes = 1024
	// maxFailureDiagnosticsSerializedBytes caps the serialized JSON size of the
	// whole diagnostics array so the event/error payload stays bounded.
	maxFailureDiagnosticsSerializedBytes = 32 * 1024
)

// FailureTruncation reports deterministic budget cuts applied to a failure's
// diagnostics so callers can distinguish a full list from a bounded one. It
// mirrors the persisted/public truncation contract and is carried through the
// kernel Event payload so events/logs report the same cuts as error_json.
type FailureTruncation struct {
	DiagnosticsTruncated bool `json:"diagnosticsTruncated,omitempty"`
	OmittedCount         int  `json:"omittedCount,omitempty"`
	// DroppedCount reports the number of malformed elements/fields that failed
	// closed at a read/projection boundary (a diagnostic element that was not an
	// object, a missing/malformed code, a secret-bearing or punctuation-bearing
	// field) so the single deterministic report counts every dropped element and
	// field, not just the budget tail cuts.
	DroppedCount int `json:"droppedCount,omitempty"`
	// TruncatedFields lists the fields whose rune budget cut fired.
	TruncatedFields []string `json:"truncatedFields,omitempty"`
	// SerializedBytes is the exact byte length of the final bounded payload.
	SerializedBytes int `json:"serializedBytes,omitempty"`
	// MinimalFallback reports whether the projection fell back to the minimal
	// safe {code, problem} contract because the payload exceeded the serialized
	// size ceiling even after diagnostics/field cuts.
	MinimalFallback bool `json:"minimalFallback,omitempty"`
}

// BoundFailureDiagnosticsCarried applies the single shared budget to a failure
// diagnostic list and carries forward any prior truncation metadata, so a
// recovery/replay round that re-bounds an already-bounded list preserves the
// earlier omittedCount/truncatedFields/serializedBytes instead of recomputing
// them away. A nil prior report means no earlier cut was recorded.
func BoundFailureDiagnosticsCarried(diagnostics []FailureDiagnostic, prior *FailureTruncation) ([]FailureDiagnostic, *FailureTruncation) {
	if len(diagnostics) == 0 {
		// An empty list must still carry a prior truncation report forward: a
		// replayed/all-items-dropped failure that re-bounds to zero diagnostics
		// preserves the earlier omittedCount/truncatedFields/serializedBytes
		// instead of recomputing them away. No diagnostics are fabricated.
		return nil, mergeFailureTruncation(prior, nil)
	}
	truncation := &FailureTruncation{}
	bounded := make([]FailureDiagnostic, 0, min(len(diagnostics), MaxFailureDiagnostics))
	truncatedFields := map[string]bool{}
	dropped := 0
	for _, diagnostic := range diagnostics {
		if len(bounded) >= MaxFailureDiagnostics {
			truncation.DiagnosticsTruncated = true
			truncation.OmittedCount++
			continue
		}
		boundedDiagnostic, drop, cut := boundFailureDiagnostic(diagnostic)
		if drop {
			// A malformed code identity is not a trustworthy diagnostic: fail
			// closed by dropping the whole element and counting it in the
			// receipt so the single deterministic report counts every dropped
			// element, not just the budget tail cuts.
			dropped++
			continue
		}
		for _, field := range cut {
			truncatedFields[field] = true
		}
		bounded = append(bounded, boundedDiagnostic)
	}
	if dropped > 0 {
		truncation.DroppedCount += dropped
		truncation.DiagnosticsTruncated = true
	}
	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) <= maxFailureDiagnosticsSerializedBytes {
			truncation.SerializedBytes = len(encoded)
			break
		}
		bounded = bounded[:len(bounded)-1]
		truncation.DiagnosticsTruncated = true
		truncation.OmittedCount++
	}
	if len(bounded) == 0 {
		return nil, mergeFailureTruncation(prior, truncation)
	}
	if !truncation.DiagnosticsTruncated {
		return bounded, mergeFailureTruncation(prior, nil)
	}
	return bounded, mergeFailureTruncation(prior, truncation)
}

// failureTruncationFieldNames is the closed set of field names that may appear
// in a truncation receipt. Only these closed field names are recorded; an
// arbitrary key is never accepted into the receipt.
var failureTruncationFieldNames = map[string]struct{}{
	"code": {}, "stage": {}, "path": {}, "problem": {}, "hint": {},
	"message": {}, "field": {}, "key": {}, "dependencyPath": {}, "index": {},
}

// closedTruncationField returns the closed field name when it belongs to the
// closed receipt vocabulary, or "" otherwise. The name is matched byte-for-byte:
// a spaced variant is never trimmed into the accepted identity.
func closedTruncationField(name string) string {
	if _, ok := failureTruncationFieldNames[name]; ok {
		return name
	}
	return ""
}

// mergeFailureTruncation carries a prior truncation report forward across a
// replay round: when the fresh bound fired no cut, the earlier report survives
// unchanged so omittedCount/truncatedFields/serializedBytes are not recomputed
// away; when both exist, the earlier loss is accumulated onto the fresh cut
// (counts summed, fields true-union deduplicated and sorted, serialized bytes
// from the latest bound). A fresh report that fired no cut yields the prior.
func mergeFailureTruncation(prior, fresh *FailureTruncation) *FailureTruncation {
	if prior == nil {
		return fresh
	}
	if fresh == nil {
		return prior
	}
	merged := &FailureTruncation{
		DiagnosticsTruncated: true,
		OmittedCount:         prior.OmittedCount + fresh.OmittedCount,
		DroppedCount:         prior.DroppedCount + fresh.DroppedCount,
		SerializedBytes:      fresh.SerializedBytes,
		MinimalFallback:      prior.MinimalFallback || fresh.MinimalFallback,
	}
	// True-union deduplicate and sort the closed field names from both receipts.
	seen := make(map[string]struct{}, len(prior.TruncatedFields)+len(fresh.TruncatedFields))
	for _, field := range append(append([]string(nil), prior.TruncatedFields...), fresh.TruncatedFields...) {
		if field = closedTruncationField(field); field == "" {
			continue
		}
		if _, exists := seen[field]; exists {
			continue
		}
		seen[field] = struct{}{}
		merged.TruncatedFields = append(merged.TruncatedFields, field)
	}
	sort.Strings(merged.TruncatedFields)
	return merged
}

// boundFailureDiagnostic validates one failure diagnostic deterministically.
// The identity fields (code/path/field) are validated byte-for-byte and fail
// closed exactly like the read/projection boundary: a malformed code drops the
// whole diagnostic element (reported via the dropped flag), and a malformed
// path/field identity is emptied (never trimmed or sanitized into a different
// valid identity). The free-text message may be trimmed, made valid UTF-8, and
// rune-capped with an honest cut. It returns the bounded diagnostic, whether
// the element was dropped for an invalid code identity, and the set of field
// names that were cut or failed closed.
func boundFailureDiagnostic(diagnostic FailureDiagnostic) (FailureDiagnostic, bool, []string) {
	code, ok := PublicFailureCode(diagnostic.Code)
	if !ok {
		// A diagnostic without a valid code identity is not a trustworthy
		// diagnostic: fail closed by dropping the whole element.
		return FailureDiagnostic{}, true, nil
	}
	diagnostic.Code = code
	var cut []string
	if path, ok := PublicFailurePath(diagnostic.Path); ok {
		diagnostic.Path = path
	} else if diagnostic.Path != "" {
		diagnostic.Path = ""
		cut = append(cut, "path")
	}
	if field, ok := PublicDiagnosticField(diagnostic.Field); ok {
		diagnostic.Field = field
	} else if diagnostic.Field != "" {
		diagnostic.Field = ""
		cut = append(cut, "field")
	}
	message := boundFailureDiagnosticField(diagnostic.Message, maxFailureDiagnosticMessageRunes)
	if message != strings.TrimSpace(strings.ToValidUTF8(diagnostic.Message, "\uFFFD")) {
		cut = append(cut, "message")
	}
	diagnostic.Message = message
	return diagnostic, false, cut
}

// boundFailureDiagnosticField trims, forces valid UTF-8, and caps a diagnostic
// field to max runes.
func boundFailureDiagnosticField(value string, maxRunes int) string {
	value = strings.TrimSpace(strings.ToValidUTF8(value, "\uFFFD"))
	if runes := []rune(value); len(runes) > maxRunes {
		return string(runes[:maxRunes])
	}
	return value
}
