package cmd

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/json"
	"errors"
	"fmt"
	"maps"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	awssdk "github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
	"github.com/aws/aws-sdk-go-v2/service/acmpca"
	acmpcatypes "github.com/aws/aws-sdk-go-v2/service/acmpca/types"
	"github.com/aws/aws-sdk-go-v2/service/autoscaling"
	autoscalingtypes "github.com/aws/aws-sdk-go-v2/service/autoscaling/types"
	"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/elasticloadbalancing"
	"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"
	iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
	"github.com/aws/aws-sdk-go-v2/service/kafka"
	"github.com/aws/aws-sdk-go-v2/service/kms"
	kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types"
	"github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
	"github.com/aws/aws-sdk-go-v2/service/route53"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
	"github.com/aws/aws-sdk-go-v2/service/sts"
	"github.com/aws/smithy-go"
	"github.com/innerr/ticat/pkg/core/model"
	"gopkg.in/yaml.v3"
	appsv1 "k8s.io/api/apps/v1"

	cpaws "github.com/tidbcloud/controlplane9/internal/aws"
	"github.com/tidbcloud/controlplane9/internal/byoc"
	"github.com/tidbcloud/controlplane9/internal/cluster"
	"github.com/tidbcloud/controlplane9/internal/config"
	"github.com/tidbcloud/controlplane9/internal/crd"
	"github.com/tidbcloud/controlplane9/internal/db"
	"github.com/tidbcloud/controlplane9/internal/dbaasui"
	"github.com/tidbcloud/controlplane9/internal/dedicatedapi"
	cp9errors "github.com/tidbcloud/controlplane9/internal/errors"
	"github.com/tidbcloud/controlplane9/internal/host"
	localk3d "github.com/tidbcloud/controlplane9/internal/host/local/k3d"
	remoteec2 "github.com/tidbcloud/controlplane9/internal/host/remote/ec2k3d"
	remotegce "github.com/tidbcloud/controlplane9/internal/host/remote/gcek3d"
	"github.com/tidbcloud/controlplane9/internal/host/remoteimage"
	"github.com/tidbcloud/controlplane9/internal/lifecycle"
	"github.com/tidbcloud/controlplane9/internal/persona"
	"github.com/tidbcloud/controlplane9/internal/pressure"
	"github.com/tidbcloud/controlplane9/internal/proof"
	"github.com/tidbcloud/controlplane9/internal/sourcebacked"
	cpterraform "github.com/tidbcloud/controlplane9/internal/terraform"
	cptilt "github.com/tidbcloud/controlplane9/internal/tilt"
	"github.com/tidbcloud/controlplane9/internal/tunnel"
	"github.com/tidbcloud/controlplane9/internal/verbose"
)

var localCleanupBuilder = buildLocalCleanup
var destroyOwnerResolver = resolveInvokerOwner
var destroyV1Alpha2OwnerResolver = resolveV1Alpha2DestroyOwner
var (
	makeV1Alpha2ScopedReaper           = newV1Alpha2ScopedReaper
	makeV1Alpha2SessionCollisionReaper = newV1Alpha2SessionCollisionReaper
)
var destroyLoadAWSConfig = cpaws.LoadAWSConfig
var destroyNewSTSClient = newSTSFromConfig
var engineFactory = cluster.NewEngineWithOptions
var v1alpha2LifecycleBuilder = buildV1Alpha2Lifecycle
var v1Alpha2UpLifecycleRunner = runEngineForWatchedUp
var starterUpActivationRunner = runStarterActivateAfterBaseline

const eagerActivationWaitTimeout = 60 * time.Minute

type starterEagerActivationStep struct {
	ConfigPath    string
	Enabled       bool
	RunActivation func(context.Context, string) (*starterStatusResult, error)
	Result        *starterStatusResult
}

func (s *starterEagerActivationStep) Name() cluster.StepName {
	return cluster.StepStarterActivation
}

func (s *starterEagerActivationStep) Run(ctx context.Context) error {
	if !s.Enabled {
		return nil
	}
	run := s.RunActivation
	if run == nil {
		run = starterUpActivationRunner
	}
	result, err := run(ctx, s.ConfigPath)
	s.Result = result
	return err
}

type essentialV2EagerActivationStep struct {
	StateDir   string
	ConfigPath string
	Enabled    bool
	Result     *essentialV2StatusResult
}

func (s *essentialV2EagerActivationStep) Name() cluster.StepName {
	return cluster.StepEssentialV2Activation
}

func (s *essentialV2EagerActivationStep) Run(ctx context.Context) error {
	if !s.Enabled {
		return nil
	}
	if err := lifecycle.WaitForTiltResources(
		ctx,
		s.StateDir,
		eagerActivationWaitTimeout,
		"premium-metadb",
		"dataplane-manager",
		"cluster-ng-server",
		"cluster-ng-executor",
		"nextgen-global-service",
		"tidb-mgmt-service",
	); err != nil {
		return fmt.Errorf("wait for Essential V2 baseline: %w", err)
	}
	result, err := runEssentialV2Activate(ctx, s.ConfigPath)
	s.Result = result
	return err
}

type byocEagerActivationStep struct {
	StateDir   string
	ConfigPath string
	Enabled    bool
	Result     *byocActivateResult
}

func (s *byocEagerActivationStep) Name() cluster.StepName {
	return cluster.StepBYOCActivation
}

func (s *byocEagerActivationStep) Run(ctx context.Context) error {
	if !s.Enabled {
		return nil
	}
	if err := lifecycle.WaitForTiltResources(
		ctx,
		s.StateDir,
		eagerActivationWaitTimeout,
		"premium-metadb",
		"dataplane-manager",
		"kong-ingress-controller",
	); err != nil {
		return fmt.Errorf("wait for BYOC baseline: %w", err)
	}
	result, err := runBYOCActivate(ctx, s.ConfigPath)
	s.Result = result
	return err
}

var destroyTiltDown = func(ctx context.Context, req *cptilt.Request) error {
	return cptilt.NewCLI().Down(ctx, req)
}
var destroyStarterDashboardForward = stopStarterDashboardAccess
var stopPersistedTiltProcess = cptilt.StopPersistedProcess
var destroySelectedHost = defaultDestroySelectedHost
var destroyStopSelectedHostRuntime = defaultStopSelectedHostRuntime
var destroyObserveRemoteRuntimeHostGone = byoc.ObserveRemoteRuntimeHostGoneForDestroy
var destroyAWSProfileCleanup = cleanupOwnedAWSProfile
var destroyTerraformDestroy = func(ctx context.Context, paths *cpterraform.Paths) error {
	return (cpterraform.Runner{}).Destroy(ctx, paths)
}
var destroyV1Alpha2CloudCleanup = runV1Alpha2AWSNukeCleanup
var destroyCloudCleanupWorkflowDispatch = dispatchPreparedDestroyCloudCleanupWorkflow
var destroyCloudCleanupWorkflowReconcile = reconcileDestroyCloudCleanupWorkflow
var destroyCloudCleanupWorkflowRunLookup = lookupDestroyCloudCleanupWorkflowRun
var v1Alpha2UpDestroyWorkflowGate = guardV1Alpha2UpAgainstActiveDestroy
var destroyCleanupWorkflowDispatchTimeout = 15 * time.Second
var destroyCleanupWorkflowRunLookupTimeout = 15 * time.Second
var destroyCleanupWorkflowObserveInterval = 250 * time.Millisecond
var destroyNukeRunner awsNukeRunner = cpaws.NukeRunner{}
var destroyNukeCredentialMaterializer = materializeDestroyNukeCredentials
var destroyNukeConfigWithAssumeRole = destroyConfigWithAssumeRole
var destroyNukeConfigWithTaggedAssumeRole = cpaws.ConfigWithAssumeRole
var destroyPrepareNukeLoadBalancers = prepareNukeLoadBalancers
var destroyPrepareNukeVPCChildren = prepareNukeVPCChildren
var destroyPrepareNukeEKSResources = prepareNukeEKSResources
var destroyProgressHeartbeatInterval = 30 * time.Second
var destroyBYOC = runBYOCDestroy
var destroyRemoveSessionRoot = os.RemoveAll
var statusTiltResourceReadiness = cptilt.NewCLI().ResourceReadiness
var statusTiltReadinessTimeout = 5 * time.Second
var statusRemoteTiltReadinessTimeout = 30 * time.Second

var destroyEKSDeletePollInterval = 15 * time.Second
var destroyEKSDeleteTimeout = 20 * time.Minute
var destroyNukeDeleteMaxAttempts = 3
var destroyAWSHTTPTimeout = 60 * time.Second
var destroyNewAWSHTTPClient = func() awssdk.HTTPClient {
	transport := verbose.DefaultTransport()
	if base, ok := transport.(*http.Transport); ok {
		dialer := &net.Dialer{
			Timeout:   destroyAWSHTTPTimeout,
			KeepAlive: 30 * time.Second,
			Resolver:  &net.Resolver{PreferGo: true},
		}
		base.DialContext = dialer.DialContext
		base.TLSHandshakeTimeout = destroyAWSHTTPTimeout
		base.ResponseHeaderTimeout = destroyAWSHTTPTimeout
		transport = base
	}
	return &http.Client{
		Timeout:   destroyAWSHTTPTimeout,
		Transport: verbose.WrapTransport(transport),
	}
}
var destroyNewELBV2Client = func(cfg awssdk.Config) destroyELBV2LoadBalancerAPI {
	return elasticloadbalancingv2.NewFromConfig(cfg)
}
var destroyNewClassicELBClient = func(cfg awssdk.Config) destroyClassicELBLoadBalancerAPI {
	return elasticloadbalancing.NewFromConfig(cfg)
}
var destroyNewEC2Client = func(cfg awssdk.Config) destroyEC2VPCChildAPI {
	return ec2.NewFromConfig(cfg)
}
var destroyNewAutoScalingClient = func(cfg awssdk.Config) destroyAutoScalingVPCChildAPI {
	return autoscaling.NewFromConfig(cfg)
}
var destroyNewEKSClient = func(cfg awssdk.Config) destroyEKSResourceAPI {
	return eks.NewFromConfig(cfg)
}
var destroyNewIAMProfileClient = func(cfg awssdk.Config) destroyIAMProfileAPI {
	return iam.NewFromConfig(cfg)
}
var destroyNewIAMPostNukeVerifyClient = func(cfg awssdk.Config) destroyIAMPostNukeVerifyAPI {
	return iam.NewFromConfig(cfg)
}
var destroyNewEC2PostNukeVerifyClient = func(cfg awssdk.Config) destroyEC2PostNukeVerifyAPI {
	return ec2.NewFromConfig(cfg)
}
var destroyNewRoute53Client = func(cfg awssdk.Config) destroyRoute53RecordSetAPI {
	return route53.NewFromConfig(cfg)
}
var destroyNewS3Client = func(cfg awssdk.Config) destroyS3BucketVerifyAPI {
	return s3.NewFromConfig(cfg)
}
var destroyNewSecretsManagerClient = func(cfg awssdk.Config) destroySecretsManagerSecretVerifyAPI {
	return secretsmanager.NewFromConfig(cfg)
}

type awsNukeRunner interface {
	Run(ctx context.Context, req *cpaws.NukeRunRequest) ([]byte, error)
}

type destroyIAMProfileAPI interface {
	ListInstanceProfiles(
		ctx context.Context,
		params *iam.ListInstanceProfilesInput,
		optFns ...func(*iam.Options),
	) (*iam.ListInstanceProfilesOutput, error)
	ListAttachedRolePolicies(
		ctx context.Context,
		params *iam.ListAttachedRolePoliciesInput,
		optFns ...func(*iam.Options),
	) (*iam.ListAttachedRolePoliciesOutput, error)
}

type destroyIAMPostNukeVerifyAPI interface {
	GetRole(
		ctx context.Context,
		params *iam.GetRoleInput,
		optFns ...func(*iam.Options),
	) (*iam.GetRoleOutput, error)
	GetInstanceProfile(
		ctx context.Context,
		params *iam.GetInstanceProfileInput,
		optFns ...func(*iam.Options),
	) (*iam.GetInstanceProfileOutput, error)
	GetPolicy(
		ctx context.Context,
		params *iam.GetPolicyInput,
		optFns ...func(*iam.Options),
	) (*iam.GetPolicyOutput, error)
}

func withDestroyAWSHTTPClient(cfg *awssdk.Config) awssdk.Config {
	if destroyAWSHTTPTimeout > 0 {
		cfg.HTTPClient = destroyNewAWSHTTPClient()
	}
	return *cfg
}

func destroyStaticAWSConfig(region, accessKeyID, secretAccessKey, sessionToken string) awssdk.Config {
	cfg := awssdk.Config{
		Region: region,
		Credentials: credentials.NewStaticCredentialsProvider(
			accessKeyID,
			secretAccessKey,
			sessionToken,
		),
	}
	cpaws.ApplyCleanupRetryPolicy(&cfg)
	return withDestroyAWSHTTPClient(&cfg)
}

type destroyELBV2LoadBalancerAPI interface {
	DescribeLoadBalancers(
		ctx context.Context,
		params *elasticloadbalancingv2.DescribeLoadBalancersInput,
		optFns ...func(*elasticloadbalancingv2.Options),
	) (*elasticloadbalancingv2.DescribeLoadBalancersOutput, error)
	DescribeTargetGroups(
		ctx context.Context,
		params *elasticloadbalancingv2.DescribeTargetGroupsInput,
		optFns ...func(*elasticloadbalancingv2.Options),
	) (*elasticloadbalancingv2.DescribeTargetGroupsOutput, error)
	DescribeLoadBalancerAttributes(
		ctx context.Context,
		params *elasticloadbalancingv2.DescribeLoadBalancerAttributesInput,
		optFns ...func(*elasticloadbalancingv2.Options),
	) (*elasticloadbalancingv2.DescribeLoadBalancerAttributesOutput, error)
	ModifyLoadBalancerAttributes(
		ctx context.Context,
		params *elasticloadbalancingv2.ModifyLoadBalancerAttributesInput,
		optFns ...func(*elasticloadbalancingv2.Options),
	) (*elasticloadbalancingv2.ModifyLoadBalancerAttributesOutput, error)
}

type destroyClassicELBLoadBalancerAPI interface {
	DescribeLoadBalancers(
		ctx context.Context,
		params *elasticloadbalancing.DescribeLoadBalancersInput,
		optFns ...func(*elasticloadbalancing.Options),
	) (*elasticloadbalancing.DescribeLoadBalancersOutput, error)
}

type destroyEC2VPCChildAPI interface {
	DescribeVpcs(ctx context.Context, params *ec2.DescribeVpcsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error)
	DescribeInternetGateways(
		ctx context.Context,
		params *ec2.DescribeInternetGatewaysInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeInternetGatewaysOutput, error)
	DescribeInstances(
		ctx context.Context,
		params *ec2.DescribeInstancesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeInstancesOutput, error)
	DescribeNatGateways(
		ctx context.Context,
		params *ec2.DescribeNatGatewaysInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeNatGatewaysOutput, error)
	DescribeNetworkInterfaces(
		ctx context.Context,
		params *ec2.DescribeNetworkInterfacesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeNetworkInterfacesOutput, error)
	DescribeRouteTables(
		ctx context.Context,
		params *ec2.DescribeRouteTablesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeRouteTablesOutput, error)
	DescribeSecurityGroups(
		ctx context.Context,
		params *ec2.DescribeSecurityGroupsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeSecurityGroupsOutput, error)
	DescribeSubnets(ctx context.Context, params *ec2.DescribeSubnetsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error)
	DescribeVpcEndpoints(
		ctx context.Context,
		params *ec2.DescribeVpcEndpointsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeVpcEndpointsOutput, error)
	CreateTags(ctx context.Context, params *ec2.CreateTagsInput, optFns ...func(*ec2.Options)) (*ec2.CreateTagsOutput, error)
	DetachInternetGateway(
		ctx context.Context,
		params *ec2.DetachInternetGatewayInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DetachInternetGatewayOutput, error)
}

type destroyAutoScalingVPCChildAPI interface {
	DescribeAutoScalingGroups(
		ctx context.Context,
		params *autoscaling.DescribeAutoScalingGroupsInput,
		optFns ...func(*autoscaling.Options),
	) (*autoscaling.DescribeAutoScalingGroupsOutput, error)
	CreateOrUpdateTags(
		ctx context.Context,
		params *autoscaling.CreateOrUpdateTagsInput,
		optFns ...func(*autoscaling.Options),
	) (*autoscaling.CreateOrUpdateTagsOutput, error)
}

type destroyEC2VPCRootCleanupAPI interface {
	destroyEC2VPCChildAPI
	DeleteInternetGateway(
		ctx context.Context,
		params *ec2.DeleteInternetGatewayInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DeleteInternetGatewayOutput, error)
	DeleteVpc(ctx context.Context, params *ec2.DeleteVpcInput, optFns ...func(*ec2.Options)) (*ec2.DeleteVpcOutput, error)
}

type destroyEC2PostNukeVerifyAPI interface {
	DescribeAddresses(
		ctx context.Context,
		params *ec2.DescribeAddressesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeAddressesOutput, error)
	DescribeInstances(
		ctx context.Context,
		params *ec2.DescribeInstancesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeInstancesOutput, error)
	DescribeInstanceConnectEndpoints(
		ctx context.Context,
		params *ec2.DescribeInstanceConnectEndpointsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeInstanceConnectEndpointsOutput, error)
	DescribeKeyPairs(
		ctx context.Context,
		params *ec2.DescribeKeyPairsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeKeyPairsOutput, error)
	DescribeInternetGateways(
		ctx context.Context,
		params *ec2.DescribeInternetGatewaysInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeInternetGatewaysOutput, error)
	DescribeNatGateways(
		ctx context.Context,
		params *ec2.DescribeNatGatewaysInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeNatGatewaysOutput, error)
	DescribeNetworkInterfaces(
		ctx context.Context,
		params *ec2.DescribeNetworkInterfacesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeNetworkInterfacesOutput, error)
	DescribeRouteTables(
		ctx context.Context,
		params *ec2.DescribeRouteTablesInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeRouteTablesOutput, error)
	DescribeSecurityGroups(
		ctx context.Context,
		params *ec2.DescribeSecurityGroupsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeSecurityGroupsOutput, error)
	DescribeSubnets(ctx context.Context, params *ec2.DescribeSubnetsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error)
	DescribeVpcEndpoints(
		ctx context.Context,
		params *ec2.DescribeVpcEndpointsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeVpcEndpointsOutput, error)
	DescribeVpcEndpointServiceConfigurations(
		ctx context.Context,
		params *ec2.DescribeVpcEndpointServiceConfigurationsInput,
		optFns ...func(*ec2.Options),
	) (*ec2.DescribeVpcEndpointServiceConfigurationsOutput, error)
	DescribeVolumes(ctx context.Context, params *ec2.DescribeVolumesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error)
	DescribeVpcs(ctx context.Context, params *ec2.DescribeVpcsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error)
}

type destroyRoute53RecordSetAPI interface {
	GetHostedZone(
		ctx context.Context,
		params *route53.GetHostedZoneInput,
		optFns ...func(*route53.Options),
	) (*route53.GetHostedZoneOutput, error)
	ListResourceRecordSets(
		ctx context.Context,
		params *route53.ListResourceRecordSetsInput,
		optFns ...func(*route53.Options),
	) (*route53.ListResourceRecordSetsOutput, error)
}

type destroyS3BucketVerifyAPI interface {
	HeadBucket(
		ctx context.Context,
		params *s3.HeadBucketInput,
		optFns ...func(*s3.Options),
	) (*s3.HeadBucketOutput, error)
}

type destroySecretsManagerSecretVerifyAPI interface {
	DescribeSecret(
		ctx context.Context,
		params *secretsmanager.DescribeSecretInput,
		optFns ...func(*secretsmanager.Options),
	) (*secretsmanager.DescribeSecretOutput, error)
}

type destroyEKSResourceAPI interface {
	ListTagsForResource(
		ctx context.Context,
		params *eks.ListTagsForResourceInput,
		optFns ...func(*eks.Options),
	) (*eks.ListTagsForResourceOutput, error)
	DescribeCluster(
		ctx context.Context,
		params *eks.DescribeClusterInput,
		optFns ...func(*eks.Options),
	) (*eks.DescribeClusterOutput, error)
	DescribeNodegroup(
		ctx context.Context,
		params *eks.DescribeNodegroupInput,
		optFns ...func(*eks.Options),
	) (*eks.DescribeNodegroupOutput, error)
	ListNodegroups(
		ctx context.Context,
		params *eks.ListNodegroupsInput,
		optFns ...func(*eks.Options),
	) (*eks.ListNodegroupsOutput, error)
	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)
	TagResource(
		ctx context.Context,
		params *eks.TagResourceInput,
		optFns ...func(*eks.Options),
	) (*eks.TagResourceOutput, error)
}

type destroyNukeEvidence struct {
	ConfigPath            string
	DryRunLog             string
	DeleteLog             string
	DirectZeroSummaryPath string
	PreparedLoadBalancers []string
	PreparedVPCChildren   []string
	PreparedEKSResources  []string
	DirectS3Buckets       []string
	DirectIAMResources    []string
	DirectSecretsManager  []string
	DirectLaunchTemplates []string
	StaleTagWarnings      []destroyStaleTagWarning `json:"stale_tag_warnings,omitempty"`
	OwnerDriftScan        string
}

type destroyReapStateSource string

const (
	destroyReapStateSourceSession   destroyReapStateSource = "session"
	destroyReapStateSourcePersisted destroyReapStateSource = "persisted"
	destroyReapStateSourceConfig    destroyReapStateSource = "config"
)

func upCmd() model.NormalCmd {
	return func(args model.ArgVals, cc *model.Cli, env *model.Env, _ []model.ParsedCmd) error {
		syncVerbose(env)

		cfgPath := args.GetRaw("config")
		localPath, localProvided := explicitLocalPath(args)
		dryRun := args.GetRaw("dry-run") == "true"
		target, err := resolveStatusWatchTarget(cfgPath, localPath, localProvided)
		if err != nil {
			return err
		}
		if len(target.SessionName) > 15 {
			return cp9errors.Newf(
				cp9errors.CategoryConfig,
				"session name %q must be at most 15 characters for cp9 up",
				target.SessionName,
			)
		}
		if target.Version == config.APIVersionV1Alpha2 {
			legacyStarterPreProvisionProvided := false
			if arg, ok := args["starter-pre-provision"]; ok {
				legacyStarterPreProvisionProvided = arg.Provided
			}
			runUp := func() error {
				return runV1Alpha2Up(
					cfgPath,
					localPath,
					localProvided,
					dryRun,
					legacyStarterPreProvisionProvided,
					cc,
					env,
				)
			}
			if dryRun {
				return runUp()
			}
			return runUpWithStatusWatch(&target, runUp, cc, env)
		}
		if dryRun {
			return cp9errors.NotImplemented("up dry-run for legacy cp9 configs")
		}
		return runUpWithStatusWatch(
			&target,
			func() error { return runLegacyUp(cfgPath, localPath) },
			cc,
			env,
		)
	}
}

func runLegacyUp(cfgPath, localPath string) error {
	cfg, local, err := config.Load(cfgPath, localPath)
	if err != nil {
		return err
	}
	localHost := config.HostConfig{}
	if local != nil {
		localHost = local.Host
	}

	selected, err := host.LoadOrResolve(cfg, local)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "load selected host: %v", err)
	}
	clusterName := selected.ClusterName
	sessionID := clusterName
	stateDir := selected.StateDir
	personaName, ok := config.ResolvePersonaName(cfg, local)
	if !ok {
		return cp9errors.New(cp9errors.CategoryConfig, "persona is required")
	}

	var (
		hostAdapter       lifecycle.ConflictChecker
		clusterCreateStep cluster.Step
	)
	switch selected.Mode {
	case config.HostModeLocalK3d:
		hostAdapter = localk3d.NewAdapter()
		clusterCreateStep = localk3d.NewClusterStep(selected, localk3d.Options{
			Agents:     cfg.Cluster.K3d.Agents,
			Memory:     cfg.Cluster.K3d.Memory,
			K3dVersion: cfg.Cluster.K3d.Version,
			K3sImage:   cfg.Cluster.K3d.K3sImage,
		})
	case config.HostModeRemoteEC2K3d:
		hostAdapter = remoteec2.NewAdapter()
		clusterCreateStep = remoteec2.NewClusterStep(selected, &remoteec2.Options{
			Agents:            cfg.Cluster.K3d.Agents,
			Memory:            cfg.Cluster.K3d.Memory,
			K3sImage:          cfg.Cluster.K3d.K3sImage,
			Profile:           cfg.Clouds.AWS.Profile,
			Region:            cfg.Clouds.AWS.Region,
			OwnerAllowlist:    cfg.CI.OwnerAllowlist,
			TTL:               cfg.Clouds.AWS.TTL.Duration,
			InstanceType:      localHost.EC2.InstanceType,
			AMI:               localHost.EC2.AMI,
			KeyPair:           localHost.EC2.KeyPair,
			SSHUser:           localHost.EC2.SSHUser,
			SSHPrivateKey:     localHost.EC2.SSHPrivateKey,
			SubnetID:          localHost.EC2.SubnetID,
			SecurityGroupIDs:  localHost.EC2.SecurityGroupIDs,
			InstanceProfile:   localHost.EC2.InstanceProfile,
			AllowedCIDR:       localHost.EC2.AllowedCIDR,
			RootVolumeSizeGiB: localHost.EC2.RootVolumeSizeGiB,
		})
	default:
		return cp9errors.NotImplemented("host mode " + string(selected.Mode))
	}

	steps := cluster.DefaultSteps()
	steps[cluster.StepPreflight] = &lifecycle.PreflightStep{
		Selected:        selected,
		PersonaName:     personaName,
		TunnelMechanism: cfg.Tunnel.Mechanism,
		ConflictChecker: hostAdapter,
	}
	steps[cluster.StepPersonaLoad] = &lifecycle.PersonaLoadStep{
		StateDir:      stateDir,
		PersonaName:   personaName,
		TopologiesDir: persona.DefaultTopologiesDir,
		Local:         local,
	}
	steps[cluster.StepClusterCreate] = clusterCreateStep
	steps[cluster.StepTunnelUp] = &tunnel.UpStep{
		StateDir:       stateDir,
		Config:         cfg.Tunnel,
		Profile:        cfg.Clouds.AWS.Profile,
		Region:         cfg.Clouds.AWS.Region,
		OwnerAllowlist: cfg.CI.OwnerAllowlist,
	}
	steps[cluster.StepCrossplaneInstall] = &cpaws.CrossplaneInstallStep{Selected: selected}
	steps[cluster.StepCredWire] = &cpaws.InjectStep{
		Profile:          cfg.Clouds.AWS.Profile,
		Region:           cfg.Clouds.AWS.Region,
		SessionID:        sessionID,
		StateDir:         stateDir,
		Selected:         selected,
		AccountAllowlist: cfg.Clouds.AWS.AccountAllowlist,
		OwnerAllowlist:   cfg.CI.OwnerAllowlist,
	}
	steps[cluster.StepCPCloudReady] = &lifecycle.CPCloudReadyStep{StateDir: stateDir}
	steps[cluster.StepPersonaSourcesReady] = &lifecycle.PersonaSourcesReadyStep{StateDir: stateDir}
	steps[cluster.StepCRDInstall] = buildCRDStep(cfg, selected)
	steps[cluster.StepSecretsInject] = buildSecretsStep(local, selected)
	steps[cluster.StepMetadbPlan] = &lifecycle.MetadbPlanStep{
		StateDir: stateDir,
		Config:   cfg,
	}
	steps[cluster.StepBuildReal] = &lifecycle.BuildRealStep{
		StateDir: stateDir,
		Selected: selected,
	}
	steps[cluster.StepGenStubs] = &lifecycle.GenStubsStep{
		StateDir: stateDir,
		Selected: selected,
	}
	steps[cluster.StepConfigGen] = &lifecycle.ConfigGenStep{
		StateDir: stateDir,
		Config:   cfg,
	}
	steps[cluster.StepMetadbSeed] = &lifecycle.MetadbSeedStep{
		StateDir:   stateDir,
		SeedConfig: cfg.Metadb.Seed,
	}
	steps[cluster.StepDeploy] = &lifecycle.DeployStep{
		StateDir: stateDir,
		Selected: selected,
	}
	steps[cluster.StepReadiness] = &lifecycle.ReadinessStep{
		StateDir: stateDir,
		Selected: selected,
		Config:   cfg,
	}
	steps[cluster.StepPersonaReady] = &lifecycle.PersonaReadyStep{StateDir: stateDir}
	engine, err := engineFactory(stateDir, clusterName, sessionID, steps, cluster.EngineOptions{
		Retry: lifecycleRetryPolicy(cfg),
	})
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "initialize engine: %v", err)
	}

	return runEngineForWatchedUp(engine)
}

func runV1Alpha2Up(
	cfgPath, localPath string,
	localProvided, dryRun, legacyStarterPreProvisionProvided bool,
	cc *model.Cli,
	env *model.Env,
) error {
	if err := rejectV1Alpha2LocalConfigOverride(localPath, localProvided); err != nil {
		return err
	}
	if legacyStarterPreProvisionProvided {
		return cp9errors.New(cp9errors.CategoryConfig,
			"up starter-pre-provision is retired; use starter.enabled: true for eager full activation, or run cp9 starter activate after cp9 up")
	}

	session, err := config.LoadSession(cfgPath)
	if err != nil {
		return err
	}
	resolved, err := config.ResolveSession(session, cfgPath)
	if err != nil {
		return err
	}
	buildLifecycle := func() (*host.Session, map[cluster.StepName]cluster.Step, error) {
		selected, steps, buildErr := v1alpha2LifecycleBuilder(resolved)
		if buildErr == nil {
			if sessionReady, ok := steps[cluster.StepSessionReady].(*lifecycle.SessionReadyStep); ok {
				sessionReady.ConfigPath = cfgPath
			}
		}
		return selected, steps, buildErr
	}
	if dryRun {
		selected, steps, err := buildLifecycle()
		if err != nil {
			return err
		}
		preflight, err := runV1Alpha2ReadOnlyPreflight(context.Background(), resolved)
		if err != nil {
			if preflight != nil {
				result, resultErr := buildV1Alpha2UpDryRunResult(context.Background(), resolved, selected, steps, preflight)
				if resultErr != nil {
					return cp9errors.Newf(cp9errors.CategoryConfig, "up dry-run: %v", resultErr)
				}
				if outputErr := model.Output(cc, env, result); outputErr != nil {
					return outputErr
				}
			}
			category := cp9errors.CategoryResource
			if cpaws.IsContractError(err) {
				category = cp9errors.CategoryConfig
			}
			return cp9errors.Newf(category, "up dry-run: %v", err)
		}
		result, err := buildV1Alpha2UpDryRunResult(context.Background(), resolved, selected, steps, preflight)
		if err != nil {
			return cp9errors.Newf(cp9errors.CategoryConfig, "up dry-run: %v", err)
		}
		return model.Output(cc, env, result)
	}
	releaseOperationLock, err := acquireV1Alpha2SessionOperationLock(cluster.DefaultStateDir, resolved.Name)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "up: %v", err)
	}
	defer releaseOperationLock()
	collision, err := runV1Alpha2UpSessionCollisionPreflight(context.Background(), resolved)
	if err != nil {
		if collision != nil {
			if outputErr := model.Output(cc, env, collision); outputErr != nil {
				return outputErr
			}
		}
		return cp9errors.Newf(cp9errors.CategoryResource, "up: %v", err)
	}
	lifecycleLock, err := acquireSessionLifecycleLock(context.Background(), resolved.Name)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "acquire session lifecycle lock: %v", err)
	}
	defer lifecycleLock.Release()
	if err := rejectPendingV1Alpha2DestroyCleanup(resolved.Name); err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "up: %v", err)
	}
	selected, steps, err := buildLifecycle()
	if err != nil {
		return err
	}
	if err := v1Alpha2UpDestroyWorkflowGate(context.Background(), resolved.Name, selected.StateDir, selected); err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "%v", err)
	}
	if err := reconcileV1Alpha2CompletedIAMReadyProfile(resolved, selected.StateDir); err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "up: %v", err)
	}
	var dedicatedPortReservation *dedicatedapi.PortReservation
	if selected.DedicatedAPI != nil {
		if _, statErr := os.Stat(filepath.Join(selected.StateDir, "host.json")); os.IsNotExist(statErr) {
			dedicatedPortReservation, err = dedicatedapi.ReservePreparedPort(selected.DedicatedAPI.HostPort)
			if err != nil {
				return cp9errors.Newf(cp9errors.CategoryResource, "%v", err)
			}
			defer func() { _ = dedicatedPortReservation.Release() }()
			if named, ok := steps[cluster.StepHostReady].(*lifecycle.NamedStep); ok {
				setPrimaryDedicatedAPIPortReservation(named.Inner, dedicatedPortReservation)
			}
		} else if statErr != nil {
			return cp9errors.Newf(cp9errors.CategoryResource, "stat selected host before Dedicated API port reservation: %v", statErr)
		}
	}
	portReservation, err := prepareV1Alpha2RuntimeSession(resolved, selected.StateDir)
	if err != nil {
		return err
	}
	if portReservation != nil {
		defer func() { _ = portReservation.Release() }()
		if tiltReady, ok := steps[cluster.StepTiltReady].(*lifecycle.TiltReadyStep); ok {
			tiltReady.PortReservation = portReservation
		}
	}
	starterActivation := &starterEagerActivationStep{
		ConfigPath: cfgPath,
		Enabled:    config.StarterActivationRequested(resolved),
	}
	essentialV2Activation := &essentialV2EagerActivationStep{
		StateDir:   selected.StateDir,
		ConfigPath: cfgPath,
		Enabled:    config.EssentialV2Enabled(resolved),
	}
	byocActivation := &byocEagerActivationStep{
		StateDir:   selected.StateDir,
		ConfigPath: cfgPath,
		Enabled:    config.BYOCEnabled(resolved),
	}
	steps[cluster.StepStarterActivation] = starterActivation
	steps[cluster.StepEssentialV2Activation] = essentialV2Activation
	steps[cluster.StepBYOCActivation] = byocActivation

	engine, err := engineFactory(selected.StateDir, resolved.Name, resolved.Name, steps, cluster.EngineOptions{
		Lifecycle: cluster.LifecycleV3,
	})
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "initialize engine: %v", err)
	}
	if config.StarterActivationRequested(resolved) {
		preApplyPlan := upDryRunStarterActivationPlan(resolved)
		if err := emitStarterActivationPreApplyPlan(selected.StateDir, preApplyPlan); err != nil {
			return err
		}
	}
	runErr := v1Alpha2UpLifecycleRunner(engine)
	if starterActivation.Result != nil {
		if outputErr := model.Output(cc, env, starterActivation.Result); outputErr != nil {
			return outputErr
		}
	}
	if essentialV2Activation.Result != nil {
		if outputErr := model.Output(cc, env, essentialV2Activation.Result); outputErr != nil {
			return outputErr
		}
	}
	if byocActivation.Result != nil {
		if outputErr := model.Output(cc, env, byocActivation.Result); outputErr != nil {
			return outputErr
		}
	}
	return runErr
}

func reconcileV1Alpha2CompletedIAMReadyProfile(resolved *config.SessionConfig, stateDir string) error {
	if !config.UsesCloudSubstrate(resolved) {
		return nil
	}
	stateExists, err := cluster.StateExists(stateDir)
	if err != nil {
		return fmt.Errorf("check lifecycle state before AWS profile reconciliation: %w", err)
	}
	if !stateExists {
		return nil
	}
	state, err := cluster.LoadState(stateDir)
	if err != nil {
		return fmt.Errorf("load lifecycle state before AWS profile reconciliation: %w", err)
	}
	iamReady := state.Steps[cluster.StepIAMReady]
	if iamReady == nil || iamReady.Status != cluster.StatusDone {
		return nil
	}
	_, err = cpaws.ReconcileLocalProfileFromSessionState(
		stateDir,
		&cpaws.LocalProfileBinding{
			Name:          resolved.Target.AWS.IAM.Profile,
			SourceProfile: resolved.Target.AWS.IAM.SourceProfile,
			RoleARN:       resolved.Target.AWS.IAM.RoleARN,
			SessionName:   resolved.Name,
			Region:        resolved.Target.Region,
		},
	)
	if err != nil {
		return fmt.Errorf("restore runtime AWS profile for resumed session: %w", err)
	}
	return nil
}

type upDryRunResult struct {
	DryRun              bool                               `json:"dry_run"`
	NoMutation          bool                               `json:"no_mutation"`
	Session             string                             `json:"session"`
	Persona             string                             `json:"persona"`
	Owner               string                             `json:"owner,omitempty"`
	SessionTags         map[string]string                  `json:"session_tags,omitempty"`
	RemoteImage         *remoteimage.Plan                  `json:"remote_image,omitempty"`
	TTL                 string                             `json:"ttl,omitempty"`
	HostMode            config.HostMode                    `json:"host_mode"`
	StateDir            string                             `json:"state_dir"`
	AWS                 upDryRunAWS                        `json:"aws"`
	UserManagedEC2Key   *cpaws.UserManagedEC2KeyValidation `json:"user_managed_ec2_key,omitempty"`
	Terraform           upDryRunTerraform                  `json:"terraform"`
	Artifacts           upDryRunArtifacts                  `json:"artifacts"`
	Services            []upDryRunService                  `json:"services"`
	EssentialV2         upDryRunEssentialV2                `json:"essential_v2"`
	DedicatedGCP        *upDryRunDedicatedGCP              `json:"dedicated_gcp,omitempty"`
	SessionCollision    *cpaws.SessionCollisionInventory   `json:"session_collision,omitempty"`
	StarterActivation   *upDryRunStarterActivation         `json:"starter_activation,omitempty"`
	BYOCActivation      *upDryRunBYOCActivation            `json:"byoc_activation,omitempty"`
	RegionalSmoke       *upDryRunRegionalSmoke             `json:"regional_smoke,omitempty"`
	StarterGlobalWiring *upDryRunStarterGlobalWire         `json:"starter_global_wiring,omitempty"`
	Pressure            pressure.Report                    `json:"pressure"`
	SkippedStages       []string                           `json:"skipped_stages"`
}

type upDryRunAWS struct {
	Cloud         string `json:"cloud"`
	Region        string `json:"region"`
	Account       string `json:"account"`
	Profile       string `json:"profile,omitempty"`
	SourceProfile string `json:"source_profile,omitempty"`
	RoleARN       string `json:"role_arn,omitempty"`
}

type upDryRunBYOCActivation struct {
	Enabled                bool     `json:"enabled"`
	Trigger                string   `json:"trigger"`
	MutatesCustomerAccount bool     `json:"mutates_customer_account"`
	ReusesAccountSetup     bool     `json:"reuses_account_setup"`
	CustomerAccountID      string   `json:"customer_account_id"`
	CustomerProfile        string   `json:"customer_profile"`
	Retained               []string `json:"retained"`
}

type upDryRunEssentialV2 struct {
	Enabled                  bool     `json:"enabled"`
	EnabledTiKVRemoteWorkers []string `json:"enabled_tikv_remote_workers"`
}

type upDryRunDedicatedGCP struct {
	cluster.DedicatedGCPPlan

	Enabled         bool     `json:"enabled"`
	Apply           bool     `json:"apply"`
	RootDiskGiB     int      `json:"root_disk_gib"`
	IngressSource   string   `json:"ingress_source"`
	RuntimeIdentity string   `json:"runtime_identity"`
	Resources       []string `json:"resources"`
}

type upDryRunTerraform struct {
	Repo    string `json:"repo"`
	Ref     string `json:"ref,omitempty"`
	Path    string `json:"path"`
	VarFile string `json:"var_file"`
	WorkDir string `json:"work_dir"`
	Plan    string `json:"plan"`
}

type upDryRunArtifacts struct {
	ResolvedSession string `json:"resolved_session"`
	ContextDir      string `json:"context_dir"`
	Tiltfile        string `json:"tiltfile"`
}

type upDryRunService struct {
	Name              string `json:"name"`
	Role              string `json:"role"`
	ConfiguredPath    string `json:"configured_path,omitempty"`
	CanonicalPath     string `json:"canonical_path,omitempty"`
	GitHEAD           string `json:"git_head,omitempty"`
	Dirty             bool   `json:"dirty"`
	Image             string `json:"image,omitempty"`
	BuildMain         string `json:"build_main,omitempty"`
	TiltResource      string `json:"tilt_resource,omitempty"`
	WorkloadKind      string `json:"workload_kind,omitempty"`
	WorkloadNamespace string `json:"workload_namespace,omitempty"`
	WorkloadName      string `json:"workload_name,omitempty"`
	WorkloadContainer string `json:"workload_container,omitempty"`
}

type upDryRunStarterActivation struct {
	Enabled             bool                       `json:"enabled"`
	Trigger             string                     `json:"trigger"`
	AIFTSEnabled        bool                       `json:"ai_fts_enabled"`
	RemoteEKSCluster    string                     `json:"remote_eks_cluster"`
	SelectedServingLane string                     `json:"selected_serving_lane"`
	TiDBPoolPods        int                        `json:"tidb_pool_pods"`
	PDMode              string                     `json:"pd_mode"`
	PDWorkloads         upDryRunStarterPDWorkloads `json:"pd_workloads"`
	Topology            []upDryRunStarterNodeGroup `json:"topology"`
	Network             upDryRunStarterNetwork     `json:"network"`
	TiKVStorage         upDryRunStarterStorage     `json:"tikv_storage"`
	Cost                upDryRunStarterCost        `json:"cost"`
	Message             string                     `json:"message"`
}

type upDryRunStarterPDWorkloads struct {
	PDNodes         int    `json:"pd_nodes"`
	RootPDReplicas  int    `json:"root_pd_replicas"`
	TSOReplicas     int    `json:"tso_replicas"`
	ResourceManager int    `json:"resource_manager_replicas"`
	RequestCPU      string `json:"request_cpu"`
	RequestMemory   string `json:"request_memory"`
	LimitCPU        string `json:"limit_cpu"`
	LimitMemory     string `json:"limit_memory"`
}

type upDryRunStarterNodeGroup struct {
	Role         string `json:"role"`
	InstanceType string `json:"instance_type"`
	Nodes        int    `json:"nodes"`
	Architecture string `json:"architecture"`
	AMIType      string `json:"ami_type"`
	Tainted      bool   `json:"tainted"`
}

type upDryRunStarterNetwork struct {
	SubnetCount         int  `json:"subnet_count"`
	SubnetPrefix        int  `json:"subnet_prefix"`
	AvailablePodIPs     int  `json:"available_pod_ips"`
	RequiredPodIPs      int  `json:"required_pod_ips"`
	SafetyMarginPercent int  `json:"safety_margin_percent"`
	Sufficient          bool `json:"sufficient"`
}

type upDryRunStarterStorage struct {
	Partitions           int `json:"partitions"`
	ReplicasPerPartition int `json:"replicas_per_partition"`
	LocalGiBPerReplica   int `json:"local_gib_per_replica"`
	EBSGiBPerReplica     int `json:"ebs_gib_per_replica"`
	TotalEBSGiB          int `json:"total_ebs_gib"`
}

type upDryRunStarterCost struct {
	EstimateStatus     string   `json:"estimate_status"`
	ExpectedCostWindow string   `json:"expected_cost_window"`
	Currency           string   `json:"currency"`
	Source             string   `json:"source"`
	AsOf               string   `json:"as_of"`
	BillableResources  []string `json:"billable_resources"`
	Message            string   `json:"message"`
}

type upDryRunRegionalSmoke struct {
	Enabled          bool     `json:"enabled"`
	Runtime          string   `json:"runtime"`
	RemoteEKSCluster string   `json:"remote_eks_cluster"`
	RemoteEKSContext string   `json:"remote_eks_context"`
	O11yStub         string   `json:"o11y_stub"`
	TerraformFirst   bool     `json:"terraform_first"`
	Source           []string `json:"source"`
	Live             []string `json:"live"`
	Stub             []string `json:"stub"`
	Generated        []string `json:"generated"`
	NotNeeded        []string `json:"not_needed"`
	FailClosed       []string `json:"fail_closed"`
	Readiness        []string `json:"readiness"`
}

type upDryRunStarterGlobalWire struct {
	Enabled            bool     `json:"enabled"`
	LocalGlobalCP      []string `json:"local_global_cp"`
	RemoteRegionalCPDP []string `json:"remote_regional_cp_dp"`
	GeneratedConfig    []string `json:"generated_config"`
	Probes             []string `json:"probes"`
	Evidence           []string `json:"evidence"`
	FailClosed         []string `json:"fail_closed"`
}

func (r *upDryRunResult) FormatText() string {
	var b strings.Builder
	fmt.Fprintf(&b, "cp9 up dry-run: no mutation\n")
	fmt.Fprintf(&b, "session:   %s\n", r.Session)
	fmt.Fprintf(&b, "persona:   %s\n", r.Persona)
	if r.Owner != "" {
		fmt.Fprintf(&b, "owner:     %s\n", r.Owner)
	}
	if len(r.SessionTags) > 0 {
		fmt.Fprintf(&b, "tags:      %s=%s, %s=%s\n",
			cpaws.TagOwner, r.SessionTags[cpaws.TagOwner],
			cpaws.TagSession, r.SessionTags[cpaws.TagSession])
	}
	if r.RemoteImage != nil {
		fmt.Fprintf(&b, "remote image: family=%s instance_type=%s architecture=%s\n",
			r.RemoteImage.Family, r.RemoteImage.InstanceType, r.RemoteImage.Architecture)
		fmt.Fprintf(&b, "  parameter:   %s\n", r.RemoteImage.Parameter)
		fmt.Fprintf(&b, "  ami:         %s\n", r.RemoteImage.AMI)
		fmt.Fprintf(&b, "  region:      %s\n", r.RemoteImage.Region)
		fmt.Fprintf(&b, "  resolved_at: %s\n", r.RemoteImage.ResolvedAt.UTC().Format(time.RFC3339))
	}
	if r.TTL != "" {
		fmt.Fprintf(&b, "ttl:       %s\n", r.TTL)
	}
	fmt.Fprintf(&b, "target:    %s/%s account=%s\n", r.AWS.Cloud, r.AWS.Region, r.AWS.Account)
	if r.UserManagedEC2Key != nil {
		fmt.Fprintf(&b, "ec2 key:   %s\n", r.UserManagedEC2Key.Summary())
	}
	fmt.Fprintf(&b, "terraform: %s %s\n", r.Terraform.WorkDir, r.Terraform.VarFile)
	fmt.Fprintf(&b, "tiltfile:  %s\n", r.Artifacts.Tiltfile)
	fmt.Fprintf(&b, "essential v2: enabled=%t enabledTikvRemoteWorkers=%s\n",
		r.EssentialV2.Enabled,
		strings.Join(r.EssentialV2.EnabledTiKVRemoteWorkers, ","),
	)
	if r.DedicatedGCP != nil && r.DedicatedGCP.Enabled {
		fmt.Fprintf(&b, "dedicated gcp: plan-only apply=%t project=%s dataplane_project=%s location=%s/%s\n",
			r.DedicatedGCP.Apply,
			r.DedicatedGCP.SeedProjectID,
			r.DedicatedGCP.DataplaneProjectID,
			r.DedicatedGCP.Region,
			r.DedicatedGCP.Zone,
		)
		fmt.Fprintf(&b, "  runtime:    %s disk=%dGiB identity=%s\n",
			r.DedicatedGCP.MachineType,
			r.DedicatedGCP.RootDiskGiB,
			r.DedicatedGCP.RuntimeIdentity,
		)
		fmt.Fprintf(&b, "  terraform:  module=%s work_dir=%s plan=%s\n",
			r.DedicatedGCP.ModulePath,
			r.DedicatedGCP.WorkDir,
			r.DedicatedGCP.PlanPath,
		)
		fmt.Fprintf(&b, "  ingress:    %s\n", r.DedicatedGCP.IngressSource)
		fmt.Fprintf(&b, "  resources:  %s\n", strings.Join(r.DedicatedGCP.Resources, ", "))
	}
	if r.SessionCollision != nil {
		fmt.Fprintf(&b, "%s\n", r.SessionCollision.FormatText())
	}
	if r.Pressure.Summary != "" {
		fmt.Fprintf(&b, "pressure:  %s\n", r.Pressure.Level)
		for line := range strings.SplitSeq(pressure.FormatDetails(&r.Pressure), "\n") {
			fmt.Fprintf(&b, "  %s\n", line)
		}
	}
	if r.RegionalSmoke != nil && r.RegionalSmoke.Enabled {
		fmt.Fprintf(&b, "regional smoke:\n")
		fmt.Fprintf(&b, "  runtime:    %s\n", r.RegionalSmoke.Runtime)
		fmt.Fprintf(&b, "  remote eks: %s context=%s\n", r.RegionalSmoke.RemoteEKSCluster, r.RegionalSmoke.RemoteEKSContext)
		fmt.Fprintf(&b, "  o11y:       %s\n", r.RegionalSmoke.O11yStub)
		fmt.Fprintf(&b, "  terraform-first generated values: %t\n", r.RegionalSmoke.TerraformFirst)
		fmt.Fprintf(&b, "  source:     %s\n", strings.Join(r.RegionalSmoke.Source, ", "))
		fmt.Fprintf(&b, "  readiness:  %s\n", strings.Join(r.RegionalSmoke.Readiness, ", "))
	}
	if r.StarterGlobalWiring != nil && r.StarterGlobalWiring.Enabled {
		fmt.Fprintf(&b, "starter global wiring:\n")
		fmt.Fprintf(&b, "  local:      %s\n", strings.Join(r.StarterGlobalWiring.LocalGlobalCP, ", "))
		fmt.Fprintf(&b, "  remote:     %s\n", strings.Join(r.StarterGlobalWiring.RemoteRegionalCPDP, ", "))
		fmt.Fprintf(&b, "  probes:     %s\n", strings.Join(r.StarterGlobalWiring.Probes, ", "))
	}
	if r.StarterActivation != nil && r.StarterActivation.Enabled {
		fmt.Fprintf(&b, "starter activation:\n")
		fmt.Fprintf(&b, "  trigger:    %s\n", r.StarterActivation.Trigger)
		fmt.Fprintf(&b, "  AI/FTS:     enabled=%t\n", r.StarterActivation.AIFTSEnabled)
		fmt.Fprintf(&b, "  remote eks: %s\n", r.StarterActivation.RemoteEKSCluster)
		fmt.Fprintf(&b, "  serving:    lane=%s initial/minimum Pods=%d\n",
			r.StarterActivation.SelectedServingLane,
			r.StarterActivation.TiDBPoolPods,
		)
		fmt.Fprintf(&b, "  pd mode:    %s nodes=%d root=%d tso=%d resource-manager=%d requests=%s/%s limits=%s/%s\n",
			r.StarterActivation.PDMode,
			r.StarterActivation.PDWorkloads.PDNodes,
			r.StarterActivation.PDWorkloads.RootPDReplicas,
			r.StarterActivation.PDWorkloads.TSOReplicas,
			r.StarterActivation.PDWorkloads.ResourceManager,
			r.StarterActivation.PDWorkloads.RequestCPU,
			r.StarterActivation.PDWorkloads.RequestMemory,
			r.StarterActivation.PDWorkloads.LimitCPU,
			r.StarterActivation.PDWorkloads.LimitMemory,
		)
		fmt.Fprintf(&b, "  topology:\n")
		for _, group := range r.StarterActivation.Topology {
			fmt.Fprintf(&b, "    - %-26s %s nodes=%d arch=%s tainted=%t\n",
				group.Role, group.InstanceType, group.Nodes, group.Architecture, group.Tainted)
		}
		fmt.Fprintf(&b, "  network:    %d x /%d subnets, pod IP required=%d available=%d margin=%d%%\n",
			r.StarterActivation.Network.SubnetCount,
			r.StarterActivation.Network.SubnetPrefix,
			r.StarterActivation.Network.RequiredPodIPs,
			r.StarterActivation.Network.AvailablePodIPs,
			r.StarterActivation.Network.SafetyMarginPercent,
		)
		fmt.Fprintf(&b, "  storage:    TiKV %d partitions x %d replicas, %d GiB local + %d GiB EBS each (total EBS %d GiB)\n",
			r.StarterActivation.TiKVStorage.Partitions,
			r.StarterActivation.TiKVStorage.ReplicasPerPartition,
			r.StarterActivation.TiKVStorage.LocalGiBPerReplica,
			r.StarterActivation.TiKVStorage.EBSGiBPerReplica,
			r.StarterActivation.TiKVStorage.TotalEBSGiB,
		)
		fmt.Fprintf(&b, "  overlap:    %s\n", r.StarterActivation.Message)
		fmt.Fprintf(&b, "  cost:       %s expected_window=%s source=%s as_of=%s; %s; billable AWS resources=%s\n",
			r.StarterActivation.Cost.EstimateStatus,
			r.StarterActivation.Cost.ExpectedCostWindow,
			r.StarterActivation.Cost.Source,
			r.StarterActivation.Cost.AsOf,
			r.StarterActivation.Cost.Message,
			strings.Join(r.StarterActivation.Cost.BillableResources, ", "),
		)
	}
	if r.BYOCActivation != nil && r.BYOCActivation.Enabled {
		fmt.Fprintf(&b, "byoc activation:\n")
		fmt.Fprintf(&b, "  trigger:                  %s\n", r.BYOCActivation.Trigger)
		fmt.Fprintf(&b, "  mutates customer account: %t\n", r.BYOCActivation.MutatesCustomerAccount)
		fmt.Fprintf(&b, "  customer:                 account=%s profile=%s\n",
			r.BYOCActivation.CustomerAccountID,
			r.BYOCActivation.CustomerProfile,
		)
		fmt.Fprintf(&b, "  retained:                 %s\n", strings.Join(r.BYOCActivation.Retained, ", "))
	}
	fmt.Fprintf(&b, "services:\n")
	for i := range r.Services {
		svc := &r.Services[i]
		fmt.Fprintf(&b, "  - %-24s %s", svc.Name, svc.Role)
		if svc.Image != "" {
			fmt.Fprintf(&b, " image=%s", svc.Image)
		}
		if svc.BuildMain != "" {
			fmt.Fprintf(&b, " build.main=%s", svc.BuildMain)
		}
		if svc.CanonicalPath != "" {
			fmt.Fprintf(&b, " configured=%s canonical=%s git.head=%s dirty=%t",
				svc.ConfiguredPath, svc.CanonicalPath, svc.GitHEAD, svc.Dirty)
		}
		if svc.TiltResource != "" {
			fmt.Fprintf(&b, " tilt.resource=%s workload=%s/%s/%s container=%s",
				svc.TiltResource, svc.WorkloadKind, svc.WorkloadNamespace, svc.WorkloadName, svc.WorkloadContainer)
		}
		b.WriteString("\n")
	}
	fmt.Fprintf(&b, "skipped lifecycle stages: %s", strings.Join(r.SkippedStages, ", "))
	return b.String()
}

func buildV1Alpha2UpDryRunResult(
	ctx context.Context,
	resolved *config.SessionConfig,
	selected *host.Session,
	steps map[cluster.StepName]cluster.Step,
	preflight *cpaws.SetupResult,
) (*upDryRunResult, error) {
	tfPaths := cpterraform.WorkspacePathsForVarFile(selected.StateDir, resolved.Terraform.VarFile)
	tiltfilePath := cluster.TiltfilePath(selected.StateDir)
	if configured := strings.TrimSpace(resolved.Tilt.File); configured != "" {
		tiltfilePath = configured
	}
	services, err := upDryRunServices(ctx, resolved, tiltfilePath)
	if err != nil {
		return nil, err
	}
	result := &upDryRunResult{
		DryRun:     true,
		NoMutation: true,
		Session:    resolved.Name,
		Persona:    config.SessionPersona(resolved),
		TTL:        upDryRunTTL(resolved),
		HostMode:   selected.Mode,
		StateDir:   selected.StateDir,
		AWS: upDryRunAWS{
			Cloud:         resolved.Target.Cloud,
			Region:        resolved.Target.Region,
			Profile:       resolved.Target.AWS.IAM.Profile,
			SourceProfile: resolved.Target.AWS.IAM.SourceProfile,
			RoleARN:       resolved.Target.AWS.IAM.RoleARN,
		},
		Terraform: upDryRunTerraform{
			Repo:    resolved.Terraform.Repo,
			Ref:     resolved.Terraform.Ref,
			Path:    resolved.Terraform.Path,
			VarFile: resolved.Terraform.VarFile,
			WorkDir: tfPaths.RootDir,
			Plan:    tfPaths.PlanPath,
		},
		Artifacts: upDryRunArtifacts{
			ResolvedSession: config.ResolvedSessionPath(selected.StateDir),
			ContextDir:      cluster.ContextDir(selected.StateDir),
			Tiltfile:        tiltfilePath,
		},
		Services: services,
		EssentialV2: upDryRunEssentialV2{
			Enabled:                  config.EssentialV2Enabled(resolved),
			EnabledTiKVRemoteWorkers: config.EssentialV2EnabledTiKVRemoteWorkers(resolved),
		},
		DedicatedGCP:        upDryRunDedicatedGCPPlan(resolved, selected.StateDir),
		StarterActivation:   upDryRunStarterActivationPlan(resolved),
		BYOCActivation:      upDryRunBYOCActivationPlan(resolved),
		RegionalSmoke:       upDryRunRegionalSmokePlan(resolved),
		StarterGlobalWiring: upDryRunStarterGlobalWiringPlan(resolved),
		Pressure:            pressure.Build(resolved),
		SkippedStages:       upDryRunSkippedStages(steps),
	}
	if preflight != nil {
		result.Owner = preflight.Owner
		result.SessionTags = maps.Clone(preflight.SessionTags)
		result.RemoteImage = preflight.RemoteImage
		result.AWS.Account = preflight.Account
		result.UserManagedEC2Key = preflight.UserManagedEC2Key
		result.SessionCollision = preflight.SessionCollision
	}
	return result, nil
}

func upDryRunBYOCActivationPlan(resolved *config.SessionConfig) *upDryRunBYOCActivation {
	if !config.BYOCEnabled(resolved) {
		return nil
	}
	retained := []string{
		"customer DNS/PCA prerequisites",
		"customer ECR cache",
		"control-plane auto-deploy-cli role",
	}
	if resolved.BYOC.AccountSetup.ReuseExistingStacks {
		retained = append(retained, "customer-owned BYOC account-setup stacks")
	}
	return &upDryRunBYOCActivation{
		Enabled:                true,
		Trigger:                "cp9 up after Dev full baseline",
		MutatesCustomerAccount: true,
		ReusesAccountSetup:     resolved.BYOC.AccountSetup.ReuseExistingStacks,
		CustomerAccountID:      resolved.BYOC.Customer.AccountID,
		CustomerProfile:        resolved.BYOC.AccountSetup.Profile,
		Retained:               retained,
	}
}

func upDryRunDedicatedGCPPlan(resolved *config.SessionConfig, stateDir string) *upDryRunDedicatedGCP {
	if !config.DedicatedGCPEnabled(resolved) {
		return nil
	}
	return &upDryRunDedicatedGCP{
		DedicatedGCPPlan: dedicatedGCPPlanFromConfig(stateDir, resolved),
		Enabled:          true,
		Apply:            false,
		RootDiskGiB:      config.DedicatedGCPRuntimeDiskGiB,
		IngressSource:    "derived from the primary AWS host public IP as one /32 before apply",
		RuntimeIdentity:  "shared Project-bootstrap cp9-developer service account via ADC; no JSON key",
		Resources: []string{
			"custom VPC",
			"regional subnet",
			"static public address",
			"IAP-only SSH firewall",
			"AWS-host-/32 Kong firewall",
			"one GCE k3d runtime",
		},
	}
}

func upDryRunTTL(resolved *config.SessionConfig) string {
	if resolved == nil || resolved.Host.TTL.Duration == 0 {
		return ""
	}
	return resolved.Host.TTL.String()
}

func upDryRunSkippedStages(steps map[cluster.StepName]cluster.Step) []string {
	stageNames := cluster.V3Steps()
	skipped := make([]string, 0, len(stageNames))
	for _, name := range stageNames {
		if _, ok := steps[name]; ok {
			skipped = append(skipped, string(name))
		}
	}
	return skipped
}

func upDryRunServices(ctx context.Context, resolved *config.SessionConfig, tiltfilePath string) ([]upDryRunService, error) {
	if resolved == nil {
		return nil, nil
	}
	// The source/live/stub graph is retired; the Session Tiltfile's
	// source_build_services below is the only remaining source of services.
	services := make([]upDryRunService, 0)
	if config.UsesSessionTiltfile(resolved) {
		selected, err := cptilt.SelectedSourceBuildServices(tiltfilePath)
		if err != nil {
			return nil, err
		}
		registry, err := sourcebacked.DefaultRegistry()
		if err != nil {
			return nil, err
		}
		for _, name := range selected {
			contract, ok := registry.Contract(name)
			if !ok {
				return nil, fmt.Errorf("source_build_services selected unknown service %s", name)
			}
			canonicalPath, err := cptilt.ResolveSourceDirectory(tiltfilePath, contract.Source.Repo)
			if err != nil {
				return nil, fmt.Errorf("inspect source-build service %s: %w", name, err)
			}
			configuredPath, err := filepath.Abs(filepath.Join(filepath.Dir(tiltfilePath), filepath.FromSlash(contract.Source.Repo)))
			if err != nil {
				return nil, fmt.Errorf("inspect source-build service %s configured path: %w", name, err)
			}
			head, err := inspectGitOutput(ctx, canonicalPath, "rev-parse", "HEAD")
			if err != nil {
				return nil, fmt.Errorf("inspect source-build service %s revision: %w", name, err)
			}
			status, err := inspectGitOutput(ctx, canonicalPath, "status", "--porcelain")
			if err != nil {
				return nil, fmt.Errorf("inspect source-build service %s dirty state: %w", name, err)
			}
			services = append(services, upDryRunService{
				Name:              name,
				Role:              "source-build",
				ConfiguredPath:    configuredPath,
				CanonicalPath:     canonicalPath,
				GitHEAD:           head,
				Dirty:             status != "",
				Image:             "cp9.local/" + name + ":placeholder",
				BuildMain:         contract.Source.Main,
				TiltResource:      contract.ImageWriter.Resource,
				WorkloadKind:      contract.RolloutTarget.Kind,
				WorkloadNamespace: contract.RolloutTarget.Namespace,
				WorkloadName:      contract.RolloutTarget.Name,
				WorkloadContainer: contract.RolloutTarget.Container,
			})
		}
	}
	slices.SortFunc(services, func(a, b upDryRunService) int {
		if a.Role != b.Role {
			return strings.Compare(a.Role, b.Role)
		}
		return strings.Compare(a.Name, b.Name)
	})
	return services, nil
}

func inspectGitOutput(ctx context.Context, directory string, args ...string) (string, error) {
	commandArgs := append([]string{"-C", directory}, args...)
	output, err := exec.CommandContext(ctx, "git", commandArgs...).CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
	}
	return strings.TrimSpace(string(output)), nil
}

func upDryRunStarterActivationPlan(resolved *config.SessionConfig) *upDryRunStarterActivation {
	if !config.StarterActivationRequested(resolved) {
		return nil
	}
	activation := starterActivationSession(resolved)
	trigger := "starter.enabled"
	if !config.StarterEnabled(resolved) && config.StarterAIFTSEnabled(resolved) {
		trigger = "starter.ai_fts.enabled"
	} else if !config.StarterEnabled(resolved) {
		trigger = "starter.observability.enabled"
	}
	topology := make([]upDryRunStarterNodeGroup, 0, len(activation.Target.AWS.RemoteEKS.NodeGroups))
	for _, group := range activation.Target.AWS.RemoteEKS.NodeGroups {
		topology = append(topology, upDryRunStarterNodeGroup{
			Role:         group.Labels["cp9.tidbcloud.com/starter-role"],
			InstanceType: group.InstanceType,
			Nodes:        group.DesiredSize,
			Architecture: group.Architecture,
			AMIType:      group.AMIType,
			Tainted:      len(group.Taints) > 0,
		})
	}
	pd := config.ResolvedStarterPDTopology(activation)
	pdWorkloads := upDryRunStarterPDWorkloads{
		PDNodes: pd.PDNodes, RootPDReplicas: pd.RootPDReplicas,
		TSOReplicas: pd.TSOReplicas, ResourceManager: pd.ResourceManagerReplicas,
		RequestCPU: pd.RequestCPU, RequestMemory: pd.RequestMemory, LimitCPU: pd.LimitCPU, LimitMemory: pd.LimitMemory,
	}
	return &upDryRunStarterActivation{
		Enabled:             true,
		Trigger:             trigger,
		AIFTSEnabled:        config.StarterAIFTSEnabled(activation),
		RemoteEKSCluster:    activation.Target.AWS.RemoteEKS.ClusterName,
		SelectedServingLane: starterSelectedServingLane,
		TiDBPoolPods:        config.ResolvedStarterTiDBPoolPods(activation),
		PDMode:              starterResolvedPDMode(activation),
		PDWorkloads:         pdWorkloads,
		Topology:            topology,
		Network:             starterMinimalNetworkPlan(activation.Target.AWS.RemoteEKS.NodeGroups),
		TiKVStorage: upDryRunStarterStorage{
			Partitions: 3, ReplicasPerPartition: 3, LocalGiBPerReplica: 10, EBSGiBPerReplica: 35, TotalEBSGiB: 315,
		},
		Cost:    starterDryRunCostEstimate(activation),
		Message: "cp9 up will complete Starter activation (substrate, workload, and readiness) before returning success.",
	}
}

func starterDryRunCostEstimate(activation *config.SessionConfig) upDryRunStarterCost {
	// Reviewed 2026-07-11 AWS Price List API snapshot for us-west-2 Linux
	// shared-tenancy on-demand instances. The range adds fixed EKS/TiKV GP3/
	// public-IPv4 baselines and a 40% allowance for root disks, load balancers,
	// request-driven services, logs, and normal price movement. It is a planning
	// window, not a quote, and does not include the already-required Dev full host.
	instanceHourlyUSD := map[string]float64{
		"m8g.4xlarge": 0.71808,
		"m8g.xlarge":  0.17952,
		"m8g.2xlarge": 0.35904,
		"r8g.xlarge":  0.23564,
		"i7ie.large":  0.31200,
		"m8g.large":   0.08976,
		"m6g.2xlarge": 0.30800,
		"m7i.xlarge":  0.20160,
		"r7i.xlarge":  0.26460,
		"m7i.large":   0.10080,
		"m6i.2xlarge": 0.38400,
	}
	groups := activation.Target.AWS.RemoteEKS.NodeGroups
	computeHourly := 0.0
	nodeCount := 0
	for _, group := range groups {
		computeHourly += instanceHourlyUSD[group.InstanceType] * float64(group.DesiredSize)
		nodeCount += group.DesiredSize
	}
	const (
		eksControlPlaneHourlyUSD = 0.10
		gp3GBMonthUSD            = 0.08
		monthHours               = 730.0
		publicIPv4HourlyUSD      = 0.005
		ancillaryUpperMultiplier = 1.40
	)
	lowerHourly := computeHourly + eksControlPlaneHourlyUSD +
		(315*gp3GBMonthUSD)/monthHours + float64(nodeCount)*publicIPv4HourlyUSD
	upperHourly := lowerHourly * ancillaryUpperMultiplier
	duration := activation.Host.TTL.Duration
	if duration <= 0 {
		duration = 8 * time.Hour
	}
	hours := duration.Hours()
	return upDryRunStarterCost{
		EstimateStatus: "estimated",
		ExpectedCostWindow: fmt.Sprintf(
			"USD %.2f-%.2f for %s (Starter regional incremental)",
			lowerHourly*hours,
			upperHourly*hours,
			duration,
		),
		Currency: "USD",
		Source:   "AWS Price List API reviewed snapshot for us-west-2 Linux on-demand plus 40% ancillary/usage allowance",
		AsOf:     "2026-07-11",
		BillableResources: []string{
			"EKS control plane",
			"EC2 managed nodes",
			"EBS GP3 volumes",
			"EC2 public IPv4 addresses",
			"Elastic Load Balancing",
			"Route 53 private hosted zone",
			"S3 storage and requests",
			"DynamoDB and SQS request usage",
			"ECR image storage",
			"Lambda invocations and compute",
			"CloudWatch Logs ingestion and storage",
		},
		Message: fmt.Sprintf(
			"estimated Starter incremental rate USD %.2f-%.2f/hour; resolved billable footprint remains authoritative",
			lowerHourly,
			upperHourly,
		),
	}
}

func starterMinimalNetworkPlan(groups []config.RemoteEKSNodeGroupConfig) upDryRunStarterNetwork {
	const (
		subnetCount         = 3
		subnetPrefix        = 20
		awsReservedIPs      = 5
		systemPodIPs        = 64
		safetyMarginPercent = 30
	)
	availablePerSubnet := (1 << (32 - subnetPrefix)) - awsReservedIPs
	maximumPods := systemPodIPs
	for _, group := range groups {
		maximumPods += group.DesiredSize * starterMaxPodsPerNode(group.InstanceType)
	}
	required := (maximumPods*(100+safetyMarginPercent) + 99) / 100
	return upDryRunStarterNetwork{
		SubnetCount:         subnetCount,
		SubnetPrefix:        subnetPrefix,
		AvailablePodIPs:     subnetCount * availablePerSubnet,
		RequiredPodIPs:      required,
		SafetyMarginPercent: safetyMarginPercent,
		Sufficient:          required <= subnetCount*availablePerSubnet,
	}
}

func validateStarterMinimalNetworkCapacity(groups []config.RemoteEKSNodeGroupConfig) error {
	plan := starterMinimalNetworkPlan(groups)
	if plan.Sufficient {
		return nil
	}
	return cp9errors.Newf(
		cp9errors.CategoryConfig,
		"Starter network pod-IP capacity is insufficient: required=%d available=%d; reduce starter.activation.topology.tidb_serving.nodes or choose a topology that fits the fixed 3 x /20 subnet plan",
		plan.RequiredPodIPs,
		plan.AvailablePodIPs,
	)
}

func starterMaxPodsPerNode(instanceType string) int {
	// Use the conservative EKS secondary-IP max-pods ceilings for the reviewed
	// instance allowlist. VPC CNI prefix delegation can raise these limits, but
	// the dry-run capacity gate must not depend on it being enabled.
	switch strings.TrimSpace(instanceType) {
	case "m8g.large", "m7i.large":
		return 29
	case "m8g.xlarge", "m8g.2xlarge", "m8g.4xlarge", "r8g.xlarge", "m6g.2xlarge", "m7i.xlarge", "r7i.xlarge", "m6i.2xlarge", "i7ie.large":
		return 58
	default:
		return 0
	}
}

func upDryRunRegionalSmokePlan(resolved *config.SessionConfig) *upDryRunRegionalSmoke {
	return nil
}

func upDryRunRegionalSmokeClusterName(sessionName, remoteClusterName string) string {
	if clusterName := strings.TrimSpace(remoteClusterName); clusterName != "" {
		return clusterName
	}
	sessionName = strings.TrimSpace(sessionName)
	if strings.HasPrefix(sessionName, "cp9-") {
		return sessionName + "-starter-regional"
	}
	return "cp9-" + sessionName + "-starter-regional"
}

func upDryRunStarterGlobalWiringPlan(resolved *config.SessionConfig) *upDryRunStarterGlobalWire {
	return nil
}

func v1Alpha2SubstrateSession(resolved *config.SessionConfig) *config.SessionConfig {
	if config.StarterActivationRequested(resolved) {
		return starterActivationSession(resolved)
	}
	return resolved
}

func buildV1Alpha2Lifecycle(resolved *config.SessionConfig) (*host.Session, map[cluster.StepName]cluster.Step, error) {
	if err := validateStarterAIFTSInputs(resolved); err != nil {
		return nil, nil, err
	}
	selected := host.ResolveSessionConfig(resolved)
	personaName := config.SessionPersona(resolved)
	substrateSession := v1Alpha2SubstrateSession(resolved)
	if config.StarterActivationRequested(resolved) {
		if err := validateStarterMinimalNetworkCapacity(substrateSession.Target.AWS.RemoteEKS.NodeGroups); err != nil {
			return nil, nil, err
		}
	}
	loadedHost := false
	if loaded, err := host.Load(selected.StateDir); err == nil {
		selected = loaded
		loadedHost = true
	} else if !os.IsNotExist(err) {
		return nil, nil, cp9errors.Newf(cp9errors.CategoryResource, "load selected host: %v", err)
	}
	if personaName == "dev-full" {
		if loadedHost && selected.DedicatedAPI == nil {
			return nil, nil, cp9errors.Newf(
				cp9errors.CategoryResource,
				"legacy dev-full session %q has no independent Dedicated API state and cannot be migrated safely in place; run cp9 destroy and cp9 up again",
				selected.ClusterName,
			)
		}
		if err := dedicatedapi.Prepare(selected, dedicatedapi.AllocateLoopbackPort); err != nil {
			return nil, nil, cp9errors.Newf(cp9errors.CategoryResource, "%v", err)
		}
	}
	var gcpSelected *host.Session
	if config.DedicatedGCPEnabled(resolved) {
		gcpSelected = host.ResolveGCPRegionalSession(resolved, selected.StateDir)
		if loaded, err := host.Load(gcpSelected.StateDir); err == nil {
			gcpSelected = loaded
			if gcpSelected.DedicatedAPI == nil {
				return nil, nil, cp9errors.Newf(
					cp9errors.CategoryResource,
					"legacy GCP regional session %q has no independent Dedicated API state and cannot be migrated safely in place; run cp9 destroy and cp9 up again",
					gcpSelected.ClusterName,
				)
			}
		} else if !os.IsNotExist(err) {
			return nil, nil, cp9errors.Newf(cp9errors.CategoryResource, "load GCP regional host: %v", err)
		} else if lifecycleState, stateErr := cluster.LoadState(selected.StateDir); stateErr == nil &&
			lifecycleState.Steps[cluster.StepHostReady] != nil &&
			lifecycleState.Steps[cluster.StepHostReady].Status == cluster.StatusDone {
			return nil, nil, cp9errors.Newf(
				cp9errors.CategoryResource,
				"session %q completed host_ready before the GCP regional host was persisted and cannot be migrated safely in place; run cp9 destroy and cp9 up again",
				selected.ClusterName,
			)
		} else if stateErr != nil && !errors.Is(stateErr, os.ErrNotExist) {
			return nil, nil, cp9errors.Newf(cp9errors.CategoryResource, "load lifecycle state for GCP regional host: %v", stateErr)
		} else if err := dedicatedapi.Prepare(gcpSelected, func() (int, error) {
			for range 16 {
				port, allocateErr := dedicatedapi.AllocateLoopbackPort()
				if allocateErr != nil || selected.DedicatedAPI == nil || port != selected.DedicatedAPI.HostPort {
					return port, allocateErr
				}
			}
			return 0, fmt.Errorf("allocate distinct GCP Dedicated API loopback port")
		}); err != nil {
			return nil, nil, cp9errors.Newf(cp9errors.CategoryResource, "%v", err)
		}
	}

	var (
		hostAdapter       lifecycle.ConflictChecker
		clusterCreateStep cluster.Step
		o11yEndpointReady func(context.Context) error
	)
	switch selected.Mode {
	case config.HostModeLocalK3d:
		hostAdapter = localk3d.NewAdapter()
		clusterCreateStep = localk3d.NewClusterStep(selected, localk3d.Options{})
	case config.HostModeRemoteEC2K3d:
		hostAdapter = remoteec2.NewAdapter()
		remoteClusterStep := remoteec2.NewClusterStep(selected, &remoteec2.Options{
			Profile:             resolved.Target.AWS.IAM.Profile,
			Region:              resolved.Target.Region,
			RuntimeContractPath: cpterraform.WorkspacePaths(selected.StateDir).ContractPath,
			OwnerAllowlist:      resolved.CI.OwnerAllowlist,
			TTL:                 resolved.Host.TTL.Duration,
			InstanceType:        resolved.Host.EC2.InstanceType,
			KeyPair:             resolved.Host.EC2.KeyPair,
			SSHUser:             resolved.Host.EC2.SSHUser,
			SSHPrivateKey:       resolved.Host.EC2.SSHPrivateKey,
			SubnetID:            resolved.Host.EC2.SubnetID,
			SecurityGroupIDs:    resolved.Host.EC2.SecurityGroupIDs,
			InstanceProfile:     resolved.Host.EC2.InstanceProfile,
			AllowedCIDR:         resolved.Host.EC2.AllowedCIDR,
			RootVolumeSizeGiB:   resolved.Host.EC2.RootVolumeSizeGiB,
			PublicHTTPIngress:   personaName == "dev-full",
			O11YEndpointService: personaName == "dev-full",
			Exposure: remoteec2.ExposureOptions{
				Enabled:        resolved.Host.Exposure.Enabled,
				ZoneID:         resolved.Host.Exposure.ZoneID,
				ZoneName:       resolved.Host.Exposure.ZoneName,
				CertificateARN: resolved.Host.Exposure.CertificateARN,
			},
		})
		clusterCreateStep = remoteClusterStep
		if personaName == "dev-full" {
			o11yEndpointReady = remoteClusterStep.WaitForO11YEndpointServicePrivateDNS
		}
	default:
		return nil, nil, cp9errors.NotImplemented("host mode " + string(selected.Mode))
	}

	steps := cluster.DefaultStepsForLifecycle(cluster.LifecycleV3)
	var requiredBinaries []string
	if config.DedicatedGCPEnabled(resolved) {
		requiredBinaries = []string{"gcloud", "terraform"}
	}
	steps[cluster.StepPreflight] = &lifecycle.PreflightStep{
		Selected:         selected,
		PersonaName:      personaName,
		TunnelMechanism:  "",
		ConflictChecker:  hostAdapter,
		RequiredBinaries: requiredBinaries,
	}
	steps[cluster.StepConfigLoad] = &lifecycle.ConfigLoadStep{
		StateDir: selected.StateDir,
		Session:  resolved,
	}
	ownerIdentityReady := cluster.Step(&cpaws.OwnerIdentityReadyStep{
		Profile:        resolved.Target.AWS.IAM.Profile,
		SourceProfile:  resolved.Target.AWS.IAM.SourceProfile,
		Region:         resolved.Target.Region,
		OwnerAllowlist: resolved.CI.OwnerAllowlist,
	})
	if config.DedicatedGCPEnabled(resolved) {
		ownerIdentityReady = &lifecycle.DedicatedGCPIdentityReadyStep{Inner: ownerIdentityReady, Session: resolved}
	}
	steps[cluster.StepOwnerIdentityReady] = ownerIdentityReady
	steps[cluster.StepIAMReady] = &cpaws.IAMReadyStep{
		SessionID:      resolved.Name,
		StateDir:       selected.StateDir,
		Profile:        resolved.Target.AWS.IAM.Profile,
		SourceProfile:  resolved.Target.AWS.IAM.SourceProfile,
		Region:         resolved.Target.Region,
		RoleARN:        resolved.Target.AWS.IAM.RoleARN,
		OwnerAllowlist: resolved.CI.OwnerAllowlist,
	}
	steps[cluster.StepSubstrateInit] = &lifecycle.SubstrateInitStep{
		StateDir: selected.StateDir,
		Session:  substrateSession,
	}
	steps[cluster.StepSubstratePlan] = &lifecycle.SubstratePlanStep{
		StateDir: selected.StateDir,
		Session:  substrateSession,
	}
	steps[cluster.StepSubstrateApply] = &lifecycle.SubstrateApplyStep{
		StateDir: selected.StateDir,
		Session:  substrateSession,
	}
	hostReadyInner := clusterCreateStep
	if selected.DedicatedAPI != nil {
		hostReadyInner = &lifecycle.DedicatedAPIHostReadyStep{
			Selected: selected,
			Inner:    clusterCreateStep,
		}
	}
	if gcpSelected != nil {
		gcpStep := &remotegce.Step{StateDir: selected.StateDir, Session: resolved, Selected: gcpSelected}
		hostReadyInner = &lifecycle.DedicatedGCPHostReadyStep{
			Primary:     hostReadyInner,
			PrimaryHost: selected,
			GCPHost:     gcpSelected,
			GCP:         gcpStep,
		}
	}
	steps[cluster.StepHostReady] = &lifecycle.NamedStep{
		StepName: cluster.StepHostReady,
		Inner:    hostReadyInner,
	}
	// The Session Tiltfile owns the service graph and the runtime context, so
	// service_plan, context_materialize and tiltfile_generate are not stages of
	// this lifecycle at all -- they are not installed as no-ops either.
	steps[cluster.StepMetadbSeed] = &cluster.NoopStep{StepName: cluster.StepMetadbSeed}
	steps[cluster.StepSessionContextWrite] = &lifecycle.SessionContextWriteStep{StateDir: selected.StateDir}
	tiltReady := &lifecycle.TiltReadyStep{StateDir: selected.StateDir}
	if gcpSelected != nil {
		tiltReady.GCPRuntime = &lifecycle.GCPRuntimeTiltStep{PrimaryStateDir: selected.StateDir}
	}
	steps[cluster.StepTiltReady] = tiltReady
	steps[cluster.StepSessionReady] = &lifecycle.SessionReadyStep{
		StateDir:          selected.StateDir,
		Session:           resolved,
		O11YEndpointReady: o11yEndpointReady,
	}
	if !config.UsesCloudSubstrate(resolved) {
		steps[cluster.StepOwnerIdentityReady] = &lifecycle.LocalOwnerIdentityReadyStep{
			StateDir: selected.StateDir,
			Session:  resolved,
		}
		steps[cluster.StepIAMReady] = &cluster.NoopStep{StepName: cluster.StepIAMReady}
		steps[cluster.StepSubstrateInit] = &cluster.NoopStep{StepName: cluster.StepSubstrateInit}
		steps[cluster.StepSubstratePlan] = &cluster.NoopStep{StepName: cluster.StepSubstratePlan}
		steps[cluster.StepSubstrateApply] = &lifecycle.LocalSubstrateApplyStep{StateDir: selected.StateDir}
		steps[cluster.StepMetadbSeed] = &lifecycle.LocalMetadbSeedStep{StateDir: selected.StateDir}
	}
	return selected, steps, nil
}

func setPrimaryDedicatedAPIPortReservation(step cluster.Step, reservation *dedicatedapi.PortReservation) {
	switch typed := step.(type) {
	case *lifecycle.DedicatedAPIHostReadyStep:
		typed.PortReservation = reservation
	case *lifecycle.DedicatedGCPHostReadyStep:
		setPrimaryDedicatedAPIPortReservation(typed.Primary, reservation)
	}
}

func runEngineForWatchedUp(engine *cluster.Engine) error {
	_, runErr := engine.Run(context.Background())
	if runErr != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "cp9 up failed: %v", runErr)
	}
	return nil
}

func destroyCmd() model.NormalCmd {
	return func(args model.ArgVals, cc *model.Cli, env *model.Env, _ []model.ParsedCmd) error {
		syncVerbose(env)

		cfgPath := args.GetRaw("config")
		localPath, localProvided := explicitLocalPath(args)
		version, err := config.DetectAPIVersion(cfgPath)
		if err != nil {
			return err
		}
		if version == config.APIVersionV1Alpha2 {
			return runV1Alpha2Destroy(cfgPath, localPath, localProvided, cc, env)
		}
		return runLegacyDestroy(cfgPath, localPath, cc, env)
	}
}

func runLegacyDestroy(cfgPath, localPath string, cc *model.Cli, env *model.Env) error {
	cfg, local, err := config.Load(cfgPath, localPath)
	if err != nil {
		return err
	}

	selected, err := host.LoadOrResolve(cfg, local)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy: load selected host: %v", err)
	}
	reapState, reapStateSource, reapStateFound, err := loadOrCreateDestroyReapState(cfg, selected.StateDir)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy: %v", err)
	}
	if reapStateFound {
		if err := saveReapState(cluster.DefaultStateDir, reapState); err != nil {
			return cp9errors.Newf(cp9errors.CategoryResource, "destroy: persist reaper state: %v", err)
		}
	}
	clusterName := selected.ClusterName
	localCleanup, err := localCleanupBuilder(selected)
	if err != nil {
		return err
	}
	destroyer := &cluster.Destroyer{
		StateDir:     selected.StateDir,
		ClusterName:  clusterName,
		LocalCleanup: localCleanup,
	}

	stage1, stage1Err := destroyer.Destroy(context.Background())
	if stage1Err != nil && !reapStateFound {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: %v", stage1Err)
	}
	annotateStage1Error(stage1, stage1Err)
	result := destroyCommandResult{Stage1: stage1}
	if !reapStateFound {
		return model.Output(cc, env, result)
	}

	reaper, err := makeScopedReaper(reapState.Profile)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: %v", err)
	}
	stage2, err := reaper.Reap(context.Background(), reapState.Scope, false, reapState.Survivors)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: %v", err)
	}
	result.Stage2 = stage2
	if destroyShouldFailOnEmptyInitialScan(reapStateSource, reapState, stage2) {
		if saveErr := saveReapState(cluster.DefaultStateDir, reapState); saveErr != nil {
			return cp9errors.Newf(cp9errors.CategoryResource,
				"destroy failed: empty initial scan violated C4 tagging contract and failed to persist reaper state: %v",
				saveErr)
		}
		if err := model.Output(cc, env, result); err != nil {
			return err
		}
		return cp9errors.New(
			cp9errors.CategoryResource,
			"destroy failed: empty initial Stage-2 scan with saved state context indicates a tagging gap",
		)
	}

	if err := model.Output(cc, env, result); err != nil {
		return err
	}
	if len(stage2.Survivors) > 0 {
		reapState.Survivors = stage2.Survivors
		if saveErr := saveReapState(cluster.DefaultStateDir, reapState); saveErr != nil {
			return cp9errors.Newf(cp9errors.CategoryResource,
				"destroy failed: post-scan left %d survivor(s) and failed to persist reaper state: %v",
				len(stage2.Survivors), saveErr)
		}
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: post-scan left %d tagged survivor(s)", len(stage2.Survivors))
	}
	if removeErr := removeReapState(cluster.DefaultStateDir, reapState.Scope.Owner, reapState.Scope.SessionID); removeErr != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: remove reaper state: %v", removeErr)
	}
	return nil
}

type destroyCommandResult struct {
	Stage1 *cluster.DestroyResult `json:"stage1,omitempty"`
	Stage2 *cpaws.ReapResult      `json:"stage2,omitempty"`
}

func (r destroyCommandResult) FormatText() string {
	var parts []string
	if r.Stage1 != nil {
		parts = appendPrefixedTextPart(parts, "stage 1: ", r.Stage1.FormatText())
	}
	if r.Stage2 != nil {
		parts = appendPrefixedTextPart(parts, "stage 2: ", r.Stage2.FormatText())
	}
	if len(parts) == 0 {
		return "destroy: nothing to clean up"
	}
	return strings.Join(parts, "\n")
}

func annotateStage1Error(result *cluster.DestroyResult, err error) {
	if err == nil {
		return
	}
	if result == nil {
		return
	}
	result.Message = firstNonEmptyString(result.Message, "stage 1 error: "+err.Error())
}

func destroyShouldFailOnEmptyInitialScan(source destroyReapStateSource, state *cpaws.ReapState, result *cpaws.ReapResult) bool {
	if state == nil || result == nil {
		return false
	}
	if result.Initial != 0 || result.UsedResume {
		return false
	}
	switch source {
	case destroyReapStateSourceSession:
		return true
	case destroyReapStateSourcePersisted:
		if state.Completed {
			return false
		}
		return len(state.Survivors) == 0
	default:
		return false
	}
}

func destroyAllowsEmptyInitialScan(destroyCtx *v1alpha2DestroyContext, terraformDestroyErr error) bool {
	if destroyCtx == nil {
		return false
	}
	if destroyCtx.TerraformStateOK {
		return terraformDestroyErr == nil
	}
	return destroyContextStoppedBeforeSubstrateApply(destroyCtx)
}

func destroyContextStoppedBeforeSubstrateApply(destroyCtx *v1alpha2DestroyContext) bool {
	if destroyCtx == nil || destroyCtx.State == nil {
		return false
	}
	step := destroyCtx.State.Steps[cluster.StepSubstrateApply]
	if step == nil {
		return false
	}
	return step.Status == cluster.StatusPending
}

func loadOrCreateDestroyReapState(cfg *config.Config, stateDir string) (*cpaws.ReapState, destroyReapStateSource, bool, error) {
	if cpaws.SessionStateExists(stateDir) {
		awsState, err := cpaws.LoadSessionState(stateDir)
		if err != nil {
			return nil, "", false, fmt.Errorf("load AWS session state: %w", err)
		}
		scope := cpaws.Scope{
			Owner:     awsState.Owner,
			SessionID: awsState.SessionID,
			Regions:   cfg.Clouds.AWS.Regions,
		}.Normalize()
		return &cpaws.ReapState{
			Scope:   scope,
			Profile: awsState.Profile,
		}, destroyReapStateSourceSession, true, nil
	}

	reapState, err := findReapStateBySession(cluster.DefaultStateDir, filepath.Base(stateDir))
	switch {
	case err == nil:
		return reapState, destroyReapStateSourcePersisted, true, nil
	case errors.Is(err, os.ErrNotExist):
		owner, err := destroyOwnerResolver(context.Background(), cfg)
		if err != nil {
			return nil, "", false, fmt.Errorf("resolve destroy owner: %w", err)
		}
		scope := cpaws.Scope{
			Owner:     owner,
			SessionID: filepath.Base(stateDir),
			Regions:   cfg.Clouds.AWS.Regions,
		}.Normalize()
		if err := scope.Validate(); err != nil {
			return nil, "", false, err
		}
		return &cpaws.ReapState{
			Scope:   scope,
			Profile: cfg.Clouds.AWS.Profile,
		}, destroyReapStateSourceConfig, true, nil
	default:
		return nil, "", false, err
	}
}

type destroyV1Alpha2Result struct {
	StateDir              string                              `json:"state_dir,omitempty"`
	Progress              []destroyProgress                   `json:"progress,omitempty"`
	BYOCCleanup           *byoc.DestroyResult                 `json:"byoc_cleanup,omitempty"`
	TiltDown              string                              `json:"tilt_down,omitempty"`
	HostCleanup           string                              `json:"host_cleanup,omitempty"`
	GCPRuntimeDown        string                              `json:"gcp_runtime_down,omitempty"`
	GCPAWSIdentityDestroy string                              `json:"gcp_aws_identity_destroy,omitempty"`
	GCPTerraformDestroy   string                              `json:"gcp_terraform_destroy,omitempty"`
	TerraformDestroy      string                              `json:"terraform_destroy,omitempty"`
	Reaper                *cpaws.ReapResult                   `json:"reaper,omitempty"`
	AWSNukeConfig         string                              `json:"aws_nuke_config,omitempty"`
	AWSNukeDryRunLog      string                              `json:"aws_nuke_dry_run_log,omitempty"`
	AWSNukeDeleteLog      string                              `json:"aws_nuke_delete_log,omitempty"`
	DirectZeroSummaryPath string                              `json:"direct_zero_summary_path,omitempty"`
	AWSNukePreparedLBs    []string                            `json:"aws_nuke_prepared_load_balancers,omitempty"`
	AWSNukePreparedVPC    []string                            `json:"aws_nuke_prepared_vpc_children,omitempty"`
	AWSNukePreparedEKS    []string                            `json:"aws_nuke_prepared_eks_resources,omitempty"`
	DirectS3Buckets       []string                            `json:"direct_s3_buckets,omitempty"`
	DirectIAMResources    []string                            `json:"direct_iam_resources,omitempty"`
	DirectSecrets         []string                            `json:"direct_secrets_manager_resources,omitempty"`
	DirectLaunchTemplates []string                            `json:"direct_launch_templates,omitempty"`
	StaleTagWarnings      []destroyStaleTagWarning            `json:"stale_tag_warnings,omitempty"`
	OwnerDriftScan        string                              `json:"owner_tag_drift_scan,omitempty"`
	CloudCleanupHandoff   string                              `json:"cloud_cleanup_handoff,omitempty"`
	CloudCleanupReceipt   string                              `json:"cloud_cleanup_receipt,omitempty"`
	CloudCleanupWorkflow  *destroyCloudCleanupWorkflowHandoff `json:"cloud_cleanup_workflow,omitempty"`
	AWSProfileCleanup     string                              `json:"aws_profile_cleanup,omitempty"`
	SessionRootRemoved    bool                                `json:"session_root_removed,omitempty"`
	Closeout              *destroyCloseout                    `json:"closeout,omitempty"`
	Message               string                              `json:"message,omitempty"`
}

type destroyProgress struct {
	Phase    string `json:"phase"`
	Status   string `json:"status"`
	Elapsed  string `json:"elapsed,omitempty"`
	Artifact string `json:"artifact,omitempty"`
	Message  string `json:"message,omitempty"`
}

type destroyCloseout struct {
	Status               string                              `json:"status"`
	Stages               []destroyCloseoutStage              `json:"stages,omitempty"`
	LastCompletedPhase   string                              `json:"last_completed_phase,omitempty"`
	CurrentFailedPhase   string                              `json:"current_failed_phase,omitempty"`
	FinalVerdict         string                              `json:"final_verdict,omitempty"`
	DirectZeroSummary    string                              `json:"direct_zero_summary_path,omitempty"`
	CloudCleanupWorkflow *destroyCloudCleanupWorkflowHandoff `json:"cloud_cleanup_workflow,omitempty"`
	ReaperInitial        int                                 `json:"reaper_initial,omitempty"`
	ReaperDeleted        int                                 `json:"reaper_deleted,omitempty"`
	FinalSurvivors       int                                 `json:"final_survivors"`
	ResourceClassCounts  map[string]int                      `json:"resource_class_counts"`
	StaleTagWarnings     []destroyStaleTagWarning            `json:"stale_tag_warnings,omitempty"`
	Survivors            []destroyCloseoutSurvivor           `json:"survivors,omitempty"`
	UnavailableReason    string                              `json:"unavailable_reason,omitempty"`
}

func (c *destroyCloseout) MarshalJSON() ([]byte, error) {
	if c == nil {
		return []byte("null"), nil
	}
	var resourceClassCounts any
	if c.ResourceClassCounts != nil {
		resourceClassCounts = c.ResourceClassCounts
	}
	type closeoutJSON struct {
		Status               string                              `json:"status"`
		Stages               []destroyCloseoutStage              `json:"stages,omitempty"`
		LastCompletedPhase   string                              `json:"last_completed_phase,omitempty"`
		CurrentFailedPhase   string                              `json:"current_failed_phase,omitempty"`
		FinalVerdict         string                              `json:"final_verdict,omitempty"`
		DirectZeroSummary    string                              `json:"direct_zero_summary_path,omitempty"`
		CloudCleanupWorkflow *destroyCloudCleanupWorkflowHandoff `json:"cloud_cleanup_workflow,omitempty"`
		ReaperInitial        int                                 `json:"reaper_initial,omitempty"`
		ReaperDeleted        int                                 `json:"reaper_deleted,omitempty"`
		FinalSurvivors       int                                 `json:"final_survivors"`
		ResourceClassCounts  any                                 `json:"resource_class_counts,omitempty"`
		StaleTagWarnings     []destroyStaleTagWarning            `json:"stale_tag_warnings,omitempty"`
		Survivors            []destroyCloseoutSurvivor           `json:"survivors,omitempty"`
		UnavailableReason    string                              `json:"unavailable_reason,omitempty"`
	}
	return json.Marshal(closeoutJSON{
		Status:               c.Status,
		Stages:               c.Stages,
		LastCompletedPhase:   c.LastCompletedPhase,
		CurrentFailedPhase:   c.CurrentFailedPhase,
		FinalVerdict:         c.FinalVerdict,
		DirectZeroSummary:    c.DirectZeroSummary,
		CloudCleanupWorkflow: c.CloudCleanupWorkflow,
		ReaperInitial:        c.ReaperInitial,
		ReaperDeleted:        c.ReaperDeleted,
		FinalSurvivors:       c.FinalSurvivors,
		ResourceClassCounts:  resourceClassCounts,
		StaleTagWarnings:     c.StaleTagWarnings,
		Survivors:            c.Survivors,
		UnavailableReason:    c.UnavailableReason,
	})
}

type destroyStaleTagWarning struct {
	Phase     string `json:"phase,omitempty"`
	Verifier  string `json:"verifier,omitempty"`
	ARN       string `json:"arn"`
	Type      string `json:"type"`
	Region    string `json:"region,omitempty"`
	AccountID string `json:"account_id,omitempty"`
	Owner     string `json:"owner,omitempty"`
	SessionID string `json:"session_id,omitempty"`
	Message   string `json:"message,omitempty"`
}

type destroyDirectZeroSummary struct {
	SchemaVersion       string                    `json:"schema_version"`
	Scope               destroyDirectZeroScope    `json:"scope"`
	Status              string                    `json:"status"`
	DirectZero          bool                      `json:"direct_zero"`
	FinalVerdict        string                    `json:"final_verdict"`
	FinalSurvivors      int                       `json:"final_survivors"`
	ResourceClassCounts map[string]int            `json:"resource_class_counts"`
	StaleTagWarnings    []destroyStaleTagWarning  `json:"stale_tag_warnings,omitempty"`
	ReaperInitial       int                       `json:"reaper_initial,omitempty"`
	ReaperDeleted       int                       `json:"reaper_deleted,omitempty"`
	ReaperPasses        int                       `json:"reaper_passes,omitempty"`
	Survivors           []destroyCloseoutSurvivor `json:"survivors,omitempty"`
}

type destroyDirectZeroScope struct {
	Owner     string   `json:"owner,omitempty"`
	SessionID string   `json:"session_id,omitempty"`
	Regions   []string `json:"regions,omitempty"`
}

type destroyCloseoutStage struct {
	Name    string `json:"name"`
	Status  string `json:"status"`
	Message string `json:"message,omitempty"`
}

type destroyCloseoutSurvivor struct {
	ARN       string `json:"arn"`
	Type      string `json:"type"`
	Region    string `json:"region,omitempty"`
	AccountID string `json:"account_id,omitempty"`
	Owner     string `json:"owner,omitempty"`
	SessionID string `json:"session_id,omitempty"`
}

type destroyCloudCleanupWorkflowHandoff struct {
	Result           string   `json:"result,omitempty"`
	Repo             string   `json:"repo,omitempty"`
	Ref              string   `json:"ref,omitempty"`
	Workflow         string   `json:"workflow,omitempty"`
	WorkflowURL      string   `json:"workflow_url,omitempty"`
	RunID            int64    `json:"run_id,omitempty"`
	RunURL           string   `json:"run_url,omitempty"`
	RunStatus        string   `json:"run_status,omitempty"`
	Conclusion       string   `json:"conclusion,omitempty"`
	Owner            string   `json:"owner,omitempty"`
	SessionID        string   `json:"session_id,omitempty"`
	Region           string   `json:"region,omitempty"`
	AccountID        string   `json:"account_id,omitempty"`
	DispatchID       string   `json:"dispatch_id,omitempty"`
	Regions          []string `json:"regions,omitempty"`
	FailurePhase     string   `json:"failure_phase,omitempty"`
	FallbackResult   string   `json:"fallback_result,omitempty"`
	ObservationError string   `json:"observation_error,omitempty"`
}

func (h *destroyCloudCleanupWorkflowHandoff) FormatText() string {
	if h == nil {
		return ""
	}
	var b strings.Builder
	switch strings.TrimSpace(h.Result) {
	case "dispatch_failed":
		b.WriteString("GitHub cleanup workflow dispatch failed")
	default:
		b.WriteString("GitHub cleanup workflow dispatched")
	}
	for _, part := range []string{
		"repo=" + strings.TrimSpace(h.Repo),
		"ref=" + strings.TrimSpace(h.Ref),
		"workflow=" + strings.TrimSpace(h.Workflow),
		"owner=" + strings.TrimSpace(h.Owner),
		"session=" + strings.TrimSpace(h.SessionID),
		"region=" + strings.TrimSpace(h.Region),
	} {
		if !strings.HasSuffix(part, "=") {
			fmt.Fprintf(&b, " %s", part)
		}
	}
	if h.RunID > 0 {
		fmt.Fprintf(&b, " run_id=%d", h.RunID)
	}
	if text := strings.TrimSpace(h.RunStatus); text != "" {
		fmt.Fprintf(&b, " run_status=%s", text)
	}
	if text := strings.TrimSpace(h.Conclusion); text != "" {
		fmt.Fprintf(&b, " conclusion=%s", text)
	}
	if text := strings.TrimSpace(h.FallbackResult); text != "" {
		fmt.Fprintf(&b, " fallback_result=%s", text)
	}
	if text := strings.TrimSpace(h.ObservationError); text != "" {
		fmt.Fprintf(&b, " observation_error=%q", text)
	}
	return strings.TrimSpace(b.String())
}

func (h *destroyCloudCleanupWorkflowHandoff) closeoutTextParts() []string {
	if h == nil {
		return nil
	}
	var parts []string
	if text := strings.TrimSpace(h.WorkflowURL); text != "" {
		parts = append(parts, "cleanup_workflow_url="+text)
	}
	if h.RunID > 0 {
		parts = append(parts, fmt.Sprintf("cleanup_workflow_run_id=%d", h.RunID))
	}
	if text := strings.TrimSpace(h.RunURL); text != "" {
		parts = append(parts, "cleanup_workflow_run_url="+text)
	}
	if text := strings.TrimSpace(h.Conclusion); text != "" {
		parts = append(parts, "cleanup_workflow_conclusion="+text)
	}
	if text := strings.TrimSpace(h.RunStatus); text != "" {
		parts = append(parts, "cleanup_workflow_run_status="+text)
	}
	if text := strings.TrimSpace(h.FailurePhase); text != "" {
		parts = append(parts, "cleanup_workflow_failure_phase="+text)
	}
	if text := strings.TrimSpace(h.FallbackResult); text != "" {
		parts = append(parts, "cleanup_workflow_fallback_result="+text)
	}
	if text := strings.TrimSpace(h.ObservationError); text != "" {
		parts = append(parts, fmt.Sprintf("cleanup_workflow_observation_error=%q", text))
	}
	return parts
}

type destroyOwnerDriftResource struct {
	ARN              string `json:"arn"`
	Type             string `json:"type"`
	Region           string `json:"region,omitempty"`
	AccountID        string `json:"account_id,omitempty"`
	ConflictingOwner string `json:"conflicting_owner"`
	SessionID        string `json:"session_id"`
}

type destroyOwnerDriftError struct {
	PersistedOwner string
	SessionID      string
	AccountID      string
	Region         string
	Resources      []destroyOwnerDriftResource
}

func (e *destroyOwnerDriftError) Error() string {
	if e == nil {
		return "owner tag normalization drift"
	}
	var b strings.Builder
	fmt.Fprintf(&b, "owner tag normalization drift: persisted_owner=%s session=%s", e.PersistedOwner, e.SessionID)
	if e.AccountID != "" {
		fmt.Fprintf(&b, " account=%s", e.AccountID)
	}
	if e.Region != "" {
		fmt.Fprintf(&b, " region=%s", e.Region)
	}
	for _, resource := range e.Resources {
		fmt.Fprintf(
			&b,
			"\n  conflicting_owner=%s session=%s account=%s region=%s type=%s arn=%s",
			resource.ConflictingOwner,
			resource.SessionID,
			resource.AccountID,
			resource.Region,
			resource.Type,
			resource.ARN,
		)
	}
	return b.String()
}

func (r *destroyV1Alpha2Result) FormatText() string {
	if r.Closeout != nil {
		parts := []string{r.Closeout.FormatText()}
		parts = append(parts, r.closeoutEvidenceText()...)
		return strings.Join(parts, "\n")
	}
	parts := appendNonEmptyTextParts(
		nil,
		r.GCPRuntimeDown,
		r.GCPAWSIdentityDestroy,
		r.GCPTerraformDestroy,
		r.TiltDown,
		r.HostCleanup,
		r.TerraformDestroy,
		r.AWSProfileCleanup,
	)
	if r.Reaper != nil {
		parts = appendPrefixedTextPart(parts, "reaper: ", r.Reaper.FormatText())
	}
	if len(r.AWSNukePreparedLBs) > 0 {
		parts = append(parts, fmt.Sprintf("aws-nuke prepared %d load balancers", len(r.AWSNukePreparedLBs)))
	}
	if len(r.AWSNukePreparedVPC) > 0 {
		parts = append(parts, fmt.Sprintf("aws-nuke prepared %d VPC child resources", len(r.AWSNukePreparedVPC)))
	}
	if len(r.AWSNukePreparedEKS) > 0 {
		parts = append(parts, fmt.Sprintf("aws-nuke prepared %d EKS resources", len(r.AWSNukePreparedEKS)))
	}
	if len(r.DirectS3Buckets) > 0 {
		parts = append(parts, fmt.Sprintf("direct S3 cleanup removed %d buckets", len(r.DirectS3Buckets)))
	}
	if len(r.DirectIAMResources) > 0 {
		parts = append(parts, fmt.Sprintf("direct IAM cleanup removed %d resources", len(r.DirectIAMResources)))
	}
	if len(r.DirectSecrets) > 0 {
		parts = append(parts, fmt.Sprintf("direct Secrets Manager cleanup removed %d resources", len(r.DirectSecrets)))
	}
	if len(r.DirectLaunchTemplates) > 0 {
		parts = append(parts, fmt.Sprintf("direct launch template cleanup removed %d resources", len(r.DirectLaunchTemplates)))
	}
	parts = appendNonEmptyTextParts(parts, r.OwnerDriftScan)
	parts = appendNonEmptyTextParts(parts, r.CloudCleanupHandoff)
	if r.SessionRootRemoved {
		parts = append(parts, "session dir removed")
	}
	parts = appendNonEmptyTextParts(parts, r.Message)
	if len(parts) == 0 {
		return "destroy: nothing to clean up"
	}
	return strings.Join(parts, ", ")
}

func (r *destroyV1Alpha2Result) closeoutEvidenceText() []string {
	var parts []string
	if r.AWSNukeConfig != "" {
		parts = append(parts, "  artifact aws_nuke_config="+r.AWSNukeConfig)
	}
	if r.AWSNukeDryRunLog != "" {
		parts = append(parts, "  artifact aws_nuke_dry_run_log="+r.AWSNukeDryRunLog)
	}
	if r.AWSNukeDeleteLog != "" {
		parts = append(parts, "  artifact aws_nuke_delete_log="+r.AWSNukeDeleteLog)
	}
	if r.DirectZeroSummaryPath != "" {
		parts = append(parts, "  artifact direct_zero_summary_path="+r.DirectZeroSummaryPath)
	}
	if len(r.AWSNukePreparedLBs) > 0 {
		parts = append(parts, fmt.Sprintf("  prepared load_balancers=%d", len(r.AWSNukePreparedLBs)))
	}
	if len(r.AWSNukePreparedVPC) > 0 {
		parts = append(parts, fmt.Sprintf("  prepared vpc_children=%d", len(r.AWSNukePreparedVPC)))
	}
	if len(r.AWSNukePreparedEKS) > 0 {
		parts = append(parts, fmt.Sprintf("  prepared eks_resources=%d", len(r.AWSNukePreparedEKS)))
	}
	if len(r.DirectS3Buckets) > 0 {
		parts = append(parts, fmt.Sprintf("  direct_s3_buckets=%d", len(r.DirectS3Buckets)))
	}
	if len(r.DirectIAMResources) > 0 {
		parts = append(parts, fmt.Sprintf("  direct_iam_resources=%d", len(r.DirectIAMResources)))
	}
	if len(r.DirectSecrets) > 0 {
		parts = append(parts, fmt.Sprintf("  direct_secrets_manager_resources=%d", len(r.DirectSecrets)))
	}
	if len(r.DirectLaunchTemplates) > 0 {
		parts = append(parts, fmt.Sprintf("  direct_launch_templates=%d", len(r.DirectLaunchTemplates)))
	}
	if len(r.StaleTagWarnings) > 0 {
		parts = append(parts, fmt.Sprintf("  stale_tag_warnings=%d", len(r.StaleTagWarnings)))
	}
	if text := strings.TrimSpace(r.OwnerDriftScan); text != "" {
		parts = append(parts, fmt.Sprintf("  owner_tag_drift_scan=%q", text))
	}
	if text := strings.TrimSpace(r.CloudCleanupHandoff); text != "" {
		parts = append(parts, fmt.Sprintf("  cloud_cleanup_handoff=%q", text))
	}
	if text := strings.TrimSpace(r.AWSProfileCleanup); text != "" {
		parts = append(parts, fmt.Sprintf("  aws_profile_cleanup=%q", text))
	}
	if r.CloudCleanupWorkflow != nil {
		for _, part := range r.CloudCleanupWorkflow.closeoutTextParts() {
			parts = append(parts, "  "+part)
		}
	}
	if text := strings.TrimSpace(r.Message); text != "" {
		parts = append(parts, fmt.Sprintf("  message=%q", text))
	}
	return parts
}

func (r *destroyV1Alpha2Result) FinalizeCloseout(errs []error) {
	stages := []destroyCloseoutStage{
		closeoutStageFromMessage("gcp_runtime_down", r.GCPRuntimeDown),
		closeoutStageFromMessage("gcp_aws_identity_destroy", r.GCPAWSIdentityDestroy),
		closeoutStageFromMessage("gcp_terraform_destroy", r.GCPTerraformDestroy),
		closeoutStageFromMessage("tilt_down", r.TiltDown),
		closeoutStageFromMessage("host_cleanup", r.HostCleanup),
		closeoutStageFromMessage("terraform_destroy", r.TerraformDestroy),
		r.reaperCloseoutStage(),
		r.sessionRootCloseoutStage(),
	}
	closeout := &destroyCloseout{
		Status:               "complete",
		DirectZeroSummary:    strings.TrimSpace(r.DirectZeroSummaryPath),
		CloudCleanupWorkflow: r.CloudCleanupWorkflow,
		StaleTagWarnings:     append([]destroyStaleTagWarning(nil), r.StaleTagWarnings...),
		Stages:               stages,
	}
	if r.Reaper != nil {
		closeout.ReaperInitial = r.Reaper.Initial
		closeout.ReaperDeleted = r.Reaper.Deleted
		closeout.FinalSurvivors = len(r.Reaper.Survivors)
		closeout.ResourceClassCounts = destroyResourceClassCounts(r.Reaper.Survivors)
		closeout.Survivors = closeoutSurvivors(r.Reaper.Survivors)
	}
	closeout.LastCompletedPhase, closeout.CurrentFailedPhase = destroyProgressPhaseSummary(r.Progress)
	switch {
	case len(errs) > 0:
		closeout.Status = "failed"
		closeout.FinalVerdict = "failed"
		closeout.UnavailableReason = joinDestroyErrors(errs)
	case closeout.FinalSurvivors > 0:
		closeout.Status = "survivors"
		closeout.FinalVerdict = "survivors"
	case closeout.DirectZeroSummary != "":
		closeout.Status = "complete"
		closeout.FinalVerdict = "direct-zero"
	default:
		closeout.Status = "complete"
		closeout.FinalVerdict = "complete"
	}
	r.Closeout = closeout
}

func (r *destroyV1Alpha2Result) reaperCloseoutStage() destroyCloseoutStage {
	if r.CloudCleanupWorkflow != nil {
		status := "delegated"
		if r.CloudCleanupWorkflow.Result == "dispatch_failed" {
			status = "failed"
		}
		return destroyCloseoutStage{
			Name:    "tag_scoped_reap",
			Status:  status,
			Message: r.CloudCleanupWorkflow.FormatText(),
		}
	}
	if text := strings.TrimSpace(r.CloudCleanupHandoff); text != "" {
		return destroyCloseoutStage{Name: "tag_scoped_reap", Status: "delegated", Message: text}
	}
	if r.Reaper == nil {
		return destroyCloseoutStage{Name: "tag_scoped_reap", Status: "unavailable", Message: "tag-scoped reaper did not run"}
	}
	status := "complete"
	if len(r.Reaper.Survivors) > 0 {
		status = "survivors"
	}
	return destroyCloseoutStage{
		Name:    "tag_scoped_reap",
		Status:  status,
		Message: strings.TrimSpace(r.Reaper.FormatText()),
	}
}

func (r *destroyV1Alpha2Result) sessionRootCloseoutStage() destroyCloseoutStage {
	if r.SessionRootRemoved {
		return destroyCloseoutStage{Name: "session_root", Status: "complete", Message: "session dir removed"}
	}
	return destroyCloseoutStage{Name: "session_root", Status: "skipped", Message: "session dir retained"}
}

func closeoutStageFromMessage(name, message string) destroyCloseoutStage {
	message = strings.TrimSpace(message)
	var status string
	switch {
	case message == "":
		status = "unavailable"
	case strings.Contains(message, " error:"):
		status = "error"
	case strings.Contains(message, " skipped"):
		status = "skipped"
	default:
		status = "complete"
	}
	return destroyCloseoutStage{Name: name, Status: status, Message: message}
}

func closeoutSurvivors(resources []cpaws.Resource) []destroyCloseoutSurvivor {
	if len(resources) == 0 {
		return nil
	}
	survivors := make([]destroyCloseoutSurvivor, 0, len(resources))
	for _, resource := range resources {
		survivors = append(survivors, destroyCloseoutSurvivor{
			ARN:       resource.ARN,
			Type:      string(resource.Type),
			Region:    resource.Region,
			AccountID: accountIDFromAWSARN(resource.ARN),
			Owner:     resource.Owner,
			SessionID: resource.SessionID,
		})
	}
	return survivors
}

func destroyResourceClassCounts(resources []cpaws.Resource) map[string]int {
	counts := map[string]int{}
	for _, resource := range resources {
		resourceType := strings.TrimSpace(string(resource.Type))
		if resourceType == "" {
			resourceType = "unknown"
		}
		counts[resourceType]++
	}
	return counts
}

type destroyProgressEmitter struct {
	cc      *model.Cli
	env     *model.Env
	result  *destroyV1Alpha2Result
	mu      sync.Mutex
	started map[string]time.Time
}

func newDestroyProgressEmitter(cc *model.Cli, env *model.Env, result *destroyV1Alpha2Result) *destroyProgressEmitter {
	return &destroyProgressEmitter{
		cc:      cc,
		env:     env,
		result:  result,
		started: make(map[string]time.Time),
	}
}

func (e *destroyProgressEmitter) Emit(phase, status, message, artifact string) {
	if e == nil || e.result == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	now := time.Now()
	if _, ok := e.started[phase]; !ok || status == "started" {
		e.started[phase] = now
	}
	elapsed := ""
	if started, ok := e.started[phase]; ok {
		elapsed = now.Sub(started).Round(time.Second).String()
	}
	event := destroyProgress{
		Phase:    phase,
		Status:   status,
		Elapsed:  elapsed,
		Artifact: strings.TrimSpace(artifact),
		Message:  strings.TrimSpace(message),
	}
	e.result.Progress = append(e.result.Progress, event)
	if e.shouldStreamText() {
		_ = e.cc.Screen.Print(formatDestroyProgress(&event) + "\n")
	}
}

func (e *destroyProgressEmitter) shouldStreamText() bool {
	if e == nil || e.cc == nil || e.cc.Screen == nil {
		return false
	}
	if e.env != nil && strings.EqualFold(strings.TrimSpace(e.env.GetRaw("sys.output.format")), "json") {
		return false
	}
	return true
}

func formatDestroyProgress(event *destroyProgress) string {
	if event == nil {
		return "destroy progress: phase=unknown status=unknown"
	}
	parts := []string{
		"destroy progress:",
		"phase=" + event.Phase,
		"status=" + event.Status,
	}
	if event.Elapsed != "" {
		parts = append(parts, "elapsed="+event.Elapsed)
	}
	if event.Artifact != "" {
		parts = append(parts, "artifact="+event.Artifact)
	}
	if event.Message != "" {
		parts = append(parts, fmt.Sprintf("message=%q", event.Message))
	}
	return strings.Join(parts, " ")
}

func destroyProgressPhaseSummary(events []destroyProgress) (lastCompleted, currentFailed string) {
	for _, event := range events {
		switch event.Status {
		case "completed", "skipped":
			lastCompleted = event.Phase
		case "failed":
			currentFailed = event.Phase
		}
	}
	return lastCompleted, currentFailed
}

func emitDestroyProgress(destroyCtx *v1alpha2DestroyContext, phase, status, message, artifact string) {
	if destroyCtx != nil && destroyCtx.Progress != nil {
		destroyCtx.Progress(phase, status, message, artifact)
	}
}

func accountIDFromAWSARN(arn string) string {
	parts := strings.SplitN(arn, ":", 6)
	if len(parts) < 6 || parts[0] != "arn" {
		return ""
	}
	return parts[4]
}

func (c *destroyCloseout) FormatText() string {
	if c == nil {
		return "destroy: closeout unavailable"
	}
	var b strings.Builder
	fmt.Fprintf(&b, "destroy closeout: %s final_survivors=%d", c.Status, c.FinalSurvivors)
	if strings.TrimSpace(c.FinalVerdict) != "" {
		fmt.Fprintf(&b, " final_verdict=%s", c.FinalVerdict)
	}
	if strings.TrimSpace(c.DirectZeroSummary) != "" {
		fmt.Fprintf(&b, " direct_zero_summary_path=%s", c.DirectZeroSummary)
	}
	if c.CloudCleanupWorkflow != nil {
		for _, part := range c.CloudCleanupWorkflow.closeoutTextParts() {
			fmt.Fprintf(&b, " %s", part)
		}
	}
	if c.ReaperInitial > 0 || c.ReaperDeleted > 0 {
		fmt.Fprintf(&b, " reaper_initial=%d reaper_deleted=%d", c.ReaperInitial, c.ReaperDeleted)
	}
	if counts := formatResourceClassCounts(c.ResourceClassCounts); counts != "" {
		fmt.Fprintf(&b, " resource_class_counts=%s", counts)
	} else if c.ResourceClassCounts != nil {
		fmt.Fprintf(&b, " resource_class_counts=none")
	}
	if len(c.StaleTagWarnings) > 0 {
		fmt.Fprintf(&b, " stale_tag_warnings=%d", len(c.StaleTagWarnings))
	}
	if strings.TrimSpace(c.UnavailableReason) != "" {
		fmt.Fprintf(&b, " unavailable_reason=%q", c.UnavailableReason)
	}
	if c.LastCompletedPhase != "" {
		fmt.Fprintf(&b, " last_completed_phase=%s", c.LastCompletedPhase)
	}
	if c.CurrentFailedPhase != "" {
		fmt.Fprintf(&b, " current_failed_phase=%s", c.CurrentFailedPhase)
	}
	for _, stage := range c.Stages {
		fmt.Fprintf(&b, "\n  %s: %s", stage.Name, stage.Status)
		if strings.TrimSpace(stage.Message) != "" {
			fmt.Fprintf(&b, " - %s", stage.Message)
		}
	}
	for _, survivor := range c.Survivors {
		fmt.Fprintf(&b, "\n  survivor %s type=%s region=%s account=%s owner=%s session=%s",
			survivor.ARN,
			survivor.Type,
			survivor.Region,
			survivor.AccountID,
			survivor.Owner,
			survivor.SessionID,
		)
	}
	for i := range c.StaleTagWarnings {
		if i >= 3 {
			fmt.Fprintf(&b, "\n  stale_tag_warning remaining=%d", len(c.StaleTagWarnings)-i)
			break
		}
		warning := &c.StaleTagWarnings[i]
		fmt.Fprintf(
			&b,
			"\n  stale_tag_warning %s type=%s region=%s account=%s owner=%s session=%s phase=%s verifier=%s",
			warning.ARN,
			warning.Type,
			warning.Region,
			warning.AccountID,
			warning.Owner,
			warning.SessionID,
			warning.Phase,
			warning.Verifier,
		)
		if strings.TrimSpace(warning.Message) != "" {
			fmt.Fprintf(&b, " message=%q", warning.Message)
		}
	}
	return b.String()
}

func formatResourceClassCounts(counts map[string]int) string {
	if len(counts) == 0 {
		return ""
	}
	keys := make([]string, 0, len(counts))
	for key := range counts {
		keys = append(keys, key)
	}
	slices.Sort(keys)
	parts := make([]string, 0, len(keys))
	for _, key := range keys {
		parts = append(parts, fmt.Sprintf("%s:%d", key, counts[key]))
	}
	return strings.Join(parts, ",")
}

type v1alpha2DestroyContext struct {
	StateDir            string
	State               *cluster.State
	Resolved            *config.SessionConfig
	Selected            *host.Session
	AWSState            *cpaws.SessionState
	ReapState           *cpaws.ReapState
	ReapStateSource     destroyReapStateSource
	ReapContractErr     error
	Reaper              *cpaws.Reaper
	TiltPaths           cptilt.Paths
	TerraformPaths      cpterraform.Paths
	GCPTerraformPaths   cpterraform.Paths
	TiltfileExists      bool
	TiltStatusExists    bool
	ContextIndexExists  bool
	TerraformStateOK    bool
	GCPTerraformStateOK bool
	GCPHostExists       bool
	Progress            func(phase, status, message, artifact string)
}

type destroyArtifactState uint8

const (
	destroyArtifactMissing destroyArtifactState = iota
	destroyArtifactFile
)

type v1alpha2DestroyRootClass uint8

const (
	destroyRootPersistedContext v1alpha2DestroyRootClass = iota
	destroyRootConfigScopedShell
	destroyRootInvalidShell
)

const v1alpha2SessionOperationLockDir = "lifecycle-operation-locks"

func v1alpha2SessionOperationLockPath(stateRoot, session string) string {
	digest := sha256.Sum256([]byte(strings.TrimSpace(session)))
	return filepath.Join(
		stateRoot,
		cluster.InternalStateDirName,
		v1alpha2SessionOperationLockDir,
		fmt.Sprintf("sha256-%x.lock", digest[:]),
	)
}

func v1alpha2PreviousSessionOperationLockPath(stateRoot, session string) string {
	digest := sha256.Sum256([]byte(strings.TrimSpace(session)))
	return filepath.Join(
		stateRoot,
		v1alpha2SessionOperationLockDir,
		fmt.Sprintf("sha256-%x.lock", digest[:]),
	)
}

// acquireV1Alpha2SessionOperationLock serializes up and destroy outside the
// per-session root. It acquires the pre-.internal path first so a current
// process also serializes with already-running older binaries. Both lock files
// are intentionally retained: unlinking an advisory lock permits a second
// inode to be locked while the first is live.
func acquireV1Alpha2SessionOperationLock(stateRoot, session string) (func(), error) {
	session = strings.TrimSpace(session)
	if session == "" {
		return nil, fmt.Errorf("session identity is required for the lifecycle operation lock")
	}
	previousRelease, err := acquireV1Alpha2SessionOperationLockFile(
		v1alpha2PreviousSessionOperationLockPath(stateRoot, session),
		session,
	)
	if err != nil {
		return nil, err
	}
	release, err := acquireV1Alpha2SessionOperationLockFile(
		v1alpha2SessionOperationLockPath(stateRoot, session),
		session,
	)
	if err != nil {
		previousRelease()
		return nil, err
	}
	return func() {
		release()
		previousRelease()
	}, nil
}

func acquireV1Alpha2SessionOperationLockFile(lockPath, session string) (func(), error) {
	if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
		return nil, fmt.Errorf("create session lifecycle operation lock directory: %w", err)
	}
	lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
	if err != nil {
		return nil, fmt.Errorf("open session lifecycle operation lock: %w", err)
	}
	if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
		_ = lockFile.Close()
		return nil, fmt.Errorf(
			"session lifecycle operation is already running for %q; wait for the other cp9 process to finish",
			session,
		)
	}
	return func() {
		_ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
		_ = lockFile.Close()
	}, nil
}

func rejectPendingV1Alpha2DestroyCleanup(session string) error {
	intent, err := findReapStateBySession(cluster.DefaultStateDir, session)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("inspect pending destroy cleanup intent: %w", err)
	}
	kind := "cleanup state"
	if intent != nil && intent.ConfigScoped {
		kind = "cleanup intent"
	}
	return fmt.Errorf(
		"pending destroy %s exists for session %q; retry cp9 destroy with the original config before cp9 up",
		kind,
		strings.TrimSpace(session),
	)
}

func runV1Alpha2Destroy(cfgPath, localPath string, localProvided bool, cc *model.Cli, env *model.Env) error {
	session, stateDir, err := loadV1Alpha2DestroySessionStateDir(cfgPath, localPath, localProvided)
	if err != nil {
		return err
	}
	releaseOperationLock, err := acquireV1Alpha2SessionOperationLock(cluster.DefaultStateDir, session.Name)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy: %v", err)
	}
	defer releaseOperationLock()
	lifecycleLock, err := acquireSessionLifecycleLock(context.Background(), session.Name)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy: acquire session lifecycle lock: %v", err)
	}
	defer lifecycleLock.Release()

	result := destroyV1Alpha2Result{StateDir: stateDir}
	exists, err := destroySessionRootExists(stateDir)
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy: %v", err)
	}

	var destroyCtx *v1alpha2DestroyContext
	if exists {
		rootClass, classifyErr := classifyV1Alpha2DestroyRoot(stateDir, session.Name)
		if classifyErr != nil {
			return cp9errors.Newf(cp9errors.CategoryResource, "destroy: %v", classifyErr)
		}
		switch rootClass {
		case destroyRootConfigScopedShell:
			intent, intentErr := findReapStateBySession(cluster.DefaultStateDir, session.Name)
			if intentErr != nil {
				if errors.Is(intentErr, os.ErrNotExist) {
					return cp9errors.Newf(
						cp9errors.CategoryResource,
						"destroy: session root %q has no authoritative destroy context or durable config-scoped intent",
						stateDir,
					)
				}
				return cp9errors.Newf(cp9errors.CategoryResource, "destroy: load config-scoped destroy intent: %v", intentErr)
			}
			destroyCtx, err = loadV1Alpha2DestroyContextFromIntent(
				context.Background(), session, stateDir, intent,
			)
		case destroyRootInvalidShell:
			return cp9errors.Newf(
				cp9errors.CategoryResource,
				"destroy: session root %q has no authoritative destroy context; refusing to discard its BYOC lifecycle shell",
				stateDir,
			)
		default:
			destroyCtx, err = loadV1Alpha2DestroyContext(stateDir)
		}
		if err != nil {
			return cp9errors.Newf(cp9errors.CategoryResource, "destroy: %v", err)
		}
		if !destroyContextHasAuthoritativeState(destroyCtx) {
			return cp9errors.Newf(
				cp9errors.CategoryResource,
				"destroy: session root %q has no authoritative destroy context",
				stateDir,
			)
		}
	} else {
		intent, intentErr := findReapStateBySession(cluster.DefaultStateDir, session.Name)
		switch {
		case intentErr == nil:
			destroyCtx, err = loadV1Alpha2DestroyContextFromIntent(
				context.Background(), session, stateDir, intent,
			)
		case errors.Is(intentErr, os.ErrNotExist):
			destroyCtx, err = loadV1Alpha2DestroyContextFromConfig(context.Background(), session, stateDir)
		default:
			return cp9errors.Newf(cp9errors.CategoryResource, "destroy: load config-scoped destroy intent: %v", intentErr)
		}
	}
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy: %v", err)
	}
	if destroyCtx.ReapState != nil {
		if err := saveReapState(cluster.DefaultStateDir, destroyCtx.ReapState); err != nil {
			return cp9errors.Newf(cp9errors.CategoryResource, "destroy: persist reaper state: %v", err)
		}
	}

	var destroyErrs []error
	var terraformDestroyErr error
	ctx := context.Background()
	progress := newDestroyProgressEmitter(cc, env, &result)
	if destroyCtx != nil {
		destroyCtx.Progress = progress.Emit
	}
	cloudCleanupDelegated := false
	preexistingAcceptedCloudHandoff := false
	terminalRemoteHostGone := false
	dispatchCloudCleanupHandoff := func() error {
		progress.Emit("cloud_cleanup_handoff", "started", "dispatching GitHub cleanup workflow", "")
		handoff, receiptPath, reused, handoffErr := ensureDestroyCloudCleanupHandoff(ctx, destroyCtx)
		if handoffErr != nil {
			result.CloudCleanupWorkflow = destroyFailedCloudCleanupWorkflowHandoff(destroyCtx, handoffErr)
			result.TiltDown = "tilt down skipped: cleanup workflow has no durable accepted handoff; runtime preserved"
			result.HostCleanup = "host cleanup skipped: cleanup workflow has no durable accepted handoff; runtime preserved"
			result.TerraformDestroy = "terraform destroy skipped: cloud cleanup workflow handoff did not complete"
			progress.Emit("cloud_cleanup_handoff", "failed", handoffErr.Error(), receiptPath)
			progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
			progress.Emit("host_cleanup", "skipped", result.HostCleanup, "")
			progress.Emit("terraform_destroy", "skipped", result.TerraformDestroy, destroyCtx.TerraformPaths.DestroyLog)
			return fmt.Errorf("cloud cleanup handoff: %w", handoffErr)
		}
		cloudCleanupDelegated = true
		result.CloudCleanupWorkflow = handoff
		result.CloudCleanupReceipt = receiptPath
		message := handoff.FormatText()
		if reused {
			message += " receipt=reused"
		}
		result.CloudCleanupHandoff = message
		progress.Emit("cloud_cleanup_handoff", "completed", message, receiptPath)
		return nil
	}
	failCloudCleanupHandoff := func(err error) error {
		result.FinalizeCloseout([]error{err})
		if outputErr := model.Output(cc, env, &result); outputErr != nil {
			return outputErr
		}
		return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: %v", err)
	}
	if destroyShouldHandoffCloudCleanup(destroyCtx) &&
		((destroyCtx.Selected != nil && destroyCtx.Selected.DedicatedAPI != nil) ||
			config.DedicatedGCPEnabled(destroyCtx.Resolved)) {
		var handoffErr error
		preexistingAcceptedCloudHandoff, handoffErr = acceptedDestroyCloudCleanupHandoffExists(destroyCtx)
		if handoffErr != nil {
			return cp9errors.Newf(cp9errors.CategoryResource, "destroy: inspect existing cleanup workflow handoff: %v", handoffErr)
		}
	}
	dashboardStopMessage, dashboardStopErr := destroyStarterDashboardForward(stateDir, session.Name)
	if dashboardStopErr != nil {
		destroyErrs = append(destroyErrs, fmt.Errorf("starter dashboard process stop: %w", dashboardStopErr))
		progress.Emit("starter_dashboard_stop", "failed", dashboardStopErr.Error(), starterDashboardAccessStatePath(stateDir))
	} else if strings.TrimSpace(dashboardStopMessage) != "" {
		progress.Emit("starter_dashboard_stop", "completed", dashboardStopMessage, starterDashboardAccessStatePath(stateDir))
	}
	if destroyCtx != nil && destroyCtx.Resolved != nil && config.BYOCConfigured(destroyCtx.Resolved) {
		progress.Emit(
			"byoc_cleanup",
			"started",
			"deleting Empty dataplane, isolated regional runtime, bound cross-account resources, and lease",
			byoc.JournalPath(stateDir),
		)
		byocResult, byocErr := destroyBYOC(ctx, destroyCtx.Resolved, destroyCtx.Selected, stateDir)
		if byocErr != nil {
			result.BYOCCleanup = byocResult
			result.TiltDown = "tilt down skipped: BYOC cleanup is incomplete; regional controllers were preserved"
			result.HostCleanup = "host cleanup skipped: BYOC cleanup is incomplete"
			progress.Emit("byoc_cleanup", "failed", byocErr.Error(), byoc.JournalPath(stateDir))
			progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
			progress.Emit("host_cleanup", "skipped", result.HostCleanup, "")
			destroyErr := fmt.Errorf("BYOC cleanup: %w", byocErr)
			result.FinalizeCloseout([]error{destroyErr})
			if outputErr := model.Output(cc, env, &result); outputErr != nil {
				return outputErr
			}
			return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: %v", destroyErr)
		}
		result.BYOCCleanup = byocResult
		progress.Emit(
			"byoc_cleanup",
			"completed",
			"BYOC session-owned resources are absent; shared control-plane setup principal, customer DNS/PCA, and ECR cache retained",
			byoc.JournalPath(stateDir),
		)
	}
	if config.DedicatedGCPEnabled(destroyCtx.Resolved) {
		if preexistingAcceptedCloudHandoff {
			result.GCPRuntimeDown = "GCP runtime down skipped: pre-existing durable cleanup workflow handoff may already own or have removed the GCE runtime"
			progress.Emit("gcp_runtime_down", "skipped", result.GCPRuntimeDown, "")
		} else {
			progress.Emit("gcp_runtime_down", "started", "stopping the GCP remote Tilt runtime before delegated GCE cleanup", "")
			message, err := destroyGCPRuntimeController(ctx, stateDir)
			if err != nil {
				result.GCPRuntimeDown = "GCP runtime down error: " + err.Error()
				result.GCPAWSIdentityDestroy = "GCP AWS identity destroy skipped: GCP runtime down is incomplete"
				result.GCPTerraformDestroy = "GCP terraform destroy skipped: GCP runtime down is incomplete"
				result.TiltDown = "primary tilt down skipped: GCP runtime down is incomplete"
				result.HostCleanup = "host cleanup skipped: GCP runtime down is incomplete"
				result.TerraformDestroy = "AWS terraform destroy skipped: GCP runtime down is incomplete"
				progress.Emit("gcp_runtime_down", "failed", err.Error(), "")
				result.FinalizeCloseout([]error{fmt.Errorf("GCP runtime down: %w", err)})
				if outputErr := model.Output(cc, env, &result); outputErr != nil {
					return outputErr
				}
				return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: GCP runtime down: %v", err)
			}
			result.GCPRuntimeDown = message
			progressStatus := "completed"
			if strings.Contains(message, "skipped") {
				progressStatus = "skipped"
			}
			progress.Emit("gcp_runtime_down", progressStatus, message, "")
		}
		if destroyShouldHandoffCloudCleanup(destroyCtx) && !cloudCleanupDelegated {
			if err := dispatchCloudCleanupHandoff(); err != nil {
				return failCloudCleanupHandoff(err)
			}
		}
	}
	if config.DedicatedGCPEnabled(destroyCtx.Resolved) {
		if !cloudCleanupDelegated {
			return cp9errors.New(cp9errors.CategoryResource, "destroy: GCP cloud cleanup has no durable accepted handoff")
		}
		result.GCPAWSIdentityDestroy = "GCP AWS identity terraform destroy skipped: delegated to GitHub cleanup workflow"
		result.GCPTerraformDestroy = "GCP terraform destroy skipped: delegated to GitHub cleanup workflow"
		progress.Emit("gcp_aws_identity_destroy", "skipped", result.GCPAWSIdentityDestroy, "")
		progress.Emit("gcp_terraform_destroy", "skipped", result.GCPTerraformDestroy, "")
	}
	if !preexistingAcceptedCloudHandoff && destroyCtx != nil && destroyCtx.Selected != nil &&
		destroyCtx.Selected.DedicatedAPI != nil {
		terminalRemoteHostGone, err = destroyRemoteRuntimeHostGoneForContext(ctx, destroyCtx)
		if err != nil {
			return cp9errors.Newf(cp9errors.CategoryResource, "destroy: %v", err)
		}
	}
	if destroyShouldHandoffCloudCleanup(destroyCtx) && !cloudCleanupDelegated {
		if err := dispatchCloudCleanupHandoff(); err != nil {
			return failCloudCleanupHandoff(err)
		}
	}
	deferDedicatedRuntimeCleanup := !cloudCleanupDelegated && destroyCtx.Selected != nil &&
		destroyCtx.Selected.DedicatedAPI != nil && !destroyHostCleanupDelegatedToWorkflow(destroyCtx)
	runTiltRuntimeCleanup := func() {
		if destroyCtx.TiltfileExists {
			progress.Emit("tilt_down", "started", "running tilt down", destroyCtx.TiltPaths.Tiltfile)
			switch {
			case preexistingAcceptedCloudHandoff:
				result.TiltDown = "tilt down skipped: pre-existing durable cleanup workflow handoff may already own or have removed the selected host"
				progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
			case destroyRemoteHostMissingBeforeReady(destroyCtx):
				result.TiltDown = "tilt down skipped: no persisted remote host before host_ready"
				progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
			case terminalRemoteHostGone:
				result.TiltDown = "tilt down skipped: exact remote EC2 host is terminal"
				progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
			case destroyCtx.Selected != nil &&
				destroyCtx.Selected.Mode == config.HostModeRemoteEC2K3d &&
				!destroyCtx.TiltStatusExists:
				result.TiltDown = "tilt down skipped: remote Tilt controller was not started"
				progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.Status)
			case !destroyCtx.ContextIndexExists:
				result.TiltDown = "tilt down skipped: no persisted session context"
				progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.ContextIndex)
			case destroyCtx.Selected == nil:
				err := fmt.Errorf("persisted selected host is required for tilt down")
				result.TiltDown = "tilt down error: " + err.Error()
				destroyErrs = append(destroyErrs, fmt.Errorf("tilt down: %w", err))
				progress.Emit("tilt_down", "failed", err.Error(), destroyCtx.TiltPaths.Tiltfile)
			case strings.TrimSpace(destroyCtx.Selected.Context) == "":
				err := fmt.Errorf("persisted selected host is missing kube context")
				result.TiltDown = "tilt down error: " + err.Error()
				destroyErrs = append(destroyErrs, fmt.Errorf("tilt down: %w", err))
				progress.Emit("tilt_down", "failed", err.Error(), destroyCtx.TiltPaths.Tiltfile)
			default:
				req := &cptilt.Request{
					Tiltfile:    destroyCtx.TiltPaths.Tiltfile,
					KubeContext: destroyCtx.Selected.Context,
					Kubeconfig:  destroyCtx.Selected.KubeconfigPath,
					Env: append(
						destroyCtx.Selected.KubectlEnv(),
						"CP9_SESSION_CONTEXT="+absDestroyPath(destroyCtx.TiltPaths.ContextIndex),
					),
				}
				var tiltDownErr error
				if persistedStatus, statusErr := cptilt.LoadStatus(destroyCtx.TiltPaths.Status); statusErr == nil &&
					persistedStatus.Controller == cptilt.ControllerRemote {
					tiltDownErr = cptilt.DownForHost(ctx, destroyCtx.Selected, persistedStatus, req)
				} else {
					tiltDownErr = destroyTiltDown(ctx, req)
				}
				if tiltDownErr != nil {
					if destroyTiltDownSelectedHostAPIUnavailableForCloseout(destroyCtx.Selected, tiltDownErr) {
						result.TiltDown = "tilt down skipped: selected host API unavailable for closeout: " + tiltDownErr.Error()
						progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
					} else {
						result.TiltDown = "tilt down error: " + tiltDownErr.Error()
						destroyErrs = append(destroyErrs, fmt.Errorf("tilt down: %w", tiltDownErr))
						progress.Emit("tilt_down", "failed", tiltDownErr.Error(), destroyCtx.TiltPaths.Tiltfile)
					}
				} else {
					result.TiltDown = "tilt down completed"
					progress.Emit("tilt_down", "completed", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
				}
			}
		} else {
			result.TiltDown = "tilt down skipped: no persisted Tiltfile"
			progress.Emit("tilt_down", "skipped", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
		}
		if stopMsg, err := stopPersistedTiltProcess(ctx, destroyCtx.TiltPaths.Status, stateDir); err != nil {
			result.TiltDown = strings.TrimSpace(result.TiltDown + "; tilt process stop error: " + err.Error())
			destroyErrs = append(destroyErrs, fmt.Errorf("tilt process stop: %w", err))
		} else if strings.TrimSpace(stopMsg) != "" {
			result.TiltDown = strings.TrimSpace(result.TiltDown + "; " + stopMsg)
		}
		if dashboardStopErr != nil {
			result.TiltDown = strings.TrimSpace(result.TiltDown + "; Starter dashboard process stop error: " + dashboardStopErr.Error())
		} else if strings.TrimSpace(dashboardStopMessage) != "" {
			result.TiltDown = strings.TrimSpace(result.TiltDown + "; " + dashboardStopMessage)
		}
	}
	if deferDedicatedRuntimeCleanup {
		result.TiltDown = "tilt down deferred until tag and known-resource cleanup proves zero"
		progress.Emit("tilt_down", "deferred", result.TiltDown, destroyCtx.TiltPaths.Tiltfile)
	} else {
		runTiltRuntimeCleanup()
	}

	switch {
	case cloudCleanupDelegated && destroyHostCleanupDelegatedToWorkflow(destroyCtx):
		switch {
		case preexistingAcceptedCloudHandoff:
			result.HostCleanup = "host runtime stop skipped: pre-existing durable cleanup workflow handoff may already own or have removed the selected host"
			progress.Emit("host_cleanup", "skipped", result.HostCleanup, "")
		case destroyRemoteHostMissingBeforeReady(destroyCtx):
			result.HostCleanup = "host runtime stop skipped: no persisted remote host before host_ready; cloud cleanup delegated to GitHub cleanup workflow"
			progress.Emit("host_cleanup", "skipped", result.HostCleanup, "")
		case terminalRemoteHostGone:
			result.HostCleanup = "host runtime stop skipped: exact remote EC2 host is terminal; cloud cleanup delegated to GitHub cleanup workflow"
			progress.Emit("host_cleanup", "skipped", result.HostCleanup, "")
		default:
			progress.Emit("host_cleanup", "started", "stopping selected host runtime before delegated cloud cleanup", "")
			if err := destroyStopSelectedHostRuntime(ctx, destroyCtx.Selected); err != nil {
				result.HostCleanup = "host runtime stop error: " + err.Error()
				destroyErrs = append(destroyErrs, fmt.Errorf("host runtime stop: %w", err))
				progress.Emit("host_cleanup", "failed", err.Error(), "")
			} else {
				result.HostCleanup = "host runtime stopped; cloud host cleanup delegated to GitHub cleanup workflow"
				progress.Emit("host_cleanup", "completed", result.HostCleanup, "")
			}
		}
	case deferDedicatedRuntimeCleanup:
		result.HostCleanup = "host cleanup deferred until tag and known-resource cleanup proves zero"
		progress.Emit("host_cleanup", "deferred", result.HostCleanup, "")
	case destroyCtx.Selected != nil:
		progress.Emit("host_cleanup", "started", "destroying selected host", "")
		hostCleanup, err := runV1Alpha2DestroyHostCleanup(ctx, destroyCtx)
		if err != nil {
			result.HostCleanup = "host cleanup error: " + err.Error()
			destroyErrs = append(destroyErrs, fmt.Errorf("host cleanup: %w", err))
			progress.Emit("host_cleanup", "failed", err.Error(), "")
		} else {
			result.HostCleanup = hostCleanup
			progress.Emit("host_cleanup", "completed", result.HostCleanup, "")
		}
	default:
		result.HostCleanup = "host cleanup skipped: no persisted selected host"
		progress.Emit("host_cleanup", "skipped", result.HostCleanup, "")
	}

	cloudCleanupCompleted := false
	if destroyCtx.ReapState != nil && destroyCtx.ReapState.ConfigScoped && destroyCtx.ReapState.Completed {
		cloudCleanupCompleted = true
		if destroyShouldHandoffCloudCleanup(destroyCtx) {
			cloudCleanupDelegated = true
			result.CloudCleanupHandoff = "GitHub cleanup workflow handoff already completed before closeout retry"
			progress.Emit("cloud_cleanup_handoff", "completed", result.CloudCleanupHandoff, "")
		} else {
			result.Reaper = &cpaws.ReapResult{Scope: destroyCtx.ReapState.Scope}
			progress.Emit("tag_scoped_reap", "completed", "config-scoped cleanup already completed before closeout retry", "")
		}
	}
	runCloudCleanup := func() {
		if destroyCtx.ReapState == nil || cloudCleanupCompleted {
			return
		}
		cloudCleanupCompleted = true
		stage2, nukeEvidence, reapErr := destroyV1Alpha2CloudCleanup(ctx, destroyCtx)
		if strings.TrimSpace(nukeEvidence.ConfigPath) != "" {
			result.AWSNukeConfig = nukeEvidence.ConfigPath
		}
		if strings.TrimSpace(nukeEvidence.DryRunLog) != "" {
			result.AWSNukeDryRunLog = nukeEvidence.DryRunLog
		}
		if strings.TrimSpace(nukeEvidence.DeleteLog) != "" {
			result.AWSNukeDeleteLog = nukeEvidence.DeleteLog
		}
		if strings.TrimSpace(nukeEvidence.DirectZeroSummaryPath) != "" {
			result.DirectZeroSummaryPath = nukeEvidence.DirectZeroSummaryPath
		}
		if len(nukeEvidence.PreparedLoadBalancers) > 0 {
			result.AWSNukePreparedLBs = append([]string(nil), nukeEvidence.PreparedLoadBalancers...)
		}
		if len(nukeEvidence.PreparedVPCChildren) > 0 {
			result.AWSNukePreparedVPC = append([]string(nil), nukeEvidence.PreparedVPCChildren...)
		}
		if len(nukeEvidence.PreparedEKSResources) > 0 {
			result.AWSNukePreparedEKS = append([]string(nil), nukeEvidence.PreparedEKSResources...)
		}
		if len(nukeEvidence.DirectS3Buckets) > 0 {
			result.DirectS3Buckets = append([]string(nil), nukeEvidence.DirectS3Buckets...)
		}
		if len(nukeEvidence.DirectIAMResources) > 0 {
			result.DirectIAMResources = append([]string(nil), nukeEvidence.DirectIAMResources...)
		}
		if len(nukeEvidence.DirectSecretsManager) > 0 {
			result.DirectSecrets = append([]string(nil), nukeEvidence.DirectSecretsManager...)
		}
		if len(nukeEvidence.DirectLaunchTemplates) > 0 {
			result.DirectLaunchTemplates = append([]string(nil), nukeEvidence.DirectLaunchTemplates...)
		}
		if len(nukeEvidence.StaleTagWarnings) > 0 {
			result.StaleTagWarnings = append([]destroyStaleTagWarning(nil), nukeEvidence.StaleTagWarnings...)
		}
		if strings.TrimSpace(nukeEvidence.OwnerDriftScan) != "" {
			result.OwnerDriftScan = strings.TrimSpace(nukeEvidence.OwnerDriftScan)
		}
		if stage2 != nil {
			result.Reaper = stage2
		}
		switch {
		case reapErr != nil:
			destroyErrs = append(destroyErrs, fmt.Errorf("aws-nuke cleanup: %w", reapErr))
		case stage2 == nil:
			destroyErrs = append(destroyErrs, fmt.Errorf("aws-nuke cleanup returned no result"))
		case destroyShouldFailOnEmptyInitialScan(destroyCtx.ReapStateSource, destroyCtx.ReapState, stage2) &&
			!destroyAllowsEmptyInitialScan(destroyCtx, terraformDestroyErr):
			destroyCtx.ReapState.Completed = false
			destroyCtx.ReapState.Survivors = nil
			if saveErr := saveReapState(cluster.DefaultStateDir, destroyCtx.ReapState); saveErr != nil {
				destroyErrs = append(destroyErrs, fmt.Errorf(
					"empty initial Stage-2 scan violated C4 tagging contract and failed to persist reaper state: %w",
					saveErr,
				))
			} else {
				destroyErrs = append(destroyErrs, fmt.Errorf(
					"empty initial Stage-2 scan with saved state context indicates a tagging gap",
				))
			}
		case len(stage2.Survivors) > 0:
			destroyCtx.ReapState.Completed = false
			destroyCtx.ReapState.Survivors = stage2.Survivors
			if saveErr := saveReapState(cluster.DefaultStateDir, destroyCtx.ReapState); saveErr != nil {
				destroyErrs = append(destroyErrs, fmt.Errorf(
					"post-scan left %d survivor(s) and failed to persist reaper state: %w",
					len(stage2.Survivors), saveErr,
				))
			}
			destroyErrs = append(destroyErrs, fmt.Errorf("post-scan left %d tagged survivor(s)", len(stage2.Survivors)))
		default:
			destroyCtx.ReapState.Survivors = nil
			if len(destroyErrs) > 0 || destroyCtx.ReapState.ConfigScoped {
				destroyCtx.ReapState.Completed = true
				if saveErr := saveReapState(cluster.DefaultStateDir, destroyCtx.ReapState); saveErr != nil {
					destroyErrs = append(destroyErrs, fmt.Errorf("persist successful reaper state: %w", saveErr))
				}
			} else {
				destroyCtx.ReapState.Completed = false
				if removeErr := removeReapState(
					cluster.DefaultStateDir,
					destroyCtx.ReapState.Scope.Owner,
					destroyCtx.ReapState.Scope.SessionID,
				); removeErr != nil {
					destroyErrs = append(destroyErrs, fmt.Errorf("remove reaper state: %w", removeErr))
				}
			}
		}
	}
	if destroyShouldHandoffCloudCleanup(destroyCtx) {
		if cloudCleanupDelegated {
			result.TerraformDestroy = "terraform destroy skipped: delegated to GitHub cleanup workflow"
		} else {
			result.TerraformDestroy = "terraform destroy skipped: cloud cleanup workflow handoff did not complete"
		}
		progress.Emit("terraform_destroy", "skipped", result.TerraformDestroy, destroyCtx.TerraformPaths.DestroyLog)
	} else {
		if destroyCtx.TerraformStateOK {
			progress.Emit("terraform_destroy", "started", "running Terraform destroy", destroyCtx.TerraformPaths.DestroyLog)
			if err := destroyTerraformDestroy(ctx, &destroyCtx.TerraformPaths); err != nil {
				terraformDestroyErr = err
				result.TerraformDestroy = "terraform destroy error: " + err.Error()
				destroyErrs = append(destroyErrs, fmt.Errorf("terraform destroy: %w", err))
				progress.Emit("terraform_destroy", "failed", err.Error(), destroyCtx.TerraformPaths.DestroyLog)
			} else {
				result.TerraformDestroy = "terraform destroy completed"
				progress.Emit("terraform_destroy", "completed", result.TerraformDestroy, destroyCtx.TerraformPaths.DestroyLog)
			}
		} else {
			result.TerraformDestroy = "terraform destroy skipped: no persisted Terraform state"
			progress.Emit("terraform_destroy", "skipped", result.TerraformDestroy, destroyCtx.TerraformPaths.DestroyLog)
		}

		runCloudCleanup()
	}
	if destroyCtx.ReapContractErr != nil {
		destroyErrs = append(destroyErrs, destroyCtx.ReapContractErr)
	}
	if terraformDestroyErr != nil && result.Reaper != nil && len(result.Reaper.Survivors) == 0 {
		result.Message = terraformOnlyResidualMessage(cfgPath)
		destroyErrs = removeTerraformOnlyResidualErrors(destroyErrs, terraformDestroyErr)
		if len(destroyErrs) == 0 && destroyCtx.ReapState != nil {
			if removeErr := removeReapState(
				cluster.DefaultStateDir,
				destroyCtx.ReapState.Scope.Owner,
				destroyCtx.ReapState.Scope.SessionID,
			); removeErr != nil {
				destroyErrs = append(destroyErrs, fmt.Errorf("remove reaper state: %w", removeErr))
			}
		}
	}
	if deferDedicatedRuntimeCleanup {
		if len(destroyErrs) > 0 {
			result.HostCleanup = "host cleanup skipped: cloud cleanup was not proven successful; runtime preserved"
			progress.Emit("host_cleanup", "skipped", result.HostCleanup, "")
		} else {
			runTiltRuntimeCleanup()
			if len(destroyErrs) > 0 {
				result.HostCleanup = "host cleanup skipped: Tilt runtime teardown failed; host preserved"
				progress.Emit("host_cleanup", "skipped", result.HostCleanup, "")
			}
		}
		if len(destroyErrs) == 0 {
			progress.Emit("host_cleanup", "started", "cloud cleanup passed; destroying selected host last", "")
			hostCleanup, hostCleanupErr := runV1Alpha2DestroyHostCleanup(ctx, destroyCtx)
			if hostCleanupErr != nil {
				result.HostCleanup = "host cleanup error: " + hostCleanupErr.Error()
				destroyErrs = append(destroyErrs, fmt.Errorf("host cleanup: %w", hostCleanupErr))
				progress.Emit("host_cleanup", "failed", hostCleanupErr.Error(), "")
			} else {
				result.HostCleanup = hostCleanup
				progress.Emit("host_cleanup", "completed", result.HostCleanup, "")
			}
		}
	}
	if cloudCleanupDelegated && len(destroyErrs) == 0 {
		if removeErr := removeReapState(
			cluster.DefaultStateDir,
			destroyCtx.ReapState.Scope.Owner,
			destroyCtx.ReapState.Scope.SessionID,
		); removeErr != nil {
			destroyErrs = append(destroyErrs, fmt.Errorf("remove delegated reaper state: %w", removeErr))
			progress.Emit("cloud_cleanup_handoff", "failed", removeErr.Error(), result.CloudCleanupReceipt)
		}
	}
	if len(destroyErrs) == 0 && destroyCtx.Resolved != nil {
		profile := strings.TrimSpace(destroyCtx.Resolved.Target.AWS.IAM.Profile)
		if profile != "" {
			message, cleanupErr := destroyAWSProfileCleanup(profile, destroyCtx.Resolved.Name)
			if cleanupErr != nil {
				destroyErrs = append(destroyErrs, fmt.Errorf("cleanup owned AWS profile: %w", cleanupErr))
				progress.Emit("aws_profile_cleanup", "failed", cleanupErr.Error(), "")
			} else {
				result.AWSProfileCleanup = message
				progress.Emit("aws_profile_cleanup", "completed", message, "")
			}
		}
	}

	if len(destroyErrs) == 0 {
		progress.Emit("session_root_remove", "started", "removing session directory", stateDir)
		if err := destroyRemoveSessionRoot(stateDir); err != nil {
			destroyErrs = append(destroyErrs, fmt.Errorf("remove session dir: %w", err))
			progress.Emit("session_root_remove", "failed", err.Error(), stateDir)
		} else {
			result.SessionRootRemoved = true
			progress.Emit("session_root_remove", "completed", "session dir removed", stateDir)
			if destroyCtx.ReapState != nil && destroyCtx.ReapState.ConfigScoped {
				if removeErr := removeReapState(
					cluster.DefaultStateDir,
					destroyCtx.ReapState.Scope.Owner,
					destroyCtx.ReapState.Scope.SessionID,
				); removeErr != nil {
					destroyErrs = append(destroyErrs, fmt.Errorf("remove config-scoped destroy intent: %w", removeErr))
					progress.Emit("config_destroy_intent_remove", "failed", removeErr.Error(), "")
				} else {
					progress.Emit("config_destroy_intent_remove", "completed", "config-scoped destroy intent removed", "")
				}
			}
		}
	} else {
		progress.Emit("session_root_remove", "skipped", "session dir retained because destroy has errors", stateDir)
	}

	result.FinalizeCloseout(destroyErrs)
	if err := model.Output(cc, env, &result); err != nil {
		return err
	}
	if len(destroyErrs) == 0 {
		return nil
	}
	return cp9errors.Newf(cp9errors.CategoryResource, "destroy failed: %s", joinDestroyErrors(destroyErrs))
}

func loadV1Alpha2DestroySessionStateDir(cfgPath, localPath string, localProvided bool) (*config.SessionConfig, string, error) {
	if err := rejectV1Alpha2LocalConfigOverride(localPath, localProvided); err != nil {
		return nil, "", err
	}
	session, err := config.LoadSessionForDestroy(cfgPath)
	if err != nil {
		return nil, "", err
	}
	return session, cluster.SessionStateDir(session.Name), nil
}

func destroyHostCleanupDelegatedToWorkflow(destroyCtx *v1alpha2DestroyContext) bool {
	if destroyCtx == nil || destroyCtx.Selected == nil || !destroyShouldHandoffCloudCleanup(destroyCtx) {
		return false
	}
	return destroyCtx.Selected.Mode != config.HostModeLocalK3d
}

func destroyRemoteHostMissingBeforeReady(destroyCtx *v1alpha2DestroyContext) bool {
	if destroyCtx == nil || !destroyRemoteSelectedHostMayBeMissing(destroyCtx.State) {
		return false
	}
	if destroyCtx.Selected != nil {
		return destroyCtx.Selected.Mode == config.HostModeRemoteEC2K3d &&
			(destroyCtx.Selected.Remote == nil || strings.TrimSpace(destroyCtx.Selected.Remote.InstanceID) == "")
	}
	if destroyCtx.Resolved != nil {
		return destroyCtx.Resolved.Host.Mode == config.HostModeRemoteEC2K3d
	}
	if destroyCtx.State == nil {
		return false
	}
	return destroyCtx.State.HostMode == config.HostModeRemoteEC2K3d
}

func destroyRemoteRuntimeHostGoneForContext(ctx context.Context, destroyCtx *v1alpha2DestroyContext) (bool, error) {
	if destroyCtx == nil || destroyCtx.Selected == nil {
		return false, nil
	}
	selected := destroyCtx.Selected
	if selected.Mode == config.HostModeRemoteEC2K3d {
		if destroyRemoteHostMissingBeforeReady(destroyCtx) {
			return true, nil
		}
		if selected.Remote == nil {
			return false, fmt.Errorf("observe remote runtime host: persisted remote host is required")
		}
		instanceID := strings.TrimSpace(selected.Remote.InstanceID)
		if instanceID == "" {
			return false, fmt.Errorf("observe remote runtime host: persisted remote host is missing instance ID")
		}
		if destroyCtx.AWSState == nil {
			return false, fmt.Errorf("observe remote runtime host: persisted remote host and AWS state are required")
		}
		remoteRegion := strings.TrimSpace(selected.Remote.Region)
		awsRegion := strings.TrimSpace(destroyCtx.AWSState.Region)
		if remoteRegion != "" && awsRegion != "" && remoteRegion != awsRegion {
			return false, fmt.Errorf(
				"observe remote runtime host: persisted host region %s does not match AWS state region %s",
				remoteRegion,
				awsRegion,
			)
		}
		gone, err := destroyObserveRemoteRuntimeHostGone(ctx, byoc.Input{
			Region:                firstNonEmptyString(remoteRegion, awsRegion),
			ControlPlaneAccountID: strings.TrimSpace(destroyCtx.AWSState.Account),
			ControlPlaneProfile:   firstNonEmptyString(selected.Remote.Profile, destroyCtx.AWSState.Profile),
			RemoteInstanceID:      instanceID,
		})
		if err != nil {
			return false, fmt.Errorf("observe remote runtime host: %w", err)
		}
		if gone {
			return true, nil
		}
	}
	return false, nil
}

func runV1Alpha2DestroyHostCleanup(ctx context.Context, destroyCtx *v1alpha2DestroyContext) (string, error) {
	if destroyCtx == nil || destroyCtx.Selected == nil {
		return "host cleanup skipped: no persisted selected host", nil
	}
	hosts := v1alpha2DestroyHostCleanupTargets(destroyCtx)
	var messages []string
	for _, selected := range hosts {
		if selected == nil {
			continue
		}
		if err := destroySelectedHost(ctx, selected); err != nil {
			return "", err
		}
		messages = append(messages, describeDestroyedSelectedHost(selected))
	}
	if len(messages) == 0 {
		return "host cleanup skipped: no persisted selected host", nil
	}
	return strings.Join(messages, "; "), nil
}

func v1alpha2DestroyHostCleanupTargets(destroyCtx *v1alpha2DestroyContext) []*host.Session {
	if destroyCtx == nil || destroyCtx.Selected == nil {
		return nil
	}
	var targets []*host.Session
	if destroyShouldHandoffCloudCleanup(destroyCtx) && destroyCtx.Selected.Mode != config.HostModeLocalK3d {
		return targets
	}
	targets = append(targets, destroyCtx.Selected)
	return targets
}

func makeDestroyScopedReaper(destroyCtx *v1alpha2DestroyContext) (*cpaws.Reaper, error) {
	if destroyCtx == nil || destroyCtx.ReapState == nil {
		return nil, fmt.Errorf("destroy reaper state is required")
	}
	profile := strings.TrimSpace(destroyCtx.ReapState.Profile)
	if profile == "" {
		return nil, fmt.Errorf("destroy reaper profile is required")
	}
	scope := destroyCtx.ReapState.Scope.Normalize()
	var reaper *cpaws.Reaper
	err := withDestroyReaperSessionEnv(scope.Owner, scope.SessionID, func() error {
		var err error
		reaper, err = makeScopedReaper(profile)
		return err
	})
	return reaper, err
}

func destroySourceProfile(destroyCtx *v1alpha2DestroyContext) string {
	if destroyCtx == nil {
		return ""
	}
	if destroyCtx.AWSState != nil {
		if sourceProfile := strings.TrimSpace(destroyCtx.AWSState.SourceProfile); sourceProfile != "" {
			return sourceProfile
		}
	}
	if destroyCtx.Resolved != nil {
		return strings.TrimSpace(destroyCtx.Resolved.Target.AWS.IAM.SourceProfile)
	}
	return ""
}

func runV1Alpha2AWSNukeCleanup(
	ctx context.Context,
	destroyCtx *v1alpha2DestroyContext,
) (*cpaws.ReapResult, destroyNukeEvidence, error) {
	var evidence destroyNukeEvidence
	if destroyCtx == nil || destroyCtx.ReapState == nil {
		return nil, evidence, fmt.Errorf("destroy reaper state is required")
	}
	if destroyCtx.AWSState != nil {
		if err := validateDestroySessionState(destroyCtx.AWSState); err != nil {
			return nil, evidence, err
		}
	}
	scope := destroyCtx.ReapState.Scope.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, evidence, err
	}
	if scope.Owner == "" || scope.SessionID == "" {
		return nil, evidence, fmt.Errorf("aws-nuke cleanup requires owner/session scope")
	}
	accountID := ""
	if destroyCtx.AWSState != nil {
		accountID = strings.TrimSpace(destroyCtx.AWSState.Account)
	}
	if accountID == "" {
		accountID = v1alpha2DestroyReaperAccountID(destroyCtx.Resolved)
	}
	if accountID == "" {
		return nil, evidence, fmt.Errorf("persisted AWS session state is missing account")
	}

	destroyDir := cluster.DestroyDir(destroyCtx.StateDir)
	configPath := filepath.Join(destroyDir, "aws-nuke.yaml")
	evidence.ConfigPath = configPath
	reaper := destroyCtx.Reaper
	if reaper == nil {
		var err error
		reaper, err = makeDestroyScopedReaper(destroyCtx)
		if err != nil {
			return nil, evidence, fmt.Errorf("configure cleanup verifier: %w", err)
		}
	}
	if err := detectDestroyOwnerTagDrift(ctx, reaper, scope, accountID); err != nil {
		var driftErr *destroyOwnerDriftError
		if errors.As(err, &driftErr) {
			return nil, evidence, err
		}
		evidence.OwnerDriftScan = "owner tag drift scan unavailable: " + err.Error()
	}
	runReq := cpaws.NukeRunRequest{
		ConfigPath:     configPath,
		Profile:        strings.TrimSpace(destroyCtx.ReapState.Profile),
		Region:         firstNonEmptyString(destroyReapRegions(destroyCtx.Resolved, destroyCtx.AWSState)...),
		AccountID:      accountID,
		SessionName:    "cp9-destroy-" + scope.SessionID,
		MaxWaitRetries: 10,
	}
	if destroyCtx.AWSState != nil {
		runReq.AssumeRoleARN = strings.TrimSpace(destroyCtx.AWSState.RoleARN)
	}
	if runReq.Profile == "" && destroyCtx.AWSState != nil {
		runReq.Profile = strings.TrimSpace(destroyCtx.AWSState.Profile)
	}
	if runReq.Region == "" {
		runReq.Region = firstNonEmptyString(scope.Regions...)
	}
	if runReq.AssumeRoleARN == "" && destroyCtx.Resolved != nil {
		runReq.AssumeRoleARN = strings.TrimSpace(destroyCtx.Resolved.Target.AWS.IAM.RoleARN)
	}
	if runReq.AssumeRoleARN != "" {
		if sourceProfile := destroySourceProfile(destroyCtx); sourceProfile != "" {
			runReq.Profile = sourceProfile
		}
	}
	nukeCredentialSource := runReq
	if err := destroyNukeCredentialMaterializer(ctx, destroyCtx, scope, &runReq); err != nil {
		return nil, evidence, err
	}
	returnWithSummary := func(final *cpaws.ReapResult) (*cpaws.ReapResult, destroyNukeEvidence, error) {
		if err := writeDestroyDirectZeroSummary(scope, final, &evidence); err != nil {
			return nil, evidence, err
		}
		return final, evidence, nil
	}
	var initial *cpaws.ReapResult
	var err error
	err = withDestroyReaperSessionEnv(scope.Owner, scope.SessionID, func() error {
		var err error
		initial, err = reaper.Reap(ctx, scope, true, destroyCtx.ReapState.Survivors)
		return err
	})
	if err != nil {
		return nil, evidence, fmt.Errorf("initial cleanup verify: %w", err)
	}
	if initial == nil {
		return nil, evidence, fmt.Errorf("initial cleanup verify returned no result")
	}
	iamProfileRunReq := runReq
	profileIAMResources, err := destroyFindTaggedInstanceProfileIAMResources(ctx, scope, &iamProfileRunReq)
	if err != nil {
		return nil, evidence, err
	}
	initial.Survivors = mergeDestroyCleanupResources(initial.Survivors, profileIAMResources)
	if len(initial.Survivors) > 0 {
		filtered, warnings, err := filterPostNukeStaleSurvivorsWithWarnings(
			ctx,
			initial.Survivors,
			&runReq,
			"initial_cleanup_verify",
		)
		if err != nil {
			return nil, evidence, err
		}
		evidence.StaleTagWarnings = append(evidence.StaleTagWarnings, warnings...)
		initial.Survivors = filtered
	}
	if len(initial.Survivors) == 0 {
		return returnWithSummary(initial)
	}
	emitDestroyProgress(destroyCtx, "load_balancer_cleanup_prepare", "started", "preparing load balancers for aws-nuke", destroyDir)
	preparedLoadBalancers, err := destroyPrepareNukeLoadBalancers(ctx, initial.Survivors, scope, &runReq)
	if err != nil {
		emitDestroyProgress(destroyCtx, "load_balancer_cleanup_prepare", "failed", err.Error(), destroyDir)
		return nil, evidence, err
	}
	evidence.PreparedLoadBalancers = append([]string(nil), preparedLoadBalancers...)
	emitDestroyProgress(
		destroyCtx,
		"load_balancer_cleanup_prepare",
		"completed",
		fmt.Sprintf("prepared %d load balancer(s)", len(preparedLoadBalancers)),
		destroyDir,
	)
	emitDestroyProgress(destroyCtx, "vpc_child_cleanup_prepare", "started", "preparing VPC child resources for aws-nuke", destroyDir)
	preparedVPCChildren, err := destroyPrepareNukeVPCChildren(ctx, initial.Survivors, scope, &runReq)
	if err != nil {
		emitDestroyProgress(destroyCtx, "vpc_child_cleanup_prepare", "failed", err.Error(), destroyDir)
		return nil, evidence, err
	}
	evidence.PreparedVPCChildren = append([]string(nil), preparedVPCChildren...)
	emitDestroyProgress(
		destroyCtx,
		"vpc_child_cleanup_prepare",
		"completed",
		fmt.Sprintf("prepared %d VPC child resource(s)", len(preparedVPCChildren)),
		destroyDir,
	)
	emitDestroyProgress(destroyCtx, "eks_cleanup_prepare", "started", "preparing EKS resources for aws-nuke", destroyDir)
	preparedEKSResources, err := destroyPrepareNukeEKSResources(ctx, initial.Survivors, scope, &runReq)
	if err != nil {
		emitDestroyProgress(destroyCtx, "eks_cleanup_prepare", "failed", err.Error(), destroyDir)
		return nil, evidence, err
	}
	evidence.PreparedEKSResources = append([]string(nil), preparedEKSResources...)
	emitDestroyProgress(
		destroyCtx,
		"eks_cleanup_prepare",
		"completed",
		fmt.Sprintf("prepared %d EKS resource(s)", len(preparedEKSResources)),
		destroyDir,
	)
	emitDestroyProgress(destroyCtx, "s3_direct_cleanup", "started", "deleting scoped S3 buckets before aws-nuke", destroyDir)
	directS3Buckets, err := destroyCleanupS3Buckets(ctx, reaper, scope, initial.Survivors)
	if err != nil {
		emitDestroyProgress(destroyCtx, "s3_direct_cleanup", "failed", err.Error(), destroyDir)
		return nil, evidence, err
	}
	evidence.DirectS3Buckets = append([]string(nil), directS3Buckets...)
	emitDestroyProgress(
		destroyCtx,
		"s3_direct_cleanup",
		"completed",
		fmt.Sprintf("deleted %d S3 bucket(s)", len(directS3Buckets)),
		destroyDir,
	)
	emitDestroyProgress(destroyCtx, "iam_direct_cleanup", "started", "deleting scoped IAM resources before aws-nuke", destroyDir)
	directIAMResources, survivorsAfterDirectIAM, err := destroyCleanupIAMResources(
		ctx,
		reaper,
		scope,
		initial.Survivors,
		&runReq,
	)
	if err != nil {
		emitDestroyProgress(destroyCtx, "iam_direct_cleanup", "failed", err.Error(), destroyDir)
		return nil, evidence, err
	}
	evidence.DirectIAMResources = append([]string(nil), directIAMResources...)
	emitDestroyProgress(
		destroyCtx,
		"iam_direct_cleanup",
		"completed",
		fmt.Sprintf("deleted %d IAM resource(s)", len(directIAMResources)),
		destroyDir,
	)
	if len(directIAMResources) > 0 {
		initial.Survivors = survivorsAfterDirectIAM
		if len(initial.Survivors) == 0 {
			return returnWithSummary(initial)
		}
	}
	emitDestroyProgress(
		destroyCtx,
		"secretsmanager_direct_cleanup",
		"started",
		"deleting scoped Secrets Manager resources before aws-nuke",
		destroyDir,
	)
	directSecrets, survivorsAfterDirectSecrets, err := destroyCleanupSecretsManagerResources(ctx, reaper, scope, initial.Survivors)
	if err != nil {
		emitDestroyProgress(destroyCtx, "secretsmanager_direct_cleanup", "failed", err.Error(), destroyDir)
		return nil, evidence, err
	}
	evidence.DirectSecretsManager = append([]string(nil), directSecrets...)
	emitDestroyProgress(
		destroyCtx,
		"secretsmanager_direct_cleanup",
		"completed",
		fmt.Sprintf("deleted %d Secrets Manager resource(s)", len(directSecrets)),
		destroyDir,
	)
	if len(directSecrets) > 0 {
		initial.Survivors = survivorsAfterDirectSecrets
		if len(initial.Survivors) == 0 {
			return returnWithSummary(initial)
		}
	}
	emitDestroyProgress(
		destroyCtx,
		"launch_template_direct_cleanup",
		"started",
		"deleting scoped EC2 launch templates before aws-nuke",
		destroyDir,
	)
	directLaunchTemplates, survivorsAfterDirectLaunchTemplates, err := destroyCleanupEC2TailResources(
		ctx,
		reaper,
		scope,
		initial.Survivors,
		cpaws.ResourceLaunchTemplate,
	)
	if err != nil {
		emitDestroyProgress(destroyCtx, "launch_template_direct_cleanup", "failed", err.Error(), destroyDir)
		return nil, evidence, err
	}
	evidence.DirectLaunchTemplates = append([]string(nil), directLaunchTemplates...)
	emitDestroyProgress(
		destroyCtx,
		"launch_template_direct_cleanup",
		"completed",
		fmt.Sprintf("deleted %d EC2 launch template(s)", len(directLaunchTemplates)),
		destroyDir,
	)
	if len(directLaunchTemplates) > 0 {
		initial.Survivors = survivorsAfterDirectLaunchTemplates
		if len(initial.Survivors) == 0 {
			return returnWithSummary(initial)
		}
	}
	resourceTypes := destroyNukeResourceTypes(initial.Survivors, preparedVPCChildren)
	if len(resourceTypes) == 0 {
		verify, warnings, verifyErr := verifyDestroyPostCleanup(ctx, destroyCtx, reaper, scope, initial, &runReq)
		evidence.StaleTagWarnings = append(evidence.StaleTagWarnings, warnings...)
		if verifyErr != nil {
			return nil, evidence, verifyErr
		}
		if len(verify.Survivors) == 0 {
			return returnWithSummary(verify)
		}
		return nil, evidence, fmt.Errorf("aws-nuke cleanup has no supported resource types for %d survivors", len(verify.Survivors))
	}
	if err := cpaws.WriteNukeConfig(configPath, &cpaws.NukeConfigRequest{
		AccountID:     accountID,
		Scope:         scope,
		ResourceTypes: resourceTypes,
	}); err != nil {
		return nil, evidence, err
	}
	dryRunLog := filepath.Join(destroyDir, "aws-nuke-dry-run.log")
	dryRunOut, err := runDestroyNukeWithProgress(ctx, destroyCtx, "aws_nuke_dry_run", dryRunLog, &runReq)
	writeErr := writeDestroyNukeLog(dryRunLog, dryRunOut)
	if writeErr == nil {
		evidence.DryRunLog = dryRunLog
	}
	if err != nil {
		return nil, evidence, errors.Join(err, writeErr)
	}
	if writeErr != nil {
		emitDestroyProgress(destroyCtx, "aws_nuke_dry_run", "failed", writeErr.Error(), dryRunLog)
		return nil, evidence, writeErr
	}
	deleteLog := filepath.Join(destroyDir, "aws-nuke-delete.log")
	deleteOut, deleteReq, err := runDestroyNukeDeleteWithCredentialRetry(
		ctx,
		destroyCtx,
		scope,
		&nukeCredentialSource,
		deleteLog,
	)
	runReq = deleteReq
	writeErr = writeDestroyNukeLog(deleteLog, deleteOut)
	if writeErr == nil {
		evidence.DeleteLog = deleteLog
	}
	if err != nil {
		emitDestroyProgress(
			destroyCtx,
			"vpc_root_cleanup_after_nuke",
			"started",
			"trying direct exact-session VPC root cleanup after aws-nuke failure",
			"",
		)
		directVPCRoots, finalizerErr := finalizeNukeVPCRootsAfterDelete(ctx, initial.Survivors, scope, &runReq)
		if finalizerErr != nil {
			emitDestroyProgress(destroyCtx, "vpc_root_cleanup_after_nuke", "failed", finalizerErr.Error(), "")
			return nil, evidence, errors.Join(err, writeErr, finalizerErr)
		}
		if len(directVPCRoots) == 0 {
			emitDestroyProgress(destroyCtx, "vpc_root_cleanup_after_nuke", "skipped", "no exact-session VPC roots needed direct cleanup", "")
		} else {
			emitDestroyProgress(
				destroyCtx,
				"vpc_root_cleanup_after_nuke",
				"completed",
				fmt.Sprintf("deleted %d VPC root resource(s)", len(directVPCRoots)),
				"",
			)
		}
		verify, warnings, verifyErr := verifyDestroyPostCleanup(ctx, destroyCtx, reaper, scope, initial, &runReq)
		evidence.StaleTagWarnings = append(evidence.StaleTagWarnings, warnings...)
		if verifyErr != nil {
			return nil, evidence, errors.Join(err, writeErr, verifyErr)
		}
		if len(verify.Survivors) > 0 {
			return nil, evidence, errors.Join(err, writeErr)
		}
		if summaryErr := writeDestroyDirectZeroSummary(scope, verify, &evidence); summaryErr != nil {
			return nil, evidence, errors.Join(err, writeErr, summaryErr)
		}
		return verify, evidence, writeErr
	}
	if writeErr != nil {
		emitDestroyProgress(destroyCtx, "aws_nuke_delete", "failed", writeErr.Error(), deleteLog)
		return nil, evidence, writeErr
	}
	verify, warnings, err := verifyDestroyPostCleanup(ctx, destroyCtx, reaper, scope, initial, &runReq)
	evidence.StaleTagWarnings = append(evidence.StaleTagWarnings, warnings...)
	if err != nil {
		return nil, evidence, err
	}
	return returnWithSummary(verify)
}

func verifyDestroyPostCleanup(
	ctx context.Context,
	destroyCtx *v1alpha2DestroyContext,
	reaper *cpaws.Reaper,
	scope cpaws.Scope,
	initial *cpaws.ReapResult,
	req *cpaws.NukeRunRequest,
) (*cpaws.ReapResult, []destroyStaleTagWarning, error) {
	if initial == nil {
		return nil, nil, fmt.Errorf("initial cleanup verify returned no result")
	}
	var verify *cpaws.ReapResult
	emitDestroyProgress(destroyCtx, "post_cleanup_verify", "started", "verifying zero scoped survivors after aws-nuke", "")
	err := withDestroyReaperSessionEnv(scope.Owner, scope.SessionID, func() error {
		var err error
		verify, err = reaper.Reap(ctx, scope, true, nil)
		return err
	})
	if err != nil {
		emitDestroyProgress(destroyCtx, "post_cleanup_verify", "failed", err.Error(), "")
		return nil, nil, fmt.Errorf("post-cleanup verify: %w", err)
	}
	if verify == nil {
		emitDestroyProgress(destroyCtx, "post_cleanup_verify", "failed", "post-cleanup verify returned no result", "")
		return nil, nil, fmt.Errorf("post-cleanup verify returned no result")
	}
	var warnings []destroyStaleTagWarning
	if len(verify.Survivors) > 0 {
		filtered, staleWarnings, err := filterPostNukeStaleSurvivorsWithWarnings(
			ctx,
			verify.Survivors,
			req,
			"post_cleanup_verify",
		)
		if err != nil {
			return nil, nil, err
		}
		warnings = append(warnings, staleWarnings...)
		verify.Survivors = filtered
	}
	verify.Initial = initial.Initial
	verify.UsedResume = initial.UsedResume
	verify.Passes += initial.Passes
	verify.DryRun = false
	verify.Deleted = 0
	emitDestroyProgress(
		destroyCtx,
		"post_cleanup_verify",
		"completed",
		fmt.Sprintf("post-cleanup verify survivors=%d", len(verify.Survivors)),
		"",
	)
	return verify, warnings, nil
}

func detectDestroyOwnerTagDrift(ctx context.Context, reaper *cpaws.Reaper, scope cpaws.Scope, accountID string) error {
	if reaper == nil || reaper.Scanner == nil {
		return fmt.Errorf("owner tag drift scan requires cleanup scanner")
	}
	scope = scope.Normalize()
	if scope.Owner == "" || scope.SessionID == "" {
		return nil
	}
	allOwnerScope := cpaws.Scope{
		SessionID:  scope.SessionID,
		Regions:    scope.Regions,
		AllOwners:  true,
		AdminSweep: true,
	}.Normalize()
	resources, err := reaper.Scanner.Scan(ctx, allOwnerScope)
	if err != nil {
		return fmt.Errorf("owner tag drift scan: %w", err)
	}

	var drift []destroyOwnerDriftResource
	for _, resource := range resources {
		if resource.SessionID != scope.SessionID {
			continue
		}
		owner := strings.TrimSpace(resource.Owner)
		if owner == "" || cpaws.OwnerTagMatches(scope.Owner, owner) {
			continue
		}
		resourceAccount := firstNonEmptyString(accountIDFromAWSARN(resource.ARN), accountID)
		drift = append(drift, destroyOwnerDriftResource{
			ARN:              resource.ARN,
			Type:             string(resource.Type),
			Region:           resource.Region,
			AccountID:        resourceAccount,
			ConflictingOwner: owner,
			SessionID:        resource.SessionID,
		})
	}
	if len(drift) == 0 {
		return nil
	}
	slices.SortFunc(drift, func(a, b destroyOwnerDriftResource) int {
		return strings.Compare(a.ARN, b.ARN)
	})
	return &destroyOwnerDriftError{
		PersistedOwner: scope.Owner,
		SessionID:      scope.SessionID,
		AccountID:      accountID,
		Region:         strings.Join(scope.Regions, ","),
		Resources:      drift,
	}
}

func terraformOnlyResidualMessage(cfgPath string) string {
	return fmt.Sprintf(
		"terraform-only residual state: cloud cleanup verified zero live survivors; refresh credentials and rerun `cp9 destroy --config %s` to clear Terraform state",
		cfgPath,
	)
}

func removeTerraformOnlyResidualErrors(errs []error, terraformErr error) []error {
	if len(errs) == 0 || terraformErr == nil {
		return errs
	}
	out := make([]error, 0, len(errs))
	for _, err := range errs {
		switch {
		case errors.Is(err, terraformErr):
			continue
		case strings.Contains(err.Error(), "empty initial Stage-2 scan with saved state context indicates a tagging gap"):
			continue
		default:
			out = append(out, err)
		}
	}
	return out
}

func runDestroyNukeWithProgress(
	ctx context.Context,
	destroyCtx *v1alpha2DestroyContext,
	phase string,
	artifact string,
	req *cpaws.NukeRunRequest,
) ([]byte, error) {
	mode := "dry-run"
	if req != nil && req.Delete {
		mode = "delete"
	}
	emitDestroyProgress(destroyCtx, phase, "started", "running aws-nuke "+mode, artifact)

	type nukeResult struct {
		out []byte
		err error
	}
	done := make(chan nukeResult, 1)
	go func() {
		out, err := destroyNukeRunner.Run(ctx, req)
		done <- nukeResult{out: out, err: err}
	}()

	heartbeat := destroyProgressHeartbeatInterval
	if heartbeat <= 0 {
		heartbeat = 30 * time.Second
	}
	ticker := time.NewTicker(heartbeat)
	defer ticker.Stop()

	for {
		select {
		case result := <-done:
			if result.err != nil {
				emitDestroyProgress(destroyCtx, phase, "failed", destroyNukeFailureMessage(mode, result.err, result.out), artifact)
			} else {
				emitDestroyProgress(destroyCtx, phase, "completed", "aws-nuke "+mode+" completed", artifact)
			}
			return result.out, result.err
		case <-ticker.C:
			emitDestroyProgress(destroyCtx, phase, "running", "aws-nuke "+mode+" still running", artifact)
		}
	}
}

const (
	destroyNukeFailureTailLines     = 8
	destroyNukeFailureTailLineLimit = 240
)

func destroyNukeFailureMessage(mode string, err error, out []byte) string {
	message := "aws-nuke " + mode + " failed"
	if err != nil && strings.TrimSpace(err.Error()) != "" {
		message += ": " + strings.TrimSpace(err.Error())
	}
	if tail := destroyNukeOutputTail(out, destroyNukeFailureTailLines); tail != "" {
		message += "; output tail: " + tail
	}
	return message
}

func destroyNukeOutputTail(out []byte, maxLines int) string {
	if maxLines <= 0 {
		return ""
	}
	var lines []string
	for line := range strings.SplitSeq(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") {
		line = strings.Join(strings.Fields(line), " ")
		if line == "" {
			continue
		}
		if len(line) > destroyNukeFailureTailLineLimit {
			line = line[:destroyNukeFailureTailLineLimit] + "..."
		}
		lines = append(lines, line)
	}
	if len(lines) > maxLines {
		lines = lines[len(lines)-maxLines:]
	}
	return strings.Join(lines, " | ")
}

func runDestroyNukeDeleteWithCredentialRetry(
	ctx context.Context,
	destroyCtx *v1alpha2DestroyContext,
	scope cpaws.Scope,
	credentialSource *cpaws.NukeRunRequest,
	logPath string,
) ([]byte, cpaws.NukeRunRequest, error) {
	maxAttempts := destroyNukeDeleteMaxAttempts
	if maxAttempts <= 0 {
		maxAttempts = 1
	}
	var combined []byte
	var lastReq cpaws.NukeRunRequest
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		deleteReq := *credentialSource
		if err := destroyNukeCredentialMaterializer(ctx, destroyCtx, scope, &deleteReq); err != nil {
			return combined, lastReq, err
		}
		deleteReq.Delete = true
		lastReq = deleteReq
		if attempt > 1 {
			emitDestroyProgress(
				destroyCtx,
				"aws_nuke_delete",
				"retrying",
				fmt.Sprintf("retrying aws-nuke delete after expired AWS credentials with refreshed credentials attempt=%d/%d", attempt, maxAttempts),
				logPath,
			)
		}
		out, err := runDestroyNukeWithProgress(ctx, destroyCtx, "aws_nuke_delete", logPath, &deleteReq)
		if len(combined) > 0 {
			combined = append(combined, '\n')
		}
		if attempt > 1 {
			combined = fmt.Appendf(combined, "\n--- aws-nuke delete retry attempt %d ---\n", attempt)
		}
		combined = append(combined, out...)
		if err == nil {
			return combined, deleteReq, nil
		}
		lastErr = err
		if !nukeOutputHasRequestExpired(out, err) || attempt == maxAttempts {
			return combined, deleteReq, err
		}
	}
	return combined, lastReq, lastErr
}

func nukeOutputHasRequestExpired(out []byte, err error) bool {
	for _, token := range []string{"RequestExpired", "ExpiredToken", "ExpiredTokenException"} {
		if bytes.Contains(out, []byte(token)) {
			return true
		}
		if err != nil && strings.Contains(err.Error(), token) {
			return true
		}
	}
	return false
}

func writeDestroyNukeLog(path string, data []byte) error {
	if strings.TrimSpace(path) == "" {
		return fmt.Errorf("aws-nuke log path is required")
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return fmt.Errorf("create aws-nuke log dir: %w", err)
	}
	if err := os.WriteFile(path, data, 0o600); err != nil {
		return fmt.Errorf("write aws-nuke log: %w", err)
	}
	return nil
}

type destroyStaticScopeScanner struct {
	resources []cpaws.Resource
}

func (s *destroyStaticScopeScanner) Scan(context.Context, cpaws.Scope) ([]cpaws.Resource, error) {
	return append([]cpaws.Resource(nil), s.resources...), nil
}

func destroyCleanupS3Buckets(
	ctx context.Context,
	reaper *cpaws.Reaper,
	scope cpaws.Scope,
	resources []cpaws.Resource,
) ([]string, error) {
	if reaper == nil || reaper.Cleaner == nil {
		return nil, nil
	}
	var buckets []cpaws.Resource
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceS3 {
			continue
		}
		if !cpaws.OwnerTagMatches(scope.Owner, resource.Owner) || resource.SessionID != scope.SessionID {
			return nil, fmt.Errorf(
				"refusing direct S3 cleanup for %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resource.ARN,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		buckets = append(buckets, resource)
	}
	if len(buckets) == 0 {
		return nil, nil
	}
	s3Reaper := cpaws.NewReaper(&destroyStaticScopeScanner{resources: buckets}, reaper.Cleaner)
	result, err := s3Reaper.Reap(ctx, scope, false, nil)
	if err != nil {
		return nil, fmt.Errorf("direct S3 cleanup before aws-nuke: %w", err)
	}
	if result == nil {
		return nil, fmt.Errorf("direct S3 cleanup returned no result")
	}
	if len(result.Survivors) > 0 {
		return nil, fmt.Errorf("direct S3 cleanup left %d survivors", len(result.Survivors))
	}
	removed := make([]string, 0, len(buckets))
	for _, bucket := range buckets {
		removed = append(removed, bucket.ARN)
	}
	slices.Sort(removed)
	return removed, nil
}

func destroyCleanupEKSResources(
	ctx context.Context,
	scope cpaws.Scope,
	resources []cpaws.Resource,
	req *cpaws.NukeRunRequest,
) ([]string, []cpaws.Resource, error) {
	if req == nil {
		return nil, nil, fmt.Errorf("direct EKS cleanup requires aws-nuke run request")
	}
	if req.AccessKeyID == "" || req.SecretAccessKey == "" {
		return nil, nil, fmt.Errorf("direct EKS cleanup requires frozen credentials")
	}
	scope = scope.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, nil, fmt.Errorf("direct EKS cleanup scope: %w", err)
	}
	var eksResources []cpaws.Resource
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceEKSCluster && resource.Type != cpaws.ResourceEKSNodegroup {
			continue
		}
		if strings.TrimSpace(resource.Owner) != "" && !cpaws.OwnerTagMatches(scope.Owner, resource.Owner) {
			return nil, nil, fmt.Errorf(
				"refusing direct EKS cleanup for %s with ambiguous owner=%q, want %q",
				resource.ARN,
				resource.Owner,
				scope.Owner,
			)
		}
		if strings.TrimSpace(resource.SessionID) != "" && resource.SessionID != scope.SessionID {
			return nil, nil, fmt.Errorf(
				"refusing direct EKS cleanup for %s with ambiguous session=%q, want %q",
				resource.ARN,
				resource.SessionID,
				scope.SessionID,
			)
		}
		eksResources = append(eksResources, resource)
	}
	if len(eksResources) == 0 {
		return nil, resources, nil
	}

	clients := map[string]destroyEKSResourceAPI{}
	clientFor := func(region string) (destroyEKSResourceAPI, error) {
		region = strings.TrimSpace(region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			return nil, fmt.Errorf("direct EKS cleanup requires region")
		}
		if client := clients[region]; client != nil {
			return client, nil
		}
		cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
		client := destroyNewEKSClient(cfg)
		clients[region] = client
		return client, nil
	}

	var existingEKSResources []cpaws.Resource
	for i := range eksResources {
		resource := &eksResources[i]
		client, err := clientFor(resource.Region)
		if err != nil {
			return nil, nil, err
		}
		tags, ok, err := scopedEKSTags(ctx, client, resource.ARN, scope)
		if err != nil {
			return nil, nil, err
		}
		if !ok {
			continue
		}
		existingEKSResources = append(existingEKSResources, *resource)
		if strings.TrimSpace(tags[cpaws.TagOwner]) == "" {
			if err := tagNukeEKSResource(ctx, client, resource.ARN, scope); err != nil {
				return nil, nil, err
			}
		}
	}
	eksResources = existingEKSResources

	sortEKSResourcesForDeletion(eksResources)
	var removed []string
	for _, resource := range eksResources {
		client, err := clientFor(resource.Region)
		if err != nil {
			return nil, nil, err
		}
		switch resource.Type {
		case cpaws.ResourceEKSNodegroup:
			clusterName, nodegroupName := destroyEKSNodegroupNamesFromARN(resource.ARN)
			if clusterName == "" || nodegroupName == "" {
				return nil, nil, fmt.Errorf("cannot parse EKS nodegroup ARN %s", resource.ARN)
			}
			if err := deleteAndWaitEKSNodegroup(ctx, client, clusterName, nodegroupName); err != nil {
				return nil, nil, err
			}
			removed = append(removed, resource.ARN)
		case cpaws.ResourceEKSCluster:
			clusterName := destroyEKSClusterNameFromARN(resource.ARN)
			if clusterName == "" {
				return nil, nil, fmt.Errorf("cannot parse EKS cluster ARN %s", resource.ARN)
			}
			if err := deleteAndWaitEKSCluster(ctx, client, clusterName); err != nil {
				return nil, nil, err
			}
			removed = append(removed, resource.ARN)
		}
	}
	slices.Sort(removed)
	return removed, withoutEKSResources(resources), nil
}

func deleteAndWaitAttachedEKSNodegroups(
	ctx context.Context,
	client destroyEKSResourceAPI,
	clusterName string,
) error {
	var names []string
	var token *string
	for {
		out, err := client.ListNodegroups(ctx, &eks.ListNodegroupsInput{
			ClusterName: awssdk.String(clusterName),
			NextToken:   token,
		})
		if err != nil {
			if destroyEKSNotFound(err) {
				return nil
			}
			return fmt.Errorf("list EKS nodegroups for %s before cluster cleanup: %w", clusterName, err)
		}
		names = append(names, out.Nodegroups...)
		token = out.NextToken
		if token == nil || awssdk.ToString(token) == "" {
			break
		}
	}
	slices.Sort(names)
	for _, name := range names {
		if err := deleteAndWaitEKSNodegroup(ctx, client, clusterName, name); err != nil {
			return err
		}
	}
	return nil
}

func sortEKSResourcesForDeletion(resources []cpaws.Resource) {
	slices.SortFunc(resources, func(a, b cpaws.Resource) int {
		ap := 1
		if a.Type == cpaws.ResourceEKSNodegroup {
			ap = 0
		}
		bp := 1
		if b.Type == cpaws.ResourceEKSNodegroup {
			bp = 0
		}
		if ap != bp {
			return ap - bp
		}
		return strings.Compare(a.ARN, b.ARN)
	})
}

func withoutEKSResources(resources []cpaws.Resource) []cpaws.Resource {
	out := make([]cpaws.Resource, 0, len(resources))
	for _, resource := range resources {
		if resource.Type == cpaws.ResourceEKSCluster || resource.Type == cpaws.ResourceEKSNodegroup {
			continue
		}
		out = append(out, resource)
	}
	return out
}

func deleteAndWaitEKSNodegroup(ctx context.Context, client destroyEKSResourceAPI, clusterName, nodegroupName string) error {
	if _, err := client.DeleteNodegroup(ctx, &eks.DeleteNodegroupInput{
		ClusterName:   awssdk.String(clusterName),
		NodegroupName: awssdk.String(nodegroupName),
	}); err != nil && !destroyEKSNotFound(err) {
		return fmt.Errorf("delete EKS nodegroup %s/%s before aws-nuke: %w", clusterName, nodegroupName, err)
	}
	return waitForEKSNodegroupDeleted(ctx, client, clusterName, nodegroupName)
}

func deleteAndWaitEKSCluster(ctx context.Context, client destroyEKSResourceAPI, clusterName string) error {
	waitCtx, cancel := context.WithTimeout(ctx, destroyEKSDeleteTimeout)
	defer cancel()
	for {
		if err := deleteAndWaitAttachedEKSNodegroups(waitCtx, client, clusterName); err != nil {
			return err
		}
		_, err := client.DeleteCluster(waitCtx, &eks.DeleteClusterInput{Name: awssdk.String(clusterName)})
		if err == nil || destroyEKSNotFound(err) {
			return waitForEKSClusterDeleted(waitCtx, client, clusterName)
		}
		if !destroyEKSResourceInUse(err) {
			return fmt.Errorf("delete EKS cluster %s before aws-nuke: %w", clusterName, err)
		}
		timer := time.NewTimer(destroyEKSDeletePollInterval)
		select {
		case <-waitCtx.Done():
			timer.Stop()
			return fmt.Errorf("timed out deleting EKS cluster %s after attached nodegroups changed: %w", clusterName, waitCtx.Err())
		case <-timer.C:
		}
	}
}

func waitForEKSNodegroupDeleted(ctx context.Context, client destroyEKSResourceAPI, clusterName, nodegroupName string) error {
	return waitForEKSDeleted(ctx, func(ctx context.Context) error {
		_, err := client.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{
			ClusterName:   awssdk.String(clusterName),
			NodegroupName: awssdk.String(nodegroupName),
		})
		return err
	}, fmt.Sprintf("EKS nodegroup %s/%s", clusterName, nodegroupName))
}

func waitForEKSClusterDeleted(ctx context.Context, client destroyEKSResourceAPI, clusterName string) error {
	return waitForEKSDeleted(ctx, func(ctx context.Context) error {
		_, err := client.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: awssdk.String(clusterName)})
		return err
	}, fmt.Sprintf("EKS cluster %s", clusterName))
}

func waitForEKSDeleted(ctx context.Context, describe func(context.Context) error, label string) error {
	timeout := destroyEKSDeleteTimeout
	if timeout <= 0 {
		timeout = 20 * time.Minute
	}
	poll := destroyEKSDeletePollInterval
	if poll <= 0 {
		poll = 15 * time.Second
	}
	waitCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()
	for {
		err := describe(waitCtx)
		if destroyEKSNotFound(err) {
			return nil
		}
		if err != nil {
			return fmt.Errorf("wait for %s deletion before aws-nuke: %w", label, err)
		}
		timer := time.NewTimer(poll)
		select {
		case <-waitCtx.Done():
			timer.Stop()
			return fmt.Errorf("timed out waiting for %s deletion before aws-nuke: %w", label, waitCtx.Err())
		case <-timer.C:
		}
	}
}

func destroyEKSNotFound(err error) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.ErrorCode() {
		case "ResourceNotFoundException", "NotFoundException", "NotFound":
			return true
		}
	}
	msg := err.Error()
	return strings.Contains(msg, "ResourceNotFoundException") ||
		strings.Contains(msg, "not found")
}

func destroyEKSResourceInUse(err error) bool {
	var apiErr smithy.APIError
	return errors.As(err, &apiErr) && apiErr.ErrorCode() == "ResourceInUseException"
}

func destroyEKSClusterNameFromARN(arn string) string {
	parts := strings.Split(arn, ":")
	if len(parts) < 6 {
		return ""
	}
	resource := parts[5]
	if !strings.HasPrefix(resource, "cluster/") {
		return ""
	}
	return strings.TrimPrefix(resource, "cluster/")
}

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

func destroyCleanupIAMResources(
	ctx context.Context,
	reaper *cpaws.Reaper,
	scope cpaws.Scope,
	resources []cpaws.Resource,
	req *cpaws.NukeRunRequest,
) ([]string, []cpaws.Resource, error) {
	var iamResources []cpaws.Resource
	for _, resource := range resources {
		if !destroyDirectIAMCleanupType(resource.Type) {
			continue
		}
		if !cpaws.OwnerTagMatches(scope.Owner, resource.Owner) || resource.SessionID != scope.SessionID {
			return nil, nil, fmt.Errorf(
				"refusing direct IAM cleanup for %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resource.ARN,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		iamResources = append(iamResources, resource)
	}
	if len(iamResources) == 0 {
		return nil, resources, nil
	}
	if reaper == nil || reaper.Cleaner == nil || reaper.Scanner == nil {
		return nil, nil, fmt.Errorf("direct IAM cleanup requires scanner and cleaner")
	}
	removed := make([]string, 0, len(iamResources))
	handled := make(map[string]struct{}, len(iamResources))
	for i := range iamResources {
		resource := iamResources[i]
		if _, err := reaper.Cleaner.Delete(ctx, &resource); err != nil {
			return nil, nil, fmt.Errorf("direct IAM cleanup %s: %w", resource.ARN, err)
		}
		removed = append(removed, resource.ARN)
		handled[resource.ARN] = struct{}{}
	}
	verify, err := reaper.Reap(ctx, scope, true, nil)
	if err != nil {
		return nil, nil, fmt.Errorf("direct IAM cleanup verify: %w", err)
	}
	if verify == nil {
		return nil, nil, fmt.Errorf("direct IAM cleanup verify returned no result")
	}
	verify.Survivors = withoutHandledPurgeSurvivors(verify.Survivors, handled)
	survivors, _, err := filterPostNukeStaleSurvivorsWithWarnings(
		ctx,
		verify.Survivors,
		req,
		"direct-iam-cleanup-verify",
	)
	if err != nil {
		return nil, nil, fmt.Errorf("direct IAM cleanup verify survivors: %w", err)
	}
	for _, survivor := range survivors {
		if destroyDirectIAMCleanupType(survivor.Type) {
			return nil, nil, fmt.Errorf("direct IAM cleanup left survivor %s", survivor.String())
		}
	}
	slices.Sort(removed)
	return removed, survivors, nil
}

func destroyFindTaggedInstanceProfileIAMResources(
	ctx context.Context,
	scope cpaws.Scope,
	req *cpaws.NukeRunRequest,
) ([]cpaws.Resource, error) {
	if req == nil || strings.TrimSpace(req.AccessKeyID) == "" || strings.TrimSpace(req.SecretAccessKey) == "" {
		return nil, nil
	}
	region := firstNonEmptyString(req.Region, firstNonEmptyString(scope.Regions...))
	if region == "" {
		region = "us-east-1"
	}
	cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
	client := destroyNewIAMProfileClient(cfg)
	var resources []cpaws.Resource
	input := &iam.ListInstanceProfilesInput{}
	for {
		out, err := client.ListInstanceProfiles(ctx, input)
		if err != nil {
			return nil, fmt.Errorf("discover tagged IAM instance profile residuals: %w", err)
		}
		for _, profile := range out.InstanceProfiles {
			tags := iamTagMap(profile.Tags)
			if !destroyIAMTagsMatchScope(tags, scope) {
				continue
			}
			profileName := awssdk.ToString(profile.InstanceProfileName)
			profileARN := awssdk.ToString(profile.Arn)
			if profileARN != "" {
				resources = append(resources, cpaws.Resource{
					ARN:       profileARN,
					Type:      cpaws.ResourceIAMInstanceProfile,
					Region:    region,
					SessionID: tags[cpaws.TagSession],
					Owner:     tags[cpaws.TagOwner],
				})
			}
			for _, role := range profile.Roles {
				roleName := awssdk.ToString(role.RoleName)
				roleARN := awssdk.ToString(role.Arn)
				if roleName == "" || roleARN == "" || !destroyTaggedProfileOwnsRole(profileName, roleName, scope.SessionID) {
					continue
				}
				resources = append(resources, cpaws.Resource{
					ARN:       roleARN,
					Type:      cpaws.ResourceIAMRole,
					Region:    region,
					SessionID: tags[cpaws.TagSession],
					Owner:     tags[cpaws.TagOwner],
				})
				policyARNs, err := destroyTaggedRoleLocalPolicies(ctx, client, roleName, scope.SessionID)
				if err != nil {
					return nil, err
				}
				for _, policyARN := range policyARNs {
					resources = append(resources, cpaws.Resource{
						ARN:       policyARN,
						Type:      cpaws.ResourceIAMPolicy,
						Region:    region,
						SessionID: tags[cpaws.TagSession],
						Owner:     tags[cpaws.TagOwner],
					})
				}
			}
		}
		if !out.IsTruncated || out.Marker == nil {
			break
		}
		input.Marker = out.Marker
	}
	return resources, nil
}

func destroyDeveloperCleanupRoleARN(destroyCtx *v1alpha2DestroyContext) string {
	if destroyCtx == nil {
		return ""
	}
	if destroyCtx.AWSState != nil {
		if roleARN := strings.TrimSpace(destroyCtx.AWSState.RoleARN); roleARN != "" {
			return roleARN
		}
	}
	if destroyCtx.Resolved != nil {
		return strings.TrimSpace(destroyCtx.Resolved.Target.AWS.IAM.RoleARN)
	}
	return ""
}

func destroyShouldHandoffCloudCleanup(destroyCtx *v1alpha2DestroyContext) bool {
	if destroyCtx == nil || destroyCtx.Resolved == nil || destroyCtx.ReapState == nil {
		return false
	}
	return destroyUsesCleanupWorkflow(destroyCtx.Resolved)
}

func destroyUsesCleanupWorkflow(session *config.SessionConfig) bool {
	if session == nil {
		return false
	}
	switch config.SessionPersona(session) {
	case "dev-full":
		return true
	default:
		return false
	}
}

func dispatchPreparedDestroyCloudCleanupWorkflow(
	ctx context.Context,
	destroyCtx *v1alpha2DestroyContext,
	receipt *destroyCloudCleanupHandoffReceipt,
) (*destroyCloudCleanupWorkflowHandoff, error) {
	if destroyCtx == nil || destroyCtx.ReapState == nil {
		return nil, destroyCloudCleanupDispatchNotSent(fmt.Errorf("destroy reaper state is required"))
	}
	if receipt == nil {
		return nil, destroyCloudCleanupDispatchNotSent(fmt.Errorf("prepared cleanup handoff receipt is required"))
	}
	scope := destroyCtx.ReapState.Scope.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, destroyCloudCleanupDispatchNotSent(err)
	}
	if scope.Owner == "" || scope.SessionID == "" {
		return nil, destroyCloudCleanupDispatchNotSent(fmt.Errorf("cleanup workflow dispatch requires owner/session scope"))
	}
	if err := validateDestroyCloudCleanupWorkflowScope(scope); err != nil {
		return nil, destroyCloudCleanupDispatchNotSent(err)
	}
	region := firstNonEmptyString(destroyReapRegions(destroyCtx.Resolved, destroyCtx.AWSState)...)
	if region == "" {
		region = firstNonEmptyString(scope.Regions...)
	}
	if region == "" {
		return nil, destroyCloudCleanupDispatchNotSent(fmt.Errorf("cleanup workflow dispatch requires a region"))
	}
	accountID := ""
	if destroyCtx.AWSState != nil {
		accountID = strings.TrimSpace(destroyCtx.AWSState.Account)
	}
	if accountID == "" {
		accountID = v1alpha2DestroyReaperAccountID(destroyCtx.Resolved)
	}
	if accountID == "" {
		return nil, destroyCloudCleanupDispatchNotSent(fmt.Errorf("cleanup workflow dispatch requires an AWS account id"))
	}
	roleARN := destroyDeveloperCleanupRoleARN(destroyCtx)
	if roleARN == "" {
		return nil, destroyCloudCleanupDispatchNotSent(fmt.Errorf("cleanup workflow dispatch requires a cleanup role ARN"))
	}
	if _, err := exec.LookPath("gh"); err != nil {
		return nil, destroyCloudCleanupDispatchNotSent(fmt.Errorf("gh CLI is required for cleanup workflow dispatch: %w", err))
	}
	cleanupScope, err := encodeDestroyCloudCleanupScopeReceipt(receipt)
	if err != nil {
		return nil, destroyCloudCleanupDispatchNotSent(err)
	}
	repo := receipt.Repo
	workflowFile := receipt.Workflow
	ref := receipt.Ref
	handoff := newDestroyCloudCleanupWorkflowHandoff(receipt, region)
	args := []string{
		"workflow", "run", workflowFile,
		"--repo", repo,
		"--ref", ref,
		"-f", "dry_run=false",
		"-f", "region=" + region,
		"-f", "owner=" + scope.Owner,
		"-f", "session=" + scope.SessionID,
		"-f", "account_id=" + accountID,
		"-f", "role_arn=" + roleARN,
		"-f", "dispatch_id=" + receipt.DispatchID,
		"-f", "cleanup_scope_b64=" + cleanupScope,
	}
	dispatchCtx := ctx
	cancel := func() {}
	if destroyCleanupWorkflowDispatchTimeout > 0 {
		dispatchCtx, cancel = context.WithTimeout(ctx, destroyCleanupWorkflowDispatchTimeout)
	}
	defer cancel()
	// #nosec G702 -- gh is invoked without a shell; workflow inputs are passed as argv fields.
	cmd := exec.CommandContext(dispatchCtx, "gh", args...)
	out, err := verbose.RunCommand(cmd)
	if err != nil {
		if ctxErr := dispatchCtx.Err(); ctxErr != nil {
			return nil, fmt.Errorf("dispatch cp9 cleanup workflow: %w", ctxErr)
		}
		message := strings.TrimSpace(string(out))
		if message != "" {
			return nil, fmt.Errorf("dispatch cp9 cleanup workflow: %w: %s", err, message)
		}
		return nil, fmt.Errorf("dispatch cp9 cleanup workflow: %w", err)
	}
	if run, observeErr := observeDestroyCloudCleanupWorkflowRun(
		dispatchCtx,
		receipt,
		scope,
		region,
	); observeErr != nil {
		handoff.ObservationError = observeErr.Error()
	} else if run != nil {
		applyDestroyCloudCleanupWorkflowRun(handoff, run)
	}
	return handoff, nil
}

func reconcileDestroyCloudCleanupWorkflow(
	ctx context.Context,
	destroyCtx *v1alpha2DestroyContext,
	receipt *destroyCloudCleanupHandoffReceipt,
) (*destroyCloudCleanupWorkflowHandoff, error) {
	if destroyCtx == nil || destroyCtx.ReapState == nil || receipt == nil {
		return nil, fmt.Errorf("prepared cleanup handoff receipt and destroy scope are required")
	}
	region := firstNonEmptyString(receipt.Regions...)
	if region == "" {
		return nil, fmt.Errorf("prepared cleanup handoff receipt has no region")
	}
	reconcileCtx := ctx
	cancel := func() {}
	if destroyCleanupWorkflowDispatchTimeout > 0 {
		reconcileCtx, cancel = context.WithTimeout(ctx, destroyCleanupWorkflowDispatchTimeout)
	}
	defer cancel()
	run, err := observeDestroyCloudCleanupWorkflowRun(
		reconcileCtx,
		receipt,
		destroyCtx.ReapState.Scope,
		region,
	)
	if err != nil {
		return nil, err
	}
	handoff := newDestroyCloudCleanupWorkflowHandoff(receipt, region)
	applyDestroyCloudCleanupWorkflowRun(handoff, run)
	return handoff, nil
}

func newDestroyCloudCleanupWorkflowHandoff(
	receipt *destroyCloudCleanupHandoffReceipt,
	region string,
) *destroyCloudCleanupWorkflowHandoff {
	return &destroyCloudCleanupWorkflowHandoff{
		Result:         "dispatched",
		Repo:           receipt.Repo,
		Ref:            receipt.Ref,
		Workflow:       receipt.Workflow,
		WorkflowURL:    destroyCloudCleanupWorkflowURL(receipt.Repo, receipt.Workflow),
		Owner:          receipt.Owner,
		SessionID:      receipt.SessionID,
		Region:         region,
		AccountID:      receipt.AccountID,
		DispatchID:     receipt.DispatchID,
		Regions:        append([]string(nil), receipt.Regions...),
		FallbackResult: "not-run-delegated-to-workflow",
	}
}

func applyDestroyCloudCleanupWorkflowRun(
	handoff *destroyCloudCleanupWorkflowHandoff,
	run *destroyGitHubWorkflowRun,
) {
	handoff.RunID = run.DatabaseID
	handoff.RunURL = strings.TrimSpace(run.URL)
	handoff.RunStatus = strings.TrimSpace(run.Status)
	handoff.Conclusion = normalizeGitHubWorkflowConclusion(run.Status, run.Conclusion)
}

type destroyGitHubWorkflowRun struct {
	DatabaseID   int64  `json:"id"`
	URL          string `json:"html_url"`
	Status       string `json:"status"`
	Conclusion   string `json:"conclusion"`
	Event        string `json:"event"`
	DisplayTitle string `json:"display_title"`
	HeadBranch   string `json:"head_branch,omitempty"`
	Path         string `json:"path,omitempty"`
}

type destroyGitHubWorkflowRuns struct {
	WorkflowRuns []destroyGitHubWorkflowRun `json:"workflow_runs"`
}

func observeDestroyCloudCleanupWorkflowRun(
	ctx context.Context,
	receipt *destroyCloudCleanupHandoffReceipt,
	scope cpaws.Scope,
	region string,
) (*destroyGitHubWorkflowRun, error) {
	if receipt == nil {
		return nil, fmt.Errorf("cleanup workflow handoff receipt is required")
	}
	if receipt.PreparedAt.IsZero() {
		return nil, fmt.Errorf("cleanup workflow run observation requires a prepared-at cursor")
	}
	expectedTitle := destroyCloudCleanupWorkflowRunTitle(scope, region, receipt.DispatchID)
	searchLimit := 100
	createdFilter := ">=" + receipt.PreparedAt.UTC().Truncate(time.Second).Format(time.RFC3339)
	for {
		runs, err := listDestroyCloudCleanupWorkflowRuns(
			ctx,
			receipt.Repo,
			[]string{
				"event=workflow_dispatch",
				"branch=" + strings.TrimSpace(receipt.Ref),
				"created=" + createdFilter,
			},
			searchLimit,
			0,
		)
		if err != nil {
			return nil, fmt.Errorf("observe cp9 cleanup workflow run: %w", err)
		}
		for i := range runs {
			if destroyCloudCleanupWorkflowRunMatches(&runs[i], receipt, expectedTitle) {
				return &runs[i], nil
			}
		}
		if len(runs) >= searchLimit {
			searchLimit *= 2
			continue
		}
		if destroyCleanupWorkflowObserveInterval <= 0 {
			return nil, fmt.Errorf("workflow dispatch accepted but no workflow_dispatch run matched %q", expectedTitle)
		}
		timer := time.NewTimer(destroyCleanupWorkflowObserveInterval)
		select {
		case <-ctx.Done():
			timer.Stop()
			return nil, fmt.Errorf("workflow dispatch accepted but no workflow_dispatch run matched %q: %w", expectedTitle, ctx.Err())
		case <-timer.C:
		}
	}
}

func destroyCloudCleanupWorkflowRunMatches(
	run *destroyGitHubWorkflowRun,
	receipt *destroyCloudCleanupHandoffReceipt,
	expectedTitle string,
) bool {
	if run == nil {
		return false
	}
	if event := strings.TrimSpace(run.Event); event != "" && event != "workflow_dispatch" {
		return false
	}
	if strings.TrimSpace(run.DisplayTitle) != expectedTitle {
		return false
	}
	if branch := strings.TrimSpace(run.HeadBranch); branch != "" && branch != strings.TrimSpace(receipt.Ref) {
		return false
	}
	if runPath := strings.TrimSpace(run.Path); runPath != "" {
		runPath = strings.SplitN(runPath, "@", 2)[0]
		receiptPath := strings.TrimSpace(receipt.Workflow)
		if runPath != receiptPath && filepath.Base(runPath) != filepath.Base(receiptPath) {
			return false
		}
	}
	return run.DatabaseID != 0 || strings.TrimSpace(run.URL) != ""
}

func listDestroyCloudCleanupWorkflowRuns(
	ctx context.Context,
	repo string,
	filters []string,
	limit int,
	stopAtRunID int64,
) ([]destroyGitHubWorkflowRun, error) {
	if strings.TrimSpace(repo) == "" {
		return nil, fmt.Errorf("cleanup workflow repository is required")
	}
	if limit <= 0 {
		return nil, nil
	}
	pageSize := min(limit, 100)
	var runs []destroyGitHubWorkflowRun
	for page := 1; len(runs) < limit; page++ {
		args := []string{
			"api", "repos/" + strings.TrimSpace(repo) + "/actions/runs",
			"-f", "per_page=" + strconv.Itoa(pageSize),
			"-f", "page=" + strconv.Itoa(page),
			"--method", "GET",
		}
		for _, filter := range filters {
			args = append(args, "-f", filter)
		}
		// #nosec G702 -- gh is invoked without a shell; repository and filters are passed as argv fields.
		cmd := exec.CommandContext(ctx, "gh", args...)
		cmd.Env = ghPlainJSONEnv(cmd.Env)
		out, err := verbose.RunCommand(cmd)
		if err != nil {
			if ctxErr := ctx.Err(); ctxErr != nil {
				return nil, ctxErr
			}
			message := strings.TrimSpace(string(out))
			if message != "" {
				return nil, fmt.Errorf("list repository workflow runs: %w: %s", err, message)
			}
			return nil, fmt.Errorf("list repository workflow runs: %w", err)
		}
		var response destroyGitHubWorkflowRuns
		if err := json.Unmarshal(out, &response); err != nil {
			return nil, fmt.Errorf("decode repository workflow runs: %w", err)
		}
		for _, run := range response.WorkflowRuns {
			runs = append(runs, run)
			if stopAtRunID > 0 && run.DatabaseID == stopAtRunID {
				return runs, nil
			}
			if len(runs) >= limit {
				break
			}
		}
		if len(response.WorkflowRuns) < pageSize {
			break
		}
	}
	return runs, nil
}

func ghPlainJSONEnv(base []string) []string {
	if len(base) == 0 {
		base = os.Environ()
	}
	out := make([]string, 0, len(base)+2)
	for _, entry := range base {
		switch {
		case strings.HasPrefix(entry, "FORCE_COLOR="),
			strings.HasPrefix(entry, "CLICOLOR_FORCE="),
			strings.HasPrefix(entry, "CLICOLOR="),
			strings.HasPrefix(entry, "GH_COLOR="),
			strings.HasPrefix(entry, "NO_COLOR="):
			continue
		default:
			out = append(out, entry)
		}
	}
	return append(out, "GH_COLOR=never", "NO_COLOR=1", "CLICOLOR=0")
}

func lookupDestroyCloudCleanupWorkflowRun(
	ctx context.Context,
	repo string,
	runID int64,
) (*destroyGitHubWorkflowRun, error) {
	if strings.TrimSpace(repo) == "" || runID <= 0 {
		return nil, fmt.Errorf("cleanup workflow run lookup requires repo and run id")
	}
	if _, err := exec.LookPath("gh"); err != nil {
		return nil, fmt.Errorf("gh CLI is required to verify cleanup workflow status: %w", err)
	}
	lookupCtx := ctx
	cancel := func() {}
	if destroyCleanupWorkflowRunLookupTimeout > 0 {
		lookupCtx, cancel = context.WithTimeout(ctx, destroyCleanupWorkflowRunLookupTimeout)
	}
	defer cancel()
	runs, err := listDestroyCloudCleanupWorkflowRuns(lookupCtx, repo, nil, 2000, runID)
	if err != nil {
		return nil, fmt.Errorf("inspect cleanup workflow run %d: %w", runID, err)
	}
	for i := range runs {
		if runs[i].DatabaseID == runID {
			run := runs[i]
			return &run, nil
		}
	}
	return nil, fmt.Errorf("repository workflow run list did not contain run %d", runID)
}

func destroyCloudCleanupWorkflowRunTitle(scope cpaws.Scope, region, dispatchID string) string {
	scope = scope.Normalize()
	return fmt.Sprintf(
		"cp9 cleanup owner=%s session=%s region=%s dispatch=%s",
		strings.TrimSpace(scope.Owner),
		strings.TrimSpace(scope.SessionID),
		strings.TrimSpace(region),
		strings.TrimSpace(dispatchID),
	)
}

func normalizeGitHubWorkflowConclusion(status, conclusion string) string {
	if text := strings.TrimSpace(conclusion); text != "" {
		return text
	}
	if text := strings.TrimSpace(status); text != "" && text != "completed" {
		return "pending"
	}
	return ""
}

func destroyCloudCleanupWorkflowURL(repo, workflowFile string) string {
	repo = strings.TrimSpace(repo)
	workflowFile = strings.TrimSpace(workflowFile)
	if repo == "" || workflowFile == "" {
		return ""
	}
	workflowFile = strings.TrimPrefix(workflowFile, ".github/workflows/")
	workflowFile = strings.TrimPrefix(workflowFile, "/")
	return fmt.Sprintf("https://github.com/%s/actions/workflows/%s", repo, workflowFile)
}

func destroyCloudCleanupWorkflowRepo() string {
	return firstNonEmptyString(
		strings.TrimSpace(os.Getenv("CP9_CLEANUP_WORKFLOW_REPO")),
		strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY")),
		"tidbcloud/controlplane9",
	)
}

func destroyCloudCleanupWorkflowFile() string {
	return firstNonEmptyString(
		strings.TrimSpace(os.Getenv("CP9_CLEANUP_WORKFLOW_FILE")),
		"cp9-session-cleanup.yml",
	)
}

func destroyCloudCleanupWorkflowRef() string {
	return firstNonEmptyString(
		strings.TrimSpace(os.Getenv("CP9_CLEANUP_WORKFLOW_REF")),
		"main",
	)
}

func destroyFailedCloudCleanupWorkflowHandoff(
	destroyCtx *v1alpha2DestroyContext,
	dispatchErr error,
) *destroyCloudCleanupWorkflowHandoff {
	repo := destroyCloudCleanupWorkflowRepo()
	workflowFile := destroyCloudCleanupWorkflowFile()
	handoff := &destroyCloudCleanupWorkflowHandoff{
		Result:         "dispatch_failed",
		Repo:           repo,
		Ref:            destroyCloudCleanupWorkflowRef(),
		Workflow:       workflowFile,
		WorkflowURL:    destroyCloudCleanupWorkflowURL(repo, workflowFile),
		FailurePhase:   "cloud_cleanup_handoff",
		FallbackResult: "local-cleanup-disabled",
	}
	if destroyCtx == nil {
		if dispatchErr != nil {
			handoff.ObservationError = dispatchErr.Error()
		}
		return handoff
	}
	if destroyCtx.ReapState != nil {
		scope := destroyCtx.ReapState.Scope.Normalize()
		handoff.Owner = scope.Owner
		handoff.SessionID = scope.SessionID
		handoff.Region = firstNonEmptyString(scope.Regions...)
	}
	if region := firstNonEmptyString(destroyReapRegions(destroyCtx.Resolved, destroyCtx.AWSState)...); region != "" {
		handoff.Region = region
	}
	if destroyCtx.AWSState != nil {
		handoff.AccountID = strings.TrimSpace(destroyCtx.AWSState.Account)
	}
	if handoff.AccountID == "" {
		handoff.AccountID = v1alpha2DestroyReaperAccountID(destroyCtx.Resolved)
	}
	if dispatchErr != nil {
		handoff.ObservationError = dispatchErr.Error()
	}
	return handoff
}

func validateDestroyCloudCleanupWorkflowScope(scope cpaws.Scope) error {
	if strings.ContainsAny(scope.Owner, "\t\n\r") {
		return fmt.Errorf("cleanup workflow dispatch owner must not contain tab or newline characters")
	}
	session := scope.SessionID
	if len(session) < 3 || len(session) > 32 {
		return destroyCloudCleanupWorkflowSessionFormatError()
	}
	if !isLowerASCIIAlpha(session[0]) {
		return destroyCloudCleanupWorkflowSessionFormatError()
	}
	if !isLowerASCIIAlnum(session[len(session)-1]) {
		return destroyCloudCleanupWorkflowSessionFormatError()
	}
	for i := 1; i < len(session)-1; i++ {
		c := session[i]
		if isLowerASCIIAlnum(c) || c == '-' {
			continue
		}
		return destroyCloudCleanupWorkflowSessionFormatError()
	}
	return nil
}

func destroyCloudCleanupWorkflowSessionFormatError() error {
	return fmt.Errorf(
		"cleanup workflow dispatch session must be 3-32 DNS-compatible chars: lowercase alnum/hyphen, start with a letter, end with alnum",
	)
}

func isLowerASCIIAlpha(c byte) bool {
	return c >= 'a' && c <= 'z'
}

func isLowerASCIIAlnum(c byte) bool {
	return isLowerASCIIAlpha(c) || (c >= '0' && c <= '9')
}

func iamTagMap(tags []iamtypes.Tag) map[string]string {
	out := make(map[string]string, len(tags))
	for _, tag := range tags {
		out[awssdk.ToString(tag.Key)] = awssdk.ToString(tag.Value)
	}
	return out
}

func destroyIAMTagsMatchScope(tags map[string]string, scope cpaws.Scope) bool {
	if strings.TrimSpace(tags[cpaws.TagSession]) != strings.TrimSpace(scope.SessionID) {
		return false
	}
	if !cpaws.OwnerTagMatches(scope.Owner, tags[cpaws.TagOwner]) {
		return false
	}
	switch strings.TrimSpace(tags[cpaws.TagManaged]) {
	case cpaws.TagManagedTerraformSubstrate, "true":
		return true
	default:
		return false
	}
}

func destroyTaggedProfileOwnsRole(profileName, roleName, sessionID string) bool {
	profileName = strings.TrimSpace(profileName)
	roleName = strings.TrimSpace(roleName)
	sessionID = strings.TrimSpace(sessionID)
	if profileName == "" || roleName == "" || sessionID == "" {
		return false
	}
	return roleName == profileName || strings.Contains(roleName, sessionID)
}

func destroyTaggedRoleLocalPolicies(ctx context.Context, client destroyIAMProfileAPI, roleName, sessionID string) ([]string, error) {
	var policyARNs []string
	input := &iam.ListAttachedRolePoliciesInput{RoleName: awssdk.String(roleName)}
	for {
		out, err := client.ListAttachedRolePolicies(ctx, input)
		if err != nil {
			if destroyIAMNotFound(err) {
				return policyARNs, nil
			}
			return nil, fmt.Errorf("discover tagged IAM role policies %s: %w", roleName, err)
		}
		for _, policy := range out.AttachedPolicies {
			policyARN := awssdk.ToString(policy.PolicyArn)
			policyName := awssdk.ToString(policy.PolicyName)
			if policyARN == "" || strings.HasPrefix(policyARN, "arn:aws:iam::aws:policy/") {
				continue
			}
			if !strings.Contains(policyName, roleName) && !strings.Contains(policyName, sessionID) {
				continue
			}
			policyARNs = append(policyARNs, policyARN)
		}
		if !out.IsTruncated || out.Marker == nil {
			break
		}
		input.Marker = out.Marker
	}
	return policyARNs, nil
}

func destroyIAMNotFound(err error) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.ErrorCode() {
		case "NoSuchEntity", "NoSuchEntityException", "NotFound":
			return true
		}
	}
	msg := err.Error()
	return strings.Contains(msg, "NoSuchEntity") || strings.Contains(msg, "not found")
}

func mergeDestroyCleanupResources(primary, extra []cpaws.Resource) []cpaws.Resource {
	if len(extra) == 0 {
		return primary
	}
	out := append([]cpaws.Resource(nil), primary...)
	seen := make(map[string]struct{}, len(out)+len(extra))
	for i := range out {
		seen[destroyCleanupResourceKey(&out[i])] = struct{}{}
	}
	for i := range extra {
		key := destroyCleanupResourceKey(&extra[i])
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out = append(out, extra[i])
	}
	return out
}

func destroyCleanupResourceKey(resource *cpaws.Resource) string {
	if resource == nil {
		return ""
	}
	return resource.Region + "\x00" + string(resource.Type) + "\x00" + resource.ARN
}

func destroyDirectIAMCleanupType(resourceType cpaws.ResourceType) bool {
	switch resourceType {
	case cpaws.ResourceIAMInstanceProfile, cpaws.ResourceIAMPolicy, cpaws.ResourceIAMRole:
		return true
	default:
		return false
	}
}

func destroyCleanupSecretsManagerResources(
	ctx context.Context,
	reaper *cpaws.Reaper,
	scope cpaws.Scope,
	resources []cpaws.Resource,
) ([]string, []cpaws.Resource, error) {
	var secretResources []cpaws.Resource
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceSecretsManagerSecret {
			continue
		}
		if !cpaws.OwnerTagMatches(scope.Owner, resource.Owner) || resource.SessionID != scope.SessionID {
			return nil, nil, fmt.Errorf(
				"refusing direct Secrets Manager cleanup for %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resource.ARN,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		secretResources = append(secretResources, resource)
	}
	if len(secretResources) == 0 {
		return nil, resources, nil
	}
	if reaper == nil || reaper.Cleaner == nil || reaper.Scanner == nil {
		return nil, nil, fmt.Errorf("direct Secrets Manager cleanup requires scanner and cleaner")
	}
	removed := make([]string, 0, len(secretResources))
	for i := range secretResources {
		resource := secretResources[i]
		if _, err := reaper.Cleaner.Delete(ctx, &resource); err != nil {
			return nil, nil, fmt.Errorf("direct Secrets Manager cleanup %s: %w", resource.ARN, err)
		}
		removed = append(removed, resource.ARN)
	}
	removedSet := make(map[string]struct{}, len(removed))
	for _, arn := range removed {
		removedSet[arn] = struct{}{}
	}
	verify, err := reaper.Reap(ctx, scope, true, nil)
	if err != nil {
		return nil, nil, fmt.Errorf("direct Secrets Manager cleanup verify: %w", err)
	}
	if verify == nil {
		return nil, nil, fmt.Errorf("direct Secrets Manager cleanup verify returned no result")
	}
	survivors := make([]cpaws.Resource, 0, len(verify.Survivors))
	for _, survivor := range verify.Survivors {
		if survivor.Type == cpaws.ResourceSecretsManagerSecret {
			if _, removed := removedSet[survivor.ARN]; removed {
				continue
			}
			return nil, nil, fmt.Errorf("direct Secrets Manager cleanup left survivor %s", survivor.String())
		}
		survivors = append(survivors, survivor)
	}
	slices.Sort(removed)
	return removed, survivors, nil
}

func destroyCleanupEC2TailResources(
	ctx context.Context,
	reaper *cpaws.Reaper,
	scope cpaws.Scope,
	resources []cpaws.Resource,
	resourceType cpaws.ResourceType,
) ([]string, []cpaws.Resource, error) {
	var matches []cpaws.Resource
	for _, resource := range resources {
		if resource.Type != resourceType {
			continue
		}
		if (strings.TrimSpace(resource.Owner) != "" && !cpaws.OwnerTagMatches(scope.Owner, resource.Owner)) ||
			resource.SessionID != scope.SessionID {
			return nil, nil, fmt.Errorf(
				"refusing direct %s cleanup for %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resourceType,
				resource.ARN,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		matches = append(matches, resource)
	}
	if len(matches) == 0 {
		return nil, resources, nil
	}
	if reaper == nil || reaper.Cleaner == nil || reaper.Scanner == nil {
		return nil, nil, fmt.Errorf("direct %s cleanup requires scanner and cleaner", resourceType)
	}
	removed := make([]string, 0, len(matches))
	for i := range matches {
		resource := matches[i]
		if _, err := reaper.Cleaner.Delete(ctx, &resource); err != nil {
			return nil, nil, fmt.Errorf("direct %s cleanup %s: %w", resourceType, resource.ARN, err)
		}
		removed = append(removed, resource.ARN)
	}
	removedSet := make(map[string]struct{}, len(removed))
	for _, arn := range removed {
		removedSet[arn] = struct{}{}
	}
	verify, err := reaper.Reap(ctx, scope, true, nil)
	if err != nil {
		return nil, nil, fmt.Errorf("direct %s cleanup verify: %w", resourceType, err)
	}
	if verify == nil {
		return nil, nil, fmt.Errorf("direct %s cleanup verify returned no result", resourceType)
	}
	survivors := make([]cpaws.Resource, 0, len(verify.Survivors))
	for _, survivor := range verify.Survivors {
		if survivor.Type == resourceType {
			if _, removed := removedSet[survivor.ARN]; removed {
				continue
			}
			return nil, nil, fmt.Errorf("direct %s cleanup left survivor %s", resourceType, survivor.String())
		}
		survivors = append(survivors, survivor)
	}
	slices.Sort(removed)
	return removed, survivors, nil
}

func filterPostNukeStaleSurvivorsWithWarnings(
	ctx context.Context,
	survivors []cpaws.Resource,
	req *cpaws.NukeRunRequest,
	phase string,
) ([]cpaws.Resource, []destroyStaleTagWarning, error) {
	if len(survivors) == 0 {
		return nil, nil, nil
	}
	if req == nil {
		return survivors, nil, fmt.Errorf("aws-nuke run request is required for post-cleanup verification")
	}
	if strings.TrimSpace(req.AccessKeyID) == "" || strings.TrimSpace(req.SecretAccessKey) == "" {
		return survivors, nil, fmt.Errorf("aws-nuke credentials must be frozen before post-cleanup verification")
	}
	ec2Clients := map[string]destroyEC2PostNukeVerifyAPI{}
	eksClients := map[string]destroyEKSResourceAPI{}
	elbv2Clients := map[string]destroyELBV2LoadBalancerAPI{}
	classicELBClients := map[string]destroyClassicELBLoadBalancerAPI{}
	iamClients := map[string]destroyIAMPostNukeVerifyAPI{}
	route53Clients := map[string]destroyRoute53RecordSetAPI{}
	s3Clients := map[string]destroyS3BucketVerifyAPI{}
	secretsClients := map[string]destroySecretsManagerSecretVerifyAPI{}
	acmpcaClients := map[string]destroyACMPCAVerifyAPI{}
	kmsClients := map[string]destroyKMSVerifyAPI{}
	mskClients := map[string]destroyMSKVerifyAPI{}
	var kept []cpaws.Resource
	var warnings []destroyStaleTagWarning
	for _, survivor := range survivors {
		region := strings.TrimSpace(survivor.Region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			kept = append(kept, survivor)
			continue
		}
		verifier := "ec2"
		message := "Resource Groups Tagging API returned a stale mapping; direct EC2 API proved the resource is gone"
		var gone bool
		var err error
		switch survivor.Type {
		case cpaws.ResourceLoadBalancer, cpaws.ResourceTargetGroup:
			if survivor.Type == cpaws.ResourceLoadBalancer && postNukeClassicELBLoadBalancerARN(survivor.ARN) {
				client := classicELBClients[region]
				if client == nil {
					cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
					client = destroyNewClassicELBClient(cfg)
					classicELBClients[region] = client
				}
				gone, err = postNukeClassicELBSurvivorGone(ctx, client, &survivor)
				verifier = "elb"
				message = "Resource Groups Tagging API returned a stale mapping; direct Classic ELB API proved the resource is gone"
			} else {
				client := elbv2Clients[region]
				if client == nil {
					cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
					client = destroyNewELBV2Client(cfg)
					elbv2Clients[region] = client
				}
				gone, err = postNukeELBV2SurvivorGone(ctx, client, &survivor)
				verifier = "elbv2"
				message = "Resource Groups Tagging API returned a stale mapping; direct ELBv2 API proved the resource is gone"
			}
		case cpaws.ResourceEKSCluster, cpaws.ResourceEKSNodegroup:
			client := eksClients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewEKSClient(cfg)
				eksClients[region] = client
			}
			gone, err = postNukeEKSSurvivorGone(ctx, client, &survivor)
			verifier = "eks"
			message = "Resource Groups Tagging API returned a stale mapping; direct EKS API proved the resource is gone"
		case cpaws.ResourceIAMRole, cpaws.ResourceIAMInstanceProfile, cpaws.ResourceIAMPolicy:
			client := iamClients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewIAMPostNukeVerifyClient(cfg)
				iamClients[region] = client
			}
			gone, err = postNukeIAMSurvivorGone(ctx, client, &survivor)
			verifier = "iam"
			message = "Resource Groups Tagging API returned a stale mapping; direct IAM API proved the resource is gone"
		case cpaws.ResourceRoute53HostedZone:
			client := route53Clients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewRoute53Client(cfg)
				route53Clients[region] = client
			}
			gone, err = postNukeRoute53HostedZoneSurvivorGone(ctx, client, &survivor)
			verifier = "route53"
			message = "Resource Groups Tagging API returned a stale mapping; direct Route53 API proved the resource is gone"
		case cpaws.ResourceS3:
			client := s3Clients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewS3Client(cfg)
				s3Clients[region] = client
			}
			gone, err = postNukeS3SurvivorGone(ctx, client, &survivor)
			verifier = "s3"
			message = "Resource Groups Tagging API returned a stale mapping; direct S3 API proved the resource is gone"
		case cpaws.ResourceSecretsManagerSecret:
			client := secretsClients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewSecretsManagerClient(cfg)
				secretsClients[region] = client
			}
			gone, err = postNukeSecretsManagerSurvivorGone(ctx, client, &survivor)
			verifier = "secretsmanager"
			message = "Resource Groups Tagging API returned a stale mapping; direct Secrets Manager API proved the resource is gone"
		case cpaws.ResourceEKSPodIdentity:
			client := eksClients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewEKSClient(cfg)
				eksClients[region] = client
			}
			gone, err = postNukeEKSPodIdentitySurvivorGone(ctx, client, &survivor)
			verifier = "eks"
			message = "Resource Groups Tagging API returned a stale mapping; the EKS cluster is gone so its pod identity association is gone"
		case cpaws.ResourceACMPCACertAuthority:
			client := acmpcaClients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewACMPCAClient(cfg)
				acmpcaClients[region] = client
			}
			gone, err = postNukeACMPCASurvivorGone(ctx, client, &survivor)
			verifier = "acm-pca"
			message = "Resource Groups Tagging API returned a stale mapping; direct ACM-PCA API proved the certificate authority is deleted"
		case cpaws.ResourceKMSKey:
			client := kmsClients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewKMSClient(cfg)
				kmsClients[region] = client
			}
			gone, err = postNukeKMSKeySurvivorGone(ctx, client, &survivor)
			verifier = "kms"
			message = "Resource Groups Tagging API returned a stale mapping; direct KMS API proved the key is gone or pending deletion"
		case cpaws.ResourceMSKCluster:
			client := mskClients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewMSKClient(cfg)
				mskClients[region] = client
			}
			gone, err = postNukeMSKClusterSurvivorGone(ctx, client, &survivor)
			verifier = "msk"
			message = "Resource Groups Tagging API returned a stale mapping; direct MSK API proved the cluster is gone"
		default:
			client := ec2Clients[region]
			if client == nil {
				cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
				client = destroyNewEC2PostNukeVerifyClient(cfg)
				ec2Clients[region] = client
			}
			gone, err = postNukeEC2SurvivorGone(ctx, client, &survivor)
		}
		if err != nil {
			return survivors, warnings, err
		}
		if gone {
			warnings = append(warnings, destroyStaleTagWarning{
				Phase:     strings.TrimSpace(phase),
				Verifier:  verifier,
				ARN:       survivor.ARN,
				Type:      string(survivor.Type),
				Region:    region,
				AccountID: accountIDFromAWSARN(survivor.ARN),
				Owner:     survivor.Owner,
				SessionID: survivor.SessionID,
				Message:   message,
			})
			continue
		}
		kept = append(kept, survivor)
	}
	return kept, warnings, nil
}

func postNukeIAMSurvivorGone(ctx context.Context, client destroyIAMPostNukeVerifyAPI, survivor *cpaws.Resource) (bool, error) {
	if client == nil || survivor == nil {
		return false, nil
	}
	switch survivor.Type {
	case cpaws.ResourceIAMRole:
		roleName := postNukeIAMNameFromARN(survivor.ARN, "role/")
		if roleName == "" {
			return false, nil
		}
		out, err := client.GetRole(ctx, &iam.GetRoleInput{RoleName: awssdk.String(roleName)})
		if err != nil {
			if destroyIAMNotFound(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke IAM role %s: %w", roleName, err)
		}
		return out == nil || out.Role == nil, nil
	case cpaws.ResourceIAMInstanceProfile:
		profileName := postNukeIAMNameFromARN(survivor.ARN, "instance-profile/")
		if profileName == "" {
			return false, nil
		}
		out, err := client.GetInstanceProfile(ctx, &iam.GetInstanceProfileInput{
			InstanceProfileName: awssdk.String(profileName),
		})
		if err != nil {
			if destroyIAMNotFound(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke IAM instance profile %s: %w", profileName, err)
		}
		return out == nil || out.InstanceProfile == nil, nil
	case cpaws.ResourceIAMPolicy:
		policyARN := strings.TrimSpace(survivor.ARN)
		if !postNukeIAMPolicyARN(policyARN) {
			return false, nil
		}
		out, err := client.GetPolicy(ctx, &iam.GetPolicyInput{PolicyArn: awssdk.String(policyARN)})
		if err != nil {
			if destroyIAMNotFound(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke IAM policy %s: %w", policyARN, err)
		}
		return out == nil || out.Policy == nil, nil
	default:
		return false, nil
	}
}

func postNukeIAMNameFromARN(arn, prefix string) string {
	parts := strings.SplitN(strings.TrimSpace(arn), ":", 6)
	if len(parts) < 6 {
		return ""
	}
	resource := parts[5]
	name := strings.TrimPrefix(resource, prefix)
	if name == resource || name == "" {
		return ""
	}
	pathParts := strings.Split(name, "/")
	return strings.TrimSpace(pathParts[len(pathParts)-1])
}

func postNukeIAMPolicyARN(arn string) bool {
	parts := strings.SplitN(strings.TrimSpace(arn), ":", 6)
	return len(parts) == 6 && parts[2] == "iam" && strings.HasPrefix(parts[5], "policy/")
}

func postNukeS3SurvivorGone(ctx context.Context, client destroyS3BucketVerifyAPI, survivor *cpaws.Resource) (bool, error) {
	if client == nil || survivor == nil || survivor.Type != cpaws.ResourceS3 {
		return false, nil
	}
	bucket := postNukeS3BucketNameFromARN(survivor.ARN)
	if bucket == "" {
		return false, nil
	}
	if _, err := client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: awssdk.String(bucket)}); err != nil {
		if s3NotFoundError(err) {
			return true, nil
		}
		return false, fmt.Errorf("verify post-nuke S3 bucket %s: %w", bucket, err)
	}
	return false, nil
}

func postNukeS3BucketNameFromARN(arn string) string {
	trimmed := strings.TrimSpace(arn)
	bucket := strings.TrimPrefix(trimmed, "arn:aws:s3:::")
	if bucket == trimmed || bucket == "" {
		return ""
	}
	if name, _, ok := strings.Cut(bucket, "/"); ok {
		bucket = name
	}
	return strings.TrimSpace(bucket)
}

func postNukeSecretsManagerSurvivorGone(
	ctx context.Context,
	client destroySecretsManagerSecretVerifyAPI,
	survivor *cpaws.Resource,
) (bool, error) {
	if client == nil || survivor == nil || survivor.Type != cpaws.ResourceSecretsManagerSecret ||
		strings.TrimSpace(survivor.ARN) == "" {
		return false, nil
	}
	out, err := client.DescribeSecret(ctx, &secretsmanager.DescribeSecretInput{
		SecretId: awssdk.String(survivor.ARN),
	})
	if err != nil {
		if secretsManagerNotFoundError(err) {
			return true, nil
		}
		return false, fmt.Errorf("verify post-nuke Secrets Manager secret %s: %w", survivor.ARN, err)
	}
	if out == nil || awssdk.ToString(out.ARN) == "" {
		return true, nil
	}
	// A secret scheduled for deletion keeps appearing in the Resource Groups
	// Tagging API and via DescribeSecret (with DeletedDate set) for its recovery
	// window, but it is cleaned up: aws-nuke has issued the delete, it cannot be
	// removed sooner, and AWS does not bill secrets pending deletion. Treat it as
	// gone so it is not a false survivor that blocks the purge.
	return out.DeletedDate != nil, nil
}

func postNukeRoute53HostedZoneSurvivorGone(
	ctx context.Context,
	client destroyRoute53RecordSetAPI,
	survivor *cpaws.Resource,
) (bool, error) {
	if client == nil || survivor == nil || survivor.Type != cpaws.ResourceRoute53HostedZone {
		return false, nil
	}
	hostedZoneID := postNukeRoute53HostedZoneIDFromARN(survivor.ARN)
	if hostedZoneID == "" {
		return false, nil
	}
	out, err := client.GetHostedZone(ctx, &route53.GetHostedZoneInput{Id: awssdk.String(hostedZoneID)})
	if err != nil {
		if route53NotFoundError(err) {
			return true, nil
		}
		return false, fmt.Errorf("verify post-nuke Route53 hosted zone %s: %w", hostedZoneID, err)
	}
	return out == nil || out.HostedZone == nil, nil
}

func postNukeRoute53HostedZoneIDFromARN(arn string) string {
	trimmed := strings.TrimSpace(arn)
	hostedZoneID := strings.TrimPrefix(trimmed, "arn:aws:route53:::hostedzone/")
	if hostedZoneID == trimmed || hostedZoneID == "" {
		return ""
	}
	return strings.TrimSpace(hostedZoneID)
}

func postNukeEKSSurvivorGone(ctx context.Context, client destroyEKSResourceAPI, survivor *cpaws.Resource) (bool, error) {
	if client == nil || survivor == nil || strings.TrimSpace(survivor.ARN) == "" {
		return false, nil
	}
	switch survivor.Type {
	case cpaws.ResourceEKSCluster:
		clusterName := destroyEKSClusterNameFromARN(survivor.ARN)
		if clusterName == "" {
			return false, nil
		}
		out, err := client.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: awssdk.String(clusterName)})
		if err != nil {
			if destroyEKSNotFound(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke EKS cluster %s: %w", clusterName, err)
		}
		return out == nil || out.Cluster == nil, nil
	case cpaws.ResourceEKSNodegroup:
		clusterName, nodegroupName := destroyEKSNodegroupNamesFromARN(survivor.ARN)
		if clusterName == "" || nodegroupName == "" {
			return false, nil
		}
		out, err := client.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{
			ClusterName:   awssdk.String(clusterName),
			NodegroupName: awssdk.String(nodegroupName),
		})
		if err != nil {
			if destroyEKSNotFound(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke EKS nodegroup %s/%s: %w", clusterName, nodegroupName, err)
		}
		return out == nil || out.Nodegroup == nil, nil
	default:
		return false, nil
	}
}

// destroyEKSPodIdentityClusterFromARN extracts the cluster name from an EKS pod
// identity association ARN: arn:aws:eks:region:acct:podidentityassociation/<cluster>/<id>.
func destroyEKSPodIdentityClusterFromARN(arn string) string {
	const marker = ":podidentityassociation/"
	_, after, ok := strings.Cut(arn, marker)
	if !ok {
		return ""
	}
	rest := after
	parts := strings.SplitN(rest, "/", 2)
	if len(parts) == 0 {
		return ""
	}
	return strings.TrimSpace(parts[0])
}

// postNukeEKSPodIdentitySurvivorGone treats an EKS pod identity association as
// gone when its owning cluster no longer exists. Pod identity associations only
// exist while their cluster does and are removed with it, so a missing cluster
// proves the (tag-API stale) association is gone. aws-nuke has no resource type
// for pod identity associations, so this stale check is how purge converges.
func postNukeEKSPodIdentitySurvivorGone(ctx context.Context, client destroyEKSResourceAPI, survivor *cpaws.Resource) (bool, error) {
	if client == nil || survivor == nil {
		return false, nil
	}
	clusterName := destroyEKSPodIdentityClusterFromARN(survivor.ARN)
	if clusterName == "" {
		return false, nil
	}
	out, err := client.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: awssdk.String(clusterName)})
	if err != nil {
		if destroyEKSNotFound(err) {
			return true, nil
		}
		return false, fmt.Errorf("verify post-nuke EKS pod identity cluster %s: %w", clusterName, err)
	}
	return out == nil || out.Cluster == nil, nil
}

// destroyACMPCAVerifyAPI is the read-only ACM-PCA surface used to confirm a
// certificate authority is gone after cleanup.
type destroyACMPCAVerifyAPI interface {
	DescribeCertificateAuthority(
		ctx context.Context,
		params *acmpca.DescribeCertificateAuthorityInput,
		optFns ...func(*acmpca.Options),
	) (*acmpca.DescribeCertificateAuthorityOutput, error)
	UpdateCertificateAuthority(
		ctx context.Context,
		params *acmpca.UpdateCertificateAuthorityInput,
		optFns ...func(*acmpca.Options),
	) (*acmpca.UpdateCertificateAuthorityOutput, error)
	DeleteCertificateAuthority(
		ctx context.Context,
		params *acmpca.DeleteCertificateAuthorityInput,
		optFns ...func(*acmpca.Options),
	) (*acmpca.DeleteCertificateAuthorityOutput, error)
}

// destroyCleanupCloudFormationStacks removes session-owned CloudFormation stacks
// through the reaper's regional cleaner, which already caches a client per region
// and carries the frozen cleanup-role credentials.
//
// A stack is only ever deleted here: the reaper's verify pass runs dry. Deletion
// is asynchronous, so returns the ARNs proven gone -- a stack whose delete is
// still in flight stays a survivor and is re-checked on a later pass rather than
// being counted as zero.
func destroyCleanupCloudFormationStacks(
	ctx context.Context,
	reaper *cpaws.Reaper,
	scope cpaws.Scope,
	resources []cpaws.Resource,
) (map[string]struct{}, error) {
	gone := map[string]struct{}{}
	defaultRegion := firstNonEmptyString(scope.Regions...)
	var stacks []cpaws.Resource
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceCloudFormationStack {
			continue
		}
		if (strings.TrimSpace(resource.Owner) != "" && !cpaws.OwnerTagMatches(scope.Owner, resource.Owner)) ||
			resource.SessionID != scope.SessionID {
			return nil, fmt.Errorf(
				"refusing direct CloudFormation cleanup for %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resource.ARN, resource.Owner, resource.SessionID, scope.Owner, scope.SessionID,
			)
		}
		if strings.TrimSpace(resource.Region) == "" {
			resource.Region = defaultRegion
		}
		stacks = append(stacks, resource)
	}
	if len(stacks) == 0 {
		return gone, nil
	}
	if reaper == nil || reaper.Cleaner == nil {
		return nil, fmt.Errorf("direct CloudFormation cleanup requires a cleaner")
	}
	for i := range stacks {
		deleted, err := reaper.Cleaner.Delete(ctx, &stacks[i])
		if err != nil {
			return nil, err
		}
		if !deleted {
			gone[stacks[i].ARN] = struct{}{}
		}
	}
	return gone, nil
}

// purgeSessionHostAPI is the EC2 surface needed to stop a session's control
// plane before its dataplane is reaped.
type purgeSessionHostAPI interface {
	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)
}

var purgeNewSessionHostClient = func(cfg awssdk.Config) purgeSessionHostAPI {
	return ec2.NewFromConfig(cfg)
}

// purgeSessionHostStopTimeout bounds the wait for the control plane to stop. The
// point is that the reconciler is no longer running, which shutting-down already
// guarantees, so this only has to cover the API taking a moment to reflect it.
const purgeSessionHostStopTimeout = 3 * time.Minute

// purgeTerminateSessionHost terminates the session's Remote Dev host before
// anything else is deleted.
//
// The host runs k3d and crossplane, and crossplane reconciles the very dataplane
// resources the reap is about to remove. Deleting them while it is still running
// means it recreates them mid-run: a NAT gateway observed with CreateTime
// 05:28:12 inside the 05:25:52-05:43:31 window of one such run. cp9's own
// ordering made this certain rather than unlucky -- deletePriority puts
// ResourceEC2Instance last, behind everything the host reconciles.
//
// Nothing here is fatal, and that is the whole contract: this is an optimisation
// that removes a source of churn, not a precondition for reaping. A throttled
// DescribeInstances must not turn a run that would have deleted the session into
// a no-op. Every failure is reported and the reap continues, because a resource
// the reconciler recreates still surfaces as a survivor, which already blocks.
func purgeTerminateSessionHost(ctx context.Context, scope cpaws.Scope, req *cpaws.NukeRunRequest) {
	if req == nil || strings.TrimSpace(req.AccessKeyID) == "" || strings.TrimSpace(req.SecretAccessKey) == "" {
		return
	}
	region := firstNonEmptyString(strings.TrimSpace(req.Region), firstNonEmptyString(scope.Regions...))
	if region == "" || strings.TrimSpace(scope.SessionID) == "" || strings.TrimSpace(scope.Owner) == "" {
		return
	}
	cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
	client := purgeNewSessionHostClient(cfg)

	ids, err := purgeFindSessionHostInstances(ctx, client, scope)
	if err != nil {
		fmt.Fprintf(os.Stderr, "purge: could not look up session host for %s; reaping anyway: %v\n", scope.SessionID, err)
		return
	}
	if len(ids) == 0 {
		return
	}
	verbose.Logf("AWS ec2:TerminateInstances session-reconciler session=%s ids=%v", scope.SessionID, ids)
	if _, err := client.TerminateInstances(ctx, &ec2.TerminateInstancesInput{InstanceIds: ids}); err != nil {
		if !ec2ResourceGoneError(err) {
			fmt.Fprintf(os.Stderr, "purge: could not terminate session host %v; reaping anyway: %v\n", ids, err)
		}
		return
	}
	purgeWaitSessionHostTerminated(ctx, client, scope)
}

// purgeFindSessionHostInstances returns the live control-plane hosts for this
// scope.
//
// Owner is part of the selection, not a check applied afterwards: an instance
// whose owner tag is missing or belongs to someone else is left alone rather than
// terminated, because an exact session and Name match is not enough to justify
// terminating something cp9 cannot attribute to this owner. Skipping is also the
// right response to a mismatch -- it deselects one instance instead of failing a
// cleanup that has nothing to do with it.
func purgeFindSessionHostInstances(
	ctx context.Context,
	client purgeSessionHostAPI,
	scope cpaws.Scope,
) ([]string, error) {
	out, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{
		Filters: []ec2types.Filter{
			{Name: awssdk.String("tag:" + cpaws.TagSession), Values: []string{scope.SessionID}},
			{Name: awssdk.String("tag:Name"), Values: []string{cpaws.SessionHostNameTag(scope.SessionID)}},
			{Name: awssdk.String("instance-state-name"), Values: []string{"pending", "running", "stopping", "stopped"}},
		},
	})
	if err != nil {
		if ec2ResourceGoneError(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("describe session host for %s: %w", scope.SessionID, err)
	}
	var ids []string
	for i := range out.Reservations {
		for j := range out.Reservations[i].Instances {
			instance := out.Reservations[i].Instances[j]
			id := awssdk.ToString(instance.InstanceId)
			if id == "" {
				continue
			}
			owner := strings.TrimSpace(ec2TagMap(instance.Tags)[cpaws.TagOwner])
			if owner == "" || !cpaws.OwnerTagMatches(scope.Owner, owner) {
				fmt.Fprintf(os.Stderr,
					"purge: leaving instance %s running: owner tag %q does not match scope owner %q\n",
					id, owner, scope.Owner)
				continue
			}
			ids = append(ids, id)
		}
	}
	slices.Sort(ids)
	return ids, nil
}

// purgeWaitSessionHostTerminated waits until no selected host is still running.
func purgeWaitSessionHostTerminated(ctx context.Context, client purgeSessionHostAPI, scope cpaws.Scope) {
	deadline := time.Now().Add(purgeSessionHostStopTimeout)
	for {
		remaining, err := purgeFindSessionHostInstances(ctx, client, scope)
		if err != nil || len(remaining) == 0 {
			return
		}
		if ctx.Err() != nil || !time.Now().Before(deadline) {
			// shutting-down already stops the reconciler, and anything it recreated
			// still surfaces as a survivor.
			fmt.Fprintf(os.Stderr,
				"purge: session host %v still running after %s; continuing\n", remaining, purgeSessionHostStopTimeout)
			return
		}
		select {
		case <-ctx.Done():
			return
		case <-time.After(purgeRetrySleep()):
		}
	}
}

var destroyNewACMPCAClient = func(cfg awssdk.Config) destroyACMPCAVerifyAPI {
	return acmpca.NewFromConfig(cfg)
}

type destroyKMSVerifyAPI interface {
	DescribeKey(
		ctx context.Context,
		params *kms.DescribeKeyInput,
		optFns ...func(*kms.Options),
	) (*kms.DescribeKeyOutput, error)
}

var destroyNewKMSClient = func(cfg awssdk.Config) destroyKMSVerifyAPI {
	return kms.NewFromConfig(cfg)
}

func postNukeKMSKeySurvivorGone(
	ctx context.Context,
	client destroyKMSVerifyAPI,
	survivor *cpaws.Resource,
) (bool, error) {
	if client == nil || survivor == nil || survivor.Type != cpaws.ResourceKMSKey ||
		strings.TrimSpace(survivor.ARN) == "" {
		return false, nil
	}
	out, err := client.DescribeKey(ctx, &kms.DescribeKeyInput{KeyId: awssdk.String(survivor.ARN)})
	if err != nil {
		if destroyServiceNotFound(err, "NotFoundException") {
			return true, nil
		}
		return false, fmt.Errorf("verify post-nuke KMS key %s: %w", survivor.ARN, err)
	}
	if out == nil || out.KeyMetadata == nil {
		return false, fmt.Errorf("verify post-nuke KMS key %s returned no key metadata", survivor.ARN)
	}
	switch out.KeyMetadata.KeyState {
	case kmstypes.KeyStatePendingDeletion, kmstypes.KeyStatePendingReplicaDeletion:
		return true, nil
	default:
		return false, nil
	}
}

type destroyMSKVerifyAPI interface {
	DescribeClusterV2(
		ctx context.Context,
		params *kafka.DescribeClusterV2Input,
		optFns ...func(*kafka.Options),
	) (*kafka.DescribeClusterV2Output, error)
}

var destroyNewMSKClient = func(cfg awssdk.Config) destroyMSKVerifyAPI {
	return kafka.NewFromConfig(cfg)
}

func postNukeMSKClusterSurvivorGone(
	ctx context.Context,
	client destroyMSKVerifyAPI,
	survivor *cpaws.Resource,
) (bool, error) {
	if client == nil || survivor == nil || survivor.Type != cpaws.ResourceMSKCluster ||
		strings.TrimSpace(survivor.ARN) == "" {
		return false, nil
	}
	out, err := client.DescribeClusterV2(ctx, &kafka.DescribeClusterV2Input{ClusterArn: awssdk.String(survivor.ARN)})
	if err != nil {
		if destroyServiceNotFound(err, "NotFoundException") {
			return true, nil
		}
		return false, fmt.Errorf("verify post-nuke MSK cluster %s: %w", survivor.ARN, err)
	}
	if out == nil || out.ClusterInfo == nil {
		return false, fmt.Errorf("verify post-nuke MSK cluster %s returned no cluster metadata", survivor.ARN)
	}
	return false, nil
}

func destroyServiceNotFound(err error, allowedCodes ...string) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if !errors.As(err, &apiErr) {
		return false
	}
	return slices.Contains(allowedCodes, apiErr.ErrorCode())
}

// purgeACMPCAPermanentDeletionDays is the restoration window for a deleted CA.
const purgeACMPCAPermanentDeletionDays = 7

// purgeDeleteACMPCACertAuthorities directly disables (if ACTIVE) and deletes any
// ACM-PCA certificate authorities among the survivors. aws-nuke does not reliably
// delete ACTIVE root CAs, so purge handles them directly. Deletion moves a CA to
// DELETED state (restorable within the window). The returned ARN set contains CAs
// this run has proven or moved to a gone state, so callers do not depend on
// eventually-consistent describe reads before dropping stale survivor rows. It is
// scoped to the already owner/session-matched survivor ARNs.
func purgeDeleteACMPCACertAuthorities(
	ctx context.Context,
	survivors []cpaws.Resource,
	req *cpaws.NukeRunRequest,
) (map[string]struct{}, error) {
	if req == nil || strings.TrimSpace(req.AccessKeyID) == "" || strings.TrimSpace(req.SecretAccessKey) == "" {
		return map[string]struct{}{}, nil
	}
	clients := map[string]destroyACMPCAVerifyAPI{}
	handled := map[string]struct{}{}
	for i := range survivors {
		survivor := &survivors[i]
		if survivor.Type != cpaws.ResourceACMPCACertAuthority {
			continue
		}
		region := strings.TrimSpace(survivor.Region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			continue
		}
		client := clients[region]
		if client == nil {
			cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
			client = destroyNewACMPCAClient(cfg)
			clients[region] = client
		}
		out, err := client.DescribeCertificateAuthority(ctx, &acmpca.DescribeCertificateAuthorityInput{
			CertificateAuthorityArn: awssdk.String(survivor.ARN),
		})
		if err != nil {
			if destroyEKSNotFound(err) {
				handled[survivor.ARN] = struct{}{}
				continue
			}
			return nil, fmt.Errorf("describe ACM-PCA %s: %w", survivor.ARN, err)
		}
		if out == nil || out.CertificateAuthority == nil {
			continue
		}
		status := out.CertificateAuthority.Status
		if status == acmpcatypes.CertificateAuthorityStatusDeleted {
			handled[survivor.ARN] = struct{}{}
			continue
		}
		if status == acmpcatypes.CertificateAuthorityStatusActive {
			if _, err := client.UpdateCertificateAuthority(ctx, &acmpca.UpdateCertificateAuthorityInput{
				CertificateAuthorityArn: awssdk.String(survivor.ARN),
				Status:                  acmpcatypes.CertificateAuthorityStatusDisabled,
			}); err != nil {
				return nil, fmt.Errorf("disable ACM-PCA %s: %w", survivor.ARN, err)
			}
		}
		if _, err := client.DeleteCertificateAuthority(ctx, &acmpca.DeleteCertificateAuthorityInput{
			CertificateAuthorityArn:     awssdk.String(survivor.ARN),
			PermanentDeletionTimeInDays: awssdk.Int32(purgeACMPCAPermanentDeletionDays),
		}); err != nil {
			if destroyEKSNotFound(err) {
				handled[survivor.ARN] = struct{}{}
				continue
			}
			return nil, fmt.Errorf("delete ACM-PCA %s: %w", survivor.ARN, err)
		}
		handled[survivor.ARN] = struct{}{}
	}
	return handled, nil
}

// purgeELBV2DeleteAPI is the ELBv2 surface the purge needs to remove orphaned
// load balancers by VPC membership.
type purgeELBV2DeleteAPI interface {
	DescribeLoadBalancers(
		ctx context.Context,
		params *elasticloadbalancingv2.DescribeLoadBalancersInput,
		optFns ...func(*elasticloadbalancingv2.Options),
	) (*elasticloadbalancingv2.DescribeLoadBalancersOutput, error)
	DeleteLoadBalancer(
		ctx context.Context,
		params *elasticloadbalancingv2.DeleteLoadBalancerInput,
		optFns ...func(*elasticloadbalancingv2.Options),
	) (*elasticloadbalancingv2.DeleteLoadBalancerOutput, error)
}

var purgeNewELBV2DeleteClient = func(cfg awssdk.Config) purgeELBV2DeleteAPI {
	return elasticloadbalancingv2.NewFromConfig(cfg)
}

func purgeEC2UnsuccessfulError(action string, items []ec2types.UnsuccessfulItem) error {
	details := make([]string, 0, len(items))
	for _, item := range items {
		code, message := "", ""
		if item.Error != nil {
			code = awssdk.ToString(item.Error.Code)
			message = awssdk.ToString(item.Error.Message)
		}
		details = append(details, fmt.Sprintf("resource=%s code=%s message=%s", awssdk.ToString(item.ResourceId), code, message))
	}
	return fmt.Errorf("%s unsuccessful: %s", action, strings.Join(details, "; "))
}

func purgeDeleteVPCEndpointServicesForLoadBalancer(
	ctx context.Context,
	client *ec2.Client,
	loadBalancerARN string,
) error {
	if strings.TrimSpace(loadBalancerARN) == "" {
		return nil
	}
	var serviceIDs []string
	var token *string
	for {
		verbose.Logf("AWS ec2:DescribeVpcEndpointServiceConfigurations load-balancer=%s", loadBalancerARN)
		out, err := client.DescribeVpcEndpointServiceConfigurations(ctx, &ec2.DescribeVpcEndpointServiceConfigurationsInput{
			NextToken: token,
		})
		if err != nil {
			return fmt.Errorf("describe VPC endpoint services for load balancer %s: %w", loadBalancerARN, err)
		}
		for i := range out.ServiceConfigurations {
			service := out.ServiceConfigurations[i]
			if slices.Contains(service.NetworkLoadBalancerArns, loadBalancerARN) ||
				slices.Contains(service.GatewayLoadBalancerArns, loadBalancerARN) {
				serviceIDs = append(serviceIDs, awssdk.ToString(service.ServiceId))
			}
		}
		token = out.NextToken
		if token == nil || awssdk.ToString(token) == "" {
			break
		}
	}
	for _, serviceID := range serviceIDs {
		if serviceID == "" {
			continue
		}
		var endpointIDs []string
		token = nil
		for {
			verbose.Logf("AWS ec2:DescribeVpcEndpointConnections service=%s", serviceID)
			out, err := client.DescribeVpcEndpointConnections(ctx, &ec2.DescribeVpcEndpointConnectionsInput{
				Filters:   []ec2types.Filter{{Name: awssdk.String("service-id"), Values: []string{serviceID}}},
				NextToken: token,
			})
			if err != nil {
				return fmt.Errorf("describe VPC endpoint connections for %s: %w", serviceID, err)
			}
			for i := range out.VpcEndpointConnections {
				connection := out.VpcEndpointConnections[i]
				state := string(connection.VpcEndpointState)
				if strings.EqualFold(state, string(ec2types.StateAvailable)) ||
					strings.EqualFold(state, string(ec2types.StatePendingAcceptance)) {
					endpointIDs = append(endpointIDs, awssdk.ToString(connection.VpcEndpointId))
				}
			}
			token = out.NextToken
			if token == nil || awssdk.ToString(token) == "" {
				break
			}
		}
		if len(endpointIDs) > 0 {
			verbose.Logf("AWS ec2:RejectVpcEndpointConnections service=%s endpoints=%v", serviceID, endpointIDs)
			out, err := client.RejectVpcEndpointConnections(ctx, &ec2.RejectVpcEndpointConnectionsInput{
				ServiceId:      awssdk.String(serviceID),
				VpcEndpointIds: endpointIDs,
			})
			if err != nil {
				return fmt.Errorf("reject VPC endpoint connections for %s: %w", serviceID, err)
			}
			if len(out.Unsuccessful) > 0 {
				return purgeEC2UnsuccessfulError("reject VPC endpoint connections for "+serviceID, out.Unsuccessful)
			}
		}
		verbose.Logf("AWS ec2:DeleteVpcEndpointServiceConfigurations service=%s", serviceID)
		out, err := client.DeleteVpcEndpointServiceConfigurations(ctx, &ec2.DeleteVpcEndpointServiceConfigurationsInput{
			ServiceIds: []string{serviceID},
		})
		if err != nil {
			return fmt.Errorf("delete VPC endpoint service %s: %w", serviceID, err)
		}
		if len(out.Unsuccessful) > 0 {
			return purgeEC2UnsuccessfulError("delete VPC endpoint service "+serviceID, out.Unsuccessful)
		}
	}
	return nil
}

// purgeELBClassicDeleteAPI is the Classic (v1) ELB surface the purge needs to
// remove orphaned load balancers by VPC membership. In-tree Kubernetes
// cloud-provider LoadBalancer services create Classic ELBs (SG named k8s-elb-*),
// which the ELBv2 sweep does not cover.
type purgeELBClassicDeleteAPI interface {
	DescribeLoadBalancers(
		ctx context.Context,
		params *elasticloadbalancing.DescribeLoadBalancersInput,
		optFns ...func(*elasticloadbalancing.Options),
	) (*elasticloadbalancing.DescribeLoadBalancersOutput, error)
	DeleteLoadBalancer(
		ctx context.Context,
		params *elasticloadbalancing.DeleteLoadBalancerInput,
		optFns ...func(*elasticloadbalancing.Options),
	) (*elasticloadbalancing.DeleteLoadBalancerOutput, error)
}

var purgeNewELBClassicClient = func(cfg awssdk.Config) purgeELBClassicDeleteAPI {
	return elasticloadbalancing.NewFromConfig(cfg)
}

// purgeDeleteVPCLoadBalancers deletes ELBv2 load balancers residing in the
// session's cp9-tagged VPCs, even when the load balancer itself lacks cp9 tags.
//
// A Kubernetes Service of type LoadBalancer makes the AWS Load Balancer
// Controller provision an NLB tagged only with elbv2.k8s.aws/* — invisible to
// the cp9 tag-scoped reaper. Its RequesterManaged ENIs pin the subnets, so
// aws-nuke cannot delete the route table/subnets/VPC (DependencyViolation) and
// exits fatal. Deleting the load balancer releases the ENIs. VPC membership
// proves ownership: cp9 VPCs are session-scoped, so any LB inside a cp9 VPC
// belongs to that session. Best-effort and idempotent — per-LB delete errors are
// logged, and the post-nuke verify remains the final arbiter.
func purgeDeleteVPCLoadBalancers(ctx context.Context, resources []cpaws.Resource, req *cpaws.NukeRunRequest) ([]string, error) {
	if req == nil || req.AccessKeyID == "" || req.SecretAccessKey == "" {
		return nil, fmt.Errorf("aws-nuke credentials must be frozen before load balancer VPC cleanup")
	}
	vpcByRegion := map[string]map[string]struct{}{}
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceVPC || strings.TrimSpace(resource.ARN) == "" {
			continue
		}
		vpcID := ec2ResourceIDFromARN(resource.ARN, "vpc/")
		if vpcID == "" {
			continue
		}
		region := strings.TrimSpace(resource.Region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			continue
		}
		if vpcByRegion[region] == nil {
			vpcByRegion[region] = map[string]struct{}{}
		}
		vpcByRegion[region][vpcID] = struct{}{}
	}

	regions := make([]string, 0, len(vpcByRegion))
	for region := range vpcByRegion {
		regions = append(regions, region)
	}
	slices.Sort(regions)

	var deleted []string
	for _, region := range regions {
		vpcs := vpcByRegion[region]
		cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
		client := purgeNewELBV2DeleteClient(cfg)
		endpointServices := ec2.NewFromConfig(cfg)
		var marker *string
		for {
			out, err := client.DescribeLoadBalancers(ctx, &elasticloadbalancingv2.DescribeLoadBalancersInput{Marker: marker})
			if err != nil {
				return deleted, fmt.Errorf("describe load balancers in %s: %w", region, err)
			}
			for i := range out.LoadBalancers {
				lb := out.LoadBalancers[i]
				if _, ok := vpcs[awssdk.ToString(lb.VpcId)]; !ok {
					continue
				}
				arn := awssdk.ToString(lb.LoadBalancerArn)
				if err := purgeDeleteVPCEndpointServicesForLoadBalancer(ctx, endpointServices, arn); err != nil {
					fmt.Fprintf(os.Stderr, "purge: delete endpoint services for load balancer %s: %v\n", arn, err)
					continue
				}
				if _, err := client.DeleteLoadBalancer(ctx, &elasticloadbalancingv2.DeleteLoadBalancerInput{
					LoadBalancerArn: awssdk.String(arn),
				}); err != nil {
					fmt.Fprintf(os.Stderr, "purge: delete orphan load balancer %s in VPC %s: %v\n", arn, awssdk.ToString(lb.VpcId), err)
					continue
				}
				deleted = append(deleted, arn)
			}
			if out.NextMarker == nil || awssdk.ToString(out.NextMarker) == "" {
				break
			}
			marker = out.NextMarker
		}

		// Classic ELBs (in-tree Kubernetes cloud-provider LoadBalancer services),
		// deleted by name; VPCId filters to this session's VPCs.
		classic := purgeNewELBClassicClient(cfg)
		var classicMarker *string
		for {
			cout, err := classic.DescribeLoadBalancers(ctx, &elasticloadbalancing.DescribeLoadBalancersInput{Marker: classicMarker})
			if err != nil {
				return deleted, fmt.Errorf("describe classic load balancers in %s: %w", region, err)
			}
			for i := range cout.LoadBalancerDescriptions {
				lb := cout.LoadBalancerDescriptions[i]
				if _, ok := vpcs[awssdk.ToString(lb.VPCId)]; !ok {
					continue
				}
				name := awssdk.ToString(lb.LoadBalancerName)
				if _, err := classic.DeleteLoadBalancer(ctx, &elasticloadbalancing.DeleteLoadBalancerInput{
					LoadBalancerName: awssdk.String(name),
				}); err != nil {
					fmt.Fprintf(os.Stderr, "purge: delete orphan classic load balancer %s in VPC %s: %v\n", name, awssdk.ToString(lb.VPCId), err)
					continue
				}
				deleted = append(deleted, "elb/"+name)
			}
			if cout.NextMarker == nil || awssdk.ToString(cout.NextMarker) == "" {
				break
			}
			classicMarker = cout.NextMarker
		}
	}
	return deleted, nil
}

// postNukeACMPCASurvivorGone treats an ACM-PCA certificate authority as gone when
// it no longer exists or is in DELETED state. A deleted CA stays listed by the
// Resource Groups Tagging API (and queryable) during its restoration window, so
// the tag mapping is stale even though the CA is effectively gone.
func postNukeACMPCASurvivorGone(ctx context.Context, client destroyACMPCAVerifyAPI, survivor *cpaws.Resource) (bool, error) {
	if client == nil || survivor == nil || strings.TrimSpace(survivor.ARN) == "" {
		return false, nil
	}
	out, err := client.DescribeCertificateAuthority(ctx, &acmpca.DescribeCertificateAuthorityInput{
		CertificateAuthorityArn: awssdk.String(survivor.ARN),
	})
	if err != nil {
		if destroyEKSNotFound(err) {
			return true, nil
		}
		return false, fmt.Errorf("verify post-nuke ACM-PCA %s: %w", survivor.ARN, err)
	}
	if out == nil || out.CertificateAuthority == nil {
		return true, nil
	}
	return out.CertificateAuthority.Status == acmpcatypes.CertificateAuthorityStatusDeleted, nil
}

func postNukeELBV2SurvivorGone(ctx context.Context, client destroyELBV2LoadBalancerAPI, survivor *cpaws.Resource) (bool, error) {
	if client == nil || survivor == nil || strings.TrimSpace(survivor.ARN) == "" {
		return false, nil
	}
	switch survivor.Type {
	case cpaws.ResourceLoadBalancer:
		if !postNukeELBV2LoadBalancerARN(survivor.ARN) {
			return false, nil
		}
		out, err := client.DescribeLoadBalancers(ctx, &elasticloadbalancingv2.DescribeLoadBalancersInput{
			LoadBalancerArns: []string{survivor.ARN},
		})
		if err != nil {
			if elbv2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke ELBv2 load balancer %s: %w", survivor.ARN, err)
		}
		return len(out.LoadBalancers) == 0, nil
	case cpaws.ResourceTargetGroup:
		out, err := client.DescribeTargetGroups(ctx, &elasticloadbalancingv2.DescribeTargetGroupsInput{
			TargetGroupArns: []string{survivor.ARN},
		})
		if err != nil {
			if elbv2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke ELBv2 target group %s: %w", survivor.ARN, err)
		}
		return len(out.TargetGroups) == 0, nil
	default:
		return false, nil
	}
}

func postNukeClassicELBSurvivorGone(
	ctx context.Context,
	client destroyClassicELBLoadBalancerAPI,
	survivor *cpaws.Resource,
) (bool, error) {
	if client == nil || survivor == nil || survivor.Type != cpaws.ResourceLoadBalancer {
		return false, nil
	}
	name := postNukeClassicELBLoadBalancerNameFromARN(survivor.ARN)
	if name == "" {
		return false, nil
	}
	out, err := client.DescribeLoadBalancers(ctx, &elasticloadbalancing.DescribeLoadBalancersInput{
		LoadBalancerNames: []string{name},
	})
	if err != nil {
		if classicELBNotFoundError(err) {
			return true, nil
		}
		return false, fmt.Errorf("verify post-nuke Classic ELB load balancer %s: %w", survivor.ARN, err)
	}
	return out == nil || len(out.LoadBalancerDescriptions) == 0, nil
}

func postNukeELBV2LoadBalancerARN(arn string) bool {
	return strings.Contains(arn, ":loadbalancer/app/") ||
		strings.Contains(arn, ":loadbalancer/net/") ||
		strings.Contains(arn, ":loadbalancer/gwy/")
}

func postNukeClassicELBLoadBalancerARN(arn string) bool {
	return postNukeClassicELBLoadBalancerNameFromARN(arn) != ""
}

func postNukeClassicELBLoadBalancerNameFromARN(arn string) string {
	parts := strings.SplitN(strings.TrimSpace(arn), ":", 6)
	if len(parts) != 6 || parts[2] != "elasticloadbalancing" {
		return ""
	}
	name := strings.TrimPrefix(parts[5], "loadbalancer/")
	if name == parts[5] || name == "" || strings.Contains(name, "/") {
		return ""
	}
	return strings.TrimSpace(name)
}

func writeDestroyDirectZeroSummary(scope cpaws.Scope, final *cpaws.ReapResult, evidence *destroyNukeEvidence) error {
	if final == nil {
		return fmt.Errorf("direct-zero summary requires final cleanup result")
	}
	path := destroyDirectZeroSummaryPath(scope)
	summary := destroyDirectZeroSummary{
		SchemaVersion: "cp9.destroy.direct-zero.v1",
		Scope: destroyDirectZeroScope{
			Owner:     scope.Owner,
			SessionID: scope.SessionID,
			Regions:   append([]string(nil), scope.Regions...),
		},
		Status:              "complete",
		DirectZero:          len(final.Survivors) == 0,
		FinalVerdict:        "direct-zero",
		FinalSurvivors:      len(final.Survivors),
		ResourceClassCounts: destroyResourceClassCounts(final.Survivors),
		ReaperInitial:       final.Initial,
		ReaperDeleted:       final.Deleted,
		ReaperPasses:        final.Passes,
		Survivors:           closeoutSurvivors(final.Survivors),
	}
	if evidence != nil {
		summary.StaleTagWarnings = append([]destroyStaleTagWarning(nil), evidence.StaleTagWarnings...)
	}
	if len(final.Survivors) > 0 {
		summary.Status = "survivors"
		summary.FinalVerdict = "survivors"
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return fmt.Errorf("create direct-zero summary dir: %w", err)
	}
	data, err := json.MarshalIndent(summary, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal direct-zero summary: %w", err)
	}
	data = append(data, '\n')
	if err := os.WriteFile(path, data, 0o600); err != nil {
		return fmt.Errorf("write direct-zero summary %s: %w", path, err)
	}
	if evidence != nil {
		evidence.DirectZeroSummaryPath = path
	}
	return nil
}

func destroyDirectZeroSummaryPath(scope cpaws.Scope) string {
	sessionID := safeDestroySummaryFileName(firstNonEmptyString(scope.SessionID, "unknown-session"))
	owner := safeDestroySummaryFileName(scope.Owner)
	name := sessionID + "-direct-zero-summary.json"
	if owner != "" {
		name = owner + "-" + name
	}
	return filepath.Join(
		cluster.DefaultStateDir,
		cluster.InternalStateDirName,
		destroySummaryDirName,
		name,
	)
}

const destroySummaryDirName = "destroy-summaries"

func destroyDirectZeroSummaryRoots() []string {
	return []string{
		filepath.Join(cluster.DefaultStateDir, cluster.InternalStateDirName, destroySummaryDirName),
		filepath.Join(cluster.DefaultStateDir, destroySummaryDirName),
	}
}

func safeDestroySummaryFileName(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return ""
	}
	var b strings.Builder
	for _, r := range value {
		switch {
		case r >= 'a' && r <= 'z':
			_, _ = b.WriteRune(r)
		case r >= 'A' && r <= 'Z':
			_, _ = b.WriteRune(r)
		case r >= '0' && r <= '9':
			_, _ = b.WriteRune(r)
		case r == '-', r == '_', r == '.':
			_, _ = b.WriteRune(r)
		default:
			_ = b.WriteByte('-')
		}
	}
	return strings.Trim(b.String(), "-.")
}

func postNukeEC2SurvivorGone(ctx context.Context, client destroyEC2PostNukeVerifyAPI, survivor *cpaws.Resource) (bool, error) {
	if client == nil || survivor == nil {
		return false, nil
	}
	switch survivor.Type {
	case cpaws.ResourceEC2Instance:
		id := ec2ResourceIDFromARN(survivor.ARN, "instance/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{InstanceIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke EC2 instance %s: %w", id, 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 {
					return false, nil
				}
			}
		}
		return true, nil
	case cpaws.ResourceEC2ConnectEndpoint:
		id := ec2ResourceIDFromARN(survivor.ARN, "instance-connect-endpoint/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeInstanceConnectEndpoints(ctx, &ec2.DescribeInstanceConnectEndpointsInput{
			InstanceConnectEndpointIds: []string{id},
		})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke EC2 Instance Connect Endpoint %s: %w", id, err)
		}
		return len(out.InstanceConnectEndpoints) == 0, nil
	case cpaws.ResourceEC2KeyPair:
		id := ec2ResourceIDFromARN(survivor.ARN, "key-pair/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeKeyPairs(ctx, &ec2.DescribeKeyPairsInput{KeyPairIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke EC2 key pair %s: %w", id, err)
		}
		return len(out.KeyPairs) == 0, nil
	case cpaws.ResourceENI:
		id := ec2ResourceIDFromARN(survivor.ARN, "network-interface/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{NetworkInterfaceIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke ENI %s: %w", id, err)
		}
		return len(out.NetworkInterfaces) == 0, nil
	case cpaws.ResourceEBSVolume:
		id := ec2ResourceIDFromARN(survivor.ARN, "volume/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeVolumes(ctx, &ec2.DescribeVolumesInput{VolumeIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke EBS volume %s: %w", id, err)
		}
		return len(out.Volumes) == 0, nil
	case cpaws.ResourceEIP:
		id := ec2ResourceIDFromARN(survivor.ARN, "elastic-ip/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{AllocationIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke EIP %s: %w", id, err)
		}
		return len(out.Addresses) == 0, nil
	case cpaws.ResourceInternetGateway:
		id := ec2ResourceIDFromARN(survivor.ARN, "internet-gateway/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeInternetGateways(ctx, &ec2.DescribeInternetGatewaysInput{InternetGatewayIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke internet gateway %s: %w", id, err)
		}
		return len(out.InternetGateways) == 0, nil
	case cpaws.ResourceVPC:
		id := ec2ResourceIDFromARN(survivor.ARN, "vpc/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{VpcIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke VPC %s: %w", id, err)
		}
		return len(out.Vpcs) == 0, nil
	case cpaws.ResourceNATGateway:
		id := ec2ResourceIDFromARN(survivor.ARN, "natgateway/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeNatGateways(ctx, &ec2.DescribeNatGatewaysInput{NatGatewayIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke NAT gateway %s: %w", id, err)
		}
		if len(out.NatGateways) == 0 {
			return true, nil
		}
		return out.NatGateways[0].State == ec2types.NatGatewayStateDeleted, nil
	case cpaws.ResourceRouteTable:
		id := ec2ResourceIDFromARN(survivor.ARN, "route-table/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeRouteTables(ctx, &ec2.DescribeRouteTablesInput{RouteTableIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke route table %s: %w", id, err)
		}
		return len(out.RouteTables) == 0, nil
	case cpaws.ResourceSecurityGroup:
		id := ec2ResourceIDFromARN(survivor.ARN, "security-group/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{GroupIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke security group %s: %w", id, err)
		}
		return len(out.SecurityGroups) == 0, nil
	case cpaws.ResourceSubnet:
		id := ec2ResourceIDFromARN(survivor.ARN, "subnet/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{SubnetIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke subnet %s: %w", id, err)
		}
		return len(out.Subnets) == 0, nil
	case cpaws.ResourceVPCEndpoint:
		id := ec2ResourceIDFromARN(survivor.ARN, "vpc-endpoint/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeVpcEndpoints(ctx, &ec2.DescribeVpcEndpointsInput{VpcEndpointIds: []string{id}})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke VPC endpoint %s: %w", id, err)
		}
		return len(out.VpcEndpoints) == 0, nil
	case cpaws.ResourceVPCEndpointService:
		id := ec2ResourceIDFromARN(survivor.ARN, "vpc-endpoint-service/")
		if id == "" {
			return false, nil
		}
		out, err := client.DescribeVpcEndpointServiceConfigurations(ctx, &ec2.DescribeVpcEndpointServiceConfigurationsInput{
			ServiceIds: []string{id},
		})
		if err != nil {
			if ec2NotFoundError(err) {
				return true, nil
			}
			return false, fmt.Errorf("verify post-nuke VPC endpoint service %s: %w", id, err)
		}
		return len(out.ServiceConfigurations) == 0, nil
	default:
		return false, nil
	}
}

func ec2NotFoundError(err error) bool {
	if err == nil {
		return false
	}
	msg := err.Error()
	return strings.Contains(msg, "InvalidInstanceID.NotFound") ||
		strings.Contains(msg, "InvalidInstanceConnectEndpointId.NotFound") ||
		strings.Contains(msg, "InvalidKeyPair.NotFound") ||
		strings.Contains(msg, "InvalidInternetGatewayID.NotFound") ||
		strings.Contains(msg, "InvalidNetworkInterfaceID.NotFound") ||
		strings.Contains(msg, "InvalidRouteTableID.NotFound") ||
		strings.Contains(msg, "InvalidGroup.NotFound") ||
		strings.Contains(msg, "InvalidSubnetID.NotFound") ||
		strings.Contains(msg, "InvalidVolume.NotFound") ||
		strings.Contains(msg, "InvalidAllocationID.NotFound") ||
		strings.Contains(msg, "InvalidVpcID.NotFound") ||
		strings.Contains(msg, "NotFound")
}

func s3NotFoundError(err error) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.ErrorCode() {
		case "NoSuchBucket", "NotFound", "404":
			return true
		}
	}
	msg := err.Error()
	return strings.Contains(msg, "NoSuchBucket") ||
		strings.Contains(msg, "NotFound") ||
		strings.Contains(msg, "status code: 404")
}

func secretsManagerNotFoundError(err error) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.ErrorCode() {
		case "ResourceNotFoundException", "NotFoundException", "NotFound":
			return true
		}
	}
	msg := err.Error()
	return strings.Contains(msg, "ResourceNotFoundException") ||
		strings.Contains(msg, "NotFoundException") ||
		strings.Contains(msg, "NotFound")
}

func route53NotFoundError(err error) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.ErrorCode() {
		case "NoSuchHostedZone", "HostedZoneNotFound", "NotFound":
			return true
		}
	}
	msg := err.Error()
	return strings.Contains(msg, "NoSuchHostedZone") ||
		strings.Contains(msg, "HostedZoneNotFound") ||
		strings.Contains(msg, "NotFound")
}

func elbv2NotFoundError(err error) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.ErrorCode() {
		case "LoadBalancerNotFound", "LoadBalancerNotFoundException",
			"TargetGroupNotFound", "TargetGroupNotFoundException":
			return true
		}
	}
	msg := err.Error()
	return strings.Contains(msg, "LoadBalancerNotFound") ||
		strings.Contains(msg, "TargetGroupNotFound") ||
		strings.Contains(msg, "NotFound")
}

func classicELBNotFoundError(err error) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.ErrorCode() {
		case "LoadBalancerNotFound", "LoadBalancerNotFoundException":
			return true
		}
	}
	msg := err.Error()
	return strings.Contains(msg, "LoadBalancerNotFound") ||
		strings.Contains(msg, "NotFound")
}

func destroyNukeResourceTypes(
	resources []cpaws.Resource,
	preparedVPCChildren []string,
) []string {
	types := map[string]struct{}{}
	add := func(resourceType string) {
		resourceType = strings.TrimSpace(resourceType)
		if resourceType != "" {
			types[resourceType] = struct{}{}
		}
	}
	for i := range resources {
		for _, resourceType := range destroyNukeResourceTypesForResource(&resources[i]) {
			add(resourceType)
		}
	}
	for _, id := range preparedVPCChildren {
		switch {
		case strings.HasPrefix(id, nukePreparedAutoScalingGroupPrefix):
			add("AutoScalingGroup")
		case strings.HasPrefix(id, "eipalloc-"):
			add("EC2Address")
		case strings.HasPrefix(id, "nat-"):
			add("EC2NATGateway")
		case strings.HasPrefix(id, "sg-"):
			add("EC2SecurityGroup")
		case strings.HasPrefix(id, "subnet-"):
			add("EC2Subnet")
		case strings.HasPrefix(id, "rtb-"):
			add("EC2RouteTable")
		case strings.HasPrefix(id, "vpce-"):
			add("EC2VPCEndpoint")
		case strings.HasPrefix(id, "eni-"):
			add("EC2NetworkInterface")
		case strings.HasPrefix(id, "i-"):
			add("EC2Instance")
		}
	}
	out := make([]string, 0, len(types))
	for resourceType := range types {
		out = append(out, resourceType)
	}
	slices.Sort(out)
	return out
}

func destroyNukeResourceTypesForResource(resource *cpaws.Resource) []string {
	if resource == nil {
		return nil
	}
	switch resource.Type {
	case cpaws.ResourceEIP:
		return []string{"EC2Address"}
	case cpaws.ResourceEC2Instance:
		return []string{"EC2Instance"}
	case cpaws.ResourceAutoScalingGroup:
		return []string{"AutoScalingGroup"}
	case cpaws.ResourceEC2KeyPair:
		return []string{"EC2KeyPair"}
	case cpaws.ResourceInternetGateway:
		return []string{"EC2InternetGateway"}
	case cpaws.ResourceLaunchTemplate:
		return []string{"EC2LaunchTemplate"}
	case cpaws.ResourceEC2ConnectEndpoint:
		return []string{"EC2InstanceConnectEndpoint"}
	case cpaws.ResourceNATGateway:
		return []string{"EC2NATGateway"}
	case cpaws.ResourceENI:
		return []string{"EC2NetworkInterface"}
	case cpaws.ResourceRouteTable:
		return []string{"EC2RouteTable"}
	case cpaws.ResourceSecurityGroup:
		return []string{"EC2SecurityGroup"}
	case cpaws.ResourceSubnet:
		return []string{"EC2Subnet"}
	case cpaws.ResourceVPC:
		return []string{"EC2VPC"}
	case cpaws.ResourceVPCEndpoint:
		return []string{"EC2VPCEndpoint"}
	case cpaws.ResourceVPCEndpointService:
		return []string{"EC2VPCEndpointServiceConfiguration"}
	case cpaws.ResourceEBSVolume:
		return []string{"EC2Volume"}
	case cpaws.ResourceEKSCluster:
		return []string{"EKSCluster"}
	case cpaws.ResourceEKSNodegroup:
		return []string{"EKSNodegroup"}
	case cpaws.ResourceACMPCACertAuthority:
		// Real CAs are disabled then deleted by aws-nuke; already-deleted CAs are
		// dropped by the post-nuke stale-survivor check.
		return []string{"ACMPCACertificateAuthorityState", "ACMPCACertificateAuthority"}
	case cpaws.ResourceLoadBalancer:
		if arn := strings.TrimSpace(resource.ARN); arn == "" {
			return []string{"ELB", "ELBv2"}
		}
		if !postNukeELBV2LoadBalancerARN(resource.ARN) {
			return []string{"ELB"}
		}
		return []string{"ELBv2"}
	case cpaws.ResourceTargetGroup:
		return []string{"ELBv2TargetGroup"}
	case cpaws.ResourceKMSKey:
		return []string{"KMSKey"}
	case cpaws.ResourceMSKCluster:
		return []string{"MSKCluster"}
	default:
		return nil
	}
}

func materializeDestroyNukeCredentials(
	ctx context.Context,
	destroyCtx *v1alpha2DestroyContext,
	scope cpaws.Scope,
	req *cpaws.NukeRunRequest,
) error {
	if req == nil {
		return fmt.Errorf("aws-nuke run request is required")
	}
	profile := strings.TrimSpace(req.Profile)
	if profile == "" {
		return fmt.Errorf("aws-nuke profile is required before credential materialization")
	}
	if strings.TrimSpace(req.Region) == "" {
		return fmt.Errorf("aws-nuke region is required before credential materialization")
	}
	baseCfg, err := loadDestroyAWSConfigWithCredentialFallback(ctx, profile, req.Region)
	if err != nil {
		return fmt.Errorf("load aws-nuke credentials: %w", err)
	}
	baseCfg = withDestroyAWSHTTPClient(&baseCfg)
	credentialCfg := baseCfg
	roleARN := strings.TrimSpace(req.AssumeRoleARN)
	if roleARN != "" {
		if req.AssumeRoleNoTags {
			credentialCfg = destroyNukeConfigWithAssumeRole(&baseCfg, roleARN, scope.SessionID)
		} else {
			tags, err := destroyNukeAssumeRoleTags(scope)
			if err != nil {
				return err
			}
			credentialCfg = destroyNukeConfigWithTaggedAssumeRole(&baseCfg, roleARN, scope.Owner, tags)
		}
	}
	if err := cpaws.FreezeConfigCredentials(ctx, &credentialCfg); err != nil {
		if !destroyNukeCanFallbackToBaseCredentials(req, err) {
			return fmt.Errorf("freeze aws-nuke credentials: %w", err)
		}
		if destroyCtx != nil && destroyCtx.Progress != nil {
			destroyCtx.Progress(
				"aws_nuke_credentials",
				"fallback",
				"cleanup assume-role unavailable; using scoped base profile credentials",
				"",
			)
		}
		credentialCfg = baseCfg
		if fallbackErr := cpaws.FreezeConfigCredentials(ctx, &credentialCfg); fallbackErr != nil {
			return fmt.Errorf("freeze aws-nuke fallback credentials after assume-role failure %q: %w", err.Error(), fallbackErr)
		}
	}
	creds, err := credentialCfg.Credentials.Retrieve(ctx)
	if err != nil {
		return fmt.Errorf("retrieve frozen aws-nuke credentials: %w", err)
	}
	if strings.TrimSpace(creds.AccessKeyID) == "" || strings.TrimSpace(creds.SecretAccessKey) == "" {
		return fmt.Errorf("aws-nuke credentials are incomplete")
	}
	req.AccessKeyID = creds.AccessKeyID
	req.SecretAccessKey = creds.SecretAccessKey
	req.SessionToken = creds.SessionToken
	req.Profile = ""
	req.AssumeRoleARN = ""
	return nil
}

func destroyNukeCanFallbackToBaseCredentials(req *cpaws.NukeRunRequest, err error) bool {
	if req == nil || !req.AssumeRoleNoTags || strings.TrimSpace(req.AssumeRoleARN) == "" || err == nil {
		return false
	}
	// Only cleanup roles that avoid session tags may fall back. Tagged
	// developer-role/product paths stay strict so missing TagSession support does
	// not get hidden by the base profile.
	message := err.Error()
	for _, token := range []string{"AccessDenied", "NoSuchEntity", "not authorized to perform: sts:AssumeRole"} {
		if strings.Contains(message, token) {
			return true
		}
	}
	return false
}

func destroyNukeAssumeRoleTags(scope cpaws.Scope) (map[string]string, error) {
	resolved, err := cpaws.DeriveSessionTags(scope.Owner, scope.SessionID)
	if err != nil {
		return nil, fmt.Errorf("resolve aws-nuke session tags: %w", err)
	}
	return resolved, nil
}

func destroyConfigWithAssumeRole(cfg *awssdk.Config, roleARN, sessionID string) awssdk.Config {
	assumed := cfg.Copy()
	sessionName := firstNonEmptyString(strings.TrimSpace(sessionID), "cp9-destroy")
	assumed.Credentials = awssdk.NewCredentialsCache(stscreds.NewAssumeRoleProvider(
		sts.NewFromConfig(*cfg),
		strings.TrimSpace(roleARN),
		func(o *stscreds.AssumeRoleOptions) {
			o.RoleSessionName = sessionName
		},
	))
	return assumed
}

// prepareNukeOrphanEBSVolumes stamps cp9 ownership onto discovered EBS volumes that
// carry none.
//
// Dataplane volumes are created by product code that knows nothing about cp9
// sessions, so they exist with only a Kubernetes cluster tag. Ownership recovery
// attributes them to a session in memory, but aws-nuke selects by tag in AWS, so
// without this step they are discovered, reported as survivors, and never deleted —
// which turns a silent leak into a permanently failing cleanup run rather than a
// reclaimed volume.
//
// Only volumes the tracker already attributed to this exact scope are tagged, and an
// ambiguous scope is refused rather than guessed, matching the sibling assurance steps.
func prepareNukeOrphanEBSVolumes(
	ctx context.Context,
	resources []cpaws.Resource,
	scope cpaws.Scope,
	req *cpaws.NukeRunRequest,
) ([]string, error) {
	if req == nil {
		return nil, fmt.Errorf("aws-nuke run request is required")
	}
	if req.AccessKeyID == "" || req.SecretAccessKey == "" {
		return nil, fmt.Errorf("aws-nuke credentials must be frozen before EBS volume cleanup preparation")
	}
	scope = scope.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, fmt.Errorf("EBS volume cleanup scope: %w", err)
	}
	if scope.Owner == "" || scope.SessionID == "" {
		return nil, fmt.Errorf("EBS volume cleanup requires owner/session scope")
	}
	rootTags := map[string]string{
		cpaws.TagOwner:   scope.Owner,
		cpaws.TagSession: scope.SessionID,
	}
	clients := map[string]destroyEC2VPCChildAPI{}
	seen := map[string]struct{}{}
	var changed []string
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceEBSVolume || strings.TrimSpace(resource.ARN) == "" {
			continue
		}
		if !cpaws.OwnerTagMatches(scope.Owner, resource.Owner) || resource.SessionID != scope.SessionID {
			return nil, fmt.Errorf(
				"refusing to prepare EBS volume %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resource.ARN,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		id := ec2ResourceIDFromARN(resource.ARN, "volume/")
		if id == "" {
			return nil, fmt.Errorf("invalid EBS volume ARN %q", resource.ARN)
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		region := strings.TrimSpace(resource.Region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			return nil, fmt.Errorf("EBS volume %s has no region", resource.ARN)
		}
		client := clients[region]
		if client == nil {
			cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
			client = ec2.NewFromConfig(cfg)
			clients[region] = client
		}
		if err := createNukeScopeTags(ctx, client, id, rootTags); err != nil {
			return nil, err
		}
		changed = append(changed, id)
	}
	return changed, nil
}

func prepareNukeLoadBalancers(
	ctx context.Context,
	resources []cpaws.Resource,
	scope cpaws.Scope,
	req *cpaws.NukeRunRequest,
) ([]string, error) {
	if req == nil {
		return nil, fmt.Errorf("aws-nuke run request is required")
	}
	if req.AccessKeyID == "" || req.SecretAccessKey == "" {
		return nil, fmt.Errorf("aws-nuke credentials must be frozen before load balancer cleanup preparation")
	}
	scope = scope.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, fmt.Errorf("load balancer cleanup scope: %w", err)
	}
	if scope.Owner == "" || scope.SessionID == "" {
		return nil, fmt.Errorf("load balancer cleanup requires owner/session scope")
	}
	clients := map[string]destroyELBV2LoadBalancerAPI{}
	seen := map[string]struct{}{}
	var changed []string
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceLoadBalancer || strings.TrimSpace(resource.ARN) == "" {
			continue
		}
		if !postNukeELBV2LoadBalancerARN(resource.ARN) {
			continue
		}
		if !cpaws.OwnerTagMatches(scope.Owner, resource.Owner) || resource.SessionID != scope.SessionID {
			return nil, fmt.Errorf(
				"refusing to prepare load balancer %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resource.ARN,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		if _, ok := seen[resource.ARN]; ok {
			continue
		}
		seen[resource.ARN] = struct{}{}
		region := strings.TrimSpace(resource.Region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			return nil, fmt.Errorf("load balancer %s has no region", resource.ARN)
		}
		client := clients[region]
		if client == nil {
			cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
			client = destroyNewELBV2Client(cfg)
			clients[region] = client
		}
		attributes, err := client.DescribeLoadBalancerAttributes(ctx, &elasticloadbalancingv2.DescribeLoadBalancerAttributesInput{
			LoadBalancerArn: awssdk.String(resource.ARN),
		})
		if err != nil {
			if strings.Contains(err.Error(), "LoadBalancerNotFound") || strings.Contains(err.Error(), "NotFound") {
				continue
			}
			return nil, fmt.Errorf("describe load balancer attributes for aws-nuke %s: %w", resource.ARN, err)
		}
		var deletionProtection string
		for _, attribute := range attributes.Attributes {
			if awssdk.ToString(attribute.Key) == "deletion_protection.enabled" {
				deletionProtection = strings.TrimSpace(awssdk.ToString(attribute.Value))
				break
			}
		}
		switch deletionProtection {
		case "false":
			continue
		case "true":
		case "":
			return nil, fmt.Errorf("load balancer %s is missing deletion_protection.enabled attribute", resource.ARN)
		default:
			return nil, fmt.Errorf("load balancer %s has unexpected deletion_protection.enabled=%q", resource.ARN, deletionProtection)
		}
		_, err = client.ModifyLoadBalancerAttributes(ctx, &elasticloadbalancingv2.ModifyLoadBalancerAttributesInput{
			LoadBalancerArn: awssdk.String(resource.ARN),
			Attributes: []elbv2types.LoadBalancerAttribute{
				{
					Key:   awssdk.String("deletion_protection.enabled"),
					Value: awssdk.String("false"),
				},
			},
		})
		if err != nil {
			if strings.Contains(err.Error(), "LoadBalancerNotFound") || strings.Contains(err.Error(), "NotFound") {
				continue
			}
			return nil, fmt.Errorf("prepare load balancer for aws-nuke %s: %w", resource.ARN, err)
		}
		changed = append(changed, resource.ARN)
	}
	return changed, nil
}

func prepareNukeVPCChildren(
	ctx context.Context,
	resources []cpaws.Resource,
	scope cpaws.Scope,
	req *cpaws.NukeRunRequest,
) ([]string, error) {
	if req == nil {
		return nil, fmt.Errorf("aws-nuke run request is required")
	}
	if req.AccessKeyID == "" || req.SecretAccessKey == "" {
		return nil, fmt.Errorf("aws-nuke credentials must be frozen before VPC child cleanup preparation")
	}
	scope = scope.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, fmt.Errorf("VPC child cleanup scope: %w", err)
	}
	if scope.Owner == "" || scope.SessionID == "" {
		return nil, fmt.Errorf("VPC child cleanup requires owner/session scope")
	}
	clients := map[string]destroyEC2VPCChildAPI{}
	asgClients := map[string]destroyAutoScalingVPCChildAPI{}
	seen := map[string]struct{}{}
	var changed []string
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceVPC || strings.TrimSpace(resource.ARN) == "" {
			continue
		}
		if (strings.TrimSpace(resource.Owner) != "" && !cpaws.OwnerTagMatches(scope.Owner, resource.Owner)) ||
			(strings.TrimSpace(resource.SessionID) != "" && resource.SessionID != scope.SessionID) {
			return nil, fmt.Errorf(
				"refusing to prepare VPC children for %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resource.ARN,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		vpcID := ec2ResourceIDFromARN(resource.ARN, "vpc/")
		if vpcID == "" {
			return nil, fmt.Errorf("cannot parse VPC id from %s", resource.ARN)
		}
		if _, ok := seen[vpcID]; ok {
			continue
		}
		seen[vpcID] = struct{}{}
		region := strings.TrimSpace(resource.Region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			return nil, fmt.Errorf("VPC %s has no region", resource.ARN)
		}
		client := clients[region]
		if client == nil {
			cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
			client = destroyNewEC2Client(cfg)
			clients[region] = client
		}
		asgClient := asgClients[region]
		if asgClient == nil {
			cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
			asgClient = destroyNewAutoScalingClient(cfg)
			asgClients[region] = asgClient
		}
		tags, repairedRoot, err := scopedVPCTags(ctx, client, vpcID, scope)
		if err != nil {
			return nil, err
		}
		if repairedRoot {
			changed = append(changed, vpcID)
		}
		tagged, err := tagNukeVPCChildSecurityGroups(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, tagged...)
		tagged, err = tagNukeVPCChildRouteTables(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, tagged...)
		tagged, err = tagNukeVPCChildNATGateways(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, tagged...)
		tagged, err = tagNukeVPCChildSubnets(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, tagged...)
		tagged, err = tagNukeVPCChildEndpoints(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, tagged...)
		tagged, err = tagNukeVPCChildNetworkInterfaces(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, tagged...)
		tagged, err = tagNukeVPCChildAutoScalingGroups(ctx, client, asgClient, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, tagged...)
		tagged, err = tagNukeVPCChildInstances(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, tagged...)
		detached, err := detachNukeVPCInternetGateways(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		changed = append(changed, detached...)
	}
	return changed, nil
}

func finalizeNukeVPCRootsAfterDelete(
	ctx context.Context,
	resources []cpaws.Resource,
	scope cpaws.Scope,
	req *cpaws.NukeRunRequest,
) ([]string, error) {
	if req == nil {
		return nil, fmt.Errorf("aws-nuke run request is required")
	}
	if req.AccessKeyID == "" || req.SecretAccessKey == "" {
		return nil, fmt.Errorf("aws-nuke credentials must be frozen before VPC root cleanup")
	}
	scope = scope.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, fmt.Errorf("VPC root cleanup scope: %w", err)
	}
	if scope.Owner == "" || scope.SessionID == "" {
		return nil, fmt.Errorf("VPC root cleanup requires owner/session scope")
	}
	clients := map[string]destroyEC2VPCRootCleanupAPI{}
	seen := map[string]struct{}{}
	var deleted []string
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceVPC || strings.TrimSpace(resource.ARN) == "" {
			continue
		}
		if (strings.TrimSpace(resource.Owner) != "" && !cpaws.OwnerTagMatches(scope.Owner, resource.Owner)) ||
			(strings.TrimSpace(resource.SessionID) != "" && resource.SessionID != scope.SessionID) {
			return nil, fmt.Errorf(
				"refusing to clean VPC root %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				resource.ARN,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		vpcID := ec2ResourceIDFromARN(resource.ARN, "vpc/")
		if vpcID == "" {
			return nil, fmt.Errorf("cannot parse VPC id from %s", resource.ARN)
		}
		if _, ok := seen[vpcID]; ok {
			continue
		}
		seen[vpcID] = struct{}{}
		region := strings.TrimSpace(resource.Region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			return nil, fmt.Errorf("VPC %s has no region", resource.ARN)
		}
		client := clients[region]
		if client == nil {
			cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
			childClient := destroyNewEC2Client(cfg)
			rootClient, ok := childClient.(destroyEC2VPCRootCleanupAPI)
			if !ok {
				return nil, fmt.Errorf("EC2 client for %s does not support VPC root cleanup", region)
			}
			client = rootClient
			clients[region] = client
		}
		tags, _, err := scopedVPCTags(ctx, client, vpcID, scope)
		if err != nil {
			if vpcRootAlreadyDeletedError(err) {
				continue
			}
			return nil, err
		}
		detached, err := detachNukeVPCInternetGateways(ctx, client, vpcID, scope, tags)
		if err != nil {
			return nil, err
		}
		for _, gatewayID := range detached {
			err := cpaws.RetryDelete(ctx, func() error {
				_, err := client.DeleteInternetGateway(ctx, &ec2.DeleteInternetGatewayInput{
					InternetGatewayId: awssdk.String(gatewayID),
				})
				return err
			})
			if err != nil {
				if ec2NotFoundError(err) {
					continue
				}
				return nil, fmt.Errorf("delete internet gateway %s after aws-nuke: %w", gatewayID, err)
			}
			deleted = append(deleted, gatewayID)
		}
		err = cpaws.RetryDelete(ctx, func() error {
			_, err := client.DeleteVpc(ctx, &ec2.DeleteVpcInput{VpcId: awssdk.String(vpcID)})
			return err
		})
		if err != nil {
			if ec2NotFoundError(err) {
				continue
			}
			return nil, fmt.Errorf("delete VPC %s after aws-nuke: %w", vpcID, err)
		}
		deleted = append(deleted, vpcID)
	}
	return deleted, nil
}

func vpcRootAlreadyDeletedError(err error) bool {
	if err == nil {
		return false
	}
	return ec2NotFoundError(err) || strings.Contains(err.Error(), "returned 0 VPCs")
}

func prepareNukeEKSResources(
	ctx context.Context,
	resources []cpaws.Resource,
	scope cpaws.Scope,
	req *cpaws.NukeRunRequest,
) ([]string, error) {
	if req == nil {
		return nil, fmt.Errorf("aws-nuke run request is required")
	}
	if req.AccessKeyID == "" || req.SecretAccessKey == "" {
		return nil, fmt.Errorf("aws-nuke credentials must be frozen before EKS cleanup preparation")
	}
	scope = scope.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, fmt.Errorf("EKS cleanup scope: %w", err)
	}
	if scope.Owner == "" || scope.SessionID == "" {
		return nil, fmt.Errorf("EKS cleanup requires owner/session scope")
	}
	clients := map[string]destroyEKSResourceAPI{}
	seen := map[string]struct{}{}
	var changed []string
	for _, resource := range resources {
		if resource.Type != cpaws.ResourceEKSCluster && resource.Type != cpaws.ResourceEKSNodegroup {
			continue
		}
		arn := strings.TrimSpace(resource.ARN)
		if arn == "" {
			continue
		}
		if _, ok := seen[arn]; ok {
			continue
		}
		seen[arn] = struct{}{}
		if (strings.TrimSpace(resource.Owner) != "" && !cpaws.OwnerTagMatches(scope.Owner, resource.Owner)) ||
			(strings.TrimSpace(resource.SessionID) != "" && resource.SessionID != scope.SessionID) {
			return nil, fmt.Errorf(
				"refusing to prepare EKS resource %s with ambiguous scope owner=%q session=%q, want owner=%q session=%q",
				arn,
				resource.Owner,
				resource.SessionID,
				scope.Owner,
				scope.SessionID,
			)
		}
		if strings.TrimSpace(resource.Owner) != "" {
			continue
		}
		region := strings.TrimSpace(resource.Region)
		if region == "" {
			region = strings.TrimSpace(req.Region)
		}
		if region == "" {
			return nil, fmt.Errorf("EKS resource %s has no region", arn)
		}
		client := clients[region]
		if client == nil {
			cfg := destroyStaticAWSConfig(region, req.AccessKeyID, req.SecretAccessKey, req.SessionToken)
			client = destroyNewEKSClient(cfg)
			clients[region] = client
		}
		tags, ok, err := scopedEKSTags(ctx, client, arn, scope)
		if err != nil {
			return nil, err
		}
		if !ok {
			continue
		}
		if strings.TrimSpace(tags[cpaws.TagOwner]) != "" {
			continue
		}
		if err := tagNukeEKSResource(ctx, client, arn, scope); err != nil {
			return nil, err
		}
		changed = append(changed, arn)
	}
	return changed, nil
}

func scopedEKSTags(
	ctx context.Context,
	client destroyEKSResourceAPI,
	arn string,
	scope cpaws.Scope,
) (map[string]string, bool, error) {
	out, err := client.ListTagsForResource(ctx, &eks.ListTagsForResourceInput{
		ResourceArn: awssdk.String(arn),
	})
	if err != nil {
		if destroyEKSNotFound(err) {
			return nil, false, nil
		}
		return nil, false, fmt.Errorf("list EKS tags for %s before aws-nuke: %w", arn, err)
	}
	tags := map[string]string{}
	maps.Copy(tags, out.Tags)
	ownerlessName := ownerlessEKSNameMatches(arn, tags, scope)
	if tags[cpaws.TagSession] != scope.SessionID && !ownerlessName {
		return nil, false, newNukeScopeConflictError("EKS resource", arn, tags, scope)
	}
	if strings.TrimSpace(tags[cpaws.TagOwner]) == "" {
		if ownerlessName {
			return tags, true, nil
		}
		if strings.TrimSpace(tags["environment"]) != "cp9" ||
			strings.TrimSpace(tags[cpaws.TagManaged]) != "true" ||
			strings.TrimSpace(tags[cpaws.TagTTL]) == "" {
			return nil, false, fmt.Errorf(
				"refusing to prepare EKS resource %s with missing owner and tags environment=%q managed=%q ttl=%q",
				arn,
				tags["environment"],
				tags[cpaws.TagManaged],
				tags[cpaws.TagTTL],
			)
		}
		return tags, true, nil
	}
	if !cpaws.OwnerTagMatches(scope.Owner, tags[cpaws.TagOwner]) {
		return nil, false, newNukeScopeConflictError("EKS resource", arn, tags, scope)
	}
	return tags, true, nil
}

// ownerlessEKSNameMatches is ADR 0001 Recycle delete-path exception 1:
// environment=cp9 EKS with no cp9-owner may be adopted when the cluster name
// is the session, <session>-starter-regional, or dedicated-<session>-<region>-….
// Empty or matching cp9-session is allowed; a conflicting session still blocks.
func ownerlessEKSNameMatches(arn string, tags map[string]string, scope cpaws.Scope) bool {
	if strings.TrimSpace(tags["environment"]) != "cp9" {
		return false
	}
	if strings.TrimSpace(tags[cpaws.TagOwner]) != "" {
		return false
	}
	session := strings.TrimSpace(tags[cpaws.TagSession])
	if session != "" && session != scope.SessionID {
		return false
	}
	return cpaws.EKSARNMatchesSession(arn, scope.SessionID, "")
}

func tagNukeEKSResource(ctx context.Context, client destroyEKSResourceAPI, arn string, scope cpaws.Scope) error {
	if _, err := client.TagResource(ctx, &eks.TagResourceInput{
		ResourceArn: awssdk.String(arn),
		Tags: map[string]string{
			cpaws.TagOwner: scope.Owner,
		},
	}); err != nil {
		// Same concurrent-deletion race as the EC2 and trusted-parent tagging paths:
		// an EKS resource that is already gone needs no nuke scope tag.
		if destroyEKSNotFound(err) {
			verbose.Logf("EKS resource %s vanished before tagging; skipping: %v", arn, err)
			return nil
		}
		return fmt.Errorf("tag EKS resource %s for aws-nuke: %w", arn, err)
	}
	return nil
}

func scopedVPCTags(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
) (map[string]string, bool, error) {
	out, err := client.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{
		VpcIds: []string{vpcID},
	})
	if err != nil {
		return nil, false, fmt.Errorf("describe scoped VPC %s before child cleanup preparation: %w", vpcID, err)
	}
	if len(out.Vpcs) != 1 {
		return nil, false, fmt.Errorf("describe scoped VPC %s returned %d VPCs", vpcID, len(out.Vpcs))
	}
	tags := ec2TagMap(out.Vpcs[0].Tags)
	if tags[cpaws.TagSession] != scope.SessionID {
		return nil, false, newNukeScopeConflictError("VPC", vpcID, tags, scope)
	}
	if strings.TrimSpace(tags[cpaws.TagOwner]) == "" {
		if strings.TrimSpace(tags["environment"]) != "cp9" ||
			strings.TrimSpace(tags[cpaws.TagManaged]) != "true" ||
			strings.TrimSpace(tags[cpaws.TagTTL]) == "" {
			return nil, false, fmt.Errorf(
				"refusing to prepare children for VPC %s with missing owner and tags environment=%q managed=%q ttl=%q",
				vpcID,
				tags["environment"],
				tags[cpaws.TagManaged],
				tags[cpaws.TagTTL],
			)
		}
		if _, err := client.CreateTags(ctx, &ec2.CreateTagsInput{
			Resources: []string{vpcID},
			Tags: []ec2types.Tag{
				{Key: awssdk.String(cpaws.TagOwner), Value: awssdk.String(scope.Owner)},
			},
		}); err != nil {
			// The VPC can be deleted between discovery and this repair. A VPC that is
			// already gone has no children left to prepare, so report "nothing to do"
			// rather than failing the account's cleanup.
			if ec2ResourceGoneError(err) {
				verbose.Logf("VPC %s vanished before owner-tag repair; skipping: %v", vpcID, err)
				return nil, false, nil
			}
			return nil, false, fmt.Errorf("repair VPC %s owner tag before child cleanup preparation: %w", vpcID, err)
		}
		tags[cpaws.TagOwner] = scope.Owner
		return tags, true, nil
	}
	if !cpaws.OwnerTagMatches(scope.Owner, tags[cpaws.TagOwner]) {
		return nil, false, newNukeScopeConflictError("VPC", vpcID, tags, scope)
	}
	return tags, false, nil
}

func tagNukeVPCChildSecurityGroups(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	tags map[string]string,
) ([]string, error) {
	out, err := client.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{
		Filters: []ec2types.Filter{{Name: awssdk.String("vpc-id"), Values: []string{vpcID}}},
	})
	if err != nil {
		return nil, fmt.Errorf("describe VPC %s security groups before child cleanup preparation: %w", vpcID, err)
	}
	var changed []string
	for i := range out.SecurityGroups {
		group := &out.SecurityGroups[i]
		id := awssdk.ToString(group.GroupId)
		if id == "" || awssdk.ToString(group.GroupName) == "default" {
			continue
		}
		childTags := ec2TagMap(group.Tags)
		needsTag, err := childNeedsNukeScopeTags("security group", id, childTags, scope)
		if err != nil {
			return nil, err
		}
		if !needsTag {
			continue
		}
		if err := createNukeScopeTags(ctx, client, id, tags); err != nil {
			return nil, err
		}
		changed = append(changed, id)
	}
	return changed, nil
}

func tagNukeVPCChildRouteTables(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	tags map[string]string,
) ([]string, error) {
	out, err := client.DescribeRouteTables(ctx, &ec2.DescribeRouteTablesInput{
		Filters: []ec2types.Filter{{Name: awssdk.String("vpc-id"), Values: []string{vpcID}}},
	})
	if err != nil {
		return nil, fmt.Errorf("describe VPC %s route tables before child cleanup preparation: %w", vpcID, err)
	}
	var changed []string
	for i := range out.RouteTables {
		routeTable := &out.RouteTables[i]
		id := awssdk.ToString(routeTable.RouteTableId)
		if id == "" || routeTableIsMain(routeTable.Associations) {
			continue
		}
		childTags := ec2TagMap(routeTable.Tags)
		needsTag, err := childNeedsNukeScopeTags("route table", id, childTags, scope)
		if err != nil {
			return nil, err
		}
		if !needsTag {
			continue
		}
		if err := createNukeScopeTags(ctx, client, id, tags); err != nil {
			return nil, err
		}
		changed = append(changed, id)
	}
	return changed, nil
}

func tagNukeVPCChildNATGateways(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	tags map[string]string,
) ([]string, error) {
	out, err := client.DescribeNatGateways(ctx, &ec2.DescribeNatGatewaysInput{
		Filter: []ec2types.Filter{{Name: awssdk.String("vpc-id"), Values: []string{vpcID}}},
	})
	if err != nil {
		return nil, fmt.Errorf("describe VPC %s NAT gateways before child cleanup preparation: %w", vpcID, err)
	}
	var changed []string
	for i := range out.NatGateways {
		gateway := &out.NatGateways[i]
		id := awssdk.ToString(gateway.NatGatewayId)
		if id == "" || gateway.State == ec2types.NatGatewayStateDeleted {
			continue
		}
		childTags := ec2TagMap(gateway.Tags)
		needsTag, err := childNeedsNukeScopeTags("NAT gateway", id, childTags, scope)
		if err != nil {
			return nil, err
		}
		if needsTag {
			if err := createNukeScopeTags(ctx, client, id, tags); err != nil {
				return nil, err
			}
			changed = append(changed, id)
		}
		for _, address := range gateway.NatGatewayAddresses {
			allocationID := awssdk.ToString(address.AllocationId)
			if allocationID == "" {
				continue
			}
			if err := createNukeScopeTags(ctx, client, allocationID, tags); err != nil {
				return nil, err
			}
			changed = append(changed, allocationID)
		}
	}
	return changed, nil
}

func tagNukeVPCChildSubnets(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	tags map[string]string,
) ([]string, error) {
	out, err := client.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{
		Filters: []ec2types.Filter{{Name: awssdk.String("vpc-id"), Values: []string{vpcID}}},
	})
	if err != nil {
		return nil, fmt.Errorf("describe VPC %s subnets before child cleanup preparation: %w", vpcID, err)
	}
	var changed []string
	for i := range out.Subnets {
		subnet := &out.Subnets[i]
		id := awssdk.ToString(subnet.SubnetId)
		if id == "" {
			continue
		}
		childTags := ec2TagMap(subnet.Tags)
		needsTag, err := childNeedsNukeScopeTags("subnet", id, childTags, scope)
		if err != nil {
			return nil, err
		}
		if !needsTag {
			continue
		}
		if err := createNukeScopeTags(ctx, client, id, tags); err != nil {
			return nil, err
		}
		changed = append(changed, id)
	}
	return changed, nil
}

func tagNukeVPCChildEndpoints(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	tags map[string]string,
) ([]string, error) {
	out, err := client.DescribeVpcEndpoints(ctx, &ec2.DescribeVpcEndpointsInput{
		Filters: []ec2types.Filter{{Name: awssdk.String("vpc-id"), Values: []string{vpcID}}},
	})
	if err != nil {
		return nil, fmt.Errorf("describe VPC %s VPC endpoints before child cleanup preparation: %w", vpcID, err)
	}
	var changed []string
	for i := range out.VpcEndpoints {
		endpoint := &out.VpcEndpoints[i]
		id := awssdk.ToString(endpoint.VpcEndpointId)
		if id == "" || endpoint.State == ec2types.StateDeleted {
			continue
		}
		childTags := ec2TagMap(endpoint.Tags)
		needsTag, err := childNeedsNukeScopeTags("VPC endpoint", id, childTags, scope)
		if err != nil {
			return nil, err
		}
		if !needsTag {
			continue
		}
		if err := createNukeScopeTags(ctx, client, id, tags); err != nil {
			return nil, err
		}
		changed = append(changed, id)
	}
	return changed, nil
}

func tagNukeVPCChildNetworkInterfaces(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	tags map[string]string,
) ([]string, error) {
	out, err := client.DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{
		Filters: []ec2types.Filter{{Name: awssdk.String("vpc-id"), Values: []string{vpcID}}},
	})
	if err != nil {
		return nil, fmt.Errorf("describe VPC %s network interfaces before child cleanup preparation: %w", vpcID, err)
	}
	var changed []string
	for i := range out.NetworkInterfaces {
		networkInterface := &out.NetworkInterfaces[i]
		id := awssdk.ToString(networkInterface.NetworkInterfaceId)
		if id == "" {
			continue
		}
		childTags := ec2TagMap(networkInterface.TagSet)
		needsTag, err := childNeedsNukeScopeTags("network interface", id, childTags, scope)
		if err != nil {
			return nil, err
		}
		if !needsTag {
			continue
		}
		if err := createNukeScopeTags(ctx, client, id, tags); err != nil {
			return nil, err
		}
		changed = append(changed, id)
	}
	return changed, nil
}

const nukePreparedAutoScalingGroupPrefix = "asg:"

func nukePreparedAutoScalingGroupID(name string) string {
	return nukePreparedAutoScalingGroupPrefix + name
}

func tagNukeVPCChildAutoScalingGroups(
	ctx context.Context,
	ec2Client destroyEC2VPCChildAPI,
	asgClient destroyAutoScalingVPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	tags map[string]string,
) ([]string, error) {
	names, err := nukeVPCChildAutoScalingGroupNames(ctx, ec2Client, asgClient, vpcID, scope, "child cleanup preparation")
	if err != nil {
		return nil, err
	}
	changed := make([]string, 0, len(names))
	for _, name := range names {
		if err := createNukeScopeAutoScalingTags(ctx, asgClient, name, tags); err != nil {
			return nil, err
		}
		changed = append(changed, nukePreparedAutoScalingGroupID(name))
	}
	return changed, nil
}

func nukeVPCChildAutoScalingGroupNames(
	ctx context.Context,
	ec2Client destroyEC2VPCChildAPI,
	asgClient destroyAutoScalingVPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	operation string,
) ([]string, error) {
	operation = strings.TrimSpace(operation)
	if operation == "" {
		operation = "child cleanup preparation"
	}
	var names []string
	var token *string
	for {
		out, err := asgClient.DescribeAutoScalingGroups(ctx, &autoscaling.DescribeAutoScalingGroupsInput{NextToken: token})
		if err != nil {
			return nil, fmt.Errorf("describe Auto Scaling groups before %s: %w", operation, err)
		}
		for i := range out.AutoScalingGroups {
			group := &out.AutoScalingGroups[i]
			name := awssdk.ToString(group.AutoScalingGroupName)
			if name == "" {
				continue
			}
			subnetIDs := autoScalingGroupSubnetIDs(group)
			if len(subnetIDs) == 0 {
				continue
			}
			ok, err := autoScalingGroupSubnetsInVPC(ctx, ec2Client, name, subnetIDs, vpcID, operation)
			if err != nil {
				return nil, err
			}
			if !ok {
				continue
			}
			childTags := cpaws.AutoScalingTagMap(group.Tags)
			needsTag, err := childNeedsNukeScopeTags("Auto Scaling group", name, childTags, scope)
			if err != nil {
				return nil, err
			}
			if !needsTag {
				continue
			}
			names = append(names, name)
		}
		token = out.NextToken
		if strings.TrimSpace(awssdk.ToString(token)) == "" {
			break
		}
	}
	return names, nil
}

func autoScalingGroupSubnetIDs(group *autoscalingtypes.AutoScalingGroup) []string {
	if group == nil {
		return nil
	}
	parts := strings.Split(awssdk.ToString(group.VPCZoneIdentifier), ",")
	seen := map[string]struct{}{}
	var ids []string
	for _, part := range parts {
		id := strings.TrimSpace(part)
		if id == "" {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		ids = append(ids, id)
	}
	return ids
}

func autoScalingGroupSubnetsInVPC(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	groupName string,
	subnetIDs []string,
	vpcID string,
	operation string,
) (bool, error) {
	out, err := client.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{SubnetIds: subnetIDs})
	if err != nil {
		if ec2NotFoundError(err) {
			return false, nil
		}
		return false, fmt.Errorf("describe Auto Scaling group %s subnets before %s: %w", groupName, operation, err)
	}
	seen := map[string]string{}
	for i := range out.Subnets {
		subnet := &out.Subnets[i]
		id := awssdk.ToString(subnet.SubnetId)
		if id == "" {
			continue
		}
		seen[id] = awssdk.ToString(subnet.VpcId)
	}
	for _, id := range subnetIDs {
		if seen[id] != vpcID {
			return false, nil
		}
	}
	return true, nil
}

func createNukeScopeAutoScalingTags(
	ctx context.Context,
	client destroyAutoScalingVPCChildAPI,
	name string,
	rootTags map[string]string,
) error {
	tags := []autoscalingtypes.Tag{
		autoScalingScopeTag(name, cpaws.TagOwner, rootTags[cpaws.TagOwner]),
		autoScalingScopeTag(name, cpaws.TagSession, rootTags[cpaws.TagSession]),
	}
	if managed := strings.TrimSpace(rootTags[cpaws.TagManaged]); managed != "" {
		tags = append(tags, autoScalingScopeTag(name, cpaws.TagManaged, managed))
	}
	if ttl := strings.TrimSpace(rootTags[cpaws.TagTTL]); ttl != "" {
		tags = append(tags, autoScalingScopeTag(name, cpaws.TagTTL, ttl))
	}
	if _, err := client.CreateOrUpdateTags(ctx, &autoscaling.CreateOrUpdateTagsInput{Tags: tags}); err != nil {
		return fmt.Errorf("tag Auto Scaling group %s for aws-nuke: %w", name, err)
	}
	return nil
}

func autoScalingScopeTag(name, key, value string) autoscalingtypes.Tag {
	return autoscalingtypes.Tag{
		Key:               awssdk.String(key),
		Value:             awssdk.String(value),
		ResourceId:        awssdk.String(name),
		ResourceType:      awssdk.String("auto-scaling-group"),
		PropagateAtLaunch: awssdk.Bool(true),
	}
}

func tagNukeVPCChildInstances(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	tags map[string]string,
) ([]string, error) {
	ids, err := nukeVPCChildInstanceIDs(ctx, client, vpcID, scope, "child cleanup preparation")
	if err != nil {
		return nil, err
	}
	changed := make([]string, 0, len(ids))
	for _, id := range ids {
		if err := createNukeScopeTags(ctx, client, id, tags); err != nil {
			return nil, err
		}
		changed = append(changed, id)
	}
	return changed, nil
}

func nukeVPCChildInstanceIDs(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	operation string,
) ([]string, error) {
	operation = strings.TrimSpace(operation)
	if operation == "" {
		operation = "child cleanup preparation"
	}
	out, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{
		Filters: []ec2types.Filter{{Name: awssdk.String("vpc-id"), Values: []string{vpcID}}},
	})
	if err != nil {
		return nil, fmt.Errorf("describe VPC %s EC2 instances before %s: %w", vpcID, operation, err)
	}
	var ids []string
	for _, reservation := range out.Reservations {
		for i := range reservation.Instances {
			instance := &reservation.Instances[i]
			id := awssdk.ToString(instance.InstanceId)
			if id == "" || instance.State != nil && instance.State.Name == ec2types.InstanceStateNameTerminated {
				continue
			}
			if !instancePrimaryNetworkInterfaceInVPC(instance, vpcID) {
				continue
			}
			childTags := ec2TagMap(instance.Tags)
			needsTag, err := childNeedsNukeScopeTags("EC2 instance", id, childTags, scope)
			if err != nil {
				return nil, err
			}
			if !needsTag {
				continue
			}
			ids = append(ids, id)
		}
	}
	return ids, nil
}

func instancePrimaryNetworkInterfaceInVPC(instance *ec2types.Instance, vpcID string) bool {
	if instance == nil || strings.TrimSpace(vpcID) == "" {
		return false
	}
	for i := range instance.NetworkInterfaces {
		networkInterface := &instance.NetworkInterfaces[i]
		if awssdk.ToString(networkInterface.VpcId) != vpcID {
			continue
		}
		if networkInterface.Attachment == nil {
			continue
		}
		if networkInterface.Attachment.DeviceIndex != nil && awssdk.ToInt32(networkInterface.Attachment.DeviceIndex) == 0 {
			return true
		}
	}
	return false
}

func detachNukeVPCInternetGateways(
	ctx context.Context,
	client destroyEC2VPCChildAPI,
	vpcID string,
	scope cpaws.Scope,
	rootTags map[string]string,
) ([]string, error) {
	if !cpaws.OwnerTagMatches(scope.Owner, rootTags[cpaws.TagOwner]) ||
		rootTags[cpaws.TagSession] != scope.SessionID {
		return nil, fmt.Errorf(
			"refusing trusted-parent internet gateway preparation for VPC %s with tags owner=%q session=%q, want owner=%q session=%q",
			vpcID,
			rootTags[cpaws.TagOwner],
			rootTags[cpaws.TagSession],
			scope.Owner,
			scope.SessionID,
		)
	}
	out, err := client.DescribeInternetGateways(ctx, &ec2.DescribeInternetGatewaysInput{
		Filters: []ec2types.Filter{{Name: awssdk.String("attachment.vpc-id"), Values: []string{vpcID}}},
	})
	if err != nil {
		return nil, fmt.Errorf("describe VPC %s internet gateways before cleanup preparation: %w", vpcID, err)
	}
	var detached []string
	for i := range out.InternetGateways {
		gateway := &out.InternetGateways[i]
		id := awssdk.ToString(gateway.InternetGatewayId)
		if id == "" || !internetGatewayAttachedToVPC(gateway.Attachments, vpcID) {
			continue
		}
		if !internetGatewayAttachedOnlyToVPC(gateway.Attachments, vpcID) {
			return nil, fmt.Errorf(
				"refusing to prepare internet gateway %s because it is not uniquely attached to verified VPC %s",
				id,
				vpcID,
			)
		}
		tags := ec2TagMap(gateway.Tags)
		needsTag, err := childNeedsNukeScopeTags("internet gateway", id, tags, scope)
		if err != nil {
			return nil, err
		}
		if needsTag {
			if err := createNukeScopeTags(ctx, client, id, rootTags); err != nil {
				return nil, err
			}
			tags[cpaws.TagOwner] = rootTags[cpaws.TagOwner]
			tags[cpaws.TagSession] = rootTags[cpaws.TagSession]
		}
		if !cpaws.OwnerTagMatches(scope.Owner, tags[cpaws.TagOwner]) || tags[cpaws.TagSession] != scope.SessionID {
			return nil, fmt.Errorf(
				"refusing to detach internet gateway %s from VPC %s with tags owner=%q session=%q, want owner=%q session=%q",
				id,
				vpcID,
				tags[cpaws.TagOwner],
				tags[cpaws.TagSession],
				scope.Owner,
				scope.SessionID,
			)
		}
		if _, err := client.DetachInternetGateway(ctx, &ec2.DetachInternetGatewayInput{
			InternetGatewayId: awssdk.String(id),
			VpcId:             awssdk.String(vpcID),
		}); err != nil {
			if strings.Contains(err.Error(), "InvalidInternetGatewayID.NotFound") ||
				strings.Contains(err.Error(), "InvalidVpcID.NotFound") ||
				strings.Contains(err.Error(), "Gateway.NotAttached") ||
				strings.Contains(err.Error(), "not attached") {
				continue
			}
			if strings.Contains(err.Error(), "DependencyViolation") &&
				strings.Contains(err.Error(), "mapped public address") {
				continue
			}
			return nil, fmt.Errorf("detach internet gateway %s from VPC %s before aws-nuke: %w", id, vpcID, err)
		}
		detached = append(detached, id)
	}
	return detached, nil
}

func internetGatewayAttachedToVPC(attachments []ec2types.InternetGatewayAttachment, vpcID string) bool {
	for _, attachment := range attachments {
		if awssdk.ToString(attachment.VpcId) == vpcID {
			return true
		}
	}
	return false
}

func internetGatewayAttachedOnlyToVPC(attachments []ec2types.InternetGatewayAttachment, vpcID string) bool {
	return len(attachments) == 1 && awssdk.ToString(attachments[0].VpcId) == vpcID
}

type nukeScopeConflictError struct {
	kind        string
	id          string
	owner       string
	session     string
	ttl         string
	wantOwner   string
	wantSession string
}

func newNukeScopeConflictError(kind, id string, tags map[string]string, scope cpaws.Scope) error {
	return &nukeScopeConflictError{
		kind:        kind,
		id:          id,
		owner:       strings.TrimSpace(tags[cpaws.TagOwner]),
		session:     strings.TrimSpace(tags[cpaws.TagSession]),
		ttl:         strings.TrimSpace(tags[cpaws.TagTTL]),
		wantOwner:   scope.Owner,
		wantSession: scope.SessionID,
	}
}

func (e *nukeScopeConflictError) Error() string {
	return fmt.Sprintf(
		"refusing to prepare %s %s with tags owner=%q session=%q ttl=%q, want owner=%q session=%q",
		e.kind,
		e.id,
		e.owner,
		e.session,
		e.ttl,
		e.wantOwner,
		e.wantSession,
	)
}

func childNeedsNukeScopeTags(kind, id string, tags map[string]string, scope cpaws.Scope) (bool, error) {
	owner, hasOwner := tags[cpaws.TagOwner]
	session, hasSession := tags[cpaws.TagSession]
	if hasOwner && !cpaws.OwnerTagMatches(scope.Owner, owner) {
		return false, newNukeScopeConflictError(kind, id, tags, scope)
	}
	if hasSession && session != scope.SessionID {
		return false, newNukeScopeConflictError(kind, id, tags, scope)
	}
	return !hasOwner || !hasSession || strings.TrimSpace(tags[cpaws.TagTTL]) == "", nil
}

func createNukeScopeTags(ctx context.Context, client destroyEC2VPCChildAPI, id string, rootTags map[string]string) error {
	tags := []ec2types.Tag{
		{Key: awssdk.String(cpaws.TagOwner), Value: awssdk.String(rootTags[cpaws.TagOwner])},
		{Key: awssdk.String(cpaws.TagSession), Value: awssdk.String(rootTags[cpaws.TagSession])},
	}
	if managed := strings.TrimSpace(rootTags[cpaws.TagManaged]); managed != "" {
		tags = append(tags, ec2types.Tag{Key: awssdk.String(cpaws.TagManaged), Value: awssdk.String(managed)})
	}
	if ttl := strings.TrimSpace(rootTags[cpaws.TagTTL]); ttl != "" {
		tags = append(tags, ec2types.Tag{Key: awssdk.String(cpaws.TagTTL), Value: awssdk.String(ttl)})
	}
	if _, err := client.CreateTags(ctx, &ec2.CreateTagsInput{
		Resources: []string{id},
		Tags:      tags,
	}); err != nil {
		// The child may be deleted concurrently between enumeration and tagging —
		// a VPC's own teardown removes ENIs, or another sweep races this one. A
		// gone child needs no tag: aws-nuke targets by resource type and tag, not
		// by explicit id, so dropping it here loses nothing. Failing instead would
		// abort the whole account's cleanup over one vanished resource.
		if ec2ResourceGoneError(err) {
			verbose.Logf("VPC child %s vanished before tagging; skipping (already gone): %v", id, err)
			return nil
		}
		return fmt.Errorf("tag VPC child %s for aws-nuke: %w", id, err)
	}
	return nil
}

// ec2ResourceGoneError reports whether an EC2 error means the resource no longer
// exists. EC2 signals this with an Invalid<Type>ID.NotFound code rather than a
// single shared NotFound, so match the family by suffix and keep a string
// fallback for wrapped errors.
func ec2ResourceGoneError(err error) bool {
	if err == nil {
		return false
	}
	var apiErr smithy.APIError
	if errors.As(err, &apiErr) {
		code := apiErr.ErrorCode()
		if strings.HasPrefix(code, "Invalid") && strings.HasSuffix(code, ".NotFound") {
			return true
		}
	}
	return strings.Contains(err.Error(), ".NotFound")
}

func routeTableIsMain(associations []ec2types.RouteTableAssociation) bool {
	for _, association := range associations {
		if awssdk.ToBool(association.Main) {
			return true
		}
	}
	return false
}

func ec2TagMap(tags []ec2types.Tag) map[string]string {
	out := make(map[string]string, len(tags))
	for _, tag := range tags {
		out[awssdk.ToString(tag.Key)] = awssdk.ToString(tag.Value)
	}
	return out
}

func ec2ResourceIDFromARN(arn, prefix string) string {
	idx := strings.LastIndex(arn, prefix)
	if idx == -1 {
		return ""
	}
	tail := strings.TrimSpace(arn[idx+len(prefix):])
	if tail == "" {
		return ""
	}
	parts := strings.Split(tail, "/")
	return parts[0]
}

func withDestroyReaperSessionEnv(owner, sessionID string, fn func() error) error {
	if fn == nil {
		return fmt.Errorf("destroy reaper callback is required")
	}
	type restore struct {
		name    string
		value   string
		present bool
	}
	var restores []restore
	set := func(name, value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		old, present := os.LookupEnv(name)
		restores = append(restores, restore{name: name, value: old, present: present})
		_ = os.Setenv(name, value)
	}
	set("CP9_OWNER", owner)
	set("CP9_SESSION_NAME", sessionID)
	defer func() {
		for _, restore := range slices.Backward(restores) {
			if restore.present {
				_ = os.Setenv(restore.name, restore.value)
			} else {
				_ = os.Unsetenv(restore.name)
			}
		}
	}()
	return fn()
}

func absDestroyPath(path string) string {
	if strings.TrimSpace(path) == "" {
		return ""
	}
	abs, err := filepath.Abs(path)
	if err != nil {
		return path
	}
	return abs
}

func destroyGCPRuntimeController(ctx context.Context, stateDir string) (string, error) {
	gcpStateDir := host.GCPRegionalStateDir(stateDir)
	selected, err := host.Load(gcpStateDir)
	if errors.Is(err, os.ErrNotExist) {
		_, terraformErr := os.Stat(cpterraform.GCPWorkspacePaths(stateDir).StatePath)
		if errors.Is(terraformErr, os.ErrNotExist) {
			return "GCP runtime down skipped: no persisted GCP host or Terraform state", nil
		}
		if terraformErr != nil {
			return "", fmt.Errorf("stat GCP Terraform state before runtime down: %w", terraformErr)
		}
		return "", fmt.Errorf("GCP host state is missing while its Terraform state exists")
	}
	if err != nil {
		return "", fmt.Errorf("load GCP host for runtime down: %w", err)
	}
	paths := cptilt.PathsForState(gcpStateDir, nil)
	status, err := cptilt.LoadStatus(paths.Status)
	if errors.Is(err, os.ErrNotExist) {
		if selected.Execution != nil && selected.Execution.Controller == "unconfigured" {
			return "GCP runtime down skipped: persisted host contract proves the controller was never configured", nil
		}
		return "", fmt.Errorf("GCP Tilt status is missing; cannot prove the remote controller stopped")
	}
	if err != nil {
		return "", fmt.Errorf("load GCP Tilt status: %w", err)
	}
	if status.Controller != cptilt.ControllerRemote {
		return "GCP runtime down skipped: no remote Tilt controller", nil
	}
	request := &cptilt.Request{
		Tiltfile:    status.RemoteTiltfilePath,
		KubeContext: selected.Context,
		Kubeconfig:  status.RemoteKubeconfigPath,
	}
	if err := cptilt.DownForHost(ctx, selected, status, request); err != nil {
		return "", err
	}
	return "GCP runtime down completed", nil
}

func destroyTiltDownSelectedHostAPIUnavailableForCloseout(selected *host.Session, err error) bool {
	if selected == nil || err == nil {
		return false
	}
	msg := err.Error()
	switch selected.Mode {
	case config.HostModeLocalK3d:
		return strings.Contains(msg, "connect: connection refused") ||
			strings.Contains(msg, "no such host") ||
			strings.Contains(msg, "context deadline exceeded") ||
			strings.Contains(msg, "context was not found for specified context") ||
			strings.Contains(msg, "cluster has no server defined")
	case config.HostModeRemoteEKS:
		return strings.Contains(msg, "TLS handshake timeout") ||
			strings.Contains(msg, "i/o timeout") ||
			strings.Contains(msg, "connect: connection refused") ||
			strings.Contains(msg, "no such host") ||
			strings.Contains(msg, "context deadline exceeded") ||
			strings.Contains(msg, "ResourceNotFoundException")
	default:
		return false
	}
}

func loadV1Alpha2DestroyContextFromConfig(
	ctx context.Context,
	session *config.SessionConfig,
	stateDir string,
) (*v1alpha2DestroyContext, error) {
	if session == nil {
		return nil, fmt.Errorf("session config is required")
	}
	owner, err := destroyV1Alpha2OwnerResolver(ctx, session)
	if err != nil {
		return nil, fmt.Errorf("resolve destroy owner: %w", err)
	}
	scope := cpaws.Scope{
		Owner:     owner,
		SessionID: session.Name,
		Regions:   []string{session.Target.Region},
	}.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, err
	}
	reaper, _, err := makeV1Alpha2ScopedReaper(ctx, session, owner)
	if err != nil {
		return nil, fmt.Errorf("configure destroy reaper: %w", err)
	}
	reapState := &cpaws.ReapState{
		Scope: scope, Profile: strings.TrimSpace(session.Target.AWS.IAM.Profile), ConfigScoped: true,
	}
	reapState.ConfigDigest, err = v1alpha2ConfigScopedDestroyDigest(session, reapState)
	if err != nil {
		return nil, fmt.Errorf("fingerprint config-scoped destroy intent: %w", err)
	}
	return &v1alpha2DestroyContext{
		StateDir:          stateDir,
		Resolved:          session,
		ReapState:         reapState,
		ReapStateSource:   destroyReapStateSourceConfig,
		Reaper:            reaper,
		TiltPaths:         cptilt.PathsForState(stateDir, nil),
		TerraformPaths:    terraformPathsForState(stateDir, nil),
		GCPTerraformPaths: gcpTerraformPathsForState(stateDir, nil),
	}, nil
}

type v1alpha2ConfigScopedDestroyContract struct {
	Session               string            `json:"session"`
	Owner                 string            `json:"owner"`
	Regions               []string          `json:"regions"`
	AllOwners             bool              `json:"all_owners"`
	AdminSweep            bool              `json:"admin_sweep"`
	Profile               string            `json:"profile"`
	SourceProfile         string            `json:"source_profile"`
	RoleARN               string            `json:"role_arn"`
	AccountID             string            `json:"account_id"`
	Persona               string            `json:"persona"`
	CleanupWorkflow       bool              `json:"cleanup_workflow"`
	CleanupWorkflowRepo   string            `json:"cleanup_workflow_repo,omitempty"`
	CleanupWorkflowFile   string            `json:"cleanup_workflow_file,omitempty"`
	CleanupWorkflowRef    string            `json:"cleanup_workflow_ref,omitempty"`
	SessionTags           map[string]string `json:"session_tags"`
	BYOCCustomerAccountID string            `json:"byoc_customer_account_id,omitempty"`
	BYOCImageSyncRegion   string            `json:"byoc_image_sync_region,omitempty"`
	GCPSeedProjectID      string            `json:"gcp_seed_project_id,omitempty"`
	GCPDataplaneProjectID string            `json:"gcp_dataplane_project_id,omitempty"`
	GCPRegion             string            `json:"gcp_region,omitempty"`
	GCPZone               string            `json:"gcp_zone,omitempty"`
}

func v1alpha2ConfigScopedDestroyDigest(session *config.SessionConfig, reapState *cpaws.ReapState) (string, error) {
	if session == nil || reapState == nil {
		return "", fmt.Errorf("session config and reaper state are required")
	}
	scope := reapState.Scope.Normalize()
	if err := scope.Validate(); err != nil {
		return "", err
	}
	if scope.SessionID != strings.TrimSpace(session.Name) {
		return "", fmt.Errorf("reaper session %q does not match config session %q", scope.SessionID, session.Name)
	}
	sessionTags, err := cpaws.DeriveSessionTags(scope.Owner, scope.SessionID)
	if err != nil {
		return "", fmt.Errorf("derive config-scoped destroy tags: %w", err)
	}
	contract := v1alpha2ConfigScopedDestroyContract{
		Session:         strings.TrimSpace(session.Name),
		Owner:           scope.Owner,
		Regions:         append([]string(nil), scope.Regions...),
		AllOwners:       scope.AllOwners,
		AdminSweep:      scope.AdminSweep,
		Profile:         strings.TrimSpace(reapState.Profile),
		SourceProfile:   strings.TrimSpace(session.Target.AWS.IAM.SourceProfile),
		RoleARN:         strings.TrimSpace(session.Target.AWS.IAM.RoleARN),
		AccountID:       v1alpha2DestroyReaperAccountID(session),
		Persona:         config.SessionPersona(session),
		CleanupWorkflow: destroyUsesCleanupWorkflow(session),
		SessionTags:     sessionTags,
	}
	if contract.CleanupWorkflow {
		contract.CleanupWorkflowRepo = destroyCloudCleanupWorkflowRepo()
		contract.CleanupWorkflowFile = destroyCloudCleanupWorkflowFile()
		contract.CleanupWorkflowRef = destroyCloudCleanupWorkflowRef()
	}
	if session.BYOC != nil {
		contract.BYOCCustomerAccountID = strings.TrimSpace(session.BYOC.Customer.AccountID)
		contract.BYOCImageSyncRegion = strings.TrimSpace(session.BYOC.Customer.ImageSyncRegion)
	}
	if config.DedicatedGCPEnabled(session) {
		contract.GCPSeedProjectID = session.Dedicated.GCP.SeedProjectID
		contract.GCPDataplaneProjectID = session.Dedicated.GCP.DataplaneProjectID
		contract.GCPRegion = session.Dedicated.GCP.Region
		contract.GCPZone = session.Dedicated.GCP.Zone
	}
	data, err := json.Marshal(contract)
	if err != nil {
		return "", fmt.Errorf("marshal config-scoped destroy contract: %w", err)
	}
	digest := sha256.Sum256(data)
	return fmt.Sprintf("sha256:%x", digest[:]), nil
}

func loadV1Alpha2DestroyContextFromIntent(
	ctx context.Context,
	session *config.SessionConfig,
	stateDir string,
	intent *cpaws.ReapState,
) (*v1alpha2DestroyContext, error) {
	if intent == nil || !intent.ConfigScoped || strings.TrimSpace(intent.ConfigDigest) == "" {
		return nil, fmt.Errorf("durable config-scoped destroy intent is missing or incomplete")
	}
	destroyCtx, err := loadV1Alpha2DestroyContextFromConfig(ctx, session, stateDir)
	if err != nil {
		return nil, err
	}
	expected := destroyCtx.ReapState
	actualScope := intent.Scope.Normalize()
	expectedScope := expected.Scope.Normalize()
	if actualScope.Owner != expectedScope.Owner || actualScope.SessionID != expectedScope.SessionID ||
		actualScope.AllOwners != expectedScope.AllOwners || actualScope.AdminSweep != expectedScope.AdminSweep ||
		!slices.Equal(actualScope.Regions, expectedScope.Regions) ||
		strings.TrimSpace(intent.Profile) != strings.TrimSpace(expected.Profile) ||
		strings.TrimSpace(intent.ConfigDigest) != strings.TrimSpace(expected.ConfigDigest) {
		return nil, fmt.Errorf("durable config-scoped destroy intent does not match the supplied config")
	}
	intent.Scope = actualScope
	destroyCtx.ReapState = intent
	destroyCtx.ReapStateSource = destroyReapStateSourceConfig
	return destroyCtx, nil
}

func resolveV1Alpha2DestroyOwner(ctx context.Context, session *config.SessionConfig) (string, error) {
	if session == nil {
		return "", fmt.Errorf("session config is required")
	}
	profile := firstNonEmptyString(session.Target.AWS.IAM.SourceProfile, session.Target.AWS.IAM.Profile)
	if profile == "" {
		return "", fmt.Errorf("target.aws.iam.source_profile or target.aws.iam.profile is required for destroy reaper scope")
	}
	awsCfg, err := loadDestroyAWSConfigWithCredentialFallback(ctx, profile, session.Target.Region)
	if err != nil {
		return "", err
	}
	awsCfg = withDestroyAWSHTTPClient(&awsCfg)
	result, err := cpaws.ValidateAccess(ctx, destroyNewSTSClient(&awsCfg), profile, session.Target.Region)
	if err != nil {
		return "", err
	}
	return cpaws.ResolveOwnerIdentity(result, session.CI.OwnerAllowlist)
}

func newV1Alpha2ScopedReaper(
	ctx context.Context,
	session *config.SessionConfig,
	owner string,
) (*cpaws.Reaper, *cpaws.NukeRunRequest, error) {
	return newV1Alpha2ScopedReaperWithDiscovery(ctx, session, owner, false)
}

func newV1Alpha2SessionCollisionReaper(
	ctx context.Context,
	session *config.SessionConfig,
	owner string,
) (*cpaws.Reaper, *cpaws.NukeRunRequest, error) {
	return newV1Alpha2ScopedReaperWithDiscovery(ctx, session, owner, true)
}

func newV1Alpha2ScopedReaperWithDiscovery(
	ctx context.Context,
	session *config.SessionConfig,
	owner string,
	collisionOnly bool,
) (*cpaws.Reaper, *cpaws.NukeRunRequest, error) {
	if session == nil {
		return nil, nil, fmt.Errorf("session config is required")
	}
	iamCfg := session.Target.AWS.IAM
	profile := firstNonEmptyString(iamCfg.SourceProfile, iamCfg.Profile)
	if strings.TrimSpace(profile) == "" {
		return nil, nil, fmt.Errorf("target.aws.iam.source_profile or target.aws.iam.profile is required for destroy reaper")
	}
	tags, err := cpaws.DeriveSessionTags(owner, session.Name)
	if err != nil {
		return nil, nil, err
	}
	baseCfg, err := loadDestroyAWSConfigWithCredentialFallback(ctx, profile, session.Target.Region)
	if err != nil {
		return nil, nil, err
	}
	baseCfg = withDestroyAWSHTTPClient(&baseCfg)
	if roleARN := strings.TrimSpace(iamCfg.RoleARN); roleARN != "" {
		alreadyAssumed, err := destroyConfigCallerMatchesAssumedRole(ctx, &baseCfg, roleARN)
		if err != nil {
			return nil, nil, fmt.Errorf("check current destroy reaper role: %w", err)
		}
		if !alreadyAssumed {
			assumed := cpaws.ConfigWithAssumeRole(&baseCfg, roleARN, owner, tags)
			baseCfg = assumed
		}
	}
	if err := cpaws.FreezeConfigCredentials(ctx, &baseCfg); err != nil {
		return nil, nil, fmt.Errorf("freeze destroy reaper credentials: %w", err)
	}
	creds, err := baseCfg.Credentials.Retrieve(ctx)
	if err != nil {
		return nil, nil, fmt.Errorf("retrieve frozen destroy reaper credentials: %w", err)
	}
	accountID := v1alpha2DestroyReaperAccountID(session)
	scanner := &cpaws.RegionalScanner{
		Factory: func(region string) (cpaws.Tracker, error) {
			regionCfg := baseCfg.Copy()
			regionCfg.Region = region
			tracker := cpaws.NewTrackerWithIAMForAccount(
				resourcegroupstaggingapi.NewFromConfig(regionCfg),
				iam.NewFromConfig(regionCfg),
				eks.NewFromConfig(regionCfg),
				region,
				accountID,
			).WithEC2ResourceGraph(ec2.NewFromConfig(regionCfg)).
				WithELBResourceGraph(elasticloadbalancingv2.NewFromConfig(regionCfg)).
				WithAutoScalingGraph(autoscaling.NewFromConfig(regionCfg))
			if collisionOnly {
				tracker.WithSessionCollisionDiscovery()
			}
			return tracker, nil
		},
	}
	cleaner := &cpaws.RegionalCleaner{
		Factory: func(region string) (cpaws.Cleaner, error) {
			regionCfg := baseCfg.Copy()
			regionCfg.Region = region
			_, cleaner, _ := cpaws.NewClients(&regionCfg)
			return cleaner, nil
		},
	}
	return cpaws.NewReaper(scanner, cleaner), &cpaws.NukeRunRequest{
		Region:          session.Target.Region,
		AccountID:       accountID,
		AccessKeyID:     creds.AccessKeyID,
		SecretAccessKey: creds.SecretAccessKey,
		SessionToken:    creds.SessionToken,
	}, nil
}

func loadDestroyAWSConfigWithCredentialFallback(
	ctx context.Context,
	profile, region string,
) (awssdk.Config, error) {
	cfg, err := destroyLoadAWSConfig(ctx, profile, region)
	if err == nil {
		err = cpaws.FreezeConfigCredentials(ctx, &cfg)
	}
	if err == nil || !cpaws.IsSSOCredentialRefreshError(err) {
		return cfg, err
	}
	exportedCfg, exportErr := cpaws.LoadAWSConfigFromExportedProfileCredentials(ctx, profile, region, nil)
	if exportErr != nil {
		return awssdk.Config{}, fmt.Errorf("%w; AWS CLI credential export fallback failed: %w", err, exportErr)
	}
	return exportedCfg, nil
}

func destroyConfigCallerMatchesAssumedRole(ctx context.Context, cfg *awssdk.Config, roleARN string) (bool, error) {
	if cfg == nil || strings.TrimSpace(roleARN) == "" {
		return false, nil
	}
	out, err := destroyNewSTSClient(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
	if err != nil {
		return false, err
	}
	return destroyAssumedRoleARNMatches(awssdk.ToString(out.Arn), roleARN), nil
}

func destroyAssumedRoleARNMatches(callerARN, roleARN string) bool {
	roleARN = strings.TrimSpace(roleARN)
	callerARN = strings.TrimSpace(callerARN)
	parts := strings.SplitN(roleARN, ":", 6)
	if len(parts) < 6 || parts[2] != "iam" || parts[5] == "" {
		return false
	}
	rolePath := strings.TrimPrefix(parts[5], "role/")
	if rolePath == parts[5] || rolePath == "" {
		return false
	}
	roleName := rolePath
	if idx := strings.LastIndex(roleName, "/"); idx >= 0 {
		roleName = roleName[idx+1:]
	}
	if roleName == "" {
		return false
	}
	return strings.HasPrefix(callerARN, "arn:aws:sts::"+parts[4]+":assumed-role/"+roleName+"/")
}

func v1alpha2DestroyReaperAccountID(session *config.SessionConfig) string {
	if session == nil {
		return ""
	}
	roleARN := strings.TrimSpace(session.Target.AWS.IAM.RoleARN)
	parts := strings.SplitN(roleARN, ":", 6)
	if len(parts) >= 5 {
		return strings.TrimSpace(parts[4])
	}
	return ""
}

func loadV1Alpha2DestroyContext(stateDir string) (*v1alpha2DestroyContext, error) {
	destroyCtx := &v1alpha2DestroyContext{
		StateDir:          stateDir,
		TerraformPaths:    terraformPathsForState(stateDir, nil),
		GCPTerraformPaths: gcpTerraformPathsForState(stateDir, nil),
	}

	state, err := loadLifecycleState(stateDir)
	switch {
	case err == nil:
		destroyCtx.State = state
	case os.IsNotExist(err):
		// Best-effort: a partial config_load may still have enough persisted artifacts.
	default:
		return nil, fmt.Errorf("read state: %w", err)
	}

	destroyCtx.TiltPaths = cptilt.PathsForState(stateDir, destroyCtx.State)
	destroyCtx.TerraformPaths = terraformPathsForState(stateDir, destroyCtx.State)
	destroyCtx.GCPTerraformPaths = gcpTerraformPathsForState(stateDir, destroyCtx.State)
	if err := constrainDestroyTerraformPathsToSessionRoot(stateDir, &destroyCtx.TerraformPaths); err != nil {
		return nil, err
	}
	if err := constrainDestroyTerraformPathsToSessionRoot(stateDir, &destroyCtx.GCPTerraformPaths); err != nil {
		return nil, err
	}

	resolvedPath := resolvedSessionPathForDestroy(stateDir, destroyCtx.State)
	resolvedPath, err = normalizeDestroyPathUnderSessionRoot(stateDir, resolvedPath, "state.Paths.ResolvedSession")
	if err != nil {
		return nil, err
	}
	resolvedExists, err := fileExistsForDestroy(resolvedPath, "state.Paths.ResolvedSession")
	if err != nil {
		return nil, err
	}
	if resolvedExists {
		resolved, err := config.LoadSessionForDestroy(resolvedPath)
		if err != nil {
			return nil, fmt.Errorf("load resolved session: %w", err)
		}
		expectedName := expectedDestroySessionName(stateDir)
		if strings.TrimSpace(expectedName) != "" && resolved.Name != expectedName {
			return nil, fmt.Errorf(
				"resolved session artifact name %q does not match target session %q",
				resolved.Name,
				expectedName,
			)
		}
		destroyCtx.Resolved = resolved
	}
	if err := constrainDestroyTiltPaths(stateDir, &destroyCtx.TiltPaths, destroyCtx.Resolved); err != nil {
		return nil, err
	}

	selected, err := loadDestroySelectedHost(stateDir, destroyCtx.State, destroyCtx.Resolved)
	if err != nil {
		return nil, fmt.Errorf("load selected host: %w", err)
	}
	destroyCtx.Selected = selected

	if cpaws.SessionStateExists(stateDir) {
		awsState, err := cpaws.LoadSessionState(stateDir)
		if err != nil {
			return nil, fmt.Errorf("load AWS session state: %w", err)
		}
		destroyCtx.AWSState = awsState
	}

	destroyCtx.TiltfileExists, err = fileExistsForDestroy(destroyCtx.TiltPaths.Tiltfile, "state.Paths.Tiltfile")
	if err != nil {
		return nil, err
	}
	destroyCtx.TiltStatusExists, err = fileExistsForDestroy(destroyCtx.TiltPaths.Status, "Tilt status")
	if err != nil {
		return nil, err
	}
	destroyCtx.ContextIndexExists, err = fileExistsForDestroy(destroyCtx.TiltPaths.ContextIndex, "state.Paths.ContextIndex")
	if err != nil {
		return nil, err
	}
	destroyCtx.TerraformStateOK, err = fileExistsForDestroy(destroyCtx.TerraformPaths.StatePath, "state.Paths.TerraformState")
	if err != nil {
		return nil, err
	}
	destroyCtx.GCPTerraformStateOK, err = fileExistsForDestroy(
		destroyCtx.GCPTerraformPaths.StatePath,
		"state.Paths.GCPTerraformState",
	)
	if err != nil {
		return nil, err
	}
	destroyCtx.GCPHostExists, err = fileExistsForDestroy(
		host.StatePath(host.GCPRegionalStateDir(stateDir)),
		"GCP regional host state",
	)
	if err != nil {
		return nil, err
	}
	destroyCtx.ReapState, destroyCtx.ReapStateSource, destroyCtx.ReapContractErr, err = loadDestroyReapState(destroyCtx)
	if err != nil {
		return nil, err
	}
	return destroyCtx, nil
}

func expectedDestroySessionName(stateDir string) string {
	return strings.TrimSpace(filepath.Base(filepath.Clean(stateDir)))
}

//nolint:nilnil // no selected host is a valid destroy state
func loadDestroySelectedHost(stateDir string, state *cluster.State, resolved *config.SessionConfig) (*host.Session, error) {
	selected, err := loadSelectedSessionHost(stateDir)
	if err == nil {
		return selected, nil
	}
	if !os.IsNotExist(err) {
		return nil, err
	}
	if resolved != nil {
		if resolved.Host.Mode == config.HostModeRemoteEC2K3d {
			if destroyRemoteSelectedHostMayBeMissing(state) {
				return nil, nil
			}
			return nil, fmt.Errorf("persisted remote selected host is missing host.json")
		}
		return host.ResolveSessionConfig(resolved), nil
	}
	if state == nil {
		return nil, nil
	}
	clusterName := firstNonEmptyString(state.SessionName, state.ClusterName)
	if strings.TrimSpace(clusterName) == "" {
		return nil, nil
	}
	mode := state.HostMode
	if mode == "" {
		return nil, fmt.Errorf("lifecycle state is missing host mode")
	}
	if mode == config.HostModeRemoteEC2K3d {
		if destroyRemoteSelectedHostMayBeMissing(state) {
			return nil, nil
		}
		return nil, fmt.Errorf("persisted remote selected host is missing host.json")
	}
	selected = &host.Session{
		Mode:           mode,
		StateDir:       stateDir,
		ClusterName:    clusterName,
		Namespace:      clusterName,
		KubeconfigPath: filepath.Join(stateDir, "kubeconfig.yaml"),
	}
	switch mode {
	case config.HostModeLocalK3d, config.HostModeRemoteEC2K3d:
		selected.Context = "k3d-" + clusterName
	default:
		selected.Context = clusterName
	}
	return selected, nil
}

func destroyRemoteSelectedHostMayBeMissing(state *cluster.State) bool {
	if state == nil {
		return false
	}
	hostReady := state.Steps[cluster.StepHostReady]
	if hostReady == nil {
		return false
	}
	// Only a successfully completed host_ready step proves that a remote host
	// selection must have been persisted. A failed or interrupted step can stop
	// before host.json is written, and destroy must still reach Terraform and the
	// scoped reaper for any partial cloud resources.
	switch hostReady.Status {
	case cluster.StatusPending,
		cluster.StatusRunning,
		cluster.StatusFailed,
		cluster.StatusSkipped,
		cluster.StatusInterrupted:
		return true
	default:
		return false
	}
}

func loadDestroyReapState(
	destroyCtx *v1alpha2DestroyContext,
) (reapState *cpaws.ReapState, source destroyReapStateSource, contractErr, loadErr error) {
	if destroyCtx == nil {
		return nil, "", nil, fmt.Errorf("destroy context is required")
	}
	sessionID := expectedDestroySessionName(destroyCtx.StateDir)
	if destroyCtx.Resolved != nil && strings.TrimSpace(destroyCtx.Resolved.Name) != "" {
		sessionID = strings.TrimSpace(destroyCtx.Resolved.Name)
	}
	if sessionID != "" {
		pending, err := findReapStateBySession(cluster.DefaultStateDir, sessionID)
		switch {
		case err == nil && pending != nil && pending.ConfigScoped:
			return nil, "", nil, fmt.Errorf(
				"config-scoped destroy intent cannot be used as persisted lifecycle reaper state; complete the pending config-scoped destroy before starting or destroying this session",
			)
		case err == nil, errors.Is(err, os.ErrNotExist):
		case err != nil:
			return nil, "", nil, fmt.Errorf("inspect session-wide destroy cleanup state: %w", err)
		}
	}
	if destroyCtx.Resolved != nil && !config.UsesCloudSubstrate(destroyCtx.Resolved) {
		return nil, "", nil, nil
	}
	if destroyCtx.AWSState == nil {
		needsScope, err := destroyNeedsPersistedReaperScope(destroyCtx)
		if err != nil {
			return nil, "", err, nil
		}
		if needsScope {
			return nil, "", fmt.Errorf("destroy: persisted AWS session state is required for reaper scope"), nil
		}
		return nil, "", nil, nil
	}

	if err := validateDestroySessionState(destroyCtx.AWSState); err != nil {
		return nil, "", err, nil
	}

	scope := cpaws.Scope{
		Owner:     destroyCtx.AWSState.Owner,
		SessionID: destroyCtx.AWSState.SessionID,
		Regions:   destroyReapRegions(destroyCtx.Resolved, destroyCtx.AWSState),
	}.Normalize()
	if err := scope.Validate(); err != nil {
		return nil, "", fmt.Errorf("destroy: invalid persisted reaper scope: %w", err), nil
	}

	reapState, err := loadReapState(cluster.DefaultStateDir, scope.Owner, scope.SessionID)
	switch {
	case err == nil:
		if reapState.ConfigScoped {
			return nil, "", nil, fmt.Errorf(
				"config-scoped destroy intent cannot be used as persisted lifecycle reaper state; complete the pending config-scoped destroy before starting or destroying this session",
			)
		}
		if reapState.Scope.Normalize().Owner != scope.Owner || reapState.Scope.Normalize().SessionID != scope.SessionID {
			return nil, "", fmt.Errorf("destroy: persisted reaper state scope does not match AWS session scope"), nil
		}
		reapState.Scope = scope
		if strings.TrimSpace(reapState.Profile) == "" {
			reapState.Profile = destroyCtx.AWSState.Profile
		}
		return reapState, destroyReapStateSourcePersisted, nil, nil
	case errors.Is(err, os.ErrNotExist):
		return &cpaws.ReapState{
			Scope:   scope,
			Profile: destroyCtx.AWSState.Profile,
		}, destroyReapStateSourceSession, nil, nil
	default:
		return nil, "", nil, fmt.Errorf("load reaper state: %w", err)
	}
}

func destroyNeedsPersistedReaperScope(destroyCtx *v1alpha2DestroyContext) (bool, error) {
	if destroyCtx == nil {
		return false, nil
	}
	// A configured Tiltfile may exist before up authenticates. Without lifecycle
	// state, retain the old fail-closed treatment because its provenance is unknown.
	if destroyCtx.TerraformStateOK || (destroyCtx.TiltfileExists && destroyCtx.State == nil) {
		return true, nil
	}
	hostArtifact, err := fileExistsForDestroy(filepath.Join(destroyCtx.StateDir, "host.json"), "host.json")
	if err != nil {
		return false, err
	}
	if destroyCtx.Selected != nil && hostArtifact {
		return true, nil
	}
	if destroyCtx.State == nil {
		return false, nil
	}
	for _, step := range []cluster.StepName{
		cluster.StepIAMReady,
		cluster.StepSubstrateInit,
		cluster.StepSubstratePlan,
		cluster.StepSubstrateApply,
		cluster.StepHostReady,
		cluster.StepServicePlan,
		cluster.StepMetadbSeed,
		cluster.StepContextMaterialize,
		cluster.StepTiltfileGenerate,
		cluster.StepTiltReady,
		cluster.StepSessionReady,
	} {
		if stepState := destroyCtx.State.Steps[step]; stepState != nil {
			switch stepState.Status {
			case "", cluster.StatusPending, cluster.StatusSkipped:
			default:
				return true, nil
			}
		}
	}
	return false, nil
}

func validateDestroySessionState(state *cpaws.SessionState) error {
	if state == nil {
		return fmt.Errorf("persisted AWS session state is required")
	}
	if strings.TrimSpace(state.Owner) == "" {
		return fmt.Errorf("persisted AWS session state is missing owner")
	}
	if strings.TrimSpace(state.SessionID) == "" {
		return fmt.Errorf("persisted AWS session state is missing session id")
	}
	if strings.TrimSpace(state.Profile) == "" {
		return fmt.Errorf("persisted AWS session state is missing profile")
	}
	if strings.TrimSpace(state.Region) == "" {
		return fmt.Errorf("persisted AWS session state is missing region")
	}
	ownerTag := strings.ToLower(strings.TrimSpace(state.SessionTags[cpaws.TagOwner]))
	if ownerTag == "" {
		return fmt.Errorf("persisted AWS session state is missing %q tag", cpaws.TagOwner)
	}
	if !cpaws.OwnerTagMatches(state.Owner, ownerTag) {
		return fmt.Errorf("persisted AWS session state %q tag %q does not match owner %q", cpaws.TagOwner, ownerTag, state.Owner)
	}
	sessionTag := strings.TrimSpace(state.SessionTags[cpaws.TagSession])
	if sessionTag == "" {
		return fmt.Errorf("persisted AWS session state is missing %q tag", cpaws.TagSession)
	}
	if sessionTag != strings.TrimSpace(state.SessionID) {
		return fmt.Errorf("persisted AWS session state %q tag %q does not match session %q", cpaws.TagSession, sessionTag, state.SessionID)
	}
	return nil
}

func destroyReapRegions(resolved *config.SessionConfig, awsState *cpaws.SessionState) []string {
	switch {
	case resolved != nil && strings.TrimSpace(resolved.Target.Region) != "":
		return []string{resolved.Target.Region}
	case awsState != nil && strings.TrimSpace(awsState.Region) != "":
		return []string{awsState.Region}
	default:
		return nil
	}
}

func terraformPathsForState(stateDir string, state *cluster.State) cpterraform.Paths {
	paths := cpterraform.WorkspacePaths(stateDir)
	if state == nil || state.Paths == nil {
		return paths
	}
	if value := strings.TrimSpace(state.Paths.TerraformDir); value != "" {
		paths.RootDir = value
	}
	if value := strings.TrimSpace(state.Paths.TerraformSource); value != "" {
		paths.SourceDir = value
	}
	if value := strings.TrimSpace(state.Paths.TerraformTFVars); value != "" {
		paths.TFVarsPath = value
	}
	if value := strings.TrimSpace(state.Paths.TerraformState); value != "" {
		paths.StatePath = value
	}
	if value := strings.TrimSpace(state.Paths.TerraformPlan); value != "" {
		paths.PlanPath = value
	}
	if value := strings.TrimSpace(state.Paths.TerraformOutputs); value != "" {
		paths.OutputsPath = value
	}
	if value := strings.TrimSpace(state.Paths.TerraformContract); value != "" {
		paths.ContractPath = value
	}
	if value := strings.TrimSpace(state.Paths.TerraformLogsDir); value != "" {
		paths.LogsDir = value
	}
	if paths.PlanJSONPath == "" || filepath.Dir(paths.PlanJSONPath) != paths.LogsDir {
		paths.PlanJSONPath = filepath.Join(paths.LogsDir, "plan.json")
	}
	if paths.InitLogPath == "" || filepath.Dir(paths.InitLogPath) != paths.LogsDir {
		paths.InitLogPath = filepath.Join(paths.LogsDir, "init.log")
	}
	if paths.PlanLogPath == "" || filepath.Dir(paths.PlanLogPath) != paths.LogsDir {
		paths.PlanLogPath = filepath.Join(paths.LogsDir, "plan.log")
	}
	if paths.ApplyLogPath == "" || filepath.Dir(paths.ApplyLogPath) != paths.LogsDir {
		paths.ApplyLogPath = filepath.Join(paths.LogsDir, "apply.log")
	}
	if value := strings.TrimSpace(paths.DestroyLog); value == "" || filepath.Dir(value) != paths.LogsDir {
		paths.DestroyLog = filepath.Join(paths.LogsDir, "destroy.log")
	}
	return paths
}

func gcpTerraformPathsForState(stateDir string, state *cluster.State) cpterraform.Paths {
	paths := cpterraform.GCPWorkspacePaths(stateDir)
	if state == nil || state.Paths == nil {
		return paths
	}
	if value := strings.TrimSpace(state.Paths.GCPTerraformDir); value != "" {
		paths.RootDir = value
	}
	if value := strings.TrimSpace(state.Paths.GCPTerraformSource); value != "" {
		paths.SourceDir = value
	}
	if value := strings.TrimSpace(state.Paths.GCPTerraformTFVars); value != "" {
		paths.TFVarsPath = value
	}
	if value := strings.TrimSpace(state.Paths.GCPTerraformState); value != "" {
		paths.StatePath = value
	}
	if value := strings.TrimSpace(state.Paths.GCPTerraformPlan); value != "" {
		paths.PlanPath = value
	}
	if value := strings.TrimSpace(state.Paths.GCPTerraformOutputs); value != "" {
		paths.OutputsPath = value
	}
	if value := strings.TrimSpace(state.Paths.GCPTerraformLogsDir); value != "" {
		paths.LogsDir = value
	}
	paths.ContractPath = filepath.Join(paths.RootDir, "contract.json")
	paths.PlanJSONPath = filepath.Join(paths.LogsDir, "plan.json")
	paths.InitLogPath = filepath.Join(paths.LogsDir, "init.log")
	paths.PlanLogPath = filepath.Join(paths.LogsDir, "plan.log")
	paths.ApplyLogPath = filepath.Join(paths.LogsDir, "apply.log")
	paths.DestroyLog = filepath.Join(paths.LogsDir, "destroy.log")
	return paths
}

func constrainDestroyTiltPaths(stateDir string, paths *cptilt.Paths, resolved *config.SessionConfig) error {
	if paths == nil {
		return nil
	}
	var err error
	if paths.TiltDir, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.TiltDir, "state.Paths.TiltDir"); err != nil {
		return err
	}
	if config.UsesSessionTiltfile(resolved) {
		paths.Tiltfile, err = normalizeDestroySessionTiltfilePath(paths.Tiltfile, resolved.Tilt.File)
		if err != nil {
			return err
		}
	} else {
		if paths.Tiltfile, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.Tiltfile, "state.Paths.Tiltfile"); err != nil {
			return err
		}
	}
	if paths.ReportsDir, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.ReportsDir, "state.Paths.TiltDir reports"); err != nil {
		return err
	}
	if paths.ContextReport, err = normalizeDestroyPathUnderSessionRoot(
		stateDir,
		paths.ContextReport,
		"state.Paths.TiltDir context report",
	); err != nil {
		return err
	}
	if paths.ContextIndex, err = normalizeDestroyPathUnderSessionRoot(
		stateDir,
		paths.ContextIndex,
		"state.Paths.ContextDir context index",
	); err != nil {
		return err
	}
	if paths.Status, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.Status, "state.Paths.TiltDir status"); err != nil {
		return err
	}
	if paths.UpLog, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.UpLog, "state.Paths.TiltDir up log"); err != nil {
		return err
	}
	return nil
}

func normalizeDestroySessionTiltfilePath(path, configured string) (string, error) {
	cleanPath := strings.TrimSpace(path)
	if cleanPath == "" {
		return "", fmt.Errorf("state.Paths.Tiltfile is required")
	}
	configured = strings.TrimSpace(configured)
	if configured == "" {
		return "", fmt.Errorf("resolved tilt.file is required for external Session Tiltfile destroy")
	}
	absPath, err := filepath.Abs(cleanPath)
	if err != nil {
		return "", fmt.Errorf("abs state.Paths.Tiltfile: %w", err)
	}
	absConfigured, err := filepath.Abs(configured)
	if err != nil {
		return "", fmt.Errorf("abs resolved tilt.file: %w", err)
	}
	if filepath.Clean(absPath) != filepath.Clean(absConfigured) {
		return "", fmt.Errorf("state.Paths.Tiltfile %q must match resolved tilt.file %q", cleanPath, configured)
	}
	return filepath.Clean(absPath), nil
}

func constrainDestroyTerraformPathsToSessionRoot(stateDir string, paths *cpterraform.Paths) error {
	if paths == nil {
		return nil
	}
	var err error
	if paths.RootDir, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.RootDir, "state.Paths.TerraformDir"); err != nil {
		return err
	}
	if paths.SourceDir, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.SourceDir, "state.Paths.TerraformSource"); err != nil {
		return err
	}
	if paths.TFVarsPath, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.TFVarsPath, "state.Paths.TerraformTFVars"); err != nil {
		return err
	}
	if paths.StatePath, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.StatePath, "state.Paths.TerraformState"); err != nil {
		return err
	}
	if paths.PlanPath, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.PlanPath, "state.Paths.TerraformPlan"); err != nil {
		return err
	}
	if paths.OutputsPath, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.OutputsPath, "state.Paths.TerraformOutputs"); err != nil {
		return err
	}
	if paths.ContractPath, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.ContractPath, "state.Paths.TerraformContract"); err != nil {
		return err
	}
	if paths.LogsDir, err = normalizeDestroyPathUnderSessionRoot(stateDir, paths.LogsDir, "state.Paths.TerraformLogsDir"); err != nil {
		return err
	}
	if paths.PlanJSONPath, err = normalizeDestroyPathUnderSessionRoot(
		stateDir,
		paths.PlanJSONPath,
		"state.Paths.TerraformLogsDir plan json",
	); err != nil {
		return err
	}
	if paths.InitLogPath, err = normalizeDestroyPathUnderSessionRoot(
		stateDir,
		paths.InitLogPath,
		"state.Paths.TerraformLogsDir init log",
	); err != nil {
		return err
	}
	if paths.PlanLogPath, err = normalizeDestroyPathUnderSessionRoot(
		stateDir,
		paths.PlanLogPath,
		"state.Paths.TerraformLogsDir plan log",
	); err != nil {
		return err
	}
	if paths.ApplyLogPath, err = normalizeDestroyPathUnderSessionRoot(
		stateDir,
		paths.ApplyLogPath,
		"state.Paths.TerraformLogsDir apply log",
	); err != nil {
		return err
	}
	if paths.DestroyLog, err = normalizeDestroyPathUnderSessionRoot(
		stateDir,
		paths.DestroyLog,
		"state.Paths.TerraformLogsDir destroy log",
	); err != nil {
		return err
	}
	return nil
}

func normalizeDestroyPathUnderSessionRoot(stateDir, path, label string) (string, error) {
	path = strings.TrimSpace(path)
	if path == "" {
		return "", nil
	}
	rootAbs, err := filepath.Abs(stateDir)
	if err != nil {
		return "", fmt.Errorf("resolve session root %q: %w", stateDir, err)
	}
	target := filepath.Clean(path)
	if !filepath.IsAbs(target) {
		target, err = filepath.Abs(target)
		if err != nil {
			return "", fmt.Errorf("%s %q: %w", label, path, err)
		}
	}
	target = filepath.Clean(target)
	if pathWithinRoot(rootAbs, target) {
		return target, nil
	}
	resolvedRoot, rootErr := canonicalDestroyPath(rootAbs)
	resolvedTarget, targetErr := canonicalDestroyPath(target)
	if rootErr == nil && targetErr == nil && pathWithinRoot(resolvedRoot, resolvedTarget) {
		return target, nil
	}
	return "", fmt.Errorf("%s %q must stay within session root %q", label, path, filepath.Clean(rootAbs))
}

func pathWithinRoot(root, target string) bool {
	root = filepath.Clean(root)
	target = filepath.Clean(target)
	rel, err := filepath.Rel(root, target)
	if err != nil {
		return false
	}
	return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

func canonicalDestroyPath(path string) (string, error) {
	path = filepath.Clean(path)
	if resolved, err := filepath.EvalSymlinks(path); err == nil {
		return filepath.Clean(resolved), nil
	}
	parent := path
	var missing []string
	for {
		next := filepath.Dir(parent)
		if next == parent {
			return "", fmt.Errorf("no existing parent for %q", path)
		}
		missing = append(missing, filepath.Base(parent))
		parent = next
		if resolvedParent, err := filepath.EvalSymlinks(parent); err == nil {
			for _, part := range slices.Backward(missing) {
				resolvedParent = filepath.Join(resolvedParent, part)
			}
			return filepath.Clean(resolvedParent), nil
		}
	}
}

func resolvedSessionPathForDestroy(stateDir string, state *cluster.State) string {
	path := config.ResolvedSessionPath(stateDir)
	if state != nil && state.Paths != nil && strings.TrimSpace(state.Paths.ResolvedSession) != "" {
		path = state.Paths.ResolvedSession
	}
	return path
}

func defaultDestroySelectedHost(ctx context.Context, selected *host.Session) error {
	if selected == nil {
		return nil
	}
	switch selected.Mode {
	case config.HostModeLocalK3d:
		return localk3d.NewAdapter().Destroy(ctx, selected)
	case config.HostModeRemoteEC2K3d:
		return remoteec2.NewAdapter().Destroy(ctx, selected)
	case config.HostModeRemoteEKS:
		return nil
	default:
		return cp9errors.NotImplemented("host mode " + string(selected.Mode))
	}
}

func defaultStopSelectedHostRuntime(ctx context.Context, selected *host.Session) error {
	if selected == nil {
		return nil
	}
	clusterName := strings.TrimSpace(selected.ClusterName)
	if clusterName == "" {
		return fmt.Errorf("selected host is missing cluster name")
	}
	switch selected.Mode {
	case config.HostModeLocalK3d:
		client := cluster.NewExecK3dClient()
		exists, err := client.ClusterExists(ctx, clusterName)
		if err != nil {
			return err
		}
		if !exists {
			return nil
		}
		return client.StopCluster(ctx, clusterName)
	case config.HostModeRemoteEC2K3d:
		script := `set -eu
name=$1
if k3d cluster list --no-headers | awk '{print $1}' | grep -Fx -- "$name" >/dev/null; then
  k3d cluster stop "$name"
fi`
		_, err := host.RunSSH(ctx, selected, nil, "sh", "-lc", script, "cp9-stop-k3d", clusterName)
		return err
	case config.HostModeRemoteEKS:
		return nil
	default:
		return cp9errors.NotImplemented("host mode " + string(selected.Mode))
	}
}

func describeDestroyedSelectedHost(selected *host.Session) string {
	if selected == nil {
		return "host cleanup skipped"
	}
	switch selected.Mode {
	case config.HostModeRemoteEKS:
		return "host: remote EKS substrate cleanup is terraform-owned"
	case config.HostModeRemoteEC2K3d:
		instanceID := ""
		if selected.Remote != nil {
			instanceID = selected.Remote.InstanceID
		}
		if strings.TrimSpace(instanceID) == "" {
			return fmt.Sprintf("host cleanup completed for %s", selected.Mode)
		}
		return fmt.Sprintf("host: terminated %s instance %q", selected.Mode, instanceID)
	default:
		return fmt.Sprintf("host: deleted %s cluster %q", selected.Mode, selected.ClusterName)
	}
}

func destroySessionRootExists(stateDir string) (bool, error) {
	_, err := os.Stat(stateDir)
	if err == nil {
		return true, nil
	}
	if os.IsNotExist(err) {
		return false, nil
	}
	return false, fmt.Errorf("check session root %q: %w", stateDir, err)
}

// classifyV1Alpha2DestroyRoot distinguishes an ordinary persisted session from
// a config-scoped cleanup shell. The latter is safe to reconstruct only when a
// matching durable reaper intent was published before this root was created.
func classifyV1Alpha2DestroyRoot(stateDir, session string) (v1alpha2DestroyRootClass, error) {
	rootEntries, err := os.ReadDir(stateDir)
	if err != nil {
		return destroyRootInvalidShell, fmt.Errorf("read possible config-scoped destroy shell: %w", err)
	}
	byocDirFound := false
	ordinaryArtifactFound := false
	for _, entry := range rootEntries {
		switch entry.Name() {
		case filepath.Base(byoc.StateDir(stateDir)):
			byocDirFound = true
		case filepath.Base(cluster.DestroyDir(stateDir)):
		default:
			ordinaryArtifactFound = true
			continue
		}
		if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
			return destroyRootInvalidShell, nil
		}
	}
	if ordinaryArtifactFound {
		return destroyRootPersistedContext, nil
	}
	if !byocDirFound {
		return destroyRootConfigScopedShell, nil
	}

	byocEntries, err := os.ReadDir(byoc.StateDir(stateDir))
	if err != nil {
		return destroyRootInvalidShell, fmt.Errorf("read possible BYOC destroy shell: %w", err)
	}
	journalFound := false
	for _, entry := range byocEntries {
		name := entry.Name()
		switch {
		case name == filepath.Base(byoc.JournalPath(stateDir)):
			journalFound = true
		case name == filepath.Base(byoc.LockPath(stateDir)):
		case strings.HasPrefix(name, ".activation-") && strings.HasSuffix(name, ".tmp"):
		default:
			return destroyRootInvalidShell, nil
		}
		info, infoErr := entry.Info()
		if infoErr != nil {
			return destroyRootInvalidShell, fmt.Errorf("inspect possible BYOC destroy-shell artifact %q: %w", entry.Name(), infoErr)
		}
		if !info.Mode().IsRegular() {
			return destroyRootInvalidShell, nil
		}
	}
	if !journalFound {
		return destroyRootConfigScopedShell, nil
	}

	journal, err := byoc.LoadJournal(stateDir)
	if err != nil {
		return destroyRootInvalidShell, fmt.Errorf("load possible BYOC empty-lifecycle fence: %w", err)
	}
	emptyFence := journal.Session == strings.TrimSpace(session) &&
		journal.State == byoc.StateDestroyed &&
		strings.TrimSpace(journal.Fingerprint) == "" &&
		journal.Provenance == nil &&
		journal.Preflight == nil &&
		len(journal.Stages) == 0
	if emptyFence {
		return destroyRootConfigScopedShell, nil
	}
	return destroyRootInvalidShell, nil
}

func destroyContextHasAuthoritativeState(destroyCtx *v1alpha2DestroyContext) bool {
	if destroyCtx == nil {
		return false
	}
	return destroyCtx.State != nil || destroyCtx.Resolved != nil || destroyCtx.Selected != nil ||
		destroyCtx.AWSState != nil || destroyCtx.ReapState != nil ||
		destroyCtx.TiltfileExists || destroyCtx.TerraformStateOK ||
		destroyCtx.GCPTerraformStateOK || destroyCtx.GCPHostExists
}

func fileExistsForDestroy(path, label string) (bool, error) {
	state, err := classifyDestroyFileArtifact(path, label)
	return state == destroyArtifactFile, err
}

func classifyDestroyFileArtifact(path, label string) (destroyArtifactState, error) {
	if strings.TrimSpace(path) == "" {
		return destroyArtifactMissing, nil
	}
	info, err := os.Stat(path)
	if err != nil {
		if os.IsNotExist(err) {
			return destroyArtifactMissing, nil
		}
		return destroyArtifactMissing, fmt.Errorf("stat %s %q: %w", label, path, err)
	}
	if info.IsDir() {
		return destroyArtifactMissing, fmt.Errorf("%s %q must be a file, not a directory", label, path)
	}
	return destroyArtifactFile, nil
}

func joinDestroyErrors(errs []error) string {
	var parts []string
	for _, err := range errs {
		if err == nil {
			continue
		}
		parts = append(parts, err.Error())
	}
	return strings.Join(parts, "; ")
}

func statusCmd() model.NormalCmd {
	return func(args model.ArgVals, cc *model.Cli, env *model.Env, _ []model.ParsedCmd) error {
		syncVerbose(env)
		cfgPath := args.GetRaw("config")
		localPath, localProvided := explicitLocalPath(args)
		target, err := resolveStatusWatchTarget(cfgPath, localPath, localProvided)
		if err != nil {
			return err
		}
		if args.GetRaw("watch") == "true" {
			return outputStatusWatch(&target, statusWatchUntilInterrupt, cc, env)
		}
		return outputOneShotStatus(context.Background(), &target, cc, env)
	}
}

func enrichV1Alpha2Status(
	ctx context.Context,
	result *cluster.StatusResult,
	stateDir string,
	session *config.SessionConfig,
	state *cluster.State,
) {
	if result == nil {
		return
	}
	result.StateDir = stateDir
	if session != nil && session.Host.TTL.Duration > 0 {
		result.TTL = session.Host.TTL.String()
	}
	result.Artifacts = statusArtifactsFromState(stateDir, state)
	if config.DedicatedGCPEnabled(session) {
		gcpPaths := cpterraform.GCPWorkspacePaths(stateDir)
		gcpHostDir := host.GCPRegionalStateDir(stateDir)
		result.Artifacts.GCPTerraformPlan = gcpPaths.PlanPath
		result.Artifacts.GCPTerraformState = gcpPaths.StatePath
		result.Artifacts.GCPTerraformOutputs = gcpPaths.OutputsPath
		result.Artifacts.GCPRegionalHost = host.StatePath(gcpHostDir)
		result.Artifacts.GCPRegionalKubeconfig = filepath.Join(gcpHostDir, "kubeconfig.yaml")
	}
	result.Tilt = statusTiltFromState(ctx, stateDir, state)
	result.Layers = statusLayersFromState(ctx, stateDir, state, result)
	result.ResolveRuntimeStatus()
	result.Cost = &cluster.StatusCost{
		Status:  "unavailable",
		Message: "cost data is not available in this status result",
	}
}

func statusArtifactsFromState(stateDir string, state *cluster.State) *cluster.StatusArtifacts {
	if state == nil || state.Paths == nil {
		return &cluster.StatusArtifacts{
			ResolvedSession:      config.ResolvedSessionPath(stateDir),
			MetadbSeedReport:     cluster.MetadbSeedReportPath(stateDir),
			ContextDir:           cluster.ContextDir(stateDir),
			Tiltfile:             cluster.TiltfilePath(stateDir),
			RuntimePlan:          cptilt.RuntimePlanPath(stateDir),
			StarterRegionalSmoke: lifecycle.StarterRegionalSmokeEvidencePath(stateDir),
			DestroyDir:           cluster.DestroyDir(stateDir),
		}
	}
	starterRegionalSmoke := state.Paths.StarterRegionalSmoke
	if strings.TrimSpace(starterRegionalSmoke) == "" {
		starterRegionalSmoke = lifecycle.StarterRegionalSmokeEvidencePath(stateDir)
	}
	return &cluster.StatusArtifacts{
		ResolvedSession:      state.Paths.ResolvedSession,
		TerraformPlan:        state.Paths.TerraformPlan,
		MetadbSeedReport:     state.Paths.MetadbSeedReport,
		ContextDir:           state.Paths.ContextDir,
		Tiltfile:             state.Paths.Tiltfile,
		RuntimePlan:          cptilt.RuntimePlanPath(stateDir),
		StarterRegionalSmoke: starterRegionalSmoke,
		DestroyDir:           state.Paths.DestroyDir,
	}
}

func statusTiltFromState(ctx context.Context, stateDir string, state *cluster.State) *cluster.StatusTilt {
	status, err := cptilt.LoadStatusForState(stateDir, state)
	if err != nil {
		return &cluster.StatusTilt{
			Status:  "unavailable",
			Message: err.Error(),
		}
	}
	tiltStatus := "stopped"
	readyResources := 0
	totalResources := 0
	var notReadyResources []string
	var failedResources []string
	failureReasons := map[string]string{}
	readinessError := ""
	readinessRetryable := false
	selected, selectedErr := host.Load(stateDir)
	remoteController := status.Controller == cptilt.ControllerRemote ||
		(selectedErr == nil && cptilt.UsesRemoteController(selected))
	if remoteController {
		status.Controller = cptilt.ControllerRemote
	}
	if status.Running || remoteController {
		if status.Running {
			tiltStatus = "running"
		}
		readinessTimeout := statusTiltReadinessTimeout
		if remoteController {
			readinessTimeout = statusRemoteTiltReadinessTimeout
		}
		var readiness *cptilt.ResourceReadiness
		var readinessErr error
		switch {
		case status.Controller == cptilt.ControllerRemote && selectedErr != nil:
			tiltStatus = "unreachable"
			status.Running = false
			readinessErr = fmt.Errorf("load selected host for remote controller: %w", selectedErr)
		case remoteController:
			observationCtx, observationCancel := context.WithTimeout(ctx, readinessTimeout)
			observation, observeErr := cptilt.ObserveRemoteController(observationCtx, selected, status)
			readinessRetryable, observeErr = statusRemoteOperationError(
				observationCtx,
				"remote Tilt controller observation",
				readinessTimeout,
				observeErr,
			)
			observationCancel()
			switch {
			case observeErr != nil:
				tiltStatus = "unreachable"
				status.Running = false
				readinessErr = observeErr
			case !observation.Running():
				status.Running = false
				switch {
				case observation.ActiveState == "failed" || observation.Result == "failed":
					tiltStatus = "failed"
				default:
					tiltStatus = "stopped"
				}
				readinessErr = fmt.Errorf(
					"remote controller active=%s sub=%s result=%s",
					observation.ActiveState,
					observation.SubState,
					observation.Result,
				)
			default:
				tiltStatus = "running"
				status.Running = true
				status.PID = observation.PID
				resourceCtx, resourceCancel := context.WithTimeout(ctx, readinessTimeout)
				readiness, readinessErr = cptilt.ResourceReadinessForHost(resourceCtx, selected, status)
				readinessRetryable, readinessErr = statusRemoteOperationError(
					resourceCtx,
					"remote Tilt resource readiness",
					readinessTimeout,
					readinessErr,
				)
				resourceCancel()
			}
		default:
			readinessCtx, cancel := context.WithTimeout(ctx, readinessTimeout)
			readiness, readinessErr = statusTiltResourceReadiness(readinessCtx, status.APIHost, status.APIPort)
			cancel()
		}
		if readinessErr != nil {
			if tiltStatus == "running" {
				tiltStatus = "unavailable"
			}
			readinessError = strings.TrimSpace(readinessErr.Error())
		} else if readiness != nil {
			readyResources = readiness.ReadyResources
			totalResources = readiness.TotalResources
			notReadyResources = append([]string(nil), readiness.NotReadyResources...)
			for _, name := range readiness.NotReadyResources {
				resource := readiness.ResourceStates[name]
				if strings.EqualFold(resource.RuntimeStatus, "error") || strings.EqualFold(resource.UpdateStatus, "error") {
					failedResources = append(failedResources, name)
					failureReasons[name] = firstNonEmptyString(
						strings.TrimSpace(resource.Reason),
						strings.TrimSpace(resource.Message),
						strings.TrimSpace(resource.RuntimeStatus),
						strings.TrimSpace(resource.UpdateStatus),
						"unknown Tilt resource failure",
					)
				}
			}
		}
		if remoteController && status.Running && readinessErr == nil {
			syncCtx, syncCancel := context.WithTimeout(ctx, statusRemoteTiltReadinessTimeout)
			syncErr := cptilt.SyncRemoteControllerArtifacts(syncCtx, selected, status, stateDir)
			readinessRetryable, syncErr = statusRemoteOperationError(
				syncCtx,
				"remote Tilt artifact sync",
				statusRemoteTiltReadinessTimeout,
				syncErr,
			)
			syncCancel()
			if syncErr != nil {
				tiltStatus = "unavailable"
				readinessError = "remote controller artifact sync failed: " + syncErr.Error()
			}
		}
	}
	message := ""
	if status.Running {
		message = "Session Tiltfile owns the resource graph; lifecycle readiness does not prove Tilt/app resources are healthy"
	}
	if status.Running && readinessError == "" && totalResources > 0 && readyResources < totalResources {
		tiltStatus = "not_ready"
		message = fmt.Sprintf("Tilt resources ready %d/%d", readyResources, totalResources)
	}
	if len(failedResources) > 0 {
		tiltStatus = "failed"
		message = "Tilt resources failed: " + strings.Join(failedResources, ", ")
	}
	if readinessError != "" {
		if readinessRetryable {
			message = "Tilt status observation temporarily unavailable (retryable): " + readinessError
		} else {
			message = "Tilt resource readiness unavailable: " + readinessError
		}
	}
	var tunnels []cluster.StatusTunnel
	if remoteController {
		for _, supervisor := range cptilt.ObserveTunnelSupervisors(ctx, stateDir) {
			tunnels = append(tunnels, cluster.StatusTunnel{
				Role:           supervisor.Role,
				PID:            supervisor.PID,
				State:          supervisor.State,
				Attempts:       supervisor.Attempts,
				LastFailure:    supervisor.LastFailure,
				ActionRequired: supervisor.ActionRequired,
			})
		}
	}
	return &cluster.StatusTilt{
		RuntimeID:         status.RuntimeID,
		Status:            tiltStatus,
		Mode:              status.Mode,
		Controller:        firstNonEmptyString(status.Controller, cptilt.ControllerLocal),
		Running:           status.Running,
		Endpoint:          fmt.Sprintf("%s:%d", status.APIHost, status.APIPort),
		URL:               fmt.Sprintf("http://%s:%d", status.APIHost, status.APIPort),
		LogPath:           status.LogPath,
		ReadyResources:    readyResources,
		TotalResources:    totalResources,
		NotReadyResources: notReadyResources,
		FailedResources:   failedResources,
		ReadinessError:    readinessError,
		Retryable:         readinessRetryable,
		Tunnels:           tunnels,
		Message:           message,
		FailureReasons:    failureReasons,
	}
}

func statusRemoteOperationError(
	operationCtx context.Context,
	operation string,
	timeout time.Duration,
	err error,
) (bool, error) {
	if err == nil || !errors.Is(operationCtx.Err(), context.DeadlineExceeded) {
		return false, err
	}
	return true, fmt.Errorf("%s timed out after %s: %w", operation, timeout, err)
}

func statusLayersFromState(ctx context.Context, stateDir string, state *cluster.State, result *cluster.StatusResult) *cluster.StatusLayers {
	layers := &cluster.StatusLayers{
		Lifecycle: statusLifecycleLayer(state, result),
		Host: cluster.StatusHostLayer{
			Status: "configured",
		},
		ImageSource: statusImageSourceLayer(stateDir, state),
		ProductRoute: &cluster.StatusProductRouteLayer{
			Status:  proof.StatusNotCollected,
			Message: "no proof summary found for this session",
		},
		Proof: &cluster.StatusProofLayer{
			Status:  proof.StatusNotCollected,
			Message: "no proof summary found for this session",
		},
		Cleanup: &cluster.StatusCleanupLayer{
			Status:  proof.StatusNotCollected,
			Message: "no proof or direct-zero cleanup summary found for this session",
		},
	}
	if state != nil {
		layers.Host.Mode = string(state.HostMode)
		if strings.TrimSpace(layers.Host.Mode) == "" && result != nil {
			layers.Host.Mode = result.HostMode
		}
	}
	if strings.TrimSpace(layers.Host.Mode) == "" && result != nil {
		layers.Host.Mode = result.HostMode
	}
	if strings.TrimSpace(layers.Host.Mode) == "" {
		layers.Host.Status = "unknown"
	}
	var selectedHost *host.Session
	if selected, err := host.Load(stateDir); err == nil {
		selectedHost = selected
		layers.Host.Execution = selected.ExecutionFacts()
		if strings.TrimSpace(layers.Host.Mode) == "" {
			layers.Host.Mode = string(selected.Mode)
		}
	}
	if result != nil && result.Tilt != nil {
		layers.Tilt = result.Tilt
	}
	layers.UI = statusUIFromState(ctx, stateDir, state, selectedHost)
	layers.Starter = statusStarterFromState(stateDir, state)
	layers.O11Y = statusO11YFromState(stateDir, state)
	if resolved, err := loadResolvedSessionForStatus(stateDir, state); err == nil {
		layers.DedicatedGCP = statusDedicatedGCPFromConfig(ctx, stateDir, state, resolved)
	}

	if summary, summaryPath := latestStatusProofSummary(stateDir, state); summary != nil {
		layers.ProductRoute = statusProductRouteLayer(summary)
		layers.Proof = statusProofLayer(summary, summaryPath)
		layers.Cleanup = statusCleanupLayerFromProof(summary)
	}
	if direct, directPath := latestStatusDirectZeroSummary(state); direct != nil {
		mergeStatusDirectZeroCleanup(layers.Cleanup, direct, directPath)
	}
	return layers
}

func statusDedicatedGCPFromConfig(
	ctx context.Context,
	stateDir string,
	state *cluster.State,
	session *config.SessionConfig,
) *cluster.StatusDedicatedGCPLayer {
	if !config.DedicatedGCPEnabled(session) {
		return nil
	}
	layer := &cluster.StatusDedicatedGCPLayer{
		Status:           "configured",
		DedicatedGCPPlan: dedicatedGCPPlanFromConfig(stateDir, session),
		Message:          "no GCP Terraform state or regional host has been applied",
	}
	paths := cpterraform.GCPWorkspacePaths(stateDir)
	_, terraformErr := os.Stat(paths.StatePath)
	selected, hostErr := host.Load(host.GCPRegionalStateDir(stateDir))
	switch {
	case terraformErr == nil && hostErr == nil && selected.Remote != nil && selected.GCP != nil:
		layer.Status = "host_ready"
		layer.Message = "GCE+k3d and embedded Dedicated API are persisted; GCP runtime/controller is unconfigured"
		layer.InstanceName = selected.Remote.InstanceName
		layer.PublicIP = selected.Remote.PublicIP
		layer.ServiceAccountEmail = selected.GCP.ServiceAccountEmail
		layer.KubeconfigPath = selected.KubeconfigPath
		layer.Execution = selected.ExecutionFacts()
		if selected.DedicatedAPI != nil {
			layer.DedicatedAPI = selected.DedicatedAPI.LocalEndpoint
		}
		gcpStateDir := host.GCPRegionalStateDir(stateDir)
		if _, err := os.Stat(cptilt.PathsForState(gcpStateDir, nil).Status); err == nil {
			layer.Tilt = statusTiltFromState(ctx, gcpStateDir, nil)
			layer.Tilt.URL = ""
			layer.Tilt.Endpoint = ""
			switch {
			case layer.Tilt.Status == "running" &&
				(layer.Tilt.TotalResources == 0 || layer.Tilt.ReadyResources == layer.Tilt.TotalResources):
				layer.Status = "runtime_ready"
				layer.Message = "GCE host and GCP remote Tilt runtime are ready"
			case layer.Tilt.Status == "running" || layer.Tilt.Status == "not_ready":
				layer.Status = "runtime_initializing"
				layer.Message = layer.Tilt.Message
			default:
				layer.Status = "runtime_failed"
				layer.Message = firstNonEmptyString(layer.Tilt.Message, "GCP remote Tilt runtime is unavailable")
			}
		} else if !os.IsNotExist(err) {
			layer.Status = "error"
			layer.Message = "inspect GCP Tilt status: " + err.Error()
		}
	case terraformErr == nil && hostErr != nil:
		if os.IsNotExist(hostErr) && gcpRegionalHostCreateInProgress(state) {
			layer.Message = "GCP regional host create is in progress; Terraform state exists before the host contract is persisted"
			break
		}
		layer.Status = "error"
		layer.Message = "GCP Terraform state exists but the regional host contract is unavailable: " + hostErr.Error()
	case terraformErr != nil && !os.IsNotExist(terraformErr):
		layer.Status = "error"
		layer.Message = "inspect GCP Terraform state: " + terraformErr.Error()
	}
	return layer
}

func gcpRegionalHostCreateInProgress(state *cluster.State) bool {
	if state == nil {
		return false
	}
	step := state.Steps[cluster.StepHostReady]
	if step == nil {
		return false
	}
	switch step.Status {
	case cluster.StatusPending, cluster.StatusRunning:
		return true
	default:
		return false
	}
}

func dedicatedGCPPlanFromConfig(stateDir string, session *config.SessionConfig) cluster.DedicatedGCPPlan {
	gcp := session.Dedicated.GCP
	paths := cpterraform.GCPWorkspacePaths(stateDir)
	return cluster.DedicatedGCPPlan{
		SeedProjectID:      gcp.SeedProjectID,
		DataplaneProjectID: gcp.DataplaneProjectID,
		Region:             gcp.Region,
		Zone:               gcp.Zone,
		MachineType:        config.DedicatedGCPRuntimeMachineType,
		ModulePath:         cpterraform.DedicatedGCPModulePath(session.Terraform.Repo),
		WorkDir:            paths.RootDir,
		PlanPath:           paths.PlanPath,
	}
}

func statusUIFromState(ctx context.Context, stateDir string, state *cluster.State, selected *host.Session) *cluster.StatusUILayer {
	personaName := ""
	hostMode := config.HostMode("")
	if state != nil {
		personaName = strings.TrimSpace(state.Persona)
		hostMode = state.HostMode
	}
	if selected != nil {
		hostMode = selected.Mode
	}
	if personaName != "dev-full" || hostMode != config.HostModeRemoteEC2K3d {
		return nil
	}
	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	layer := &cluster.StatusUILayer{
		Status:            "not_collected",
		LoginMethod:       "Auth0",
		LoginHelp:         "https://github.com/tidbcloud/controlplane9/wiki",
		BrowserAcceptance: "unverified",
		BrowserMessage:    "real Auth0 callback and authenticated browser shell have not been accepted in this status check",
		EvidencePath:      dbaasui.ReadinessPath(stateDir),
		BrowserEvidence:   dbaasui.BrowserAcceptancePath(stateDir),
		Message:           "dbaas-ui readiness evidence has not been collected",
	}
	defer applyStatusUIBrowserAcceptance(layer, stateDir)
	if selected != nil && selected.Remote != nil {
		layer.URL = strings.TrimSpace(selected.Remote.PublicHTTPSURL)
		layer.APIURL = layer.URL
	}
	resolved, configErr := loadResolvedSessionForStatus(stateDir, state)
	if configErr == nil {
		layer.LoginEmail = strings.TrimSpace(resolved.UI.SeedIdentity.Email)
	}
	if selected == nil || selected.Remote == nil {
		layer.Status = "unavailable"
		layer.UIReady = false
		layer.Message = "selected Remote host is unavailable for live UI checks"
		return layer
	}

	if configErr != nil {
		layer.UIStaticReady = false
		layer.UIAuthReady = false
		layer.UIIdentityReady = false
		layer.StaticMessage = "resolved session UI config is unavailable"
		layer.AuthMessage = "resolved session UI config is unavailable"
		layer.IdentityMessage = "resolved session UI config is unavailable"
		layer.Status = "not_ready"
		layer.Message = "session-owned UI static delivery is not ready"
		return layer
	}

	expectedRuntimeConfig, renderErr := dbaasui.RenderRuntimeConfig(lifecycle.SessionUIO11YRuntimeConfig(resolved.UI.O11Y))
	if renderErr != nil {
		layer.UIStaticReady = false
		layer.StaticMessage = "resolved dbaas-ui runtime config is invalid"
		layer.Status = "not_ready"
		layer.Message = "session-owned UI static delivery is not ready"
		return layer
	}
	runUI := func(ctx context.Context, input []byte, args ...string) ([]byte, error) {
		return host.RunKubectl(ctx, selected, input, args...)
	}
	evidence, _ := dbaasui.InspectReadiness(ctx, &dbaasui.ReadinessInspection{
		URL:                   layer.URL,
		ExpectedRuntimeConfig: expectedRuntimeConfig,
		SeedIdentity:          resolved.UI.SeedIdentity.Email,
		Run:                   runUI,
		Auth0HTTPClient:       verbose.NewHTTPClient(30 * time.Second),
		Auth0ReadToken:        strings.TrimSpace(os.Getenv("CP9_AUTH0_READ_TOKEN")),
		InspectDeployment: func(ctx context.Context) (string, error) {
			deployment, err := runUI(ctx, nil, "-n", "cp9-global", "get", "deployment", "dbaas-ui", "-o", "json")
			if err != nil {
				return "", err
			}
			return inspectStatusUIDeployment(deployment)
		},
		InspectIdentity: func(ctx context.Context, email string, run dbaasui.CommandRunner) dbaasui.Dimension {
			return dbaasui.CheckSeedIdentityForSession(
				ctx,
				email,
				resolved.EssentialV2 != nil && resolved.EssentialV2.Enabled,
				run,
			)
		},
	})
	applyStatusUIEvidence(layer, evidence)
	return layer
}

func statusStarterFromState(stateDir string, state *cluster.State) *cluster.StatusStarterLayer {
	if state == nil || strings.TrimSpace(state.Persona) != "dev-full" {
		return nil
	}
	remote, err := loadStarterRemoteEKSArtifact(stateDir)
	if err != nil {
		return nil
	}
	kubeconfig := filepath.Join(stateDir, "remote-eks", "kubeconfig.yaml")
	if _, err := os.Stat(kubeconfig); err != nil {
		return nil
	}
	contextName := firstNonEmptyString(
		strings.TrimSpace(remote.KubeconfigContext),
		strings.TrimSpace(remote.ClusterName),
	)
	profile := ""
	if resolved, err := loadResolvedSessionForStatus(stateDir, state); err == nil {
		profile = strings.TrimSpace(resolved.Target.AWS.IAM.Profile)
	}
	return &cluster.StatusStarterLayer{
		Status:     "configured",
		Cluster:    strings.TrimSpace(remote.ClusterName),
		Region:     strings.TrimSpace(remote.Region),
		Profile:    profile,
		Kubeconfig: kubeconfig,
		Context:    contextName,
		Message:    "kubeconfig access depends on the configured AWS profile and EKS authorization",
	}
}

func applyStatusUIEvidence(layer *cluster.StatusUILayer, evidence *dbaasui.ReadinessEvidence) {
	if layer == nil || evidence == nil {
		return
	}
	layer.Status = evidence.Status
	layer.URL = firstNonEmptyString(strings.TrimSpace(evidence.URL), layer.URL)
	layer.APIURL = layer.URL
	layer.Image = evidence.Image
	layer.UIStaticReady = evidence.UIStaticReady
	layer.UIAuthReady = evidence.UIAuthReady
	layer.UIIdentityReady = evidence.UIIdentityReady
	layer.SessionOwnedReady = evidence.SessionOwnedReady
	layer.UIReady = evidence.UIReady
	layer.StaticMessage = evidence.StaticMessage
	layer.AuthMessage = evidence.AuthMessage
	layer.IdentityMessage = evidence.IdentityMessage
	switch {
	case layer.UIReady:
		layer.Message = "authenticated browser shell prerequisites are ready; browser acceptance remains separate"
	case layer.SessionOwnedReady:
		layer.Message = "session-owned UI is ready; shared Auth0 or seed identity is degraded"
	default:
		layer.Message = "session-owned UI static delivery is not ready"
	}
}

func applyStatusUIBrowserAcceptance(layer *cluster.StatusUILayer, stateDir string) {
	if layer == nil {
		return
	}
	evidence, err := dbaasui.LoadBrowserAcceptance(stateDir)
	if err != nil {
		if !dbaasui.IsBrowserAcceptanceMissing(err) {
			layer.BrowserMessage = "stored browser acceptance evidence is invalid: " + err.Error()
		}
		return
	}
	if strings.TrimSuffix(layer.URL, "/") != evidence.Origin {
		layer.BrowserMessage = "stored browser acceptance belongs to a different Session origin"
		return
	}
	layer.BrowserAcceptance = evidence.Outcome
	layer.BrowserStage = evidence.Stage
	layer.BrowserPath = evidence.Path
	checkedAt := evidence.CheckedAt.UTC()
	layer.BrowserCheckedAt = &checkedAt
	layer.BrowserMessage = evidence.Message()
}

func inspectStatusUIDeployment(data []byte) (string, error) {
	var deployment appsv1.Deployment
	if err := json.Unmarshal(data, &deployment); err != nil {
		return "", fmt.Errorf("decode live dbaas-ui Deployment: %w", err)
	}
	desired := int32(1)
	if deployment.Spec.Replicas != nil {
		desired = *deployment.Spec.Replicas
	}
	if desired < 1 {
		return "", errors.New("live dbaas-ui Deployment must request at least one replica")
	}
	image := ""
	for index := range deployment.Spec.Template.Spec.Containers {
		container := &deployment.Spec.Template.Spec.Containers[index]
		if container.Name == "dbaas-ui" {
			image = strings.TrimSpace(container.Image)
			break
		}
	}
	if deployment.Status.ObservedGeneration < deployment.Generation ||
		deployment.Status.UpdatedReplicas != desired ||
		deployment.Status.AvailableReplicas != desired {
		return image, errors.New("live dbaas-ui Deployment rollout is incomplete")
	}
	if err := dbaasui.ValidateImageReference(image); err != nil {
		return image, err
	}
	return image, nil
}

func loadResolvedSessionForStatus(stateDir string, state *cluster.State) (*config.SessionConfig, error) {
	path := config.ResolvedSessionPath(stateDir)
	if state != nil && state.Paths != nil && strings.TrimSpace(state.Paths.ResolvedSession) != "" {
		path = state.Paths.ResolvedSession
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var session config.SessionConfig
	if err := yaml.Unmarshal(data, &session); err != nil {
		return nil, err
	}
	return &session, nil
}

func statusImageSourceLayer(stateDir string, state *cluster.State) *cluster.StatusImageSourceLayer {
	path := proof.ImageSourceSummaryPath(stateDir, state)
	layer := &cluster.StatusImageSourceLayer{
		Status:      "unknown",
		SummaryPath: path,
	}
	imageSource, err := proof.LoadImageSourceSummary(path)
	if err != nil {
		layer.Message = err.Error()
		return layer
	}
	layer.Status = firstNonEmptyString(imageSource.Status, proof.StatusPassed)
	layer.Mode = strings.TrimSpace(imageSource.Mode)
	layer.SharedBaseServices = append([]string(nil), imageSource.SharedBaseServices...)
	layer.PremiumServices = append([]string(nil), imageSource.PremiumServices...)
	layer.StarterServices = append([]string(nil), imageSource.StarterServices...)
	layer.DedicatedServices = append([]string(nil), imageSource.DedicatedServices...)
	layer.SourceBuildServices = append([]string(nil), imageSource.SourceBuildServices...)
	layer.StagingImageServices = append([]string(nil), imageSource.StagingImageServices...)
	layer.AlwaysLocalStubServices = append([]string(nil), imageSource.AlwaysLocalStubServices...)
	layer.GatedServices = append([]string(nil), imageSource.GatedServices...)
	layer.ServiceClassifications = cloneStatusImageSourceClassifications(imageSource.ServiceClassifications)
	layer.Images = cloneStatusStringMap(imageSource.Images)
	layer.SummaryPath = firstNonEmptyString(strings.TrimSpace(imageSource.ArtifactPath), path)
	return layer
}

func cloneStatusImageSourceClassifications(in map[string]proof.ImageSourceClassification) map[string]cluster.ImageSourceClassification {
	if len(in) == 0 {
		return nil
	}
	out := make(map[string]cluster.ImageSourceClassification, len(in))
	for service := range in {
		classification := in[service]
		out[service] = cluster.ImageSourceClassification{
			Class:         classification.Class,
			Mode:          classification.Mode,
			SelectedImage: classification.SelectedImage,
			StagingImage:  classification.StagingImage,
			SourceRepo:    classification.SourceRepo,
			SourceMain:    classification.SourceMain,
			SourceMode:    classification.SourceMode,
			BackendKind:   classification.BackendKind,
			BackendScope:  classification.BackendScope,
			SupportStatus: classification.SupportStatus,
			Evidence:      classification.Evidence,
			Reason:        classification.Reason,
		}
	}
	return out
}

func cloneStatusStringMap(in map[string]string) map[string]string {
	if len(in) == 0 {
		return nil
	}
	out := make(map[string]string, len(in))
	maps.Copy(out, in)
	return out
}

func statusLifecycleLayer(state *cluster.State, result *cluster.StatusResult) cluster.StatusLifecycleLayer {
	layer := cluster.StatusLifecycleLayer{Status: "unknown"}
	if result != nil {
		layer.State = result.Lifecycle
		layer.ResumeAt = result.ResumeAt
		switch {
		case result.LifecycleComplete:
			layer.Status = "ready"
		case result.ResumeAt != "":
			layer.Status = "incomplete"
		}
	}
	if state == nil {
		return layer
	}
	for _, name := range cluster.StepsForLifecycle(state.Lifecycle) {
		step := state.Steps[name]
		if step == nil {
			continue
		}
		switch step.Status {
		case cluster.StatusFailed:
			layer.Status = "failed"
			layer.State = name
			layer.ResumeAt = name
			layer.Message = step.Error
			return layer
		case cluster.StatusRunning:
			layer.Status = "running"
			layer.State = name
			layer.ResumeAt = name
			return layer
		}
	}
	return layer
}

func statusProductRouteLayer(summary *proof.Summary) *cluster.StatusProductRouteLayer {
	sourceTiDBIDs := statusCreatedTiDBResourceIDs(summary, "source")
	restoredTiDBIDs := statusCreatedTiDBResourceIDs(summary, "restored")
	if summary == nil || summary.ProductRoute == nil {
		return &cluster.StatusProductRouteLayer{
			Status:          proof.StatusNotCollected,
			SourceTiDBIDs:   sourceTiDBIDs,
			RestoredTiDBIDs: restoredTiDBIDs,
		}
	}
	route := summary.ProductRoute
	layer := &cluster.StatusProductRouteLayer{
		Status:             firstNonEmptyString(route.Status, proof.StatusNotCollected),
		PublicOpenAPIRoute: strings.TrimSpace(route.PublicRoute),
		KongRoute:          strings.TrimSpace(route.KongRoute),
		BackendRoute:       strings.TrimSpace(route.BackendRoute),
		SQLEndpoint:        strings.TrimSpace(route.Endpoint),
		SourceTiDBIDs:      sourceTiDBIDs,
		RestoredTiDBIDs:    restoredTiDBIDs,
	}
	if route.SQL != nil {
		layer.SQL = &cluster.StatusSQLReadiness{
			Ready:  strings.TrimSpace(route.SQL.Result) != "",
			Path:   strings.TrimSpace(route.SQL.Path),
			Result: strings.TrimSpace(route.SQL.Result),
		}
	}
	return layer
}

func statusCreatedTiDBResourceIDs(summary *proof.Summary, kind string) []string {
	if summary == nil {
		return nil
	}
	kind = strings.TrimSpace(kind)
	if kind == "" {
		return nil
	}
	out := make([]string, 0)
	seen := make(map[string]struct{})
	for i := range summary.Trains {
		for _, raw := range summary.Trains[i].CreatedResourceIDs {
			id := strings.TrimSpace(raw)
			if id == "" || !statusCreatedTiDBResourceIDMatchesKind(id, kind) {
				continue
			}
			if _, ok := seen[id]; ok {
				continue
			}
			seen[id] = struct{}{}
			out = append(out, id)
		}
	}
	return out
}

func statusCreatedTiDBResourceIDMatchesKind(id, kind string) bool {
	prefix := "tidb:" + kind
	return id == prefix ||
		strings.HasPrefix(id, prefix+":") ||
		strings.HasPrefix(id, prefix+"-")
}

func statusProofLayer(summary *proof.Summary, summaryPath string) *cluster.StatusProofLayer {
	if summary == nil {
		return &cluster.StatusProofLayer{Status: proof.StatusNotCollected}
	}
	reportPath := strings.TrimSpace(summary.ArtifactPaths.Markdown)
	if reportPath == "" && strings.TrimSpace(summaryPath) != "" {
		reportPath = filepath.Join(filepath.Dir(summaryPath), "proof-summary.md")
	}
	return &cluster.StatusProofLayer{
		Status:           firstNonEmptyString(summary.Status, proof.StatusNotCollected),
		FailurePhase:     strings.TrimSpace(summary.FailurePhase),
		LastFailureClass: statusLastFailureClass(summary),
		SummaryPath:      firstNonEmptyString(strings.TrimSpace(summary.ArtifactPaths.JSON), summaryPath),
		ReportPath:       reportPath,
		CompletedAt:      summary.CompletedAt,
	}
}

func statusCleanupLayerFromProof(summary *proof.Summary) *cluster.StatusCleanupLayer {
	if summary == nil || summary.Cleanup == nil {
		return &cluster.StatusCleanupLayer{Status: proof.StatusNotCollected}
	}
	cleanup := summary.Cleanup
	return &cluster.StatusCleanupLayer{
		Status:           firstNonEmptyString(cleanup.Status, proof.StatusNotCollected),
		DirectZero:       cleanup.DirectZero,
		WorkflowURL:      strings.TrimSpace(cleanup.WorkflowURL),
		WorkflowResult:   strings.TrimSpace(cleanup.WorkflowResult),
		FailurePhase:     strings.TrimSpace(cleanup.FailurePhase),
		FallbackResult:   strings.TrimSpace(cleanup.FallbackResult),
		FinalVerdict:     strings.TrimSpace(cleanup.FinalSurvivorVerdict),
		ArtifactPath:     strings.TrimSpace(cleanup.ArtifactPath),
		StaleTagWarnings: append([]string(nil), cleanup.StaleTagWarnings...),
	}
}

func mergeStatusDirectZeroCleanup(layer *cluster.StatusCleanupLayer, summary *destroyDirectZeroSummary, path string) {
	if summary == nil {
		return
	}
	if layer == nil {
		return
	}
	layer.DirectZero = summary.DirectZero
	layer.FinalVerdict = strings.TrimSpace(summary.FinalVerdict)
	layer.FinalSurvivors = summary.FinalSurvivors
	layer.ArtifactPath = firstNonEmptyString(layer.ArtifactPath, path)
	layer.StaleTagWarnings = append(layer.StaleTagWarnings, statusStaleTagWarnings(summary.StaleTagWarnings)...)
	switch {
	case summary.DirectZero:
		layer.Status = proof.StatusPassed
	case summary.FinalSurvivors > 0:
		layer.Status = "survivors"
	case strings.TrimSpace(summary.Status) != "":
		layer.Status = strings.TrimSpace(summary.Status)
	}
	layer.Message = ""
}

func statusLastFailureClass(summary *proof.Summary) string {
	if summary == nil {
		return ""
	}
	if strings.TrimSpace(summary.FailurePhase) == "train" {
		if text := statusLastTrainFailureClass(summary); text != "" {
			return text
		}
	}
	for index := range slices.Backward(summary.Stages) {
		if text := strings.TrimSpace(summary.Stages[index].FailureClass); text != "" {
			return text
		}
	}
	return statusLastTrainFailureClass(summary)
}

func statusLastTrainFailureClass(summary *proof.Summary) string {
	if summary == nil {
		return ""
	}
	for index := range slices.Backward(summary.Trains) {
		train := &summary.Trains[index]
		for _, request := range slices.Backward(train.FailedRequests) {
			if text := strings.TrimSpace(request.FailureClass); text != "" {
				return text
			}
		}
	}
	return ""
}

func latestStatusProofSummary(stateDir string, state *cluster.State) (*proof.Summary, string) {
	var best *proof.Summary
	bestPath := ""
	bestMod := time.Time{}
	for _, path := range statusProofSummaryCandidates(stateDir) {
		summary, modTime, err := readStatusProofSummary(path)
		if err != nil || !statusProofSummaryMatches(summary, state) {
			continue
		}
		if best == nil || statusProofSummaryNewer(summary, modTime, best, bestMod) {
			best = summary
			bestPath = path
			bestMod = modTime
		}
	}
	return best, bestPath
}

func statusProofSummaryCandidates(stateDir string) []string {
	seen := map[string]bool{}
	var out []string
	add := func(path string) {
		path = filepath.Clean(strings.TrimSpace(path))
		if path == "." || seen[path] {
			return
		}
		seen[path] = true
		out = append(out, path)
	}
	add(filepath.Join(stateDir, "proof", "proof-summary.json"))
	for _, root := range []string{
		filepath.Join("artifacts", "proof"),
		filepath.Join("artifacts", "premium-openapi"),
	} {
		if _, err := os.Stat(root); err != nil {
			continue
		}
		_ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
			if err != nil || entry == nil || entry.IsDir() {
				return nil
			}
			if entry.Name() == "proof-summary.json" {
				add(path)
			}
			return nil
		})
	}
	return out
}

func readStatusProofSummary(path string) (*proof.Summary, time.Time, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, time.Time{}, err
	}
	var summary proof.Summary
	if err := json.Unmarshal(data, &summary); err != nil {
		return nil, time.Time{}, err
	}
	info, _ := os.Stat(path)
	modTime := time.Time{}
	if info != nil {
		modTime = info.ModTime()
	}
	return &summary, modTime, nil
}

func statusProofSummaryMatches(summary *proof.Summary, state *cluster.State) bool {
	if summary == nil || state == nil {
		return false
	}
	sessionName := firstNonEmptyString(state.SessionName, state.ClusterName, state.SessionID)
	if sessionName == "" {
		return false
	}
	return strings.TrimSpace(summary.Session.Name) == sessionName
}

func statusProofSummaryNewer(candidate *proof.Summary, candidateMod time.Time, current *proof.Summary, currentMod time.Time) bool {
	candidateAt := candidate.CompletedAt
	currentAt := current.CompletedAt
	if !candidateAt.IsZero() || !currentAt.IsZero() {
		return candidateAt.After(currentAt)
	}
	return candidateMod.After(currentMod)
}

func latestStatusDirectZeroSummary(state *cluster.State) (*destroyDirectZeroSummary, string) {
	var best *destroyDirectZeroSummary
	bestPath := ""
	bestMod := time.Time{}
	for _, root := range destroyDirectZeroSummaryRoots() {
		entries, err := os.ReadDir(root)
		if err != nil {
			continue
		}
		for _, entry := range entries {
			if entry == nil || entry.IsDir() || !strings.HasSuffix(entry.Name(), "-direct-zero-summary.json") {
				continue
			}
			path := filepath.Join(root, entry.Name())
			summary, modTime, err := readStatusDirectZeroSummary(path)
			if err != nil || !statusDirectZeroSummaryMatches(summary, state) {
				continue
			}
			if best == nil || modTime.After(bestMod) {
				best = summary
				bestPath = path
				bestMod = modTime
			}
		}
	}
	return best, bestPath
}

func readStatusDirectZeroSummary(path string) (*destroyDirectZeroSummary, time.Time, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, time.Time{}, err
	}
	var summary destroyDirectZeroSummary
	if err := json.Unmarshal(data, &summary); err != nil {
		return nil, time.Time{}, err
	}
	info, _ := os.Stat(path)
	modTime := time.Time{}
	if info != nil {
		modTime = info.ModTime()
	}
	return &summary, modTime, nil
}

func statusDirectZeroSummaryMatches(summary *destroyDirectZeroSummary, state *cluster.State) bool {
	if summary == nil || state == nil {
		return false
	}
	sessionName := strings.TrimSpace(summary.Scope.SessionID)
	if sessionName == "" {
		return false
	}
	for _, candidate := range []string{state.SessionName, state.ClusterName, state.SessionID} {
		if strings.TrimSpace(candidate) == sessionName {
			return true
		}
	}
	return false
}

func statusStaleTagWarnings(warnings []destroyStaleTagWarning) []string {
	out := make([]string, 0, len(warnings))
	for i := range warnings {
		warning := warnings[i]
		text := strings.TrimSpace(warning.Message)
		if text == "" {
			text = strings.TrimSpace(warning.ARN)
		}
		if warning.Type != "" {
			text = strings.TrimSpace(warning.Type + ": " + text)
		}
		if text != "" {
			out = append(out, text)
		}
	}
	return out
}

type noClusterResult struct {
	NoState     bool                  `json:"no_state"`
	SessionName string                `json:"session_name,omitempty"`
	Persona     string                `json:"persona,omitempty"`
	Owner       string                `json:"owner,omitempty"`
	TTL         string                `json:"ttl,omitempty"`
	StateDir    string                `json:"state_dir,omitempty"`
	HostMode    string                `json:"host_mode,omitempty"`
	Layers      *cluster.StatusLayers `json:"layers,omitempty"`
}

func (r *noClusterResult) FormatText() string {
	var b strings.Builder
	b.WriteString("no cluster state found — run `cp9 up` to create a cluster")
	if r.SessionName != "" {
		fmt.Fprintf(&b, "\nSession:  %s", r.SessionName)
	}
	if r.Persona != "" {
		fmt.Fprintf(&b, "\nPersona:  %s", r.Persona)
	}
	if r.Owner != "" {
		fmt.Fprintf(&b, "\nOwner:    %s", r.Owner)
	}
	if r.TTL != "" {
		fmt.Fprintf(&b, "\nTTL:      %s", r.TTL)
	}
	if r.StateDir != "" {
		fmt.Fprintf(&b, "\nStateDir: %s", r.StateDir)
	}
	if r.HostMode != "" {
		fmt.Fprintf(&b, "\nHostMode: %s", r.HostMode)
	}
	if r.Layers != nil {
		fmt.Fprintf(&b, "\nLayers:\n  Lifecycle: %s", r.Layers.Lifecycle.Status)
		if r.Layers.Host.Mode != "" {
			fmt.Fprintf(&b, "\n  Host:      %s", r.Layers.Host.Mode)
		}
		if r.Layers.DedicatedGCP != nil {
			fmt.Fprintf(&b, "\n  DedicatedGCP: %s project=%s dataplane_project=%s location=%s/%s plan=%s",
				r.Layers.DedicatedGCP.Status,
				r.Layers.DedicatedGCP.SeedProjectID,
				r.Layers.DedicatedGCP.DataplaneProjectID,
				r.Layers.DedicatedGCP.Region,
				r.Layers.DedicatedGCP.Zone,
				r.Layers.DedicatedGCP.PlanPath,
			)
		}
		if r.Layers.ImageSource != nil {
			fmt.Fprintf(&b, "\n  ImageSource: %s", r.Layers.ImageSource.Status)
			if r.Layers.ImageSource.Mode != "" {
				fmt.Fprintf(&b, " mode=%s", r.Layers.ImageSource.Mode)
			}
		}
		if r.Layers.ProductRoute != nil {
			fmt.Fprintf(&b, "\n  Product:  %s", r.Layers.ProductRoute.Status)
		}
		if r.Layers.Proof != nil {
			fmt.Fprintf(&b, "\n  Proof:    %s", r.Layers.Proof.Status)
		}
		if r.Layers.Cleanup != nil {
			fmt.Fprintf(&b, "\n  Cleanup:  %s direct_zero=%t", r.Layers.Cleanup.Status, r.Layers.Cleanup.DirectZero)
		}
	}
	return b.String()
}

func buildV1Alpha2NoClusterResult(ctx context.Context, stateDir string, session *config.SessionConfig) *noClusterResult {
	result := &noClusterResult{
		NoState:  true,
		StateDir: stateDir,
		Layers: &cluster.StatusLayers{
			Lifecycle: cluster.StatusLifecycleLayer{
				Status:  "no_state",
				Message: "no cluster state found",
			},
			Host: cluster.StatusHostLayer{
				Status: "unknown",
			},
			ImageSource: &cluster.StatusImageSourceLayer{
				Status:      "unknown",
				SummaryPath: proof.ImageSourceSummaryPath(stateDir, nil),
				Message:     "image source summary not collected because no cluster state was found",
			},
			ProductRoute: &cluster.StatusProductRouteLayer{Status: proof.StatusNotCollected},
			Proof:        &cluster.StatusProofLayer{Status: proof.StatusNotCollected},
			Cleanup:      &cluster.StatusCleanupLayer{Status: proof.StatusNotCollected},
		},
	}
	if session == nil {
		return result
	}
	result.SessionName = session.Name
	result.Persona = config.SessionPersona(session)
	result.TTL = upDryRunTTL(session)
	result.HostMode = string(session.Host.Mode)
	result.Layers.Host.Mode = result.HostMode
	result.Layers.Host.Status = "configured"
	result.Layers.DedicatedGCP = statusDedicatedGCPFromConfig(ctx, stateDir, nil, session)
	return result
}

// buildCRDStep constructs the CRD install step from config.
func buildCRDStep(cfg *config.Config, selected *host.Session) *crd.InstallStep {
	services := crdServices(cfg)
	namespace := cfg.Name
	if namespace == "" {
		namespace = "default"
	}
	return &crd.InstallStep{
		StepName:          cluster.StepCRDInstall,
		Services:          services,
		CRDMappings:       cfg.CRDs.Mappings,
		Namespace:         namespace,
		SeedConfig:        cfg.CRDs.Seed,
		Selected:          selected,
		StateDir:          selected.StateDir,
		StagingKubeconfig: cfg.CRDs.StagingAccess.Kubeconfig,
		StagingContext:    cfg.CRDs.StagingAccess.Context,
		LocalKubeconfig:   selected.KubeconfigPath,
		LocalContext:      selected.Context,
	}
}

func buildSecretsStep(local *config.LocalConfig, selected *host.Session) *crd.InstallStep {
	var secrets map[string]config.SecretSource
	if local != nil {
		secrets = local.Secrets
	}
	return &crd.InstallStep{
		StepName:        cluster.StepSecretsInject,
		Secrets:         secrets,
		Namespace:       selected.Namespace,
		Selected:        selected,
		StateDir:        selected.StateDir,
		LocalKubeconfig: selected.KubeconfigPath,
		LocalContext:    selected.Context,
	}
}

func lifecycleRetryPolicy(cfg *config.Config) cluster.RetryPolicy {
	return cluster.RetryPolicy{
		Total: cfg.Retry.Total,
		ByStep: map[cluster.StepName]int{
			cluster.StepBuildReal:         max(cfg.Retry.Build, 1),
			cluster.StepGenStubs:          max(cfg.Retry.Build, 1),
			cluster.StepDeploy:            max(cfg.Retry.Deploy, 1),
			cluster.StepReadiness:         max(cfg.Retry.Test, 1),
			cluster.StepTunnelUp:          1,
			cluster.StepCrossplaneInstall: 1,
			cluster.StepCredWire:          1,
			cluster.StepCPCloudReady:      1,
		},
	}
}

func buildLocalCleanup(selected *host.Session) (cluster.LocalCleanupFunc, error) {
	switch selected.Mode {
	case config.HostModeLocalK3d:
		hostAdapter := localk3d.NewAdapter()
		return func(ctx context.Context) (string, error) {
			var parts []string

			dbResult, err := db.Destroy(ctx, selected.StateDir)
			switch {
			case err == nil:
				parts = append(parts, dbResult.FormatText())
			case isMissingDBState(err):
				parts = append(parts, "db: no local metadb state")
			default:
				parts = append(parts, fmt.Sprintf("db cleanup error: %v", err))
			}

			if err := hostAdapter.Destroy(ctx, selected); err != nil {
				parts = append(parts, fmt.Sprintf("host cleanup error: %v", err))
			} else {
				parts = append(parts, fmt.Sprintf("host: deleted %s cluster %q", selected.Mode, selected.ClusterName))
			}

			return strings.Join(parts, ", "), nil
		}, nil
	case config.HostModeRemoteEKS:
		return func(ctx context.Context) (string, error) {
			dbResult, err := db.Destroy(ctx, selected.StateDir)
			switch {
			case err == nil:
				return dbResult.FormatText() + ", host: remote EKS substrate cleanup is terraform-owned", nil
			case isMissingDBState(err):
				return "db: no local metadb state, host: remote EKS substrate cleanup is terraform-owned", nil
			default:
				return fmt.Sprintf("db cleanup error: %v, host: remote EKS substrate cleanup is terraform-owned", err), nil
			}
		}, nil
	case config.HostModeRemoteEC2K3d:
		hostAdapter := remoteec2.NewAdapter()
		return func(ctx context.Context) (string, error) {
			var parts []string

			dbResult, err := db.Destroy(ctx, selected.StateDir)
			switch {
			case err == nil:
				parts = append(parts, dbResult.FormatText())
			case isMissingDBState(err):
				parts = append(parts, "db: no local metadb state")
			default:
				parts = append(parts, fmt.Sprintf("db cleanup error: %v", err))
			}

			if err := hostAdapter.Destroy(ctx, selected); err != nil {
				parts = append(parts, fmt.Sprintf("host cleanup error: %v", err))
			} else {
				instanceID := ""
				if selected.Remote != nil {
					instanceID = selected.Remote.InstanceID
				}
				parts = append(parts, fmt.Sprintf("host: terminated %s instance %q", selected.Mode, instanceID))
			}

			return strings.Join(parts, ", "), nil
		}, nil
	default:
		return nil, cp9errors.NotImplemented("host mode " + string(selected.Mode))
	}
}

func isMissingDBState(err error) bool {
	if err == nil {
		return false
	}
	return errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no database state found")
}
