package schema

import (
	"fmt"
	"strings"

	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// MaxRollupDepth is the platform-wide authoring-time and runtime depth cap
// for rollup summary chains. Both the authoring-time validator (domain/schema)
// and the runtime guard (features/analytics) use this value. If you change
// one, change the other — they are separate constants to avoid a package
// import cycle, but must stay in sync.
const MaxRollupDepth = 8

// ResolvedRollup is the canonical result of rollup summary field resolution.
// It carries both the physical SQL storage type and the canonicalized
// RollupConfig that must be persisted — not the caller's raw casing/whitespace
// variant. Metadata create/update and pack/apply must persist the canonical
// form and use the resolved SQL type for DDL, bypassing the generic
// MapFieldTypeToSQL path which has no entry for "rollupsummary".
type ResolvedRollup struct {
	SQLType         string
	CanonicalConfig models.RollupConfig
	Result          RollupResultMetadata
}

// RollupResultMetadata is the public scalar contract for a rollup result.
// It is intentionally separate from SQLType: aggregate storage keeps enough
// precision for correct computation, while callers need the logical result
// type and formatting facets of the summarized value.
type RollupResultMetadata struct {
	Type      models.FieldType
	Precision *int
	Scale     *int
}

// CloneRollupConfig deep-copies a RollupConfig, including its optional Filter
// pointer, so authoring/mapping paths can round-trip config without aliasing
// mutable caller-owned memory.
func CloneRollupConfig(cfg *models.RollupConfig) *models.RollupConfig {
	if cfg == nil {
		return nil
	}
	cloned := *cfg
	if cfg.Filter != nil {
		filter := *cfg.Filter
		cloned.Filter = &filter
	}
	if cfg.ResultType != nil {
		resultType := *cfg.ResultType
		cloned.ResultType = &resultType
	}
	cloned.ResultPrecision = cloneRollupInt(cfg.ResultPrecision)
	cloned.ResultScale = cloneRollupInt(cfg.ResultScale)
	return &cloned
}

// ResolveRollup is the single-fact authoring contract that returns both the
// canonicalized RollupConfig and the resolved SQL storage type in one call.
// Metadata create/update must call this instead of calling
// ValidateAndCanonicalizeRollupConfig and ResolveRollupSQLType separately,
// ensuring the persisted config and DDL type always come from the same
// authority.
//
// sourceFieldSQLType is the SQL type of the source field on the summary object.
// For COUNT (which has no source field), pass an empty string.
//
// Precision contract (same as ResolveRollupSQLType):
//   - COUNT, SUM, AVG → DECIMAL(65,30)
//   - MIN, MAX → sourceFieldSQLType (pass-through)
func ResolveRollup(parentObjectAPIName string, cfg models.RollupConfig, lookup SchemaLookupFunc, sourceFieldSQLType string) (ResolvedRollup, error) {
	canonical, summaryObject, summaryField, err := validateAndCanonicalizeRollupConfig(parentObjectAPIName, cfg, lookup)
	if err != nil {
		return ResolvedRollup{}, err
	}
	sqlType, err := ResolveRollupSQLType(canonical.CalcType, sourceFieldSQLType)
	if err != nil {
		return ResolvedRollup{}, err
	}
	var result RollupResultMetadata
	if canonical.CalcType == "COUNT" {
		result = rollupResultMetadataForType(constants.FieldTypeInt, nil)
	} else {
		result, err = rollupResultMetadataForField(summaryObject.APIName, *summaryField, lookup, nil)
		if err != nil {
			return ResolvedRollup{}, err
		}
	}
	result = rollupResultMetadataForCalcType(canonical.CalcType, result)
	resultType := result.Type
	canonical.ResultType = &resultType
	canonical.ResultPrecision = cloneRollupInt(result.Precision)
	canonical.ResultScale = cloneRollupInt(result.Scale)
	return ResolvedRollup{
		SQLType:         sqlType,
		CanonicalConfig: canonical,
		Result:          cloneRollupResultMetadata(result),
	}, nil
}

func rollupResultMetadataForCalcType(calcType string, source RollupResultMetadata) RollupResultMetadata {
	if strings.ToUpper(strings.TrimSpace(calcType)) != "AVG" || source.Type != constants.FieldTypeInt {
		return source
	}

	// AVG can produce a fractional value even when each input is an integer.
	// Its aggregate column is DECIMAL(65,30), so publish the corresponding
	// decimal result facets rather than an integer contract that read paths
	// cannot honor.
	return RollupResultMetadata{
		Type:      constants.FieldTypeDouble,
		Precision: rollupIntPtr(65),
		Scale:     rollupIntPtr(30),
	}
}

func resolveRollupResultMetadata(cfg models.RollupConfig, lookup SchemaLookupFunc, seen map[string]struct{}) (RollupResultMetadata, error) {
	calcType := strings.ToUpper(strings.TrimSpace(cfg.CalcType))
	if calcType == "COUNT" {
		return rollupResultMetadataForType(constants.FieldTypeInt, nil), nil
	}
	if lookup == nil {
		return RollupResultMetadata{}, fmt.Errorf("rollupsummary %s requires schema lookup for result type", calcType)
	}
	summaryObject, err := lookup(cfg.SummaryObject)
	if err != nil || summaryObject == nil {
		return RollupResultMetadata{}, fmt.Errorf("rollupsummary %s summaryObject %q not found for result type", calcType, cfg.SummaryObject)
	}
	summaryField, ok := findFieldMetadata(summaryObject.Fields, cfg.SummaryField)
	if !ok {
		return RollupResultMetadata{}, fmt.Errorf("rollupsummary %s summaryField %s.%s not found for result type", calcType, summaryObject.APIName, cfg.SummaryField)
	}
	result, err := rollupResultMetadataForField(summaryObject.APIName, *summaryField, lookup, seen)
	if err != nil {
		return RollupResultMetadata{}, err
	}
	return rollupResultMetadataForCalcType(calcType, result), nil
}

func rollupResultMetadataForField(objectAPIName string, field models.FieldMetadata, lookup SchemaLookupFunc, seen map[string]struct{}) (RollupResultMetadata, error) {
	base := constants.NormalizeFieldType(string(field.Type))
	if base == constants.FieldTypeRollupSummary {
		if field.RollupConfig == nil {
			return RollupResultMetadata{}, fmt.Errorf("rollupsummary source field %q is missing rollupConfig", field.APIName)
		}
		key := strings.ToLower(strings.TrimSpace(objectAPIName)) + "." + strings.ToLower(strings.TrimSpace(field.APIName))
		if seen != nil {
			if _, ok := seen[key]; ok {
				return RollupResultMetadata{}, fmt.Errorf("detected cycle while resolving rollup result type for field %q", field.APIName)
			}
		}
		nextSeen := make(map[string]struct{}, len(seen)+1)
		for seenKey := range seen {
			nextSeen[seenKey] = struct{}{}
		}
		nextSeen[key] = struct{}{}
		return resolveRollupResultMetadata(*field.RollupConfig, lookup, nextSeen)
	}

	resultType := base
	if isFormulaBackedField(field) && field.ReturnType != nil && strings.TrimSpace(string(*field.ReturnType)) != "" {
		resultType = constants.NormalizeFieldType(string(*field.ReturnType))
	}
	return rollupResultMetadataForType(models.FieldType(resultType), &field), nil
}

// isFormulaBackedField keeps rollup public-type derivation aligned with the
// physical schema resolver. Legacy formula fields can retain a scalar Type and
// publish their actual result type through Formula plus ReturnType.
func isFormulaBackedField(field models.FieldMetadata) bool {
	return field.Formula != nil || field.Type == constants.FieldTypeFormula
}

func rollupResultMetadataForType(fieldType models.FieldType, source *models.FieldMetadata) RollupResultMetadata {
	resultType := canonicalRollupResultType(fieldType)
	result := RollupResultMetadata{Type: resultType}
	if source != nil {
		result.Precision = cloneRollupInt(source.Precision)
		result.Scale = cloneRollupInt(source.Scale)
	}

	switch result.Type {
	case constants.FieldTypeInt:
		if result.Precision == nil {
			result.Precision = rollupIntPtr(18)
		}
		result.Scale = rollupIntPtr(0)
	case constants.FieldTypeDouble:
		if result.Precision == nil {
			result.Precision = rollupIntPtr(18)
		}
		if result.Scale == nil {
			result.Scale = rollupIntPtr(0)
		}
	case constants.FieldTypeCurrency, constants.FieldTypePercent:
		if result.Precision == nil {
			result.Precision = rollupIntPtr(18)
		}
		if result.Scale == nil {
			result.Scale = rollupIntPtr(2)
		}
	}
	return result
}

func canonicalRollupResultType(fieldType models.FieldType) models.FieldType {
	switch constants.NormalizeFieldType(string(fieldType)) {
	case "number":
		return constants.FieldTypeDouble
	case "integer", "int", "long":
		return constants.FieldTypeInt
	default:
		return models.FieldType(constants.NormalizeFieldType(string(fieldType)))
	}
}

func cloneRollupResultMetadata(in RollupResultMetadata) RollupResultMetadata {
	out := in
	out.Precision = cloneRollupInt(in.Precision)
	out.Scale = cloneRollupInt(in.Scale)
	return out
}

func cloneRollupInt(value *int) *int {
	if value == nil {
		return nil
	}
	cloned := *value
	return &cloned
}

func rollupIntPtr(value int) *int {
	return &value
}

// SchemaLookupFunc resolves an object's API name and its fields from a
// potentially case-insensitive/whitespace-padded input. The resolver uses
// this to canonicalize SummaryObject, SummaryField, and RelationshipField
// to live schema API names.
type SchemaLookupFunc func(objectAPIName string) (*models.ObjectMetadata, error)

// ResolveRollupSQLType returns the physical SQL storage type for a rollup
// summary field based on its CalcType and the source field's SQL type.
//
// Precision contract:
//   - COUNT, SUM, AVG → DECIMAL(65,30)
//   - MIN, MAX → sourceFieldSQLType (pass-through)
//
// This is the single authoritative path for rollup SQL type resolution.
// Both metadata CRUD and pack/apply must call this instead of
// MapFieldTypeToSQL("rollupsummary"), which returns INVALID_TYPE_ERROR.
func ResolveRollupSQLType(calcType, sourceFieldSQLType string) (string, error) {
	normalized := strings.ToUpper(strings.TrimSpace(calcType))
	switch normalized {
	case "COUNT":
		return "DECIMAL(65,30)", nil
	case "SUM", "AVG":
		if !isNumericRollupStorageType(sourceFieldSQLType) {
			return "", fmt.Errorf("rollupsummary %s requires numeric source field SQL type, got %q", normalized, sourceFieldSQLType)
		}
		return "DECIMAL(65,30)", nil
	case "MIN", "MAX":
		if strings.TrimSpace(sourceFieldSQLType) == "" {
			return "", fmt.Errorf("rollupsummary MIN/MAX requires a non-empty source field SQL type")
		}
		return sourceFieldSQLType, nil
	default:
		return "", fmt.Errorf("unsupported rollupsummary calcType: %q", calcType)
	}
}

// ResolveSchemaFieldSQLType resolves the physical SQL type for an existing
// schema field. Formula-backed fields must resolve through ReturnType rather
// than their public logical Type so pack/apply and metadata CRUD derive the
// same rollup source-field contract.
func ResolveSchemaFieldSQLType(field *models.FieldMetadata, mapFieldTypeToSQL func(string) string) (string, error) {
	if field == nil {
		return "", fmt.Errorf("nil schema field")
	}
	if mapFieldTypeToSQL == nil {
		return "", fmt.Errorf("nil field type mapper")
	}

	isFormulaBacked := isFormulaBackedField(*field)
	typeName := string(field.Type)
	if isFormulaBacked && field.ReturnType != nil && strings.TrimSpace(string(*field.ReturnType)) != "" {
		typeName = string(*field.ReturnType)
	}

	sqlType := mapFieldTypeToSQL(strings.TrimSpace(typeName))
	if sqlType == "" || sqlType == "INVALID_TYPE_ERROR" {
		if isFormulaBacked {
			return "", fmt.Errorf("formula field %s has unresolvable returnType %q", field.APIName, typeName)
		}
		return "", fmt.Errorf("unknown logical type %q", field.Type)
	}
	return sqlType, nil
}

// ValidateAndCanonicalizeRollupConfig canonicalizes a RollupConfig and then
// validates the shared authoring contract that both metadata CRUD and
// pack/apply must enforce:
//   - SummaryObject must resolve through the supplied lookup
//   - RelationshipField must exist on SummaryObject and reference only the
//     parent object being authored
//   - Non-COUNT calc types must resolve SummaryField on SummaryObject
//
// The returned config uses live API names from the lookup for every resolved
// field/object so callers can persist the canonical form directly.
func ValidateAndCanonicalizeRollupConfig(parentObjectAPIName string, cfg models.RollupConfig, lookup SchemaLookupFunc) (models.RollupConfig, error) {
	canonical, _, _, err := validateAndCanonicalizeRollupConfig(parentObjectAPIName, cfg, lookup)
	return canonical, err
}

func validateAndCanonicalizeRollupConfig(parentObjectAPIName string, cfg models.RollupConfig, lookup SchemaLookupFunc) (models.RollupConfig, *models.ObjectMetadata, *models.FieldMetadata, error) {
	canonical := CanonicalizeRollupConfig(cfg, lookup)
	calcType := strings.ToUpper(strings.TrimSpace(canonical.CalcType))
	parentObjectAPIName = strings.TrimSpace(parentObjectAPIName)

	if strings.TrimSpace(canonical.SummaryObject) == "" {
		return models.RollupConfig{}, nil, nil, fmt.Errorf("rollupsummary %s requires summaryObject", calcType)
	}
	if lookup == nil {
		return models.RollupConfig{}, nil, nil, fmt.Errorf("rollupsummary %s requires schema lookup for summaryObject %q", calcType, canonical.SummaryObject)
	}

	summaryObject, err := lookup(canonical.SummaryObject)
	if err != nil || summaryObject == nil {
		return models.RollupConfig{}, nil, nil, fmt.Errorf("rollupsummary %s summaryObject %q not found", calcType, canonical.SummaryObject)
	}
	canonical.SummaryObject = summaryObject.APIName

	if strings.TrimSpace(canonical.RelationshipField) == "" {
		return models.RollupConfig{}, nil, nil, fmt.Errorf("rollupsummary %s requires relationshipField", calcType)
	}
	relationshipField, ok := findFieldMetadata(summaryObject.Fields, canonical.RelationshipField)
	if !ok {
		return models.RollupConfig{}, nil, nil, fmt.Errorf("rollupsummary relationshipField %s.%s not found", canonical.SummaryObject, canonical.RelationshipField)
	}
	canonical.RelationshipField = relationshipField.APIName
	if parentObjectAPIName == "" || !fieldMetadataReferencesOnlyObject(relationshipField, parentObjectAPIName) {
		return models.RollupConfig{}, nil, nil, fmt.Errorf("rollupsummary relationshipField %s.%s must reference only %s", canonical.SummaryObject, canonical.RelationshipField, parentObjectAPIName)
	}

	if calcType == "COUNT" {
		canonical.SummaryField = "*"
		return canonical, summaryObject, nil, nil
	}

	if strings.TrimSpace(canonical.SummaryField) == "" {
		return models.RollupConfig{}, nil, nil, fmt.Errorf("rollupsummary %s requires summaryField", calcType)
	}
	summaryField, ok := findFieldMetadata(summaryObject.Fields, canonical.SummaryField)
	if !ok {
		return models.RollupConfig{}, nil, nil, fmt.Errorf("rollupsummary summaryField %s.%s not found", canonical.SummaryObject, canonical.SummaryField)
	}
	canonical.SummaryField = summaryField.APIName

	return canonical, summaryObject, summaryField, nil
}

// CanonicalizeRollupConfig normalizes a RollupConfig by trimming whitespace,
// uppercasing CalcType, and resolving SummaryObject/SummaryField/RelationshipField
// to canonical API names via the schema lookup. When the lookup succeeds,
// the canonical name replaces the raw input; when it fails, the trimmed raw
// value is kept (the validator will catch missing objects later).
//
// COUNT fields: SummaryField is set to "*" regardless of input.
//
// This is the single authoritative path for RollupConfig canonicalization.
// Both metadata CRUD and pack/apply must persist the canonical form.
func CanonicalizeRollupConfig(cfg models.RollupConfig, lookup SchemaLookupFunc) models.RollupConfig {
	result := cfg
	// Result facets are resolver-owned derived facts, never caller-authored
	// configuration. ResolveRollup populates them only after the source field
	// has been resolved through the canonical schema lookup.
	result.ResultType = nil
	result.ResultPrecision = nil
	result.ResultScale = nil
	result.CalcType = strings.ToUpper(strings.TrimSpace(result.CalcType))
	result.SummaryObject = strings.TrimSpace(result.SummaryObject)
	result.SummaryField = strings.TrimSpace(result.SummaryField)
	result.RelationshipField = strings.TrimSpace(result.RelationshipField)

	// Canonicalize SummaryObject via schema lookup.
	if lookup != nil && strings.TrimSpace(result.SummaryObject) != "" {
		objMeta, err := lookup(result.SummaryObject)
		if err == nil && objMeta != nil {
			result.SummaryObject = objMeta.APIName
			// Canonicalize SummaryField and RelationshipField within the
			// resolved object.
			if strings.TrimSpace(result.SummaryField) != "" {
				for _, f := range objMeta.Fields {
					if strings.EqualFold(f.APIName, result.SummaryField) {
						result.SummaryField = f.APIName
						break
					}
				}
			}
			if strings.TrimSpace(result.RelationshipField) != "" {
				for _, f := range objMeta.Fields {
					if strings.EqualFold(f.APIName, result.RelationshipField) {
						result.RelationshipField = f.APIName
						break
					}
				}
			}
		}
	}

	// COUNT always uses "*" as SummaryField (no source field needed).
	if result.CalcType == "COUNT" {
		result.SummaryField = "*"
	}

	// Preserve Filter pointer if present.
	if cfg.Filter != nil {
		filter := *result.Filter
		result.Filter = &filter
	}

	return result
}

func findFieldMetadata(fields []models.FieldMetadata, fieldAPIName string) (*models.FieldMetadata, bool) {
	for i := range fields {
		if strings.EqualFold(fields[i].APIName, strings.TrimSpace(fieldAPIName)) {
			return &fields[i], true
		}
	}
	return nil, false
}

func fieldMetadataReferencesOnlyObject(field *models.FieldMetadata, objectAPIName string) bool {
	if field == nil {
		return false
	}
	if field.IsPolymorphic {
		return false
	}
	return referencesOnlyObject(field.ReferenceTo, objectAPIName) || referencesOnlyObject(field.WritableReferenceTo, objectAPIName)
}

func referencesOnlyObject(values []string, want string) bool {
	want = strings.TrimSpace(want)
	if want == "" {
		return false
	}
	var seen string
	for _, value := range values {
		trimmed := strings.TrimSpace(value)
		if trimmed == "" {
			continue
		}
		if seen != "" {
			return false
		}
		seen = trimmed
	}
	return seen != "" && strings.EqualFold(seen, want)
}

func isNumericRollupStorageType(sqlType string) bool {
	switch upper := strings.ToUpper(strings.TrimSpace(sqlType)); {
	case upper == "INT", upper == "INTEGER", upper == "BIGINT", upper == "SMALLINT", upper == "TINYINT", upper == "MEDIUMINT":
		return true
	case strings.HasPrefix(upper, "DECIMAL("), strings.HasPrefix(upper, "NUMERIC("), strings.HasPrefix(upper, "FLOAT"), strings.HasPrefix(upper, "DOUBLE"), strings.HasPrefix(upper, "REAL"):
		return true
	default:
		return false
	}
}

// ValidateRollupGraph validates that the rollup summary field declarations
// across all objects in the metadata snapshot do not contain directed cycles
// (including self-loops) and do not exceed MaxRollupDepth chain depth.
//
// changedObjectName is the object whose fields are being authored; the
// validator checks the full graph (existing + proposed) to catch
// cross-object cycles.
//
// This is the shared authoring-time validator used by both metadata CRUD
// and pack/apply. The controlplane/packs validator delegates to this.
func ValidateRollupGraph(allSchemas []*models.ObjectMetadata, changedObjectName string) error {
	// Build directed adjacency list: parent → children it rolls up over.
	// Edge: object P with rollup field summarizing object C → P→C.
	type edge struct {
		parentField string
		childKey    string // canonical (lowercased)
	}

	// canonicalizeObjectKey matches the pack pipeline's canonicalization.
	canonicalizeObjectKey := func(name string) string {
		return strings.ToLower(strings.TrimSpace(name))
	}

	// rawName maps canonical key → first-seen raw name for error messages.
	rawName := make(map[string]string)
	adj := make(map[string][]edge)

	// Build edges from all schemas.
	for _, obj := range allSchemas {
		if obj == nil {
			continue
		}
		parentKey := canonicalizeObjectKey(obj.APIName)
		if parentKey == "" {
			continue
		}
		if _, seen := rawName[parentKey]; !seen {
			rawName[parentKey] = obj.APIName
		}
		for _, f := range obj.Fields {
			if f.RollupConfig == nil {
				continue
			}
			childKey := canonicalizeObjectKey(f.RollupConfig.SummaryObject)
			if childKey == "" {
				continue
			}
			if _, seen := rawName[childKey]; !seen {
				rawName[childKey] = strings.TrimSpace(f.RollupConfig.SummaryObject)
			}
			adj[parentKey] = append(adj[parentKey], edge{
				parentField: f.APIName,
				childKey:    childKey,
			})
		}
	}

	if len(adj) == 0 {
		return nil
	}

	// Collect all distinct nodes.
	allNodes := make(map[string]struct{})
	for k := range adj {
		allNodes[k] = struct{}{}
	}
	for _, edges := range adj {
		for _, e := range edges {
			allNodes[e.childKey] = struct{}{}
		}
	}

	// Iterative DFS for cycle detection.
	const (
		unvisited = 0
		onStack   = 1
		done      = 2
	)
	state := make(map[string]int, len(allNodes))

	type pathEntry struct {
		nodeKey     string
		parentField string
		viaParent   string
	}
	type frame struct {
		nodeKey string
		edges   int
		entry   pathEntry
	}

	var cycleErrs []string

	raw := func(key string) string {
		if r, ok := rawName[key]; ok {
			return r
		}
		return key
	}

	reconstructCycle := func(pathStack []pathEntry, targetKey string, backEdgeField string, srcKey string) string {
		start := -1
		for i, e := range pathStack {
			if e.nodeKey == targetKey {
				start = i
				break
			}
		}
		var parts []string
		if start >= 0 {
			for i := start; i < len(pathStack); i++ {
				e := pathStack[i]
				if e.parentField != "" {
					parts = append(parts, fmt.Sprintf("%s.%s", e.viaParent, e.parentField))
				}
				parts = append(parts, raw(e.nodeKey))
			}
		}
		parts = append(parts, fmt.Sprintf("%s.%s", raw(srcKey), backEdgeField))
		parts = append(parts, raw(targetKey))
		return strings.Join(parts, " → ")
	}

	for startKey := range allNodes {
		if state[startKey] != unvisited {
			continue
		}
		stack := []frame{{nodeKey: startKey, edges: 0, entry: pathEntry{nodeKey: startKey}}}
		pathStack := []pathEntry{{nodeKey: startKey}}
		state[startKey] = onStack

		for len(stack) > 0 {
			top := &stack[len(stack)-1]
			edges := adj[top.nodeKey]
			if top.edges >= len(edges) {
				state[top.nodeKey] = done
				stack = stack[:len(stack)-1]
				if len(pathStack) > 0 {
					pathStack = pathStack[:len(pathStack)-1]
				}
				continue
			}
			e := edges[top.edges]
			top.edges++
			switch state[e.childKey] {
			case onStack:
				cycleChain := reconstructCycle(pathStack, e.childKey, e.parentField, top.nodeKey)
				cycleErrs = append(cycleErrs, fmt.Sprintf("rollup cycle detected: %s", cycleChain))
			case unvisited:
				state[e.childKey] = onStack
				entry := pathEntry{
					nodeKey:     e.childKey,
					parentField: e.parentField,
					viaParent:   raw(top.nodeKey),
				}
				stack = append(stack, frame{nodeKey: e.childKey, edges: 0, entry: entry})
				pathStack = append(pathStack, entry)
			}
		}
	}

	// Depth check via Kahn's algorithm (topological sort + DP).
	type depthEntry struct {
		depth    int
		via      string
		viaField string
	}
	depthInfo := make(map[string]depthEntry, len(allNodes))
	inDeg := make(map[string]int, len(allNodes))
	for k := range allNodes {
		inDeg[k] = 0
	}
	for parentKey, edges := range adj {
		for _, e := range edges {
			inDeg[parentKey] = inDeg[parentKey] // ensure key exists
			inDeg[e.childKey]++
		}
	}

	queue := make([]string, 0, len(allNodes))
	for k := range allNodes {
		if inDeg[k] == 0 {
			queue = append(queue, k)
		}
	}

	for len(queue) > 0 {
		cur := queue[0]
		queue = queue[1:]
		curDepth := depthInfo[cur].depth
		for _, e := range adj[cur] {
			newDepth := curDepth + 1
			if newDepth > depthInfo[e.childKey].depth {
				depthInfo[e.childKey] = depthEntry{
					depth:    newDepth,
					via:      cur,
					viaField: e.parentField,
				}
			}
			inDeg[e.childKey]--
			if inDeg[e.childKey] == 0 {
				queue = append(queue, e.childKey)
			}
		}
	}

	var depthErrs []string
	for nodeKey, info := range depthInfo {
		if info.depth >= MaxRollupDepth {
			type hop struct {
				parentKey string
				field     string
				childKey  string
			}
			var hops []hop
			cur := nodeKey
			for {
				d := depthInfo[cur]
				if d.via == "" {
					break
				}
				hops = append(hops, hop{parentKey: d.via, field: d.viaField, childKey: cur})
				cur = d.via
			}
			for i, j := 0, len(hops)-1; i < j; i, j = i+1, j-1 {
				hops[i], hops[j] = hops[j], hops[i]
			}
			var parts []string
			if len(hops) > 0 {
				parts = append(parts, raw(hops[0].parentKey))
				for _, h := range hops {
					parts = append(parts, fmt.Sprintf("%s.%s", raw(h.parentKey), h.field))
					parts = append(parts, raw(h.childKey))
				}
			} else {
				parts = []string{raw(nodeKey)}
			}
			depthErrs = append(depthErrs, fmt.Sprintf(
				"rollup chain exceeds maximum allowed depth (%d): %s",
				MaxRollupDepth, strings.Join(parts, " → ")))
		}
	}

	var allErrs []string
	allErrs = append(allErrs, cycleErrs...)
	allErrs = append(allErrs, depthErrs...)
	if len(allErrs) == 0 {
		return nil
	}
	return fmt.Errorf("rollup cycle validation failed:\n  - %s", strings.Join(allErrs, "\n  - "))
}

// IsRollupSummaryType returns true when the field type is rollupsummary.
func IsRollupSummaryType(fieldType constants.SchemaFieldType) bool {
	return constants.NormalizeFieldType(string(fieldType)) == constants.FieldTypeRollupSummary
}
