package aws

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"slices"
	"strings"
	"time"

	awssdk "github.com/aws/aws-sdk-go-v2/aws"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"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/iam"
	"github.com/aws/aws-sdk-go-v2/service/sts"
	ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types"

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

// STSAPI is the subset of the STS API used for setup validation.
type STSAPI interface {
	GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput,
		optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error)
	AssumeRole(ctx context.Context, params *sts.AssumeRoleInput,
		optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error)
}

// IAMCleanupReadinessAPI is the IAM call Terraform needs when destroying
// deterministic cp9 workload roles.
type IAMCleanupReadinessAPI interface {
	ListInstanceProfilesForRole(ctx context.Context, params *iam.ListInstanceProfilesForRoleInput,
		optFns ...func(*iam.Options)) (*iam.ListInstanceProfilesForRoleOutput, error)
}

// SetupResult holds the effective facts from AWS Session preflight and any
// local-only runtime profile reconciliation.
type SetupResult struct {
	Valid             bool                         `json:"valid"`
	NoMutation        bool                         `json:"no_mutation"`
	CleanupReady      bool                         `json:"cleanup_ready,omitempty"`
	CleanupCommand    string                       `json:"cleanup_command,omitempty"`
	TTL               string                       `json:"ttl,omitempty"`
	Account           string                       `json:"account"`
	ARN               string                       `json:"arn"`
	UserID            string                       `json:"user_id"`
	Owner             string                       `json:"owner,omitempty"`
	Profile           string                       `json:"profile"`
	Region            string                       `json:"region"`
	RoleARN           string                       `json:"role_arn,omitempty"`
	CredentialSource  string                       `json:"credential_source,omitempty"`
	SessionTags       map[string]string            `json:"session_tags,omitempty"`
	UserManagedEC2Key *UserManagedEC2KeyValidation `json:"user_managed_ec2_key,omitempty"`
	RemoteImage       *remoteimage.Plan            `json:"remote_image,omitempty"`
	SessionCollision  *SessionCollisionInventory   `json:"session_collision,omitempty"`
	LocalProfile      *LocalProfileWrite           `json:"local_profile,omitempty"`
}

// SessionCollisionInventory is the read-only preflight result for a requested
// session name across local worktrees and the target AWS account.
type SessionCollisionInventory struct {
	Code             string     `json:"code,omitempty"`
	Status           string     `json:"status"`
	Session          string     `json:"session"`
	CurrentStatePath string     `json:"current_state_path"`
	LocalStatePaths  []string   `json:"local_state_paths,omitempty"`
	CloudResources   []Resource `json:"cloud_resources,omitempty"`
	RecoveryActions  []string   `json:"recovery_actions,omitempty"`
}

// FormatText implements model.TextFormatter.
func (r *SessionCollisionInventory) FormatText() string {
	if r == nil {
		return ""
	}
	var b strings.Builder
	fmt.Fprintf(&b, "session collision: status=%s session=%s", r.Status, r.Session)
	if r.Status == "resume" {
		fmt.Fprintf(&b, "\n  authoritative state: %s", r.CurrentStatePath)
	}
	for _, path := range r.LocalStatePaths {
		fmt.Fprintf(&b, "\n  local state: %s", path)
	}
	for i := range r.CloudResources {
		fmt.Fprintf(&b, "\n  cloud resource: %s", r.CloudResources[i].String())
	}
	for _, action := range r.RecoveryActions {
		fmt.Fprintf(&b, "\n  recovery: %s", action)
	}
	return b.String()
}

// ContractError marks a local IAM/session-config contract violation rather than
// a transient/runtime AWS transport failure.
type ContractError struct {
	err error
}

func (e *ContractError) Error() string { return e.err.Error() }
func (e *ContractError) Unwrap() error { return e.err }

func newContractError(err error) error {
	if err == nil {
		return nil
	}
	return &ContractError{err: err}
}

// IsContractError reports whether err is a local IAM/session-config contract error.
func IsContractError(err error) bool {
	var target *ContractError
	return errors.As(err, &target)
}

// FormatText implements model.TextFormatter.
func (r *SetupResult) FormatText() string {
	var b strings.Builder
	switch {
	case r.Valid && r.CredentialSource == "local":
		b.WriteString("setup: local Session preflight validated\n")
	case r.Valid:
		b.WriteString("setup: AWS access validated\n")
	case r.SessionCollision != nil && r.SessionCollision.Status == "conflict":
		b.WriteString("setup: session collision detected\n")
	default:
		b.WriteString("setup: AWS access FAILED\n")
	}
	fmt.Fprintf(&b, "  profile: %s\n", r.Profile)
	fmt.Fprintf(&b, "  region:  %s\n", r.Region)
	fmt.Fprintf(&b, "  account: %s\n", r.Account)
	fmt.Fprintf(&b, "  arn:     %s\n", r.ARN)
	if r.Owner != "" {
		fmt.Fprintf(&b, "  owner:   %s\n", r.Owner)
	}
	if r.RoleARN != "" {
		fmt.Fprintf(&b, "  role:    %s\n", r.RoleARN)
	}
	if r.CredentialSource != "" {
		fmt.Fprintf(&b, "  creds:   %s\n", r.CredentialSource)
	}
	if r.UserManagedEC2Key != nil {
		fmt.Fprintf(&b, "  ec2 key: %s\n", r.UserManagedEC2Key.Summary())
	}
	if r.TTL != "" {
		fmt.Fprintf(&b, "  ttl:     %s\n", r.TTL)
	}
	if len(r.SessionTags) > 0 {
		fmt.Fprintf(&b, "  tags:    %s\n", formatSessionTags(r.SessionTags))
	}
	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.SessionCollision != nil {
		fmt.Fprintf(&b, "  %s\n", strings.ReplaceAll(r.SessionCollision.FormatText(), "\n", "\n  "))
	}
	if r.LocalProfile != nil {
		action := "already up to date"
		if r.LocalProfile.Updated {
			action = "updated"
		}
		fmt.Fprintf(
			&b,
			"  local profile: %s %s in %s\n",
			r.LocalProfile.Name,
			action,
			r.LocalProfile.ConfigPath,
		)
		b.WriteString("  mutation: local AWS profile only; setup does not create Terraform or cloud resources\n")
	} else if r.NoMutation {
		b.WriteString("  mutation: none; setup does not create Terraform or cloud resources\n")
	}
	if r.CleanupReady {
		command := strings.TrimSpace(r.CleanupCommand)
		if command == "" {
			command = "cp9 destroy"
		}
		fmt.Fprintf(&b, "  cleanup: ready; use `%s` for closeout\n", command)
	}
	return b.String()
}

// ValidateAccess checks that the AWS credentials are valid by calling sts:GetCallerIdentity.
func ValidateAccess(ctx context.Context, stsClient STSAPI, profile, region string) (*SetupResult, error) {
	result := &SetupResult{
		NoMutation:     true,
		CleanupCommand: "cp9 destroy",
		Profile:        profile,
		Region:         region,
	}

	verbose.Logf("AWS sts:GetCallerIdentity profile=%s region=%s", profile, region)
	out, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
	if err != nil {
		result.Valid = false
		return result, fmt.Errorf("sts GetCallerIdentity: %w", err)
	}
	verbose.Logf("AWS sts:GetCallerIdentity profile=%s region=%s account=%s arn=%s",
		profile, region, awssdk.ToString(out.Account), awssdk.ToString(out.Arn))

	result.Valid = true
	result.Account = awssdk.ToString(out.Account)
	result.ARN = awssdk.ToString(out.Arn)
	result.UserID = awssdk.ToString(out.UserId)
	return result, nil
}

// ResolveOwnerIdentity returns the active cp9-owner value for the current caller.
// Human developer runs derive it from the assumed-role session name; CI must set
// CP9_OWNER and match an allowlisted caller principal ARN.
func ResolveOwnerIdentity(result *SetupResult, ownerAllowlist map[string][]string) (string, error) {
	if result == nil {
		return "", fmt.Errorf("missing AWS identity result")
	}

	if owner := strings.TrimSpace(os.Getenv("CP9_OWNER")); owner != "" {
		owner = strings.ToLower(owner)
		allowed := ownerAllowlist[result.ARN]
		if len(allowed) == 0 {
			return "", fmt.Errorf("CP9_OWNER is set but caller principal %q is not in ci.owner_allowlist", result.ARN)
		}
		for _, candidate := range allowed {
			if owner == strings.ToLower(strings.TrimSpace(candidate)) {
				return owner, nil
			}
		}
		return "", fmt.Errorf("CP9_OWNER %q is not allowlisted for caller principal %q", owner, result.ARN)
	}

	if owner, ok := ownerFromARN(result.ARN); ok {
		return strings.ToLower(owner), nil
	}

	return "", fmt.Errorf("cannot resolve cp9-owner from AWS identity %q; run aws sso login or set CP9_OWNER with ci.owner_allowlist", result.ARN)
}

// ValidateIAMReadiness proves the local IAM contract for a v1alpha2 session
// and records the one effective account from the authenticated role identity.
func ValidateIAMReadiness(
	ctx context.Context,
	stsClient STSAPI,
	profile, region, roleARN, sessionID string,
	ownerAllowlist map[string][]string,
) (*SetupResult, error) {
	result, err := ValidateAccess(ctx, stsClient, profile, region)
	if err != nil {
		return result, err
	}
	if !result.Valid {
		return result, fmt.Errorf("credentials invalid for profile=%s region=%s", profile, region)
	}
	owner, err := ResolveOwnerIdentity(result, ownerAllowlist)
	if err != nil {
		return result, newContractError(err)
	}
	resolvedTags, err := DeriveSessionTags(owner, sessionID)
	if err != nil {
		return result, newContractError(err)
	}
	credentialSource := CredentialSource(profile)
	if err := ValidateCallerRole(result.ARN, roleARN); err != nil {
		if err := assumeConfiguredRole(ctx, stsClient, result, roleARN, owner, resolvedTags); err != nil {
			return result, newContractError(err)
		}
		credentialSource = CredentialSource(profile) + "->assume-role:" + strings.TrimSpace(roleARN)
	}
	effectiveAccount, _, ok := parseCallerRoleARN(result.ARN)
	if !ok {
		return result, newContractError(fmt.Errorf(
			"AWS caller ARN %q does not identify the effective target account", result.ARN))
	}
	result.Account = effectiveAccount
	result.Owner = owner
	result.RoleARN = strings.TrimSpace(roleARN)
	result.CredentialSource = credentialSource
	result.SessionTags = resolvedTags
	return result, nil
}

// ValidateIAMCleanupReadiness proves the cp9-developer cleanup contract needed
// by Terraform destroy before cp9 reaches substrate_apply.
func ValidateIAMCleanupReadiness(ctx context.Context, iamClient IAMCleanupReadinessAPI, sessionID string) error {
	if iamClient == nil {
		return fmt.Errorf("IAM cleanup readiness client is required")
	}
	roleName := workloadRoleName(sessionID)
	if roleName == "" {
		return newContractError(fmt.Errorf("session name is required for IAM cleanup readiness"))
	}
	verbose.Logf("AWS iam:ListInstanceProfilesForRole role=%s", roleName)
	_, err := iamClient.ListInstanceProfilesForRole(ctx, &iam.ListInstanceProfilesForRoleInput{
		RoleName: awssdk.String(roleName),
	})
	if err == nil || isNotFoundErr(err) {
		return nil
	}
	if isAuthorizationErr(err) {
		return newContractError(fmt.Errorf(
			"target.aws.iam.role_arn cleanup contract is missing iam:ListInstanceProfilesForRole for %q; follow terraform/cp9-developer/SKILL.md before running cp9: %w",
			roleName,
			err,
		))
	}
	return fmt.Errorf("probe IAM cleanup readiness for %q: %w", roleName, err)
}

// ValidateIAMCleanupReadinessFromConfig builds the same assumed-role credential
// shape as ValidateIAMReadiness, then probes the cleanup IAM action.
func ValidateIAMCleanupReadinessFromConfig(
	ctx context.Context,
	cfg *awssdk.Config,
	result *SetupResult,
	sessionID string,
) error {
	if cfg == nil {
		return fmt.Errorf("AWS config is required for IAM cleanup readiness")
	}
	probeCfg := EffectiveAWSConfig(cfg, result)
	return ValidateIAMCleanupReadiness(ctx, iam.NewFromConfig(probeCfg), sessionID)
}

// ConfigWithAssumeRole returns an AWS config that assumes roleARN. A non-empty
// sessionTags map starts a tagged CP9 session; nil preserves already-transitive
// tags during role chaining without sending conflicting duplicate tags.
func ConfigWithAssumeRole(cfg *awssdk.Config, roleARN, owner string, sessionTags map[string]string) awssdk.Config {
	assumed := cfg.Copy()
	assumed.Credentials = awssdk.NewCredentialsCache(stscreds.NewAssumeRoleProvider(
		sts.NewFromConfig(*cfg),
		strings.TrimSpace(roleARN),
		func(o *stscreds.AssumeRoleOptions) {
			o.RoleSessionName = cleanupProbeSessionName(owner)
			if len(sessionTags) > 0 {
				o.Tags = cleanupProbeTags(sessionTags)
				o.TransitiveTagKeys = []string{TagOwner, TagSession}
			}
		},
	))
	return assumed
}

// EffectiveAWSConfig returns the credential shape validated by
// ValidateIAMReadiness, including a tagged assume-role provider when the source
// profile is not already the configured role.
func EffectiveAWSConfig(cfg *awssdk.Config, result *SetupResult) awssdk.Config {
	if result == nil || !strings.Contains(result.CredentialSource, "->assume-role:") ||
		strings.TrimSpace(result.RoleARN) == "" {
		return *cfg
	}
	return ConfigWithAssumeRole(cfg, result.RoleARN, result.Owner, result.SessionTags)
}

func cleanupProbeSessionName(owner string) string {
	owner = strings.TrimSpace(owner)
	if owner != "" {
		return owner
	}
	return "cp9-cleanup-probe"
}

func cleanupProbeTags(tags map[string]string) []ststypes.Tag {
	out := make([]ststypes.Tag, 0, 2)
	for _, key := range []string{TagOwner, TagSession} {
		value := strings.TrimSpace(tags[key])
		if value == "" {
			continue
		}
		out = append(out, ststypes.Tag{
			Key:   awssdk.String(key),
			Value: awssdk.String(value),
		})
	}
	return out
}

func workloadRoleName(sessionID string) string {
	sessionID = strings.TrimSpace(sessionID)
	if sessionID == "" {
		return ""
	}
	return "cp9-" + sessionID + "-workload"
}

func assumeConfiguredRole(
	ctx context.Context,
	stsClient STSAPI,
	result *SetupResult,
	roleARN, owner string,
	tags map[string]string,
) error {
	roleARN = strings.TrimSpace(roleARN)
	if roleARN == "" {
		return fmt.Errorf("target.aws.iam.role_arn is required")
	}
	verbose.Logf("AWS sts:AssumeRole role=%s session=%s", roleARN, owner)
	out, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{
		RoleArn:         awssdk.String(roleARN),
		RoleSessionName: awssdk.String(owner),
		DurationSeconds: awssdk.Int32(3600),
		Tags: []ststypes.Tag{
			{Key: awssdk.String(TagOwner), Value: awssdk.String(tags[TagOwner])},
			{Key: awssdk.String(TagSession), Value: awssdk.String(tags[TagSession])},
		},
		TransitiveTagKeys: []string{TagOwner, TagSession},
	})
	if err != nil {
		return fmt.Errorf("AWS caller ARN %q does not match target.aws.iam.role_arn %q and assume-role failed: %w", result.ARN, roleARN, err)
	}
	if out.AssumedRoleUser == nil {
		return fmt.Errorf("assume-role %q returned no assumed role user", roleARN)
	}
	result.ARN = awssdk.ToString(out.AssumedRoleUser.Arn)
	result.UserID = awssdk.ToString(out.AssumedRoleUser.AssumedRoleId)
	if err := ValidateCallerRole(result.ARN, roleARN); err != nil {
		return err
	}
	return nil
}

func ownerFromARN(arn string) (string, bool) {
	parts := strings.SplitN(arn, ":", 6)
	if len(parts) != 6 {
		return "", false
	}
	resource := parts[5]
	if !strings.HasPrefix(resource, "assumed-role/") {
		return "", false
	}

	_, tail, ok := strings.Cut(resource, "assumed-role/")
	if !ok {
		return "", false
	}
	_, sessionName, ok := strings.Cut(tail, "/")
	if !ok {
		return "", false
	}
	sessionName = strings.TrimSpace(sessionName)
	if sessionName == "" {
		return "", false
	}
	return sessionName, true
}

// ValidateCallerRole ensures the active caller session matches the configured developer role.
func ValidateCallerRole(callerARN, expectedRoleARN string) error {
	expectedRoleARN = strings.TrimSpace(expectedRoleARN)
	if expectedRoleARN == "" {
		return fmt.Errorf("target.aws.iam.role_arn is required")
	}
	expectedAccount, expectedRolePath, ok := parseRoleARN(expectedRoleARN)
	if !ok {
		return fmt.Errorf("target.aws.iam.role_arn %q is invalid", expectedRoleARN)
	}
	actualAccount, actualRolePath, ok := parseCallerRoleARN(callerARN)
	if !ok {
		return fmt.Errorf("AWS caller ARN %q does not match target.aws.iam.role_arn %q", callerARN, expectedRoleARN)
	}
	if actualAccount != expectedAccount || actualRolePath != expectedRolePath {
		return fmt.Errorf("AWS caller ARN %q does not match target.aws.iam.role_arn %q", callerARN, expectedRoleARN)
	}
	return nil
}

// DeriveSessionTags materializes the complete cp9 ownership-tag contract from
// authenticated identity and the session name. Callers cannot add or override
// tags through public session configuration.
func DeriveSessionTags(owner, sessionID string) (map[string]string, error) {
	owner = strings.TrimSpace(owner)
	if owner == "" {
		return nil, fmt.Errorf("AWS owner identity is required to derive cp9 session tags")
	}
	sessionID = strings.TrimSpace(sessionID)
	if sessionID == "" {
		return nil, fmt.Errorf("session name is required to derive cp9 session tags")
	}
	return map[string]string{
		TagOwner:   awsSafeTagValue(owner),
		TagSession: sessionID,
	}, nil
}

// ResolveLocalSessionIdentity returns the deterministic internal identity used
// by Sessions that do not have an AWS substrate. CP9_OWNER remains an optional
// local/CI label; otherwise the owner is the literal "local".
func ResolveLocalSessionIdentity(sessionID string) (string, map[string]string, error) {
	owner := strings.TrimSpace(os.Getenv("CP9_OWNER"))
	if owner == "" {
		owner = "local"
	}
	tags, err := DeriveSessionTags(owner, sessionID)
	if err != nil {
		return "", nil, err
	}
	return owner, tags, nil
}

// CredentialSource describes the local AWS credential source cp9 validated.
func CredentialSource(profile string) string {
	profile = strings.TrimSpace(profile)
	if profile == "" {
		return "default-profile"
	}
	return "profile:" + profile
}

func parseRoleARN(arn string) (account, rolePath string, ok bool) {
	parts := strings.SplitN(arn, ":", 6)
	if len(parts) != 6 || parts[2] != "iam" || parts[5] == "" {
		return "", "", false
	}
	resource := parts[5]
	if !strings.HasPrefix(resource, "role/") {
		return "", "", false
	}
	rolePath = strings.TrimPrefix(resource, "role/")
	if strings.TrimSpace(rolePath) == "" {
		return "", "", false
	}
	return parts[4], rolePath, true
}

func parseCallerRoleARN(arn string) (account, rolePath string, ok bool) {
	parts := strings.SplitN(arn, ":", 6)
	if len(parts) != 6 || parts[5] == "" {
		return "", "", false
	}
	resource := parts[5]
	switch {
	case parts[2] == "sts" && strings.HasPrefix(resource, "assumed-role/"):
		rolePath = strings.TrimPrefix(resource, "assumed-role/")
		idx := strings.LastIndex(rolePath, "/")
		if idx <= 0 {
			return "", "", false
		}
		return parts[4], rolePath[:idx], true
	case parts[2] == "iam" && strings.HasPrefix(resource, "role/"):
		rolePath = strings.TrimPrefix(resource, "role/")
		if strings.TrimSpace(rolePath) == "" {
			return "", "", false
		}
		return parts[4], rolePath, true
	default:
		return "", "", false
	}
}

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

// awsCleanupRetryMaxAttempts is the per-call attempt budget for the account-wide
// cleanup sweep. It is deliberately well above the SDK default of 3 so a burst of
// IAM throttling is ridden out rather than failing the run; adaptive mode's rate
// limiter keeps the extra attempts from amplifying the throttling.
const awsCleanupRetryMaxAttempts = 12

// ApplyCleanupRetryPolicy gives a hand-built config the same retry behaviour that
// LoadAWSConfig applies.
//
// Cleanup builds AWS configs two ways: through LoadAWSConfig, and by hand from
// frozen credentials in the destroy and tag-assurance paths. Only the first got the
// account-sweep retry policy, so the hand-built clients kept failing on IAM
// throttling after three attempts while the loaded ones rode it out. Every
// construction site must route through here so the policy has one definition.
func ApplyCleanupRetryPolicy(cfg *awssdk.Config) {
	if cfg == nil {
		return
	}
	cfg.RetryMode = awssdk.RetryModeAdaptive
	cfg.RetryMaxAttempts = awsCleanupRetryMaxAttempts
}

// LoadAWSConfig loads the AWS SDK configuration using the given profile and region.
func LoadAWSConfig(ctx context.Context, profile, region string) (awssdk.Config, error) {
	return loadAWSConfig(ctx, profile, region, verbose.NewHTTPClient(30*time.Second))
}

// LoadAWSConfigQuiet loads AWS credentials and service clients without HTTP
// body logging. Use it for operations whose credential-provider or service
// responses contain secrets that cannot safely appear in verbose diagnostics.
func LoadAWSConfigQuiet(ctx context.Context, profile, region string) (awssdk.Config, error) {
	return loadAWSConfig(ctx, profile, region, verbose.NewMetadataHTTPClient(30*time.Second))
}

func loadAWSConfig(ctx context.Context, profile, region string, httpClient awssdk.HTTPClient) (awssdk.Config, error) {
	var opts []func(*awsconfig.LoadOptions) error
	if region != "" {
		opts = append(opts, awsconfig.WithRegion(region))
	}
	if profile != "" {
		opts = append(opts, awsconfig.WithSharedConfigProfile(profile))
	}
	// Cleanup sweeps a whole account with per-resource calls — most sharply the
	// tag-tracking scan that calls iam:ListRoleTags once per IAM role. In accounts
	// with hundreds of roles the SDK default (standard mode, 3 attempts) exhausts
	// its attempts on IAM throttling and a single blocked scan fails the entire run.
	// Adaptive mode adds a client-side rate limiter that slows the send rate when it
	// sees throttling, and a larger attempt budget rides out the bursts. Per-operation
	// RetryMaxAttempts overrides (e.g. the deliberate single-attempt EC2 stale-resource
	// probes) still win, because per-call options override client config.
	opts = append(opts,
		awsconfig.WithHTTPClient(httpClient),
		awsconfig.WithRetryMode(awssdk.RetryModeAdaptive),
		awsconfig.WithRetryMaxAttempts(awsCleanupRetryMaxAttempts),
	)

	cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
	if err != nil {
		return awssdk.Config{}, fmt.Errorf("load AWS config: %w", err)
	}
	return cfg, nil
}

// LoadAWSConfigFromExportedProfileCredentials loads static credentials through
// the AWS CLI profile resolver. This matches `aws sts ... --profile <profile>`
// behavior for legacy SSO profiles whose AWS CLI role-credential cache is still
// usable even when the Go SDK's SSO access-token refresh path has expired.
func LoadAWSConfigFromExportedProfileCredentials(
	ctx context.Context,
	profile, region string,
	commandRunner func(cmd *exec.Cmd) ([]byte, error),
) (awssdk.Config, error) {
	profile = strings.TrimSpace(profile)
	if profile == "" {
		profile = "default"
	}
	exported, err := exportProfileCredentials(ctx, profile, commandRunner)
	if err != nil {
		return awssdk.Config{}, fmt.Errorf("export AWS credentials for profile %q with AWS CLI: %w", profile, err)
	}
	cfg := awssdk.Config{
		Region: region,
		Credentials: credentials.NewStaticCredentialsProvider(
			exported.AccessKeyID,
			exported.SecretAccessKey,
			exported.SessionToken,
		),
		HTTPClient: verbose.NewHTTPClient(30 * time.Second),
	}
	ApplyCleanupRetryPolicy(&cfg)
	return cfg, nil
}

// IsSSOCredentialRefreshError reports whether an AWS SDK error looks like the
// legacy SSO cache divergence where AWS CLI can still export cached role
// credentials but the SDK cannot refresh the underlying SSO access token.
func IsSSOCredentialRefreshError(err error) bool {
	if err == nil {
		return false
	}
	text := strings.ToLower(err.Error())
	if !strings.Contains(text, "sso") {
		return false
	}
	return strings.Contains(text, "failed to refresh cached credentials") ||
		strings.Contains(text, "sso session has expired") ||
		strings.Contains(text, "expired or is invalid")
}

// PrefixCredentialSource annotates a SetupResult credential source when a
// preflight used a non-default resolver such as AWS CLI credential export.
func PrefixCredentialSource(result *SetupResult, prefix string) {
	if result == nil {
		return
	}
	prefix = strings.TrimSpace(prefix)
	source := strings.TrimSpace(result.CredentialSource)
	if prefix == "" {
		return
	}
	if source == "" {
		result.CredentialSource = prefix
		return
	}
	result.CredentialSource = prefix + ":" + source
}

// FreezeConfigCredentials resolves cfg.Credentials once and replaces the
// provider with static material. Destroy/reaper uses this to avoid reaching
// back through SSO or credential_process during post-delete verification.
func FreezeConfigCredentials(ctx context.Context, cfg *awssdk.Config) error {
	if cfg == nil {
		return fmt.Errorf("AWS config is required")
	}
	if cfg.Credentials == nil {
		return fmt.Errorf("AWS config credentials provider is required")
	}
	creds, err := cfg.Credentials.Retrieve(ctx)
	if err != nil {
		return fmt.Errorf("resolve AWS credentials: %w", err)
	}
	if strings.TrimSpace(creds.AccessKeyID) == "" || strings.TrimSpace(creds.SecretAccessKey) == "" {
		return fmt.Errorf("resolved AWS credentials are incomplete")
	}
	cfg.Credentials = credentials.NewStaticCredentialsProvider(creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken)
	return nil
}
