// Package config defines both the legacy split cp9.yaml/cp9.local.yaml schema
// and the newer unified v1alpha2 session config schema.
package config

import (
	"bytes"
	"fmt"
	"os"
	"regexp"
	"strings"
	"time"

	"gopkg.in/yaml.v3"

	cperr "github.com/tidbcloud/controlplane9/internal/errors"
)

const (
	// APIVersionV1 is the only supported apiVersion.
	APIVersionV1 = "cp9/v1"

	// DefaultConfigFile is the default config file name.
	DefaultConfigFile = "cp9.yaml"

	// DefaultLocalConfigFile is the default local override file name.
	DefaultLocalConfigFile = "cp9.local.yaml"

	maxHostEC2RootVolumeSizeGiB = 16_384
)

// DatabaseProvider enumerates supported database providers.
type DatabaseProvider string

const (
	DBTiDBCloudZero    DatabaseProvider = "tidb-cloud-zero"
	DBTiDBCloudStarter DatabaseProvider = "tidb-cloud-starter"
	DBLocalMySQL       DatabaseProvider = "local-mysql"
)

// BudgetAction enumerates actions when retry budget is exceeded.
type BudgetAction string

const (
	BudgetActionDraftPR BudgetAction = "draft-pr"
	BudgetActionFail    BudgetAction = "fail"
)

// ReadinessType enumerates readiness check types.
type ReadinessType string

const (
	ReadinessAPI ReadinessType = "api"
	ReadinessTCP ReadinessType = "tcp"
)

// SeedMode enumerates database seed modes.
type SeedMode string

const (
	SeedSchemaOnly   SeedMode = "schema-only"
	SeedConfigTables SeedMode = "config-tables"
	SeedFull         SeedMode = "full"
)

// StagingAccessMethod enumerates methods for connecting to the staging database.
type StagingAccessMethod string

const (
	StagingAccessPortForward StagingAccessMethod = "port-forward"
	StagingAccessDirect      StagingAccessMethod = "direct"
)

// CRDSeedMode enumerates CRD seed modes.
type CRDSeedMode string

const (
	CRDSeedCRDsOnly  CRDSeedMode = "crds-only"
	CRDSeedConfigCRs CRDSeedMode = "config-crs"
	CRDSeedFull      CRDSeedMode = "full"
)

// Config represents the full cp9.yaml configuration.
type Config struct {
	APIVersion string         `yaml:"apiVersion"     json:"api_version"`
	Name       string         `yaml:"name,omitempty" json:"name,omitempty"`
	Cluster    ClusterConfig  `yaml:"cluster"        json:"cluster"`
	Clouds     CloudsConfig   `yaml:"clouds"         json:"clouds"`
	Tunnel     TunnelConfig   `yaml:"tunnel"         json:"tunnel,omitzero"`
	Reaper     ReaperConfig   `yaml:"reaper"         json:"reaper,omitzero"`
	Personas   PersonasConfig `yaml:"personas"       json:"personas,omitzero"`
	Staging    StagingConfig  `yaml:"staging"        json:"staging,omitzero"`
	Metadb     DatabaseConfig `yaml:"metadb"         json:"metadb"`
	CI         CIConfig       `yaml:"ci"             json:"ci,omitzero"`
	CRDs       CRDConfig      `yaml:"crds"           json:"crds,omitzero"`
	Retry      RetryConfig    `yaml:"retry"          json:"retry"`

	Readiness map[string]ReadinessCheck `yaml:"readiness" json:"readiness,omitempty"`
}

// ClusterConfig defines k3d cluster settings.
type ClusterConfig struct {
	Name string    `yaml:"name" json:"name"`
	K3d  K3dConfig `yaml:"k3d"  json:"k3d"`
}

// K3dConfig defines k3d-specific settings.
// For advanced k3d options not exposed here, use k3d's native CLI or config directly.
type K3dConfig struct {
	Agents   int    `yaml:"agents"    json:"agents"`
	Memory   string `yaml:"memory"    json:"memory"`
	Version  string `yaml:"version"   json:"version,omitempty"`
	K3sImage string `yaml:"k3s_image" json:"k3s_image,omitempty"`
}

// CloudsConfig groups per-cloud settings.
type CloudsConfig struct {
	AWS AWSConfig `yaml:"aws" json:"aws"`
}

// StagingConfig holds paths to staging artifacts used for overlay generation.
// AppDelivery is the path to the app-delivery repo containing ArgoCD manifests.
type StagingConfig struct {
	AppDelivery string `yaml:"app_delivery" json:"app_delivery,omitempty"`
}

// AWSConfig defines AWS settings for cp9-managed cloud access.
type AWSConfig struct {
	Profile          string   `yaml:"profile"                     json:"profile"`
	Regions          []string `yaml:"regions,omitempty"           json:"regions,omitempty"`
	AccountAllowlist []string `yaml:"account_allowlist,omitempty" json:"account_allowlist,omitempty"`
	TTL              Duration `yaml:"ttl"                         json:"ttl"`

	// Region is the first configured AWS region, kept as an internal convenience
	// field for existing runtime call sites.
	Region string `yaml:"-" json:"-"`
}

// TunnelConfig defines how cp9 reaches the dev VPC.
type TunnelConfig struct {
	Mechanism string          `yaml:"mechanism,omitempty" json:"mechanism,omitempty"`
	VPCA      TunnelVPCConfig `yaml:"vpc_a"               json:"vpc_a,omitzero"`
}

// TunnelVPCConfig identifies the dev VPC side used by the tunnel.
type TunnelVPCConfig struct {
	Account           string `yaml:"account,omitempty"             json:"account,omitempty"`
	Region            string `yaml:"region,omitempty"              json:"region,omitempty"`
	ExpectedEgressEIP string `yaml:"expected_egress_eip,omitempty" json:"expected_egress_eip,omitempty"`
}

// ReaperConfig defines default scope for the destroy reaper stage.
type ReaperConfig struct {
	Clouds         []string `yaml:"clouds,omitempty"          json:"clouds,omitempty"`
	DefaultRegions []string `yaml:"default_regions,omitempty" json:"default_regions,omitempty"`
}

// PersonasConfig configures repo-level persona defaults.
type PersonasConfig struct {
	Default string `yaml:"default,omitempty" json:"default,omitempty"`
}

// CIConfig defines automation-only identity allowlists.
type CIConfig struct {
	OwnerAllowlist map[string][]string `yaml:"owner_allowlist,omitempty" json:"owner_allowlist,omitempty"`
}

// DatabaseConfig defines metadb provider settings.
type DatabaseConfig struct {
	Provider      DatabaseProvider    `yaml:"provider"       json:"provider"`
	StagingAccess StagingAccessConfig `yaml:"staging_access" json:"staging_access,omitzero"`
	Seed          SeedConfig          `yaml:"seed"           json:"seed,omitzero"`
}

// StagingAccessConfig defines how to connect to the staging database for schema dumps.
type StagingAccessConfig struct {
	Method     StagingAccessMethod `yaml:"method"     json:"method,omitempty"`
	Kubeconfig string              `yaml:"kubeconfig" json:"kubeconfig,omitempty"`
}

// SeedConfig defines database seed behavior.
type SeedConfig struct {
	Default   SeedMode                     `yaml:"default"   json:"default,omitempty"`
	Overrides map[string]SeedOverrideEntry `yaml:"overrides" json:"overrides,omitempty"`
}

// SeedOverrideEntry defines per-service seed table overrides.
type SeedOverrideEntry struct {
	Tables []string `yaml:"tables" json:"tables"`
}

// ReadinessCheck defines a health check for a service.
type ReadinessCheck struct {
	Type         ReadinessType `yaml:"type"                    json:"type"`
	Method       string        `yaml:"method,omitempty"        json:"method,omitempty"`
	Path         string        `yaml:"path,omitempty"          json:"path,omitempty"`
	ExpectStatus int           `yaml:"expect_status,omitempty" json:"expect_status,omitempty"`
	Port         int           `yaml:"port,omitempty"          json:"port,omitempty"`
}

// RetryConfig defines retry budgets.
type RetryConfig struct {
	Build            int          `yaml:"build"              json:"build"`
	Deploy           int          `yaml:"deploy"             json:"deploy"`
	Test             int          `yaml:"test"               json:"test"`
	Total            int          `yaml:"total"              json:"total"`
	OnBudgetExceeded BudgetAction `yaml:"on_budget_exceeded" json:"on_budget_exceeded"`
}

// CRDConfig defines CRD installation settings.
type CRDConfig struct {
	StagingAccess CRDStagingAccess      `yaml:"staging_access" json:"staging_access,omitzero"`
	Mappings      map[string]CRDMapping `yaml:"mappings"       json:"mappings,omitempty"`
	Seed          CRDSeedConfig         `yaml:"seed"           json:"seed,omitzero"`
}

// CRDStagingAccess defines how to access the staging K8s cluster for CRD dumps.
type CRDStagingAccess struct {
	Kubeconfig string `yaml:"kubeconfig" json:"kubeconfig,omitempty"`
	Context    string `yaml:"context"    json:"context,omitempty"`
}

// CRDMapping maps a controller service to its CRD API groups.
type CRDMapping struct {
	APIGroups []string `yaml:"api_groups" json:"api_groups"`
	Exclude   bool     `yaml:"exclude"    json:"exclude,omitempty"`
}

// CRDSeedConfig defines which CRs to seed from staging.
type CRDSeedConfig struct {
	Default   CRDSeedMode                     `yaml:"default"   json:"default,omitempty"`
	Overrides map[string]CRDSeedOverrideEntry `yaml:"overrides" json:"overrides,omitempty"`
}

// CRDSeedOverrideEntry defines per-service CR seed overrides.
type CRDSeedOverrideEntry struct {
	Kinds []string `yaml:"kinds" json:"kinds"`
}

// LocalConfig represents cp9.local.yaml overrides.
type LocalConfig struct {
	Name      string                    `yaml:"name,omitempty"    json:"name,omitempty"`
	Persona   string                    `yaml:"persona,omitempty" json:"persona,omitempty"`
	Host      HostConfig                `yaml:"host"              json:"host,omitzero"`
	Sources   map[string]SourceOverride `yaml:"sources"           json:"sources,omitempty"`
	Overrides OverridesConfig           `yaml:"overrides"         json:"overrides,omitzero"`
	Secrets   map[string]SecretSource   `yaml:"secrets"           json:"secrets,omitempty"`
}

// SecretSource defines a secret source for a service.
type SecretSource struct {
	EnvFile string            `yaml:"env_file" json:"env_file,omitempty"`
	Values  map[string]string `yaml:"values"   json:"values,omitempty"`
}

// SourceOverride defines a local source pointer for a REAL service.
//
// Path must point at the service's main-package entry file
// (e.g. ../tidb-management-service/cmd/tidb-mgmt-portal/main.go), not at the
// repo or package directory: a single repo may host many main packages, so a
// directory is ambiguous. The build context is derived as filepath.Dir(Path).
// Ref is the optional git ref the developer expects to be checked out.
type SourceOverride struct {
	Path string `yaml:"path"          json:"path"`
	Ref  string `yaml:"ref,omitempty" json:"ref,omitempty"`
}

// HostMode selects where the cp9 k3d control-plane host runs.
type HostMode string

const (
	HostModeLocalK3d     HostMode = "local-k3d"
	HostModeRemoteEC2K3d HostMode = "remote-ec2-k3d"
	// HostModeRemoteGCEK3d is the internal second-runtime mode derived from
	// dedicated.gcp. It is not accepted as the primary user-facing host.mode.
	HostModeRemoteGCEK3d HostMode = "remote-gce-k3d"
	// HostModeRemoteEKS is an internal selected-host mode derived from
	// Terraform's Starter regional remote EKS output. It is not accepted as a
	// user-facing host.mode value.
	HostModeRemoteEKS HostMode = "remote-eks"
)

// HostConfig selects the cp9 host adapter.
type HostConfig struct {
	Mode HostMode      `yaml:"mode,omitempty" json:"mode,omitempty"`
	EC2  HostEC2Config `yaml:"ec2"            json:"ec2,omitzero"`
}

// HostEC2Config holds remote host sizing hints.
type HostEC2Config struct {
	InstanceType      string   `yaml:"instance_type,omitempty"        json:"instance_type,omitempty"`
	AMI               string   `yaml:"ami,omitempty"                  json:"ami,omitempty"`
	KeyPair           string   `yaml:"key_pair,omitempty"             json:"key_pair,omitempty"`
	SSHUser           string   `yaml:"ssh_user,omitempty"             json:"ssh_user,omitempty"`
	SSHPrivateKey     string   `yaml:"ssh_private_key,omitempty"      json:"ssh_private_key,omitempty"`
	SubnetID          string   `yaml:"subnet_id,omitempty"            json:"subnet_id,omitempty"`
	SecurityGroupIDs  []string `yaml:"security_group_ids,omitempty"   json:"security_group_ids,omitempty"`
	InstanceProfile   string   `yaml:"instance_profile,omitempty"     json:"instance_profile,omitempty"`
	AllowedCIDR       string   `yaml:"allowed_cidr,omitempty"         json:"allowed_cidr,omitempty"`
	RootVolumeSizeGiB int      `yaml:"root_volume_size_gib,omitempty" json:"root_volume_size_gib,omitempty"`
}

// OverridesConfig defines per-session persona overrides.
type OverridesConfig struct {
	ForceReal []string                     `yaml:"force_real,omitempty" json:"force_real,omitempty"`
	ForceStub []string                     `yaml:"force_stub,omitempty" json:"force_stub,omitempty"`
	ExtraEnv  map[string]map[string]string `yaml:"extra_env,omitempty"  json:"extra_env,omitempty"`
}

// Duration wraps time.Duration for YAML unmarshaling from strings like "72h".
type Duration struct {
	time.Duration
}

func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
	var s string
	if err := value.Decode(&s); err != nil {
		return err
	}
	parsed, err := time.ParseDuration(s)
	if err != nil {
		return fmt.Errorf("invalid duration %q: %w", s, err)
	}
	d.Duration = parsed
	return nil
}

func (d Duration) MarshalYAML() (any, error) {
	return d.String(), nil
}

func (d Duration) MarshalJSON() ([]byte, error) {
	return fmt.Appendf(nil, "%q", d.String()), nil
}

// dnsNameRegex validates that a name is DNS-compatible (RFC 1123 label).
var dnsNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*[a-z0-9]$`)

var reservedSessionNames = map[string]struct{}{
	"destroy-summaries":         {},
	"lifecycle-operation-locks": {},
	"reaper":                    {},
}

// ec2InstanceTypeRegex validates the general EC2 instance type shape while
// allowing both Graviton/arm64 families (m7g.xlarge) and amd64 families
// (m7i.xlarge) for remote-ec2-k3d.
var ec2InstanceTypeRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*\d+[a-z0-9-]*\.[a-z0-9-]+$`)

// envVarRegex matches ${VAR_NAME} patterns.
var envVarRegex = regexp.MustCompile(`\$\{([^}]+)\}`)

// expandEnvVars expands ${VAR} patterns in a string using os.Getenv.
func expandEnvVars(s string) string {
	return expandEnvVarsExcept(s)
}

// expandEnvVarsExcept expands ${VAR} patterns in a string using os.Getenv,
// except for placeholder names that cp9 needs to resolve later.
func expandEnvVarsExcept(s string, except ...string) string {
	skip := make(map[string]struct{}, len(except))
	for _, name := range except {
		skip[name] = struct{}{}
	}
	return envVarRegex.ReplaceAllStringFunc(s, func(match string) string {
		varName := match[2 : len(match)-1]
		if _, ok := skip[varName]; ok {
			return match
		}
		if val, ok := os.LookupEnv(varName); ok {
			return val
		}
		return match // leave unexpanded if not set
	})
}

// expandEnvInBytes expands ${VAR} in raw YAML bytes before parsing.
func expandEnvInBytes(data []byte) []byte {
	return []byte(expandEnvVars(string(data)))
}

// expandEnvInBytesExcept expands ${VAR} in raw YAML bytes before parsing,
// except for placeholders that cp9 must preserve for a later resolution pass.
func expandEnvInBytesExcept(data []byte, except ...string) []byte {
	return []byte(expandEnvVarsExcept(string(data), except...))
}

// Load reads cp9.yaml and optionally merges cp9.local.yaml, validates, and returns the config.
func Load(configPath, localConfigPath string) (*Config, *LocalConfig, error) {
	cfg, err := loadConfig(configPath)
	if err != nil {
		return nil, nil, err
	}

	var local *LocalConfig
	if localConfigPath != "" {
		if _, statErr := os.Stat(localConfigPath); statErr == nil {
			local, err = loadLocalConfig(localConfigPath)
			if err != nil {
				return nil, nil, err
			}
		} else if !os.IsNotExist(statErr) {
			return nil, nil, cperr.Newf(cperr.CategoryConfig,
				"cannot access local config file %q: %v", localConfigPath, statErr)
		}
	}

	applyDefaults(cfg, local)
	if err := validate(cfg, local); err != nil {
		return nil, nil, err
	}

	if name, ok, err := ResolveSessionName(cfg, local); err != nil {
		return nil, nil, err
	} else if ok {
		cfg.Name = name
		cfg.Cluster.Name = name
	}
	if local != nil && local.Persona == "" && cfg.Personas.Default != "" {
		local.Persona = cfg.Personas.Default
	}

	return cfg, local, nil
}

// applyDefaults fills in zero-valued optional fields with documented defaults.
func applyDefaults(cfg *Config, local *LocalConfig) {
	if cfg.Cluster.K3d.Agents == 0 {
		cfg.Cluster.K3d.Agents = 2
	}
	if cfg.Cluster.K3d.Memory == "" {
		cfg.Cluster.K3d.Memory = "8G"
	}
	if len(cfg.Clouds.AWS.Regions) == 0 && cfg.Clouds.AWS.Region != "" {
		cfg.Clouds.AWS.Regions = []string{cfg.Clouds.AWS.Region}
	}
	if len(cfg.Clouds.AWS.Regions) == 0 {
		cfg.Clouds.AWS.Regions = []string{"us-west-2"}
	}
	cfg.Clouds.AWS.Region = cfg.Clouds.AWS.Regions[0]
	if cfg.Clouds.AWS.TTL.Duration == 0 {
		cfg.Clouds.AWS.TTL.Duration = 72 * time.Hour
	}
	if cfg.Metadb.Provider == "" {
		cfg.Metadb.Provider = DBTiDBCloudZero
	}
	if cfg.Retry.Build == 0 {
		cfg.Retry.Build = 3
	}
	if cfg.Retry.Deploy == 0 {
		cfg.Retry.Deploy = 2
	}
	if cfg.Retry.Test == 0 {
		cfg.Retry.Test = 3
	}
	if cfg.Retry.Total == 0 {
		cfg.Retry.Total = 5
	}
	if cfg.Retry.OnBudgetExceeded == "" {
		cfg.Retry.OnBudgetExceeded = BudgetActionDraftPR
	}
	if local != nil && local.Host.Mode == "" {
		local.Host.Mode = HostModeLocalK3d
	}
	if local != nil && local.Host.Mode == HostModeRemoteEC2K3d && local.Host.EC2.InstanceType == "" {
		local.Host.EC2.InstanceType = "m7g.xlarge"
	}
}

func loadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "cannot read config file %q: %v", path, err)
	}

	data = expandEnvInBytes(data)

	if version, probeErr := detectAPIVersionBytes(data); probeErr == nil && version == APIVersionV1Alpha2 {
		return nil, cperr.New(
			cperr.CategoryConfig,
			"unified v1alpha2 session config detected; legacy split-config commands still expect cp9/v1 + cp9.local.yaml. Use the v1alpha2 session path with cp9.dev-full.remote-ec2-k3d.example.yaml",
		)
	}

	var cfg Config
	if err := decodeStrictYAML(data, &cfg); err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "invalid YAML in %q: %v", path, err)
	}

	return &cfg, nil
}

func loadLocalConfig(path string) (*LocalConfig, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "cannot read local config file %q: %v", path, err)
	}

	data = expandEnvInBytes(data)

	var local LocalConfig
	if err := decodeStrictYAML(data, &local); err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "invalid YAML in %q: %v", path, err)
	}

	return &local, nil
}

func decodeStrictYAML(data []byte, out any) error {
	dec := yaml.NewDecoder(bytes.NewReader(data))
	dec.KnownFields(true)
	return dec.Decode(out)
}

// ResolveSessionName returns the effective session name and whether one was provided.
func ResolveSessionName(cfg *Config, local *LocalConfig) (string, bool, error) {
	if local != nil && strings.TrimSpace(local.Name) != "" {
		return validateSessionName(local.Name)
	}
	if strings.TrimSpace(cfg.Name) != "" {
		return validateSessionName(cfg.Name)
	}
	if cfg.Cluster.Name != "" {
		return "", false, cperr.New(cperr.CategoryConfig,
			"cluster.name is no longer the active session identity; move it to cp9.local.yaml name or cp9.yaml name")
	}
	return "", false, nil
}

// ResolvePersonaName returns the effective persona name and whether one was provided.
func ResolvePersonaName(cfg *Config, local *LocalConfig) (string, bool) {
	if local != nil && strings.TrimSpace(local.Persona) != "" {
		return local.Persona, true
	}
	if strings.TrimSpace(cfg.Personas.Default) != "" {
		return cfg.Personas.Default, true
	}
	return "", false
}

func validateSessionName(name string) (string, bool, error) {
	name, ok, err := validateSessionNameSyntax(name)
	if err != nil {
		return "", false, err
	}
	if _, reserved := reservedSessionNames[name]; reserved {
		return "", false, cperr.Newf(
			cperr.CategoryConfig,
			"name %q is reserved for CP9 internal state compatibility; choose another session name",
			name,
		)
	}
	return name, ok, nil
}

func validateSessionNameSyntax(name string) (string, bool, error) {
	name = strings.TrimSpace(name)
	if len(name) < 3 || len(name) > 32 {
		return "", false, cperr.Newf(cperr.CategoryConfig,
			"name %q must be 3-32 characters", name)
	}
	if !dnsNameRegex.MatchString(name) {
		return "", false, cperr.Newf(cperr.CategoryConfig,
			"name %q must be DNS-compatible (lowercase alphanumeric and hyphens, starting with a letter)", name)
	}
	return name, true, nil
}

// validate checks required fields and valid enum values.
func validate(cfg *Config, local *LocalConfig) error {
	if cfg.APIVersion != APIVersionV1 {
		return cperr.Newf(cperr.CategoryConfig,
			"unsupported apiVersion %q (expected %q)", cfg.APIVersion, APIVersionV1)
	}

	if _, ok, err := ResolveSessionName(cfg, local); err != nil {
		return err
	} else if !ok {
		return cperr.New(cperr.CategoryConfig,
			"name is required in cp9.local.yaml (or cp9.yaml name as a reviewed repo default)")
	}

	if local != nil {
		if _, ok := ResolvePersonaName(cfg, local); !ok {
			return cperr.New(cperr.CategoryConfig,
				"persona is required in cp9.local.yaml or cp9.yaml personas.default")
		}
		switch local.Host.Mode {
		case HostModeLocalK3d, HostModeRemoteEC2K3d:
			// valid
		default:
			return cperr.Newf(cperr.CategoryConfig,
				"host.mode %q is invalid (expected %q or %q)",
				local.Host.Mode, HostModeLocalK3d, HostModeRemoteEC2K3d)
		}
		if local.Host.Mode == HostModeRemoteEC2K3d {
			switch {
			case strings.TrimSpace(local.Host.EC2.AMI) == "":
				return cperr.New(cperr.CategoryConfig, "host.ec2.ami is required for host.mode remote-ec2-k3d")
			case strings.TrimSpace(local.Host.EC2.KeyPair) == "":
				return cperr.New(cperr.CategoryConfig, "host.ec2.key_pair is required for host.mode remote-ec2-k3d")
			case strings.TrimSpace(local.Host.EC2.SSHUser) == "":
				return cperr.New(cperr.CategoryConfig, "host.ec2.ssh_user is required for host.mode remote-ec2-k3d")
			case strings.TrimSpace(local.Host.EC2.SSHPrivateKey) == "":
				return cperr.New(cperr.CategoryConfig, "host.ec2.ssh_private_key is required for host.mode remote-ec2-k3d")
			case strings.TrimSpace(local.Host.EC2.SubnetID) == "":
				return cperr.New(cperr.CategoryConfig, "host.ec2.subnet_id is required for host.mode remote-ec2-k3d")
			}
			if local.Host.EC2.RootVolumeSizeGiB < 0 {
				return cperr.New(cperr.CategoryConfig, "host.ec2.root_volume_size_gib must be non-negative when set")
			}
			if local.Host.EC2.RootVolumeSizeGiB > maxHostEC2RootVolumeSizeGiB {
				return cperr.Newf(
					cperr.CategoryConfig,
					"host.ec2.root_volume_size_gib must be at most %d GiB",
					maxHostEC2RootVolumeSizeGiB,
				)
			}
			if !ec2InstanceTypeRegex.MatchString(strings.TrimSpace(local.Host.EC2.InstanceType)) {
				return cperr.Newf(
					cperr.CategoryConfig,
					"host.ec2.instance_type %q must be a valid EC2 instance type for host.mode remote-ec2-k3d (for example %q)",
					local.Host.EC2.InstanceType,
					"m7g.xlarge",
				)
			}
		}
		for svc, src := range local.Sources {
			if src.Path == "" {
				return cperr.Newf(cperr.CategoryConfig, "sources[%q].path is required", svc)
			}
			if !strings.HasSuffix(src.Path, "main.go") {
				return cperr.Newf(cperr.CategoryConfig,
					"sources[%q].path %q must point at a main.go file", svc, src.Path)
			}
		}
	}

	switch cfg.Metadb.Provider {
	case DBTiDBCloudZero, DBTiDBCloudStarter, DBLocalMySQL:
		// valid
	default:
		return cperr.Newf(cperr.CategoryConfig,
			"invalid metadb.provider %q (expected one of: %s, %s, %s)",
			cfg.Metadb.Provider, DBTiDBCloudZero, DBTiDBCloudStarter, DBLocalMySQL)
	}

	if cfg.Clouds.AWS.TTL.Duration < 0 {
		return cperr.Newf(cperr.CategoryConfig,
			"clouds.aws.ttl %v must not be negative", cfg.Clouds.AWS.TTL.Duration)
	}
	if cfg.Clouds.AWS.TTL.Duration > 168*time.Hour {
		return cperr.Newf(cperr.CategoryConfig,
			"clouds.aws.ttl %v exceeds maximum of 168h", cfg.Clouds.AWS.TTL.Duration)
	}

	if cfg.Retry.Build < 0 || cfg.Retry.Deploy < 0 || cfg.Retry.Test < 0 || cfg.Retry.Total < 0 {
		return cperr.New(cperr.CategoryConfig, "retry budgets must be non-negative")
	}

	switch cfg.Retry.OnBudgetExceeded {
	case BudgetActionDraftPR, BudgetActionFail:
		// valid
	default:
		return cperr.Newf(cperr.CategoryConfig,
			"invalid retry.on_budget_exceeded %q (expected %q or %q)",
			cfg.Retry.OnBudgetExceeded, BudgetActionDraftPR, BudgetActionFail)
	}

	for name, check := range cfg.Readiness {
		switch check.Type {
		case ReadinessAPI:
			if check.Method == "" {
				return cperr.Newf(cperr.CategoryConfig,
					"readiness[%q]: method is required for type %q", name, ReadinessAPI)
			}
			if check.Path == "" {
				return cperr.Newf(cperr.CategoryConfig,
					"readiness[%q]: path is required for type %q", name, ReadinessAPI)
			}
			if check.ExpectStatus == 0 {
				return cperr.Newf(cperr.CategoryConfig,
					"readiness[%q]: expect_status is required for type %q", name, ReadinessAPI)
			}
		case ReadinessTCP:
			if check.Port <= 0 || check.Port > 65535 {
				return cperr.Newf(cperr.CategoryConfig,
					"readiness[%q]: port must be 1-65535 for type %q", name, ReadinessTCP)
			}
		default:
			return cperr.Newf(cperr.CategoryConfig,
				"invalid readiness type %q for service %q (expected %q or %q)",
				check.Type, name, ReadinessAPI, ReadinessTCP)
		}
	}

	if cfg.Metadb.StagingAccess.Method != "" {
		switch cfg.Metadb.StagingAccess.Method {
		case StagingAccessPortForward, StagingAccessDirect:
			// valid
		default:
			return cperr.Newf(cperr.CategoryConfig,
				"metadb.staging_access.method %q is invalid (expected %q or %q)",
				cfg.Metadb.StagingAccess.Method, StagingAccessPortForward, StagingAccessDirect)
		}
	}

	if cfg.Metadb.Seed.Default != "" {
		switch cfg.Metadb.Seed.Default {
		case SeedSchemaOnly, SeedConfigTables, SeedFull:
			// valid
		default:
			return cperr.Newf(cperr.CategoryConfig,
				"metadb.seed.default %q is invalid (expected %q, %q, or %q)",
				cfg.Metadb.Seed.Default, SeedSchemaOnly, SeedConfigTables, SeedFull)
		}
	}

	// Validate CRD config.
	if cfg.CRDs.Seed.Default != "" {
		switch cfg.CRDs.Seed.Default {
		case CRDSeedCRDsOnly, CRDSeedConfigCRs, CRDSeedFull:
			// valid
		default:
			return cperr.Newf(cperr.CategoryConfig,
				"crds.seed.default %q is invalid (expected %q, %q, or %q)",
				cfg.CRDs.Seed.Default, CRDSeedCRDsOnly, CRDSeedConfigCRs, CRDSeedFull)
		}
	}
	for name, mapping := range cfg.CRDs.Mappings {
		if !mapping.Exclude && len(mapping.APIGroups) == 0 {
			return cperr.Newf(cperr.CategoryConfig,
				"crds.mappings[%q]: api_groups must not be empty (or set exclude: true)", name)
		}
	}

	return nil
}

// Defaults returns a Config with default values applied.
func Defaults() *Config {
	return &Config{
		APIVersion: APIVersionV1,
		Cluster: ClusterConfig{
			K3d: K3dConfig{
				Agents: 2,
				Memory: "8G",
			},
		},
		Clouds: CloudsConfig{
			AWS: AWSConfig{
				Regions: []string{"us-west-2"},
				Region:  "us-west-2",
				TTL:     Duration{72 * time.Hour},
			},
		},
		Metadb: DatabaseConfig{
			Provider: DBTiDBCloudZero,
		},
		Retry: RetryConfig{
			Build:            3,
			Deploy:           2,
			Test:             3,
			Total:            5,
			OnBudgetExceeded: BudgetActionDraftPR,
		},
	}
}
