package config

import (
	"fmt"
	"net"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/envcompat"
)

const (
	DefaultHTTPAddress                  = ":3001"
	DefaultPublicBaseURL                = "http://localhost:3001"
	DefaultAuthHeader                   = "Authorization"
	DefaultTrustedJWTHeader             = "X-AgentTeam-Verified-JWT"
	DefaultKnowledgeQASearXNGBaseURL    = "http://localhost:8080"
	DefaultMaxArtifactBytes             = int64(16 * 1024 * 1024)
	DefaultResourceActivityRefreshAfter = 15 * time.Minute
	MaxEmbeddingDimensions              = 16384

	AutomationRoleAPI            = "api"
	AutomationRoleScheduler      = "scheduler"
	AutomationRoleRunnerDispatch = "runner-dispatch"
	AutomationRoleEventer        = "eventer"
	AutomationRoleReconciler     = "reconciler"

	AutomationBlobDriverFilesystem = "filesystem"

	AutomationExecutorDocker = "docker"

	KnowledgeQAWebSearchProviderSearXNG = "searxng"
)

// NormalizeSplitTrustedJWTHeader defines the single trusted-identity header
// accepted by split Gateway/ProfileWorkspace deployments. Standalone resource
// runtimes may keep configuring their own protected-listener header, but the
// split bridge, edge sanitizer, and embedded AGS must agree on one name.
func NormalizeSplitTrustedJWTHeader(header string) (string, error) {
	header = strings.TrimSpace(header)
	if header == "" {
		return DefaultTrustedJWTHeader, nil
	}
	if !strings.EqualFold(header, DefaultTrustedJWTHeader) {
		return "", fmt.Errorf("split deployment MESHX_AUTH_JWT_HEADER must be %q", DefaultTrustedJWTHeader)
	}
	return DefaultTrustedJWTHeader, nil
}

// ResourceRuntimeConfig is the aggregate configuration for the resource runtime
// hosted in the AgentTeam process. It owns the resource HTTP listener and identity
// handling, resource persistence, AgentTeam Core integration, embedded AGS,
// Automation, personal access tokens, Knowledge Base and Knowledge Q&A,
// resource activity, and internal-admin access.
//
// ResourceRuntimeConfig is not interchangeable with Config: Config describes
// the general AgentTeam server and application, while ResourceRuntimeConfig describes
// the resource-domain runtime composed alongside it. LoadResourceRuntimeFromEnv
// reads the supported environment variables and compatibility aliases;
// NormalizeResourceRuntime applies defaults, normalization, and validation.
type ResourceRuntimeConfig struct {
	HTTPAddress   string
	PublicBaseURL string

	Auth                 ResourceRuntimeAuthConfig
	Database             ResourceDatabaseConfig
	AgentTeamCore        AgentTeamCoreConfig
	ContentStore         ContentStoreConfig
	AGS                  AGSConfig
	Automation           AutomationConfig
	PersonalAccessTokens PersonalAccessTokenConfig
	KB                   KBConfig
	KnowledgeQA          KnowledgeQAConfig
	ResourceActivity     ResourceActivityConfig
	InternalAdmin        InternalAdminConfig
}

// InternalAdminConfig controls the service credential accepted by private
// resource-runtime HTTP routes intended for trusted internal callers.
type InternalAdminConfig struct {
	ServiceToken string
}

// ResourceRuntimeAuthConfig controls identity extraction for resource-runtime
// requests, including the trusted header contract, mock identities, and the
// protected-listener requirement for upstream-verified JWT claims. It does not
// configure token issuance or general AgentTeam authorization policy.
type ResourceRuntimeAuthConfig struct {
	Enabled                           bool
	Header                            string
	AllowMock                         bool
	TrustedJWTClaims                  bool
	TrustedJWTClaimsProtectedListener bool
}

// ResourceDatabaseConfig controls resource-domain persistence and automatic
// schema migration. Its database is separate from both AgentTeam Core and the
// embedded AGS database.
type ResourceDatabaseConfig struct {
	DSN                string
	DisableAutoMigrate bool
}

// AgentTeamCoreConfig controls the optional connection used to consult AgentTeam Core
// workspace ownership and governance state. It does not configure the resource
// or embedded AGS databases.
type AgentTeamCoreConfig struct {
	DatabaseDSN string
}

// AGSConfig controls the embedded AGS instance used by the resource runtime,
// including its database and Git storage, public URL and administrator
// identity, token compatibility, and embedding provider settings.
type AGSConfig struct {
	DatabaseDSN         string
	GitRepoDir          string
	BaseURL             string
	AdminLogin          string
	AdminToken          string
	AllowAnyToken       bool
	EmbeddingAPIKey     string
	EmbeddingBaseURL    string
	EmbeddingModel      string
	EmbeddingDimensions int
}

// AutomationConfig controls the Automation runtime roles, reconciliation and
// runner execution, Docker isolation and checkout limits, job and publication
// budgets, credential encryption, actions-runner integration, and blob storage.
type AutomationConfig struct {
	Roles                     []string
	ReconcilerInterval        time.Duration
	ReconcilerBatchSize       int
	ServerRunnerEnabled       bool
	ServerRunnerID            string
	ServerRunnerLabels        []string
	ServerRunnerConcurrency   int
	ServerRunnerWorkDir       string
	ServerRunnerPollInterval  time.Duration
	ServerRunnerJobTimeout    time.Duration
	Executor                  string
	DockerImages              map[string]string
	DockerNetwork             string
	DockerCPUs                float64
	DockerMemoryBytes         int64
	DockerPidsLimit           int64
	DockerUser                string
	DockerMounts              []AutomationDockerMount
	CheckoutMaxBytes          int64
	CheckoutMaxFiles          int
	CheckoutTimeout           time.Duration
	MaxAutomationSourceBytes  int
	MaxAutomationJobs         int
	MaxAutomationStepsPerJob  int
	MaxRunInputBytes          int
	MaxJobInputBytes          int
	MaxLogChunkBytes          int
	MaxArtifactBytes          int64
	MaxRunnerLabels           int
	MaxActiveRunsPerWorkspace int
	WikiPublishMaxPages       int
	WikiPublishMaxEntries     int
	WikiPublishMaxPageBytes   int64
	TreePublishMaxFiles       int
	TreePublishMaxEntries     int
	TreePublishMaxFileBytes   int64
	TreePublishMaxBytes       int64
	ActionsRunnerBaseURL      string
	CredentialsMasterKey      string
	BlobDriver                string
	BlobFilesystemRoot        string
}

// AutomationDockerMount describes one explicitly configured host-to-container
// mount for Docker automation jobs. Normalization requires an absolute host
// source and an allowed absolute container target before the mount is used.
type AutomationDockerMount struct {
	Source   string
	Target   string
	ReadOnly bool
}

// PersonalAccessTokenConfig controls encryption of stored personal access token
// credentials. It is intentionally separate from Automation credential
// encryption so the two stores can be configured independently.
type PersonalAccessTokenConfig struct {
	CredentialsMasterKey string
}

// KBConfig controls Knowledge Base source mirroring, public repository caching,
// mirror scheduling and timeouts, outbound proxying, and wiki generation.
type KBConfig struct {
	SourceMirrorWorkDir            string
	PublicRepoCacheDir             string
	PublicRepoCacheRefreshInterval time.Duration
	PublicRepoCacheRefreshBatch    int
	SourceMirrorOutboundProxy      string
	SourceMirrorWorkerInterval     time.Duration
	SourceMirrorWorkerBatch        int
	SourceMirrorSchedulerInterval  time.Duration
	SourceMirrorSchedulerBatch     int
	SourceMirrorTimeout            time.Duration
	WikiGenerationEnabled          bool
	WikiGenerationBinary           string
	WikiGenerationWorkDir          string
	WikiGenerationTimeout          time.Duration
}

// KnowledgeQAConfig controls the optional Knowledge Q&A inference runtime and
// whether raw model output is exposed for debugging. An empty MaaS base URL
// leaves that runtime disabled.
type KnowledgeQAConfig struct {
	MaaSBaseURL       string
	WebSearchProvider string
	SearXNGBaseURL    string
	DebugModelOutput  bool
}

// ResourceActivityConfig controls when cached resource activity is considered
// stale and should be refreshed from its source.
type ResourceActivityConfig struct {
	RefreshAfter time.Duration
}

// LoadResourceRuntimeFromEnv reads the resource runtime environment contract,
// including supported compatibility aliases, then returns a normalized and
// validated configuration.
func LoadResourceRuntimeFromEnv() (ResourceRuntimeConfig, error) {
	dockerImages, err := parseAutomationDockerImages(os.Getenv("MESHX_AUTOMATION_DOCKER_IMAGES"))
	if err != nil {
		return ResourceRuntimeConfig{}, err
	}
	dockerMounts, err := parseAutomationDockerMounts(os.Getenv("MESHX_AUTOMATION_DOCKER_MOUNTS"))
	if err != nil {
		return ResourceRuntimeConfig{}, err
	}
	return NormalizeResourceRuntime(ResourceRuntimeConfig{
		HTTPAddress:   stringEnv("AGENTTEAM_HTTP_ADDRESS"),
		PublicBaseURL: stringEnv("AGENTTEAM_PUBLIC_BASE_URL"),
		Auth: ResourceRuntimeAuthConfig{
			Enabled:          boolEnvDefault("MESHX_AUTH_ENABLED", true),
			Header:           stringEnv("MESHX_AUTH_JWT_HEADER"),
			AllowMock:        boolEnvDefault("MESHX_AUTH_ALLOW_MOCK", true),
			TrustedJWTClaims: boolEnvDefault("MESHX_AUTH_TRUSTED_JWT_CLAIMS", false),
			TrustedJWTClaimsProtectedListener: boolEnvDefault(
				"MESHX_AUTH_TRUSTED_JWT_CLAIMS_PROTECTED_LISTENER",
				false,
			),
		},
		Database: ResourceDatabaseConfig{
			DSN:                stringEnv("MESHX_DATABASE_DSN"),
			DisableAutoMigrate: !boolEnvDefault("MESHX_DATABASE_AUTO_MIGRATE", true),
		},
		AgentTeamCore: AgentTeamCoreConfig{
			DatabaseDSN: stringEnv("AGENTTEAM_DATABASE_DSN", "AGENTTEAM_CORE_STORAGE_DSN"),
		},
		ContentStore: loadContentStoreConfig(),
		AGS: AGSConfig{
			DatabaseDSN:         stringEnv("MESHX_AGS_DATABASE_DSN"),
			GitRepoDir:          stringEnv("MESHX_AGS_GIT_REPO_DIR"),
			BaseURL:             stringEnv("AGENTTEAM_PUBLIC_BASE_URL"),
			AdminLogin:          stringEnv("MESHX_AGS_ADMIN_LOGIN"),
			AdminToken:          stringEnv("MESHX_AGS_ADMIN_TOKEN"),
			AllowAnyToken:       boolEnvDefault("MESHX_AGS_ALLOW_ANY_TOKEN", true),
			EmbeddingAPIKey:     stringEnv("MESHX_AGS_EMBEDDING_API_KEY", "EMBEDDING_API_KEY"),
			EmbeddingBaseURL:    stringEnv("MESHX_AGS_EMBEDDING_BASE_URL", "EMBEDDING_BASE_URL"),
			EmbeddingModel:      stringEnv("MESHX_AGS_EMBEDDING_MODEL", "EMBEDDING_MODEL"),
			EmbeddingDimensions: intEnvDefault("MESHX_AGS_EMBEDDING_DIMENSIONS", 0, "EMBEDDING_DIMENSIONS"),
		},
		Automation: AutomationConfig{
			Roles:                     splitCSV(os.Getenv("MESHX_AUTOMATION_ROLES")),
			ReconcilerInterval:        durationEnvDefault("MESHX_AUTOMATION_RECONCILER_INTERVAL", 30*time.Second),
			ReconcilerBatchSize:       intEnvDefault("MESHX_AUTOMATION_RECONCILER_BATCH_SIZE", 100),
			ServerRunnerEnabled:       boolEnvDefault("MESHX_AUTOMATION_SERVER_RUNNER_ENABLED", true),
			ServerRunnerID:            stringEnv("MESHX_AUTOMATION_SERVER_RUNNER_ID"),
			ServerRunnerLabels:        splitCSV(os.Getenv("MESHX_AUTOMATION_SERVER_RUNNER_LABELS")),
			ServerRunnerConcurrency:   intEnvDefault("MESHX_AUTOMATION_SERVER_RUNNER_CONCURRENCY", 1),
			ServerRunnerWorkDir:       stringEnv("MESHX_AUTOMATION_SERVER_RUNNER_WORKDIR"),
			ServerRunnerPollInterval:  durationEnvDefault("MESHX_AUTOMATION_SERVER_RUNNER_POLL_INTERVAL", time.Second),
			ServerRunnerJobTimeout:    durationEnvDefault("MESHX_AUTOMATION_SERVER_RUNNER_JOB_TIMEOUT", 30*time.Minute),
			Executor:                  stringEnv("MESHX_AUTOMATION_EXECUTOR"),
			DockerImages:              dockerImages,
			DockerNetwork:             stringEnv("MESHX_AUTOMATION_DOCKER_NETWORK"),
			DockerCPUs:                floatEnvDefault("MESHX_AUTOMATION_DOCKER_CPUS", 1),
			DockerMemoryBytes:         int64EnvDefault("MESHX_AUTOMATION_DOCKER_MEMORY_BYTES", 1024*1024*1024),
			DockerPidsLimit:           int64EnvDefault("MESHX_AUTOMATION_DOCKER_PIDS_LIMIT", 256),
			DockerUser:                stringEnv("MESHX_AUTOMATION_DOCKER_USER"),
			DockerMounts:              dockerMounts,
			CheckoutMaxBytes:          int64EnvDefault("MESHX_AUTOMATION_CHECKOUT_MAX_BYTES", 512*1024*1024),
			CheckoutMaxFiles:          intEnvDefault("MESHX_AUTOMATION_CHECKOUT_MAX_FILES", 100000),
			CheckoutTimeout:           durationEnvDefault("MESHX_AUTOMATION_CHECKOUT_TIMEOUT", 5*time.Minute),
			MaxAutomationSourceBytes:  intEnvDefault("MESHX_MAX_AUTOMATION_SOURCE_BYTES", 256*1024),
			MaxAutomationJobs:         intEnvDefault("MESHX_MAX_AUTOMATION_JOBS", 256),
			MaxAutomationStepsPerJob:  intEnvDefault("MESHX_MAX_AUTOMATION_STEPS_PER_JOB", 200),
			MaxRunInputBytes:          intEnvDefault("MESHX_MAX_RUN_INPUT_BYTES", 64*1024),
			MaxJobInputBytes:          intEnvDefault("MESHX_MAX_JOB_INPUT_BYTES", 128*1024),
			MaxLogChunkBytes:          intEnvDefault("MESHX_MAX_LOG_CHUNK_BYTES", 64*1024),
			MaxArtifactBytes:          int64EnvDefault("MESHX_MAX_ARTIFACT_BYTES", DefaultMaxArtifactBytes),
			MaxRunnerLabels:           intEnvDefault("MESHX_MAX_RUNNER_LABELS", 8),
			MaxActiveRunsPerWorkspace: intEnvDefault("MESHX_MAX_ACTIVE_RUNS_PER_WORKSPACE", 1000),
			WikiPublishMaxPages:       intEnvDefault("MESHX_AUTOMATION_WIKI_PUBLISH_MAX_PAGES", 5000),
			WikiPublishMaxEntries:     intEnvDefault("MESHX_AUTOMATION_WIKI_PUBLISH_MAX_ENTRIES", 10000),
			WikiPublishMaxPageBytes:   int64EnvDefault("MESHX_AUTOMATION_WIKI_PUBLISH_MAX_PAGE_BYTES", 2*1024*1024),
			TreePublishMaxFiles:       intEnvDefault("MESHX_AUTOMATION_TREE_PUBLISH_MAX_FILES", 10000),
			TreePublishMaxEntries:     intEnvDefault("MESHX_AUTOMATION_TREE_PUBLISH_MAX_ENTRIES", 20000),
			TreePublishMaxFileBytes:   int64EnvDefault("MESHX_AUTOMATION_TREE_PUBLISH_MAX_FILE_BYTES", 16*1024*1024),
			TreePublishMaxBytes:       int64EnvDefault("MESHX_AUTOMATION_TREE_PUBLISH_MAX_BYTES", 256*1024*1024),
			ActionsRunnerBaseURL:      stringEnv("MESHX_AUTOMATION_ACTIONS_RUNNER_BASE_URL"),
			CredentialsMasterKey:      stringEnv("MESHX_AUTOMATION_CREDENTIALS_MASTER_KEY"),
			BlobDriver:                stringEnv("MESHX_AUTOMATION_BLOB_DRIVER"),
			BlobFilesystemRoot:        stringEnv("MESHX_AUTOMATION_BLOB_FILESYSTEM_ROOT"),
		},
		PersonalAccessTokens: PersonalAccessTokenConfig{
			CredentialsMasterKey: stringEnv("MESHX_PERSONAL_ACCESS_TOKEN_CREDENTIALS_MASTER_KEY"),
		},
		KB: KBConfig{
			SourceMirrorWorkDir:            stringEnv("MESHX_KB_SOURCE_MIRROR_WORK_DIR"),
			PublicRepoCacheDir:             stringEnv("MESHX_KB_PUBLIC_REPO_CACHE_DIR"),
			PublicRepoCacheRefreshInterval: durationEnvDefault("MESHX_KB_PUBLIC_REPO_CACHE_REFRESH_INTERVAL", time.Minute),
			PublicRepoCacheRefreshBatch:    intEnvDefault("MESHX_KB_PUBLIC_REPO_CACHE_REFRESH_BATCH", 3),
			SourceMirrorOutboundProxy:      stringEnv("MESHX_KB_SOURCE_MIRROR_OUTBOUND_PROXY"),
			SourceMirrorWorkerInterval:     durationEnvDefault("MESHX_KB_SOURCE_MIRROR_WORKER_INTERVAL", 30*time.Second),
			SourceMirrorWorkerBatch:        intEnvDefault("MESHX_KB_SOURCE_MIRROR_WORKER_BATCH", 3),
			SourceMirrorSchedulerInterval:  durationEnvDefault("MESHX_KB_SOURCE_MIRROR_SCHEDULER_INTERVAL", 60*time.Second),
			SourceMirrorSchedulerBatch:     intEnvDefault("MESHX_KB_SOURCE_MIRROR_SCHEDULER_BATCH", 25),
			SourceMirrorTimeout:            durationEnvDefault("MESHX_KB_SOURCE_MIRROR_TIMEOUT", 30*time.Minute),
			WikiGenerationEnabled:          boolEnvDefault("MESHX_KB_WIKI_GENERATION_ENABLED", false),
			WikiGenerationBinary:           stringEnv("MESHX_KB_WIKI_GENERATION_BINARY"),
			WikiGenerationWorkDir:          stringEnv("MESHX_KB_WIKI_GENERATION_WORK_DIR"),
			WikiGenerationTimeout:          durationEnvDefault("MESHX_KB_WIKI_GENERATION_TIMEOUT", 2*time.Hour),
		},
		InternalAdmin: InternalAdminConfig{ServiceToken: stringEnv("MESHX_INTERNAL_ADMIN_SERVICE_TOKEN")},
		KnowledgeQA: KnowledgeQAConfig{
			MaaSBaseURL:       stringEnv("AGENTTEAM_MAAS_BASE_URL"),
			WebSearchProvider: stringEnv("MESHX_KNOWLEDGE_QA_WEB_SEARCH_PROVIDER"),
			SearXNGBaseURL:    stringEnv("MESHX_KNOWLEDGE_QA_SEARXNG_BASE_URL"),
			DebugModelOutput:  boolEnvDefault("MESHX_KNOWLEDGE_QA_DEBUG_MODEL_OUTPUT", false),
		},
		ResourceActivity: ResourceActivityConfig{
			RefreshAfter: durationEnvDefault("MESHX_RESOURCE_ACTIVITY_REFRESH_AFTER", DefaultResourceActivityRefreshAfter),
		},
	})
}

// NormalizeResourceRuntime applies resource-runtime defaults, canonicalizes
// configured values, and rejects invalid or unsafe combinations. Callers that
// construct ResourceRuntimeConfig directly must normalize it before use.
func NormalizeResourceRuntime(cfg ResourceRuntimeConfig) (ResourceRuntimeConfig, error) {
	cfg.HTTPAddress = firstNonEmpty(cfg.HTTPAddress, DefaultHTTPAddress)
	if err := validateAddress(cfg.HTTPAddress); err != nil {
		return ResourceRuntimeConfig{}, fmt.Errorf("AGENTTEAM_HTTP_ADDRESS: %w", err)
	}
	publicBaseURL, err := normalizeAbsoluteHTTPURL(firstNonEmpty(cfg.PublicBaseURL, DefaultPublicBaseURL))
	if err != nil {
		return ResourceRuntimeConfig{}, fmt.Errorf("AGENTTEAM_PUBLIC_BASE_URL: %w", err)
	}
	cfg.PublicBaseURL = publicBaseURL
	authHeaderConfigured := strings.TrimSpace(cfg.Auth.Header) != ""
	if cfg.Auth.TrustedJWTClaims {
		cfg.Auth.Header = firstNonEmpty(strings.TrimSpace(cfg.Auth.Header), DefaultTrustedJWTHeader)
	} else {
		cfg.Auth.Header = firstNonEmpty(strings.TrimSpace(cfg.Auth.Header), DefaultAuthHeader)
	}
	if cfg.Auth.TrustedJWTClaims {
		if authHeaderConfigured && strings.EqualFold(cfg.Auth.Header, DefaultAuthHeader) {
			return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_AUTH_JWT_HEADER must be an upstream-verified non-Authorization header when MESHX_AUTH_TRUSTED_JWT_CLAIMS is true")
		}
		if !cfg.Auth.TrustedJWTClaimsProtectedListener {
			return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_AUTH_TRUSTED_JWT_CLAIMS_PROTECTED_LISTENER must be true when MESHX_AUTH_TRUSTED_JWT_CLAIMS is true")
		}
		cfg.Auth.AllowMock = false
	}
	cfg.Database.DSN = firstNonEmpty(cfg.Database.DSN, "file:.data/meshx/meshx.db?cache=shared&_foreign_keys=on")
	cfg.AgentTeamCore.DatabaseDSN = strings.TrimSpace(cfg.AgentTeamCore.DatabaseDSN)
	if strings.TrimSpace(cfg.KnowledgeQA.MaaSBaseURL) != "" {
		maasBaseURL, err := normalizeAbsoluteHTTPURL(cfg.KnowledgeQA.MaaSBaseURL)
		if err != nil {
			return ResourceRuntimeConfig{}, fmt.Errorf("AGENTTEAM_MAAS_BASE_URL: %w", err)
		}
		cfg.KnowledgeQA.MaaSBaseURL = maasBaseURL
	}
	cfg.KnowledgeQA.WebSearchProvider = strings.ToLower(strings.TrimSpace(cfg.KnowledgeQA.WebSearchProvider))
	cfg.KnowledgeQA.SearXNGBaseURL = strings.TrimSpace(cfg.KnowledgeQA.SearXNGBaseURL)
	if cfg.KnowledgeQA.WebSearchProvider == "" && cfg.KnowledgeQA.SearXNGBaseURL != "" {
		cfg.KnowledgeQA.WebSearchProvider = KnowledgeQAWebSearchProviderSearXNG
	}
	switch cfg.KnowledgeQA.WebSearchProvider {
	case "":
		cfg.KnowledgeQA.SearXNGBaseURL = ""
	case KnowledgeQAWebSearchProviderSearXNG:
		searXNGBaseURL, err := normalizeAbsoluteHTTPURL(
			firstNonEmpty(cfg.KnowledgeQA.SearXNGBaseURL, DefaultKnowledgeQASearXNGBaseURL),
		)
		if err != nil {
			return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_KNOWLEDGE_QA_SEARXNG_BASE_URL: %w", err)
		}
		cfg.KnowledgeQA.SearXNGBaseURL = searXNGBaseURL
	default:
		return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_KNOWLEDGE_QA_WEB_SEARCH_PROVIDER must be empty or %q", KnowledgeQAWebSearchProviderSearXNG)
	}

	cfg.AGS.GitRepoDir = firstNonEmpty(cfg.AGS.GitRepoDir, ".data/meshx/ags-repos")
	cfg.AGS.BaseURL = firstNonEmpty(cfg.AGS.BaseURL, cfg.PublicBaseURL)
	cfg.AGS.AdminLogin = firstNonEmpty(cfg.AGS.AdminLogin, "meshx-admin")
	cfg.AGS.AdminToken = firstNonEmpty(cfg.AGS.AdminToken, "meshx-dev-token")
	cfg.AGS.EmbeddingAPIKey = strings.TrimSpace(cfg.AGS.EmbeddingAPIKey)
	cfg.AGS.EmbeddingBaseURL = strings.TrimSpace(cfg.AGS.EmbeddingBaseURL)
	cfg.AGS.EmbeddingModel = strings.TrimSpace(cfg.AGS.EmbeddingModel)
	if cfg.AGS.EmbeddingDimensions < 0 || cfg.AGS.EmbeddingDimensions > MaxEmbeddingDimensions {
		return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_AGS_EMBEDDING_DIMENSIONS must be between 0 and %d", MaxEmbeddingDimensions)
	}
	if strings.TrimSpace(cfg.AGS.DatabaseDSN) == "" {
		return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_AGS_DATABASE_DSN is required")
	}
	roles, err := normalizeAutomationRoles(cfg.Automation.Roles)
	if err != nil {
		return ResourceRuntimeConfig{}, err
	}
	cfg.Automation.Roles = roles
	if cfg.Automation.ReconcilerInterval <= 0 {
		cfg.Automation.ReconcilerInterval = 30 * time.Second
	}
	if cfg.Automation.ReconcilerBatchSize <= 0 {
		cfg.Automation.ReconcilerBatchSize = 100
	}
	cfg.Automation.ServerRunnerID = firstNonEmpty(strings.TrimSpace(cfg.Automation.ServerRunnerID), "server-default")
	cfg.Automation.ServerRunnerLabels = splitCSV(strings.Join(cfg.Automation.ServerRunnerLabels, ","))
	if len(cfg.Automation.ServerRunnerLabels) == 0 {
		cfg.Automation.ServerRunnerLabels = []string{"ubuntu-latest"}
	}
	if cfg.Automation.ServerRunnerConcurrency <= 0 {
		cfg.Automation.ServerRunnerConcurrency = 1
	}
	if cfg.Automation.ServerRunnerWorkDir == "" {
		cfg.Automation.ServerRunnerWorkDir = ".data/meshx/automation-runner"
	}
	if cfg.Automation.ServerRunnerPollInterval <= 0 {
		cfg.Automation.ServerRunnerPollInterval = time.Second
	}
	if cfg.Automation.ServerRunnerJobTimeout <= 0 {
		cfg.Automation.ServerRunnerJobTimeout = 30 * time.Minute
	}
	cfg.Automation.Executor = NormalizeAutomationExecutor(cfg.Automation.Executor)
	if cfg.Automation.Executor == "" {
		cfg.Automation.Executor = AutomationExecutorDocker
	}
	switch cfg.Automation.Executor {
	case AutomationExecutorDocker:
	default:
		return ResourceRuntimeConfig{}, fmt.Errorf("unsupported automation executor %q", cfg.Automation.Executor)
	}
	dockerImages, err := normalizeAutomationDockerImages(cfg.Automation.DockerImages)
	if err != nil {
		return ResourceRuntimeConfig{}, err
	}
	cfg.Automation.DockerImages = dockerImages
	cfg.Automation.DockerNetwork = strings.TrimSpace(cfg.Automation.DockerNetwork)
	if cfg.Automation.DockerNetwork == "" {
		cfg.Automation.DockerNetwork = "none"
	}
	if strings.HasPrefix(strings.ToLower(cfg.Automation.DockerNetwork), "container:") {
		return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_AUTOMATION_DOCKER_NETWORK must not join another container")
	}
	if cfg.Automation.DockerCPUs <= 0 {
		cfg.Automation.DockerCPUs = 1
	}
	if cfg.Automation.DockerMemoryBytes <= 0 {
		cfg.Automation.DockerMemoryBytes = 1024 * 1024 * 1024
	}
	if cfg.Automation.DockerPidsLimit <= 0 {
		cfg.Automation.DockerPidsLimit = 256
	}
	cfg.Automation.DockerUser = firstNonEmpty(strings.TrimSpace(cfg.Automation.DockerUser), defaultAutomationDockerUser())
	dockerMounts, err := normalizeAutomationDockerMounts(cfg.Automation.DockerMounts)
	if err != nil {
		return ResourceRuntimeConfig{}, err
	}
	cfg.Automation.DockerMounts = dockerMounts
	if cfg.Automation.CheckoutMaxBytes <= 0 {
		cfg.Automation.CheckoutMaxBytes = 512 * 1024 * 1024
	}
	if cfg.Automation.CheckoutMaxFiles <= 0 {
		cfg.Automation.CheckoutMaxFiles = 100000
	}
	if cfg.Automation.CheckoutTimeout <= 0 {
		cfg.Automation.CheckoutTimeout = 5 * time.Minute
	}
	if cfg.Automation.MaxAutomationSourceBytes <= 0 {
		cfg.Automation.MaxAutomationSourceBytes = 256 * 1024
	}
	if cfg.Automation.MaxAutomationJobs <= 0 {
		cfg.Automation.MaxAutomationJobs = 256
	}
	if cfg.Automation.MaxAutomationStepsPerJob <= 0 {
		cfg.Automation.MaxAutomationStepsPerJob = 200
	}
	if cfg.Automation.MaxRunInputBytes <= 0 {
		cfg.Automation.MaxRunInputBytes = 64 * 1024
	}
	if cfg.Automation.MaxJobInputBytes <= 0 {
		cfg.Automation.MaxJobInputBytes = 128 * 1024
	}
	if cfg.Automation.MaxLogChunkBytes <= 0 {
		cfg.Automation.MaxLogChunkBytes = 64 * 1024
	}
	if cfg.Automation.MaxArtifactBytes <= 0 {
		cfg.Automation.MaxArtifactBytes = DefaultMaxArtifactBytes
	}
	if cfg.Automation.MaxRunnerLabels <= 0 {
		cfg.Automation.MaxRunnerLabels = 8
	}
	if cfg.Automation.MaxActiveRunsPerWorkspace <= 0 {
		cfg.Automation.MaxActiveRunsPerWorkspace = 1000
	}
	if cfg.Automation.WikiPublishMaxPages <= 0 {
		cfg.Automation.WikiPublishMaxPages = 5000
	}
	if cfg.Automation.WikiPublishMaxEntries <= 0 {
		cfg.Automation.WikiPublishMaxEntries = 10000
	}
	if cfg.Automation.WikiPublishMaxPageBytes <= 0 {
		cfg.Automation.WikiPublishMaxPageBytes = 2 * 1024 * 1024
	}
	if cfg.Automation.TreePublishMaxFiles <= 0 {
		cfg.Automation.TreePublishMaxFiles = 10000
	}
	if cfg.Automation.TreePublishMaxEntries <= 0 {
		cfg.Automation.TreePublishMaxEntries = 20000
	}
	if cfg.Automation.TreePublishMaxFileBytes <= 0 {
		cfg.Automation.TreePublishMaxFileBytes = 16 * 1024 * 1024
	}
	if cfg.Automation.TreePublishMaxBytes <= 0 {
		cfg.Automation.TreePublishMaxBytes = 256 * 1024 * 1024
	}
	cfg.Automation.CredentialsMasterKey = strings.TrimSpace(cfg.Automation.CredentialsMasterKey)
	if _, err := cfg.Automation.CredentialEncryptionKey(); err != nil {
		return ResourceRuntimeConfig{}, err
	}
	cfg.PersonalAccessTokens.CredentialsMasterKey = strings.TrimSpace(cfg.PersonalAccessTokens.CredentialsMasterKey)
	if _, err := cfg.PersonalAccessTokens.CredentialEncryptionKey(); err != nil {
		return ResourceRuntimeConfig{}, err
	}
	actionsRunnerBaseURL, err := normalizeAbsoluteHTTPURL(cfg.Automation.ActionsRunnerBaseURL)
	if err != nil {
		return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_AUTOMATION_ACTIONS_RUNNER_BASE_URL: %w", err)
	}
	cfg.Automation.ActionsRunnerBaseURL = actionsRunnerBaseURL
	cfg.Automation.BlobDriver = NormalizeAutomationBlobDriver(cfg.Automation.BlobDriver)
	if cfg.Automation.BlobDriver == "" {
		cfg.Automation.BlobDriver = AutomationBlobDriverFilesystem
	}
	if cfg.Automation.BlobFilesystemRoot == "" {
		cfg.Automation.BlobFilesystemRoot = ".data/meshx/automation-blobs"
	}
	switch cfg.Automation.BlobDriver {
	case AutomationBlobDriverFilesystem:
		if strings.TrimSpace(cfg.Automation.BlobFilesystemRoot) == "" {
			return ResourceRuntimeConfig{}, fmt.Errorf("MESHX_AUTOMATION_BLOB_FILESYSTEM_ROOT is required when automation blob driver is filesystem")
		}
	default:
		return ResourceRuntimeConfig{}, fmt.Errorf("unsupported automation blob driver %q", cfg.Automation.BlobDriver)
	}
	cfg.KB.SourceMirrorWorkDir = firstNonEmpty(cfg.KB.SourceMirrorWorkDir, ".data/meshx/kb-source-mirrors")
	cfg.ContentStore.FilesystemRoot = firstNonEmpty(cfg.ContentStore.FilesystemRoot, ".data/meshx/content-store")
	cfg.KB.PublicRepoCacheDir = firstNonEmpty(cfg.KB.PublicRepoCacheDir, filepath.Join(filepath.Dir(cfg.KB.SourceMirrorWorkDir), "public-repo-cache"))
	if cfg.KB.PublicRepoCacheRefreshInterval <= 0 {
		cfg.KB.PublicRepoCacheRefreshInterval = time.Minute
	}
	if cfg.KB.PublicRepoCacheRefreshBatch <= 0 {
		cfg.KB.PublicRepoCacheRefreshBatch = 3
	}
	if trimmed := strings.TrimSpace(cfg.KB.SourceMirrorOutboundProxy); trimmed != "" {
		proxyURL, err := parseProxyURL(trimmed, "MESHX_KB_SOURCE_MIRROR_OUTBOUND_PROXY")
		if err != nil {
			return ResourceRuntimeConfig{}, err
		}
		if proxyURL != nil {
			cfg.KB.SourceMirrorOutboundProxy = proxyURL.String()
		}
	}
	if cfg.KB.SourceMirrorWorkerInterval <= 0 {
		cfg.KB.SourceMirrorWorkerInterval = 30 * time.Second
	}
	if cfg.KB.SourceMirrorWorkerBatch <= 0 {
		cfg.KB.SourceMirrorWorkerBatch = 3
	}
	if cfg.KB.SourceMirrorSchedulerInterval <= 0 {
		cfg.KB.SourceMirrorSchedulerInterval = 60 * time.Second
	}
	if cfg.KB.SourceMirrorSchedulerBatch <= 0 {
		cfg.KB.SourceMirrorSchedulerBatch = 25
	}
	if cfg.KB.SourceMirrorTimeout <= 0 {
		cfg.KB.SourceMirrorTimeout = 30 * time.Minute
	}
	cfg.KB.WikiGenerationBinary = firstNonEmpty(strings.TrimSpace(cfg.KB.WikiGenerationBinary), "agentteam-insight")
	cfg.KB.WikiGenerationWorkDir = firstNonEmpty(strings.TrimSpace(cfg.KB.WikiGenerationWorkDir), filepath.Join(filepath.Dir(cfg.KB.SourceMirrorWorkDir), "wiki-generation"))
	if cfg.KB.WikiGenerationTimeout <= 0 {
		cfg.KB.WikiGenerationTimeout = 2 * time.Hour
	}
	if cfg.ResourceActivity.RefreshAfter <= 0 {
		cfg.ResourceActivity.RefreshAfter = DefaultResourceActivityRefreshAfter
	}
	return cfg, nil
}

func (cfg AutomationConfig) HasRole(role string) bool {
	target := NormalizeAutomationRole(role)
	for _, configured := range cfg.Roles {
		if configured == target {
			return true
		}
	}
	return false
}

func NormalizeAutomationRole(role string) string {
	return strings.ToLower(strings.TrimSpace(role))
}

func NormalizeAutomationBlobDriver(driver string) string {
	return strings.ToLower(strings.TrimSpace(driver))
}

func NormalizeAutomationExecutor(executor string) string {
	return strings.ToLower(strings.TrimSpace(executor))
}

func defaultAutomationDockerUser() string {
	return fmt.Sprintf("%d:%d", os.Geteuid(), os.Getegid())
}

// CredentialEncryptionKey returns the decoded automation secret encryption key.
func (cfg AutomationConfig) CredentialEncryptionKey() ([]byte, error) {
	encodedKey := strings.TrimSpace(cfg.CredentialsMasterKey)
	return decodeOptionalCredentialEncryptionKey(encodedKey, "MESHX_AUTOMATION_CREDENTIALS_MASTER_KEY")
}

// CredentialEncryptionKey returns the decoded personal external token encryption key.
func (cfg PersonalAccessTokenConfig) CredentialEncryptionKey() ([]byte, error) {
	encodedKey := strings.TrimSpace(cfg.CredentialsMasterKey)
	return decodeOptionalCredentialEncryptionKey(encodedKey, "MESHX_PERSONAL_ACCESS_TOKEN_CREDENTIALS_MASTER_KEY")
}

func decodeOptionalCredentialEncryptionKey(encodedKey, envName string) ([]byte, error) {
	if encodedKey == "" {
		return nil, nil
	}
	return decodeCredentialEncryptionKey(encodedKey, envName)
}

func validateAddress(address string) error {
	if strings.HasPrefix(address, ":") {
		_, err := strconv.Atoi(strings.TrimPrefix(address, ":"))
		if err != nil {
			return fmt.Errorf("invalid port in %q", address)
		}
		return nil
	}
	_, _, err := net.SplitHostPort(address)
	if err != nil {
		return fmt.Errorf("must be host:port or :port: %w", err)
	}
	return nil
}

// HTTPPort returns the port component from a normalized MeshX HTTP listen address.
func HTTPPort(address string) string {
	if strings.HasPrefix(address, ":") {
		return strings.TrimPrefix(address, ":")
	}
	_, port, err := net.SplitHostPort(address)
	if err != nil {
		return strings.TrimPrefix(address, ":")
	}
	return port
}

func normalizeAbsoluteHTTPURL(raw string) (string, error) {
	raw = strings.TrimRight(strings.TrimSpace(raw), "/")
	if raw == "" {
		return "", nil
	}
	parsed, err := url.Parse(raw)
	if err != nil {
		return "", err
	}
	if parsed.Scheme != "http" && parsed.Scheme != "https" {
		return "", fmt.Errorf("must use http or https")
	}
	if strings.TrimSpace(parsed.Host) == "" {
		return "", fmt.Errorf("must include a host")
	}
	if parsed.User != nil {
		return "", fmt.Errorf("must not include user info")
	}
	if parsed.RawQuery != "" || parsed.Fragment != "" {
		return "", fmt.Errorf("must not include query or fragment")
	}
	return raw, nil
}

func parseProxyURL(raw string, envName string) (*url.URL, error) {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return nil, nil
	}
	if !strings.Contains(trimmed, "://") {
		return &url.URL{
			Scheme: "socks5",
			Host:   trimmed,
		}, nil
	}
	parsed, err := url.Parse(trimmed)
	if err != nil {
		return nil, fmt.Errorf("%s must be a valid proxy URL: %w", envName, err)
	}
	if strings.TrimSpace(parsed.Host) == "" {
		return nil, fmt.Errorf("%s must include a proxy host", envName)
	}
	switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
	case "http", "https", "socks5", "socks5h":
	default:
		return nil, fmt.Errorf("%s must use http, https, socks5, or socks5h", envName)
	}
	return parsed, nil
}

func stringEnv(preferred string, legacy ...string) string {
	return envcompat.String(preferred, "", legacy...)
}

func firstNonEmpty(value, fallback string) string {
	if strings.TrimSpace(value) == "" {
		return fallback
	}
	return value
}

func boolEnvDefault(key string, fallback bool) bool {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	parsed, err := strconv.ParseBool(raw)
	if err != nil {
		return fallback
	}
	return parsed
}

func intEnvDefault(preferred string, fallback int, legacy ...string) int {
	raw := envcompat.String(preferred, "", legacy...)
	if raw == "" {
		return fallback
	}
	parsed, err := strconv.Atoi(raw)
	if err != nil || parsed <= 0 {
		return fallback
	}
	return parsed
}

func int64EnvDefault(key string, fallback int64) int64 {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	parsed, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || parsed <= 0 {
		return fallback
	}
	return parsed
}

func floatEnvDefault(key string, fallback float64) float64 {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	parsed, err := strconv.ParseFloat(raw, 64)
	if err != nil || parsed <= 0 {
		return fallback
	}
	return parsed
}

func durationEnvDefault(key string, fallback time.Duration) time.Duration {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	parsed, err := time.ParseDuration(raw)
	if err != nil || parsed <= 0 {
		return fallback
	}
	return parsed
}

func splitCSV(raw string) []string {
	var values []string
	for _, part := range strings.Split(raw, ",") {
		if trimmed := strings.TrimSpace(part); trimmed != "" {
			values = append(values, trimmed)
		}
	}
	return values
}

func parseAutomationDockerImages(raw string) (map[string]string, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, nil
	}
	images := map[string]string{}
	for _, trimmed := range splitCSV(raw) {
		label, image, ok := strings.Cut(trimmed, "=")
		if !ok {
			return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_IMAGES entries must be label=image")
		}
		label = strings.TrimSpace(label)
		image = strings.TrimSpace(image)
		if label == "" || image == "" {
			return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_IMAGES entries must include label and image")
		}
		if _, ok := images[label]; ok {
			return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_IMAGES label %q is duplicated", label)
		}
		images[label] = image
	}
	return images, nil
}

func normalizeAutomationDockerImages(raw map[string]string) (map[string]string, error) {
	if len(raw) == 0 {
		return nil, nil
	}
	images := make(map[string]string, len(raw))
	for label, image := range raw {
		normalized := normalizeAutomationDockerLabel(label)
		if normalized == "" {
			return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_IMAGES labels must be non-empty")
		}
		image = strings.TrimSpace(image)
		if image == "" {
			return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_IMAGES image for label %q is required", label)
		}
		if _, ok := images[normalized]; ok {
			return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_IMAGES label %q is duplicated", label)
		}
		images[normalized] = image
	}
	return images, nil
}

func normalizeAutomationDockerLabel(label string) string {
	return strings.ToLower(strings.TrimSpace(label))
}

func parseAutomationDockerMounts(raw string) ([]AutomationDockerMount, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, nil
	}
	var mounts []AutomationDockerMount
	for _, item := range splitCSV(raw) {
		parts := strings.Split(item, ":")
		if len(parts) < 2 || len(parts) > 3 {
			return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS entries must be host_path:container_path[:ro|rw]")
		}
		source := strings.TrimSpace(parts[0])
		target := strings.TrimSpace(parts[1])
		readOnly := true
		if len(parts) == 3 {
			switch strings.ToLower(strings.TrimSpace(parts[2])) {
			case "", "ro", "readonly", "read-only":
				readOnly = true
			case "rw", "readwrite", "read-write":
				readOnly = false
			default:
				return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS mode for %q must be ro or rw", target)
			}
		}
		mounts = append(mounts, AutomationDockerMount{Source: source, Target: target, ReadOnly: readOnly})
	}
	return mounts, nil
}

func normalizeAutomationDockerMounts(raw []AutomationDockerMount) ([]AutomationDockerMount, error) {
	if len(raw) == 0 {
		return nil, nil
	}
	out := make([]AutomationDockerMount, 0, len(raw))
	targets := map[string]struct{}{}
	for _, item := range raw {
		source, err := normalizeAutomationDockerMountSource(item.Source)
		if err != nil {
			return nil, err
		}
		target, err := normalizeAutomationDockerMountTarget(item.Target)
		if err != nil {
			return nil, err
		}
		if _, ok := targets[target]; ok {
			return nil, fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS target %q is duplicated", target)
		}
		targets[target] = struct{}{}
		out = append(out, AutomationDockerMount{Source: source, Target: target, ReadOnly: item.ReadOnly})
	}
	return out, nil
}

func normalizeAutomationDockerMountSource(raw string) (string, error) {
	source := strings.TrimSpace(raw)
	if source == "" {
		return "", fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS source is required")
	}
	if strings.HasPrefix(source, "~/") || source == "~" {
		home, err := os.UserHomeDir()
		if err != nil || strings.TrimSpace(home) == "" {
			return "", fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS could not resolve home directory")
		}
		if source == "~" {
			source = home
		} else {
			source = filepath.Join(home, strings.TrimPrefix(source, "~/"))
		}
	}
	if !filepath.IsAbs(source) {
		return "", fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS source %q must be absolute or use ~/", raw)
	}
	return filepath.Clean(source), nil
}

func normalizeAutomationDockerMountTarget(raw string) (string, error) {
	target := path.Clean(strings.TrimSpace(raw))
	if target == "." || !strings.HasPrefix(target, "/") {
		return "", fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS target %q must be an absolute container path", raw)
	}
	if target == "/" {
		return "", fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS target must not be /")
	}
	if target == "/workspace" || strings.HasPrefix(target, "/workspace/") {
		return "", fmt.Errorf("MESHX_AUTOMATION_DOCKER_MOUNTS target must not be /workspace or a child of /workspace")
	}
	return target, nil
}

func normalizeAutomationRoles(values []string) ([]string, error) {
	if len(values) == 0 {
		return []string{AutomationRoleAPI, AutomationRoleScheduler, AutomationRoleRunnerDispatch, AutomationRoleEventer, AutomationRoleReconciler}, nil
	}
	allowed := map[string]struct{}{
		AutomationRoleAPI:            {},
		AutomationRoleScheduler:      {},
		AutomationRoleRunnerDispatch: {},
		AutomationRoleEventer:        {},
		AutomationRoleReconciler:     {},
	}
	var roles []string
	seen := map[string]struct{}{}
	for _, value := range values {
		role := NormalizeAutomationRole(value)
		if role == "" {
			continue
		}
		if role == "all" {
			return []string{AutomationRoleAPI, AutomationRoleScheduler, AutomationRoleRunnerDispatch, AutomationRoleEventer, AutomationRoleReconciler}, nil
		}
		if _, ok := allowed[role]; !ok {
			return nil, fmt.Errorf("MESHX_AUTOMATION_ROLES: unknown role %q", value)
		}
		if _, ok := seen[role]; ok {
			continue
		}
		seen[role] = struct{}{}
		roles = append(roles, role)
	}
	if len(roles) == 0 {
		return []string{AutomationRoleAPI, AutomationRoleScheduler, AutomationRoleRunnerDispatch, AutomationRoleEventer, AutomationRoleReconciler}, nil
	}
	return roles, nil
}

const DefaultShutdownTimeout = 10 * time.Second
