package aws

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	awssdk "github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/cloudformation"
	cfntypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/ec2"
	ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
	"github.com/aws/aws-sdk-go-v2/service/eks"
	"github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2"
	elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types"
	"github.com/aws/aws-sdk-go-v2/service/iam"
	"github.com/aws/aws-sdk-go-v2/service/lambda"
	"github.com/aws/aws-sdk-go-v2/service/route53"
	route53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
	"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
	"github.com/aws/aws-sdk-go-v2/service/sns"
	"github.com/aws/aws-sdk-go-v2/service/sqs"
	smithy "github.com/aws/smithy-go"

	"github.com/tidbcloud/controlplane9/internal/verbose"
)

const deleteRetryLimit = 4

// Service-specific API subsets for testing.

type EKSAPI interface {
	DeleteCluster(ctx context.Context, params *eks.DeleteClusterInput, optFns ...func(*eks.Options)) (*eks.DeleteClusterOutput, error)
	DeleteNodegroup(ctx context.Context, params *eks.DeleteNodegroupInput, optFns ...func(*eks.Options)) (*eks.DeleteNodegroupOutput, error)
}

type EC2API interface {
	DeleteVpc(ctx context.Context, params *ec2.DeleteVpcInput, optFns ...func(*ec2.Options)) (*ec2.DeleteVpcOutput, error)
	DeleteSubnet(ctx context.Context, params *ec2.DeleteSubnetInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSubnetOutput, error)
	DescribeRouteTables(
		ctx context.Context,
		params *ec2.DescribeRouteTablesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeRouteTablesOutput, error)
	DisassociateRouteTable(
		ctx context.Context,
		params *ec2.DisassociateRouteTableInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DisassociateRouteTableOutput, error)
	DeleteRouteTable(
		ctx context.Context,
		params *ec2.DeleteRouteTableInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteRouteTableOutput, error)
	DescribeInternetGateways(
		ctx context.Context,
		params *ec2.DescribeInternetGatewaysInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeInternetGatewaysOutput, error)
	DetachInternetGateway(
		ctx context.Context,
		params *ec2.DetachInternetGatewayInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DetachInternetGatewayOutput, error)
	DeleteInternetGateway(
		ctx context.Context,
		params *ec2.DeleteInternetGatewayInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteInternetGatewayOutput, error)
	DeleteNatGateway(
		ctx context.Context,
		params *ec2.DeleteNatGatewayInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteNatGatewayOutput, error)
	ReleaseAddress(
		ctx context.Context,
		params *ec2.ReleaseAddressInput,
		optFns ...func(*ec2.Options),
	) (*ec2.ReleaseAddressOutput, error)
	DeleteVpcEndpoints(
		ctx context.Context,
		params *ec2.DeleteVpcEndpointsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteVpcEndpointsOutput, error)
	DescribeVpcEndpoints(
		ctx context.Context,
		params *ec2.DescribeVpcEndpointsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeVpcEndpointsOutput, error)
	DeleteVpcEndpointServiceConfigurations(
		ctx context.Context,
		params *ec2.DeleteVpcEndpointServiceConfigurationsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteVpcEndpointServiceConfigurationsOutput, error)
	DescribeVpcEndpointServiceConfigurations(
		ctx context.Context,
		params *ec2.DescribeVpcEndpointServiceConfigurationsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeVpcEndpointServiceConfigurationsOutput, error)
	DeleteLaunchTemplate(
		ctx context.Context,
		params *ec2.DeleteLaunchTemplateInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteLaunchTemplateOutput, error)
	DescribeInstances(
		ctx context.Context,
		params *ec2.DescribeInstancesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeInstancesOutput, error)
	TerminateInstances(ctx context.Context, params *ec2.TerminateInstancesInput, optFns ...func(*ec2.Options)) (*ec2.TerminateInstancesOutput, error)
	DeleteVolume(ctx context.Context, params *ec2.DeleteVolumeInput, optFns ...func(*ec2.Options)) (*ec2.DeleteVolumeOutput, error)
	DeleteSnapshot(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error)
	DeleteNetworkInterface(
		ctx context.Context,
		params *ec2.DeleteNetworkInterfaceInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteNetworkInterfaceOutput, error)
	DescribeNetworkInterfaces(
		ctx context.Context,
		params *ec2.DescribeNetworkInterfacesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeNetworkInterfacesOutput, error)
	DeleteSecurityGroup(
		ctx context.Context,
		params *ec2.DeleteSecurityGroupInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteSecurityGroupOutput, error)
}

type ELBV2API interface {
	ModifyLoadBalancerAttributes(ctx context.Context, params *elasticloadbalancingv2.ModifyLoadBalancerAttributesInput,
		optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.ModifyLoadBalancerAttributesOutput, error)
	DeleteLoadBalancer(ctx context.Context, params *elasticloadbalancingv2.DeleteLoadBalancerInput,
		optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DeleteLoadBalancerOutput, error)
	DeleteTargetGroup(ctx context.Context, params *elasticloadbalancingv2.DeleteTargetGroupInput,
		optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DeleteTargetGroupOutput, error)
}

type IAMAPI interface {
	ListAttachedRolePolicies(ctx context.Context, params *iam.ListAttachedRolePoliciesInput,
		optFns ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error)
	DetachRolePolicy(ctx context.Context, params *iam.DetachRolePolicyInput,
		optFns ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error)
	ListRolePolicies(ctx context.Context, params *iam.ListRolePoliciesInput,
		optFns ...func(*iam.Options)) (*iam.ListRolePoliciesOutput, error)
	DeleteRolePolicy(ctx context.Context, params *iam.DeleteRolePolicyInput,
		optFns ...func(*iam.Options)) (*iam.DeleteRolePolicyOutput, error)
	DeleteRole(ctx context.Context, params *iam.DeleteRoleInput, optFns ...func(*iam.Options)) (*iam.DeleteRoleOutput, error)
	ListInstanceProfilesForRole(ctx context.Context, params *iam.ListInstanceProfilesForRoleInput,
		optFns ...func(*iam.Options)) (*iam.ListInstanceProfilesForRoleOutput, error)
	GetInstanceProfile(ctx context.Context, params *iam.GetInstanceProfileInput,
		optFns ...func(*iam.Options)) (*iam.GetInstanceProfileOutput, error)
	RemoveRoleFromInstanceProfile(ctx context.Context, params *iam.RemoveRoleFromInstanceProfileInput,
		optFns ...func(*iam.Options)) (*iam.RemoveRoleFromInstanceProfileOutput, error)
	DeleteInstanceProfile(ctx context.Context, params *iam.DeleteInstanceProfileInput,
		optFns ...func(*iam.Options)) (*iam.DeleteInstanceProfileOutput, error)
	ListEntitiesForPolicy(ctx context.Context, params *iam.ListEntitiesForPolicyInput,
		optFns ...func(*iam.Options)) (*iam.ListEntitiesForPolicyOutput, error)
	ListPolicyVersions(ctx context.Context, params *iam.ListPolicyVersionsInput,
		optFns ...func(*iam.Options)) (*iam.ListPolicyVersionsOutput, error)
	DetachUserPolicy(ctx context.Context, params *iam.DetachUserPolicyInput,
		optFns ...func(*iam.Options)) (*iam.DetachUserPolicyOutput, error)
	DetachGroupPolicy(ctx context.Context, params *iam.DetachGroupPolicyInput,
		optFns ...func(*iam.Options)) (*iam.DetachGroupPolicyOutput, error)
	DeletePolicyVersion(ctx context.Context, params *iam.DeletePolicyVersionInput,
		optFns ...func(*iam.Options)) (*iam.DeletePolicyVersionOutput, error)
	DeletePolicy(ctx context.Context, params *iam.DeletePolicyInput, optFns ...func(*iam.Options)) (*iam.DeletePolicyOutput, error)
}

// SQSAPI is the subset of the SQS API used for cleanup.
type SQSAPI interface {
	DeleteQueue(ctx context.Context, params *sqs.DeleteQueueInput, optFns ...func(*sqs.Options)) (*sqs.DeleteQueueOutput, error)
}

// S3API is the subset of the S3 API used for cleanup.
type S3API interface {
	ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
	ListObjectVersions(ctx context.Context, params *s3.ListObjectVersionsInput, optFns ...func(*s3.Options)) (*s3.ListObjectVersionsOutput, error)
	DeleteObjects(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error)
	DeleteBucket(ctx context.Context, params *s3.DeleteBucketInput, optFns ...func(*s3.Options)) (*s3.DeleteBucketOutput, error)
}

// DynamoDBAPI is the subset of the DynamoDB API used for cleanup.
type DynamoDBAPI interface {
	DeleteTable(ctx context.Context, params *dynamodb.DeleteTableInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteTableOutput, error)
}

// SNSAPI is the subset of the SNS API used for cleanup.
type SNSAPI interface {
	DeleteTopic(ctx context.Context, params *sns.DeleteTopicInput, optFns ...func(*sns.Options)) (*sns.DeleteTopicOutput, error)
}

// LambdaAPI is the subset of the Lambda API used for cleanup.
type LambdaAPI interface {
	DeleteFunction(ctx context.Context, params *lambda.DeleteFunctionInput,
		optFns ...func(*lambda.Options)) (*lambda.DeleteFunctionOutput, error)
}

// SecretsManagerAPI is the subset of the Secrets Manager API used for cleanup.
type SecretsManagerAPI interface {
	DeleteSecret(ctx context.Context, params *secretsmanager.DeleteSecretInput,
		optFns ...func(*secretsmanager.Options)) (*secretsmanager.DeleteSecretOutput, error)
}

// Route53API is the subset of Route53 used for cleanup.
type Route53API interface {
	ListResourceRecordSets(ctx context.Context, params *route53.ListResourceRecordSetsInput,
		optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error)
	ChangeResourceRecordSets(ctx context.Context, params *route53.ChangeResourceRecordSetsInput,
		optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
	GetChange(ctx context.Context, params *route53.GetChangeInput,
		optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error)
	DeleteHostedZone(ctx context.Context, params *route53.DeleteHostedZoneInput,
		optFns ...func(*route53.Options)) (*route53.DeleteHostedZoneOutput, error)
}

// CloudFormationAPI is the CloudFormation surface needed to remove a
// session-owned stack. Deletion is asynchronous, so the cleaner reports a stack
// that is already terminal as gone rather than deleting it again. DELETE_FAILED
// recovery needs the resource list and the ability to drop termination protection.
type CloudFormationAPI interface {
	DescribeStacks(ctx context.Context, params *cloudformation.DescribeStacksInput,
		optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeStacksOutput, error)
	DeleteStack(ctx context.Context, params *cloudformation.DeleteStackInput,
		optFns ...func(*cloudformation.Options)) (*cloudformation.DeleteStackOutput, error)
	ListStackResources(ctx context.Context, params *cloudformation.ListStackResourcesInput,
		optFns ...func(*cloudformation.Options)) (*cloudformation.ListStackResourcesOutput, error)
	UpdateTerminationProtection(ctx context.Context, params *cloudformation.UpdateTerminationProtectionInput,
		optFns ...func(*cloudformation.Options)) (*cloudformation.UpdateTerminationProtectionOutput, error)
}

// ResourceCleaner deletes AWS resources by type.
type ResourceCleaner struct {
	EKS            EKSAPI
	EC2            EC2API
	PlacementGroup *ec2.Client
	ELBV2          ELBV2API
	IAM            IAMAPI
	SQS            SQSAPI
	S3             S3API
	DynamoDB       DynamoDBAPI
	SNS            SNSAPI
	Lambda         LambdaAPI
	SecretsManager SecretsManagerAPI
	Route53        Route53API
	CloudFormation CloudFormationAPI
}

// Delete removes the given resource. Returns true if deleted, false if already gone.
func (c *ResourceCleaner) Delete(ctx context.Context, r *Resource) (bool, error) {
	if r == nil {
		return false, fmt.Errorf("resource must not be nil")
	}
	switch r.Type {
	case ResourceVPC:
		return c.deleteVPC(ctx, r.ARN)
	case ResourceSubnet:
		return c.deleteSubnet(ctx, r.ARN)
	case ResourceRouteTable:
		return c.deleteRouteTable(ctx, r.ARN)
	case ResourceInternetGateway:
		return c.deleteInternetGateway(ctx, r.ARN)
	case ResourceNATGateway:
		return c.deleteNATGateway(ctx, r.ARN)
	case ResourceVPCEndpoint:
		return c.deleteVPCEndpoint(ctx, r.ARN)
	case ResourceVPCEndpointService:
		return c.deleteVPCEndpointService(ctx, r.ARN)
	case ResourceEIP:
		return c.deleteEIP(ctx, r.ARN)
	case ResourceLaunchTemplate:
		return c.deleteLaunchTemplate(ctx, r.ARN)
	case ResourceEC2PlacementGroup:
		return c.deletePlacementGroup(ctx, r.ARN)
	case ResourceEKSCluster:
		return c.deleteEKSCluster(ctx, r.ARN)
	case ResourceEKSNodegroup:
		return c.deleteEKSNodegroup(ctx, r.ARN)
	case ResourceEC2Instance:
		return c.deleteEC2Instance(ctx, r.ARN)
	case ResourceEBSVolume:
		return c.deleteEBSVolume(ctx, r.ARN)
	case ResourceEBSSnapshot:
		return c.deleteEBSSnapshot(ctx, r.ARN)
	case ResourceENI:
		return c.deleteNetworkInterface(ctx, r.ARN)
	case ResourceSecurityGroup:
		return c.deleteSecurityGroup(ctx, r.ARN)
	case ResourceIAMRole:
		return c.deleteIAMRole(ctx, r.ARN)
	case ResourceIAMInstanceProfile:
		return c.deleteIAMInstanceProfile(ctx, r.ARN)
	case ResourceIAMPolicy:
		return c.deleteIAMPolicy(ctx, r.ARN)
	case ResourceRoute53HostedZone:
		return c.deleteRoute53HostedZone(ctx, r.ARN)
	case ResourceCloudFormationStack:
		return c.deleteCloudFormationStack(ctx, r.ARN)
	case ResourceLoadBalancer:
		return c.deleteLoadBalancer(ctx, r.ARN)
	case ResourceTargetGroup:
		return c.deleteTargetGroup(ctx, r.ARN)
	case ResourceSQS:
		return c.deleteSQS(ctx, r.ARN)
	case ResourceS3:
		return c.deleteS3(ctx, r.ARN)
	case ResourceDynamoDB:
		return c.deleteDynamoDB(ctx, r.ARN)
	case ResourceSNS:
		return c.deleteSNS(ctx, r.ARN)
	case ResourceLambda:
		return c.deleteLambda(ctx, r.ARN)
	case ResourceSecretsManagerSecret:
		return c.deleteSecretsManagerSecret(ctx, r.ARN)
	default:
		return false, fmt.Errorf("unsupported resource type %q for ARN %s", r.Type, r.ARN)
	}
}

func (c *ResourceCleaner) deleteVPC(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	vpcID := ec2IDFromARN(arn, "vpc/")
	if vpcID == "" {
		return false, fmt.Errorf("cannot parse VPC id from ARN %s", arn)
	}
	verbose.Logf("AWS ec2:DeleteVpc arn=%s vpc=%s", arn, vpcID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteVpc(ctx, &ec2.DeleteVpcInput{VpcId: awssdk.String(vpcID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete VPC %s: %w", vpcID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteSubnet(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	subnetID := ec2IDFromARN(arn, "subnet/")
	if subnetID == "" {
		return false, fmt.Errorf("cannot parse subnet id from ARN %s", arn)
	}
	verbose.Logf("AWS ec2:DeleteSubnet arn=%s subnet=%s", arn, subnetID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteSubnet(ctx, &ec2.DeleteSubnetInput{SubnetId: awssdk.String(subnetID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete subnet %s: %w", subnetID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteRouteTable(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	routeTableID := ec2IDFromARN(arn, "route-table/")
	if routeTableID == "" {
		return false, fmt.Errorf("cannot parse route table id from ARN %s", arn)
	}
	if err := c.disassociateRouteTable(ctx, routeTableID); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("disassociate route table %s: %w", routeTableID, err)
	}
	verbose.Logf("AWS ec2:DeleteRouteTable arn=%s route_table=%s", arn, routeTableID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteRouteTable(ctx, &ec2.DeleteRouteTableInput{RouteTableId: awssdk.String(routeTableID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete route table %s: %w", routeTableID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) disassociateRouteTable(ctx context.Context, routeTableID string) error {
	out, err := c.EC2.DescribeRouteTables(ctx, &ec2.DescribeRouteTablesInput{
		RouteTableIds: []string{routeTableID},
	})
	if err != nil {
		return err
	}
	for i := range out.RouteTables {
		for _, association := range out.RouteTables[i].Associations {
			if awssdk.ToBool(association.Main) || association.RouteTableAssociationId == nil {
				continue
			}
			associationID := awssdk.ToString(association.RouteTableAssociationId)
			verbose.Logf("AWS ec2:DisassociateRouteTable route_table=%s association=%s", routeTableID, associationID)
			_, err := c.EC2.DisassociateRouteTable(ctx, &ec2.DisassociateRouteTableInput{
				AssociationId: awssdk.String(associationID),
			})
			if err != nil && !isNotFoundErr(err) {
				return err
			}
		}
	}
	return nil
}

func (c *ResourceCleaner) deleteInternetGateway(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	gatewayID := ec2IDFromARN(arn, "internet-gateway/")
	if gatewayID == "" {
		return false, fmt.Errorf("cannot parse internet gateway id from ARN %s", arn)
	}
	if err := c.detachInternetGateway(ctx, gatewayID); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("detach internet gateway %s: %w", gatewayID, err)
	}
	verbose.Logf("AWS ec2:DeleteInternetGateway arn=%s gateway=%s", arn, gatewayID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteInternetGateway(ctx, &ec2.DeleteInternetGatewayInput{
			InternetGatewayId: awssdk.String(gatewayID),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete internet gateway %s: %w", gatewayID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) detachInternetGateway(ctx context.Context, gatewayID string) error {
	out, err := c.EC2.DescribeInternetGateways(ctx, &ec2.DescribeInternetGatewaysInput{
		InternetGatewayIds: []string{gatewayID},
	})
	if err != nil {
		return err
	}
	for _, gateway := range out.InternetGateways {
		for _, attachment := range gateway.Attachments {
			if attachment.VpcId == nil {
				continue
			}
			vpcID := awssdk.ToString(attachment.VpcId)
			verbose.Logf("AWS ec2:DetachInternetGateway gateway=%s vpc=%s", gatewayID, vpcID)
			_, err := c.EC2.DetachInternetGateway(ctx, &ec2.DetachInternetGatewayInput{
				InternetGatewayId: awssdk.String(gatewayID),
				VpcId:             awssdk.String(vpcID),
			})
			if err != nil && !isNotFoundErr(err) {
				return err
			}
		}
	}
	return nil
}

func (c *ResourceCleaner) deleteNATGateway(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	natGatewayID := ec2IDFromARN(arn, "natgateway/")
	if natGatewayID == "" {
		return false, fmt.Errorf("cannot parse NAT gateway id from ARN %s", arn)
	}
	verbose.Logf("AWS ec2:DeleteNatGateway arn=%s nat_gateway=%s", arn, natGatewayID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteNatGateway(ctx, &ec2.DeleteNatGatewayInput{
			NatGatewayId: awssdk.String(natGatewayID),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete NAT gateway %s: %w", natGatewayID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteVPCEndpoint(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	endpointID := ec2IDFromARN(arn, "vpc-endpoint/")
	if endpointID == "" {
		return false, fmt.Errorf("cannot parse VPC endpoint id from ARN %s", arn)
	}
	live, err := c.vpcEndpointExists(ctx, endpointID)
	if err != nil {
		return false, fmt.Errorf("describe VPC endpoint %s before delete: %w", endpointID, err)
	}
	if !live {
		return false, nil
	}
	verbose.Logf("AWS ec2:DeleteVpcEndpoints arn=%s endpoint=%s", arn, endpointID)
	var out *ec2.DeleteVpcEndpointsOutput
	err = withDeleteRetry(ctx, func() error {
		var deleteErr error
		out, deleteErr = c.EC2.DeleteVpcEndpoints(ctx, &ec2.DeleteVpcEndpointsInput{
			VpcEndpointIds: []string{endpointID},
		})
		return deleteErr
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete VPC endpoint %s: %w", endpointID, err)
	}
	if deleted, err := vpcEndpointDeleteResult(endpointID, out); err != nil || !deleted {
		return deleted, err
	}
	return true, nil
}

func (c *ResourceCleaner) vpcEndpointExists(ctx context.Context, endpointID string) (bool, error) {
	callCtx, cancel := ec2DeleteRequestContext(ctx)
	defer cancel()
	out, err := c.EC2.DescribeVpcEndpoints(callCtx, &ec2.DescribeVpcEndpointsInput{
		VpcEndpointIds: []string{endpointID},
	}, ec2SingleAttemptOptions...)
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, err
	}
	return len(out.VpcEndpoints) > 0, nil
}

func vpcEndpointDeleteResult(endpointID string, out *ec2.DeleteVpcEndpointsOutput) (bool, error) {
	if out == nil || len(out.Unsuccessful) == 0 {
		return true, nil
	}
	for _, item := range out.Unsuccessful {
		if item.Error == nil {
			continue
		}
		code := awssdk.ToString(item.Error.Code)
		if notFoundCodes[code] {
			return false, nil
		}
		return false, fmt.Errorf("delete VPC endpoint %s unsuccessful: %s: %s",
			endpointID, code, awssdk.ToString(item.Error.Message))
	}
	return false, fmt.Errorf("delete VPC endpoint %s unsuccessful", endpointID)
}

func (c *ResourceCleaner) deleteVPCEndpointService(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	serviceID := ec2IDFromARN(arn, "vpc-endpoint-service/")
	if serviceID == "" {
		return false, fmt.Errorf("cannot parse VPC endpoint service id from ARN %s", arn)
	}
	live, err := c.vpcEndpointServiceExists(ctx, serviceID)
	if err != nil {
		return false, fmt.Errorf("describe VPC endpoint service %s before delete: %w", serviceID, err)
	}
	if !live {
		return false, nil
	}
	verbose.Logf("AWS ec2:DeleteVpcEndpointServiceConfigurations arn=%s service=%s", arn, serviceID)
	var out *ec2.DeleteVpcEndpointServiceConfigurationsOutput
	err = withDeleteRetry(ctx, func() error {
		var deleteErr error
		out, deleteErr = c.EC2.DeleteVpcEndpointServiceConfigurations(ctx, &ec2.DeleteVpcEndpointServiceConfigurationsInput{
			ServiceIds: []string{serviceID},
		})
		return deleteErr
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete VPC endpoint service %s: %w", serviceID, err)
	}
	if deleted, err := vpcEndpointServiceDeleteResult(serviceID, out); err != nil || !deleted {
		return deleted, err
	}
	return true, nil
}

func (c *ResourceCleaner) vpcEndpointServiceExists(ctx context.Context, serviceID string) (bool, error) {
	callCtx, cancel := ec2DeleteRequestContext(ctx)
	defer cancel()
	out, err := c.EC2.DescribeVpcEndpointServiceConfigurations(callCtx, &ec2.DescribeVpcEndpointServiceConfigurationsInput{
		ServiceIds: []string{serviceID},
	}, ec2SingleAttemptOptions...)
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, err
	}
	return len(out.ServiceConfigurations) > 0, nil
}

func vpcEndpointServiceDeleteResult(serviceID string, out *ec2.DeleteVpcEndpointServiceConfigurationsOutput) (bool, error) {
	if out == nil || len(out.Unsuccessful) == 0 {
		return true, nil
	}
	for _, item := range out.Unsuccessful {
		if item.Error == nil {
			continue
		}
		code := awssdk.ToString(item.Error.Code)
		if notFoundCodes[code] {
			return false, nil
		}
		return false, fmt.Errorf("delete VPC endpoint service %s unsuccessful: %s: %s",
			serviceID, code, awssdk.ToString(item.Error.Message))
	}
	return false, fmt.Errorf("delete VPC endpoint service %s unsuccessful", serviceID)
}

func (c *ResourceCleaner) deleteEIP(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	allocationID := ec2IDFromARN(arn, "elastic-ip/")
	if allocationID == "" {
		return false, fmt.Errorf("cannot parse EIP allocation id from ARN %s", arn)
	}
	verbose.Logf("AWS ec2:ReleaseAddress arn=%s allocation=%s", arn, allocationID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.ReleaseAddress(ctx, &ec2.ReleaseAddressInput{AllocationId: awssdk.String(allocationID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("release EIP %s: %w", allocationID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteLaunchTemplate(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	templateID := ec2IDFromARN(arn, "launch-template/")
	if templateID == "" {
		return false, fmt.Errorf("cannot parse launch template id from ARN %s", arn)
	}
	verbose.Logf("AWS ec2:DeleteLaunchTemplate arn=%s template=%s", arn, templateID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteLaunchTemplate(ctx, &ec2.DeleteLaunchTemplateInput{
			LaunchTemplateId: awssdk.String(templateID),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete launch template %s: %w", templateID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deletePlacementGroup(ctx context.Context, arn string) (bool, error) {
	if c.PlacementGroup == nil {
		return false, fmt.Errorf("EC2 placement group client not configured")
	}
	groupID := ec2IDFromARN(arn, "placement-group/")
	if groupID == "" {
		return false, fmt.Errorf("cannot parse placement group id from ARN %s", arn)
	}
	out, err := c.PlacementGroup.DescribePlacementGroups(ctx, &ec2.DescribePlacementGroupsInput{
		GroupIds: []string{groupID},
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("describe placement group %s before delete: %w", groupID, err)
	}
	if out == nil || len(out.PlacementGroups) == 0 {
		return false, nil
	}
	groupName := strings.TrimSpace(awssdk.ToString(out.PlacementGroups[0].GroupName))
	if groupName == "" {
		return false, fmt.Errorf("placement group %s has no name", groupID)
	}
	verbose.Logf("AWS ec2:DeletePlacementGroup arn=%s group=%s", arn, groupID)
	err = withDeleteRetry(ctx, func() error {
		_, err := c.PlacementGroup.DeletePlacementGroup(ctx, &ec2.DeletePlacementGroupInput{
			GroupName: awssdk.String(groupName),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete placement group %s: %w", groupID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteEKSCluster(ctx context.Context, arn string) (bool, error) {
	if c.EKS == nil {
		return false, fmt.Errorf("EKS client not configured")
	}
	clusterName := eksClusterNameFromARN(arn)
	if clusterName == "" {
		return false, fmt.Errorf("cannot parse EKS cluster name from ARN %s", arn)
	}
	verbose.Logf("AWS eks:DeleteCluster arn=%s cluster=%s", arn, clusterName)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EKS.DeleteCluster(ctx, &eks.DeleteClusterInput{Name: awssdk.String(clusterName)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete EKS cluster %s: %w", clusterName, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteEKSNodegroup(ctx context.Context, arn string) (bool, error) {
	if c.EKS == nil {
		return false, fmt.Errorf("EKS client not configured")
	}
	clusterName, nodegroupName := eksNodegroupNamesFromARN(arn)
	if clusterName == "" || nodegroupName == "" {
		return false, fmt.Errorf("cannot parse EKS nodegroup from ARN %s", arn)
	}
	verbose.Logf("AWS eks:DeleteNodegroup arn=%s cluster=%s nodegroup=%s", arn, clusterName, nodegroupName)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EKS.DeleteNodegroup(ctx, &eks.DeleteNodegroupInput{
			ClusterName:   awssdk.String(clusterName),
			NodegroupName: awssdk.String(nodegroupName),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete EKS nodegroup %s/%s: %w", clusterName, nodegroupName, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteEC2Instance(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	instanceID := ec2IDFromARN(arn, "instance/")
	if instanceID == "" {
		return false, fmt.Errorf("cannot parse EC2 instance id from ARN %s", arn)
	}
	live, err := c.ec2InstanceExists(ctx, instanceID)
	if err != nil {
		return false, fmt.Errorf("describe EC2 instance %s before terminate: %w", instanceID, err)
	}
	if !live {
		return false, nil
	}
	verbose.Logf("AWS ec2:TerminateInstances arn=%s instance=%s", arn, instanceID)
	err = withDeleteRetry(ctx, func() error {
		callCtx, cancel := ec2DeleteRequestContext(ctx)
		defer cancel()
		_, err := c.EC2.TerminateInstances(callCtx, &ec2.TerminateInstancesInput{
			InstanceIds: []string{instanceID},
		}, ec2SingleAttemptOptions...)
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("terminate EC2 instance %s: %w", instanceID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) ec2InstanceExists(ctx context.Context, instanceID string) (bool, error) {
	callCtx, cancel := ec2DeleteRequestContext(ctx)
	defer cancel()
	out, err := c.EC2.DescribeInstances(callCtx, &ec2.DescribeInstancesInput{
		InstanceIds: []string{instanceID},
	}, ec2SingleAttemptOptions...)
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, err
	}
	for i := range out.Reservations {
		for j := range out.Reservations[i].Instances {
			state := out.Reservations[i].Instances[j].State
			if state != nil && state.Name == ec2types.InstanceStateNameTerminated {
				continue
			}
			return true, nil
		}
	}
	return false, nil
}

func ec2DeleteRequestContext(ctx context.Context) (context.Context, context.CancelFunc) {
	if _, ok := ctx.Deadline(); ok {
		return context.WithCancel(ctx)
	}
	return context.WithTimeout(ctx, staleTaggedEC2ResourceLookupTimeout)
}

func (c *ResourceCleaner) deleteEBSVolume(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	volumeID := ec2IDFromARN(arn, "volume/")
	if volumeID == "" {
		return false, fmt.Errorf("cannot parse EBS volume id from ARN %s", arn)
	}
	verbose.Logf("AWS ec2:DeleteVolume arn=%s volume=%s", arn, volumeID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: awssdk.String(volumeID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete EBS volume %s: %w", volumeID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteEBSSnapshot(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	snapshotID := ec2IDFromARN(arn, "snapshot/")
	if snapshotID == "" {
		return false, fmt.Errorf("cannot parse EBS snapshot id from ARN %s", arn)
	}
	verbose.Logf("AWS ec2:DeleteSnapshot arn=%s snapshot=%s", arn, snapshotID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: awssdk.String(snapshotID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete EBS snapshot %s: %w", snapshotID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteNetworkInterface(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	interfaceID := ec2IDFromARN(arn, "network-interface/")
	if interfaceID == "" {
		return false, fmt.Errorf("cannot parse ENI id from ARN %s", arn)
	}
	live, err := c.networkInterfaceExists(ctx, interfaceID)
	if err != nil {
		return false, fmt.Errorf("describe ENI %s before delete: %w", interfaceID, err)
	}
	if !live {
		return false, nil
	}
	verbose.Logf("AWS ec2:DeleteNetworkInterface arn=%s eni=%s", arn, interfaceID)
	err = withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteNetworkInterface(ctx, &ec2.DeleteNetworkInterfaceInput{NetworkInterfaceId: awssdk.String(interfaceID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete ENI %s: %w", interfaceID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) networkInterfaceExists(ctx context.Context, interfaceID string) (bool, error) {
	callCtx, cancel := ec2DeleteRequestContext(ctx)
	defer cancel()
	out, err := c.EC2.DescribeNetworkInterfaces(callCtx, &ec2.DescribeNetworkInterfacesInput{
		NetworkInterfaceIds: []string{interfaceID},
	}, ec2SingleAttemptOptions...)
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, err
	}
	return len(out.NetworkInterfaces) > 0, nil
}

func (c *ResourceCleaner) deleteSecurityGroup(ctx context.Context, arn string) (bool, error) {
	if c.EC2 == nil {
		return false, fmt.Errorf("EC2 client not configured")
	}
	groupID := ec2IDFromARN(arn, "security-group/")
	if groupID == "" {
		return false, fmt.Errorf("cannot parse security group id from ARN %s", arn)
	}
	verbose.Logf("AWS ec2:DeleteSecurityGroup arn=%s sg=%s", arn, groupID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.EC2.DeleteSecurityGroup(ctx, &ec2.DeleteSecurityGroupInput{GroupId: awssdk.String(groupID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete security group %s: %w", groupID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteIAMRole(ctx context.Context, arn string) (bool, error) {
	if c.IAM == nil {
		return false, fmt.Errorf("IAM client not configured")
	}
	roleName := iamNameFromARN(arn, "role/")
	if roleName == "" {
		return false, fmt.Errorf("cannot parse IAM role from ARN %s", arn)
	}

	verbose.Logf("AWS iam:DeleteRole arn=%s role=%s", arn, roleName)
	if err := c.detachRolePolicies(ctx, roleName); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("detach IAM role policies %s: %w", roleName, err)
	}
	if err := c.removeRoleFromInstanceProfiles(ctx, roleName); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("remove IAM role %s from instance profiles: %w", roleName, err)
	}

	err := withDeleteRetry(ctx, func() error {
		_, err := c.IAM.DeleteRole(ctx, &iam.DeleteRoleInput{RoleName: awssdk.String(roleName)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete IAM role %s: %w", roleName, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteIAMInstanceProfile(ctx context.Context, arn string) (bool, error) {
	if c.IAM == nil {
		return false, fmt.Errorf("IAM client not configured")
	}
	profileName := iamNameFromARN(arn, "instance-profile/")
	if profileName == "" {
		return false, fmt.Errorf("cannot parse IAM instance profile from ARN %s", arn)
	}
	if err := c.emptyInstanceProfile(ctx, profileName); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("remove roles from IAM instance profile %s: %w", profileName, err)
	}
	verbose.Logf("AWS iam:DeleteInstanceProfile arn=%s profile=%s", arn, profileName)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.IAM.DeleteInstanceProfile(ctx, &iam.DeleteInstanceProfileInput{
			InstanceProfileName: awssdk.String(profileName),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete IAM instance profile %s: %w", profileName, err)
	}
	return true, nil
}

func (c *ResourceCleaner) emptyInstanceProfile(ctx context.Context, profileName string) error {
	out, err := c.IAM.GetInstanceProfile(ctx, &iam.GetInstanceProfileInput{
		InstanceProfileName: awssdk.String(profileName),
	})
	if err != nil {
		return err
	}
	if out.InstanceProfile == nil {
		return nil
	}
	for _, role := range out.InstanceProfile.Roles {
		roleName := awssdk.ToString(role.RoleName)
		if roleName == "" {
			continue
		}
		verbose.Logf("AWS iam:RemoveRoleFromInstanceProfile profile=%s role=%s", profileName, roleName)
		_, err := c.IAM.RemoveRoleFromInstanceProfile(ctx, &iam.RemoveRoleFromInstanceProfileInput{
			InstanceProfileName: awssdk.String(profileName),
			RoleName:            awssdk.String(roleName),
		})
		if err != nil && !isNotFoundErr(err) {
			return err
		}
	}
	return nil
}

func (c *ResourceCleaner) removeRoleFromInstanceProfiles(ctx context.Context, roleName string) error {
	var profileNames []string
	var marker *string
	for {
		verbose.Logf("AWS iam:ListInstanceProfilesForRole role=%s marker=%q", roleName, awssdk.ToString(marker))
		out, err := c.IAM.ListInstanceProfilesForRole(ctx, &iam.ListInstanceProfilesForRoleInput{
			RoleName: awssdk.String(roleName),
			Marker:   marker,
		})
		if err != nil {
			return err
		}
		for _, profile := range out.InstanceProfiles {
			profileName := awssdk.ToString(profile.InstanceProfileName)
			if profileName == "" {
				continue
			}
			profileNames = append(profileNames, profileName)
		}
		if !out.IsTruncated || out.Marker == nil {
			break
		}
		marker = out.Marker
	}
	for _, profileName := range profileNames {
		verbose.Logf("AWS iam:RemoveRoleFromInstanceProfile profile=%s role=%s", profileName, roleName)
		_, err := c.IAM.RemoveRoleFromInstanceProfile(ctx, &iam.RemoveRoleFromInstanceProfileInput{
			InstanceProfileName: awssdk.String(profileName),
			RoleName:            awssdk.String(roleName),
		})
		if err != nil && !isNotFoundErr(err) {
			return err
		}
	}
	return nil
}

func (c *ResourceCleaner) detachRolePolicies(ctx context.Context, roleName string) error {
	var attachedARNs []string
	var marker *string
	for {
		verbose.Logf("AWS iam:ListAttachedRolePolicies role=%s marker=%q", roleName, awssdk.ToString(marker))
		attached, err := c.IAM.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{
			RoleName: awssdk.String(roleName),
			Marker:   marker,
		})
		if err != nil {
			return err
		}
		for _, policy := range attached.AttachedPolicies {
			if policy.PolicyArn == nil {
				continue
			}
			attachedARNs = append(attachedARNs, awssdk.ToString(policy.PolicyArn))
		}
		if !attached.IsTruncated || attached.Marker == nil {
			break
		}
		marker = attached.Marker
	}
	for _, policyARN := range attachedARNs {
		verbose.Logf("AWS iam:DetachRolePolicy role=%s policy=%s", roleName, policyARN)
		_, err := c.IAM.DetachRolePolicy(ctx, &iam.DetachRolePolicyInput{
			RoleName:  awssdk.String(roleName),
			PolicyArn: awssdk.String(policyARN),
		})
		if err != nil && !isNotFoundErr(err) {
			return err
		}
	}

	var inlinePolicyNames []string
	marker = nil
	for {
		verbose.Logf("AWS iam:ListRolePolicies role=%s marker=%q", roleName, awssdk.ToString(marker))
		inlinePolicies, err := c.IAM.ListRolePolicies(ctx, &iam.ListRolePoliciesInput{
			RoleName: awssdk.String(roleName),
			Marker:   marker,
		})
		if err != nil {
			return err
		}
		inlinePolicyNames = append(inlinePolicyNames, inlinePolicies.PolicyNames...)
		if !inlinePolicies.IsTruncated || inlinePolicies.Marker == nil {
			break
		}
		marker = inlinePolicies.Marker
	}
	for _, policyName := range inlinePolicyNames {
		verbose.Logf("AWS iam:DeleteRolePolicy role=%s policy=%s", roleName, policyName)
		_, err := c.IAM.DeleteRolePolicy(ctx, &iam.DeleteRolePolicyInput{
			RoleName:   awssdk.String(roleName),
			PolicyName: awssdk.String(policyName),
		})
		if err != nil && !isNotFoundErr(err) {
			return err
		}
	}
	return nil
}

func (c *ResourceCleaner) deleteIAMPolicy(ctx context.Context, arn string) (bool, error) {
	if c.IAM == nil {
		return false, fmt.Errorf("IAM client not configured")
	}
	verbose.Logf("AWS iam:DeletePolicy arn=%s", arn)
	if err := c.detachPolicyEntities(ctx, arn); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("detach IAM policy entities %s: %w", arn, err)
	}
	if err := c.deletePolicyVersions(ctx, arn); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete IAM policy versions %s: %w", arn, err)
	}
	err := withDeleteRetry(ctx, func() error {
		_, err := c.IAM.DeletePolicy(ctx, &iam.DeletePolicyInput{PolicyArn: awssdk.String(arn)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete IAM policy %s: %w", arn, err)
	}
	return true, nil
}

func (c *ResourceCleaner) detachPolicyEntities(ctx context.Context, arn string) error {
	var roleNames, userNames, groupNames []string
	var marker *string
	for {
		verbose.Logf("AWS iam:ListEntitiesForPolicy policy=%s marker=%q", arn, awssdk.ToString(marker))
		entities, err := c.IAM.ListEntitiesForPolicy(ctx, &iam.ListEntitiesForPolicyInput{
			PolicyArn: awssdk.String(arn),
			Marker:    marker,
		})
		if err != nil {
			return err
		}
		for _, role := range entities.PolicyRoles {
			if role.RoleName != nil {
				roleNames = append(roleNames, awssdk.ToString(role.RoleName))
			}
		}
		for _, user := range entities.PolicyUsers {
			if user.UserName != nil {
				userNames = append(userNames, awssdk.ToString(user.UserName))
			}
		}
		for _, group := range entities.PolicyGroups {
			if group.GroupName != nil {
				groupNames = append(groupNames, awssdk.ToString(group.GroupName))
			}
		}
		if !entities.IsTruncated || entities.Marker == nil {
			break
		}
		marker = entities.Marker
	}

	for _, roleName := range roleNames {
		verbose.Logf("AWS iam:DetachRolePolicy role=%s policy=%s", roleName, arn)
		_, err := c.IAM.DetachRolePolicy(ctx, &iam.DetachRolePolicyInput{
			RoleName:  awssdk.String(roleName),
			PolicyArn: awssdk.String(arn),
		})
		if err != nil && !isNotFoundErr(err) {
			return err
		}
	}
	for _, userName := range userNames {
		verbose.Logf("AWS iam:DetachUserPolicy user=%s policy=%s", userName, arn)
		_, err := c.IAM.DetachUserPolicy(ctx, &iam.DetachUserPolicyInput{
			UserName:  awssdk.String(userName),
			PolicyArn: awssdk.String(arn),
		})
		if err != nil && !isNotFoundErr(err) {
			return err
		}
	}
	for _, groupName := range groupNames {
		verbose.Logf("AWS iam:DetachGroupPolicy group=%s policy=%s", groupName, arn)
		_, err := c.IAM.DetachGroupPolicy(ctx, &iam.DetachGroupPolicyInput{
			GroupName: awssdk.String(groupName),
			PolicyArn: awssdk.String(arn),
		})
		if err != nil && !isNotFoundErr(err) {
			return err
		}
	}
	return nil
}

func (c *ResourceCleaner) deletePolicyVersions(ctx context.Context, arn string) error {
	var versionIDs []string
	var marker *string
	for {
		verbose.Logf("AWS iam:ListPolicyVersions policy=%s marker=%q", arn, awssdk.ToString(marker))
		versions, err := c.IAM.ListPolicyVersions(ctx, &iam.ListPolicyVersionsInput{
			PolicyArn: awssdk.String(arn),
			Marker:    marker,
		})
		if err != nil {
			return err
		}
		for _, version := range versions.Versions {
			if version.VersionId == nil || version.IsDefaultVersion {
				continue
			}
			versionIDs = append(versionIDs, awssdk.ToString(version.VersionId))
		}
		if !versions.IsTruncated || versions.Marker == nil {
			break
		}
		marker = versions.Marker
	}
	for _, versionID := range versionIDs {
		verbose.Logf("AWS iam:DeletePolicyVersion policy=%s version=%s", arn, versionID)
		_, err := c.IAM.DeletePolicyVersion(ctx, &iam.DeletePolicyVersionInput{
			PolicyArn: awssdk.String(arn),
			VersionId: awssdk.String(versionID),
		})
		if err != nil && !isNotFoundErr(err) {
			return err
		}
	}
	return nil
}

func (c *ResourceCleaner) deleteLoadBalancer(ctx context.Context, arn string) (bool, error) {
	if c.ELBV2 == nil {
		return false, fmt.Errorf("ELBV2 client not configured")
	}
	if err := c.disableLoadBalancerDeletionProtection(ctx, arn); err != nil {
		return false, err
	}
	verbose.Logf("AWS elbv2:DeleteLoadBalancer arn=%s", arn)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.ELBV2.DeleteLoadBalancer(ctx, &elasticloadbalancingv2.DeleteLoadBalancerInput{
			LoadBalancerArn: awssdk.String(arn),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete load balancer %s: %w", arn, err)
	}
	return true, nil
}

func (c *ResourceCleaner) disableLoadBalancerDeletionProtection(ctx context.Context, arn string) error {
	verbose.Logf("AWS elbv2:ModifyLoadBalancerAttributes deletion_protection=false arn=%s", arn)
	_, err := c.ELBV2.ModifyLoadBalancerAttributes(ctx, &elasticloadbalancingv2.ModifyLoadBalancerAttributesInput{
		LoadBalancerArn: awssdk.String(arn),
		Attributes: []elbv2types.LoadBalancerAttribute{
			{
				Key:   awssdk.String("deletion_protection.enabled"),
				Value: awssdk.String("false"),
			},
		},
	})
	if err != nil {
		if isNotFoundErr(err) {
			return nil
		}
		return fmt.Errorf("disable load balancer deletion protection %s: %w", arn, err)
	}
	return nil
}

func (c *ResourceCleaner) deleteTargetGroup(ctx context.Context, arn string) (bool, error) {
	if c.ELBV2 == nil {
		return false, fmt.Errorf("ELBV2 client not configured")
	}
	verbose.Logf("AWS elbv2:DeleteTargetGroup arn=%s", arn)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.ELBV2.DeleteTargetGroup(ctx, &elasticloadbalancingv2.DeleteTargetGroupInput{
			TargetGroupArn: awssdk.String(arn),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete target group %s: %w", arn, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteSQS(ctx context.Context, arn string) (bool, error) {
	if c.SQS == nil {
		return false, fmt.Errorf("SQS client not configured")
	}
	url, err := sqsURLFromARN(arn)
	if err != nil {
		return false, err
	}
	verbose.Logf("AWS sqs:DeleteQueue arn=%s url=%s", arn, url)
	err = withDeleteRetry(ctx, func() error {
		_, err := c.SQS.DeleteQueue(ctx, &sqs.DeleteQueueInput{QueueUrl: awssdk.String(url)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete SQS queue %s: %w", arn, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteS3(ctx context.Context, arn string) (bool, error) {
	if c.S3 == nil {
		return false, fmt.Errorf("S3 client not configured")
	}
	bucket := s3BucketFromARN(arn)
	if bucket == "" {
		return false, fmt.Errorf("cannot parse bucket name from ARN %s", arn)
	}
	if err := c.emptyBucket(ctx, bucket); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("empty S3 bucket %s: %w", bucket, err)
	}
	verbose.Logf("AWS s3:DeleteBucket bucket=%s arn=%s", bucket, arn)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.S3.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: awssdk.String(bucket)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete S3 bucket %s: %w", bucket, err)
	}
	return true, nil
}

func (c *ResourceCleaner) emptyBucket(ctx context.Context, bucket string) error {
	if err := c.emptyBucketVersions(ctx, bucket); err != nil {
		return err
	}
	var continuationToken *string
	for {
		verbose.Logf("AWS s3:ListObjectsV2 bucket=%s continuation=%q", bucket, awssdk.ToString(continuationToken))
		out, err := c.S3.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
			Bucket:            awssdk.String(bucket),
			MaxKeys:           awssdk.Int32(1000),
			ContinuationToken: continuationToken,
		})
		if err != nil {
			return err
		}
		if len(out.Contents) == 0 {
			return nil
		}

		objects := make([]s3types.ObjectIdentifier, len(out.Contents))
		for i, obj := range out.Contents {
			objects[i] = s3types.ObjectIdentifier{Key: obj.Key}
		}

		verbose.Logf("AWS s3:DeleteObjects bucket=%s count=%d", bucket, len(objects))
		_, err = c.S3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
			Bucket: awssdk.String(bucket),
			Delete: &s3types.Delete{Objects: objects, Quiet: awssdk.Bool(true)},
		})
		if err != nil {
			return err
		}

		if !awssdk.ToBool(out.IsTruncated) {
			return nil
		}
		continuationToken = out.NextContinuationToken
	}
}

func (c *ResourceCleaner) emptyBucketVersions(ctx context.Context, bucket string) error {
	var keyMarker *string
	var versionMarker *string
	for {
		verbose.Logf("AWS s3:ListObjectVersions bucket=%s key_marker=%q version_marker=%q",
			bucket, awssdk.ToString(keyMarker), awssdk.ToString(versionMarker))
		out, err := c.S3.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
			Bucket:          awssdk.String(bucket),
			MaxKeys:         awssdk.Int32(1000),
			KeyMarker:       keyMarker,
			VersionIdMarker: versionMarker,
		})
		if err != nil {
			return err
		}
		objects := make([]s3types.ObjectIdentifier, 0, len(out.Versions)+len(out.DeleteMarkers))
		for i := range out.Versions {
			version := &out.Versions[i]
			objects = append(objects, s3types.ObjectIdentifier{
				Key:       version.Key,
				VersionId: version.VersionId,
			})
		}
		for _, marker := range out.DeleteMarkers {
			objects = append(objects, s3types.ObjectIdentifier{
				Key:       marker.Key,
				VersionId: marker.VersionId,
			})
		}
		if len(objects) > 0 {
			verbose.Logf("AWS s3:DeleteObjects versions bucket=%s count=%d", bucket, len(objects))
			_, err = c.S3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
				Bucket: awssdk.String(bucket),
				Delete: &s3types.Delete{Objects: objects, Quiet: awssdk.Bool(true)},
			})
			if err != nil {
				return err
			}
		}
		if !awssdk.ToBool(out.IsTruncated) {
			return nil
		}
		keyMarker = out.NextKeyMarker
		versionMarker = out.NextVersionIdMarker
	}
}

func (c *ResourceCleaner) deleteDynamoDB(ctx context.Context, arn string) (bool, error) {
	if c.DynamoDB == nil {
		return false, fmt.Errorf("DynamoDB client not configured")
	}
	tableName := dynamoDBTableFromARN(arn)
	if tableName == "" {
		return false, fmt.Errorf("cannot parse table name from ARN %s", arn)
	}
	verbose.Logf("AWS dynamodb:DeleteTable arn=%s table=%s", arn, tableName)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.DynamoDB.DeleteTable(ctx, &dynamodb.DeleteTableInput{TableName: awssdk.String(tableName)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete DynamoDB table %s: %w", tableName, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteSNS(ctx context.Context, arn string) (bool, error) {
	if c.SNS == nil {
		return false, fmt.Errorf("SNS client not configured")
	}
	verbose.Logf("AWS sns:DeleteTopic arn=%s", arn)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.SNS.DeleteTopic(ctx, &sns.DeleteTopicInput{TopicArn: awssdk.String(arn)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete SNS topic %s: %w", arn, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteLambda(ctx context.Context, arn string) (bool, error) {
	if c.Lambda == nil {
		return false, fmt.Errorf("lambda client not configured")
	}
	verbose.Logf("AWS lambda:DeleteFunction arn=%s", arn)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.Lambda.DeleteFunction(ctx, &lambda.DeleteFunctionInput{FunctionName: awssdk.String(arn)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete Lambda function %s: %w", arn, err)
	}
	return true, nil
}

func (c *ResourceCleaner) deleteSecretsManagerSecret(ctx context.Context, arn string) (bool, error) {
	if c.SecretsManager == nil {
		return false, fmt.Errorf("secrets manager client not configured")
	}
	verbose.Logf("AWS secretsmanager:DeleteSecret arn=%s", arn)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.SecretsManager.DeleteSecret(ctx, &secretsmanager.DeleteSecretInput{
			SecretId:                   awssdk.String(arn),
			ForceDeleteWithoutRecovery: awssdk.Bool(true),
		})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete Secrets Manager secret %s: %w", arn, err)
	}
	return true, nil
}

// deleteCloudFormationStack removes a session-owned stack. Deletion is
// asynchronous and a deleted stack stays describable, so DELETE_COMPLETE reports
// as gone rather than being deleted again, and DELETE_IN_PROGRESS reports as
// still present so an in-flight delete is never counted as done.
func (c *ResourceCleaner) deleteCloudFormationStack(ctx context.Context, arn string) (bool, error) {
	if c.CloudFormation == nil {
		return false, fmt.Errorf("CloudFormation client not configured")
	}
	// A stack ARN carries stack/<name>/<id>; DescribeStacks and DeleteStack both
	// accept the full ARN, which is unambiguous where a bare name is not.
	if arnResourceTail(arn, "stack/") == "" {
		return false, fmt.Errorf("cannot parse CloudFormation stack from ARN %s", arn)
	}
	out, err := c.CloudFormation.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{
		StackName: awssdk.String(arn),
	})
	if err != nil {
		// A stack that was fully deleted and aged out is no longer describable.
		if cloudFormationStackAbsentError(err) {
			return false, nil
		}
		return false, fmt.Errorf("describe CloudFormation stack %s: %w", arn, err)
	}
	if len(out.Stacks) == 0 {
		return false, nil
	}
	stack := out.Stacks[0]
	switch stack.StackStatus {
	case cfntypes.StackStatusDeleteComplete:
		return false, nil
	case cfntypes.StackStatusDeleteInProgress:
		// An in-flight delete is still a survivor for this pass. Do not issue
		// another DeleteStack; the next verify or scheduled run re-checks.
		return true, nil
	}
	if awssdk.ToBool(stack.EnableTerminationProtection) {
		verbose.Logf("AWS cloudformation:UpdateTerminationProtection arn=%s enable=false", arn)
		if _, err := c.CloudFormation.UpdateTerminationProtection(ctx, &cloudformation.UpdateTerminationProtectionInput{
			StackName:                   awssdk.String(arn),
			EnableTerminationProtection: awssdk.Bool(false),
		}); err != nil {
			return false, fmt.Errorf("disable termination protection for CloudFormation stack %s: %w", arn, err)
		}
	}
	if stack.StackStatus == cfntypes.StackStatusDeleteFailed {
		if err := c.recoverFailedCloudFormationResources(ctx, arn); err != nil {
			return false, err
		}
	}
	verbose.Logf("AWS cloudformation:DeleteStack arn=%s status=%s", arn, stack.StackStatus)
	if _, err := c.CloudFormation.DeleteStack(ctx, &cloudformation.DeleteStackInput{
		StackName: awssdk.String(arn),
	}); err != nil {
		if cloudFormationStackAbsentError(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete CloudFormation stack %s: %w", arn, err)
	}
	return true, nil
}

func (c *ResourceCleaner) recoverFailedCloudFormationResources(ctx context.Context, stackARN string) error {
	var token *string
	for {
		out, err := c.CloudFormation.ListStackResources(ctx, &cloudformation.ListStackResourcesInput{
			StackName: awssdk.String(stackARN),
			NextToken: token,
		})
		if err != nil {
			return fmt.Errorf("list CloudFormation stack resources %s: %w", stackARN, err)
		}
		for _, summary := range out.StackResourceSummaries {
			if summary.ResourceStatus != cfntypes.ResourceStatusDeleteFailed {
				continue
			}
			if awssdk.ToString(summary.ResourceType) != "AWS::Route53::HostedZone" {
				continue
			}
			zoneID := strings.TrimSpace(awssdk.ToString(summary.PhysicalResourceId))
			if zoneID == "" {
				continue
			}
			if c.Route53 == nil {
				return fmt.Errorf("delete-failed hosted zone %s in stack %s needs Route53 cleanup", zoneID, stackARN)
			}
			if err := c.emptyRoute53HostedZone(ctx, zoneID); err != nil && !isNotFoundErr(err) {
				return fmt.Errorf("empty delete-failed hosted zone %s in stack %s: %w", zoneID, stackARN, err)
			}
			verbose.Logf("AWS route53:DeleteHostedZone stack=%s zone=%s", stackARN, zoneID)
			err := withDeleteRetry(ctx, func() error {
				_, err := c.Route53.DeleteHostedZone(ctx, &route53.DeleteHostedZoneInput{Id: awssdk.String(zoneID)})
				return err
			})
			if err != nil && !isNotFoundErr(err) {
				return fmt.Errorf("delete delete-failed hosted zone %s in stack %s: %w", zoneID, stackARN, err)
			}
		}
		if out.NextToken == nil || awssdk.ToString(out.NextToken) == "" {
			return nil
		}
		token = out.NextToken
	}
}

// cloudFormationStackAbsentError reports whether CloudFormation is telling us the
// stack does not exist. CloudFormation answers with a ValidationError rather than
// a NotFound code, so the shared not-found table cannot recognise it. Match on the
// ValidationError code as well as the message: "does not exist" alone is generic
// enough to appear in unrelated failures, and reading one of those as absence
// would report a live stack as gone.
func cloudFormationStackAbsentError(err error) bool {
	var apiErr smithy.APIError
	return errors.As(err, &apiErr) &&
		apiErr.ErrorCode() == "ValidationError" &&
		strings.Contains(apiErr.ErrorMessage(), "does not exist")
}

func (c *ResourceCleaner) deleteRoute53HostedZone(ctx context.Context, arn string) (bool, error) {
	if c.Route53 == nil {
		return false, fmt.Errorf("Route53 client not configured")
	}
	hostedZoneID := route53HostedZoneIDFromARN(arn)
	if hostedZoneID == "" {
		return false, fmt.Errorf("cannot parse Route53 hosted zone id from ARN %s", arn)
	}
	if err := c.emptyRoute53HostedZone(ctx, hostedZoneID); err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("empty Route53 hosted zone %s: %w", hostedZoneID, err)
	}
	verbose.Logf("AWS route53:DeleteHostedZone arn=%s zone=%s", arn, hostedZoneID)
	err := withDeleteRetry(ctx, func() error {
		_, err := c.Route53.DeleteHostedZone(ctx, &route53.DeleteHostedZoneInput{Id: awssdk.String(hostedZoneID)})
		return err
	})
	if err != nil {
		if isNotFoundErr(err) {
			return false, nil
		}
		return false, fmt.Errorf("delete Route53 hosted zone %s: %w", hostedZoneID, err)
	}
	return true, nil
}

func (c *ResourceCleaner) emptyRoute53HostedZone(ctx context.Context, hostedZoneID string) error {
	var startName *string
	var startType route53types.RRType
	var startIdentifier *string
	for {
		out, err := c.Route53.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{
			HostedZoneId:          awssdk.String(hostedZoneID),
			StartRecordName:       startName,
			StartRecordType:       startType,
			StartRecordIdentifier: startIdentifier,
		})
		if err != nil {
			return err
		}
		var changes []route53types.Change
		for i := range out.ResourceRecordSets {
			if route53RecordIsHostedZoneDefault(out.ResourceRecordSets[i].Type) {
				continue
			}
			changes = append(changes, route53types.Change{
				Action:            route53types.ChangeActionDelete,
				ResourceRecordSet: &out.ResourceRecordSets[i],
			})
		}
		if len(changes) > 0 {
			verbose.Logf("AWS route53:ChangeResourceRecordSets zone=%s deletes=%d", hostedZoneID, len(changes))
			changed, err := c.Route53.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
				HostedZoneId: awssdk.String(hostedZoneID),
				ChangeBatch:  &route53types.ChangeBatch{Changes: changes},
			})
			if err != nil && !isNotFoundErr(err) {
				return err
			}
			if err == nil {
				if changed == nil || changed.ChangeInfo == nil || strings.TrimSpace(awssdk.ToString(changed.ChangeInfo.Id)) == "" {
					return fmt.Errorf("route53 change for hosted zone %s returned no change id", hostedZoneID)
				}
				if waitErr := c.waitRoute53Change(ctx, awssdk.ToString(changed.ChangeInfo.Id)); waitErr != nil {
					return waitErr
				}
			}
		}
		if !out.IsTruncated {
			return nil
		}
		startName = out.NextRecordName
		startType = out.NextRecordType
		startIdentifier = out.NextRecordIdentifier
	}
}

func route53RecordIsHostedZoneDefault(recordType route53types.RRType) bool {
	return recordType == route53types.RRTypeSoa || recordType == route53types.RRTypeNs
}

const route53ChangeWait = 60 * time.Second
const route53ChangePoll = 2 * time.Second

func (c *ResourceCleaner) waitRoute53Change(ctx context.Context, changeID string) error {
	changeID = strings.TrimSpace(changeID)
	if c.Route53 == nil {
		return fmt.Errorf("route53 client not configured")
	}
	if changeID == "" {
		return fmt.Errorf("route53 change id is empty")
	}
	deadline := time.Now().Add(route53ChangeWait)
	for {
		if err := ctx.Err(); err != nil {
			return err
		}
		out, err := c.Route53.GetChange(ctx, &route53.GetChangeInput{Id: awssdk.String(changeID)})
		if err != nil {
			if isNotFoundErr(err) {
				return nil
			}
			return err
		}
		if out.ChangeInfo != nil && out.ChangeInfo.Status == route53types.ChangeStatusInsync {
			return nil
		}
		if !time.Now().Before(deadline) {
			return fmt.Errorf("route53 change %s did not reach INSYNC", changeID)
		}
		time.Sleep(route53ChangePoll)
	}
}

func withDeleteRetry(ctx context.Context, fn func() error) error {
	var lastErr error
	for attempt := 1; attempt <= deleteRetryLimit; attempt++ {
		if err := ctx.Err(); err != nil {
			return err
		}
		lastErr = fn()
		if lastErr == nil || isNotFoundErr(lastErr) || !isRetryableDeleteErr(lastErr) {
			return lastErr
		}
		time.Sleep(time.Duration(attempt) * 200 * time.Millisecond)
	}
	return lastErr
}

// RetryDelete retries transient AWS delete throttling and dependency races.
func RetryDelete(ctx context.Context, fn func() error) error {
	return withDeleteRetry(ctx, fn)
}

// notFoundCodes lists AWS API error codes that indicate a resource is already deleted.
var notFoundCodes = map[string]bool{
	"NotFound":                  true,
	"NoSuchBucket":              true,
	"NoSuchKey":                 true,
	"ResourceNotFoundException": true,
	"QueueDoesNotExist":         true,
	"AWS.SimpleQueueService.NonExistentQueue": true,
	"InvalidGroup.NotFound":                   true,
	"InvalidNetworkInterfaceID.NotFound":      true,
	"InvalidInstanceID.NotFound":              true,
	"InvalidVolume.NotFound":                  true,
	"InvalidSnapshot.NotFound":                true,
	"InvalidVpcID.NotFound":                   true,
	"InvalidSubnetID.NotFound":                true,
	"InvalidRouteTableID.NotFound":            true,
	"InvalidInternetGatewayID.NotFound":       true,
	"NatGatewayNotFound":                      true,
	"InvalidAllocationID.NotFound":            true,
	"InvalidVpcEndpoint.NotFound":             true,
	"InvalidVpcEndpointId.NotFound":           true,
	"InvalidVpcEndpointID.NotFound":           true,
	"InvalidVpcEndpointServiceId.NotFound":    true,
	"InvalidVpcEndpointServiceID.NotFound":    true,
	"InvalidVpcEndpointService.NotFound":      true,
	"InvalidLaunchTemplateId.NotFound":        true,
	"InvalidPlacementGroup.Unknown":           true,
	"NoSuchEntity":                            true,
	"NoSuchHostedZone":                        true,
	"LoadBalancerNotFound":                    true,
	"TargetGroupNotFound":                     true,
}

var throttleCodes = map[string]bool{
	"Throttling":               true,
	"ThrottlingException":      true,
	"TooManyRequestsException": true,
	"RequestLimitExceeded":     true,
	"RequestThrottled":         true,
	"PriorRequestNotComplete":  true,
	"TooManyRequests":          true,
	"SlowDown":                 true,
}

var dependencyRetryCodes = map[string]bool{
	"DeleteConflict":         true,
	"DependencyViolation":    true,
	"ResourceInUse":          true,
	"ResourceInUseException": true,
	"HostedZoneNotEmpty":     true,
}

var authorizationCodes = map[string]bool{
	"AccessDenied":                true,
	"AccessDeniedException":       true,
	"UnauthorizedOperation":       true,
	"AuthorizationError":          true,
	"NotAuthorized":               true,
	"NotAuthorizedException":      true,
	"UnrecognizedClientException": true,
}

// isNotFoundErr checks if the error indicates the resource was already deleted.
func isNotFoundErr(err error) bool {
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		return notFoundCodes[apiErr.ErrorCode()]
	}
	msg := err.Error()
	for code := range notFoundCodes {
		if strings.Contains(msg, code) {
			return true
		}
	}
	return false
}

func isAuthorizationErr(err error) bool {
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		return authorizationCodes[apiErr.ErrorCode()]
	}
	msg := err.Error()
	for code := range authorizationCodes {
		if strings.Contains(msg, code) {
			return true
		}
	}
	return false
}

func isThrottleErr(err error) bool {
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		return throttleCodes[apiErr.ErrorCode()]
	}
	msg := err.Error()
	for code := range throttleCodes {
		if strings.Contains(msg, code) {
			return true
		}
	}
	return false
}

func isRetryableDeleteErr(err error) bool {
	if isThrottleErr(err) {
		return true
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		return dependencyRetryCodes[apiErr.ErrorCode()]
	}
	msg := err.Error()
	for code := range dependencyRetryCodes {
		if strings.Contains(msg, code) {
			return true
		}
	}
	return false
}

// sqsURLFromARN converts an SQS ARN to a queue URL.
func sqsURLFromARN(arn string) (string, error) {
	parts := strings.SplitN(arn, ":", 6)
	if len(parts) < 6 {
		return "", fmt.Errorf("invalid SQS ARN %q", arn)
	}
	region := parts[3]
	account := parts[4]
	queue := parts[5]
	return fmt.Sprintf("https://sqs.%s.amazonaws.com/%s/%s", region, account, queue), nil
}

func s3BucketFromARN(arn string) string {
	parts := strings.SplitN(arn, ":", 7)
	if len(parts) < 6 {
		return ""
	}
	return parts[len(parts)-1]
}

func dynamoDBTableFromARN(arn string) string {
	parts := strings.SplitN(arn, ":", 6)
	if len(parts) < 6 {
		return ""
	}
	resource := parts[5]
	if _, after, ok := strings.Cut(resource, "/"); ok {
		return after
	}
	return ""
}

func eksClusterNameFromARN(arn string) string {
	return arnResourceTail(arn, "cluster/")
}

func eksNodegroupNamesFromARN(arn string) (clusterName, nodegroupName string) {
	resource := arnResourceTail(arn, "")
	if !strings.HasPrefix(resource, "nodegroup/") {
		return "", ""
	}
	parts := strings.Split(resource, "/")
	if len(parts) < 3 {
		return "", ""
	}
	return parts[1], parts[2]
}

func ec2IDFromARN(arn, prefix string) string {
	return arnResourceTail(arn, prefix)
}

func iamNameFromARN(arn, prefix string) string {
	resource := arnResourceTail(arn, prefix)
	if resource == "" {
		return ""
	}
	parts := strings.Split(resource, "/")
	return parts[len(parts)-1]
}

func route53HostedZoneIDFromARN(arn string) string {
	return arnResourceTail(arn, "hostedzone/")
}

func arnResourceTail(arn, prefix string) string {
	parts := strings.SplitN(arn, ":", 6)
	if len(parts) < 6 {
		return ""
	}
	resource := parts[5]
	if prefix == "" {
		return resource
	}
	if !strings.HasPrefix(resource, prefix) {
		return ""
	}
	return strings.TrimPrefix(resource, prefix)
}
