package automation

import (
	"context"
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"time"

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

type approvalCrossObjectLookupCacheContextKey struct{}

type approvalCrossObjectLookupKey struct {
	objectName string
	recordID   string
}

type approvalCrossObjectLookupResult struct {
	record models.SObject
	found  bool
	// fetchedFields tracks which parent fields have been read through the
	// field-capped system read (#3945). Criteria system reads are capped to
	// one authored field per hop, so a cached parent row may lack a field a
	// LATER criterion needs — presence in this set, not key-presence on the
	// row, decides whether another capped read is required (a genuinely NULL
	// column and a never-fetched column are indistinguishable on the map).
	fetchedFields map[string]struct{}
}

type approvalCrossObjectLookupCache struct {
	results map[approvalCrossObjectLookupKey]approvalCrossObjectLookupResult
}

// withApprovalCrossObjectLookupCache scopes relationship reads to a single
// approval eligibility evaluation. It intentionally does not outlive the
// request, so entry-criteria resolution always reads current record state.
func withApprovalCrossObjectLookupCache(ctx context.Context) context.Context {
	if cache, _ := ctx.Value(approvalCrossObjectLookupCacheContextKey{}).(*approvalCrossObjectLookupCache); cache != nil {
		return ctx
	}
	return context.WithValue(ctx, approvalCrossObjectLookupCacheContextKey{}, &approvalCrossObjectLookupCache{
		results: make(map[approvalCrossObjectLookupKey]approvalCrossObjectLookupResult),
	})
}

func approvalCrossObjectLookupCacheFromContext(ctx context.Context) *approvalCrossObjectLookupCache {
	cache, _ := ctx.Value(approvalCrossObjectLookupCacheContextKey{}).(*approvalCrossObjectLookupCache)
	return cache
}

func approvalBoolValue(value interface{}) bool {
	switch v := value.(type) {
	case bool:
		return v
	case int:
		return v != 0
	case int64:
		return v != 0
	case float64:
		return v != 0
	case string:
		return strings.EqualFold(strings.TrimSpace(v), "true") || strings.TrimSpace(v) == "1"
	default:
		return false
	}
}

func approvalStringValue(value interface{}) string {
	switch v := value.(type) {
	case nil:
		return ""
	case string:
		return v
	case []byte:
		return string(v)
	case time.Time:
		return v.UTC().Format(time.RFC3339)
	default:
		return fmt.Sprint(v)
	}
}

func firstNonEmptyApprovalValue(values ...interface{}) string {
	for _, value := range values {
		if resolved := strings.TrimSpace(approvalStringValue(value)); resolved != "" {
			return resolved
		}
	}
	return ""
}

func approvalIntValue(value interface{}) int {
	switch v := value.(type) {
	case int:
		return v
	case int64:
		return int(v)
	case int32:
		return int(v)
	case float64:
		return int(v)
	case float32:
		return int(v)
	case json.Number:
		if i, err := v.Int64(); err == nil {
			return int(i)
		}
	case string:
		if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
			return i
		}
	}
	return 0
}

func cloneAutomationRecord(in models.Record) models.Record {
	if in == nil {
		return nil
	}
	out := make(models.Record, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

// cloneAutomationSObject remains only as a compatibility bridge for existing
// callers. New L0 paths must use the application-neutral Record vocabulary.
func cloneAutomationSObject(in models.SObject) models.SObject {
	return cloneAutomationRecord(in)
}

// resolveProcessObjectAPIName returns the API name of the
// object whose records this process operates on. If the caller already has
// the value cached, we short-circuit; otherwise we fetch ProcessDefinition
// to read TableEnumOrId. Returns ("", nil) if the lookup yields nothing;
// callers should treat that as "skip actions" rather than an error.
func (s *ApprovalService) resolveProcessObjectAPIName(ctx context.Context, processDefId, cached string, user *models.UserSession) (string, error) {
	if cached != "" {
		return cached, nil
	}
	if processDefId == "" {
		return "", nil
	}
	pdFilter := fmt.Sprintf("Id == '%s'", escapeApprovalFilterValue(processDefId))
	pdResult, err := s.query.QueryWithFilter(ctx, constants.TableProcessDefinition, pdFilter, user, "", "", 1)
	if err != nil {
		return "", err
	}
	if pdResult == nil || len(pdResult.Records) == 0 {
		return "", nil
	}
	apiName, _ := pdResult.Records[0]["TableEnumOrId"].(string)
	return apiName, nil
}

// resolveEmailTemplateID returns the identifier to pass to
// LoadAndRenderTemplate for a WorkflowAlert.Template reference. The
// reference is treated as opaque: L0 does NOT parse Salesforce-shaped
// folder paths like "unfiled$public/Quote_Approved"; that authoring
// grammar is an L1-pack concern. A "/" in the reference is logged as
// deprecated and the value is returned unchanged so the renderer fails
// gracefully when the template can't be loaded by that ID.
func (s *ApprovalService) resolveEmailTemplateID(templateRef, logPrefix, actionName string) string {
	if strings.Contains(templateRef, "/") {
		s.logger.Warn(fmt.Sprintf("%s '%s': deprecated Salesforce-shaped template reference %q: pack should write a plain template Id or DeveloperName; treating as opaque", logPrefix, actionName, templateRef))
	}
	return templateRef
}

// criteriaHopFailure reports a cross-object criteria read/projection failure
// WITHOUT the parent record id and WITHOUT the inner error text: the FK id
// IS the value of an (potentially FLS-hidden) reference field, and inner
// query/projection errors embed it in filters and messages — none of that
// may reach error chains or server logs (leakage fence, #3945). The parent
// object and FK field name carry the diagnostic weight; the inner error
// class is preserved in words, not text.
func criteriaHopFailure(stage, parentObj, fkField string) error {
	return fmt.Errorf("cross-object criteria %s for %s via %s failed (detail withheld: it may embed a hidden foreign-key value)", stage, parentObj, fkField)
}

// resolveCrossObjectValue follows a foreign-key relationship to read a leaf
// field value on a related record. `parentObj` is the related-object name
// from a cross-object criterion reference. `rest` is everything after the first
// dot; if it contains a further dot a second hop is performed. Deeper paths are
// not supported and return ok=false.
//
// The parent row is read under the field-capped SystemContext criteria read
// (#3945): the submitter definitionally reads the record being submitted,
// but a criteria hop target can be FLS- or sharing-hidden from them, and an
// admin-authored criterion must evaluate the same regardless — fixing FLS
// only on the root record would leave an identically-shaped residual bug on
// the hop. Each hop reads exactly one authored field (the terminal leaf, or
// the next hop's FK), so the widened authority stays capped to what the
// criteria author wrote.
//
// Returns the raw leaf value and ok=true on a successful hop, ("", false,
// nil) when the FK is empty/missing or the related record does not exist
// (a dangling FK compares as null, Salesforce parity), and a non-nil err
// only on lookup/projection failure.
func (s *ApprovalService) resolveCrossObjectValue(ctx context.Context, parentObj, rest string, record models.SObject, user *models.UserSession) (interface{}, bool, error) {
	parentObj = strings.TrimSpace(parentObj)
	rest = strings.TrimSpace(rest)
	if parentObj == "" || rest == "" {
		return "", false, nil
	}
	fkField := parentObj + "Id"
	raw, ok := record[fkField]
	if !ok || raw == nil {
		return "", false, nil
	}
	fkID := strings.TrimSpace(fmt.Sprintf("%v", raw))
	if fkID == "" {
		return "", false, nil
	}
	neededField := rest
	nextHop := strings.Index(rest, ".")
	if nextHop >= 0 {
		// This hop only reads the next FK (physical); the terminal hop does
		// its own targeted projection.
		neededField = rest[:nextHop] + "Id"
	}
	cacheKey := approvalCrossObjectLookupKey{objectName: parentObj, recordID: fkID}
	cache := approvalCrossObjectLookupCacheFromContext(ctx)
	var related models.SObject
	var fetched map[string]struct{}
	if cache != nil {
		if cached, hit := cache.results[cacheKey]; hit {
			if !cached.found {
				return "", false, nil
			}
			related, fetched = cached.record, cached.fetchedFields
		}
	}
	if related == nil {
		row, found, err := s.approvalCriteriaSystemRead(ctx, parentObj, fkID, []string{neededField}, user)
		if err != nil {
			return "", false, criteriaHopFailure("lookup", parentObj, fkField)
		}
		if !found {
			if cache != nil {
				cache.results[cacheKey] = approvalCrossObjectLookupResult{}
			}
			return "", false, nil
		}
		related = row
		fetched = map[string]struct{}{neededField: {}}
	} else if _, have := fetched[neededField]; !have {
		// The cached row was capped to earlier criteria's fields; fetch the
		// one this criterion needs and merge. A row absent NOW means the
		// record was deleted mid-evaluation — keep the cached row and let
		// the field compare as nil, same as a dangling FK.
		row, found, err := s.approvalCriteriaSystemRead(ctx, parentObj, fkID, []string{neededField}, user)
		if err != nil {
			return "", false, criteriaHopFailure("lookup", parentObj, fkField)
		}
		if found {
			if value, ok := row[neededField]; ok {
				related[neededField] = value
			}
		}
		if fetched == nil {
			fetched = make(map[string]struct{}, 1)
		}
		fetched[neededField] = struct{}{}
	}
	if nextHop >= 0 {
		if cache != nil {
			cache.results[cacheKey] = approvalCrossObjectLookupResult{record: related, found: true, fetchedFields: fetched}
		}
		return s.resolveCrossObjectValue(ctx, rest[:nextHop], rest[nextHop+1:], related, user)
	}
	// Targeted (#3913 review round 3): project the parent only when the
	// requested terminal field is a same-object active-formula field absent
	// from the loaded row — a criterion on a physical parent field must not
	// depend on formula-view availability.
	related, projErr := s.ensureCriteriaFormulaFieldsProjected(ctx, parentObj, fkID, related, []criterionItem{{field: rest}}, "", user)
	if projErr != nil {
		return "", false, criteriaHopFailure("formula projection", parentObj, fkField)
	}
	if cache != nil {
		cache.results[cacheKey] = approvalCrossObjectLookupResult{record: related, found: true, fetchedFields: fetched}
	}
	return s.crossObjectFieldValue(related, parentObj, rest), true, nil
}

// crossObjectFieldValue reads a criterion field off a resolved parent record,
// warning when the key is entirely absent — same diagnosability contract as
// the root-record lookup in matchesCriterion (#3913): absence (typo'd
// criteria, un-projected formula class, security-blocked field) is otherwise
// indistinguishable from a legitimate nil non-match.
func (s *ApprovalService) crossObjectFieldValue(record models.SObject, parentObj, field string) interface{} {
	value, present := record[field]
	if !present {
		s.logger.Warn(fmt.Sprintf("Cross-object criterion field %q absent from loaded %s record; comparing as nil", field, parentObj))
	}
	return value
}
