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, the standard-relationship
// table, 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:
//   - the standard-relationship table (Account → AccountId, RecordType →
//     RecordTypeId, …) — single source for both callers.
//   - ResolveRelationFK: pure 4-tier 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"
	"github.com/nexuscrm/shared/pkg/salesforce"
)

// 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)
}

// LookupStandardRelationTarget returns the target object name for a Salesforce
// standard FK column (AccountId → Account, RecordTypeId → RecordType, …). The
// reverse of LookupStandardRelationFK for the asymmetric cases (Owner / Created
// By / LastModifiedBy all resolve to User) plus a curated set of standard
// targets. Returns ("", false) when the FK column is not a recognised standard
// or when its target is intentionally polymorphic (e.g. ParentId).
//
// Delegates to shared/pkg/salesforce as the single SSoT for the standard
// FK → target table; this package owns no parallel copy.
//
// Used as a metadata-cache-miss fallback in callers that may run before the
// ObjectMetadata.ReferenceTo for a field is loaded.
func LookupStandardRelationTarget(fkColumn string) (string, bool) {
	return salesforce.StandardObjectForForeignKey(fkColumn)
}

// LookupStandardRelationFK returns the FK column name for a Salesforce
// standard relationship segment (Account → AccountId, RecordType →
// RecordTypeId, …). Exposed so callers that need the metadata-free fast path
// (e.g. automation/relationshipFKField, used by SOQL filter builders that
// have no schema in hand) consult the same SSoT as ResolveRelationFK Tier 3
// rather than maintaining a parallel copy of the table.
//
// Delegates to shared/pkg/salesforce as the single SSoT for the standard
// relationship → FK table; this package owns no parallel copy.
//
// Returns ("", false) when the segment is not a known standard relationship.
func LookupStandardRelationFK(segment string) (string, bool) {
	return salesforce.StandardRelationshipForeignKey(segment)
}

// 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. Standard-relationship table (handles standard names with irregular FK
//     conventions, e.g. CreatedBy → CreatedById).
//  4. Salesforce convention "<segment>Id" (last resort; covers admin-added
//     standard references like User.UserRoleId, User.ManagerId, …).
//
// 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"
	}
	// Tier 3: standard-relationship table (sourced from shared/pkg/salesforce SSoT).
	if fk, ok := salesforce.StandardRelationshipForeignKey(segment); ok {
		return fk
	}
	// Tier 4: Salesforce "<segment>Id" convention.
	return segment + "Id"
}

// 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 Task.WhoId pointing at a Lead is not cached / stamped
//     as Contact).
//   - 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. Task.WhoId → [Contact, Lead],
	// Task.WhatId → [Account, Opportunity, …]). Pre-#2057-round-7,
	// callers picked refTo[0] for any length, so frozen formulas / flow
	// fetches / validation could query the wrong SObject 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
}
