package orchestrator

import (
	"encoding/json"
	"errors"
	"strings"

	"github.com/tidbcloud/AgentTeam/internal/workflow/run"
)

// Bounds for the persisted public failure payload. Every field is capped so
// a runtime error can never push unbounded or operational text into the durable
// error_json that Run show/log/effect/activation project.
const (
	maxFailureCodeRunes       = 128
	maxFailureStageRunes      = 64
	maxFailurePathRunes       = 1024
	maxFailureProblemRunes    = 1024
	maxFailureHintRunes       = 1024
	maxFailureMessageRunes    = 1024
	maxFailureDiagnostics     = 16
	maxFailureSerializedBytes = 32 * 1024
)

// PersistedFailure is the public-safe failure payload persisted in
// error_json for a Run, activation, or effect. It carries only stable, bounded
// fields: code, stage, path, problem, hint, retryable, safe diagnostics, an
// optional WorkItem reference, and truncation metadata. Arbitrary causes, SQL,
// stack traces, credentials, and tenant identifiers are never persisted here.
type PersistedFailure struct {
	Code        string                `json:"code"`
	Stage       string                `json:"stage,omitempty"`
	Path        string                `json:"path,omitempty"`
	Problem     string                `json:"problem,omitempty"`
	Hint        string                `json:"hint,omitempty"`
	Retryable   bool                  `json:"retryable,omitempty"`
	Diagnostics []PersistedDiagnostic `json:"diagnostics,omitempty"`
	WorkItem    *PersistedWorkItem    `json:"workItem,omitempty"`
	// Truncation reports the deterministic budget cuts the persisted encoder
	// applied to Diagnostics (count cap, per-field rune cap, or serialized-size
	// cap). It is populated only when a cut fired, so its presence is itself the
	// "this payload was truncated" signal carried through Run/effect/activation.
	Truncation *PersistedTruncation `json:"truncation,omitempty"`
}

// PersistedTruncation reports deterministic budget cuts applied to the persisted
// failure diagnostics so a caller can distinguish a full list from a bounded
// one. It mirrors the publicerr.Truncation contract for the runtime persistence
// path and is carried through Run show/log/effect/activation projections.
type PersistedTruncation struct {
	DiagnosticsTruncated bool     `json:"diagnosticsTruncated,omitempty"`
	OmittedCount         int      `json:"omittedCount,omitempty"`
	DroppedCount         int      `json:"droppedCount,omitempty"`
	TruncatedFields      []string `json:"truncatedFields,omitempty"`
	SerializedBytes      int      `json:"serializedBytes,omitempty"`
	MinimalFallback      bool     `json:"minimalFallback,omitempty"`
}

// PersistedDiagnostic is a safe bounded diagnostic persisted alongside a
// failure. It carries only stable code/path/field/index/message values and
// never operational identifiers or arbitrary cause text.
type PersistedDiagnostic struct {
	Code    string `json:"code,omitempty"`
	Path    string `json:"path,omitempty"`
	Field   string `json:"field,omitempty"`
	Index   *int   `json:"index,omitempty"`
	Message string `json:"message,omitempty"`
}

// PersistedWorkItem identifies the exact dynamic WorkItem whose dependency
// materialization or settlement failed, so the projection can pinpoint it.
type PersistedWorkItem struct {
	Key            string `json:"key,omitempty"`
	Index          *int   `json:"index,omitempty"`
	DependencyPath string `json:"dependencyPath,omitempty"`
}

// boundedFailureField trims, forces valid UTF-8, and caps a free-text failure
// field (problem/hint/message). It must only be used for free text: identity
// fields (code/stage/path/workItem key/dependencyPath) are validated
// byte-for-byte by the exact validators and never trimmed, sanitized, or
// capped into a different valid identity.
func boundedFailureField(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
}

// persistedIdentityField validates one persisted identity byte-for-byte with the
// shared exact grammar and fails closed (empty) when the value is not an
// exact valid identity. It is never TrimSpace'd, UTF-8-replacement-converted, or
// capped into a different valid identity.
func persistedIdentityField(value string, validate func(string) (string, bool)) string {
	accepted, ok := validate(value)
	if !ok {
		return ""
	}
	return accepted
}

// newPersistedFailure builds a persisted public failure from a stable code,
// optional stage/path/problem/hint, the retryable decision, safe diagnostics,
// an optional WorkItem reference, and optional prior truncation metadata. It
// never accepts an arbitrary cause string. Identity fields (code/stage/path/
// workItem key/dependencyPath) are validated byte-for-byte and fail closed to
// empty when malformed; free-text fields (problem/hint) are bounded with honest
// truncation. Prior truncation is carried forward so a replay that re-bounds an
// already-bounded list preserves the earlier omittedCount/truncatedFields/
// serializedBytes instead of recomputing them away.
func newPersistedFailure(code, stage, path, problem, hint string, retryable bool, diagnostics []PersistedDiagnostic, workItem *PersistedWorkItem, priorTruncation *PersistedTruncation) PersistedFailure {
	failure := PersistedFailure{
		Code:      persistedIdentityField(code, run.PublicFailureCode),
		Stage:     persistedIdentityField(stage, run.PublicFailureStage),
		Path:      persistedIdentityField(path, run.PublicFailurePath),
		Problem:   boundedFailureField(problem, maxFailureProblemRunes),
		Hint:      boundedFailureField(hint, maxFailureHintRunes),
		Retryable: retryable,
	}
	// Always route through the shared carried budget so prior truncation
	// metadata survives even when the replayed/all-dropped diagnostics list is
	// empty; an empty list never fabricates diagnostics but preserves the earlier
	// omittedCount/truncatedFields/serializedBytes.
	failure.Diagnostics, failure.Truncation = boundPersistedDiagnosticsCarried(diagnostics, priorTruncation)
	if workItem != nil {
		key := persistedIdentityField(workItem.Key, run.PublicWorkItemKey)
		dependencyPath := persistedIdentityField(workItem.DependencyPath, run.ValidDependencyPath)
		if key != "" {
			failure.WorkItem = &PersistedWorkItem{
				Key:            key,
				DependencyPath: dependencyPath,
			}
			if workItem.Index != nil {
				index := *workItem.Index
				failure.WorkItem.Index = &index
			}
		}
	}
	return failure
}

// boundEffectResultFailureDiagnostics applies the single shared diagnostic
// budget to a persisted diagnostic list and carries prior truncation metadata
// forward. It is the one projection used by the durable EffectResult encoder so
// the persisted ResultJSON, its ErrorJSON, and the failure events report the
// exact same deterministic cuts.
func boundEffectResultFailureDiagnostics(diagnostics []PersistedDiagnostic, prior *PersistedTruncation) ([]PersistedDiagnostic, *PersistedTruncation) {
	if len(diagnostics) == 0 {
		// An empty EffectResult diagnostics list must still carry a prior
		// truncation report forward so a replay of an all-dropped diagnostics
		// list preserves the earlier omittedCount/truncatedFields/serializedBytes.
		return nil, prior
	}
	bounded, truncation := boundPersistedDiagnosticsCarried(diagnostics, prior)
	return bounded, truncation
}

// boundPersistedDiagnosticsCarried bounds a persisted diagnostic list and
// carries any prior truncation metadata forward through the single shared kernel
// budget, so recovery/replay preserves earlier cuts instead of recomputing them
// away on an already-bounded list.
func boundPersistedDiagnosticsCarried(diagnostics []PersistedDiagnostic, prior *PersistedTruncation) ([]PersistedDiagnostic, *PersistedTruncation) {
	if len(diagnostics) == 0 {
		// Preserve prior truncation metadata across an empty-list replay/import:
		// the earlier omittedCount/truncatedFields/serializedBytes survive without
		// fabricating diagnostics. A nil prior yields nil.
		return nil, prior
	}
	kernelDiagnostics := make([]run.FailureDiagnostic, 0, len(diagnostics))
	for _, diagnostic := range diagnostics {
		kernelDiagnostics = append(kernelDiagnostics, run.FailureDiagnostic{
			Code:    diagnostic.Code,
			Path:    diagnostic.Path,
			Field:   diagnostic.Field,
			Index:   diagnostic.Index,
			Message: diagnostic.Message,
		})
	}
	bounded, truncation := run.BoundFailureDiagnosticsCarried(kernelDiagnostics, kernelFailureTruncation(prior))
	if len(bounded) == 0 {
		return nil, persistedTruncation(truncation)
	}
	projected := make([]PersistedDiagnostic, 0, len(bounded))
	for _, diagnostic := range bounded {
		projected = append(projected, PersistedDiagnostic{
			Code:    diagnostic.Code,
			Path:    diagnostic.Path,
			Field:   diagnostic.Field,
			Index:   diagnostic.Index,
			Message: diagnostic.Message,
		})
	}
	return projected, persistedTruncation(truncation)
}

// persistedTruncation maps the kernel failure truncation report onto the
// persisted truncation contract. A nil kernel report (no cut) yields nil.
func persistedTruncation(truncation *run.FailureTruncation) *PersistedTruncation {
	if truncation == nil {
		return nil
	}
	return &PersistedTruncation{
		DiagnosticsTruncated: truncation.DiagnosticsTruncated,
		OmittedCount:         truncation.OmittedCount,
		DroppedCount:         truncation.DroppedCount,
		TruncatedFields:      append([]string(nil), truncation.TruncatedFields...),
		SerializedBytes:      truncation.SerializedBytes,
		MinimalFallback:      truncation.MinimalFallback,
	}
}

// kernelFailureTruncation maps a persisted truncation report onto the kernel
// failure truncation contract so a replayed EffectResult carries its earlier
// budget cuts forward into the kernel Failure. A nil persisted report yields nil.
func kernelFailureTruncation(truncation *PersistedTruncation) *run.FailureTruncation {
	if truncation == nil {
		return nil
	}
	return &run.FailureTruncation{
		DiagnosticsTruncated: truncation.DiagnosticsTruncated,
		OmittedCount:         truncation.OmittedCount,
		DroppedCount:         truncation.DroppedCount,
		TruncatedFields:      append([]string(nil), truncation.TruncatedFields...),
		SerializedBytes:      truncation.SerializedBytes,
		MinimalFallback:      truncation.MinimalFallback,
	}
}

// encodePersistedFailure serializes a persisted public failure. The payload
// is always a JSON object (the store's typed error boundary) and never
// contains arbitrary cause text.
func encodePersistedFailure(failure PersistedFailure) (json.RawMessage, error) {
	if failure.Code == "" {
		return nil, errEmptyPersistedFailureCode
	}
	payload, err := json.Marshal(failure)
	if err != nil {
		return nil, err
	}
	return payload, nil
}

var errEmptyPersistedFailureCode = errors.New("persisted failure requires a stable code")

// runFailureFromKernel projects a runkernel.Failure into the persisted
// persisted payload, deriving stage/path/problem/hint/retryable/workItem from
// the kernel fields when present. The arbitrary authoring Message is never used
// as the persisted hint or problem: problem/hint always come from the kernel's
// explicit Problem/Hint fields or the stable code mapping, so no cause text
// crosses the storage boundary.
func runFailureFromKernel(failure *run.Failure, fallbackStage string) (json.RawMessage, error) {
	if failure == nil {
		return nil, nil
	}
	stage := persistedIdentityField(failure.Stage, run.PublicFailureStage)
	if stage == "" {
		stage = persistedIdentityField(fallbackStage, run.PublicFailureStage)
	}
	problem := boundedFailureField(firstNonEmpty(failure.Problem, run.FailureProblem(failure.Code)), maxFailureProblemRunes)
	hint := boundedFailureField(firstNonEmpty(failure.Hint, run.FailureHint(failure.Code)), maxFailureHintRunes)
	var workItem *PersistedWorkItem
	if failure.WorkItem != nil {
		workItem = &PersistedWorkItem{
			Key:            failure.WorkItem.Key,
			DependencyPath: failure.WorkItem.DependencyPath,
			Index:          failure.WorkItem.Index,
		}
	}
	persisted := newPersistedFailure(
		failure.Code,
		stage,
		failure.Path,
		problem,
		hint,
		failure.Retryable,
		projectFailureDiagnostics(failure.Diagnostics),
		workItem,
		persistedTruncation(failure.Truncation),
	)
	return encodePersistedFailure(persisted)
}

// projectFailureDiagnostics carries kernel failure diagnostics into the
// persisted payload without any count pre-cap: the complete list reaches the
// single budget at the persistence boundary (boundPersistedDiagnostics), which
// applies the count/field/serialized-size caps exactly once and reports the
// truncation metadata (omittedCount, truncatedFields, serializedBytes).
func projectFailureDiagnostics(diagnostics []run.FailureDiagnostic) []PersistedDiagnostic {
	if len(diagnostics) == 0 {
		return nil
	}
	projected := make([]PersistedDiagnostic, 0, len(diagnostics))
	for _, diagnostic := range diagnostics {
		projected = append(projected, PersistedDiagnostic{
			Code:    diagnostic.Code,
			Path:    diagnostic.Path,
			Field:   diagnostic.Field,
			Index:   diagnostic.Index,
			Message: diagnostic.Message,
		})
	}
	return projected
}

// kernelFailureDiagnostics converts persisted diagnostics back into the kernel
// shape for completion/reducer consumption, carrying the complete persisted
// list (already budgeted at write time) so replay round trips are lossless.
func kernelFailureDiagnostics(diagnostics []PersistedDiagnostic) []run.FailureDiagnostic {
	if len(diagnostics) == 0 {
		return nil
	}
	projected := make([]run.FailureDiagnostic, 0, len(diagnostics))
	for _, diagnostic := range diagnostics {
		projected = append(projected, run.FailureDiagnostic{
			Code:    diagnostic.Code,
			Path:    diagnostic.Path,
			Field:   diagnostic.Field,
			Index:   diagnostic.Index,
			Message: diagnostic.Message,
		})
	}
	return projected
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return value
		}
	}
	return ""
}
