package bootstrap

import (
	"strings"
	"testing"

	"github.com/nexuscrm/shared/pkg/constants"
	connector "github.com/pingcap-inc/nexuscrm-contract/pkg/connector"
)

const validConnectorPushContractPayload = `{
	"flowDeveloperName": "Contact_To_Remote",
	"flowActionName": "PushContact",
	"slotLineageId": "contact-remote-sync",
	"sourceObject": "Contact",
	"destinationSlotId": "remote-contact",
	"profileOwnerPack": "hubspot-connector",
	"profileName": "default",
	"targetObject": "remoteContact",
	"createPathTemplate": "/crm/v3/objects/contacts",
	"updatePathTemplate": "/crm/v3/objects/contacts/{remoteId}",
	"responseIdPath": "id",
	"remoteIdField": "HubSpotContactId__c",
	"remoteIdProvenanceField": "HubSpotContactIdSource__c"
}`

func TestParseConnectorPushDurableMetadataContractUsesPackOwnerAndStableSlots(t *testing.T) {
	contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(validConnectorPushContractPayload), "crm-flow-pack")
	if err != nil {
		t.Fatalf("ParseConnectorPushDurableMetadataContractFromPayload: %v", err)
	}
	if contract.ConnectorOwnerKey != "crm-flow-pack" {
		t.Fatalf("ConnectorOwnerKey = %q, want pack owner", contract.ConnectorOwnerKey)
	}
	if contract.ActionSlotID != "contact-remote-sync" {
		t.Fatalf("ActionSlotID = %q, want stable slot lineage literal", contract.ActionSlotID)
	}
	if contract.ActionType != constants.ActionTypePushToConnector {
		t.Fatalf("ActionType = %q, want %q", contract.ActionType, constants.ActionTypePushToConnector)
	}
	if contract.DestinationSlotID != "remote-contact" {
		t.Fatalf("DestinationSlotID = %q, want literal destination slot", contract.DestinationSlotID)
	}
	if contract.TargetObject != "remoteContact" || contract.RemoteIDField != "HubSpotContactId__c" {
		t.Fatalf("write target not assembled from literal payload: %+v", contract)
	}
	if contract.BodyRoot != "" {
		t.Fatalf("BodyRoot = %q, want blank when omitted", contract.BodyRoot)
	}
	if contract.SameOriginWritePolicy != ConnectorPushSameOriginWritePolicySuppress {
		t.Fatalf("SameOriginWritePolicy = %q, want suppress default", contract.SameOriginWritePolicy)
	}
}

func TestParseConnectorPushDurableMetadataContractAcceptsSameOriginAllowPatchExistingPolicy(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"destinationSlotId": "remote-contact",`, `"destinationSlotId": "remote-contact",
		"sameOriginWritePolicy": "allowPatchExisting",`, 1)
	contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
	if err != nil {
		t.Fatalf("ParseConnectorPushDurableMetadataContractFromPayload: %v", err)
	}
	if contract.SameOriginWritePolicy != ConnectorPushSameOriginWritePolicyAllowPatchExisting {
		t.Fatalf("SameOriginWritePolicy = %q, want allowPatchExisting", contract.SameOriginWritePolicy)
	}
}

func TestParseConnectorPushDurableMetadataContractAcceptsOptionalBodyRoot(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"createPathTemplate":`, `"bodyRoot": "properties", "createPathTemplate":`, 1)
	contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
	if err != nil {
		t.Fatalf("ParseConnectorPushDurableMetadataContractFromPayload: %v", err)
	}
	if contract.BodyRoot != "properties" {
		t.Fatalf("BodyRoot = %q, want properties", contract.BodyRoot)
	}
}

func TestParseConnectorPushDurableMetadataContractAcceptsAssociations(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
			"associations": [{
			"sourceLookupField": "ParentRef__c",
			"sourceObject": "account_parent",
			"remoteIdField": "RemoteKey__c",
			"targetActionSlotId": "account-remote-sync",
			"targetDestinationSlotId": "remote-account",
			"targetObject": "remoteRelated",
		"payloadField": "links",
		"payloadMergeMode": "append",
		"inlinePhases": ["create"],
		"payloadTemplate": {"to": {"id": "{toId}"}},
		"associationPath": "/link-api/{fromObject}/{fromId}/to/{toObject}/{toId}",
		"associationMethod": "PUT",
		"calloutPhases": ["update"],
		"calloutBodyTemplate": [{"linkTypeCode": 7}]
	}],`, 1)
	contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
	if err != nil {
		t.Fatalf("ParseConnectorPushDurableMetadataContractFromPayload: %v", err)
	}
	if len(contract.Associations) != 1 {
		t.Fatalf("Associations = %#v, want one", contract.Associations)
	}
	assoc := contract.Associations[0]
	if assoc.SourceLookupField != "ParentRef__c" || assoc.SourceObject != "account_parent" || assoc.RemoteIDField != "RemoteKey__c" || assoc.TargetActionSlotID != "account-remote-sync" || assoc.TargetDestinationSlotID != "remote-account" || assoc.TargetObject != "remoteRelated" {
		t.Fatalf("association lookup contract = %+v", assoc)
	}
	if assoc.PayloadMergeMode != "append" || strings.Join(assoc.InlinePhases, ",") != "create" || strings.Join(assoc.CalloutPhases, ",") != "update" {
		t.Fatalf("association phase/merge contract = %+v", assoc)
	}
}

func TestParseConnectorPushDurableMetadataContractAcceptsJunctionAssociations(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
		"associations": [{
			"junction": {
				"object": "SourceRelatedLink",
				"sourceLookupField": "SourceItemId",
				"targetLookupField": "RelatedItemId",
				"filters": [{"field": "IsPrimary", "equals": true}],
				"limit": 1
			},
			"sourceObject": "RelatedItem",
			"remoteIdField": "RemoteRelatedId__c",
			"targetActionSlotId": "related-remote-sync",
			"targetDestinationSlotId": "remote-related",
			"targetObject": "remoteRelated",
			"payloadField": "links",
			"payloadMergeMode": "append",
			"inlinePhases": ["create"],
			"payloadTemplate": {"to": {"id": "{toId}"}}
		}],`, 1)
	contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "generic-flow-pack")
	if err != nil {
		t.Fatalf("ParseConnectorPushDurableMetadataContractFromPayload: %v", err)
	}
	if len(contract.Associations) != 1 {
		t.Fatalf("Associations = %#v, want one", contract.Associations)
	}
	assoc := contract.Associations[0]
	if assoc.SourceLookupField != "" {
		t.Fatalf("SourceLookupField = %q, want blank when junction is authored", assoc.SourceLookupField)
	}
	if assoc.Junction == nil {
		t.Fatal("Junction = nil, want parsed junction lookup contract")
	}
	if assoc.Junction.Object != "SourceRelatedLink" || assoc.Junction.SourceLookupField != "SourceItemId" || assoc.Junction.TargetLookupField != "RelatedItemId" || assoc.Junction.Limit != 1 {
		t.Fatalf("junction contract = %+v", assoc.Junction)
	}
	if len(assoc.Junction.Filters) != 1 || assoc.Junction.Filters[0].Field != "IsPrimary" || assoc.Junction.Filters[0].Equals != true {
		t.Fatalf("junction filters = %#v", assoc.Junction.Filters)
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsJunctionNullFilterLiteral(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
		"associations": [{
			"junction": {
				"object": "SourceRelatedLink",
				"sourceLookupField": "SourceItemId",
				"targetLookupField": "RelatedItemId",
				"filters": [{"field": "IsPrimary", "equals": null}]
			},
			"sourceObject": "RelatedItem",
			"remoteIdField": "RemoteRelatedId__c",
			"targetActionSlotId": "related-remote-sync",
			"targetDestinationSlotId": "remote-related",
			"targetObject": "remoteRelated",
			"payloadField": "links",
			"payloadMergeMode": "append",
			"inlinePhases": ["create"],
			"payloadTemplate": {"to": {"id": "{toId}"}}
		}],`, 1)
	_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "generic-flow-pack")
	if err == nil || !strings.Contains(err.Error(), "junction.filters[0].equals: must not be null") {
		t.Fatalf("error = %v, want null junction filter literal rejection", err)
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsJunctionLimitAboveOne(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
		"associations": [{
			"junction": {
				"object": "SourceRelatedLink",
				"sourceLookupField": "SourceItemId",
				"targetLookupField": "RelatedItemId",
				"filters": [{"field": "IsPrimary", "equals": true}],
				"limit": 2
			},
			"sourceObject": "RelatedItem",
			"remoteIdField": "RemoteRelatedId__c",
			"targetActionSlotId": "related-remote-sync",
			"targetDestinationSlotId": "remote-related",
			"targetObject": "remoteRelated",
			"payloadField": "links",
			"payloadMergeMode": "append",
			"inlinePhases": ["create"],
			"payloadTemplate": {"to": {"id": "{toId}"}}
		}],`, 1)
	_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "generic-flow-pack")
	if err == nil || !strings.Contains(err.Error(), "junction.limit must be 1") {
		t.Fatalf("error = %v, want junction limit rejection", err)
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsAssociationWithDirectAndJunctionLookup(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
		"associations": [{
			"sourceLookupField": "ParentRef__c",
			"junction": {
				"object": "SourceRelatedLink",
				"sourceLookupField": "SourceItemId",
				"targetLookupField": "RelatedItemId"
			},
			"sourceObject": "RelatedItem",
			"remoteIdField": "RemoteRelatedId__c",
			"targetActionSlotId": "related-remote-sync",
			"targetDestinationSlotId": "remote-related",
			"targetObject": "remoteRelated",
			"payloadField": "links",
			"payloadMergeMode": "append",
			"inlinePhases": ["create"],
			"payloadTemplate": {"to": {"id": "{toId}"}}
		}],`, 1)
	_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "generic-flow-pack")
	if err == nil || !strings.Contains(err.Error(), "sourceLookupField must be blank when junction is declared") {
		t.Fatalf("error = %v, want direct/junction exclusivity rejection", err)
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsAssociationTemplateTokenKeys(t *testing.T) {
	for _, tc := range []struct {
		name      string
		extra     string
		wantError string
	}{
		{
			name: "inline payload token key",
			extra: `"payloadField": "links",
			"payloadMergeMode": "append",
			"inlinePhases": ["create"],
			"payloadTemplate": {"{toObject}Id": "{toId}"}`,
			wantError: "payloadTemplate must be pack-owned literal metadata",
		},
		{
			name: "callout body token key",
			extra: `"associationPath": "/link-api/{fromObject}/{fromId}/to/{toObject}/{toId}",
			"associationMethod": "PUT",
			"calloutPhases": ["update"],
			"calloutBodyTemplate": {"{fromObject}Id": "{fromId}"}`,
			wantError: "calloutBodyTemplate must be pack-owned literal metadata",
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
			"associations": [{
				"sourceLookupField": "ParentRef__c",
				"sourceObject": "account_parent",
				"remoteIdField": "RemoteKey__c",
				"targetActionSlotId": "account-remote-sync",
				"targetDestinationSlotId": "remote-account",
				"targetObject": "remoteRelated",
			`+tc.extra+`
		}],`, 1)
			_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
			if err == nil {
				t.Fatal("expected association template tokenized key to be rejected")
			}
			if !strings.Contains(err.Error(), tc.wantError) {
				t.Fatalf("error = %q, want substring %q", err.Error(), tc.wantError)
			}
		})
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsNoopAssociations(t *testing.T) {
	for _, tc := range []struct {
		name      string
		extra     string
		wantError string
	}{
		{
			name:      "tuple only",
			wantError: "must declare an inline payload or association callout",
		},
		{
			name:      "orphan inline phase",
			extra:     `"inlinePhases": ["create"]`,
			wantError: "inlinePhases requires payloadTemplate",
		},
		{
			name:      "orphan callout phase",
			extra:     `"calloutPhases": ["update"]`,
			wantError: "calloutPhases requires associationPath",
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			extra := tc.extra
			if extra != "" {
				extra = ",\n			" + extra
			}
			payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
		"associations": [{
			"sourceLookupField": "ParentRef__c",
			"sourceObject": "account_parent",
			"remoteIdField": "RemoteKey__c",
			"targetActionSlotId": "account-remote-sync",
			"targetDestinationSlotId": "remote-account",
			"targetObject": "remoteRelated"`+extra+`
		}],`, 1)
			_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
			if err == nil {
				t.Fatal("expected noop association metadata to be rejected")
			}
			if !strings.Contains(err.Error(), tc.wantError) {
				t.Fatalf("error = %q, want %q", err.Error(), tc.wantError)
			}
		})
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsDynamicAssociationMetadata(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
		"associations": [{
			"sourceLookupField": "$Record.ParentRef__c",
			"sourceObject": "account_parent",
			"remoteIdField": "RemoteKey__c",
			"targetActionSlotId": "account-remote-sync",
			"targetDestinationSlotId": "remote-account",
			"targetObject": "remoteRelated",
		"payloadField": "links",
		"payloadMergeMode": "append",
		"inlinePhases": ["create"],
		"payloadTemplate": {"to": {"id": "{!DynamicRemoteId}"}}
	}],`, 1)
	_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
	if err == nil {
		t.Fatal("expected dynamic association metadata to be rejected")
	}
	if !strings.Contains(err.Error(), "associations[0].sourceLookupField must be pack-owned literal metadata") {
		t.Fatalf("error = %q", err.Error())
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsCreateAssociationCalloutPhase(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
	"associations": [{
			"sourceLookupField": "ParentRef__c",
			"sourceObject": "account_parent",
			"remoteIdField": "RemoteKey__c",
			"targetActionSlotId": "account-remote-sync",
			"targetDestinationSlotId": "remote-account",
			"targetObject": "remoteRelated",
		"associationPath": "/link-api/{fromObject}/{fromId}/to/{toObject}/{toId}",
		"associationMethod": "PUT",
		"calloutPhases": ["create"]
	}],`, 1)
	_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
	if err == nil {
		t.Fatal("expected create association callout phase to be rejected")
	}
	if !strings.Contains(err.Error(), "calloutPhases does not support create") {
		t.Fatalf("error = %q", err.Error())
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsNonIdempotentAssociationCalloutMethod(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
		"associations": [{
				"sourceLookupField": "ParentRef__c",
				"sourceObject": "account_parent",
				"remoteIdField": "RemoteKey__c",
				"targetActionSlotId": "account-remote-sync",
				"targetDestinationSlotId": "remote-account",
				"targetObject": "remoteRelated",
			"associationPath": "/link-api/{fromObject}/{fromId}/to/{toObject}/{toId}",
			"associationMethod": "POST",
			"calloutPhases": ["update"]
		}],`, 1)
	_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
	if err == nil {
		t.Fatal("expected non-idempotent association method to be rejected")
	}
	if !strings.Contains(err.Error(), "associationMethod must be PUT") {
		t.Fatalf("error = %q", err.Error())
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsCreateInlineFromTokens(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"responseIdPath": "id",`, `"responseIdPath": "id",
		"associations": [{
				"sourceLookupField": "ParentRef__c",
				"sourceObject": "account_parent",
				"remoteIdField": "RemoteKey__c",
				"targetActionSlotId": "account-remote-sync",
				"targetDestinationSlotId": "remote-account",
				"targetObject": "remoteRelated",
			"payloadField": "links",
			"payloadMergeMode": "append",
			"inlinePhases": ["create"],
			"payloadTemplate": {"from": {"id": "{fromId}", "type": "{fromObject}"}, "to": {"id": "{toId}"}}
		}],`, 1)
	_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
	if err == nil {
		t.Fatal("expected create inline association from tokens to be rejected")
	}
	if !strings.Contains(err.Error(), "payloadTemplate must not reference") {
		t.Fatalf("error = %q", err.Error())
	}
}

func TestParseConnectorPushDurableMetadataContractAcceptsPreviousSlotLineageIDs(t *testing.T) {
	payload := strings.Replace(validConnectorPushContractPayload, `"sourceObject":`, `"previousSlotLineageIds": ["contact-remote-v1", "contact-remote-v2"], "sourceObject":`, 1)
	contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "crm-flow-pack")
	if err != nil {
		t.Fatalf("ParseConnectorPushDurableMetadataContractFromPayload: %v", err)
	}
	want := []string{"contact-remote-v1", "contact-remote-v2"}
	if strings.Join(contract.PreviousActionSlotIDs, ",") != strings.Join(want, ",") {
		t.Fatalf("PreviousActionSlotIDs = %#v, want %#v", contract.PreviousActionSlotIDs, want)
	}
}

func TestParseConnectorPushDurableMetadataContractRejectsDynamicIdentityAndWriteTargets(t *testing.T) {
	cases := []struct {
		name    string
		payload string
		want    string
	}{
		{
			name:    "connector owner override",
			payload: strings.Replace(validConnectorPushContractPayload, `"flowDeveloperName":`, `"connectorOwnerKey": "other-pack", "flowDeveloperName":`, 1),
			want:    "connectorOwnerKey is derived from the pack owner",
		},
		{
			name:    "action slot override",
			payload: strings.Replace(validConnectorPushContractPayload, `"flowDeveloperName":`, `"actionSlotId": "mutable-slot", "flowDeveloperName":`, 1),
			want:    "actionSlotId is derived from slotLineageId",
		},
		{
			name:    "dynamic slot lineage",
			payload: strings.Replace(validConnectorPushContractPayload, `"slotLineageId": "contact-remote-sync"`, `"slotLineageId": {"elementReference": "$Record.Slot__c"}`, 1),
			want:    "slotLineageId must be a pack-owned literal string; elementReference is not allowed",
		},
		{
			name:    "dynamic previous slot lineage list",
			payload: strings.Replace(validConnectorPushContractPayload, `"sourceObject":`, `"previousSlotLineageIds": {"elementReference": "$Record.Slot__c"}, "sourceObject":`, 1),
			want:    "previousSlotLineageIds must be a pack-owned literal string list; elementReference is not allowed",
		},
		{
			name:    "previous slot lineage includes current lineage",
			payload: strings.Replace(validConnectorPushContractPayload, `"sourceObject":`, `"previousSlotLineageIds": ["contact-remote-sync"], "sourceObject":`, 1),
			want:    "previousSlotLineageIds must not include slotLineageId",
		},
		{
			name:    "duplicate previous slot lineage",
			payload: strings.Replace(validConnectorPushContractPayload, `"sourceObject":`, `"previousSlotLineageIds": ["contact-remote-v1", "contact-remote-v1"], "sourceObject":`, 1),
			want:    "previousSlotLineageIds contains duplicate",
		},
		{
			name:    "dynamic destination slot",
			payload: strings.Replace(validConnectorPushContractPayload, `"destinationSlotId": "remote-contact"`, `"destinationSlotId": {"elementReference": "$Record.Destination__c"}`, 1),
			want:    "destinationSlotId must be a pack-owned literal string; elementReference is not allowed",
		},
		{
			name:    "record target object",
			payload: strings.Replace(validConnectorPushContractPayload, `"targetObject": "remoteContact"`, `"targetObject": "$Record.TargetObject__c"`, 1),
			want:    "targetObject must be a pack-owned literal string; Flow or record references are not allowed",
		},
		{
			name:    "flow reference target object",
			payload: strings.Replace(validConnectorPushContractPayload, `"targetObject": "remoteContact"`, `"targetObject": "{!SelectedTargetObject}"`, 1),
			want:    "targetObject must be a pack-owned literal string; Flow or record references are not allowed",
		},
		{
			name:    "dynamic create path",
			payload: strings.Replace(validConnectorPushContractPayload, `"createPathTemplate": "/crm/v3/objects/contacts"`, `"createPathTemplate": {"elementReference": "$Record.Path__c"}`, 1),
			want:    "createPathTemplate must be a pack-owned literal string; elementReference is not allowed",
		},
		{
			name:    "dynamic writeback field",
			payload: strings.Replace(validConnectorPushContractPayload, `"remoteIdField": "HubSpotContactId__c"`, `"remoteIdField": {"elementReference": "$Record.RemoteIdField__c"}`, 1),
			want:    "remoteIdField must be a pack-owned literal string; elementReference is not allowed",
		},
		{
			name:    "dynamic body root",
			payload: strings.Replace(validConnectorPushContractPayload, `"createPathTemplate":`, `"bodyRoot": {"elementReference": "$Record.BodyRoot__c"}, "createPathTemplate":`, 1),
			want:    "bodyRoot must be a pack-owned literal string; elementReference is not allowed",
		},
		{
			name:    "invalid body root",
			payload: strings.Replace(validConnectorPushContractPayload, `"createPathTemplate":`, `"bodyRoot": "properties.extra", "createPathTemplate":`, 1),
			want:    "bodyRoot must be a simple literal field name",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(tc.payload), "crm-flow-pack")
			if err == nil {
				t.Fatal("expected dynamic/derived field to be rejected")
			}
			if !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("error = %q, want substring %q", err.Error(), tc.want)
			}
		})
	}
}

func TestValidateConnectorPushDurableMetadataContractsRequiresPackOwnedOutboundMapping(t *testing.T) {
	contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(validConnectorPushContractPayload), "crm-flow-pack")
	if err != nil {
		t.Fatalf("ParseConnectorPushDurableMetadataContractFromPayload: %v", err)
	}
	validMapping := ConnectorFieldMappingInput{
		Source:       "crm-flow-pack",
		SourceObject: "Contact",
		TargetObject: "remoteContact",
		ActionType:   constants.ActionTypePushToConnector,
		Direction:    DirectionOutbound,
		SourceField:  "Email",
		TargetField:  "email",
	}
	if err := ValidateConnectorPushDurableMetadataContracts([]ConnectorPushDurableMetadataContract{contract}, []ConnectorFieldMappingInput{validMapping}); err != nil {
		t.Fatalf("valid pack-owned outbound mapping rejected: %v", err)
	}

	wrongOwner := validMapping
	wrongOwner.Source = "other-pack"
	err = ValidateConnectorPushDurableMetadataContracts([]ConnectorPushDurableMetadataContract{contract}, []ConnectorFieldMappingInput{wrongOwner})
	if err == nil {
		t.Fatal("expected wrong-owner mapping to be rejected")
	}
	if !strings.Contains(err.Error(), "requires an outbound connector_field_mapping owned by crm-flow-pack") {
		t.Fatalf("error = %q", err.Error())
	}

	for _, mutate := range []func(*ConnectorFieldMappingInput){
		func(row *ConnectorFieldMappingInput) { row.SourceObject = "contact" },
		func(row *ConnectorFieldMappingInput) { row.TargetObject = "remotecontact" },
		func(row *ConnectorFieldMappingInput) { row.ActionType = "pushtoconnector" },
	} {
		mapping := validMapping
		mutate(&mapping)
		if err := ValidateConnectorPushDurableMetadataContracts([]ConnectorPushDurableMetadataContract{contract}, []ConnectorFieldMappingInput{mapping}); err == nil {
			t.Fatal("expected differently-cased mapping tuple to be rejected")
		}
	}

	inboundOnly := validMapping
	inboundOnly.Direction = DirectionInbound
	err = ValidateConnectorPushDurableMetadataContracts([]ConnectorPushDurableMetadataContract{contract}, []ConnectorFieldMappingInput{inboundOnly})
	if err == nil {
		t.Fatal("expected inbound-only mapping to be rejected")
	}
}

func TestValidateConnectorPushDurableMetadataContractsRequiresAssociationTargetContract(t *testing.T) {
	opportunityDeal := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "crm-flow-pack",
		ActionSlotID:      "opportunity-deal-sync",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "Opportunity",
		DestinationSlotID: "hubspot-deal",
		ProfileOwnerPack:  "connector-a",
		ProfileName:       "default",
		TargetObject:      "deals",
		RemoteIDField:     "HubSpotDealId__c",
		Associations: []ConnectorPushAssociationContract{{
			SourceLookupField:       "AccountId",
			SourceObject:            "Account",
			RemoteIDField:           "HubSpotCompanyId__c",
			TargetActionSlotID:      "account-company-sync",
			TargetDestinationSlotID: "hubspot-company",
			TargetObject:            "companies",
			PayloadField:            "associations",
			PayloadMergeMode:        "append",
			InlinePhases:            []string{"create"},
			PayloadTemplate: map[string]interface{}{
				"to": map[string]interface{}{"id": "{toId}"},
			},
		}},
	}
	accountCompany := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey:     "crm-flow-pack",
		ActionSlotID:          "account-company-sync",
		PreviousActionSlotIDs: []string{"account-company-sync-v1"},
		ActionType:            constants.ActionTypePushToConnector,
		SourceObject:          "Account",
		DestinationSlotID:     "hubspot-company",
		ProfileOwnerPack:      "connector-a",
		ProfileName:           "default",
		TargetObject:          "companies",
		RemoteIDField:         "HubSpotCompanyId__c",
	}
	mappings := []ConnectorFieldMappingInput{
		connectorPushTestOutboundMapping(opportunityDeal),
		connectorPushTestOutboundMapping(accountCompany),
	}
	contracts := []ConnectorPushDurableMetadataContract{opportunityDeal, accountCompany}
	if err := ValidateConnectorPushDurableMetadataContracts(contracts, mappings); err != nil {
		t.Fatalf("association target contract rejected: %v", err)
	}
	if got := strings.Join(contracts[0].Associations[0].TargetPreviousActionSlotIDs, ","); got != "account-company-sync-v1" {
		t.Fatalf("TargetPreviousActionSlotIDs = %q, want target contract previous slot lineage", got)
	}
	if got := contracts[0].Associations[0].TargetProfileOwnerPack; got != "connector-a" {
		t.Fatalf("TargetProfileOwnerPack = %q, want target contract profile owner", got)
	}
	if got := contracts[0].Associations[0].TargetProfileName; got != "default" {
		t.Fatalf("TargetProfileName = %q, want target contract profile name", got)
	}
	sourceItemRemote := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "generic-flow-pack",
		ActionSlotID:      "source-item-remote-sync",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "SourceItem",
		DestinationSlotID: "remote-source-item",
		ProfileOwnerPack:  "connector-a",
		ProfileName:       "default",
		TargetObject:      "remoteSourceItem",
		RemoteIDField:     "RemoteSourceItemId__c",
		Associations: []ConnectorPushAssociationContract{{
			Junction: &ConnectorPushAssociationJunctionContract{
				Object:            "SourceRelatedLink",
				SourceLookupField: "SourceItemId",
				TargetLookupField: "RelatedItemId",
				Filters: []ConnectorPushAssociationJunctionFilter{{
					Field:  "IsPrimary",
					Equals: true,
				}},
				Limit: 1,
			},
			SourceObject:            "RelatedItem",
			RemoteIDField:           "RemoteRelatedItemId__c",
			TargetActionSlotID:      "related-item-remote-sync",
			TargetDestinationSlotID: "remote-related-item",
			TargetObject:            "remoteRelatedItem",
			PayloadField:            "links",
			PayloadMergeMode:        "append",
			InlinePhases:            []string{"create"},
			PayloadTemplate: map[string]interface{}{
				"to": map[string]interface{}{"id": "{toId}"},
			},
		}},
	}
	relatedItemRemote := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "generic-flow-pack",
		ActionSlotID:      "related-item-remote-sync",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "RelatedItem",
		DestinationSlotID: "remote-related-item",
		ProfileOwnerPack:  "connector-a",
		ProfileName:       "default",
		TargetObject:      "remoteRelatedItem",
		RemoteIDField:     "RemoteRelatedItemId__c",
	}
	junctionContracts := []ConnectorPushDurableMetadataContract{sourceItemRemote, relatedItemRemote}
	junctionMappings := []ConnectorFieldMappingInput{
		connectorPushTestOutboundMapping(sourceItemRemote),
		connectorPushTestOutboundMapping(relatedItemRemote),
	}
	junctionSchemaOptions := ConnectorPushContractValidationOptions{
		ObjectFields: map[string][]ConnectorPushObjectFieldMetadata{
			"sourcerelatedlink": {
				{
					APIName:     "SourceItemId",
					Type:        string(constants.FieldTypeReference),
					ReferenceTo: []string{"SourceItem"},
				},
				{
					APIName:     "RelatedItemId",
					Type:        string(constants.FieldTypeReference),
					ReferenceTo: []string{"RelatedItem"},
				},
				{
					APIName: "IsPrimary",
					Type:    string(constants.FieldTypeBoolean),
				},
			},
		},
		RequireSourceLookupFieldSchema: true,
	}
	if err := ValidateConnectorPushDurableMetadataContractsWithOptions(junctionContracts, junctionMappings, junctionSchemaOptions); err != nil {
		t.Fatalf("schema-backed junction association rejected: %v", err)
	}
	schemaOptions := ConnectorPushContractValidationOptions{
		ObjectFields: map[string][]ConnectorPushObjectFieldMetadata{
			"opportunity": {{
				APIName:     "AccountId",
				Type:        string(constants.FieldTypeReference),
				ReferenceTo: []string{"Account"},
			}},
		},
		RequireSourceLookupFieldSchema: true,
	}
	contractsWithSchema := []ConnectorPushDurableMetadataContract{cloneConnectorPushContractForTest(opportunityDeal), accountCompany}
	if err := ValidateConnectorPushDurableMetadataContractsWithOptions(contractsWithSchema, mappings, schemaOptions); err != nil {
		t.Fatalf("schema-backed association source lookup rejected: %v", err)
	}

	previousAuthoredSlot := cloneConnectorPushContractForTest(opportunityDeal)
	previousAuthoredSlot.Associations[0].TargetActionSlotID = "account-company-sync-v1"
	previousSlotContracts := []ConnectorPushDurableMetadataContract{previousAuthoredSlot, accountCompany}
	if err := ValidateConnectorPushDurableMetadataContracts(previousSlotContracts, []ConnectorFieldMappingInput{connectorPushTestOutboundMapping(previousAuthoredSlot), connectorPushTestOutboundMapping(accountCompany)}); err != nil {
		t.Fatalf("association target previous slot lineage rejected: %v", err)
	}
	if got := strings.Join(previousSlotContracts[0].Associations[0].TargetPreviousActionSlotIDs, ","); got != "account-company-sync" {
		t.Fatalf("previous-slot authored target fallbacks = %q, want current target slot", got)
	}

	bodyRootConflict := opportunityDeal
	bodyRootConflict.BodyRoot = "associations"
	err := ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{bodyRootConflict, accountCompany},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(bodyRootConflict), connectorPushTestOutboundMapping(accountCompany)},
	)
	if err == nil {
		t.Fatal("expected association payloadField/bodyRoot collision to be rejected")
	}
	if !strings.Contains(err.Error(), `payloadField "associations" conflicts with bodyRoot`) {
		t.Fatalf("error = %q", err.Error())
	}

	mappingFieldConflict := cloneConnectorPushContractForTest(opportunityDeal)
	mappingFieldConflict.Associations[0].PayloadField = "name"
	err = ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{mappingFieldConflict, accountCompany},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(mappingFieldConflict), connectorPushTestOutboundMapping(accountCompany)},
	)
	if err == nil {
		t.Fatal("expected association payloadField/outbound targetField collision to be rejected")
	}
	if !strings.Contains(err.Error(), `payloadField "name" conflicts with outbound connector_field_mapping targetField`) {
		t.Fatalf("error = %q", err.Error())
	}

	invalidPayloadField := cloneConnectorPushContractForTest(opportunityDeal)
	invalidPayloadField.Associations[0].PayloadField = "associations.items"
	err = ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{invalidPayloadField, accountCompany},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(invalidPayloadField), connectorPushTestOutboundMapping(accountCompany)},
	)
	if err == nil {
		t.Fatal("expected unsupported association payloadField shape to be rejected")
	}
	if !strings.Contains(err.Error(), "associations[0].payloadField must be a simple literal field name") {
		t.Fatalf("error = %q", err.Error())
	}

	err = ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{opportunityDeal},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(opportunityDeal)},
	)
	if err == nil {
		t.Fatal("expected missing association target connector_push_contract to be rejected")
	}
	if !strings.Contains(err.Error(), "associations[0] target must resolve to exactly one connector_push_contract") {
		t.Fatalf("error = %q", err.Error())
	}

	mismatchedCompany := accountCompany
	mismatchedCompany.RemoteIDField = "WrongCompanyId__c"
	err = ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{opportunityDeal, mismatchedCompany},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(opportunityDeal), connectorPushTestOutboundMapping(mismatchedCompany)},
	)
	if err == nil {
		t.Fatal("expected mismatched association target connector_push_contract to be rejected")
	}
	if !strings.Contains(err.Error(), `remoteIdField="HubSpotCompanyId__c"`) {
		t.Fatalf("error = %q", err.Error())
	}

	casingMismatch := accountCompany
	casingMismatch.SourceObject = "account"
	err = ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{opportunityDeal, casingMismatch},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(opportunityDeal), connectorPushTestOutboundMapping(casingMismatch)},
	)
	if err == nil {
		t.Fatal("expected differently-cased association target connector_push_contract to be rejected")
	}
	if !strings.Contains(err.Error(), `sourceObject="Account"`) {
		t.Fatalf("error = %q", err.Error())
	}

	profileMismatch := accountCompany
	profileMismatch.ProfileName = "alternate"
	err = ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{opportunityDeal, profileMismatch},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(opportunityDeal), connectorPushTestOutboundMapping(profileMismatch)},
	)
	if err == nil {
		t.Fatal("expected profile-incompatible association target connector_push_contract to be rejected")
	}
	if !strings.Contains(err.Error(), "target profile must match source contract profile") {
		t.Fatalf("error = %q", err.Error())
	}

	missingLookupField := cloneConnectorPushContractForTest(opportunityDeal)
	missingLookupField.Associations[0].SourceLookupField = "MissingAccountId"
	err = ValidateConnectorPushDurableMetadataContractsWithOptions(
		[]ConnectorPushDurableMetadataContract{missingLookupField, accountCompany},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(missingLookupField), connectorPushTestOutboundMapping(accountCompany)},
		schemaOptions,
	)
	if err == nil {
		t.Fatal("expected missing association source lookup field to be rejected")
	}
	if !strings.Contains(err.Error(), "sourceLookupField Opportunity.MissingAccountId is not declared") {
		t.Fatalf("error = %q", err.Error())
	}

	err = ValidateConnectorPushDurableMetadataContractsWithOptions(
		[]ConnectorPushDurableMetadataContract{cloneConnectorPushContractForTest(opportunityDeal), accountCompany},
		mappings,
		ConnectorPushContractValidationOptions{RequireSourceLookupFieldSchema: true},
	)
	if err == nil {
		t.Fatal("expected missing association source lookup schema to be rejected")
	}
	if !strings.Contains(err.Error(), "sourceLookupField schema is required") {
		t.Fatalf("error = %q", err.Error())
	}

	wrongLookupReference := ConnectorPushContractValidationOptions{
		ObjectFields: map[string][]ConnectorPushObjectFieldMetadata{
			"opportunity": {{
				APIName:     "AccountId",
				Type:        string(constants.FieldTypeReference),
				ReferenceTo: []string{"Contact"},
			}},
		},
		RequireSourceLookupFieldSchema: true,
	}
	err = ValidateConnectorPushDurableMetadataContractsWithOptions(
		[]ConnectorPushDurableMetadataContract{cloneConnectorPushContractForTest(opportunityDeal), accountCompany},
		mappings,
		wrongLookupReference,
	)
	if err == nil {
		t.Fatal("expected association source lookup reference mismatch to be rejected")
	}
	if !strings.Contains(err.Error(), `ReferenceTo=[Contact] does not include sourceObject="Account"`) {
		t.Fatalf("error = %q", err.Error())
	}

	noopAssociation := cloneConnectorPushContractForTest(opportunityDeal)
	noopAssociation.Associations[0].PayloadField = ""
	noopAssociation.Associations[0].PayloadMergeMode = ""
	noopAssociation.Associations[0].InlinePhases = nil
	noopAssociation.Associations[0].PayloadTemplate = nil
	err = ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{noopAssociation, accountCompany},
		[]ConnectorFieldMappingInput{connectorPushTestOutboundMapping(noopAssociation), connectorPushTestOutboundMapping(accountCompany)},
	)
	if err == nil {
		t.Fatal("expected apply-level noop association metadata to be rejected")
	}
	if !strings.Contains(err.Error(), "must declare an inline payload or association callout") {
		t.Fatalf("error = %q", err.Error())
	}
}

func cloneConnectorPushContractForTest(contract ConnectorPushDurableMetadataContract) ConnectorPushDurableMetadataContract {
	contract.PreviousActionSlotIDs = append([]string(nil), contract.PreviousActionSlotIDs...)
	contract.Associations = append([]ConnectorPushAssociationContract(nil), contract.Associations...)
	return contract
}

func connectorPushTestOutboundMapping(contract ConnectorPushDurableMetadataContract) ConnectorFieldMappingInput {
	return ConnectorFieldMappingInput{
		Source:       strings.TrimSpace(contract.ConnectorOwnerKey),
		SourceObject: strings.TrimSpace(contract.SourceObject),
		TargetObject: strings.TrimSpace(contract.TargetObject),
		ActionType:   constants.ActionTypePushToConnector,
		Direction:    DirectionOutbound,
		SourceField:  "Name",
		TargetField:  "name",
	}
}

// TestValidateConnectorPushDurableMetadataContractsRemoteIDFieldUniqueness proves
// that two contracts in the same pack with the same (ConnectorOwnerKey,
// SourceObject) must declare the same RemoteIDField: a second distinct
// RemoteIDField would create two durable destinations that share the same
// ActiveKey bucket while pointing to different LinkKeys.
func TestValidateConnectorPushDurableMetadataContractsRemoteIDFieldUniqueness(t *testing.T) {
	validMapping := ConnectorFieldMappingInput{
		Source:       "crm-flow-pack",
		SourceObject: "Contact",
		TargetObject: "remoteContact",
		ActionType:   constants.ActionTypePushToConnector,
		Direction:    DirectionOutbound,
		SourceField:  "Email",
		TargetField:  "email",
	}

	// REJECT: same (owner, object), SAME RemoteIDField, DIFFERENT DestinationSlotID.
	// Two destinations sharing one remote-id field collide on the 4-tuple evidence
	// ActiveKey while finalizing different durable LinkKeys — destination B could adopt
	// destination A's authored evidence and PATCH the wrong remote object (the unsafe case).
	contractA := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "crm-flow-pack",
		ActionSlotID:      "contact-remote-sync-a",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "Contact",
		DestinationSlotID: "remote-contact-a",
		TargetObject:      "remoteContact",
		RemoteIDField:     "HubSpotContactId__c",
	}
	contractB := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "crm-flow-pack",
		ActionSlotID:      "contact-remote-sync-b",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "Contact",
		DestinationSlotID: "remote-contact-b",
		TargetObject:      "remoteContact",
		RemoteIDField:     "HubSpotContactId__c", // SAME field as A, DIFFERENT destination → unsafe
	}
	err := ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{contractA, contractB},
		[]ConnectorFieldMappingInput{validMapping},
	)
	if err == nil {
		t.Fatal("expected two destinations sharing one RemoteIDField for the same (owner,object) to be rejected")
	}
	if !strings.Contains(err.Error(), "must map to exactly one connector-push link identity per source object") {
		t.Fatalf("error = %q, want 'must map to exactly one connector-push link identity per source object'", err.Error())
	}

	// REJECT: same (owner, object, RemoteIDField, DestinationSlotID) but DIFFERENT
	// ActionSlotID. The durable LinkKey includes ActionSlotID, so two action slots
	// sharing the evidence field+destination still finalize different links from the
	// same evidence bucket — must fail closed (not just the differing-destination case).
	contractActSlotA := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "crm-flow-pack",
		ActionSlotID:      "contact-remote-sync-slot-1",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "Contact",
		DestinationSlotID: "remote-contact-shared",
		TargetObject:      "remoteContact",
		RemoteIDField:     "HubSpotContactId__c",
	}
	contractActSlotB := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "crm-flow-pack",
		ActionSlotID:      "contact-remote-sync-slot-2", // different action slot, same dest+field
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "Contact",
		DestinationSlotID: "remote-contact-shared",
		TargetObject:      "remoteContact",
		RemoteIDField:     "HubSpotContactId__c",
	}
	if err := ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{contractActSlotA, contractActSlotB},
		[]ConnectorFieldMappingInput{validMapping},
	); err == nil {
		t.Fatal("expected two action slots sharing one RemoteIDField+destination for the same (owner,object) to be rejected")
	} else if !strings.Contains(err.Error(), "must map to exactly one connector-push link identity per source object") {
		t.Fatalf("actionSlot-collision error = %q, want link-identity rejection", err.Error())
	}

	// ACCEPT: same (owner, object), different DestinationSlotID, DIFFERENT RemoteIDField.
	// Distinct destinations that use distinct remote-id fields key distinct evidence
	// buckets, so there is no cross-destination collision — this is the supported shape.
	contractC := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "crm-flow-pack",
		ActionSlotID:      "contact-remote-sync-c",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "Contact",
		DestinationSlotID: "remote-contact-c",
		TargetObject:      "remoteContact",
		RemoteIDField:     "HubSpotContactId__c",
	}
	contractD := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "crm-flow-pack",
		ActionSlotID:      "contact-remote-sync-d",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "Contact",
		DestinationSlotID: "remote-contact-d",
		TargetObject:      "remoteContact",
		RemoteIDField:     "SalesforceContactId__c", // DIFFERENT field → distinct evidence bucket → safe
	}
	if err := ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{contractC, contractD},
		[]ConnectorFieldMappingInput{validMapping},
	); err != nil {
		t.Fatalf("two destinations on same (owner,object) with DISTINCT RemoteIDFields must be accepted: %v", err)
	}

	// ACCEPT: same RemoteIDField across DIFFERENT SourceObjects under one owner.
	// The invariant is per (owner, object), so reusing a field name on a different
	// object is valid.
	leadMapping := ConnectorFieldMappingInput{
		Source:       "crm-flow-pack",
		SourceObject: "Lead",
		TargetObject: "remoteLead",
		ActionType:   constants.ActionTypePushToConnector,
		Direction:    DirectionOutbound,
		SourceField:  "Email",
		TargetField:  "email",
	}
	contractLead := ConnectorPushDurableMetadataContract{
		ConnectorOwnerKey: "crm-flow-pack",
		ActionSlotID:      "lead-remote-sync",
		ActionType:        constants.ActionTypePushToConnector,
		SourceObject:      "Lead",
		DestinationSlotID: "remote-lead",
		TargetObject:      "remoteLead",
		RemoteIDField:     "HubSpotContactId__c",
	}
	if err := ValidateConnectorPushDurableMetadataContracts(
		[]ConnectorPushDurableMetadataContract{contractA, contractLead},
		[]ConnectorFieldMappingInput{validMapping, leadMapping},
	); err != nil {
		t.Fatalf("same RemoteIDField across different SourceObjects must be accepted: %v", err)
	}
}

func TestParseConnectorPushDurableMetadataContractDiagnosticErrorFieldPaths(t *testing.T) {
	base := func(extra string) string {
		return `{
            "flowDeveloperName": "TestFlow",
            "flowActionName": "TestAction",
            "slotLineageId": "test-slot-001",
            "sourceObject": "Lead",
            "destinationSlotId": "dest-001",
            "profileOwnerPack": "hubspot-crm",
            "profileName": "default",
            "targetObject": "Contact",
            "createPathTemplate": "/crm/v3/objects/contacts",
            "updatePathTemplate": "/crm/v3/objects/contacts/{remoteId}",
            "responseIdPath": "id",
            "remoteIdField": "HubSpotId__c"` + extra + `}`
	}

	t.Run("absent_gives_nil", func(t *testing.T) {
		contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(base("")), "test-owner")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if contract.DiagnosticErrorFieldPaths != nil {
			t.Errorf("expected nil DiagnosticErrorFieldPaths, got %v", contract.DiagnosticErrorFieldPaths)
		}
	})

	t.Run("present_parses", func(t *testing.T) {
		payload := base(`,
            "diagnosticErrorFieldPaths": ["category", "errors[].code"]`)
		contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "test-owner")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if len(contract.DiagnosticErrorFieldPaths) != 2 {
			t.Errorf("expected 2 DiagnosticErrorFieldPaths, got %d: %v", len(contract.DiagnosticErrorFieldPaths), contract.DiagnosticErrorFieldPaths)
		}
		if contract.DiagnosticErrorFieldPaths[0] != "category" {
			t.Errorf("expected first path %q, got %q", "category", contract.DiagnosticErrorFieldPaths[0])
		}
		if contract.DiagnosticErrorFieldPaths[1] != "errors[].code" {
			t.Errorf("expected second path %q, got %q", "errors[].code", contract.DiagnosticErrorFieldPaths[1])
		}
	})

	t.Run("unknown_field_rejected", func(t *testing.T) {
		payload := base(`, "unknownField": "x"`)
		_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "test-owner")
		if err == nil {
			t.Error("expected error for unknown field, got nil")
		}
	})
}

func TestParseConnectorPushDurableMetadataContractCreateConflictResolution(t *testing.T) {
	payload := `{
            "flowDeveloperName": "TestFlow",
            "flowActionName": "TestAction",
            "slotLineageId": "test-slot-001",
            "sourceObject": "Lead",
            "destinationSlotId": "dest-001",
            "profileOwnerPack": "hubspot-crm",
            "profileName": "default",
            "targetObject": "Contact",
            "createPathTemplate": "/crm/v3/objects/contacts",
            "updatePathTemplate": "/crm/v3/objects/contacts/{remoteId}",
            "responseIdPath": "id",
            "remoteIdField": "HubSpotId__c",
            "createConflictResolution": {
              "statusCodes": [409],
              "diagnosticMatch": {"category": "CONFLICT"},
              "identity": {
                "sourceField": "Email",
                "targetProperty": "email",
                "requestBodyPath": "properties.email",
                "normalization": "email"
              },
              "resolve": {
                "method": "GET",
                "pathTemplate": "/crm/v3/objects/contacts/{identity}?idProperty=email",
                "responseIdPath": "id",
                "requireExactlyOne": true
              },
              "onResolved": "adoptLinkThenPatch",
              "noOldSnapshotPolicy": "adoptLinkThenPatchAuthorizedFrozenCreatePayload"
            }
          }`
	contract, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(payload), "test-owner")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	resolution := contract.CreateConflictResolution
	if resolution == nil {
		t.Fatal("expected createConflictResolution to parse")
	}
	if got := resolution.StatusCodes; len(got) != 1 || got[0] != 409 {
		t.Fatalf("statusCodes = %#v, want [409]", got)
	}
	if resolution.DiagnosticMatch["category"] != "CONFLICT" {
		t.Fatalf("diagnosticMatch = %#v", resolution.DiagnosticMatch)
	}
	if resolution.Identity.RequestBodyPath != "properties.email" || resolution.Identity.Normalization != "email" {
		t.Fatalf("identity = %#v", resolution.Identity)
	}
	if resolution.Resolve.Method != "GET" || resolution.Resolve.ResponseIDPath != "id" || !resolution.Resolve.RequireExactlyOne {
		t.Fatalf("resolve = %#v", resolution.Resolve)
	}
	if resolution.NoOldSnapshotPolicy != connector.ConnectorPushConflictNoOldSnapshotAdoptLinkThenPatchAuthorizedFrozenCreatePayload {
		t.Fatalf("noOldSnapshotPolicy = %q, want authorized frozen create payload", resolution.NoOldSnapshotPolicy)
	}

	t.Run("accepts_adopt_link_only", func(t *testing.T) {
		adoptOnly := strings.Replace(payload, `"noOldSnapshotPolicy": "adoptLinkThenPatchAuthorizedFrozenCreatePayload"`, `"noOldSnapshotPolicy": "adoptLinkOnly"`, 1)
		parsed, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(adoptOnly), "test-owner")
		if err != nil {
			t.Fatalf("parse adoptLinkOnly: %v", err)
		}
		if got := parsed.CreateConflictResolution.NoOldSnapshotPolicy; got != connector.ConnectorPushConflictNoOldSnapshotAdoptLinkOnly {
			t.Fatalf("noOldSnapshotPolicy = %q, want adoptLinkOnly", got)
		}
	})

	t.Run("requires_exactly_one", func(t *testing.T) {
		invalid := strings.Replace(payload, `"requireExactlyOne": true`, `"requireExactlyOne": false`, 1)
		_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(invalid), "test-owner")
		if err == nil || !strings.Contains(err.Error(), "requireExactlyOne must be true") {
			t.Fatalf("error = %v, want requireExactlyOne rejection", err)
		}
	})

	t.Run("rejects_dynamic_identity_path", func(t *testing.T) {
		invalid := strings.Replace(payload, `"requestBodyPath": "properties.email"`, `"requestBodyPath": "$Record.Email"`, 1)
		_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(invalid), "test-owner")
		if err == nil || !strings.Contains(err.Error(), "requestBodyPath must be pack-owned literal metadata") {
			t.Fatalf("error = %v, want dynamic requestBodyPath rejection", err)
		}
	})

	t.Run("rejects_unknown_no_old_snapshot_policy", func(t *testing.T) {
		invalid := strings.Replace(payload, `"noOldSnapshotPolicy": "adoptLinkThenPatchAuthorizedFrozenCreatePayload"`, `"noOldSnapshotPolicy": "rebuildAsSystem"`, 1)
		_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(invalid), "test-owner")
		if err == nil || !strings.Contains(err.Error(), "noOldSnapshotPolicy must be blank") {
			t.Fatalf("error = %v, want unknown no-old-snapshot policy rejection", err)
		}
	})

	t.Run("rejects_associations_for_frozen_create_payload", func(t *testing.T) {
		withAssociation := strings.Replace(payload, `"responseIdPath": "id",`, `"responseIdPath": "id",
            "associations": [{
              "sourceLookupField": "ParentRef__c",
              "sourceObject": "account_parent",
              "remoteIdField": "RemoteKey__c",
              "targetActionSlotId": "account-remote-sync",
              "targetDestinationSlotId": "remote-account",
              "targetObject": "remoteRelated",
              "payloadField": "links",
              "payloadMergeMode": "append",
              "inlinePhases": ["create"],
              "payloadTemplate": {"to": {"id": "{toId}"}},
              "associationPath": "/link-api/{fromObject}/{fromId}/to/{toObject}/{toId}",
              "associationMethod": "PUT",
              "calloutPhases": ["update"],
              "calloutBodyTemplate": [{"linkTypeCode": 7}]
            }],`, 1)
		_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(withAssociation), "test-owner")
		if err == nil || !strings.Contains(err.Error(), "does not permit associations") {
			t.Fatalf("error = %v, want frozen create payload association rejection", err)
		}
	})

	t.Run("rejects_associations_for_adopt_link_only", func(t *testing.T) {
		adoptOnly := strings.Replace(payload, `"noOldSnapshotPolicy": "adoptLinkThenPatchAuthorizedFrozenCreatePayload"`, `"noOldSnapshotPolicy": "adoptLinkOnly"`, 1)
		withAssociation := strings.Replace(adoptOnly, `"responseIdPath": "id",`, `"responseIdPath": "id",
            "associations": [{
              "sourceLookupField": "ParentRef__c",
              "sourceObject": "account_parent",
              "remoteIdField": "RemoteKey__c",
              "targetActionSlotId": "account-remote-sync",
              "targetDestinationSlotId": "remote-account",
              "targetObject": "remoteRelated",
              "payloadField": "links",
              "payloadMergeMode": "append",
              "inlinePhases": ["create"],
              "payloadTemplate": {"to": {"id": "{toId}"}},
              "associationPath": "/link-api/{fromObject}/{fromId}/to/{toObject}/{toId}",
              "associationMethod": "PUT",
              "calloutPhases": ["update"],
              "calloutBodyTemplate": [{"linkTypeCode": 7}]
            }],`, 1)
		_, err := ParseConnectorPushDurableMetadataContractFromPayload([]byte(withAssociation), "test-owner")
		if err == nil || !strings.Contains(err.Error(), "does not permit associations") {
			t.Fatalf("error = %v, want adopt-only association rejection", err)
		}
	})
}
