package eks

import (
	"context"
	"encoding/json"
	"fmt"
	"testing"
	"time"

	"github.com/crossplane-contrib/provider-aws/apis/iam/v1alpha1"
	"github.com/crossplane-contrib/provider-aws/apis/iam/v1beta1"
	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
	"github.com/go-logr/logr"
	"github.com/onsi/gomega"
	"github.com/stretchr/testify/assert"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/utils/pointer"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
	"sigs.k8s.io/yaml"

	"github.com/tidbcloud/eks-provider/controllers/cluster/task"
	cloudv1 "github.com/tidbcloud/infra-api/api/v1"
	"github.com/tidbcloud/infra-provider/pkg/config"
	"github.com/tidbcloud/infra-provider/pkg/consts"
	"github.com/tidbcloud/infra-provider/pkg/k8s"
	"github.com/tidbcloud/infra-provider/pkg/namer"
	"github.com/tidbcloud/infra-provider/pkg/scheme"
	"github.com/tidbcloud/infra-provider/pkg/util"
)

func TestRoleIdentityUnmarshalYAML(t *testing.T) {
	cases := []struct {
		desc        string
		data        []byte
		expected    []RoleIdentity
		expectedErr error
	}{
		{
			desc: "normal case",
			data: []byte(`
- rolearn: arn:aws:iam::123456789012:role/test-role-1
- rolearn: arn:aws:iam::123456789012:role/test-role-2
  username: test-user-1
- rolearn: arn:aws:iam::123456789012:role/test-role-3
  groups:
  - aaa
  - bbb
- rolearn: arn:aws:iam::123456789012:role/test-role-4
  username: test-user-2
  groups:
  - aaa
  - bbb
- rolearn: arn:aws:iam::123456789012:role/test-role-4
  username: test-user-2
  groups:
  - aaa
  - bbb
  xxx: yyy
            `),
			expected: []RoleIdentity{
				{
					RoleARN: "arn:aws:iam::123456789012:role/test-role-1",
				},
				{
					RoleARN:  "arn:aws:iam::123456789012:role/test-role-2",
					Username: "test-user-1",
				},
				{
					RoleARN: "arn:aws:iam::123456789012:role/test-role-3",
					Groups: []string{
						"aaa",
						"bbb",
					},
				},
				{
					RoleARN:  "arn:aws:iam::123456789012:role/test-role-4",
					Username: "test-user-2",
					Groups: []string{
						"aaa",
						"bbb",
					},
				},
				{
					RoleARN:  "arn:aws:iam::123456789012:role/test-role-4",
					Username: "test-user-2",
					Groups: []string{
						"aaa",
						"bbb",
					},
					reserved: map[string]interface{}{
						"xxx": "yyy",
					},
				},
			},
		},
		{
			desc: "rolearn is not string",
			data: []byte(`
- rolearn: 111
            `),
			expected:    []RoleIdentity{},
			expectedErr: fmt.Errorf("error unmarshaling JSON: while decoding JSON: rolearn is not string"),
		},
		{
			desc: "username is not string",
			data: []byte(`
- username: 111
            `),
			expected:    []RoleIdentity{},
			expectedErr: fmt.Errorf("error unmarshaling JSON: while decoding JSON: username is not string"),
		},
		{
			desc: "groups is not []interface{}",
			data: []byte(`
- groups: 111
            `),
			expected:    []RoleIdentity{},
			expectedErr: fmt.Errorf("error unmarshaling JSON: while decoding JSON: groups is not []interface{}"),
		},
		{
			desc: "groups is not []string{}",
			data: []byte(`
- groups:
  - 111
            `),
			expected:    []RoleIdentity{},
			expectedErr: fmt.Errorf("error unmarshaling JSON: while decoding JSON: groups is not []string{}"),
		},
	}

	for i := range cases {
		c := &cases[i]
		t.Run(c.desc, func(tt *testing.T) {
			tt.Parallel()
			rs := []RoleIdentity{}
			if err := yaml.Unmarshal(c.data, &rs); err != nil {
				assert.ErrorContains(tt, err, c.expectedErr.Error(), c.desc)
			} else {
				assert.Equal(tt, c.expected, rs, c.desc)
			}
		})
	}
}

func TestRoleIdentityMarshalYAML(t *testing.T) {
	cases := []struct {
		desc     string
		rs       []RoleIdentity
		expected []byte
	}{
		{
			desc: "normal case",
			rs: []RoleIdentity{
				{
					RoleARN: "arn:aws:iam::123456789012:role/test-role-1",
				},
				{
					RoleARN:  "arn:aws:iam::123456789012:role/test-role-2",
					Username: "test-user-1",
				},
				{
					RoleARN: "arn:aws:iam::123456789012:role/test-role-3",
					Groups: []string{
						"aaa",
						"bbb",
					},
				},
				{
					RoleARN:  "arn:aws:iam::123456789012:role/test-role-4",
					Username: "test-user-2",
					Groups: []string{
						"aaa",
						"bbb",
					},
				},
				{
					RoleARN:  "arn:aws:iam::123456789012:role/test-role-4",
					Username: "test-user-2",
					Groups: []string{
						"aaa",
						"bbb",
					},
					reserved: map[string]interface{}{
						"xxx": "yyy",
					},
				},
			},
			expected: []byte(`- rolearn: arn:aws:iam::123456789012:role/test-role-1
- rolearn: arn:aws:iam::123456789012:role/test-role-2
  username: test-user-1
- groups:
  - aaa
  - bbb
  rolearn: arn:aws:iam::123456789012:role/test-role-3
- groups:
  - aaa
  - bbb
  rolearn: arn:aws:iam::123456789012:role/test-role-4
  username: test-user-2
- groups:
  - aaa
  - bbb
  rolearn: arn:aws:iam::123456789012:role/test-role-4
  username: test-user-2
  xxx: yyy
`),
		},
	}

	for i := range cases {
		c := &cases[i]
		t.Run(c.desc, func(tt *testing.T) {
			tt.Parallel()
			data, err := yaml.Marshal(c.rs)
			assert.NoError(tt, err, c.desc)
			assert.Equal(tt, string(c.expected), string(data), c.desc)
		})
	}
}

func TestNodeRole_createRoleAndBindPolicy(t1 *testing.T) {
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Namespace: "ns1",
			Name:      "c1",
			Labels: map[string]string{
				cloudv1.TenantTagKey: "t1",
			},
		},
	}
	providerConfig := "pc-ns1-c1-da7b7a92"
	type args struct {
		// nolint: unused
		role                func(*v1beta1.Role)
		roleName            string
		roleDescription     string
		trustPolicyDocument string
		policyNameSuffix    string
		policyDocument      string
		policyDescription   string
	}
	tests := []struct {
		name    string
		args    args
		checker func(t *testing.T, err error, role *v1beta1.Role)
	}{
		{
			name: "normal case",
			args: args{
				roleName:            "role1",
				roleDescription:     "role1-description",
				trustPolicyDocument: "role1-trust",
				policyNameSuffix:    "policy-suffix",
				policyDocument:      "policy-document",
				policyDescription:   "policy-desc",
			},
			checker: func(t *testing.T, err error, role *v1beta1.Role) {
				assert.ErrorContains(t, err, "or policy(role1-policy-suffix, false)")
				assert.Equal(t, &v1beta1.Role{
					TypeMeta: metav1.TypeMeta{
						APIVersion: "iam.aws.crossplane.io/v1beta1",
						Kind:       "Role",
					},
					ObjectMeta: metav1.ObjectMeta{
						Name:            "role1",
						ResourceVersion: "1",
						Labels:          cluster.Labels,
						Annotations: map[string]string{
							// ignore this key-value
							consts.LastAppliedConfigAnnotation: role.Annotations[consts.LastAppliedConfigAnnotation],
						},
					},
					Spec: v1beta1.RoleSpec{
						ForProvider: v1beta1.RoleParameters{
							Description:              pointer.String("role1-description"),
							AssumeRolePolicyDocument: "role1-trust",
							Tags:                     iamTagFromLabels(util.AddResourceName(cloudv1.TagsFromLabels(cluster.Labels), "role1")),
						},
						ResourceSpec: xpv1.ResourceSpec{
							DeletionPolicy:          xpv1.DeletionDelete,
							ProviderConfigReference: &xpv1.Reference{Name: providerConfig},
						},
					},
				}, role)
			},
		},
	}
	for _, tt := range tests {
		kubeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects().Build()
		ctx := context.Background()
		t := NodeRole{
			BaseTask: &task.BaseTask{
				Context: &task.Context{
					Cluster: cluster,
					Local:   kubeClient,
				},
			},
		}
		t1.Run(tt.name, func(t1 *testing.T) {
			_, err := t.createRoleAndBindPolicy(ctx, tt.args.roleName, tt.args.roleDescription, tt.args.trustPolicyDocument, tt.args.policyNameSuffix, tt.args.policyDocument, tt.args.policyDescription)
			role := &v1beta1.Role{}
			role.Name = "role1"
			_ = kubeClient.Get(ctx, client.ObjectKeyFromObject(role), role)
			tt.checker(t1, err, role)
		})
	}
}

func TestNodeRole_bindPolicyToRole(t1 *testing.T) {
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Namespace: "ns1",
			Name:      "c1",
		},
	}
	providerConfig := "pc-ns1-c1-da7b7a92"
	type args struct {
		role              func(*v1beta1.Role)
		policy            func(policy *v1beta1.Policy)
		attachment        func(attachment *v1beta1.RolePolicyAttachment)
		initObjects       func(policy *v1beta1.Policy, attachment *v1beta1.RolePolicyAttachment) []client.Object
		policyNameSuffix  string
		policyDocument    string
		policyDescription string
	}
	tests := []struct {
		name    string
		args    args
		checker func(t *testing.T, err error, policy *v1beta1.Policy, attachment *v1beta1.RolePolicyAttachment)
	}{
		{
			name: "policy is not ready",
			args: args{
				policyNameSuffix:  "policy-suffix",
				policyDocument:    "policy-document",
				policyDescription: "policy-desc",
			},
			checker: func(t *testing.T, err error, policy *v1beta1.Policy, attachment *v1beta1.RolePolicyAttachment) {
				assert.ErrorContains(t, err, "or policy(role1-policy-suffix, false)")
				assert.Equal(t, &v1beta1.Policy{
					ObjectMeta: metav1.ObjectMeta{
						Name: "role1-policy-suffix",
						Labels: map[string]string{
							RoleRefLabelKey: "role1",
						},
						ResourceVersion: "1",
					},
					Spec: v1beta1.PolicySpec{
						ForProvider: v1beta1.PolicyParameters{
							Description: pointer.String("policy-desc"),
							Document:    "policy-document",
							Name:        "role1-policy-suffix",
						},
						ResourceSpec: xpv1.ResourceSpec{
							DeletionPolicy:          xpv1.DeletionDelete,
							ProviderConfigReference: &xpv1.Reference{Name: providerConfig},
						},
					},
				}, policy)
			},
		},
		{
			name: "attachment is not ready",
			args: args{
				policy: func(policy *v1beta1.Policy) {
					policy.Status.AtProvider.ARN = "policy-arn"
				},
				initObjects: func(policy *v1beta1.Policy, attachment *v1beta1.RolePolicyAttachment) []client.Object {
					return []client.Object{policy}
				},
				policyNameSuffix:  "policy-suffix",
				policyDocument:    "policy-document",
				policyDescription: "policy-desc",
			},
			checker: func(t *testing.T, err error, policy *v1beta1.Policy, attachment *v1beta1.RolePolicyAttachment) {
				assert.Equal(t, &v1beta1.RolePolicyAttachment{
					ObjectMeta: metav1.ObjectMeta{
						Name: "role1-9afe39b7",
						Labels: map[string]string{
							RoleRefLabelKey: "role1",
						},
						ResourceVersion: "1",
						OwnerReferences: []metav1.OwnerReference{
							{
								APIVersion: "iam.aws.crossplane.io/v1beta1",
								Kind:       "Role",
								Name:       "role1",
							},
						},
					},
					Spec: v1beta1.RolePolicyAttachmentSpec{
						ForProvider: v1beta1.RolePolicyAttachmentParameters{
							RoleNameRef: &xpv1.Reference{
								Name: "role1",
							},
							PolicyARN: policy.Status.AtProvider.ARN,
						},
						ResourceSpec: xpv1.ResourceSpec{
							DeletionPolicy:          xpv1.DeletionDelete,
							ProviderConfigReference: &xpv1.Reference{Name: providerConfig},
						},
					},
				}, attachment)
			},
		},
	}
	for _, tt := range tests {
		role := &v1beta1.Role{
			ObjectMeta: metav1.ObjectMeta{
				Name: "role1",
			},
			Status: v1beta1.RoleStatus{
				AtProvider: v1beta1.RoleExternalStatus{
					ARN: "role1-arn",
				},
			},
		}
		policy := &v1beta1.Policy{
			ObjectMeta: metav1.ObjectMeta{
				Name: "role1-policy-suffix",
			},
		}
		attachment := &v1beta1.RolePolicyAttachment{
			ObjectMeta: metav1.ObjectMeta{
				Name: "role1-9afe39b7",
			},
		}
		if tt.args.role != nil {
			tt.args.role(role)
		}
		if tt.args.policy != nil {
			tt.args.policy(policy)
		}
		if tt.args.attachment != nil {
			tt.args.attachment(attachment)
		}

		objects := []client.Object{role}
		if tt.args.initObjects != nil {
			objects = append(objects, tt.args.initObjects(policy, attachment)...)
		}
		kubeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objects...).Build()
		ctx := context.Background()
		t := NodeRole{
			BaseTask: &task.BaseTask{
				Context: &task.Context{
					Cluster: cluster,
					Local:   kubeClient,
				},
			},
		}
		t1.Run(tt.name, func(t1 *testing.T) {
			err := t.bindPolicyToRole(ctx, role, tt.args.policyNameSuffix, tt.args.policyDocument, tt.args.policyDescription)
			_ = kubeClient.Get(ctx, client.ObjectKeyFromObject(policy), policy)
			_ = kubeClient.Get(ctx, client.ObjectKeyFromObject(attachment), attachment)
			tt.checker(t1, err, policy, attachment)
		})
	}
}

func TestAWSReadwritePolicyName(t *testing.T) {
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Namespace: "nightly-ms-10138126433153288418",
			Name:      "10138126433153288418",
		},
	}
	c := task.Context{
		Cluster: cluster,
	}
	policyName := buildPolicyName(c.GetAWSNodeRoleName(), sharedBucketReadwriteRolePolicyNameSuffix)
	assert.Equal(t, "ng-role-nightly-ms-10138126433153288418-10138126433153-2822e74d-sb-readwrite", policyName)
	assert.LessOrEqualf(t, len(policyName), namer.AWSIAMPolicyNameLimit, "ensure policy name leghth of shared bucket readwrite policy")
}

func TestNodeRole_grantReadwritePolicyOnSharedBucketToNodeRole(t *testing.T) {
	tests := []struct {
		name                 string
		cluster              func(*cloudv1.Cluster)
		objects              []client.Object
		wantErr              bool
		extraResourceChecker func(t *testing.T, policy *util.PolicyDocument, statement *util.PolicyStatement)
	}{
		{
			name:    "create new policy and bind to role",
			wantErr: true,
		},
		{
			name: "append resource to existing policy",
			objects: []client.Object{
				&v1beta1.Policy{
					ObjectMeta: metav1.ObjectMeta{
						Name: buildPolicyName("test-role", sharedBucketReadwriteRolePolicyNameSuffix),
					},
					Spec: v1beta1.PolicySpec{
						ForProvider: v1beta1.PolicyParameters{
							Document: `{
								"Version": "2012-10-17",
								"Statement": [
																	{
										"Sid": "OtherStatement",
										"Effect": "Allow",
										"Action": [
											"s3:GetObject",
											"s3:PutObject"
										],
										"Resource": [
											"arn:aws:s3:::existing-bucket/existing-prefix/*"
										]
									},
									{
										"Sid": "ReadWriteWithinPrefix",
										"Effect": "Allow",
										"Action": [
											"s3:GetObject",
											"s3:PutObject"
										],
										"Resource": [
											"arn:aws:s3:::existing-bucket/existing-prefix/*"
										]
									}
								]
							}`,
						},
					},
					Status: v1beta1.PolicyStatus{
						AtProvider: v1beta1.PolicyObservation{
							ARN: "policy-arn",
						},
					},
				},
				&v1beta1.RolePolicyAttachment{
					ObjectMeta: metav1.ObjectMeta{
						Name: policyAttachmentName("test-role", buildPolicyName("test-role", sharedBucketReadwriteRolePolicyNameSuffix)),
					},
				},
			},
			wantErr: false,
			extraResourceChecker: func(t *testing.T, policy *util.PolicyDocument, statement *util.PolicyStatement) {
				assert.Contains(t, statement.Resource, "arn:aws:s3:::existing-bucket/existing-prefix/*")
				actual, _ := policy.ToString()
				expected := `{"Version":"2012-10-17","Statement":[` +
					`{"Sid":"OtherStatement","Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::existing-bucket/existing-prefix/*"]},` +
					`{"Sid":"ReadWriteWithinPrefix","Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::existing-bucket/existing-prefix/*","arn:aws:s3:::test-bucket/test-prefix/*"]}]}`
				assert.Equal(t, expected, actual)
			},
		},
		{
			name: "resource already exists",
			objects: []client.Object{
				&v1beta1.Policy{
					ObjectMeta: metav1.ObjectMeta{
						Name: buildPolicyName("test-role", sharedBucketReadwriteRolePolicyNameSuffix),
					},
					Spec: v1beta1.PolicySpec{
						ForProvider: v1beta1.PolicyParameters{
							Document: `{
								"Version": "2012-10-17",
								"Statement": [
									{
										"Sid": "ReadWriteWithinPrefix",
										"Effect": "Allow",
										"Action": [
											"s3:GetObject",
											"s3:PutObject"
										],
										"Resource": [
											"arn:aws:s3:::test-bucket/test-prefix/*"
										]
									}
								]
							}`,
						},
					},
					Status: v1beta1.PolicyStatus{
						AtProvider: v1beta1.PolicyObservation{
							ARN: "policy-arn",
						},
					},
				},
				&v1beta1.RolePolicyAttachment{
					ObjectMeta: metav1.ObjectMeta{
						Name: policyAttachmentName("test-role", buildPolicyName("test-role", sharedBucketReadwriteRolePolicyNameSuffix)),
					},
				},
			},
			wantErr: false,
			extraResourceChecker: func(t *testing.T, policy *util.PolicyDocument, statement *util.PolicyStatement) {
				actual, _ := policy.ToString()
				expected := `{"Version":"2012-10-17","Statement":[` +
					`{"Sid":"ReadWriteWithinPrefix","Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::test-bucket/test-prefix/*"]}]}`
				assert.Equal(t, expected, actual)
			},
		},
		{
			name: "invalid bucket URL",
			cluster: func(cluster *cloudv1.Cluster) {
				cluster.Spec.SharedBucket.BucketURL = "invalid-url"
			},
			wantErr: true,
		},
	}

	for _, tt := range tests {
		cluster := &cloudv1.Cluster{
			ObjectMeta: metav1.ObjectMeta{
				Name: "test-cluster",
			},
			Spec: cloudv1.ClusterSpec{
				SharedBucket: &cloudv1.SharedBucket{
					BucketURL: "s3://test-bucket/test-prefix/",
				},
			},
		}
		if tt.cluster != nil {
			tt.cluster(cluster)
		}
		role := &v1beta1.Role{
			ObjectMeta: metav1.ObjectMeta{
				Name: "test-role",
			},
			Status: v1beta1.RoleStatus{
				AtProvider: v1beta1.RoleExternalStatus{
					ARN: "test-role-arn",
				},
			},
		}
		t.Run(tt.name, func(t *testing.T) {
			objects := []client.Object{role}
			if tt.objects != nil {
				objects = append(objects, tt.objects...)
			}

			kubeClient := fake.NewClientBuilder().
				WithScheme(scheme.Scheme).
				WithObjects(objects...).
				Build()

			nodeRole := &NodeRole{
				BaseTask: &task.BaseTask{
					Context: &task.Context{
						Cluster: cluster,
						Local:   kubeClient,
					},
				},
			}

			err := nodeRole.grantReadwritePolicyOnSharedBucketToNodeRole(context.Background(), role)
			if (err != nil) != tt.wantErr {
				t.Errorf("grantReadwritePolicyOnSharedBucketToNodeRole() error = %v, wantErr %v", err, tt.wantErr)
			}

			if !tt.wantErr {
				// Verify policy was created/updated
				policyName := buildPolicyName(role.Name, sharedBucketReadwriteRolePolicyNameSuffix)
				policy := &v1beta1.Policy{}
				err = kubeClient.Get(context.Background(), client.ObjectKey{Name: policyName}, policy)
				assert.NoError(t, err)

				// Verify policy document contains the new resource
				doc := &util.PolicyDocument{}
				err = json.Unmarshal([]byte(policy.Spec.ForProvider.Document), doc)
				assert.NoError(t, err)

				stmt, ok := doc.FindStatementBySid(sharedBucketReadwriteRolePolicyStatementSid)
				assert.True(t, ok)

				expectedResource := fmt.Sprintf("arn:aws:s3:::%s/%s/*",
					"test-bucket",
					"test-prefix")
				assert.Contains(t, stmt.Resource, expectedResource)
				if tt.extraResourceChecker != nil {
					tt.extraResourceChecker(t, doc, stmt)
				}
			}
		})
	}
}

func TestNodeRolePostCreate(t *testing.T) {
	g := gomega.NewWithT(t)

	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Namespace: "ns1",
			Name:      "c1",
		},
	}
	taskCtx := &task.Context{Cluster: cluster}
	roleName := taskCtx.GetAWSNodeRoleName()

	fixedNow := time.Now()

	// managedPolicies has 1 entry, so expected attachment count = 2 (custom) + 1 (managed) = 3
	managedPolicies := []string{"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"}

	makeRole := func(arn string, readyTime time.Time) *v1beta1.Role {
		r := &v1beta1.Role{
			ObjectMeta: metav1.ObjectMeta{Name: roleName},
		}
		r.Status.AtProvider.ARN = arn
		r.Status.Conditions = []xpv1.Condition{{
			Type:               xpv1.TypeReady,
			Status:             corev1.ConditionTrue,
			LastTransitionTime: metav1.NewTime(readyTime),
		}}
		return r
	}

	makeRoleNotReady := func(arn string) *v1beta1.Role {
		r := &v1beta1.Role{
			ObjectMeta: metav1.ObjectMeta{Name: roleName},
		}
		r.Status.AtProvider.ARN = arn
		// No Ready condition or Ready=False
		return r
	}

	makeAttachment := func(name string, readyTime time.Time) *v1beta1.RolePolicyAttachment {
		r := &v1beta1.RolePolicyAttachment{
			ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{RoleRefLabelKey: roleName}},
		}
		r.Status.AtProvider.AttachedPolicyARN = "arn:policy"
		r.Status.Conditions = []xpv1.Condition{{
			Type:               xpv1.TypeReady,
			Status:             corev1.ConditionTrue,
			LastTransitionTime: metav1.NewTime(readyTime),
		}}
		return r
	}

	makeAttachmentNotReady := func(name string) *v1beta1.RolePolicyAttachment {
		return &v1beta1.RolePolicyAttachment{
			ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{RoleRefLabelKey: roleName}},
			Status:     v1beta1.RolePolicyAttachmentStatus{},
		}
	}

	makeAttachmentWithoutTransition := func(name string) *v1beta1.RolePolicyAttachment {
		r := &v1beta1.RolePolicyAttachment{
			ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{RoleRefLabelKey: roleName}},
		}
		r.Status.AtProvider.AttachedPolicyARN = "arn:policy"
		r.Status.Conditions = []xpv1.Condition{{
			Type:   xpv1.TypeReady,
			Status: corev1.ConditionTrue,
		}}
		return r
	}

	makeInstanceProfile := func(readyTime time.Time) *v1alpha1.InstanceProfile {
		ip := &v1alpha1.InstanceProfile{
			ObjectMeta: metav1.ObjectMeta{Name: roleName},
		}
		ip.Status.Conditions = []xpv1.Condition{{
			Type:               xpv1.TypeReady,
			Status:             corev1.ConditionTrue,
			LastTransitionTime: metav1.NewTime(readyTime),
		}}
		return ip
	}

	makeInstanceProfileNotReady := func() *v1alpha1.InstanceProfile {
		return &v1alpha1.InstanceProfile{
			ObjectMeta: metav1.ObjectMeta{Name: roleName},
		}
	}

	newNodeRole := func(cl client.Client) *NodeRole {
		return &NodeRole{
			BaseTask: &task.BaseTask{
				Context: &task.Context{
					Cluster: cluster,
					Local:   cl,
					Logger:  logr.Discard(),
					Config: config.RootConfig{
						ClusterController: config.ClusterControllerConfig{
							AWS: config.AWSConfig{
								NodeRoleManagedPolicies: managedPolicies,
							},
						},
					},
				},
			},
			Now: func() time.Time { return fixedNow },
		}
	}

	expectRetriableNotRequeueOnly := func(err error) {
		g.Expect(err).NotTo(gomega.BeNil())
		res := k8s.ShouldRequeue(err)
		g.Expect(res.IsZero()).To(gomega.BeFalse())
		g.Expect(k8s.RequeueOnly(err)).To(gomega.BeFalse())
	}

	// expected = 2 (custom) + 1 (managed) = 3
	attachmentCount := 3

	// case 1: one attachment not ready -> requeue
	t.Run("attachment not ready", func(t *testing.T) {
		objs := []client.Object{
			makeRole("arn:role", fixedNow.Add(-2*time.Minute)),
			makeInstanceProfile(fixedNow.Add(-2 * time.Minute)),
		}
		for i := 0; i < attachmentCount; i++ {
			name := fmt.Sprintf("%s-attach-%d", roleName, i)
			if i == 0 {
				objs = append(objs, makeAttachmentNotReady(name))
			} else {
				objs = append(objs, makeAttachment(name, fixedNow.Add(-2*time.Minute)))
			}
		}
		cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
		nodeRole := newNodeRole(cl)
		res := nodeRole.PostCreate(context.Background())
		expectRetriableNotRequeueOnly(res.RawError())
	})

	// case 2: all ready but within 1 minute cooldown -> requeue
	t.Run("within propagation cooldown", func(t *testing.T) {
		objs := []client.Object{
			makeRole("arn:role", fixedNow.Add(-30*time.Second)),
			makeInstanceProfile(fixedNow.Add(-30 * time.Second)),
		}
		for i := 0; i < attachmentCount; i++ {
			name := fmt.Sprintf("%s-attach-%d", roleName, i)
			objs = append(objs, makeAttachment(name, fixedNow.Add(-30*time.Second)))
		}
		cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
		nodeRole := newNodeRole(cl)
		res := nodeRole.PostCreate(context.Background())
		expectRetriableNotRequeueOnly(res.RawError())
	})

	// case 3: role ARN not populated -> requeue
	t.Run("role ARN not populated", func(t *testing.T) {
		objs := []client.Object{
			makeRole("", fixedNow.Add(-2*time.Minute)),
			makeInstanceProfile(fixedNow.Add(-2 * time.Minute)),
		}
		for i := 0; i < attachmentCount; i++ {
			name := fmt.Sprintf("%s-attach-%d", roleName, i)
			objs = append(objs, makeAttachment(name, fixedNow.Add(-2*time.Minute)))
		}
		cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
		nodeRole := newNodeRole(cl)
		res := nodeRole.PostCreate(context.Background())
		expectRetriableNotRequeueOnly(res.RawError())
	})

	// case 4: instance profile not ready -> requeue
	t.Run("instance profile not ready", func(t *testing.T) {
		objs := []client.Object{
			makeRole("arn:role", fixedNow.Add(-2*time.Minute)),
			makeInstanceProfileNotReady(),
		}
		for i := 0; i < attachmentCount; i++ {
			name := fmt.Sprintf("%s-attach-%d", roleName, i)
			objs = append(objs, makeAttachment(name, fixedNow.Add(-2*time.Minute)))
		}
		cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
		nodeRole := newNodeRole(cl)
		res := nodeRole.PostCreate(context.Background())
		expectRetriableNotRequeueOnly(res.RawError())
	})

	// case 5: transition time not populated on all resources -> requeue
	t.Run("transition time not populated", func(t *testing.T) {
		// role with Ready=True but zero LastTransitionTime
		roleNoTransition := &v1beta1.Role{
			ObjectMeta: metav1.ObjectMeta{Name: roleName},
		}
		roleNoTransition.Status.AtProvider.ARN = "arn:role"
		roleNoTransition.Status.Conditions = []xpv1.Condition{{
			Type:   xpv1.TypeReady,
			Status: corev1.ConditionTrue,
		}}
		objs := []client.Object{roleNoTransition}
		// instance profile with zero transition
		ip := makeInstanceProfileNotReady()
		ip.Status.Conditions = []xpv1.Condition{{
			Type:   xpv1.TypeReady,
			Status: corev1.ConditionTrue,
		}}
		objs = append(objs, ip)
		for i := 0; i < attachmentCount; i++ {
			name := fmt.Sprintf("%s-attach-%d", roleName, i)
			objs = append(objs, makeAttachmentWithoutTransition(name))
		}
		cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
		nodeRole := newNodeRole(cl)
		res := nodeRole.PostCreate(context.Background())
		expectRetriableNotRequeueOnly(res.RawError())
	})

	// case 6: all ready and beyond 1 minute -> Done
	t.Run("all ready beyond cooldown", func(t *testing.T) {
		objs := []client.Object{
			makeRole("arn:role", fixedNow.Add(-2*time.Minute)),
			makeInstanceProfile(fixedNow.Add(-2 * time.Minute)),
		}
		for i := 0; i < attachmentCount; i++ {
			name := fmt.Sprintf("%s-attach-%d", roleName, i)
			objs = append(objs, makeAttachment(name, fixedNow.Add(-2*time.Minute)))
		}
		cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
		nodeRole := newNodeRole(cl)
		res := nodeRole.PostCreate(context.Background())
		g.Expect(res.RawError()).To(gomega.BeNil())
		g.Expect(res.IsDoneState()).To(gomega.BeTrue())
	})

	// case 7: role has ARN but Ready condition is not True -> requeue
	t.Run("role Ready condition false", func(t *testing.T) {
		objs := []client.Object{
			makeRoleNotReady("arn:role"),
			makeInstanceProfile(fixedNow.Add(-2 * time.Minute)),
		}
		for i := 0; i < attachmentCount; i++ {
			name := fmt.Sprintf("%s-attach-%d", roleName, i)
			objs = append(objs, makeAttachment(name, fixedNow.Add(-2*time.Minute)))
		}
		cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
		nodeRole := newNodeRole(cl)
		res := nodeRole.PostCreate(context.Background())
		expectRetriableNotRequeueOnly(res.RawError())
	})

	// case 8: partial attachments — 1 out of 3 expected exists and is ready -> requeue
	t.Run("partial attachments ready", func(t *testing.T) {
		objs := []client.Object{
			makeRole("arn:role", fixedNow.Add(-2*time.Minute)),
			makeInstanceProfile(fixedNow.Add(-2 * time.Minute)),
			makeAttachment(fmt.Sprintf("%s-attach-0", roleName), fixedNow.Add(-2*time.Minute)),
		}
		// only 1 attachment exists, but expected is 3
		cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
		nodeRole := newNodeRole(cl)
		res := nodeRole.PostCreate(context.Background())
		expectRetriableNotRequeueOnly(res.RawError())
	})
}
