package formula

// relation_resolver.go — single source for relation-name → FK column → fetch
// resolution shared by records/ and automation/.
//
// CLAUDE.md R10: "for a given (user, object, record) tuple, every read path
// for the same fact must go through the same metadata helper. Multiple paths
// is the defect even when answers currently agree."
//
// Pre-#2057 records.buildRecordFetcher and automation.buildFormulaFetcher
// each carried their own relation-resolution + fetch logic. Even when their
// outputs agreed, the duplication was the defect (R10): the next time
// somebody touched RelationshipName precedence, reference target resolution,
// or the 3-value (resolved, definitive-null, indeterminate) contract, only
// one path got the change. This file is the single home; the two
// callers each provide a thin RelationFetchDeps adapter for their own I/O
// backend (records: bypass-RLS repo.FindOne; automation: RLS-aware
// QueryService scoped to the interview user).
//
// What lives here:
//   - ResolveRelationFK: pure metadata/convention function from segment to FK column.
//   - FetchRelatedRecord: 3-value contract that turns a (record, relationName)
//     into (related, target, resolved).
//
// What does NOT live here:
//   - Caching (per-interview rCache stays in automation/loadRelatedRecord;
//     records/ does not cache today).
//   - Logging policy (each caller owns its own WRN prefix so log greps stay
//     greppable per subsystem).
//   - Access control (each caller's adapter wires the appropriate I/O path).

import (
	"context"
	"strings"

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

// RelationFetchDeps abstracts the I/O backend so each caller plugs in its own
// access-control story. The interface is deliberately small (two methods) so
// adapters stay ~15-30 LOC each.
//
//   - LookupReferenceTo answers "what target object(s) does this FK column
//     point at?" The slice is the field's ReferenceTo (length 1 today; the
//     helper picks index 0; polymorphic targets are a separate roadmap item).
//
//   - FetchByID retrieves the parent record by ID. Implementations decide
//     access-control rules. Contract:
//     (record, nil)  → success
//     (nil,    nil)  → orphan FK (no such row)
//     (nil,    err)  → transient error / metadata miss
type RelationFetchDeps interface {
	LookupReferenceTo(parentObjectAPIName, fkColumn string) []string
	FetchByID(ctx context.Context, targetObjectAPIName, id string) (map[string]interface{}, error)
}

// LookupStandardRelationFK returns the generic Salesforce convention FK column
// for metadata-free callers. Runtime paths with ObjectMetadata should prefer
// ResolveRelationFK so relationshipName/reference metadata is authoritative.
func LookupStandardRelationFK(segment string) (string, bool) {
	if segment == "" || strings.HasSuffix(segment, "__r") {
		return "", false
	}
	return segment + "Id", true
}

// ResolveRelationFK maps a relationship segment to its FK column using four
// tiers in priority order:
//
//  1. RelationshipName match on a schema field (metadata-driven, most
//     precise; lets admins rename a relationship without breaking formulas).
//  2. Custom __r suffix → __c (Salesforce naming convention; works without
//     metadata, e.g. when called from a metadata-free fast path).
//  3. Salesforce convention "<segment>Id" only when the field exists on the
//     supplied schema and is a reference. Metadata-free callers fall back to
//     the same convention through LookupStandardRelationFK.
//
// Tier 1 must beat Tier 2 because a field with APIName "ParentId" and
// RelationshipName "Parent__r" should resolve to "ParentId", not "Parent__c".
// The same precedence trap was already fixed independently in records/ and
// automation/ before consolidation; this helper enforces it for both.
//
// Returns "" when no FK can be resolved (segment empty, or none of the four
// tiers match). Callers treat "" as a definitive null per the
// FetchRelatedRecord contract.
func ResolveRelationFK(schema *models.ObjectMetadata, segment string) string {
	if segment == "" {
		return ""
	}
	// Tier 1: metadata-driven RelationshipName lookup.
	if schema != nil {
		for i := range schema.Fields {
			f := &schema.Fields[i]
			if f.RelationshipName != nil && *f.RelationshipName == segment {
				return f.APIName
			}
		}
	}
	// Tier 2: custom relationship naming convention.
	if strings.HasSuffix(segment, "__r") {
		return strings.TrimSuffix(segment, "__r") + "__c"
	}
	candidate := segment + "Id"
	if schema == nil {
		return candidate
	}
	for i := range schema.Fields {
		f := &schema.Fields[i]
		if f.APIName == candidate && (len(f.ReferenceTo) > 0 || constants.NormalizeFieldType(string(f.Type)) == constants.FieldTypeReference) {
			return candidate
		}
	}
	return ""
}

// ResolveRelationTarget resolves the target SObject name for a relationship
// FK on a given record. Encapsulates the polymorphic-discriminator gate so
// every reader of the (record, fkColumn) → target fact returns the same
// answer.
//
// Readers wired through this helper (CLAUDE.md R10 enumeration, PR #2057
// round-7 F5 / round-8 polymorphic FK fix):
//
//   - pkg/formula.FetchRelatedRecord (records + automation fetch path)
//   - automation.loadRelatedRecord pre-compute (cache key + attributes.type
//     stamp; must match FetchRelatedRecord's resolved target byte-for-byte
//     so a polymorphic reference pointing at one declared target is not
//     cached / stamped as another).
//   - metadata.resolveValidationRelation (validation formula closure;
//     records.FindOne is invoked directly with this target so a discriminator
//     mismatch must fail-closed rather than query the wrong table).
//
// Return contract:
//
//   - (target, true)  → success: caller should fetch from `target`.
//   - ("",     false) → indeterminate: no ReferenceTo metadata, polymorphic
//     reference with missing/empty/non-string discriminator, or
//     discriminator value not in the declared ReferenceTo set. Callers MUST
//     surface this as indeterminate (records → ErrRelationFetch; automation
//     → flow.r10:WRN; validation → silent skip with nil parent) — never
//     fall through to refTo[0].
//
// Single-target references (len(refTo) == 1) are unambiguous. Polymorphic
// references (len(refTo) >= 2) consult `<fkColumn>` + constants.Polymorphic
// TypeSuffix on the record; the value is validated against `refTo` so an
// arbitrary string is never trusted as a target SObject name.
func ResolveRelationTarget(record map[string]interface{}, fkColumn string, refTo []string) (string, bool) {
	switch len(refTo) {
	case 0:
		return "", false
	case 1:
		return refTo[0], true
	}
	// Polymorphic reference (e.g. GenericRefId → [TypeA, TypeB],
	// AnotherRefId → [TypeC, TypeD, …]). Pre-#2057-round-7,
	// callers picked refTo[0] for any length, so frozen formulas / flow
	// fetches / validation could query the wrong target object when the row's
	// actual target was not the first ReferenceTo entry.
	if record == nil {
		return "", false
	}
	if fkColumn == "" {
		return "", false
	}
	typeColumn := fkColumn + constants.PolymorphicTypeSuffix
	typeRaw, ok := record[typeColumn]
	if !ok || typeRaw == nil {
		return "", false
	}
	typeStr, ok := typeRaw.(string)
	if !ok || typeStr == "" {
		return "", false
	}
	for _, t := range refTo {
		if t == typeStr {
			return typeStr, true
		}
	}
	return "", false
}

// FetchRelatedRecord is the SINGLE shared 3-value contract used by both
// records.buildRecordFetcher and automation.buildFormulaFetcher.
//
// Returns (related, target, resolved) where:
//
//   - (record, target, true)  — success: a parent row was loaded.
//
//   - (nil,    "",     true)  — DEFINITIVE NULL per SF semantics. One of:
//
//   - relationName cannot be mapped to an FK column (silent skip)
//
//   - the FK column is absent from the record / nil / empty string
//
//   - (nil,    target, true)  — DEFINITIVE NULL: orphan FK (FK present, but
//     the parent row does not exist). Target is preserved for caller logs.
//
//   - (nil,    target, false) — INDETERMINATE. One of:
//
//   - the FK column has no ReferenceTo metadata (cache miss; target ""
//     is also returned in this case since we couldn't pin one)
//
//   - FetchByID returned a transient error
//     Callers MUST surface this case (e.g. records turns it into
//     ErrRelationFetch so computeFormulaFields preserves the prior stored
//     value; automation logs flow.r10: WRN). They MUST NOT treat it as a
//     definitive null.
//
// The function is intentionally pure: no caching, no logging, no access-
// control branches. Callers layer those concerns around the call site.
func FetchRelatedRecord(
	ctx context.Context,
	deps RelationFetchDeps,
	schema *models.ObjectMetadata,
	currentObjectAPIName string,
	record map[string]interface{},
	relationName string,
) (related map[string]interface{}, target string, resolved bool) {
	if deps == nil {
		// No I/O backend wired → nothing we can fetch. Treat as indeterminate
		// so callers don't silently substitute null.
		return nil, "", false
	}

	// 1. Map the segment to an FK column. Unmapped → definitive null.
	fkColumn := ResolveRelationFK(schema, relationName)
	if fkColumn == "" {
		return nil, "", true
	}

	// 2. Read the FK from the record. Absent / nil / empty → definitive null.
	fkVal, present := record[fkColumn]
	if !present || fkVal == nil {
		return nil, "", true
	}
	fkStr, ok := fkVal.(string)
	if !ok {
		// Non-string FK is unusual (IDs are strings in this model) — treat
		// as definitive null rather than fabricate a string conversion that
		// could mis-key a fetch. Same conservative call records/ already made.
		return nil, "", true
	}
	if fkStr == "" {
		return nil, "", true
	}

	// 3. Resolve target object via the deps. No ReferenceTo → indeterminate
	// (we don't know what to query); FetchByID error → indeterminate;
	// FetchByID nil → orphan (definitive null with target preserved).
	refTo := deps.LookupReferenceTo(currentObjectAPIName, fkColumn)
	t, ok := ResolveRelationTarget(record, fkColumn, refTo)
	if !ok {
		return nil, "", false
	}
	target = t

	fetched, err := deps.FetchByID(ctx, target, fkStr)
	if err != nil {
		return nil, target, false
	}
	if fetched == nil {
		// Orphan FK — definitive null per SF semantics.
		return nil, target, true
	}
	return fetched, target, true
}
