package sfcrmappservice

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"reflect"
	"strings"
	"testing"
	"time"

	catalogsemantic "github.com/pingcap-inc/nexuscrm-contract/pkg/catalog/consumerv1"
	permissionsemantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/effectivepermissionsv1"
	pagelayoutsemantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/pagelayoutresolutionv1"
)

func candidateLifecycleObjects(parent string) []searchObjectSpec {
	tableType := "standard_object"
	return []searchObjectSpec{
		{APIName: parent, Label: parent, TableType: &tableType, Fields: []searchFieldSpec{{APIName: "Id", Type: "id"}}},
		{APIName: "Child", Label: "Children", TableType: &tableType, Fields: []searchFieldSpec{
			{APIName: "Id", Type: "id"}, {APIName: "Name", Type: "string"}, {APIName: "CreatedDate", Type: "datetime"},
			{APIName: "ParentId", Label: "Parent", Type: "reference", ReferenceTo: []string{parent}, RelationshipName: "Parent"},
		}},
	}
}

// Exercise the actual candidate HTTP entrypoint with immutable Catalog input
// and the neutral Runtime ports; only the remote services are substituted.
func candidateLifecycleHTTP(t *testing.T, parent string, objects []searchObjectSpec, presentation relatedListPresentation, listFields map[string][]string, status int) []relatedListCandidate {
	t.Helper()
	for index := range objects {
		empty := []string{}
		objects[index].CreateRequiredFields = &empty
	}
	view := searchTestView(t, objects)
	view.EffectiveComponents[0].ComponentFormatVersion = effectiveObjectSearchComponentFormatV6
	behavior, err := json.Marshal(presentation)
	if err != nil {
		t.Fatal(err)
	}
	view.EffectiveComponents = append(view.EffectiveComponents, catalogsemantic.EffectiveMetadataComponent{ComponentKind: catalogsemantic.EffectiveRelatedListPresentationComponentKind, BusinessKey: catalogsemantic.EffectiveRelatedListPresentationComponentBusinessKey, ComponentFormatVersion: 1, CanonicalBehavior: behavior, ContentDigest: catalogsemantic.ComputeEffectiveMetadataComponentDigest(behavior)})
	view.Lineage.ViewDigest = catalogsemantic.ComputeAppMetadataViewV3Digest(view)
	if _, err := searchObjectSpecsFromView(view); err != nil {
		t.Fatalf("invalid lifecycle fixture: %v", err)
	}
	columnCalls := 0
	service := &Service{requestTimeout: time.Second,
		appMetadataV3: func(context.Context, catalogsemantic.GetRequest) (catalogsemantic.AppMetadataViewV3, error) {
			return view, nil
		},
		permissionMetadata: &RuntimePermissionMetadataClient{readEffective: func(_ context.Context, bearer string, request permissionsemantic.Request) (permissionsemantic.Response, error) {
			if bearer != "user-token" {
				t.Fatalf("bearer=%q", bearer)
			}
			receipts := view.Receipts
			receipts.CapabilityVersion = permissionsemantic.CapabilityName
			return selectedPermissionTestResponse(permissionsemantic.Response{Receipts: receipts, SystemPermissions: []permissionsemantic.PermissionGrant{{PermissionID: "CustomizeApplication", Granted: true}}}, request), nil
		}},
		readObjectListFieldsRuntime: func(_ context.Context, _ string, request pagelayoutsemantic.ObjectListFieldsRequest) (pagelayoutsemantic.ObjectListFieldsResponse, error) {
			columnCalls++
			if columnCalls != 1 || request.MinCatalogRevision == nil || *request.MinCatalogRevision != view.Receipts.CatalogRevision {
				t.Fatalf("unbound or repeated column batch: %#v", request)
			}
			receipts := view.Receipts
			receipts.CapabilityVersion = pagelayoutsemantic.CapabilityName
			result := pagelayoutsemantic.ObjectListFieldsResponse{Receipts: receipts}
			seen := make(map[string]bool)
			for _, object := range request.ObjectIDs {
				key := strings.ToLower(object)
				fields, found := listFields[key]
				if !found || seen[key] {
					t.Fatalf("unknown or duplicate Runtime target %q", object)
				}
				seen[key] = true
				result.Objects = append(result.Objects, pagelayoutsemantic.ObjectListFields{ObjectID: object, OrderedFieldIDs: fields})
			}
			return result, nil
		},
	}
	request := httptest.NewRequest(http.MethodGet, "/services/data/v63.0/ui-api/related-list-candidates/"+parent, nil)
	request.Header.Set("Authorization", "Bearer user-token")
	response := httptest.NewRecorder()
	service.ServeHTTP(response, request)
	if response.Code != status {
		t.Fatalf("HTTP=%d body=%s", response.Code, response.Body.String())
	}
	if status != http.StatusOK {
		return nil
	}
	var result struct {
		Candidates []relatedListCandidate `json:"candidates"`
	}
	if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
		t.Fatal(err)
	}
	if result.Candidates == nil {
		t.Fatal("missing candidate envelope")
	}
	if len(result.Candidates) > 0 && columnCalls != 1 {
		t.Fatalf("candidate columns read %d times", columnCalls)
	}
	return result.Candidates
}

func assertCandidateLayoutAuthorization(t *testing.T, parent string, objects []searchObjectSpec, candidates []relatedListCandidate, listFields map[string][]string, resolutions []pagelayoutsemantic.RelatedListResolution) {
	t.Helper()
	specs := make(map[string]searchObjectSpec)
	permissions := permissionsemantic.Response{}
	for _, object := range objects {
		specs[strings.ToLower(object.APIName)] = object
		permissions.ObjectPermissions = append(permissions.ObjectPermissions, permissionsemantic.ObjectPermission{ResourceType: object.APIName, CanRead: true})
		for _, field := range object.Fields {
			permissions.FieldPermissions = append(permissions.FieldPermissions, permissionsemantic.FieldPermission{ResourceType: object.APIName, FieldName: field.APIName, CanRead: true})
		}
	}
	// This is the Layout shape selected by the editor, serialized and decoded
	// through the same stored-layout decoder used by Runtime layout reads.
	layout := appPageLayout{ID: "layout-candidates", ObjectAPIName: parent, LayoutName: "Candidates", RelatedLists: []appRelatedList{}}
	for _, candidate := range candidates {
		layout.RelatedLists = append(layout.RelatedLists, appRelatedList{ID: candidate.ID, Label: candidate.Label, SecondaryLabel: candidate.SecondaryLabel, ObjectAPIName: candidate.ObjectAPIName, LookupField: candidate.LookupField, Fields: candidate.Fields})
	}
	encoded, err := json.Marshal(layout)
	if err != nil {
		t.Fatal(err)
	}
	stored, err := decodeAppPageLayout(encoded)
	if err != nil {
		t.Fatal(err)
	}
	if err := attachRelatedListResolutions(&stored, resolutions); err != nil {
		t.Fatal(err)
	}
	authorized, err := authorizePageLayout(stored, specs, permissions, listFields)
	if err != nil {
		t.Fatal(err)
	}
	if len(authorized.RelatedLists) != len(candidates) {
		t.Fatalf("candidates disappeared on read: before=%#v after=%#v", candidates, authorized.RelatedLists)
	}
	for index, candidate := range candidates {
		related := authorized.RelatedLists[index]
		if related.ID != candidate.ID || !reflect.DeepEqual(related.Fields, candidate.Fields) {
			t.Fatalf("candidate changed on read: before=%#v after=%#v", candidate, related)
		}
		if _, derived := virtualHistoryParent(candidate.ObjectAPIName, specs); derived {
			// A derived list is readable through its parent. Removing the parent
			// grant must reject the whole parent layout, not query fake grants.
			denied := permissions
			denied.ObjectPermissions = nil
			for _, grant := range permissions.ObjectPermissions {
				if grant.ResourceType != parent {
					denied.ObjectPermissions = append(denied.ObjectPermissions, grant)
				}
			}
			if _, err := authorizePageLayout(stored, specs, denied, listFields); err == nil {
				t.Fatal("history survived denied parent access")
			}
			continue
		}
		// Setup authority is not end-user read authority. The same saved list
		// must disappear for a user without access to its display object.
		denied := permissions
		denied.ObjectPermissions = nil
		for _, grant := range permissions.ObjectPermissions {
			if grant.ResourceType != candidate.ObjectAPIName {
				denied.ObjectPermissions = append(denied.ObjectPermissions, grant)
			}
		}
		filtered, err := authorizePageLayout(stored, specs, denied, listFields)
		if err != nil {
			t.Fatal(err)
		}
		for _, visible := range filtered.RelatedLists {
			if visible.ID == candidate.ID {
				t.Fatalf("unreadable related list survived: %#v", visible)
			}
		}
	}
}

func TestOfficialRelatedListCandidatesRemainSchemaBoundThroughLayoutRead(t *testing.T) {
	raw, err := os.ReadFile("../../salesforce-crm-standard/related_lists/standard_related_list_rules.json")
	if err != nil {
		t.Fatal(err)
	}
	for _, parent := range []string{"Quote", "Order", "Contract"} {
		for _, fileShape := range []string{"absent", "field-reference", "stale-relationship"} {
			t.Run(parent+"/"+fileShape, func(t *testing.T) {
				var presentation relatedListPresentation
				if err := json.Unmarshal(raw, &presentation); err != nil {
					t.Fatal(err)
				}
				presentation.Relationships = []relatedListPresentationColumns{}
				objects := candidateLifecycleObjects(parent)
				listFields := map[string][]string{"child": {"Name"}}
				if fileShape != "absent" {
					file := candidateLifecycleObjects(parent)[1]
					file.APIName, file.Label = "_System_File", "Stored files"
					if fileShape == "stale-relationship" {
						file.Fields[3].ReferenceTo = nil
						presentation.Relationships = append(presentation.Relationships, relatedListPresentationColumns{ParentObjectAPIName: parent, ChildObjectAPIName: file.APIName, FieldAPIName: "ParentId"})
					}
					objects = append(objects, file)
					listFields["_system_file"] = []string{"Id"}
				}
				candidates := candidateLifecycleHTTP(t, parent, objects, presentation, listFields, http.StatusOK)
				want := 2 // physical child plus the parent-derived history list
				if fileShape == "field-reference" {
					want++
				}
				if len(candidates) != want {
					t.Fatalf("candidates=%#v want=%d", candidates, want)
				}
				for _, candidate := range candidates {
					if candidate.ObjectAPIName == "_System_File" && (candidate.Label != "Files" || !candidate.IsRecommended || !reflect.DeepEqual(candidate.Fields, []string{"Name", "CreatedDate"})) {
						t.Fatalf("official presentation lost: %#v", candidate)
					}
				}
				assertCandidateLayoutAuthorization(t, parent, objects, candidates, listFields, nil)
			})
		}
	}
}

func TestRelatedListCandidateRulesCannotInventLayoutReferences(t *testing.T) {
	for _, test := range []struct {
		name, child, lookup string
		resolved            bool
	}{
		{"missing display", "DisplayOnly", "ParentId", false},
		{"missing display with resolution", "DisplayOnly", "ParentId", true},
		{"missing lookup", "Child", "MissingId", false},
		{"lookup does not reference parent", "Child", "Name", false},
	} {
		t.Run(test.name, func(t *testing.T) {
			objects := candidateLifecycleObjects("Parent")
			rule := relatedListPresentationRule{ParentObjectAPIName: "Parent", ChildObjectAPIName: test.child, LookupField: test.lookup, AppendIfMissing: true, Label: "Unusable", Fields: []string{"Name"}}
			if test.resolved {
				rule.Resolution = &appRelatedListResolution{Query: appRelatedListQueryTarget{ObjectAPIName: "Child", LookupField: "ParentId"}}
			}
			presentation := relatedListPresentation{Rules: []relatedListPresentationRule{rule}, Relationships: []relatedListPresentationColumns{}}
			fields := map[string][]string{"child": {"Name"}}
			candidates := candidateLifecycleHTTP(t, "Parent", objects, presentation, fields, http.StatusOK)
			if len(candidates) != 2 || candidates[0].ID != "Child-ParentId" || candidates[1].ID != "ParentHistory-ParentId" {
				t.Fatalf("rule minted an unusable candidate: %#v", candidates)
			}
			assertCandidateLayoutAuthorization(t, "Parent", objects, candidates, fields, nil)
		})
	}
}

func TestRelatedListCandidateResolvedColumnsSurviveLayoutRead(t *testing.T) {
	for _, test := range []struct {
		name         string
		missingQuery bool
	}{{"resolved", false}, {"missing-query", true}} {
		t.Run(test.name, func(t *testing.T) {
			missingQuery := test.missingQuery
			objects := candidateLifecycleObjects("Parent")
			resolution := &appRelatedListResolution{Query: appRelatedListQueryTarget{ObjectAPIName: "QueryChild", LookupField: "ParentId"}, Detail: appRelatedListDetailTarget{ObjectAPIName: "QueryChild"}, List: appRelatedListListTarget{ObjectAPIName: "QueryChild", FilterField: "ParentId"}, Create: appRelatedListCreateTarget{Kind: "none"}}
			if !missingQuery {
				target := objects[1]
				target.APIName = "QueryChild"
				target.Fields = append(append([]searchFieldSpec(nil), target.Fields...), searchFieldSpec{APIName: "QueryOnly", Type: "string"})
				objects = append(objects, target)
			}
			presentation := relatedListPresentation{Rules: []relatedListPresentationRule{{ParentObjectAPIName: "Parent", ChildObjectAPIName: "Child", LookupField: "ParentId", Fields: []string{"name", "NAME", "Parent.Id", "QueryOnly", "Missing"}, Resolution: resolution}}, Relationships: []relatedListPresentationColumns{}}
			fields := map[string][]string{"querychild": {"Name"}}
			status := http.StatusOK
			if missingQuery {
				status = http.StatusServiceUnavailable
			}
			candidates := candidateLifecycleHTTP(t, "Parent", objects, presentation, fields, status)
			if missingQuery {
				return
			}
			if len(candidates) != 3 || candidates[2].ID != "ParentHistory-ParentId" {
				t.Fatalf("candidates=%#v", candidates)
			}
			for _, candidate := range candidates[:2] {
				if !reflect.DeepEqual(candidate.Fields, []string{"Name"}) {
					t.Fatalf("unsaveable columns: %#v", candidate)
				}
			}
			assertCandidateLayoutAuthorization(t, "Parent", objects, candidates, fields, []pagelayoutsemantic.RelatedListResolution{{DisplayObjectID: "Child", DisplayLookupField: "ParentId", Query: pagelayoutsemantic.ResolvedQueryTarget{ObjectID: "QueryChild", LookupField: "ParentId"}, Detail: pagelayoutsemantic.ResolvedDetailTarget{ObjectID: "QueryChild"}, List: pagelayoutsemantic.ResolvedListTarget{ObjectID: "QueryChild", FilterField: "ParentId"}, Create: pagelayoutsemantic.ResolvedCreateTarget{Kind: "none"}}})
		})
	}
}

func TestRelatedListCandidatesRejectIncompleteReadAuthority(t *testing.T) {
	for _, test := range []struct {
		name string
		edit func([]searchObjectSpec, *appRelatedListResolution)
	}{
		{"old parent projection", func(objects []searchObjectSpec, _ *appRelatedListResolution) { objects[0].TableType = nil }},
		{"old child projection", func(objects []searchObjectSpec, _ *appRelatedListResolution) { objects[1].TableType = nil }},
		{"missing query lookup", func(_ []searchObjectSpec, r *appRelatedListResolution) { r.Query.LookupField = "Missing" }},
		{"missing detail object", func(_ []searchObjectSpec, r *appRelatedListResolution) { r.Detail.ObjectAPIName = "Missing" }},
		{"missing list object", func(_ []searchObjectSpec, r *appRelatedListResolution) { r.List.ObjectAPIName = "Missing" }},
		{"missing list filter", func(_ []searchObjectSpec, r *appRelatedListResolution) { r.List.FilterField = "Missing" }},
		{"missing query parent field", func(_ []searchObjectSpec, r *appRelatedListResolution) { r.Query.ParentFieldAPIName = "Missing" }},
		{"missing list parent field", func(_ []searchObjectSpec, r *appRelatedListResolution) { r.List.ParentFieldAPIName = "Missing" }},
	} {
		t.Run(test.name, func(t *testing.T) {
			objects := candidateLifecycleObjects("Parent")
			resolution := appRelatedListResolution{Query: appRelatedListQueryTarget{ObjectAPIName: "Child", LookupField: "ParentId"}, Detail: appRelatedListDetailTarget{ObjectAPIName: "Child"}, List: appRelatedListListTarget{ObjectAPIName: "Child", FilterField: "ParentId"}, Create: appRelatedListCreateTarget{Kind: "none"}}
			test.edit(objects, &resolution)
			presentation := relatedListPresentation{Rules: []relatedListPresentationRule{{ParentObjectAPIName: "Parent", ChildObjectAPIName: "Child", LookupField: "ParentId", Resolution: &resolution}}, Relationships: []relatedListPresentationColumns{}}
			candidateLifecycleHTTP(t, "Parent", objects, presentation, map[string][]string{"child": {"Name"}}, http.StatusServiceUnavailable)
		})
	}
}

func TestRelatedListCandidateBucketDefaultsFollowMatchedRule(t *testing.T) {
	for _, test := range []struct {
		name, ruleParent, bucket, want string
	}{
		{"no rule", "", "", "additional"},
		{"unmatched rule", "OtherParent", "", "additional"},
		{"omitted bucket", "Parent", "", "recommended"},
		{"explicit recommended", "Parent", "recommended", "recommended"},
		{"explicit additional", "Parent", "additional", "additional"},
		{"explicit system", "Parent", "system", "system"},
	} {
		t.Run(test.name, func(t *testing.T) {
			presentation := relatedListPresentation{Rules: []relatedListPresentationRule{}, Relationships: []relatedListPresentationColumns{}}
			if test.ruleParent != "" {
				presentation.Rules = append(presentation.Rules, relatedListPresentationRule{ParentObjectAPIName: test.ruleParent, ChildObjectAPIName: "Child", LookupField: "ParentId", Bucket: test.bucket})
			}
			candidates := candidateLifecycleHTTP(t, "Parent", candidateLifecycleObjects("Parent"), presentation, map[string][]string{"child": {"Name"}}, http.StatusOK)
			if len(candidates) != 2 || candidates[1].ID != "ParentHistory-ParentId" {
				t.Fatalf("candidates=%#v", candidates)
			}
			got := candidates[0]
			if got.Bucket != test.want || got.IsRecommended != (test.want == "recommended") || got.IsSystem != (test.want == "system") {
				t.Fatalf("bucket projection=%#v, want %q", got, test.want)
			}
		})
	}
}
