package rls

import (
	"strings"
	"testing"
)

func TestToSQLFilterUsesGroupMembershipClosureForSharingReadsButInlinesGroupOwners(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName:           "Case",
		HasOwnerField:           true,
		UserID:                  "005-user",
		GroupOwnerIDs:           []string{"00G-owned-a", "00G-owned-b"},
		SharingRuleFromGroupIDs: []string{"00G-source"},
		IncludeManualShares:     true,
	}, "Case")

	required := []string{
		"FROM `_System_GroupMembershipClosure` gmc WHERE gmc.GroupId = '00G-source'",
		"FROM `_System_GroupMembershipClosure` gmc2 WHERE gmc2.MemberType = 'User' AND gmc2.MemberId = '005-user'",
		"`OwnerId` IN ('00G-owned-a', '00G-owned-b')",
	}
	for _, want := range required {
		if !strings.Contains(got, want) {
			t.Fatalf("RLS SQL missing %q\nSQL:\n%s", want, got)
		}
	}
	if strings.Contains(got, "SELECT gmc.GroupId") {
		t.Fatalf("group-owned reads should inline user group ids instead of a semi-join:\n%s", got)
	}
	if strings.Contains(got, "FROM GroupMember") {
		t.Fatalf("RLS SQL still reads GroupMember directly:\n%s", got)
	}
}

func TestToSQLFilterTargetRecordReadFiltersDeletedTargetsOnQuery(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "HistoryLike",
		TargetRecordFilters: []TargetRecordFilter{{
			TargetObjectAPIName:   "GenericObject",
			TargetObjectField:     "TargetObjectType",
			TargetRecordField:     "TargetObjectId",
			TargetReadTable:       "v_GenericObject",
			TargetIDColumn:        "Id",
			TargetIsDeletedColumn: "IsDeleted",
			TargetRLSResult:       &Result{Skip: true, ObjectAPIName: "GenericObject"},
			TargetIncludeDeleted:  false,
		}},
	}, "HistoryLike")

	required := []string{
		"`HistoryLike`.`TargetObjectType` = 'GenericObject'",
		"EXISTS (SELECT 1 FROM `v_GenericObject` WHERE `v_GenericObject`.`Id` = `HistoryLike`.`TargetObjectId` AND `v_GenericObject`.`IsDeleted` = 0)",
	}
	for _, want := range required {
		if !strings.Contains(got, want) {
			t.Fatalf("target-record RLS SQL missing %q\nSQL:\n%s", want, got)
		}
	}
}

func TestToSQLFilterTargetRecordReadQueryAllAllowsDeletedPublicTargets(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "HistoryLike",
		TargetRecordFilters: []TargetRecordFilter{{
			TargetObjectAPIName:   "GenericObject",
			TargetObjectField:     "TargetObjectType",
			TargetRecordField:     "TargetObjectId",
			TargetReadTable:       "v_GenericObject",
			TargetIDColumn:        "Id",
			TargetIsDeletedColumn: "IsDeleted",
			TargetRLSResult:       &Result{Skip: true, ObjectAPIName: "GenericObject"},
			TargetIncludeDeleted:  true,
		}},
	}, "HistoryLike")

	if strings.Contains(got, "IsDeleted") {
		t.Fatalf("queryAll target-record RLS should not filter deleted targets:\n%s", got)
	}
	if !strings.Contains(got, "`HistoryLike`.`TargetObjectType` = 'GenericObject'") {
		t.Fatalf("target-record RLS missing target object predicate:\n%s", got)
	}
}

func TestToSQLFilterTargetRecordReadCombinesTargetRLSAndDeletedFilter(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "HistoryLike",
		TargetRecordFilters: []TargetRecordFilter{{
			TargetObjectAPIName:   "GenericObject",
			TargetObjectField:     "TargetObjectType",
			TargetRecordField:     "TargetObjectId",
			TargetReadTable:       "v_GenericObject",
			TargetIDColumn:        "Id",
			TargetIsDeletedColumn: "IsDeleted",
			TargetRLSResult: &Result{
				ObjectAPIName: "GenericObject",
				HasOwnerField: true,
				OwnerUserIDs:  []string{"005-reader"},
			},
		}},
	}, "HistoryLike")

	for _, want := range []string{
		"`OwnerId` = '005-reader'",
		"`v_GenericObject`.`IsDeleted` = 0",
		"SELECT 1 FROM `v_GenericObject`",
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("target-record RLS SQL missing %q\nSQL:\n%s", want, got)
		}
	}
}

func TestToSQLFilterTargetRecordReadGroupsAssociationsWithoutOverGranting(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "ActivityLike",
		HasOwnerField: true,
		OwnerUserIDs:  []string{"005-reader"},
		TargetRecordFilters: []TargetRecordFilter{
			{
				TargetObjectField: "WhoType",
				TargetRecordField: "WhoId",
				Alternatives: []TargetRecordFilterAlternative{{
					TargetObjectAPIName: "PersonLike",
					TargetRLSResult: &Result{
						ObjectAPIName: "PersonLike",
						HasOwnerField: true,
						OwnerUserIDs:  []string{"005-reader"},
					},
				}},
			},
			{
				TargetObjectField: "WhatType",
				TargetRecordField: "WhatId",
				Alternatives: []TargetRecordFilterAlternative{{
					TargetObjectAPIName: "BusinessLike",
					TargetRLSResult: &Result{
						ObjectAPIName: "BusinessLike",
						HasOwnerField: true,
						OwnerUserIDs:  []string{"005-reader"},
					},
				}},
			},
		},
	}, "ActivityLike")

	for _, want := range []string{
		"`ActivityLike`.`WhoId` IS NULL",
		"`ActivityLike`.`WhatId` IS NULL",
		"`ActivityLike`.`WhoType` = 'PersonLike'",
		"`ActivityLike`.`WhatType` = 'BusinessLike'",
		"`ActivityLike`.`WhoId` IS NOT NULL",
		"`ActivityLike`.`WhatId` IS NOT NULL",
		" AND ",
		" OR `OwnerId` = '005-reader'",
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("grouped target-record SQL missing %q\nSQL:\n%s", want, got)
		}
	}
	if strings.Index(got, "`ActivityLike`.`WhoId` IS NULL") > strings.Index(got, "`ActivityLike`.`WhatId` IS NULL") {
		t.Fatalf("target association order changed unexpectedly:\n%s", got)
	}
}

func TestToSQLFilterTargetRecordReadKeepsLegacyPolymorphicTargetsAsAlternatives(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "ActivityLike",
		TargetRecordFilters: []TargetRecordFilter{
			{
				TargetObjectAPIName: "PersonLike",
				TargetObjectField:   "TargetType",
				TargetRecordField:   "TargetId",
				TargetRLSResult:     &Result{Skip: true, ObjectAPIName: "PersonLike"},
			},
			{
				TargetObjectAPIName: "BusinessLike",
				TargetObjectField:   "TargetType",
				TargetRecordField:   "TargetId",
				TargetRLSResult:     &Result{Skip: true, ObjectAPIName: "BusinessLike"},
			},
		},
	}, "ActivityLike")

	for _, want := range []string{
		"`ActivityLike`.`TargetType` = 'PersonLike'",
		"`ActivityLike`.`TargetType` = 'BusinessLike'",
		" OR ",
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("legacy polymorphic target SQL missing %q\nSQL:\n%s", want, got)
		}
	}
	if count := strings.Count(got, "`ActivityLike`.`TargetId` IS NULL"); count != 1 {
		t.Fatalf("legacy polymorphic targets must share one association guard, got %d guards:\n%s", count, got)
	}
}

func TestToSQLFilterTargetRecordReadKeepsUnreadableGroupedAssociationFailClosed(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "ActivityLike",
		TargetRecordFilters: []TargetRecordFilter{
			{
				TargetObjectField: "WhoType",
				TargetRecordField: "WhoId",
				Alternatives: []TargetRecordFilterAlternative{{
					TargetObjectAPIName: "PersonLike",
					TargetRLSResult:     &Result{Skip: true, ObjectAPIName: "PersonLike"},
				}},
			},
			{
				TargetObjectField: "WhatType",
				TargetRecordField: "WhatId",
				Alternatives:      []TargetRecordFilterAlternative{},
			},
		},
	}, "ActivityLike")

	for _, want := range []string{
		"`ActivityLike`.`WhoType` = 'PersonLike'",
		"`ActivityLike`.`WhatId` IS NOT NULL",
		"`ActivityLike`.`WhatId` IS NULL",
		"1 = 0",
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("grouped target-record SQL must keep an unreadable association fail-closed; missing %q:\n%s", want, got)
		}
	}
}

// TestToSQLFilterExcludesSoftDeletedShares guards against the leak where the
// query/list RLS fragments matched soft-deleted (revoked) shares. A revoked
// approval share is soft-deleted (IsDeleted=1); without this filter the former
// approver retained read access through query/list surfaces after the approval
// resolved.
func TestToSQLFilterExcludesSoftDeletedShares(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName:       "Case",
		HasOwnerField:       true,
		UserID:              "005-user",
		IncludeManualShares: true,
		IncludeTeamMembers:  true,
	}, "Case")

	required := []string{
		"_System_RecordShare rs WHERE rs.ObjectApiName = 'Case' AND (rs.IsDeleted = 0 OR rs.IsDeleted IS NULL)",
		"_System_TeamMember tm WHERE tm.ObjectApiName = 'Case' AND (tm.IsDeleted = 0 OR tm.IsDeleted IS NULL)",
	}
	for _, want := range required {
		if !strings.Contains(got, want) {
			t.Fatalf("RLS SQL must exclude soft-deleted shares, missing %q\nSQL:\n%s", want, got)
		}
	}
}

// issue #2585: when IncludeManualShares / IncludeTeamMembers are false — set at
// BuildRLSResult time when the user provably has no manual share / team membership
// for the object — ToSQLFilter must NOT emit the corresponding `Id IN (SELECT …)`
// semi-join. Its absence is exactly what lets the planner use an ordered-index
// TopN; only the owner predicate remains.
func TestToSQLFilterDropsShareTeamArmsWhenFlagsFalse(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName:       "Case",
		HasOwnerField:       true,
		UserID:              "005-user",
		OwnerUserIDs:        []string{"005-user"},
		IncludeManualShares: false,
		IncludeTeamMembers:  false,
	}, "Case")

	if got != "`OwnerId` = '005-user'" {
		t.Fatalf("dropped share/team arms should leave only the owner predicate, got:\n%s", got)
	}
	for _, bad := range []string{"_System_RecordShare", "_System_TeamMember"} {
		if strings.Contains(got, bad) {
			t.Fatalf("share/team arm must not be emitted when its flag is false (found %q)\n%s", bad, got)
		}
	}
}

func TestToSQLFilterSelfTargetKeepsUncorrelatedMembership(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "Node",
		TargetRecordFilters: []TargetRecordFilter{{
			TargetObjectField: "ParentType", TargetRecordField: "ParentId",
			Alternatives: []TargetRecordFilterAlternative{{
				TargetObjectAPIName: "Node", TargetReadTable: "Node", TargetIDColumn: "Id",
				TargetRLSResult: &Result{ObjectAPIName: "Node", HasOwnerField: true, OwnerUserIDs: []string{"005-reader"}},
			}},
		}},
	}, "Node")

	if !strings.Contains(got, "`Node`.`ParentId` IN (SELECT `Id` FROM `Node` WHERE `OwnerId` = '005-reader')") {
		t.Fatalf("self-target visibility must avoid a shadowed correlated reference:\n%s", got)
	}
}

// PR-D0a fail-closed SQL-lowering invariants (capture-and-freeze).
//
// These pin the pure, over-grant-sensitive edges of ToSQLFilter: the cases
// where getting the render wrong would either expose all rows (empty filter)
// or wrong-scope rows. A golden encodes the code's *current* behavior; if any
// of these ever flips, that is a security-relevant regression, not a churn.

// A nil result means "no RLS computed" and yields no WHERE fragment — the
// caller adds no filter, so every row is visible. Pin the empty string.
func TestToSQLFilterNilResultEmitsNoFilter(t *testing.T) {
	if got := ToSQLFilter(nil, "Case"); got != "" {
		t.Fatalf("nil result must emit no filter (\"\"), got %q", got)
	}
}

// Skip==true means the object is unrestricted for this user, so ToSQLFilter
// emits no WHERE fragment. This is the over-grant-sensitive case: "" == the
// caller applies no filter, so ALL ROWS are visible. This must only ever be
// reached when Skip is genuinely set; pinning it guards the render itself.
func TestToSQLFilterSkipEmitsNoFilterAllRowsVisible(t *testing.T) {
	got := ToSQLFilter(&Result{Skip: true, ObjectAPIName: "Case"}, "Case")
	if got != "" {
		// "" == no WHERE fragment == all rows visible to the caller.
		t.Fatalf("Skip result must emit \"\" (all rows visible), got %q", got)
	}
}

// DenyAll==true is the explicit deny: no row can satisfy the predicate.
func TestToSQLFilterDenyAllEmitsFalsePredicate(t *testing.T) {
	got := ToSQLFilter(&Result{DenyAll: true, ObjectAPIName: "Case"}, "Case")
	if got != "1 = 0" {
		t.Fatalf("DenyAll result must emit the false predicate \"1 = 0\", got %q", got)
	}
}

// A RESTRICTED result whose OR-part set comes out empty (HasOwnerField true but
// every owner/group/share/team/criteria arm empty) falls through to rls.go:244-246
// and renders "1 = 0" — identical to DenyAll. This is fail-closed by
// construction: nothing matched ⇒ deny, never ⇒ empty filter. Pin that the
// restricted-but-nothing-matched render is byte-for-byte the DenyAll render.
func TestToSQLFilterRestrictedButNothingMatchedFailsClosedLikeDenyAll(t *testing.T) {
	restricted := ToSQLFilter(&Result{ObjectAPIName: "Case", HasOwnerField: true}, "Case")
	denyAll := ToSQLFilter(&Result{DenyAll: true, ObjectAPIName: "Case"}, "Case")
	if restricted != "1 = 0" {
		t.Fatalf("restricted result with no matching arms must fail closed to \"1 = 0\", got %q", restricted)
	}
	if restricted != denyAll {
		t.Fatalf("restricted-but-nothing-matched must render identically to DenyAll: %q != %q", restricted, denyAll)
	}
}

// AllowedIDs with an empty AllowedIDsColumn defaults the filter column to "Id"
// (rls.go:106-116). With tableQualifier "", the qualifier defaults to
// ObjectAPIName. Pin the exact rendered IN-clause.
func TestToSQLFilterAllowedIDsDefaultsColumnToId(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "Folder",
		AllowedIDs:    []string{"a1", "a2"},
	}, "")
	if want := "`Folder`.`Id` IN ('a1','a2')"; got != want {
		t.Fatalf("AllowedIDs with empty column must filter on default Id\nwant: %q\ngot:  %q", want, got)
	}
}

// AllowedIDsColumn=="FolderId" overrides the default column; the filter is
// rendered on FolderId (rls.go:106-116). Pin the exact rendered IN-clause.
func TestToSQLFilterAllowedIDsHonorsExplicitColumn(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName:    "Doc",
		AllowedIDs:       []string{"d1"},
		AllowedIDsColumn: "FolderId",
	}, "")
	if want := "`Doc`.`FolderId` IN ('d1')"; got != want {
		t.Fatalf("AllowedIDsColumn=FolderId must filter on FolderId\nwant: %q\ngot:  %q", want, got)
	}
}

// An owner arm (HasOwnerField true + a single OwnerUserID) renders a predicate
// on the OwnerId column carrying that id. Substring-assert the owner column and
// the id both appear (mirrors the existing substring-assert style).
func TestToSQLFilterOwnerArmFiltersOnOwnerColumnAndID(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName: "Case",
		HasOwnerField: true,
		OwnerUserIDs:  []string{"005-owner"},
	}, "Case")
	for _, want := range []string{"`OwnerId`", "005-owner"} {
		if !strings.Contains(got, want) {
			t.Fatalf("owner arm SQL missing %q\nSQL:\n%s", want, got)
		}
	}
}

func TestToSQLFilterUsesPreparedOwnerAndRecordIDColumns(t *testing.T) {
	got := ToSQLFilter(&Result{
		ObjectAPIName:      "Case",
		HasOwnerField:      true,
		OwnerIDColumn:      "owner_id",
		RecordIDColumn:     "record_id",
		OwnerUserIDs:       []string{"005-owner"},
		UserID:             "005-owner",
		IncludeTeamMembers: true,
	}, "case_rows")
	for _, want := range []string{"`owner_id`", "`case_rows`.`record_id`"} {
		if !strings.Contains(got, want) {
			t.Fatalf("prepared RLS SQL missing %q\nSQL:\n%s", want, got)
		}
	}
	for _, stale := range []string{"`OwnerId`", "`case_rows`.`Id`"} {
		if strings.Contains(got, stale) {
			t.Fatalf("prepared RLS SQL retained logical column %q\nSQL:\n%s", stale, got)
		}
	}
}
