package permissions

// Regression coverage for issue #3845: RLS visibility on >=2-level
// ControlledByParent chains must propagate "unrestricted" transitively.
//
// Chain fixture: GrandObj <- MidObj (CbP, required FK GrandRef)
//                         <- LeafObj (CbP, required FK MidRef)
// Both edges are required-FK, i.e. parentOnly: the children carry ONLY the
// parent-visibility arm, so a dropped parent arm degenerates straight to
// the deny-all sentinel — which is exactly how #3845 presented (parent
// record page loads, every child query/related-list count returns zero).
//
// The existing golden matrix (rls_policy_matrix_golden_test.go) pins the
// over-grant direction (Skip only for privileged arms); these tests pin the
// under-grant direction on chains.

import (
	"context"
	"strings"
	"testing"

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

type cbpChainMetadata struct {
	accessDecisionGoldenMetadata
	rels map[string]*models.Relationship // child API name -> sharing-parent edge
}

func (m *cbpChainMetadata) GetSharingParentRelationship(_ context.Context, childAPI string) *models.Relationship {
	return m.rels[childAPI]
}

func (m *cbpChainMetadata) GetParentControlledObject(_ context.Context, schema *models.ObjectMetadata) string {
	if schema == nil || schema.SharingModel != constants.SharingModelControlledByParent {
		return ""
	}
	if rel := m.rels[schema.APIName]; rel != nil {
		return rel.ParentObjectAPIName
	}
	return ""
}

type cbpChainFixture struct {
	svc   *PermissionService
	grand *models.ObjectMetadata
	mid   *models.ObjectMetadata
	leaf  *models.ObjectMetadata
}

// buildCbPChainFixture wires the three-level chain. rootModel controls
// GrandObj's sharing model; withMidEdge=false removes MidObj's sharing-parent
// relationship row (the malformed-CbP shape). objectPerms grants Read on all
// three objects and, when rootViewAll, object-level ViewAllRecords on the root.
func buildCbPChainFixture(user *models.UserSession, rootModel constants.SharingModel, rootViewAll, withMidEdge bool) cbpChainFixture {
	grand := &models.ObjectMetadata{
		APIName:      "GrandObj",
		SharingModel: rootModel,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, IsSystem: true},
			{APIName: constants.FieldOwnerID},
			{APIName: "Name"},
		},
	}
	mid := &models.ObjectMetadata{
		APIName:      "MidObj",
		SharingModel: constants.SharingModelControlledByParent,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, IsSystem: true},
			{APIName: constants.FieldOwnerID},
			{APIName: "GrandRef", Required: true}, // required FK -> parentOnly edge
		},
	}
	leaf := &models.ObjectMetadata{
		APIName:      "LeafObj",
		SharingModel: constants.SharingModelControlledByParent,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, IsSystem: true},
			{APIName: constants.FieldOwnerID},
			{APIName: "MidRef", Required: true}, // required FK -> parentOnly edge
		},
	}

	rels := map[string]*models.Relationship{
		"LeafObj": {
			ChildObjectAPIName:  "LeafObj",
			ParentObjectAPIName: "MidObj",
			FieldAPIName:        "MidRef",
			IsSharingParent:     true,
		},
	}
	if withMidEdge {
		rels["MidObj"] = &models.Relationship{
			ChildObjectAPIName:  "MidObj",
			ParentObjectAPIName: "GrandObj",
			FieldAPIName:        "GrandRef",
			IsSharingParent:     true,
		}
	}

	metadata := &cbpChainMetadata{
		accessDecisionGoldenMetadata: accessDecisionGoldenMetadata{
			schemas: []*models.ObjectMetadata{grand, mid, leaf},
		},
		rels: rels,
	}

	repo := &accessDecisionGoldenRepo{
		objectPerms: map[string]*models.ObjectPermissions{
			accessDecisionGoldenKey(user.ID, "GrandObj"): {
				SobjectType:               "GrandObj",
				PermissionsRead:           true,
				PermissionsViewAllRecords: rootViewAll,
			},
			accessDecisionGoldenKey(user.ID, "MidObj"): {
				SobjectType:     "MidObj",
				PermissionsRead: true,
			},
			accessDecisionGoldenKey(user.ID, "LeafObj"): {
				SobjectType:     "LeafObj",
				PermissionsRead: true,
			},
		},
		systemPerms: map[string]map[string]bool{}, // never ViewAllData here
	}

	return cbpChainFixture{
		svc:   NewPermissionService(repo, metadata, nil),
		grand: grand,
		mid:   mid,
		leaf:  leaf,
	}
}

func (f cbpChainFixture) filters(ctx context.Context, user *models.UserSession) (grandSQL, midSQL, leafSQL string) {
	grandSQL = RLSResultToFilterExpr(f.svc.BuildRLSResult(ctx, f.grand, "GrandObj", user), "")
	midSQL = RLSResultToFilterExpr(f.svc.BuildRLSResult(ctx, f.mid, "MidObj", user), "")
	leafSQL = RLSResultToFilterExpr(f.svc.BuildRLSResult(ctx, f.leaf, "LeafObj", user), "")
	return grandSQL, midSQL, leafSQL
}

// #3845 repro: object-level ViewAllRecords on the chain root (no ViewAllData)
// must make every level unrestricted — previously the leaf collapsed to
// "1 = 0" while the middle stayed fully visible.
func TestRLSCbPChainRootViewAllRecordsUnrestrictsDescendants(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "user-var-root", ProfileID: "profile-ops"}
	f := buildCbPChainFixture(user, constants.SharingModelPrivate, true, true)

	grandSQL, midSQL, leafSQL := f.filters(ctx, user)
	if grandSQL != "" {
		t.Fatalf("GrandObj: expected unrestricted via ViewAllRecords, got %q", grandSQL)
	}
	if midSQL != "" {
		t.Fatalf("MidObj: expected unrestricted (root fully visible), got %q", midSQL)
	}
	if leafSQL != "" {
		t.Fatalf("LeafObj: expected unrestricted (parent chain fully visible), got %q", leafSQL)
	}
}

// Public root OWD: same transitive expectation for a plain user.
func TestRLSCbPChainPublicReadRootUnrestrictsDescendants(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "user-plain", ProfileID: "profile-standard"}
	f := buildCbPChainFixture(user, constants.SharingModelPublicRead, false, true)

	grandSQL, midSQL, leafSQL := f.filters(ctx, user)
	if grandSQL != "" || midSQL != "" || leafSQL != "" {
		t.Fatalf("public root must unrestrict the whole chain, got grand=%q mid=%q leaf=%q",
			grandSQL, midSQL, leafSQL)
	}
}

// Over-grant guard: a Private root with NO privilege must keep every level
// restricted — the leaf's parent arm must survive as a real subquery chain,
// not collapse to unrestricted.
func TestRLSCbPChainPrivateRootStaysRestrictedForPlainUser(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "user-plain", ProfileID: "profile-standard"}
	f := buildCbPChainFixture(user, constants.SharingModelPrivate, false, true)

	grandSQL, midSQL, leafSQL := f.filters(ctx, user)
	if grandSQL == "" || grandSQL == "1 = 0" {
		t.Fatalf("GrandObj: expected a real owner/share filter, got %q", grandSQL)
	}
	if midSQL == "" {
		t.Fatalf("MidObj: must stay restricted under a Private root")
	}
	if !strings.Contains(midSQL, "`GrandRef` IN (SELECT Id FROM `GrandObj` WHERE") {
		t.Fatalf("MidObj: parent arm missing, got %q", midSQL)
	}
	if leafSQL == "" {
		t.Fatalf("LeafObj: must stay restricted under a Private root")
	}
	if !strings.Contains(leafSQL, "`MidRef` IN (SELECT Id FROM `MidObj` WHERE") {
		t.Fatalf("LeafObj: parent arm missing, got %q", leafSQL)
	}
}

// Optional leaf edge (FK not required): the leaf keeps owner/share fallback
// arms for standalone/orphan rows. With the parent chain fully visible via
// root ViewAllRecords, parent-linked leaf rows are all visible, but the
// filter must NOT collapse to "" — orphan rows stay gated by the fallbacks
// (PR #3870 follow-up).
func TestRLSCbPChainOptionalLeafEdgeKeepsOrphanFallback(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "user-var-root", ProfileID: "profile-ops"}
	f := buildCbPChainFixture(user, constants.SharingModelPrivate, true, true)
	// Make the leaf's sharing-parent FK optional: fallback arms are retained.
	for i := range f.leaf.Fields {
		if f.leaf.Fields[i].APIName == "MidRef" {
			f.leaf.Fields[i].Required = false
		}
	}

	_, midSQL, leafSQL := f.filters(ctx, user)
	if midSQL != "" {
		t.Fatalf("MidObj: expected unrestricted (root fully visible), got %q", midSQL)
	}
	if leafSQL == "" {
		t.Fatalf("LeafObj: optional edge must not collapse to unrestricted — orphan rows would bypass owner/share gating")
	}
	if !strings.Contains(leafSQL, "`MidRef` IN (SELECT Id FROM `MidObj`)") {
		t.Fatalf("LeafObj: parent-linked rows must be granted via the unfiltered parent arm, got %q", leafSQL)
	}
	if !strings.Contains(leafSQL, "`OwnerId` = 'user-var-root'") {
		t.Fatalf("LeafObj: owner fallback arm must survive for orphan rows, got %q", leafSQL)
	}
}

// Optional leaf edge whose fallback arms render EMPTY for this principal
// (leaf has no OwnerId field; share/team probes prove empty): the filter
// must still be the unfiltered parent arm — never "" — so standalone/orphan
// rows stay invisible, symmetric with record access.
func TestRLSCbPChainOptionalLeafEdgeEmptyFallbacksStillGateOrphans(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "user-var-root", ProfileID: "profile-ops"}
	f := buildCbPChainFixture(user, constants.SharingModelPrivate, true, true)
	// Optional edge + no OwnerId field on the leaf: no fallback arms render.
	fields := f.leaf.Fields[:0]
	for _, fl := range f.leaf.Fields {
		if fl.APIName == constants.FieldOwnerID {
			continue
		}
		if fl.APIName == "MidRef" {
			fl.Required = false
		}
		fields = append(fields, fl)
	}
	f.leaf.Fields = fields

	_, midSQL, leafSQL := f.filters(ctx, user)
	if midSQL != "" {
		t.Fatalf("MidObj: expected unrestricted (root fully visible), got %q", midSQL)
	}
	if leafSQL != "`MidRef` IN (SELECT Id FROM `MidObj`)" {
		t.Fatalf("LeafObj: expected exactly the unfiltered parent arm, got %q", leafSQL)
	}
}

// Fail-closed guard: a CbP middle object with NO sharing-parent edge is
// malformed (deny-all); the leaf below it must inherit the match-nothing
// subquery, never unrestricted visibility.
func TestRLSCbPChainMalformedMiddleEdgeFailsClosed(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "user-var-root", ProfileID: "profile-ops"}
	f := buildCbPChainFixture(user, constants.SharingModelPrivate, true, false)

	_, midSQL, leafSQL := f.filters(ctx, user)
	if midSQL != "1 = 0" {
		t.Fatalf("MidObj without a sharing-parent edge must deny all, got %q", midSQL)
	}
	if leafSQL == "" {
		t.Fatalf("LeafObj: malformed middle must not collapse to unrestricted")
	}
	if !strings.Contains(leafSQL, "`MidRef` IN (SELECT Id FROM `MidObj` WHERE 1 = 0)") {
		t.Fatalf("LeafObj: expected match-nothing parent subquery, got %q", leafSQL)
	}
}
