package dialectsfcrm

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

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

// The CRM frontend reads object metadata from /sobjects/{object}/describe today and must read it from
// /ui-api/object-info instead before a browser can stop carrying a programmatic credential
// (#1865 §12, slice 1). That is only safe while the two answer identically about a field: the
// frontend spreads the whole describe field onto its own shape, so a fact object-info omits or spells
// differently does not fail loudly — a picklist renders empty, a required marker disappears, a
// currency loses its scale.
//
// These tests own that agreement. They are deliberately about *sameness*, not about particular
// values: pinning "Amount has scale 2" would pass while object-info answered from a second derivation
// that happens to agree today.

// richFieldSchemaFixture returns fields chosen to make every attribute in the v63 FieldInfo shape
// differ from its zero value somewhere, so an attribute that is silently dropped cannot pass.
func richFieldSchemaFixture() []models.FieldMetadata {
	percent := constants.FieldTypePercent
	helpText := "Two decimal places, in the account currency."
	controlling := "Stage__c"
	scale := 2
	relationship := "Owner"
	formula := "Amount__c * 0.1"

	return []models.FieldMetadata{
		{
			APIName: "Amount__c", Label: "Amount", Type: constants.FieldTypeCurrency,
			Createable: true, Updateable: true, Required: true, HelpText: &helpText, Scale: &scale,
		},
		{
			APIName: "Discount_Pct__c", Label: "Discount Pct", Type: constants.FieldTypeFormula,
			ReturnType: &percent, Formula: &formula,
		},
		{
			APIName: "External_Key__c", Label: "External Key", Type: constants.FieldTypeString,
			Createable: true, Updateable: true, IsUnique: true,
		},
		{
			APIName: "Notes__c", Label: "Notes", Type: constants.FieldTypeRichtextarea,
			Createable: true, Updateable: true,
		},
		{
			APIName: "Stage__c", Label: "Stage", Type: constants.FieldTypePicklist,
			Createable: true, Updateable: true,
			Options: models.PicklistOptions{{Value: "New", Label: "New"}},
		},
		{
			APIName: "Sub_Stage__c", Label: "Sub Stage", Type: constants.FieldTypePicklist,
			Createable: true, Updateable: true,
			ControllingField:   &controlling,
			PicklistDependency: map[string][]string{"New": {"A", "B"}},
			Options:            models.PicklistOptions{{Value: "A", Label: "A"}, {Value: "B", Label: "B"}},
		},
		{
			APIName: "OwnerId", Label: "Owner", Type: constants.FieldTypeReference,
			ReferenceTo: []string{"User"}, RelationshipName: &relationship,
			Createable: true, Updateable: true,
		},
		{
			APIName: "System_Flag", Label: "System Flag", Type: constants.FieldTypeBoolean,
			IsSystem: true,
		},
		// An optional checkbox, which is the case that broke the first version of this projection.
		// Describe reports every boolean as non-nillable — a checkbox is false, never null — so
		// deriving requiredness by inverting nillable marked fields like PricebookEntry.IsActive and
		// Contact.DoNotCall required. Requiredness has to come from the schema's own bit.
		{
			APIName: "Do_Not_Call__c", Label: "Do Not Call", Type: constants.FieldTypeBoolean,
			Createable: true, Updateable: true, Required: false,
		},
		// And a required checkbox, so the fixture distinguishes "not required" from "never required".
		{
			APIName: "Terms_Accepted__c", Label: "Terms Accepted", Type: constants.FieldTypeBoolean,
			Createable: true, Updateable: true, Required: true,
		},
	}
}

func objectInfoAndDescribeForFixture(t *testing.T) (*uiAPIObjectInfo, describeSObjectResult) {
	t.Helper()

	stub := newUIAPITestContracts()
	stub.schema.Fields = append(stub.schema.Fields, richFieldSchemaFixture()...)
	deps := uiAPIDeps(stub, nil)

	infoResp, err := UIAPIObjectInfoNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005"},
		Params:  map[string]string{"objectApiName": "Account"},
	}, deps)
	if err != nil {
		t.Fatalf("UIAPIObjectInfoNeutralHandler error = %v", err)
	}
	if infoResp.Status != http.StatusOK {
		t.Fatalf("object-info status = %d, want 200; body=%#v", infoResp.Status, infoResp.Body)
	}
	info, ok := infoResp.Body.(*uiAPIObjectInfo)
	if !ok {
		t.Fatalf("object-info body type = %T, want *uiAPIObjectInfo", infoResp.Body)
	}

	describeResp, err := DescribeObjectNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    &models.UserSession{ID: "005"},
		Params:  map[string]string{"sobject": "Account"},
	}, deps)
	if err != nil {
		t.Fatalf("DescribeObjectNeutralHandler error = %v", err)
	}
	if describeResp.Status != http.StatusOK {
		t.Fatalf("describe status = %d, want 200; body=%#v", describeResp.Status, describeResp.Body)
	}
	described, ok := describeResp.Body.(describeSObjectResult)
	if !ok {
		t.Fatalf("describe body type = %T, want describeSObjectResult", describeResp.Body)
	}
	return info, described
}

// TestObjectInfoAnswersTheSameFieldFactsAsDescribe is the invariant the frontend migration rests on.
func TestObjectInfoAnswersTheSameFieldFactsAsDescribe(t *testing.T) {
	info, described := objectInfoAndDescribeForFixture(t)

	checked := 0
	for _, field := range described.Fields {
		if !field.Readable {
			continue
		}
		fi, ok := info.Fields[field.Name]
		if !ok {
			t.Errorf("field %q is in describe but missing from object-info", field.Name)
			continue
		}
		checked++

		for _, disagreement := range []struct {
			attribute string
			describe  any
			objectSet any
		}{
			{"label", field.Label, fi.Label},
			{"dataType", field.Type, fi.DataType},
			{"createable", field.Createable, fi.Createable},
			{"updateable", field.Updateable, fi.Updateable},
			{"length", field.Length, fi.Length},
			{"nameField", field.NameField, fi.NameField},
			{"custom", field.Custom, fi.Custom},
			{"calculated", field.Calculated, fi.Calculated},
			{"unique", field.Unique, fi.Unique},
			{"precision", field.Precision, fi.Precision},
			{"scale", field.Scale, fi.Scale},
			{"sortable", field.Sortable, fi.Sortable},
			{"filterable", field.Filterable, fi.Filterable},
			{"htmlFormatted", field.HtmlFormatted, fi.HtmlFormatted},
			{"reference", len(field.ReferenceTo) > 0, fi.Reference},
		} {
			if disagreement.describe != disagreement.objectSet {
				t.Errorf("field %q attribute %s: describe=%v object-info=%v",
					field.Name, disagreement.attribute, disagreement.describe, disagreement.objectSet)
			}
		}
		assertStringPointersAgree(t, field.Name, "inlineHelpText", field.InlineHelpText, fi.InlineHelpText)
		assertStringPointersAgree(t, field.Name, "controllerName", field.ControllerName, fi.ControllerName)
	}

	// Requiredness is checked against the schema rather than against describe, because describe's
	// shape has no attribute for it. The first version of this test inverted describe's `nillable`
	// on both sides of the comparison, so it agreed with a lossy derivation and passed while every
	// optional checkbox was being reported required.
	assertRequirednessMatchesTheSchema(t, info)

	if checked < len(richFieldSchemaFixture()) {
		t.Fatalf("compared %d fields, want at least the %d in the fixture: the comparison is not reaching them",
			checked, len(richFieldSchemaFixture()))
	}
}

// TestTheFixtureExercisesEveryAddedAttribute keeps the agreement test above honest. Comparing two
// answers that are both the zero value proves nothing, so this fails if the fixture stops producing a
// non-zero value for an attribute the frontend depends on.
func TestTheFixtureExercisesEveryAddedAttribute(t *testing.T) {
	info, _ := objectInfoAndDescribeForFixture(t)

	seen := map[string]bool{}
	for _, fi := range info.Fields {
		if fi.Custom {
			seen["custom"] = true
		}
		if fi.Calculated {
			seen["calculated"] = true
		}
		if fi.Unique {
			seen["unique"] = true
		}
		if fi.Precision != 0 {
			seen["precision"] = true
		}
		if fi.Scale != 0 {
			seen["scale"] = true
		}
		if fi.Sortable {
			seen["sortable"] = true
		}
		if fi.Filterable {
			seen["filterable"] = true
		}
		if fi.HtmlFormatted {
			seen["htmlFormatted"] = true
		}
		if fi.InlineHelpText != nil && *fi.InlineHelpText != "" {
			seen["inlineHelpText"] = true
		}
		if fi.ControllerName != nil && *fi.ControllerName != "" {
			seen["controllerName"] = true
		}
		if fi.Reference {
			seen["reference"] = true
		}
		if fi.Required {
			seen["required"] = true
		}
	}

	for _, attribute := range []string{
		"custom", "calculated", "unique", "precision", "scale", "sortable",
		"filterable", "htmlFormatted", "inlineHelpText", "controllerName", "reference", "required",
	} {
		if !seen[attribute] {
			t.Errorf("no field in the fixture produces a non-zero %s, so the agreement test cannot detect it being dropped", attribute)
		}
	}
}

// TestObjectInfoStillCarriesNoPicklistValues guards the parity line from the other side. Picklist
// values are the single biggest reason the frontend still reads describe, which makes inlining them
// here the tempting shortcut — and it would be a v63 break: the UI API serves them from their own
// versioned endpoint, keyed by record type, not from FieldInfo.
func TestObjectInfoStillCarriesNoPicklistValues(t *testing.T) {
	info, _ := objectInfoAndDescribeForFixture(t)

	stage, ok := info.Fields["Stage__c"]
	if !ok {
		t.Fatal("Stage__c missing from object-info")
	}
	if stage.DataType != string(constants.FieldTypePicklist) {
		t.Fatalf("Stage__c dataType = %q, want picklist", stage.DataType)
	}
	// Structural, so it fails on a field being added rather than on a value.
	for _, field := range []string{"picklistValues", "nillable"} {
		if fieldInfoHasJSONKey(t, stage, field) {
			t.Fatalf("object-info FieldInfo carries %q, which is not a v63 UI API FieldInfo attribute", field)
		}
	}
}

func assertStringPointersAgree(t *testing.T, fieldName, attribute string, describe, objectInfo *string) {
	t.Helper()
	switch {
	case describe == nil && objectInfo == nil:
	case describe == nil || objectInfo == nil:
		t.Errorf("field %q attribute %s: describe=%v object-info=%v (one is absent)",
			fieldName, attribute, stringPointerForMessage(describe), stringPointerForMessage(objectInfo))
	case *describe != *objectInfo:
		t.Errorf("field %q attribute %s: describe=%q object-info=%q", fieldName, attribute, *describe, *objectInfo)
	}
}

func stringPointerForMessage(value *string) string {
	if value == nil {
		return "<nil>"
	}
	return *value
}

// fieldInfoHasJSONKey reports whether the serialized FieldInfo carries a key, so the parity check
// above is about the wire shape rather than about a Go field name.
func fieldInfoHasJSONKey(t *testing.T, field uiAPIFieldInfo, key string) bool {
	t.Helper()
	encoded, err := json.Marshal(field)
	if err != nil {
		t.Fatalf("marshal FieldInfo: %v", err)
	}
	var decoded map[string]any
	if err := json.Unmarshal(encoded, &decoded); err != nil {
		t.Fatalf("unmarshal FieldInfo: %v", err)
	}
	_, present := decoded[key]
	return present
}

// assertRequirednessMatchesTheSchema is its own check because requiredness is the one field fact
// describe does not carry, so it cannot be part of the describe comparison.
//
// The boolean cases are the point. Describe reports every boolean as non-nillable — a checkbox is
// false, never null — so inverting nillable to obtain requiredness marks optional checkboxes such as
// PricebookEntry.IsActive or Contact.DoNotCall as required, which puts false required markers in the
// wire contract and makes a form refuse to save.
func assertRequirednessMatchesTheSchema(t *testing.T, info *uiAPIObjectInfo) {
	t.Helper()

	booleansChecked := 0
	for _, field := range richFieldSchemaFixture() {
		fi, ok := info.Fields[field.APIName]
		if !ok {
			continue
		}
		if fi.Required != field.Required {
			t.Errorf("field %q required = %t, want the schema's %t", field.APIName, fi.Required, field.Required)
		}
		if field.Type == constants.FieldTypeBoolean {
			booleansChecked++
		}
	}
	if booleansChecked < 2 {
		t.Fatalf("checked %d boolean fields, want both the optional and the required one: the case that regressed is not covered", booleansChecked)
	}
}
