package dialectsfcrm

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"reflect"
	"testing"

	"github.com/nexuscrm/dialect-sf-crm/wire"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/contract"
	"github.com/nexuscrm/shared/pkg/dialect"
	sharederrors "github.com/nexuscrm/shared/pkg/errors"
	"github.com/nexuscrm/shared/pkg/models"
)

type multipleValidationRulesError struct{}

func (multipleValidationRulesError) Error() string { return "several validation rules failed" }
func (multipleValidationRulesError) ValidationRuleFailures() []sharederrors.ValidationRuleFailure {
	return []sharederrors.ValidationRuleFailure{
		{RuleName: "Existing_Database", Message: "Existing Database is required", Field: "Existing_Database__c"},
		{RuleName: "Lead_Industry", Message: "Industry is required", Field: "Industry"},
	}
}

// fakeTreeRecords is a FRESH shared-only contract.Records fake for the module's
// byte-golden. It deliberately does NOT copy backend's fakeCompositeTreeRecordsContract
// (whose file imports gin + backend/pkg/auth — copying it would break the seal).
//
// L0-E CreateTree PR-2: the composite/tree handler now decodes the SF wire request
// into []contract.TreeNode and calls the neutral contract.TreeNodeWriter seam, so
// this fake also implements TreeNodeWriter.CreateTreeNodes and the byte-golden
// exercises the NEUTRAL path. CreateTreeNodes captures the decoded root type / user
// and returns a neutral []contract.TreeCreateResult; the success-body strip
// assertion (case 7) stays load-bearing because the handler must project only the
// {referenceId,id,errors} triple even though the neutral result type carries no
// SF carriers.
type fakeTreeRecords struct {
	rootType            string
	treeNodes           []contract.TreeNode
	userID              string
	retrieveObject      string
	retrieveID          string
	retrieveRecord      contract.Record
	retrieveManyObject  string
	retrieveManyIDs     []string
	retrieveManyRecords map[string]map[string]contract.Record
	insertObject        string
	insertData          contract.Record
	insertResult        contract.Record
	updateObject        string
	updateID            string
	updateData          contract.Record
	deleteObject        string
	deleteID            string
	createRecordsCalled bool
	insertErr           error
	updateErr           error
	deleteErr           error
	createErr           error
}

func (f *fakeTreeRecords) Insert(_ context.Context, objectName string, data contract.Record, user *models.UserSession) (contract.Record, error) {
	f.insertObject = objectName
	f.insertData = data
	if user != nil {
		f.userID = user.ID
	}
	if f.insertErr != nil {
		return nil, f.insertErr
	}
	if f.insertResult != nil {
		return f.insertResult, nil
	}
	return contract.Record{constants.FieldID: "001xx0000000001AAA"}, nil
}
func (f *fakeTreeRecords) Retrieve(_ context.Context, objectName string, id string, _ *models.UserSession) (contract.Record, error) {
	f.retrieveObject = objectName
	f.retrieveID = id
	if f.retrieveRecord != nil {
		return f.retrieveRecord, nil
	}
	return nil, nil
}
func (f *fakeTreeRecords) RetrieveMany(_ context.Context, objectName string, ids []string, _ *models.UserSession) (map[string]contract.Record, error) {
	f.retrieveManyObject = objectName
	f.retrieveManyIDs = append([]string(nil), ids...)
	if f.retrieveManyRecords != nil {
		return f.retrieveManyRecords[objectName], nil
	}
	return nil, nil
}
func (f *fakeTreeRecords) Update(_ context.Context, objectName string, id string, updates contract.Record, user *models.UserSession) error {
	f.updateObject = objectName
	f.updateID = id
	f.updateData = updates
	if user != nil {
		f.userID = user.ID
	}
	return f.updateErr
}
func (f *fakeTreeRecords) Delete(_ context.Context, objectName string, id string, user *models.UserSession) error {
	f.deleteObject = objectName
	f.deleteID = id
	if user != nil {
		f.userID = user.ID
	}
	return f.deleteErr
}
func (f *fakeTreeRecords) UpsertByExternalID(context.Context, string, string, string, contract.Record, *models.UserSession) (contract.Record, bool, error) {
	return nil, false, nil
}
func (f *fakeTreeRecords) RetrieveByField(context.Context, string, string, string, *models.UserSession) (contract.Record, error) {
	return nil, nil
}
func (f *fakeTreeRecords) CreateRecords(context.Context, []contract.Record, bool, *models.UserSession) ([]contract.RecordMutationResult, error) {
	f.createRecordsCalled = true
	return nil, nil
}
func (f *fakeTreeRecords) UpdateRecords(context.Context, []contract.Record, bool, *models.UserSession) ([]contract.RecordMutationResult, error) {
	return nil, nil
}
func (f *fakeTreeRecords) DeleteRecords(context.Context, []string, bool, *models.UserSession) ([]contract.RecordMutationResult, error) {
	return nil, nil
}
func (f *fakeTreeRecords) UpsertRecords(context.Context, string, string, []contract.Record, bool, *models.UserSession) ([]contract.RecordMutationResult, error) {
	return nil, nil
}
func (f *fakeTreeRecords) RetrieveRecords(context.Context, string, []string, []string, *models.UserSession) ([]contract.Record, error) {
	return nil, nil
}

// CreateTreeNodes is the neutral TreeNodeWriter seam the composite/tree handler now
// calls after decoding the SF wire request. It captures the decoded root object type
// (from the first root node's ObjectType, mirroring the old CreateTree rootType) and
// the caller user, then returns one neutral TreeCreateResult per root — the typed
// successor of the old {referenceId,id,errors} SObject. It carries NO SF carriers,
// so the handler's projection, not the engine result, is what strips the wire shape.
func (f *fakeTreeRecords) CreateTreeNodes(_ context.Context, nodes []contract.TreeNode, user *models.UserSession) ([]contract.TreeCreateResult, error) {
	f.treeNodes = nodes
	if len(nodes) > 0 {
		f.rootType = nodes[0].ObjectType
	}
	if user != nil {
		f.userID = user.ID
	}
	if f.createErr != nil {
		return nil, f.createErr
	}
	return []contract.TreeCreateResult{{
		ReferenceID: "ref1",
		ID:          "001xx0000000001AAA",
		Errors:      []string{},
	}}, nil
}

func (f *fakeTreeRecords) ObjectTypeForID(context.Context, string) (string, error) {
	return "", nil
}

type candidateDeleteRecords struct {
	*fakeTreeRecords
	candidates          map[string][]string
	deleteRecordsCalled bool
}

func (f *candidateDeleteRecords) ObjectTypeCandidatesForIDs(context.Context, []string) (map[string][]string, error) {
	return f.candidates, nil
}

func (f *candidateDeleteRecords) DeleteRecords(context.Context, []string, bool, *models.UserSession) ([]contract.RecordMutationResult, error) {
	f.deleteRecordsCalled = true
	return nil, nil
}

func dialectDeps(records contract.Records) dialect.Deps {
	return dialect.Deps{Contracts: contract.Deps{Records: records, Metadata: mutationPolicyGoldenMetadata()}}
}

func TestGetRecordUsesPartitionOwnedReadDecorator(t *testing.T) {
	records := &fakeTreeRecords{retrieveRecord: contract.Record{
		constants.FieldID: "001xx0000000001AAA",
		"FileRef":         "file-1",
	}}
	decorator := &uiAPITestDecorator{}
	partition := NewCompositeTreePartition(func(p *CompositeTreePartition) {
		p.recordDecorator = decorator
	})
	resp, err := partition.GetRecordNeutralHandler(context.Background(), &dialect.Request{
		Version: "v61.0",
		Params:  map[string]string{"sobject": "Account", "id": "001xx0000000001AAA"},
	}, dialectDeps(records))
	if err != nil {
		t.Fatalf("GetRecordNeutralHandler() error = %v", err)
	}
	if resp.Status != http.StatusOK {
		t.Fatalf("status = %d, want 200; body=%#v", resp.Status, resp.Body)
	}
	if records.retrieveObject != "Account" || records.retrieveID != "001xx0000000001AAA" {
		t.Fatalf("retrieve target = %s/%s, want Account/001xx0000000001AAA", records.retrieveObject, records.retrieveID)
	}
	if decorator.prefix != "/services/data/v61.0/sobjects/_System_File" {
		t.Fatalf("decorator prefix = %q, want versioned files path", decorator.prefix)
	}
	body, ok := resp.Body.(models.SObject)
	if !ok {
		t.Fatalf("body type = %T, want models.SObject", resp.Body)
	}
	if got := body["FileRef"]; got != "/services/data/v61.0/sobjects/_System_File/file-1" {
		t.Fatalf("FileRef = %v, want decorated file URL", got)
	}
}

func mutationPolicyGoldenMetadata() contract.Metadata {
	return &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{
		"Account":           mutationPolicyGoldenOpenSchema("Account"),
		"RecordType":        mutationPolicyGoldenSchema("RecordType", "", "RecordType public mutations are not supported until the standard REST read/query contract is round-trippable", "INVALID_REQUEST", nil),
		"Flow":              mutationPolicyGoldenSchema("Flow", "", "Flow standard REST writes are closed after the Flow-native email notification cutover; use Flow Tooling, source-backed pack, or tenant overlay authoring so Flow validation can reject legacy emailAlert metadata and version activation bypasses", "", nil),
		"ProcessDefinition": mutationPolicyGoldenSchema("ProcessDefinition", "", "ProcessDefinition generic standard REST writes are closed after the Flow-native email notification cutover; use ApprovalProcess metadata or direct ProcessDefinition create/update so approval child sync and retired ProcessAction.Alert activation validation run before enabling the process", "", nil),
		"ProcessAction":     mutationPolicyGoldenSchema("ProcessAction", "", "ProcessAction public standard REST writes are closed; use ApprovalProcess metadata deploy or source-backed CRM pack authoring so approval child materialization remains metadata-owned", "", nil),
	}}
}

func mutationPolicyGoldenOpenSchema(objectName string) *models.ObjectMetadata {
	return &models.ObjectMetadata{APIName: objectName}
}

func mutationPolicyGoldenSchema(objectName, field, message, errorCode string, condition *models.RecordMutationCondition) *models.ObjectMetadata {
	return &models.ObjectMetadata{
		APIName: objectName,
		AccessPolicy: &models.ObjectAccessPolicy{RecordMutation: &models.RecordMutationAccessPolicy{Deny: []models.RecordMutationDenial{{
			Surfaces:  []string{models.RecordMutationSurfaceTreeCreate},
			Field:     field,
			Message:   message,
			ErrorCode: errorCode,
			When:      condition,
		}}}},
	}
}

func dialectDescribeDeps(metadata contract.Metadata, policy contract.Policy) dialect.Deps {
	return dialect.Deps{Contracts: contract.Deps{Metadata: metadata, Policy: policy}}
}

func neutralReq(objectName string, body []byte) *dialect.Request {
	return &dialect.Request{
		User:   &models.UserSession{ID: "005xx0000000001AAA"},
		Params: map[string]string{"objectName": objectName},
		Body:   body,
	}
}

func singleRecordWriteReq(objectName, id string, body []byte) *dialect.Request {
	return &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005xx0000000001AAA"},
		Params:  map[string]string{"sobject": objectName, "id": id},
		Body:    body,
	}
}

func singleRecordWriteDeps(records contract.Records, metadata contract.Metadata) dialect.Deps {
	return dialect.Deps{Contracts: contract.Deps{Records: records, Metadata: metadata}}
}

func schemaWithMutationPolicy(t *testing.T, objectName string) *models.ObjectMetadata {
	t.Helper()
	for _, policy := range RequiredRecordMutationPolicies() {
		if policy.ObjectAPIName != objectName {
			continue
		}
		policy := policy.Policy
		return &models.ObjectMetadata{
			APIName: objectName,
			AccessPolicy: &models.ObjectAccessPolicy{
				RecordMutation: &policy,
			},
		}
	}
	t.Fatalf("missing required record mutation policy for %s", objectName)
	return nil
}

func mustJSON(t *testing.T, v any) string {
	t.Helper()
	b, err := json.Marshal(v)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	return string(b)
}

// assertBody pins resp.Body's marshalled JSON to a literal absolute oracle, and
// (when wantHelper != "") also to the module's own wire.* helper output.
func assertBody(t *testing.T, name string, body any, wantHelper, wantLiteral string) {
	t.Helper()
	got := mustJSON(t, body)
	if got != wantLiteral {
		t.Fatalf("%s body = %s, want literal %s", name, got, wantLiteral)
	}
	if wantHelper != "" && got != wantHelper {
		t.Fatalf("%s body = %s, want helper %s", name, got, wantHelper)
	}
}

// TestCompositeTreeNeutralHandlerEnvelopesByteIdentical is the relocated byte-level
// parity oracle: every wire envelope the gin-free composite/tree handler emits is
// pinned to the module's wire.* constructors AND to literal JSON strings (an absolute
// oracle, not the module's own helper). Ported from the deleted backend
// composite_tree_dialect_test.go:48-191, all 8 behavioral cases preserved.
func TestCompositeTreeNeutralHandlerEnvelopesByteIdentical(t *testing.T) {
	deps := dialectDeps

	// 1. invalid JSON body.
	resp, err := CompositeTreeNeutralHandler(context.Background(), neutralReq("Account", []byte("{not json")), deps(&fakeTreeRecords{}))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("invalid-json status = %d, want 400", resp.Status)
	}
	assertBody(t, "invalid-json", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInvalidRequest, "Invalid request body")),
		`[{"errorCode":"INVALID_REQUEST","message":"Invalid request body"}]`)

	// 2. > 200 records.
	many := `{"records":[`
	for i := 0; i < 201; i++ {
		if i > 0 {
			many += ","
		}
		many += `{"attributes":{"type":"Account"},"Name":"x"}`
	}
	many += `]}`
	resp, _ = CompositeTreeNeutralHandler(context.Background(), neutralReq("Account", []byte(many)), deps(&fakeTreeRecords{}))
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("limit status = %d, want 400", resp.Status)
	}
	assertBody(t, "limit", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeLimitExceeded, "Maximum 200 records allowed")),
		`[{"errorCode":"LIMIT_EXCEEDED","message":"Maximum 200 records allowed"}]`)

	// 3. RecordType object name -> public-mutation rejection.
	resp, _ = CompositeTreeNeutralHandler(context.Background(), neutralReq("RecordType", []byte(`{"records":[]}`)), deps(&fakeTreeRecords{}))
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("recordtype status = %d, want 400", resp.Status)
	}
	assertBody(t, "recordtype", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInvalidRequest, "RecordType public mutations are not supported until the standard REST read/query contract is round-trippable")),
		`[{"errorCode":"INVALID_REQUEST","message":"RecordType public mutations are not supported until the standard REST read/query contract is round-trippable"}]`)

	// 4. Flow guard -> object-level SFErrorResponse(INVALID_FIELD...).
	resp, _ = CompositeTreeNeutralHandler(context.Background(), neutralReq("Flow", []byte(`{"records":[]}`)), deps(&fakeTreeRecords{}))
	wantStatus, wantBody := wire.ValidationErrorEnvelope("", "Flow standard REST writes are closed after the Flow-native email notification cutover; use Flow Tooling, source-backed pack, or tenant overlay authoring so Flow validation can reject legacy emailAlert metadata and version activation bypasses", wire.SFCodeInvalidFieldForInsertUpdate)
	if resp.Status != wantStatus {
		t.Fatalf("Flow guard status = %d, want %d", resp.Status, wantStatus)
	}
	assertBody(t, "flow-guard", resp.Body,
		mustJSON(t, wantBody),
		`[{"errorCode":"INVALID_FIELD_FOR_INSERT_UPDATE","message":"Flow standard REST writes are closed after the Flow-native email notification cutover; use Flow Tooling, source-backed pack, or tenant overlay authoring so Flow validation can reject legacy emailAlert metadata and version activation bypasses"}]`)

	// 5. ProcessDefinition generic-write guard.
	resp, _ = CompositeTreeNeutralHandler(context.Background(), neutralReq("ProcessDefinition", []byte(`{"records":[]}`)), deps(&fakeTreeRecords{}))
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("ProcessDefinition guard status = %d, want 400", resp.Status)
	}
	assertBody(t, "processdefinition-guard", resp.Body,
		"", // skip helper-equality; pin the literal directly
		`[{"errorCode":"INVALID_FIELD_FOR_INSERT_UPDATE","message":"ProcessDefinition generic standard REST writes are closed after the Flow-native email notification cutover; use ApprovalProcess metadata or direct ProcessDefinition create/update so approval child sync and retired ProcessAction.Alert activation validation run before enabling the process"}]`)

	// 6. CreateTree engine error -> SFErrorResponse(INVALID_REQUEST, err.Error()).
	resp, _ = CompositeTreeNeutralHandler(context.Background(),
		neutralReq("Account", []byte(`{"records":[{"attributes":{"type":"Account"},"Name":"Acme"}]}`)),
		deps(&fakeTreeRecords{createErr: errors.New("boom")}))
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("CreateTree-error status = %d, want 400", resp.Status)
	}
	assertBody(t, "createtree-error", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInvalidRequest, "boom")),
		`[{"errorCode":"INVALID_REQUEST","message":"boom"}]`)

	// 7. Success -> 201, {hasErrors:false, results:[{errors,id,referenceId}]}, no leaked carriers.
	okFake := &fakeTreeRecords{}
	resp, _ = CompositeTreeNeutralHandler(context.Background(),
		neutralReq("Account", []byte(`{"records":[{"attributes":{"type":"Account"},"Name":"Acme"}]}`)),
		deps(okFake))
	if resp.Status != http.StatusCreated {
		t.Fatalf("success status = %d, want 201", resp.Status)
	}
	if okFake.rootType != "Account" || okFake.userID != "005xx0000000001AAA" {
		t.Fatalf("CreateTree got rootType=%q userID=%q", okFake.rootType, okFake.userID)
	}
	// Literal pin (alphabetical map-key order from encoding/json): errors, id, referenceId.
	assertBody(t, "success", resp.Body, "",
		`{"hasErrors":false,"results":[{"errors":[],"id":"001xx0000000001AAA","referenceId":"ref1"}]}`)
	var body struct {
		HasErrors bool                     `json:"hasErrors"`
		Results   []map[string]interface{} `json:"results"`
	}
	if err := json.Unmarshal([]byte(mustJSON(t, resp.Body)), &body); err != nil {
		t.Fatalf("decode success body: %v", err)
	}
	if body.HasErrors || len(body.Results) != 1 || body.Results[0]["id"] != "001xx0000000001AAA" || body.Results[0]["referenceId"] != "ref1" {
		t.Fatalf("success results = %#v", body.Results)
	}
	if _, ok := body.Results[0][models.ObjectTypeMetadataKey]; ok {
		t.Fatalf("success leaked neutral object type: %#v", body.Results[0])
	}
	if _, ok := body.Results[0]["attributes"]; ok {
		t.Fatalf("success leaked attributes carrier: %#v", body.Results[0])
	}

	// 8. Trailing data after a valid JSON body is ACCEPTED (201, CreateTree reached).
	trailFake := &fakeTreeRecords{}
	resp, _ = CompositeTreeNeutralHandler(context.Background(),
		neutralReq("Account", []byte(`{"records":[{"attributes":{"type":"Account"},"Name":"Acme"}]}trailing-garbage`)),
		deps(trailFake))
	if resp.Status != http.StatusCreated {
		t.Fatalf("trailing-data status = %d, want 201 (lenient bind); body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	if trailFake.rootType != "Account" {
		t.Fatalf("trailing-data: CreateTree not reached (rootType=%q)", trailFake.rootType)
	}
}

// TestCompositeTreeNeutralHandlerRejectsMalformedNestedChildAtomically pins the
// fail-closed decode regression fix (reviewer IANTHEREAL, #299 tree.go:125). A
// Composite-Tree request whose nested relationship carries a non-object child
// record (e.g. Contacts: {"records":[123]}) MUST be rejected atomically with the
// v63 malformed-request envelope BEFORE any mutation — never silently dropped into
// a parent-only tree. The legacy engine path preserved the bad child and failed at
// insert, keeping the whole tree atomic; this asserts the neutral path matches that
// by proving CreateTreeNodes is never reached (fake stub records zero inserts).
func TestCompositeTreeNeutralHandlerRejectsMalformedNestedChildAtomically(t *testing.T) {
	// Open metadata for every object the tree touches so the mutation-policy guard
	// (which resolves each object incl. child/grandchild schemas) passes and the
	// decode is what rejects the malformed shape — not a metadata-lookup 500.
	openSchemas := &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{
		"Account": mutationPolicyGoldenOpenSchema("Account"),
		"Contact": mutationPolicyGoldenOpenSchema("Contact"),
		"Case":    mutationPolicyGoldenOpenSchema("Case"),
	}}
	deps := func(records contract.Records) dialect.Deps {
		return dialect.Deps{Contracts: contract.Deps{Records: records, Metadata: openSchemas}}
	}

	// A non-object child (the number 123) inside a relationship's records array.
	malformedChild := &fakeTreeRecords{}
	resp, err := CompositeTreeNeutralHandler(context.Background(),
		neutralReq("Account", []byte(`{"records":[{"attributes":{"type":"Account"},"Name":"Acme","Contacts":{"records":[123]}}]}`)),
		deps(malformedChild))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("malformed-child status = %d, want 400; body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	// v63 malformed-request envelope (INVALID_REQUEST), mirroring the invalid-body arm.
	assertBody(t, "malformed-child", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInvalidRequest, "composite tree relationship \"Contacts\" has a non-object child record")),
		`[{"errorCode":"INVALID_REQUEST","message":"composite tree relationship \"Contacts\" has a non-object child record"}]`)
	// ATOMIC: the mutation seam must never be reached — no record created.
	if malformedChild.treeNodes != nil || malformedChild.rootType != "" || malformedChild.userID != "" {
		t.Fatalf("CreateTreeNodes reached for malformed child (treeNodes=%#v rootType=%q userID=%q); malformed tree must fail closed before mutation",
			malformedChild.treeNodes, malformedChild.rootType, malformedChild.userID)
	}

	// A malformed grandchild (non-object) nested one level deeper must ALSO reject
	// the whole tree — the decode error propagates up from every recursion level.
	malformedGrandchild := &fakeTreeRecords{}
	resp, err = CompositeTreeNeutralHandler(context.Background(),
		neutralReq("Account", []byte(`{"records":[{"attributes":{"type":"Account"},"Name":"Acme","Contacts":{"records":[{"attributes":{"type":"Contact"},"LastName":"Doe","Cases":{"records":[123]}}]}}]}`)),
		deps(malformedGrandchild))
	if err != nil {
		t.Fatalf("unexpected error (grandchild): %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("malformed-grandchild status = %d, want 400; body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "malformed-grandchild", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInvalidRequest, "composite tree relationship \"Cases\" has a non-object child record")),
		`[{"errorCode":"INVALID_REQUEST","message":"composite tree relationship \"Cases\" has a non-object child record"}]`)
	if malformedGrandchild.treeNodes != nil || malformedGrandchild.rootType != "" || malformedGrandchild.userID != "" {
		t.Fatalf("CreateTreeNodes reached for malformed grandchild (treeNodes=%#v rootType=%q userID=%q); malformed tree must fail closed before mutation",
			malformedGrandchild.treeNodes, malformedGrandchild.rootType, malformedGrandchild.userID)
	}
}

func TestSingleRecordWriteNeutralHandlersSuccess(t *testing.T) {
	account := &models.ObjectMetadata{
		APIName: "Account",
		Fields: []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		},
	}
	metadata := &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{"Account": account}}
	records := &fakeTreeRecords{}
	deps := singleRecordWriteDeps(records, metadata)

	resp, err := CreateRecordNeutralHandler(context.Background(), singleRecordWriteReq("Account", "", []byte(`{"Name":"Acme"}`)), deps)
	if err != nil {
		t.Fatalf("CreateRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusCreated {
		t.Fatalf("create status = %d, want 201 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "create-success", resp.Body, "",
		`{"errors":[],"id":"001xx0000000001AAA","success":true}`)
	if records.insertObject != "Account" || records.insertData["Name"] != "Acme" || records.userID != "005xx0000000001AAA" {
		t.Fatalf("insert call = object %q data %#v user %q", records.insertObject, records.insertData, records.userID)
	}

	resp, err = UpdateRecordNeutralHandler(context.Background(), singleRecordWriteReq("Account", "001xx0000000001AAA", []byte(`{"Name":"Renamed"}`)), deps)
	if err != nil {
		t.Fatalf("UpdateRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusNoContent {
		t.Fatalf("update status = %d, want 204 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	if records.updateObject != "Account" || records.updateID != "001xx0000000001AAA" || records.updateData["Name"] != "Renamed" {
		t.Fatalf("update call = object %q id %q data %#v", records.updateObject, records.updateID, records.updateData)
	}

	resp, err = DeleteRecordNeutralHandler(context.Background(), singleRecordWriteReq("Account", "001xx0000000001AAA", nil), deps)
	if err != nil {
		t.Fatalf("DeleteRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusNoContent {
		t.Fatalf("delete status = %d, want 204 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	if records.deleteObject != "Account" || records.deleteID != "001xx0000000001AAA" {
		t.Fatalf("delete call = object %q id %q", records.deleteObject, records.deleteID)
	}
}

func TestSingleRecordWriteProjectsEveryValidationRuleFailure(t *testing.T) {
	lead := &models.ObjectMetadata{
		APIName: "Lead",
		Fields: []models.FieldMetadata{
			{APIName: "IQM_Completed__c", Type: constants.FieldTypeBoolean},
		},
	}
	metadata := &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{"Lead": lead}}
	records := &fakeTreeRecords{updateErr: multipleValidationRulesError{}}

	resp, err := UpdateRecordNeutralHandler(
		context.Background(),
		singleRecordWriteReq("Lead", "00Qxx0000000001AAA", []byte(`{"IQM_Completed__c":true}`)),
		singleRecordWriteDeps(records, metadata),
	)
	if err != nil {
		t.Fatalf("UpdateRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.Status)
	}
	assertBody(t, "all-validation-rules", resp.Body, "",
		`[{"errorCode":"FIELD_CUSTOM_VALIDATION_EXCEPTION","message":"Existing Database is required","fields":["Existing_Database__c"]},{"errorCode":"FIELD_CUSTOM_VALIDATION_EXCEPTION","message":"Industry is required","fields":["Industry"]}]`)
}

func TestSingleRecordWriteNeutralHandlersCanonicalizeFieldCase(t *testing.T) {
	order := &models.ObjectMetadata{
		APIName: "Order",
		Fields: []models.FieldMetadata{
			{APIName: "AccountId", Type: constants.FieldTypeReference},
			{APIName: "EffectiveDate", Type: constants.FieldTypeDate},
			{APIName: "Status", Type: constants.FieldTypePicklist},
		},
	}
	metadata := &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{"Order": order}}
	records := &fakeTreeRecords{}
	deps := singleRecordWriteDeps(records, metadata)

	resp, err := CreateRecordNeutralHandler(context.Background(), singleRecordWriteReq("Order", "", []byte(`{"accountId":"001xx","effectivedate":"2026-04-01","Status":"Draft"}`)), deps)
	if err != nil {
		t.Fatalf("CreateRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusCreated {
		t.Fatalf("create status = %d, want 201 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	if records.insertData["AccountId"] != "001xx" || records.insertData["EffectiveDate"] != "2026-04-01" || records.insertData["Status"] != "Draft" {
		t.Fatalf("insert data = %#v, want canonical field keys", records.insertData)
	}
	if _, ok := records.insertData["accountId"]; ok {
		t.Fatalf("insert data kept lowercase accountId: %#v", records.insertData)
	}

	resp, err = UpdateRecordNeutralHandler(context.Background(), singleRecordWriteReq("Order", "801xx", []byte(`{"status":"Activated"}`)), deps)
	if err != nil {
		t.Fatalf("UpdateRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusNoContent {
		t.Fatalf("update status = %d, want 204 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	if records.updateData["Status"] != "Activated" {
		t.Fatalf("update data = %#v, want canonical Status", records.updateData)
	}
	if _, ok := records.updateData["status"]; ok {
		t.Fatalf("update data kept lowercase status: %#v", records.updateData)
	}
}

func TestGetRecordNeutralHandlerHydratesImplicitRecordTypeLookupName(t *testing.T) {
	const accountID = "001xx0000000001AAA"
	const recordTypeID = "012xx0000000001AAA"
	account := &models.ObjectMetadata{
		APIName: "Account",
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
			{APIName: constants.FieldName, Type: constants.FieldTypeString, IsNameField: true},
			{APIName: constants.FieldRecordTypeID, Type: constants.FieldTypeReference},
		},
	}
	recordType := &models.ObjectMetadata{
		APIName: constants.ObjectRecordType,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
			{APIName: constants.FieldName, Type: constants.FieldTypeString, IsNameField: true},
		},
	}
	records := &fakeTreeRecords{
		retrieveRecord: contract.Record{
			constants.FieldID:           accountID,
			constants.FieldName:         "Acme",
			constants.FieldRecordTypeID: recordTypeID,
		},
		retrieveManyRecords: map[string]map[string]contract.Record{
			constants.ObjectRecordType: {
				recordTypeID: {
					constants.FieldID:   recordTypeID,
					constants.FieldName: "Business Account",
				},
			},
		},
	}
	deps := dialect.Deps{Contracts: contract.Deps{
		Records: records,
		Metadata: &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{
			"Account":                  account,
			constants.ObjectRecordType: recordType,
		}},
	}}

	resp, err := GetRecordNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005xx0000000001AAA"},
		Params:  map[string]string{"sobject": "Account", "id": accountID},
	}, deps)
	if err != nil {
		t.Fatalf("GetRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusOK {
		t.Fatalf("status = %d, want 200 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	body, ok := resp.Body.(models.SObject)
	if !ok {
		t.Fatalf("body type = %T, want models.SObject", resp.Body)
	}
	if got := body[constants.FieldRecordTypeID+"_Name"]; got != "Business Account" {
		t.Fatalf("RecordTypeId_Name = %#v, want Business Account; body=%s", got, mustJSON(t, body))
	}
	if records.retrieveManyObject != constants.ObjectRecordType {
		t.Fatalf("RetrieveMany object = %q, want %q", records.retrieveManyObject, constants.ObjectRecordType)
	}
	if !reflect.DeepEqual(records.retrieveManyIDs, []string{recordTypeID}) {
		t.Fatalf("RetrieveMany ids = %#v, want [%s]", records.retrieveManyIDs, recordTypeID)
	}
}

func TestLookupHydrationTargetObjectHandlesPolymorphicFieldsBeforeReferenceTargets(t *testing.T) {
	tests := []struct {
		name  string
		field models.FieldMetadata
		want  string
	}{
		{
			name: "owner always resolves to user despite target order",
			field: models.FieldMetadata{
				APIName:       constants.FieldOwnerID,
				Type:          constants.FieldTypeReference,
				ReferenceTo:   []string{"Group", constants.TableUser},
				IsPolymorphic: true,
			},
			want: constants.TableUser,
		},
		{
			name: "non owner polymorphic is skipped without discriminator",
			field: models.FieldMetadata{
				APIName:       "WhatId",
				Type:          constants.FieldTypeReference,
				ReferenceTo:   []string{"Account", "Opportunity"},
				IsPolymorphic: true,
			},
			want: "",
		},
		{
			name: "non polymorphic explicit target still passes through",
			field: models.FieldMetadata{
				APIName:     "AccountId",
				Type:        constants.FieldTypeReference,
				ReferenceTo: []string{"Account"},
			},
			want: "Account",
		},
		{
			name: "non polymorphic platform fallback applies when target is absent",
			field: models.FieldMetadata{
				APIName: constants.FieldRecordTypeID,
				Type:    constants.FieldTypeReference,
			},
			want: constants.ObjectRecordType,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := lookupHydrationTargetObject(tt.field); got != tt.want {
				t.Fatalf("lookupHydrationTargetObject(%+v) = %q, want %q", tt.field, got, tt.want)
			}
		})
	}
}

func TestCreateRecordNeutralHandlerRequiredFieldParity(t *testing.T) {
	account := &models.ObjectMetadata{
		APIName: "Account",
		Fields: []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString, Required: true},
			{APIName: "AutoNumber__c", Type: constants.FieldTypeAutonumber, Required: true},
		},
	}
	records := &fakeTreeRecords{}
	resp, err := CreateRecordNeutralHandler(context.Background(),
		singleRecordWriteReq("Account", "", []byte(`{}`)),
		singleRecordWriteDeps(records, &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{"Account": account}}))
	if err != nil {
		t.Fatalf("CreateRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "required-field", resp.Body,
		mustJSON(t, wire.SFErrorWithFields(wire.SFCodeRequiredFieldMissing, "required field(s) missing: Name", "Name")),
		`[{"errorCode":"REQUIRED_FIELD_MISSING","message":"required field(s) missing: Name","fields":["Name"]}]`)
	if records.insertObject != "" {
		t.Fatalf("Insert reached for invalid create: object=%q data=%#v", records.insertObject, records.insertData)
	}
}

func TestProcessActionPreviousPolicyRemainsRolloutCompatible(t *testing.T) {
	var got *models.RecordMutationAccessPolicy
	for _, candidate := range RolloutCompatibleRecordMutationPolicies() {
		if candidate.ObjectAPIName == "ProcessAction" {
			policy := candidate.Policy
			got = &policy
			break
		}
	}
	want := models.RecordMutationAccessPolicy{Deny: []models.RecordMutationDenial{{
		Surfaces: []string{
			models.RecordMutationSurfaceSingleCreate,
			models.RecordMutationSurfaceSingleUpdate,
			models.RecordMutationSurfaceExternalIDUpsert,
			models.RecordMutationSurfaceCollectionCreate,
			models.RecordMutationSurfaceCollectionUpdate,
			models.RecordMutationSurfaceCollectionUpsert,
			models.RecordMutationSurfaceTreeCreate,
		},
		Field:   "ActionType",
		Message: "ProcessAction standard REST writes are closed for retired ProcessAction.Alert after the Flow-native email notification cutover; use approval Flow actions for notifications while FieldUpdate and Flow ProcessAction writes remain supported, then rerun migrate-workflow-alerts-to-flows verify-zero",
		When: &models.RecordMutationCondition{FieldEquals: &models.RecordMutationFieldEqualsCondition{
			Field:           "ActionType",
			Value:           "Alert",
			CaseInsensitive: true,
		}},
	}}}
	if got == nil || !reflect.DeepEqual(*got, want) {
		t.Fatalf("ProcessAction rollout-compatible policy = %#v, want exact prior policy %#v", got, want)
	}
}

func TestProcessNodeRequiredPolicyRegistry(t *testing.T) {
	policies := RequiredRecordMutationPolicies()
	var got *RequiredRecordMutationPolicy
	for i := range policies {
		if policies[i].ObjectAPIName != "ProcessNode" {
			continue
		}
		got = &policies[i]
		if i+1 >= len(policies) || policies[i+1].ObjectAPIName != "ProcessAction" {
			t.Fatalf("required policy after ProcessNode = %#v, want ProcessAction", policies[i+1:])
		}
	}
	want := RequiredRecordMutationPolicy{
		ObjectAPIName: "ProcessNode",
		Policy: models.RecordMutationAccessPolicy{Deny: []models.RecordMutationDenial{{
			Surfaces: []string{
				models.RecordMutationSurfaceSingleCreate,
				models.RecordMutationSurfaceSingleUpdate,
				models.RecordMutationSurfaceSingleDelete,
				models.RecordMutationSurfaceExternalIDUpsert,
				models.RecordMutationSurfaceCollectionCreate,
				models.RecordMutationSurfaceCollectionUpdate,
				models.RecordMutationSurfaceCollectionDelete,
				models.RecordMutationSurfaceCollectionUpsert,
				models.RecordMutationSurfaceTreeCreate,
			},
			Message: "ProcessNode public standard REST writes are closed; use ApprovalProcess metadata deploy or source-backed CRM pack authoring so approval child materialization remains metadata-owned",
		}}},
	}
	if got == nil || !reflect.DeepEqual(*got, want) {
		t.Fatalf("ProcessNode required policy = %#v, want exact sidecar policy %#v", got, want)
	}
	for _, candidate := range RolloutCompatibleRecordMutationPolicies() {
		if candidate.ObjectAPIName == "ProcessNode" {
			t.Fatalf("ProcessNode must not have rollout-compatible policy: %#v", candidate.Policy)
		}
	}
}

func TestCompositeCollectionsDeleteGuardsProtectedCandidateBeforeDelete(t *testing.T) {
	records := &candidateDeleteRecords{
		fakeTreeRecords: &fakeTreeRecords{},
		candidates: map[string][]string{
			"shared-id": {"ProcessInstanceNode", "ProcessNode"},
		},
	}
	metadata := &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{
		"ProcessInstanceNode": {APIName: "ProcessInstanceNode"},
		"ProcessNode":         schemaWithMutationPolicy(t, "ProcessNode"),
	}}
	resp, err := CompositeCollectionsDeleteNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005xx0000000001AAA"},
		Query: map[string][]string{
			"ids":       {",shared-id"},
			"allOrNone": {"true"},
		},
	}, dialect.Deps{Contracts: contract.Deps{Records: records, Metadata: metadata}})
	if err != nil {
		t.Fatalf("CompositeCollectionsDeleteNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "processnode-ambiguous-collection-delete-guard", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInvalidFieldForInsertUpdate, "ProcessNode public standard REST writes are closed; use ApprovalProcess metadata deploy or source-backed CRM pack authoring so approval child materialization remains metadata-owned")),
		`[{"errorCode":"INVALID_FIELD_FOR_INSERT_UPDATE","message":"ProcessNode public standard REST writes are closed; use ApprovalProcess metadata deploy or source-backed CRM pack authoring so approval child materialization remains metadata-owned"}]`)
	if records.deleteRecordsCalled {
		t.Fatal("DeleteRecords reached despite protected object-type candidate")
	}
}

func TestCompositeCollectionsDeleteCandidateResolverPreservesOrdinaryPerRecordPath(t *testing.T) {
	records := &candidateDeleteRecords{
		fakeTreeRecords: &fakeTreeRecords{},
		candidates:      map[string][]string{},
	}
	resp, err := CompositeCollectionsDeleteNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005xx0000000001AAA"},
		Query: map[string][]string{
			"ids":       {",missing-id"},
			"allOrNone": {"true"},
		},
	}, dialect.Deps{Contracts: contract.Deps{
		Records:  records,
		Metadata: &fakeDescribeMetadata{},
	}})
	if err != nil {
		t.Fatalf("CompositeCollectionsDeleteNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusOK {
		t.Fatalf("status = %d, want 200 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	if !records.deleteRecordsCalled {
		t.Fatal("DeleteRecords was not reached for ordinary missing/malformed IDs")
	}
}

func TestSingleRecordWriteNeutralHandlersRecordMutationGuards(t *testing.T) {
	recordType := schemaWithMutationPolicy(t, "RecordType")
	processAction := schemaWithMutationPolicy(t, "ProcessAction")
	metadata := &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{
		"RecordType":    recordType,
		"ProcessAction": processAction,
	}}
	records := &fakeTreeRecords{}
	deps := singleRecordWriteDeps(records, metadata)

	resp, err := CreateRecordNeutralHandler(context.Background(), singleRecordWriteReq("RecordType", "", []byte(`{"Name":"Business"}`)), deps)
	if err != nil {
		t.Fatalf("CreateRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("recordtype create status = %d, want 400 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "recordtype-create-guard", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInsertFailed, "RecordType public standard REST mutation surface remains closed on this line")),
		`[{"errorCode":"INSERT_FAILED","message":"RecordType public standard REST mutation surface remains closed on this line"}]`)
	if records.insertObject != "" {
		t.Fatalf("Insert reached despite RecordType guard")
	}

	resp, err = DeleteRecordNeutralHandler(context.Background(), singleRecordWriteReq("RecordType", "012xx0000000001AAA", nil), deps)
	if err != nil {
		t.Fatalf("DeleteRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("recordtype delete status = %d, want 400 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "recordtype-delete-guard", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeDeleteFailed, "RecordType public standard REST mutation surface remains closed on this line")),
		`[{"errorCode":"DELETE_FAILED","message":"RecordType public standard REST mutation surface remains closed on this line"}]`)
	if records.deleteObject != "" {
		t.Fatalf("Delete reached despite RecordType guard")
	}

	resp, err = UpdateRecordNeutralHandler(context.Background(),
		singleRecordWriteReq("ProcessAction", "04kxx0000000001AAA", []byte(`{"ActionType":"FieldUpdate"}`)),
		deps)
	if err != nil {
		t.Fatalf("UpdateRecordNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusBadRequest {
		t.Fatalf("processaction update status = %d, want 400 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "processaction-update-guard", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeUpdateFailed, "ProcessAction public standard REST writes are closed; use ApprovalProcess metadata deploy or source-backed CRM pack authoring so approval child materialization remains metadata-owned")),
		`[{"errorCode":"UPDATE_FAILED","message":"ProcessAction public standard REST writes are closed; use ApprovalProcess metadata deploy or source-backed CRM pack authoring so approval child materialization remains metadata-owned"}]`)
	if records.updateObject != "" {
		t.Fatalf("Update reached despite ProcessAction guard")
	}
}

func TestCompositeTreeNeutralHandlerFailsClosedWhenMutationPolicySchemaLookupFails(t *testing.T) {
	records := &fakeTreeRecords{}
	resp, err := CompositeTreeNeutralHandler(context.Background(), neutralReq("RecordType", []byte(`{"records":[]}`)), dialect.Deps{Contracts: contract.Deps{
		Records:  records,
		Metadata: &fakeDescribeMetadata{},
	}})
	if err != nil {
		t.Fatalf("CompositeTreeNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "metadata-lookup-failure", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInternalError, "Metadata service unavailable")),
		`[{"errorCode":"INTERNAL_ERROR","message":"Metadata service unavailable"}]`)
	if records.rootType != "" {
		t.Fatalf("CreateTree reached with rootType=%q; metadata lookup failure must fail closed before mutation", records.rootType)
	}
}

func TestCompositeCollectionsCreateNeutralHandlerFailsClosedWhenMutationPolicySchemaLookupFails(t *testing.T) {
	records := &fakeTreeRecords{}
	resp, err := CompositeCollectionsCreateNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005xx0000000001AAA"},
		Body:    []byte(`{"records":[{"attributes":{"type":"RecordType"},"Name":"Business"}]}`),
	}, dialect.Deps{Contracts: contract.Deps{
		Records:  records,
		Metadata: &fakeDescribeMetadata{},
	}})
	if err != nil {
		t.Fatalf("CompositeCollectionsCreateNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500 body=%s", resp.Status, mustJSON(t, resp.Body))
	}
	assertBody(t, "collections-metadata-lookup-failure", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInternalError, "Metadata service unavailable")),
		`[{"errorCode":"INTERNAL_ERROR","message":"Metadata service unavailable"}]`)
	if records.createRecordsCalled {
		t.Fatalf("CreateRecords reached; metadata lookup failure must fail closed before mutation")
	}
}

// TestCompositeTreeResultProjectionKeepsOnlyTreeWireFields is re-homed from the
// deleted backend composite_tree_handler_contract_test.go:151-176. L0-E CreateTree
// PR-2 rebases it onto the neutral result type: treeCreateResultToV63 now maps a
// contract.TreeCreateResult (which carries only the wire triple by construction) to
// the v63 {referenceId,id,errors} object. The projection must emit exactly those
// three keys and nothing else.
func TestCompositeTreeResultProjectionKeepsOnlyTreeWireFields(t *testing.T) {
	got := treeCreateResultToV63(contract.TreeCreateResult{
		ReferenceID: "ref2",
		ID:          "001xx0000000002AAA",
		Errors:      []string{"boom"},
	})

	if got["id"] != "001xx0000000002AAA" {
		t.Fatalf("id = %#v, want 001xx0000000002AAA", got["id"])
	}
	if got["referenceId"] != "ref2" {
		t.Fatalf("referenceId = %#v, want ref2", got["referenceId"])
	}
	errs, ok := got["errors"].([]string)
	if !ok || len(errs) != 1 || errs[0] != "boom" {
		t.Fatalf("errors = %#v, want [boom]", got["errors"])
	}
	if len(got) != 3 {
		t.Fatalf("tree result projection emitted extra keys: %#v", got)
	}
	if _, ok := got[models.ObjectTypeMetadataKey]; ok {
		t.Fatalf("tree result leaked neutral metadata: %#v", got)
	}
	if _, ok := got["attributes"]; ok {
		t.Fatalf("tree result leaked SObject attributes: %#v", got)
	}
}

// TestCompositeTreeNodesNilErrorsProjectsEmptyArray pins the byte-safe normalization
// of a nil Errors slice to [] (never null), matching the old []interface{}{} bytes.
func TestCompositeTreeNodesNilErrorsProjectsEmptyArray(t *testing.T) {
	got := treeCreateResultToV63(contract.TreeCreateResult{ReferenceID: "ref3", ID: "id3"})
	errs, ok := got["errors"].([]string)
	if !ok || errs == nil || len(errs) != 0 {
		t.Fatalf("errors = %#v, want non-nil empty []string", got["errors"])
	}
}

func TestDescribeGlobalNeutralHandlerUsesMetadataAndPolicyContracts(t *testing.T) {
	metadata := &fakeDescribeMetadata{
		objects: []models.ObjectMetadata{
			{
				APIName:      "Account",
				Label:        "Account",
				PluralLabel:  "Accounts",
				Searchable:   true,
				SharingModel: constants.SharingModelPublicReadWrite,
				TableType:    constants.TableTypeStandardObject,
			},
			{
				APIName:     "SecretObject",
				Label:       "Secret",
				PluralLabel: "Secrets",
				Searchable:  true,
			},
		},
		keyPrefixes: map[string]string{"Account": "001"},
	}
	policy := &fakeDescribePolicy{access: map[string]*contract.EffectiveObjectAccess{
		"Account": {
			Object: contract.ObjectAccess{Create: true, Read: true, Update: true, Delete: false},
		},
		"SecretObject": {
			Object: contract.ObjectAccess{Read: false},
		},
	}}

	resp, err := DescribeGlobalNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005"},
	}, dialectDescribeDeps(metadata, policy))
	if err != nil {
		t.Fatalf("DescribeGlobalNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.Status)
	}
	body, ok := resp.Body.(describeGlobalResult)
	if !ok {
		t.Fatalf("body type = %T, want describeGlobalResult", resp.Body)
	}
	if len(body.SObjects) != 1 {
		t.Fatalf("sobjects = %#v, want one readable object", body.SObjects)
	}
	got := body.SObjects[0]
	if got.Name != "Account" || got.KeyPrefix != "001" || !got.Createable || got.Deletable {
		t.Fatalf("Account describe global = %#v", got)
	}
	if got.Urls["describe"] != "/services/data/v63.0/sobjects/Account/describe" {
		t.Fatalf("describe URL = %q", got.Urls["describe"])
	}
	if !reflect.DeepEqual(policy.objectSeen, []string{"Account", "SecretObject"}) {
		t.Fatalf("object policy calls = %v, want both objects checked", policy.objectSeen)
	}
	if len(policy.fullSeen) != 0 {
		t.Fatalf("global describe called full access projection = %v, want object-only access", policy.fullSeen)
	}
}

func TestDescribeObjectNeutralHandlerProjectsFieldsRecordTypesAndRelationships(t *testing.T) {
	helpText := "Shown beside the field label"
	account := models.ObjectMetadata{
		APIName:      "Account",
		Label:        "Account",
		PluralLabel:  "Accounts",
		Searchable:   true,
		SharingModel: constants.SharingModelPublicReadWrite,
		TableType:    constants.TableTypeStandardObject,
		Fields: []models.FieldMetadata{
			{APIName: "Id", Label: "Account ID", Type: constants.FieldTypeString, IsSystem: true},
			{APIName: "Name", Label: "Account Name", Type: constants.FieldTypeString, IsNameField: true, HelpText: &helpText},
		},
	}
	contact := models.ObjectMetadata{
		APIName:     "Contact",
		Label:       "Contact",
		PluralLabel: "Contacts",
		Fields: []models.FieldMetadata{{
			APIName:               "AccountId",
			Label:                 "Account",
			Type:                  constants.FieldTypeReference,
			ReferenceTo:           []string{"Account"},
			ChildRelationshipName: stringPtr("Contacts"),
		}},
	}
	metadata := &fakeDescribeMetadata{
		objects:     []models.ObjectMetadata{account, contact},
		schemas:     map[string]*models.ObjectMetadata{"Account": &account, "Contact": &contact},
		keyPrefixes: map[string]string{"Account": "001", "Contact": "003"},
		children:    map[string][]*models.ObjectMetadata{"Account": []*models.ObjectMetadata{&contact}},
		relationships: map[string][]*models.Relationship{
			"Contact": {{
				ParentObjectAPIName: "Account",
				ChildObjectAPIName:  "Contact",
				FieldAPIName:        "AccountId",
			}},
		},
	}
	policy := &fakeDescribePolicy{access: map[string]*contract.EffectiveObjectAccess{
		"Account": {
			Object: contract.ObjectAccess{Create: true, Read: true, Update: true, Delete: true},
			Fields: map[string]contract.FieldAccess{
				"Id":   {Readable: true},
				"Name": {Createable: true, Updateable: true, Readable: true},
			},
			RecordTypes: []contract.RecordTypeAvailability{{
				ID:        "012000000000000AAA",
				APIName:   "Master",
				Name:      "Master",
				Available: true,
				Master:    true,
				Default:   false,
			}, {
				ID:        "012-business",
				APIName:   "Business",
				Name:      "Business",
				Available: true,
				Master:    false,
				Default:   true,
			}},
			DefaultRecordTypeID:    "012-business",
			HasConcreteRecordTypes: true,
			AvailableConcreteCount: 1,
		},
	}}

	resp, err := DescribeObjectNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005"},
		Params:  map[string]string{"sobject": "Account"},
	}, dialectDescribeDeps(metadata, policy))
	if err != nil {
		t.Fatalf("DescribeObjectNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.Status)
	}
	body, ok := resp.Body.(describeSObjectResult)
	if !ok {
		t.Fatalf("body type = %T, want describeSObjectResult", resp.Body)
	}
	if body.Name != "Account" || body.KeyPrefix != "001" || !body.Createable || !body.Updateable || !body.Deletable {
		t.Fatalf("describe body = %#v", body)
	}
	if body.Urls["layouts"] != "/services/data/v63.0/sobjects/Account/describe/layouts" {
		t.Fatalf("layouts URL = %q", body.Urls["layouts"])
	}
	if len(body.Fields) != 2 || body.Fields[1].Name != "Name" || !body.Fields[1].Readable || !body.Fields[1].Createable {
		t.Fatalf("fields = %#v", body.Fields)
	}
	if body.Fields[1].InlineHelpText == nil || *body.Fields[1].InlineHelpText != helpText {
		t.Fatalf("Name inlineHelpText = %#v, want %q", body.Fields[1].InlineHelpText, helpText)
	}
	var wireField map[string]any
	if err := json.Unmarshal([]byte(mustJSON(t, body.Fields[1])), &wireField); err != nil {
		t.Fatalf("decode Name describe field: %v", err)
	}
	if wireField["inlineHelpText"] != helpText {
		t.Fatalf("Name wire inlineHelpText = %#v, want %q", wireField["inlineHelpText"], helpText)
	}
	if len(body.RecordTypeInfos) != 1 || body.RecordTypeInfos[0].RecordTypeId != "012-business" || body.RecordTypeInfos[0].Master {
		t.Fatalf("recordTypeInfos = %#v; want concrete record type projection without synthetic Master", body.RecordTypeInfos)
	}
	if len(body.ChildRelationships) != 1 || body.ChildRelationships[0].RelationshipName != "Contacts" {
		t.Fatalf("childRelationships = %#v", body.ChildRelationships)
	}
	if metadata.listCalls != 0 {
		t.Fatalf("object describe called ListObjects %d times, want ChildRelationships cache path", metadata.listCalls)
	}
	if !reflect.DeepEqual(metadata.childCalls, []string{"Account"}) {
		t.Fatalf("child relationship calls = %v, want [Account]", metadata.childCalls)
	}
}

func TestDescribeLayoutsNeutralHandlerProjectsRecordTypeSelectionEntries(t *testing.T) {
	account := models.ObjectMetadata{
		APIName:     "Account",
		Label:       "Account",
		PluralLabel: "Accounts",
		Fields: []models.FieldMetadata{
			{APIName: "Id", Label: "Account ID", Type: constants.FieldTypeString, IsSystem: true},
			{APIName: "Name", Label: "Account Name", Type: constants.FieldTypeString, IsNameField: true},
		},
	}
	metadata := &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{"Account": &account}}
	policy := &fakeDescribePolicy{access: map[string]*contract.EffectiveObjectAccess{
		"Account": {
			Object: contract.ObjectAccess{Read: true},
			RecordTypes: []contract.RecordTypeAvailability{{
				ID:        constants.SyntheticMasterRecordTypeID,
				APIName:   "Master",
				Name:      "Master",
				Available: true,
				Master:    true,
				Default:   false,
			}, {
				ID:        "012-business",
				APIName:   "Business",
				Name:      "Business",
				Available: true,
				Master:    false,
				Default:   true,
			}},
			DefaultRecordTypeID:    "012-business",
			HasConcreteRecordTypes: true,
			AvailableConcreteCount: 1,
		},
	}}

	resp, err := DescribeLayoutsNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005"},
		Params:  map[string]string{"sobject": "Account"},
	}, dialectDescribeDeps(metadata, policy))
	if err != nil {
		t.Fatalf("DescribeLayoutsNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.Status)
	}
	body, ok := resp.Body.(map[string]any)
	if !ok {
		t.Fatalf("body type = %T, want map[string]any", resp.Body)
	}
	mappings, ok := body["recordTypeMappings"].([]map[string]any)
	if !ok {
		t.Fatalf("recordTypeMappings type = %T, want []map[string]any", body["recordTypeMappings"])
	}
	if len(mappings) != 1 || mappings[0]["recordTypeId"] != "012-business" || mappings[0]["master"] == true {
		t.Fatalf("recordTypeMappings = %#v; want concrete record type projection without synthetic Master", mappings)
	}

	resp, err = DescribeLayoutByRecordTypeNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005"},
		Params: map[string]string{
			"sobject":      "Account",
			"recordTypeId": constants.SyntheticMasterRecordTypeID,
		},
	}, dialectDescribeDeps(metadata, policy))
	if err != nil {
		t.Fatalf("DescribeLayoutByRecordTypeNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusForbidden {
		t.Fatalf("direct synthetic Master layout status = %d, want 403", resp.Status)
	}
}

func TestDescribeLayoutsNeutralHandlerRejectsNoAvailableConcreteRecordTypes(t *testing.T) {
	account := models.ObjectMetadata{
		APIName:     "Account",
		Label:       "Account",
		PluralLabel: "Accounts",
		Fields: []models.FieldMetadata{
			{APIName: "Id", Label: "Account ID", Type: constants.FieldTypeString, IsSystem: true},
			{APIName: "Name", Label: "Account Name", Type: constants.FieldTypeString, IsNameField: true},
		},
	}
	metadata := &fakeDescribeMetadata{schemas: map[string]*models.ObjectMetadata{"Account": &account}}
	policy := &fakeDescribePolicy{access: map[string]*contract.EffectiveObjectAccess{
		"Account": {
			Object: contract.ObjectAccess{Read: true},
			RecordTypes: []contract.RecordTypeAvailability{{
				ID:        constants.SyntheticMasterRecordTypeID,
				APIName:   "Master",
				Name:      "Master",
				Available: true,
				Master:    true,
				Default:   false,
			}, {
				ID:        "012-hidden",
				APIName:   "Hidden",
				Name:      "Hidden",
				Available: false,
				Master:    false,
				Default:   false,
			}},
			HasConcreteRecordTypes: true,
			AvailableConcreteCount: 0,
		},
	}}

	resp, err := DescribeLayoutsNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005"},
		Params:  map[string]string{"sobject": "Account"},
	}, dialectDescribeDeps(metadata, policy))
	if err != nil {
		t.Fatalf("DescribeLayoutsNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusForbidden {
		t.Fatalf("status = %d, want 403", resp.Status)
	}
	assertBody(t, "no available concrete record types", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeForbidden, "No record types are available for object 'Account'")),
		`[{"errorCode":"FORBIDDEN","message":"No record types are available for object 'Account'"}]`)
}

func TestDescribeObjectNeutralHandlerErrorEnvelopes(t *testing.T) {
	metadata := &fakeDescribeMetadata{}
	policy := &fakeDescribePolicy{access: map[string]*contract.EffectiveObjectAccess{
		"NoRead": {Object: contract.ObjectAccess{Read: false}},
		"Ghost":  {Object: contract.ObjectAccess{Read: true}},
	}}

	resp, _ := DescribeObjectNeutralHandler(context.Background(), &dialect.Request{Params: map[string]string{"sobject": "NoRead"}}, dialectDescribeDeps(metadata, policy))
	if resp.Status != http.StatusForbidden {
		t.Fatalf("forbidden status = %d, want 403", resp.Status)
	}
	assertBody(t, "forbidden", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeForbidden, "Insufficient access to describe this object")),
		`[{"errorCode":"FORBIDDEN","message":"Insufficient access to describe this object"}]`)

	resp, _ = DescribeObjectNeutralHandler(context.Background(), &dialect.Request{Params: map[string]string{"sobject": "Ghost"}}, dialectDescribeDeps(metadata, policy))
	if resp.Status != http.StatusNotFound {
		t.Fatalf("notfound status = %d, want 404", resp.Status)
	}
	assertBody(t, "notfound", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeNotFound, "The requested resource does not exist")),
		`[{"errorCode":"NOT_FOUND","message":"The requested resource does not exist"}]`)
}

func TestSearchLayoutNeutralHandlerPlainMetadataErrorMatchesLegacyEnvelope(t *testing.T) {
	resp, err := SearchLayoutNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		Query:   map[string][]string{"q": {"Ghost"}},
	}, dialectDescribeDeps(&fakeDescribeMetadata{}, &fakeDescribePolicy{}))
	if err != nil {
		t.Fatalf("SearchLayoutNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500", resp.Status)
	}
	assertBody(t, "search layout metadata error", resp.Body,
		mustJSON(t, wire.SFErrorResponse(wire.SFCodeInternalError, "Metadata service unavailable")),
		`[{"errorCode":"INTERNAL_ERROR","message":"Metadata service unavailable"}]`)
}

func TestDescribeSystemFileNeutralHandlerPinsStaticRouteToSameDescribeMapper(t *testing.T) {
	systemFile := models.ObjectMetadata{
		APIName:     "_System_File",
		Label:       "File",
		PluralLabel: "Files",
		Fields: []models.FieldMetadata{
			{APIName: "Id", Label: "File ID", Type: constants.FieldTypeString, IsSystem: true},
		},
	}
	metadata := &fakeDescribeMetadata{
		schemas:     map[string]*models.ObjectMetadata{"_System_File": &systemFile},
		keyPrefixes: map[string]string{"_System_File": "068"},
	}
	policy := &fakeDescribePolicy{access: map[string]*contract.EffectiveObjectAccess{
		"_System_File": {
			Object: contract.ObjectAccess{Read: true},
			Fields: map[string]contract.FieldAccess{
				"Id": {Readable: true},
			},
		},
	}}

	resp, err := DescribeSystemFileNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		Params:  map[string]string{},
	}, dialectDescribeDeps(metadata, policy))
	if err != nil {
		t.Fatalf("DescribeSystemFileNeutralHandler error = %v", err)
	}
	if resp.Status != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.Status)
	}
	body, ok := resp.Body.(describeSObjectResult)
	if !ok {
		t.Fatalf("body type = %T, want describeSObjectResult", resp.Body)
	}
	if body.Name != "_System_File" || body.KeyPrefix != "068" {
		t.Fatalf("system file describe body = %#v", body)
	}
}

type fakeDescribeMetadata struct {
	objects       []models.ObjectMetadata
	schemas       map[string]*models.ObjectMetadata
	relationships map[string][]*models.Relationship
	children      map[string][]*models.ObjectMetadata
	keyPrefixes   map[string]string
	listCalls     int
	childCalls    []string
}

func (f *fakeDescribeMetadata) Describe(_ context.Context, objectName string) (*models.ObjectMetadata, error) {
	if f.schemas == nil {
		return nil, errors.New("not found")
	}
	if schema := f.schemas[objectName]; schema != nil {
		return schema, nil
	}
	return nil, errors.New("not found")
}

func (f *fakeDescribeMetadata) ListObjects(context.Context) ([]models.ObjectMetadata, error) {
	f.listCalls++
	return append([]models.ObjectMetadata(nil), f.objects...), nil
}

func (f *fakeDescribeMetadata) Relationships(_ context.Context, objectName string) ([]*models.Relationship, error) {
	return append([]*models.Relationship(nil), f.relationships[objectName]...), nil
}

func (f *fakeDescribeMetadata) ChildRelationships(_ context.Context, objectName string) ([]*models.ObjectMetadata, error) {
	f.childCalls = append(f.childCalls, objectName)
	return append([]*models.ObjectMetadata(nil), f.children[objectName]...), nil
}

func (f *fakeDescribeMetadata) KeyPrefix(_ context.Context, objectName string) (string, bool) {
	if f.keyPrefixes == nil {
		return "", false
	}
	prefix, ok := f.keyPrefixes[objectName]
	return prefix, ok
}

// ResolveLayout / ResolveRelatedListResolution satisfy
// the widened contract.Metadata interface. The describe-object/global golden cases
// in this file never touch the layouts read path, so these return the empty
// "no resolvable layout / no candidates / not virtual" verdicts. The layouts read
// path is pinned by the module's describe/layout handler tests against the neutral
// metadata seam.
func (f *fakeDescribeMetadata) ResolveLayout(_ context.Context, _ string, _ *models.UserSession, _ string) (*models.PageLayout, error) {
	return nil, nil
}

func (f *fakeDescribeMetadata) ResolvePicklistValuesForRecordType(context.Context, string, string, string) (*models.ScopedPicklistValueSet, error) {
	return nil, nil
}

func (f *fakeDescribeMetadata) ResolveRelatedListResolution(_ context.Context, _ string, _ string) (*contract.RelatedListResolution, bool) {
	return nil, false
}
func (f *fakeDescribeMetadata) Actions(context.Context, string) ([]*models.ActionMetadata, error) {
	return nil, nil
}

func (f *fakeDescribeMetadata) ActionByID(context.Context, string) (*models.ActionMetadata, error) {
	return nil, nil
}

type fakeDescribePolicy struct {
	access     map[string]*contract.EffectiveObjectAccess
	objectSeen []string
	fullSeen   []string
}

func (f *fakeDescribePolicy) Access(_ context.Context, _ *models.UserSession, objectName string, req contract.AccessRequest) (*contract.EffectiveObjectAccess, error) {
	if req.IncludeFields || req.IncludeRecordTypes {
		f.fullSeen = append(f.fullSeen, objectName)
	} else {
		f.objectSeen = append(f.objectSeen, objectName)
	}
	if access := f.access[objectName]; access != nil {
		return projectFakeAccess(access, req), nil
	}
	return &contract.EffectiveObjectAccess{}, nil
}

func (f *fakeDescribePolicy) HasCapability(context.Context, *models.UserSession, contract.Capability) bool {
	return false
}

func projectFakeAccess(access *contract.EffectiveObjectAccess, req contract.AccessRequest) *contract.EffectiveObjectAccess {
	out := *access
	if !req.IncludeFields {
		out.Fields = nil
	}
	if !req.IncludeRecordTypes {
		out.RecordTypes = nil
		out.DefaultRecordTypeID = ""
		out.HasConcreteRecordTypes = false
		out.AvailableConcreteCount = 0
	}
	return &out
}

func stringPtr(s string) *string { return &s }
