package records

import (
	"context"
	"errors"
	"testing"

	appErrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// stubOwnerValidationRepo is a minimal ports.RecordRepository stub for
// validateQueueAssignment tests. findOneFn drives both Group and User lookups
// (the table arg distinguishes them); existsFn is retained to satisfy the
// interface but is no longer called by validateQueueAssignment; checkQueueSuppFn
// drives Queue support.
type stubOwnerValidationRepo struct {
	findOneFn        func(ctx context.Context, table string, id string) (models.SObject, error)
	existsFn         func(ctx context.Context, table string, id string) (bool, error)
	checkQueueSuppFn func(ctx context.Context, queueID string, objectType string) (bool, error)
}

func (r *stubOwnerValidationRepo) FindOne(ctx context.Context, t string, id string) (models.SObject, error) {
	if r.findOneFn != nil {
		return r.findOneFn(ctx, t, id)
	}
	return nil, nil
}
func (r *stubOwnerValidationRepo) Exists(ctx context.Context, t string, id string) (bool, error) {
	if r.existsFn != nil {
		return r.existsFn(ctx, t, id)
	}
	return false, nil
}
func (r *stubOwnerValidationRepo) CheckQueueSupport(ctx context.Context, queueID string, objectType string) (bool, error) {
	if r.checkQueueSuppFn != nil {
		return r.checkQueueSuppFn(ctx, queueID, objectType)
	}
	return true, nil
}

// Satisfy the rest of ports.RecordRepository with no-op stubs.
func (r *stubOwnerValidationRepo) FindOneByField(context.Context, string, string, interface{}) (models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) GetLock(context.Context, string, string) (models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) Insert(context.Context, string, models.SObject) error { return nil }
func (r *stubOwnerValidationRepo) BulkInsert(context.Context, string, []models.SObject, []string, interface{}) error {
	return nil
}
func (r *stubOwnerValidationRepo) BulkInsertWithLanding(context.Context, string, []models.SObject, []string, interface{}) ([]bool, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) Update(context.Context, string, string, models.SObject) error {
	return nil
}
func (r *stubOwnerValidationRepo) Delete(context.Context, string, string) error         { return nil }
func (r *stubOwnerValidationRepo) PhysicalDelete(context.Context, string, string) error { return nil }
func (r *stubOwnerValidationRepo) DeleteByField(context.Context, string, string, interface{}) error {
	return nil
}
func (r *stubOwnerValidationRepo) ExistsByField(context.Context, string, string, interface{}) (bool, error) {
	return false, nil
}
func (r *stubOwnerValidationRepo) FindAny(context.Context, string, string) (models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) FindMany(context.Context, string, []string) (map[string]models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) GetChildren(context.Context, string, string, string) ([]models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) GetDeletedChildren(context.Context, string, string, string) ([]models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) CheckUniqueness(context.Context, *models.ObjectMetadata, string, interface{}, string) (bool, error) {
	return false, nil
}
func (r *stubOwnerValidationRepo) FindAllRecycleBinItems(context.Context) ([]models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) FindRecycleBinItemsByUserPage(context.Context, string, string, string, int) ([]models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) FindAllRecycleBinItemsPage(context.Context, string, string, int) ([]models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) FindRecycleBinEntry(context.Context, string) (models.SObject, error) {
	return nil, nil
}
func (r *stubOwnerValidationRepo) GetRecordType(context.Context, string) (string, error) {
	return "", nil
}
func (r *stubOwnerValidationRepo) FindObjectTypesForIDs(context.Context, []string, []string) (map[string]string, error) {
	return nil, nil
}

// fixtures for the table
func notFound(context.Context, string, string) (models.SObject, error) { return nil, nil }
func probeErr(context.Context, string, string) (models.SObject, error) {
	return nil, errors.New("db: connection reset by peer")
}

// userProbeErr returns a DB error only when looking up a User (Group returns nil record).
func userProbeErr(_ context.Context, table string, _ string) (models.SObject, error) {
	if table == constants.TableUser {
		return nil, errors.New("db: context deadline exceeded")
	}
	return nil, nil
}

// existingUser returns a User record when queried for TableUser, nil for Group.
func existingUser(_ context.Context, table string, id string) (models.SObject, error) {
	if table == constants.TableUser {
		return models.SObject{constants.FieldID: id}, nil
	}
	return nil, nil
}

func groupOfType(gtype string) func(context.Context, string, string) (models.SObject, error) {
	return func(_ context.Context, table string, id string) (models.SObject, error) {
		if table == constants.TableGroup {
			return models.SObject{constants.FieldID: id, constants.FieldGroup_Type: gtype}, nil
		}
		return nil, nil
	}
}

func groupMissingType(_ context.Context, table string, id string) (models.SObject, error) {
	if table == constants.TableGroup {
		return models.SObject{constants.FieldID: id}, nil
	}
	return nil, nil
}

// TestValidateQueueAssignment covers OwnerId existence validation (User or Group,
// existence-only / permission-bypassing) plus Queue support and fail-closed DB
// error handling. A non-existent or soft-deleted owner must fail
// INVALID_CROSS_REFERENCE_KEY; a DB probe error must fail closed with an
// internal error (not a cross-reference verdict).
func TestValidateQueueAssignment(t *testing.T) {
	cases := []struct {
		name            string
		findOne         func(context.Context, string, string) (models.SObject, error)
		exists          func(context.Context, string, string) (bool, error)
		checkSupp       func(context.Context, string, string) (bool, error)
		rec             models.SObject
		wantCrossRef    bool // expect INVALID_CROSS_REFERENCE_KEY
		wantInternalErr bool // expect non-nil error that is NOT cross-ref (fail-closed)
	}{
		{name: "non-existent owner rejected", findOne: notFound, rec: models.SObject{constants.FieldOwnerID: "usr-nope"}, wantCrossRef: true},
		{name: "existing user accepted", findOne: existingUser, rec: models.SObject{constants.FieldOwnerID: "usr-1"}},
		{name: "rls-invisible existing user accepted", findOne: existingUser, rec: models.SObject{constants.FieldOwnerID: "usr-hidden"}},
		{name: "soft-deleted user rejected", findOne: notFound, rec: models.SObject{constants.FieldOwnerID: "usr-deleted"}, wantCrossRef: true},
		{name: "existing queue group accepted", findOne: groupOfType("Queue"), rec: models.SObject{constants.FieldOwnerID: "grp-q"}},
		{name: "existing non-queue group accepted", findOne: groupOfType("Regular"), rec: models.SObject{constants.FieldOwnerID: "grp-r"}},
		{name: "group missing Type fails closed", findOne: groupMissingType, rec: models.SObject{constants.FieldOwnerID: "grp-partial"}, wantInternalErr: true},
		{name: "empty owner no-op", rec: models.SObject{constants.FieldOwnerID: ""}},
		{name: "nil owner no-op", rec: models.SObject{constants.FieldOwnerID: nil}},
		{name: "absent owner no-op", rec: models.SObject{}},
		{name: "group probe error fail-closed", findOne: probeErr, rec: models.SObject{constants.FieldOwnerID: "usr-x"}, wantInternalErr: true},
		{name: "user probe error fail-closed", findOne: userProbeErr, rec: models.SObject{constants.FieldOwnerID: "usr-x"}, wantInternalErr: true},
	}

	actorUser := &models.UserSession{ID: "usr-actor"}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ps := ownerPS(&stubOwnerValidationRepo{findOneFn: tc.findOne, existsFn: tc.exists, checkQueueSuppFn: tc.checkSupp})
			err := ps.validateQueueAssignment(context.Background(), "Account", tc.rec, actorUser)

			switch {
			case tc.wantCrossRef:
				assertInvalidCrossReferenceOwner(t, err)
			case tc.wantInternalErr:
				if err == nil {
					t.Fatal("validateQueueAssignment = nil, want fail-closed internal error")
				}
				// Must NOT be an invalid-reference verdict — that would be a
				// "does not exist" decision made on incomplete data. The neutral
				// engine returns an internal error here, not a WireInvalidReference
				// DomainWireError (the boundary maps the latter to v63
				// INVALID_CROSS_REFERENCE_KEY).
				var domainErr *appErrors.DomainWireError
				if errors.As(err, &domainErr) && domainErr.Kind == appErrors.WireInvalidReference {
					t.Fatalf("got WireInvalidReference (→INVALID_CROSS_REFERENCE_KEY) on probe error; want internal fail-closed error")
				}
			default:
				if err != nil {
					t.Fatalf("validateQueueAssignment = %v, want nil", err)
				}
			}
		})
	}

	// Combined gate: OwnerId=system is accepted ONLY when BOTH the writing actor is
	// the platform's own (system actor or nil) AND the execution context is
	// system/internal (GetSecurityContext >= SystemContext). The actor check rejects
	// a real user even under SystemContext; the SystemContext check rejects a
	// nil/system actor outside a system context — the enforced records-layer
	// boundary, not a caller convention.
	sysCtx := constants.WithSecurityContext(context.Background(), constants.SystemContext, "test")
	t.Run("system actor under SystemContext accepted", func(t *testing.T) {
		ps := ownerPS(&stubOwnerValidationRepo{findOneFn: notFound})
		sysUser := &models.UserSession{ID: constants.SystemUserID}
		err := ps.validateQueueAssignment(sysCtx, "Account", models.SObject{constants.FieldOwnerID: constants.SystemUserID}, sysUser)
		if err != nil {
			t.Fatalf("validateQueueAssignment = %v, want nil (system actor under SystemContext)", err)
		}
	})
	t.Run("nil actor under SystemContext accepted (platform write)", func(t *testing.T) {
		ps := ownerPS(&stubOwnerValidationRepo{findOneFn: notFound})
		err := ps.validateQueueAssignment(sysCtx, "Account", models.SObject{constants.FieldOwnerID: constants.SystemUserID}, nil)
		if err != nil {
			t.Fatalf("validateQueueAssignment = %v, want nil (platform write under SystemContext)", err)
		}
	})
	t.Run("system actor WITHOUT SystemContext rejected", func(t *testing.T) {
		// Actor identity alone is not enough — the write must also be under a
		// genuine system context, else a non-system code path reaching the
		// validator with the system actor could mint a rowless owner.
		ps := ownerPS(&stubOwnerValidationRepo{findOneFn: notFound})
		sysUser := &models.UserSession{ID: constants.SystemUserID}
		err := ps.validateQueueAssignment(context.Background(), "Account", models.SObject{constants.FieldOwnerID: constants.SystemUserID}, sysUser)
		assertInvalidCrossReferenceOwner(t, err)
	})
	t.Run("nil actor WITHOUT SystemContext rejected", func(t *testing.T) {
		// Land's case: a nil user reaching persistence outside SystemContext must
		// NOT persist the rowless system owner.
		ps := ownerPS(&stubOwnerValidationRepo{findOneFn: notFound})
		err := ps.validateQueueAssignment(context.Background(), "Account", models.SObject{constants.FieldOwnerID: constants.SystemUserID}, nil)
		assertInvalidCrossReferenceOwner(t, err)
	})
	t.Run("non-system actor under SystemContext rejected", func(t *testing.T) {
		ps := ownerPS(&stubOwnerValidationRepo{findOneFn: notFound})
		nonSysUser := &models.UserSession{ID: "usr-1"}
		err := ps.validateQueueAssignment(sysCtx, "Account", models.SObject{constants.FieldOwnerID: constants.SystemUserID}, nonSysUser)
		assertInvalidCrossReferenceOwner(t, err)
	})
}

func TestValidateQueueAssignment_CurrentUserOwnerSkipsRepository(t *testing.T) {
	var findOneCalls int
	ps := ownerPS(&stubOwnerValidationRepo{
		findOneFn: func(context.Context, string, string) (models.SObject, error) {
			findOneCalls++
			return nil, errors.New("owner lookup should not run for the authenticated owner")
		},
	})
	user := &models.UserSession{ID: "usr-current"}
	err := ps.validateQueueAssignment(context.Background(), "Lead", models.SObject{
		constants.FieldOwnerID: user.ID,
	}, user)
	if err != nil {
		t.Fatalf("validateQueueAssignment = %v, want nil", err)
	}
	if findOneCalls != 0 {
		t.Fatalf("FindOne calls = %d, want 0 for current-user owner", findOneCalls)
	}
}

func TestValidateQueueAssignment_SeededUserOwnerProofSkipsGroupProbe(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	SeedOwnerUserLookupProof(ctx, "usr-owner")
	var groupFindOneCalls int
	var userFindOneCalls int
	ps := ownerPS(&stubOwnerValidationRepo{
		findOneFn: func(_ context.Context, table string, id string) (models.SObject, error) {
			switch table {
			case constants.TableGroup:
				groupFindOneCalls++
				return nil, errors.New("owner user proof should skip Group probe")
			case constants.TableUser:
				userFindOneCalls++
				return models.SObject{constants.FieldID: id}, nil
			default:
				return nil, nil
			}
		},
	})
	err := ps.validateQueueAssignment(ctx, "Lead", models.SObject{
		constants.FieldOwnerID: "usr-owner",
	}, &models.UserSession{ID: "usr-actor"})
	if err != nil {
		t.Fatalf("validateQueueAssignment = %v, want nil", err)
	}
	if groupFindOneCalls != 0 {
		t.Fatalf("Group FindOne calls = %d, want 0 for seeded user owner proof", groupFindOneCalls)
	}
	if userFindOneCalls != 1 {
		t.Fatalf("User FindOne calls = %d, want 1 validation-time liveness read", userFindOneCalls)
	}
}

func TestValidateQueueAssignment_SeededUserOwnerProofStillRejectsMissingUser(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	SeedOwnerUserLookupProof(ctx, "usr-deleted")
	var groupFindOneCalls int
	var userFindOneCalls int
	ps := ownerPS(&stubOwnerValidationRepo{
		findOneFn: func(_ context.Context, table string, _ string) (models.SObject, error) {
			switch table {
			case constants.TableGroup:
				groupFindOneCalls++
				return nil, nil
			case constants.TableUser:
				userFindOneCalls++
				return nil, nil
			default:
				return nil, nil
			}
		},
	})
	err := ps.validateQueueAssignment(ctx, "Lead", models.SObject{
		constants.FieldOwnerID: "usr-deleted",
	}, &models.UserSession{ID: "usr-actor"})
	assertInvalidCrossReferenceOwner(t, err)
	if userFindOneCalls != 1 {
		t.Fatalf("User FindOne calls = %d, want 1 validation-time liveness read", userFindOneCalls)
	}
	if groupFindOneCalls != 1 {
		t.Fatalf("Group FindOne calls = %d, want 1 fallback durable-state probe after missing User", groupFindOneCalls)
	}
}

func TestValidateQueueAssignment_CachedPartialGroupFailsClosed(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)
	readGen := c.beginRead(constants.TableGroup, "grp-queue")
	c.setIfFresh(constants.TableGroup, "grp-queue", models.SObject{
		constants.FieldID: "grp-queue",
	}, true, readGen)
	var findOneCalls int
	var checkQueueSupportCalls int
	ps := ownerPS(&stubOwnerValidationRepo{
		findOneFn: func(context.Context, string, string) (models.SObject, error) {
			findOneCalls++
			return nil, errors.New("cached group should satisfy cachedFindOne")
		},
		checkQueueSuppFn: func(context.Context, string, string) (bool, error) {
			checkQueueSupportCalls++
			return false, nil
		},
	})
	err := ps.validateQueueAssignment(ctx, "Lead", models.SObject{
		constants.FieldOwnerID: "grp-queue",
	}, &models.UserSession{ID: "usr-actor"})
	if err == nil {
		t.Fatal("validateQueueAssignment = nil, want fail-closed error for partial Group row")
	}
	if findOneCalls != 0 {
		t.Fatalf("FindOne calls = %d, want 0 when cachedFindOne hits cached Group", findOneCalls)
	}
	if checkQueueSupportCalls != 0 {
		t.Fatalf("CheckQueueSupport calls = %d, want 0 without authoritative Group.Type", checkQueueSupportCalls)
	}
	var domainErr *appErrors.DomainWireError
	if errors.As(err, &domainErr) && domainErr.Kind == appErrors.WireInvalidReference {
		t.Fatalf("got WireInvalidReference on partial Group row; want internal fail-closed error")
	}
}

func ownerPS(repo *stubOwnerValidationRepo) *PersistenceService {
	return &PersistenceService{repo: repo}
}

// assertInvalidCrossReferenceOwner asserts err is the neutral WireInvalidReference
// DomainWireError on field OwnerId. The boundary dialect maps this kind to the v63
// INVALID_CROSS_REFERENCE_KEY error at HTTP 400 (covered by the dialect's
// wire_domain_mapping golden); the engine itself stays dialect-neutral.
func assertInvalidCrossReferenceOwner(t *testing.T, err error) {
	t.Helper()
	if err == nil {
		t.Fatal("validateQueueAssignment = nil, want WireInvalidReference error")
	}
	var domainErr *appErrors.DomainWireError
	if !errors.As(err, &domainErr) {
		t.Fatalf("error type = %T (%v), want *appErrors.DomainWireError", err, err)
	}
	if domainErr.Kind != appErrors.WireInvalidReference {
		t.Fatalf("kind = %q, want %q", domainErr.Kind, appErrors.WireInvalidReference)
	}
	if len(domainErr.Fields) != 1 || domainErr.Fields[0] != constants.FieldOwnerID {
		t.Fatalf("fields = %#v, want [%q]", domainErr.Fields, constants.FieldOwnerID)
	}
}
