package config

import (
	"bytes"
	"fmt"
	"net/netip"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"time"

	"gopkg.in/yaml.v3"

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

const (
	// APIVersionV1Alpha2 is the unified session config schema for the #106 train.
	APIVersionV1Alpha2 = "cp9.tidbcloud.com/v1alpha2"

	// SessionKind is the only supported kind for the v1alpha2 session config.
	SessionKind = "Session"

	// DefaultResolvedSessionFile is the normalized session artifact written under
	// the per-session state directory.
	DefaultResolvedSessionFile = "cp9.resolved.yaml"

	// DefaultUIOrganizationID is the complete local Account organization seeded
	// for the CP9 Web UI, Starter, and Essential v1 product catalogs.
	DefaultUIOrganizationID = "1440002"

	// EssentialV2UIOrganizationID keeps Essential v2 independent from the
	// default organization's Essential v1 plan selection.
	EssentialV2UIOrganizationID = "1440003"

	// EssentialV2UIProjectID is the seeded virtual project that owns Essential
	// V2 OpenAPI resources in the alternate organization.
	EssentialV2UIProjectID = "14400030002"

	// DevTierO11YBizType matches the real shared dev-tier O11Y contract.
	DevTierO11YBizType = "dev-devtier"
)

// SessionConfig is the unified v1alpha2 cp9 session config.
type SessionConfig struct {
	APIVersion  string                    `yaml:"apiVersion"             json:"apiVersion"`
	Kind        string                    `yaml:"kind"                   json:"kind"`
	Name        string                    `yaml:"name"                   json:"name"`
	Persona     string                    `yaml:"persona"                json:"persona"`
	Host        SessionHostConfig         `yaml:"host"                   json:"host"`
	Target      SessionTargetConfig       `yaml:"target"                 json:"target"`
	CI          SessionCIConfig           `yaml:"ci"                     json:"ci,omitzero"`
	Terraform   SessionTerraformConfig    `yaml:"terraform"              json:"terraform"`
	Artifacts   SessionArtifactsConfig    `yaml:"artifacts,omitempty"    json:"artifacts,omitzero"`
	Tilt        SessionTiltConfig         `yaml:"tilt"                   json:"tilt"`
	Services    SessionServicesConfig     `yaml:"services"               json:"services"`
	Starter     SessionStarterConfig      `yaml:"starter,omitempty"      json:"starter,omitzero"`
	EssentialV2 *SessionEssentialV2Config `yaml:"essential_v2,omitempty" json:"essential_v2,omitempty"`
	Dedicated   *SessionDedicatedConfig   `yaml:"dedicated,omitempty"    json:"dedicated,omitempty"`
	UI          SessionUIConfig           `yaml:"ui,omitempty"           json:"ui,omitzero"`
	BYOC        *SessionBYOCConfig        `yaml:"byoc,omitempty"         json:"byoc,omitempty"`
}

// SessionUIConfig carries only browser-acceptance prerequisites. The Remote
// Dev full UI itself is mandatory and therefore has no enabled flag.
type SessionUIConfig struct {
	SeedIdentity SessionUISeedIdentityConfig `yaml:"seed_identity,omitempty" json:"seed_identity,omitzero"`
	O11Y         *SessionUIO11YConfig        `yaml:"o11y,omitempty"          json:"o11y,omitempty"`
}

// SessionUISeedIdentityConfig optionally overrides the shared CP9 test
// identity. Auth0 remains the password owner; credentials are deliberately not
// part of the Session schema.
type SessionUISeedIdentityConfig struct {
	Email string `yaml:"email,omitempty" json:"email,omitempty"`
}

// SessionUIO11YConfig carries the same-origin O11Y runtime used by the isolated
// CP9 browser profile. It is runtime data and never changes the selected UI
// image.
type SessionUIO11YConfig struct {
	GSHost               string `yaml:"gs_host"                           json:"gs_host"`
	GSHostV2             string `yaml:"gs_host_v2"                        json:"gs_host_v2"`
	TestHostDomainSuffix string `yaml:"test_host_domain_suffix,omitempty" json:"test_host_domain_suffix,omitempty"`
	NextGenBizType       string `yaml:"nextgen_biz_type,omitempty"        json:"nextgen_biz_type,omitempty"`
	DedicatedBizType     string `yaml:"dedicated_biz_type,omitempty"      json:"dedicated_biz_type,omitempty"`
	DevTierBizType       string `yaml:"devtier_biz_type,omitempty"        json:"devtier_biz_type,omitempty"`
}

// SessionBYOCConfig separates the shareable customer handoff from the local
// operator credential used to automate the customer-side account setup.
type SessionBYOCConfig struct {
	Enabled      bool                          `yaml:"enabled,omitempty" json:"enabled,omitempty"`
	Customer     SessionBYOCCustomerConfig     `yaml:"customer"          json:"customer"`
	AccountSetup SessionBYOCAccountSetupConfig `yaml:"account_setup"     json:"account_setup"`
}

// SessionBYOCCustomerConfig is the auditable BYOC customer handoff. It contains
// no local AWS profile or runtime credential.
type SessionBYOCCustomerConfig struct {
	TenantID          string                        `yaml:"tenant_id"          json:"tenant_id"`
	AccountID         string                        `yaml:"account_id"         json:"account_id"`
	AvailabilityZones []SessionBYOCAvailabilityZone `yaml:"availability_zones" json:"availability_zones"`
	TiDB              SessionBYOCServiceConfig      `yaml:"tidb"               json:"tidb"`
	O11Y              SessionBYOCServiceConfig      `yaml:"o11y"               json:"o11y"`
	ImageSyncRegion   string                        `yaml:"image_sync_region"  json:"image_sync_region"`
	QuotaReady        bool                          `yaml:"quota_ready"        json:"quota_ready"`
}

type SessionBYOCAvailabilityZone struct {
	Name string `yaml:"name" json:"name"`
	ID   string `yaml:"id"   json:"id"`
}

// SessionBYOCServiceConfig describes customer-owned DNS and network
// prerequisites. PCA and CIDR are required only for TiDB in the first slice;
// O11Y runtime remains out of scope while its hosted zone is needed by the
// unmodified account-setup contract.
type SessionBYOCServiceConfig struct {
	CIDR           string `yaml:"cidr,omitempty"    json:"cidr,omitempty"`
	HostedZoneName string `yaml:"hosted_zone_name"  json:"hosted_zone_name"`
	HostedZoneID   string `yaml:"hosted_zone_id"    json:"hosted_zone_id"`
	PCAARN         string `yaml:"pca_arn,omitempty" json:"pca_arn,omitempty"`
	// PCASharedFromOtherAccount opts into a certificate authority owned outside
	// the customer account. The owning account is not repeated here -- it is
	// already inside pca_arn -- so this records only the intent that makes an
	// unrecognized account deliberate rather than a typo.
	PCASharedFromOtherAccount bool `yaml:"pca_shared_from_other_account,omitempty" json:"pca_shared_from_other_account,omitempty"`
}

// SessionBYOCAccountSetupConfig is local execution information for the CP9
// operator acting as the customer. The profile is never copied to the remote
// host or Kubernetes runtime.
type SessionBYOCAccountSetupConfig struct {
	Profile             string `yaml:"profile"                         json:"profile"`
	ClinicAccountID     string `yaml:"clinic_account_id"               json:"clinic_account_id"`
	ReuseExistingStacks bool   `yaml:"reuse_existing_stacks,omitempty" json:"reuse_existing_stacks,omitempty"`
}

// SessionStarterConfig declares opt-in Starter lifecycle behavior for a
// dev-full session. Starter regional EKS details remain internally managed via
// target.aws.remote_eks.
type SessionStarterConfig struct {
	Enabled       bool                              `yaml:"enabled,omitempty"       json:"enabled,omitempty"`
	Activation    SessionStarterActivationConfig    `yaml:"activation,omitempty"    json:"activation,omitzero"`
	AIFTS         SessionStarterAIFTSConfig         `yaml:"ai_fts,omitempty"        json:"ai_fts,omitzero"`
	Observability SessionStarterObservabilityConfig `yaml:"observability,omitempty" json:"observability,omitzero"`
}

// SessionStarterAIFTSConfig enables the optional hosted-embedding and AI/FTS
// search stack. Core Starter activation does not require this staging-parity
// integration or its private service credentials.
type SessionStarterAIFTSConfig struct {
	Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
}

// SessionStarterActivationConfig declares the physical Starter regional
// substrate plus the independent initial/minimum TiDB serving-lane target.
// Product controllers remain the owner of demand-driven TiDB Pod creation and
// reclamation beyond that bounded activation target.
type SessionStarterActivationConfig struct {
	PreProvision *bool `yaml:"pre_provision,omitempty" json:"-"`
	// Derived: fixed at 0 for staging regional parity, set by cp9 on load.
	TiDBPoolPods *int                         `yaml:"-"                  json:"tidb_pool_pods,omitempty"`
	PDMS         SessionStarterPDMSConfig     `yaml:"pdms,omitempty"     json:"pdms,omitzero"`
	Topology     SessionStarterTopologyConfig `yaml:"topology,omitempty" json:"topology,omitzero"`
}

// SessionStarterPDMSConfig selects the PD deployment model. Enabled preserves
// the Staging-compatible disaggregated PD microservices topology; disabled
// runs three monolithic PD replicas and omits the standalone TSO and resource
// manager workloads.
type SessionStarterPDMSConfig struct {
	Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
}

type StarterPDMode string

const (
	StarterPDModePDMS       StarterPDMode = "pdms"
	StarterPDModeMonolithic StarterPDMode = "monolithic"
)

// StarterPDTopologyContract is the single resolved source of truth consumed
// by render planning, dry-run, status, and observability. This create-time,
// substrate-immutable choice is intentionally distinct from the service/image
// graph owned by the Session Tiltfile.
type StarterPDTopologyContract struct {
	Mode                    StarterPDMode
	PDNodes                 int
	RootPDReplicas          int
	TSOReplicas             int
	SchedulerReplicas       int
	ResourceManagerReplicas int
	RequestCPU              string
	RequestMemory           string
	LimitCPU                string
	LimitMemory             string
}

// SessionStarterTopologyConfig exposes the reviewed instance choices for the
// seven Minimal-shaped node-group roles. Only the physical TiDB serving count is
// variable; the other role counts are fixed by the renderer contract.
type SessionStarterTopologyConfig struct {
	PD                   SessionStarterRoleConfig        `yaml:"pd,omitempty"                     json:"pd,omitzero"`
	TiKV                 SessionStarterRoleConfig        `yaml:"tikv,omitempty"                   json:"tikv,omitzero"`
	KeyspaceETCD         SessionStarterRoleConfig        `yaml:"keyspace_etcd,omitempty"          json:"keyspace_etcd,omitzero"`
	Gateway              SessionStarterRoleConfig        `yaml:"gateway,omitempty"                json:"gateway,omitzero"`
	RegionalControlPlane SessionStarterRoleConfig        `yaml:"regional_control_plane,omitempty" json:"regional_control_plane,omitzero"`
	TiDBServing          SessionStarterTiDBServingConfig `yaml:"tidb_serving,omitempty"           json:"tidb_serving,omitzero"`
}

type SessionStarterRoleConfig struct {
	InstanceType string `yaml:"instance_type,omitempty" json:"instance_type,omitempty"`
}

type SessionStarterTiDBServingConfig struct {
	InstanceType string `yaml:"instance_type,omitempty" json:"instance_type,omitempty"`
	Nodes        *int   `yaml:"nodes,omitempty"         json:"nodes,omitempty"`
}

type SessionStarterObservabilityConfig struct {
	Enabled             bool      `yaml:"enabled,omitempty"              json:"enabled,omitempty"`
	PrometheusRetention *Duration `yaml:"prometheus_retention,omitempty" json:"prometheus_retention,omitempty"`
}

// SessionEssentialV2Config declares whether cp9 up should activate the
// Premium-family Essential V2 shared pool. The same activation remains
// available after cp9 up through `cp9 essential-v2 activate`.
type SessionEssentialV2Config struct {
	Enabled                  bool     `yaml:"enabled"                               json:"enabled"`
	EnabledTiKVRemoteWorkers []string `yaml:"enabled_tikv_remote_workers,omitempty" json:"enabled_tikv_remote_workers,omitempty"`
}

const (
	DedicatedGCPRegion                  = "us-west1"
	DedicatedGCPRuntimeMachineType      = "n2-standard-8"
	DedicatedGCPRuntimeDiskGiB          = 160
	DedicatedGCPRuntimeServiceAccountID = "cp9-developer"
)

// DedicatedGCPRuntimeServiceAccountEmail returns the durable Project-bootstrap
// identity attached to every CP9 GCP regional runtime. Sessions consume this
// account but never create, bind, or delete it.
func DedicatedGCPRuntimeServiceAccountEmail(projectID string) string {
	return DedicatedGCPRuntimeServiceAccountID + "@" + strings.TrimSpace(projectID) + ".iam.gserviceaccount.com"
}

// SessionDedicatedConfig carries optional Dedicated provider runtimes that do
// not replace the primary AWS target/host contract.
type SessionDedicatedConfig struct {
	GCP *SessionDedicatedGCPConfig `yaml:"gcp,omitempty" json:"gcp,omitempty"`
}

// SessionDedicatedGCPConfig selects the first isolated GCP regional runtime.
// Authentication is deliberately absent: local gcloud and the GCE-attached
// service account own credentials outside cp9.yaml.
type SessionDedicatedGCPConfig struct {
	Enabled            bool   `yaml:"enabled,omitempty"              json:"enabled,omitempty"`
	SeedProjectID      string `yaml:"seed_project_id,omitempty"      json:"seed_project_id,omitempty"`
	DataplaneProjectID string `yaml:"dataplane_project_id,omitempty" json:"dataplane_project_id,omitempty"`
	Region             string `yaml:"region,omitempty"               json:"region,omitempty"`
	Zone               string `yaml:"zone,omitempty"                 json:"zone,omitempty"`
}

// SessionHostConfig selects the cp9 host for the session.
type SessionHostConfig struct {
	Mode     HostMode           `yaml:"mode,omitempty"     json:"mode,omitempty"`
	TTL      Duration           `yaml:"ttl,omitempty"      json:"ttl,omitzero"`
	EC2      HostEC2Config      `yaml:"ec2,omitempty"      json:"ec2,omitzero"`
	Exposure HostExposureConfig `yaml:"exposure,omitempty" json:"exposure,omitzero"`
}

// HostExposureConfig opts a remote session into public HTTPS exposure: an
// internet-facing ALB that terminates TLS with the runtime account's wildcard
// certificate and a per-session record in that account's delegated DNS zone.
// The certificate and DNS zone are account-bootstrap outputs managed by
// terraform/cp9-developer/SKILL.md, not session-owned resources.
type HostExposureConfig struct {
	Enabled        bool   `yaml:"enabled,omitempty"         json:"enabled,omitempty"`
	ZoneID         string `yaml:"zone_id,omitempty"         json:"zone_id,omitempty"`
	ZoneName       string `yaml:"zone_name,omitempty"       json:"zone_name,omitempty"`
	CertificateARN string `yaml:"certificate_arn,omitempty" json:"certificate_arn,omitempty"`
}

// SessionTargetConfig defines the customer/dataplane target for the session.
type SessionTargetConfig struct {
	Cloud  string           `yaml:"cloud"  json:"cloud"`
	Region string           `yaml:"region" json:"region"`
	AWS    SessionAWSConfig `yaml:"aws"    json:"aws"`
}

// SessionAWSConfig defines AWS-specific target settings.
type SessionAWSConfig struct {
	IAM       SessionIAMConfig `yaml:"iam"                  json:"iam"`
	RemoteEKS RemoteEKSConfig  `yaml:"remote_eks,omitempty" json:"remote_eks,omitzero"`
}

// RemoteEKSConfig defines the Starter regional EKS substrate managed by
// `cp9 starter activate`.
type RemoteEKSConfig struct {
	Enabled           bool                       `yaml:"enabled,omitempty"            json:"enabled,omitempty"`
	ClusterName       string                     `yaml:"cluster_name,omitempty"       json:"cluster_name,omitempty"`
	KubernetesVersion string                     `yaml:"kubernetes_version,omitempty" json:"kubernetes_version,omitempty"`
	NodeInstanceType  string                     `yaml:"node_instance_type,omitempty" json:"node_instance_type,omitempty"`
	NodeCount         int                        `yaml:"node_count,omitempty"         json:"node_count,omitempty"`
	NodeGroups        []RemoteEKSNodeGroupConfig `yaml:"node_groups,omitempty"        json:"node_groups,omitempty"`
}

// RemoteEKSNodeGroupConfig is an internal resolved renderer contract. The
// public session schema exposes role topology instead of arbitrary nodegroups.
type RemoteEKSNodeGroupConfig struct {
	Name         string               `yaml:"name"             json:"name"`
	InstanceType string               `yaml:"instance_type"    json:"instance_type"`
	Architecture string               `yaml:"architecture"     json:"architecture"`
	AMIType      string               `yaml:"ami_type"         json:"ami_type"`
	DesiredSize  int                  `yaml:"desired_size"     json:"desired_size"`
	MinSize      int                  `yaml:"min_size"         json:"min_size"`
	MaxSize      int                  `yaml:"max_size"         json:"max_size"`
	Labels       map[string]string    `yaml:"labels,omitempty" json:"labels,omitempty"`
	Taints       []RemoteEKSNodeTaint `yaml:"taints,omitempty" json:"taints,omitempty"`
}

type RemoteEKSNodeTaint struct {
	Key    string `yaml:"key"    json:"key"`
	Value  string `yaml:"value"  json:"value"`
	Effect string `yaml:"effect" json:"effect"`
}

// SessionIAMConfig defines the local IAM contract for the selected session.
// Profile is internal resolved state derived from SessionConfig.Name.
type SessionIAMConfig struct {
	Profile       string `yaml:"profile,omitempty"        json:"profile,omitempty"`
	SourceProfile string `yaml:"source_profile,omitempty" json:"source_profile,omitempty"`
	RoleARN       string `yaml:"role_arn,omitempty"       json:"role_arn,omitempty"`
}

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

// SessionTerraformConfig points cp9 at the support-infra Terraform substrate.
type SessionTerraformConfig struct {
	Repo    string `yaml:"repo,omitempty"     json:"repo,omitempty"`
	Ref     string `yaml:"ref,omitempty"      json:"ref,omitempty"`
	Path    string `yaml:"path,omitempty"     json:"path,omitempty"`
	VarFile string `yaml:"var_file,omitempty" json:"var_file,omitempty"`
	// Derived: local is the only supported backend, so cp9 sets it on load and
	// never persists it into the resolved session.
	State string `yaml:"-" json:"state,omitempty"`
}

type SessionArtifactsConfig struct {
	Environment string `yaml:"environment,omitempty" json:"environment,omitempty"`
}

// SessionTiltConfig defines how the session Tilt handoff should run.
type SessionTiltConfig struct {
	Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
	Port int    `yaml:"port,omitempty" json:"port,omitempty"`
	File string `yaml:"file,omitempty" json:"file,omitempty"`
}

// SessionServicesConfig carries the session-level build and auth settings for
// services. The service graph itself belongs to the Session Tiltfile.
type SessionServicesConfig struct {
	SourceBuildParallelism int    `yaml:"source_build_parallelism,omitempty" json:"source_build_parallelism,omitempty"`
	GitHubAppClientID      string `yaml:"github_app_client_id,omitempty"     json:"github_app_client_id,omitempty"`
}

// UsesCloudSubstrate reports whether the session needs the AWS/Terraform
// substrate lifecycle. CP-local personas can launch entirely from local host
// resources.
func UsesCloudSubstrate(cfg *SessionConfig) bool {
	if cfg == nil {
		return true
	}
	switch SessionPersona(cfg) {
	case "billing":
		return false
	default:
		return true
	}
}

// UsesSessionTiltfile reports whether the session delegates persona runtime
// graph ownership to an explicitly configured Session Tiltfile.
func UsesSessionTiltfile(cfg *SessionConfig) bool {
	return cfg != nil && strings.TrimSpace(cfg.Tilt.File) != ""
}

// SessionPersona returns the best-effort persona name for metadata and host
// defaults without requiring cp9 to own the persona service graph.
func SessionPersona(cfg *SessionConfig) string {
	if cfg == nil {
		return ""
	}
	if persona := strings.TrimSpace(cfg.Persona); persona != "" {
		return persona
	}
	// Sessions written before `persona` became explicit carry it only in the
	// Tiltfile path. Destroy must keep working for those, so the inference
	// stays as a read-side fallback; strict load requires the field.
	return personaFromTiltfilePath(cfg.Tilt.File)
}

func personaFromTiltfilePath(path string) string {
	parts := strings.FieldsFunc(filepath.ToSlash(strings.TrimSpace(path)), func(r rune) bool {
		return r == '/'
	})
	for i := 0; i+1 < len(parts); i++ {
		if parts[i] == "personas" && strings.TrimSpace(parts[i+1]) != "" {
			return parts[i+1]
		}
	}
	return ""
}

type sessionSchema struct {
	fields  map[string]*sessionSchema
	dynamic *sessionSchema
}

var sessionLeaf = &sessionSchema{}

func schemaObject(fields map[string]*sessionSchema) *sessionSchema {
	return &sessionSchema{fields: fields}
}

func schemaDynamic(dynamic *sessionSchema) *sessionSchema {
	return &sessionSchema{dynamic: dynamic}
}

var sessionConfigSchema = schemaObject(map[string]*sessionSchema{
	"apiVersion": sessionLeaf,
	"kind":       sessionLeaf,
	"name":       sessionLeaf,
	"persona":    sessionLeaf,
	"host": schemaObject(map[string]*sessionSchema{
		"mode": sessionLeaf,
		"ttl":  sessionLeaf,
		"ec2": schemaObject(map[string]*sessionSchema{
			"instance_type":        sessionLeaf,
			"key_pair":             sessionLeaf,
			"ssh_user":             sessionLeaf,
			"ssh_private_key":      sessionLeaf,
			"subnet_id":            sessionLeaf,
			"security_group_ids":   sessionLeaf,
			"allowed_cidr":         sessionLeaf,
			"root_volume_size_gib": sessionLeaf,
		}),
		"exposure": schemaObject(map[string]*sessionSchema{
			"enabled":         sessionLeaf,
			"zone_id":         sessionLeaf,
			"zone_name":       sessionLeaf,
			"certificate_arn": sessionLeaf,
		}),
	}),
	"target": schemaObject(map[string]*sessionSchema{
		"cloud":  sessionLeaf,
		"region": sessionLeaf,
		"aws": schemaObject(map[string]*sessionSchema{
			"iam": schemaObject(map[string]*sessionSchema{
				"source_profile": sessionLeaf,
				"role_arn":       sessionLeaf,
			}),
			"remote_eks": schemaObject(map[string]*sessionSchema{
				"cluster_name":       sessionLeaf,
				"kubernetes_version": sessionLeaf,
			}),
		}),
	}),
	"ci": schemaObject(map[string]*sessionSchema{
		"owner_allowlist": schemaDynamic(sessionLeaf),
	}),
	"terraform": schemaObject(map[string]*sessionSchema{
		"repo":     sessionLeaf,
		"ref":      sessionLeaf,
		"path":     sessionLeaf,
		"var_file": sessionLeaf,
	}),
	"artifacts": schemaObject(map[string]*sessionSchema{
		"environment": sessionLeaf,
	}),
	"tilt": schemaObject(map[string]*sessionSchema{
		"mode": sessionLeaf,
		"port": sessionLeaf,
		"file": sessionLeaf,
	}),
	"starter": schemaObject(map[string]*sessionSchema{
		"enabled": sessionLeaf,
		"ai_fts": schemaObject(map[string]*sessionSchema{
			"enabled": sessionLeaf,
		}),
		"activation": schemaObject(map[string]*sessionSchema{
			"pdms": schemaObject(map[string]*sessionSchema{
				"enabled": sessionLeaf,
			}),
			"topology": schemaObject(map[string]*sessionSchema{
				"pd": schemaObject(map[string]*sessionSchema{
					"instance_type": sessionLeaf,
				}),
				"tikv": schemaObject(map[string]*sessionSchema{
					"instance_type": sessionLeaf,
				}),
				"keyspace_etcd": schemaObject(map[string]*sessionSchema{
					"instance_type": sessionLeaf,
				}),
				"gateway": schemaObject(map[string]*sessionSchema{
					"instance_type": sessionLeaf,
				}),
				"regional_control_plane": schemaObject(map[string]*sessionSchema{
					"instance_type": sessionLeaf,
				}),
				"tidb_serving": schemaObject(map[string]*sessionSchema{
					"instance_type": sessionLeaf,
					"nodes":         sessionLeaf,
				}),
			}),
		}),
		"observability": schemaObject(map[string]*sessionSchema{
			"enabled":              sessionLeaf,
			"prometheus_retention": sessionLeaf,
		}),
	}),
	"essential_v2": schemaObject(map[string]*sessionSchema{
		"enabled":                     sessionLeaf,
		"enabled_tikv_remote_workers": sessionLeaf,
	}),
	"dedicated": schemaObject(map[string]*sessionSchema{
		"gcp": schemaObject(map[string]*sessionSchema{
			"enabled":              sessionLeaf,
			"seed_project_id":      sessionLeaf,
			"dataplane_project_id": sessionLeaf,
			"region":               sessionLeaf,
			"zone":                 sessionLeaf,
		}),
	}),
	"ui": schemaObject(map[string]*sessionSchema{
		"seed_identity": schemaObject(map[string]*sessionSchema{
			"email": sessionLeaf,
		}),
		"o11y": schemaObject(map[string]*sessionSchema{
			"gs_host":                 sessionLeaf,
			"gs_host_v2":              sessionLeaf,
			"test_host_domain_suffix": sessionLeaf,
			"nextgen_biz_type":        sessionLeaf,
			"dedicated_biz_type":      sessionLeaf,
			"devtier_biz_type":        sessionLeaf,
		}),
	}),
	"byoc": schemaObject(map[string]*sessionSchema{
		"enabled": sessionLeaf,
		"customer": schemaObject(map[string]*sessionSchema{
			"tenant_id":  sessionLeaf,
			"account_id": sessionLeaf,
			"availability_zones": schemaObject(map[string]*sessionSchema{
				"name": sessionLeaf,
				"id":   sessionLeaf,
			}),
			"tidb": schemaObject(map[string]*sessionSchema{
				"cidr":                          sessionLeaf,
				"hosted_zone_name":              sessionLeaf,
				"hosted_zone_id":                sessionLeaf,
				"pca_arn":                       sessionLeaf,
				"pca_shared_from_other_account": sessionLeaf,
			}),
			"o11y": schemaObject(map[string]*sessionSchema{
				"hosted_zone_name": sessionLeaf,
				"hosted_zone_id":   sessionLeaf,
			}),
			"image_sync_region": sessionLeaf,
			"quota_ready":       sessionLeaf,
		}),
		"account_setup": schemaObject(map[string]*sessionSchema{
			"profile":               sessionLeaf,
			"clinic_account_id":     sessionLeaf,
			"reuse_existing_stacks": sessionLeaf,
		}),
	}),
	"services": schemaObject(map[string]*sessionSchema{
		"source_build_parallelism": sessionLeaf,
		"github_app_client_id":     sessionLeaf,
	}),
})

// DetectAPIVersion reads the file and returns the config apiVersion.
func DetectAPIVersion(path string) (string, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return "", cperr.Newf(cperr.CategoryConfig, "cannot read config file %q: %v", path, err)
	}
	return detectAPIVersionBytes(expandEnvInBytes(data))
}

func detectAPIVersionBytes(data []byte) (string, error) {
	var probe struct {
		APIVersion string `yaml:"apiVersion"`
	}
	if err := yaml.Unmarshal(data, &probe); err != nil {
		return "", cperr.Newf(cperr.CategoryConfig, "invalid YAML while probing apiVersion: %v", err)
	}
	if strings.TrimSpace(probe.APIVersion) == "" {
		return "", cperr.New(cperr.CategoryConfig, "apiVersion is required")
	}
	return strings.TrimSpace(probe.APIVersion), nil
}

// LoadSession loads and validates a unified v1alpha2 session config.
func LoadSession(path string) (*SessionConfig, error) {
	return loadSession(path, sessionLoadStrict)
}

type sessionLoadMode uint8

const (
	sessionLoadStrict sessionLoadMode = iota
	sessionLoadDestroyCompatibility
)

// LoadSessionForDestroy loads a unified Session while tolerating retired inputs
// and UI DNS policy changes that must not block cleanup. Deprecated values are
// decoded only where legacy types still carry them and must never override
// persisted ownership or host evidence.
func LoadSessionForDestroy(path string) (*SessionConfig, error) {
	return loadSession(path, sessionLoadDestroyCompatibility)
}

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

	version, err := detectAPIVersionBytes(data)
	if err != nil {
		return nil, err
	}
	switch version {
	case APIVersionV1Alpha2:
		// continue
	case APIVersionV1:
		return nil, cperr.New(
			cperr.CategoryConfig,
			"legacy cp9/v1 split config detected; start from cp9.dev-full.remote-ec2-k3d.example.yaml and use apiVersion cp9.tidbcloud.com/v1alpha2",
		)
	default:
		return nil, cperr.Newf(cperr.CategoryConfig,
			"unsupported apiVersion %q for unified session config (expected %q)", version, APIVersionV1Alpha2)
	}

	var root yaml.Node
	if err := yaml.Unmarshal(data, &root); err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "invalid YAML in %q: %v", path, err)
	}
	if err := validateSessionKnownFields(&root, mode); err != nil {
		return nil, err
	}

	var cfg SessionConfig
	if err := root.Decode(&cfg); err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "invalid YAML in %q: %v", path, err)
	}
	if mode == sessionLoadDestroyCompatibility {
		// The compatibility loader needs the surrounding legacy document only
		// to locate and destroy its persisted resources. Never let a retired
		// caller-selected AMI become live input again.
		cfg.Host.EC2.AMI = ""
	}
	if mode != sessionLoadDestroyCompatibility || strings.TrimSpace(cfg.Target.AWS.IAM.Profile) == "" {
		cfg.Target.AWS.IAM.Profile = cfg.Name
	}
	applySessionDefaults(&cfg)
	if err := validateSession(&cfg, mode); err != nil {
		return nil, err
	}
	return &cfg, nil
}

// ResolvedSessionPath returns the normalized session artifact path under a session state dir.
func ResolvedSessionPath(stateDir string) string {
	return filepath.Join(stateDir, DefaultResolvedSessionFile)
}

// ResolveSession clones a validated session config and rewrites useful file
// paths to absolute locations for the resolved session artifact.
func ResolveSession(cfg *SessionConfig, configPath string) (*SessionConfig, error) {
	if cfg == nil {
		return nil, cperr.New(cperr.CategoryConfig, "session config is required")
	}
	absConfigPath, err := filepath.Abs(configPath)
	if err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "resolve absolute config path %q: %v", configPath, err)
	}
	baseDir := filepath.Dir(absConfigPath)
	resolved, err := cloneSession(cfg)
	if err != nil {
		return nil, err
	}
	resolved.Terraform.Repo = makeAbsPath(baseDir, resolved.Terraform.Repo)
	if resolved.Terraform.VarFile != "" {
		resolved.Terraform.VarFile = makeAbsPath(resolved.Terraform.Repo, resolved.Terraform.VarFile)
		terraformRoot := makeAbsPath(resolved.Terraform.Repo, resolved.Terraform.Path)
		if err := requirePathWithin(terraformRoot, resolved.Terraform.VarFile, "terraform.var_file"); err != nil {
			return nil, err
		}
	}
	resolved.Tilt.File = makeAbsPath(baseDir, resolved.Tilt.File)
	return resolved, nil
}

// WriteResolvedSession writes the normalized session artifact atomically.
func WriteResolvedSession(stateDir string, cfg *SessionConfig) error {
	if err := os.MkdirAll(stateDir, 0o755); err != nil {
		return cperr.Newf(cperr.CategoryConfig, "create session state dir %q: %v", stateDir, err)
	}
	persisted, err := cloneSession(cfg)
	if err != nil {
		return err
	}
	persisted.Target.AWS.IAM.Profile = ""
	data, err := encodeSessionYAML(persisted)
	if err != nil {
		return cperr.Newf(cperr.CategoryConfig, "marshal resolved session artifact: %v", err)
	}
	target := ResolvedSessionPath(stateDir)
	tmp, err := os.CreateTemp(stateDir, "cp9.resolved-*.yaml")
	if err != nil {
		return cperr.Newf(cperr.CategoryConfig, "create temp resolved session artifact: %v", err)
	}
	tmpPath := tmp.Name()
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		_ = os.Remove(tmpPath)
		return cperr.Newf(cperr.CategoryConfig, "write resolved session artifact: %v", err)
	}
	if err := tmp.Close(); err != nil {
		_ = os.Remove(tmpPath)
		return cperr.Newf(cperr.CategoryConfig, "close resolved session artifact: %v", err)
	}
	if err := os.Rename(tmpPath, target); err != nil {
		_ = os.Remove(tmpPath)
		return cperr.Newf(cperr.CategoryConfig, "persist resolved session artifact: %v", err)
	}
	return nil
}

func cloneSession(cfg *SessionConfig) (*SessionConfig, error) {
	data, err := yaml.Marshal(cfg)
	if err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "clone session config: %v", err)
	}
	var clone SessionConfig
	if err := yaml.Unmarshal(data, &clone); err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "clone session config: %v", err)
	}
	return &clone, nil
}

func applySessionDefaults(cfg *SessionConfig) {
	if cfg.Dedicated != nil && cfg.Dedicated.GCP != nil {
		gcp := cfg.Dedicated.GCP
		gcp.SeedProjectID = strings.TrimSpace(gcp.SeedProjectID)
		gcp.DataplaneProjectID = strings.TrimSpace(gcp.DataplaneProjectID)
		gcp.Region = strings.TrimSpace(gcp.Region)
		gcp.Zone = strings.TrimSpace(gcp.Zone)
	}
	if cfg.Host.Mode == "" {
		if starterIntentRequested(cfg) {
			cfg.Host.Mode = HostModeRemoteEC2K3d
		} else {
			cfg.Host.Mode = HostModeLocalK3d
		}
	}
	if strings.TrimSpace(cfg.Target.AWS.IAM.SourceProfile) == "" {
		cfg.Target.AWS.IAM.SourceProfile = cfg.Target.AWS.IAM.Profile
	}
	if cfg.Terraform.State == "" {
		cfg.Terraform.State = "local"
	}
	if cfg.Tilt.Mode == "" {
		cfg.Tilt.Mode = "up"
	}
	if SessionPersona(cfg) == "dev-full" && strings.TrimSpace(cfg.Artifacts.Environment) == "" {
		cfg.Artifacts.Environment = "dev"
	}
	if cfg.Host.Mode == HostModeRemoteEC2K3d && cfg.Host.EC2.InstanceType == "" {
		cfg.Host.EC2.InstanceType = "m7g.xlarge"
	}
	if cfg.Services.SourceBuildParallelism == 0 {
		// One at a time by default. Measured on the default m7i.xlarge (4 vCPU):
		// two concurrent Go builds saturated the host until SSH could not finish
		// a banner exchange, while AWS still reported the instance healthy. A
		// larger host can afford more, but that is the operator's call to make
		// explicitly rather than a default that strands the common one.
		cfg.Services.SourceBuildParallelism = 1
	}
	if starterIntentRequested(cfg) {
		applyStarterDefaults(&cfg.Starter)
	}
	if BYOCConfigured(cfg) && strings.TrimSpace(cfg.BYOC.Customer.TenantID) == "" {
		// The BYOC customer tenant is always the seeded CP9 Web organization, so
		// default it instead of making every operator retype one legal value. An
		// explicitly different value is still rejected by validateBYOCConfig.
		cfg.BYOC.Customer.TenantID = DefaultUIOrganizationID
	}
}

func validateSession(cfg *SessionConfig, mode sessionLoadMode) error {
	if cfg.APIVersion != APIVersionV1Alpha2 {
		return cperr.Newf(cperr.CategoryConfig,
			"unsupported apiVersion %q (expected %q)", cfg.APIVersion, APIVersionV1Alpha2)
	}
	if cfg.Kind != SessionKind {
		return cperr.Newf(cperr.CategoryConfig,
			"kind %q is invalid (expected %q)", cfg.Kind, SessionKind)
	}
	if _, _, err := validateSessionName(cfg.Name); err != nil {
		return err
	}
	if err := ValidatePersonaKey("persona", cfg.Persona); err != nil {
		return err
	}
	if mode != sessionLoadDestroyCompatibility && strings.TrimSpace(cfg.Persona) == "" {
		return cperr.New(cperr.CategoryConfig,
			"persona is required; set it to the persona this session runs, for example `persona: dev-full`")
	}
	switch cfg.Host.Mode {
	case HostModeLocalK3d:
		// supported for the narrow M1 path.
	case HostModeRemoteEC2K3d:
		if err := validateHostEC2Config(&cfg.Host.EC2); err != nil {
			return err
		}
	default:
		return cperr.Newf(cperr.CategoryConfig,
			"host.mode %q is invalid (expected %q or %q)", cfg.Host.Mode, HostModeLocalK3d, HostModeRemoteEC2K3d)
	}
	if err := validateHostExposureConfig(cfg.Host.Mode, &cfg.Host.Exposure); err != nil {
		return err
	}
	if err := validateSessionUIConfig(cfg, mode); err != nil {
		return err
	}
	if err := validateBYOCConfig(cfg); err != nil {
		return err
	}
	if err := validateStarterConfig(cfg, mode); err != nil {
		return err
	}
	if err := validateEssentialV2Config(cfg); err != nil {
		return err
	}
	if err := validateDedicatedGCPConfig(cfg); err != nil {
		return err
	}
	if environment := strings.TrimSpace(cfg.Artifacts.Environment); environment != "" && environment != "dev" && environment != "staging" {
		return cperr.Newf(cperr.CategoryConfig, "artifacts.environment %q is invalid (expected %q or %q)", environment, "dev", "staging")
	}
	usesCloudSubstrate := UsesCloudSubstrate(cfg)
	if usesCloudSubstrate {
		if cfg.Target.Cloud != "aws" {
			return cperr.Newf(cperr.CategoryConfig,
				"target.cloud %q is invalid for Premium Reserved M1 (expected %q)", cfg.Target.Cloud, "aws")
		}
		if strings.TrimSpace(cfg.Target.Region) == "" {
			return cperr.New(cperr.CategoryConfig, "target.region is required")
		}
		if strings.TrimSpace(cfg.Target.AWS.IAM.SourceProfile) == strings.TrimSpace(cfg.Target.AWS.IAM.Profile) {
			return cperr.Newf(
				cperr.CategoryConfig,
				"target.aws.iam.source_profile must differ from Session name %q because cp9 uses the Session name as its runtime profile",
				cfg.Name,
			)
		}
		if strings.TrimSpace(cfg.Target.AWS.IAM.RoleARN) == "" {
			return cperr.New(cperr.CategoryConfig, "target.aws.iam.role_arn is required for the local developer path")
		}
		if rolePath := sessionRolePath(cfg.Target.AWS.IAM.RoleARN); strings.Contains(rolePath, "/") {
			return cperr.Newf(
				cperr.CategoryConfig,
				"target.aws.iam.role_arn %q must not include an IAM role path for the Premium Reserved M1 path",
				cfg.Target.AWS.IAM.RoleARN,
			)
		}
		if err := validateRemoteEKSConfig(cfg, mode); err != nil {
			return err
		}
	}
	for callerARN, owners := range cfg.CI.OwnerAllowlist {
		if strings.TrimSpace(callerARN) == "" {
			return cperr.New(cperr.CategoryConfig, "ci.owner_allowlist caller ARN keys must not be empty")
		}
		for _, owner := range owners {
			if strings.TrimSpace(owner) == "" {
				return cperr.Newf(cperr.CategoryConfig, "ci.owner_allowlist[%q] contains an empty owner value", callerARN)
			}
		}
	}
	if usesCloudSubstrate {
		if strings.TrimSpace(cfg.Terraform.Repo) == "" {
			return cperr.New(cperr.CategoryConfig, "terraform.repo is required")
		}
		if strings.TrimSpace(cfg.Terraform.Path) == "" {
			return cperr.New(cperr.CategoryConfig, "terraform.path is required")
		}
		if strings.TrimSpace(cfg.Terraform.VarFile) == "" {
			return cperr.New(cperr.CategoryConfig, "terraform.var_file is required")
		}
		if err := validateTerraformVarFileName(cfg.Terraform.VarFile); err != nil {
			return err
		}
		if cfg.Terraform.State != "" && cfg.Terraform.State != "local" {
			return cperr.Newf(cperr.CategoryConfig,
				"terraform.state %q is invalid (expected %q)", cfg.Terraform.State, "local")
		}
	}
	switch cfg.Tilt.Mode {
	case "up", "ci", "generate-only":
		// valid
	default:
		return cperr.Newf(cperr.CategoryConfig,
			"tilt.mode %q is invalid (expected %q, %q, or %q)", cfg.Tilt.Mode, "up", "ci", "generate-only")
	}
	// Not enforced for the destroy loader: it exists to read a legacy document
	// well enough to reach that session's resources, and refusing to load one
	// would strand them in the account.
	if mode != sessionLoadDestroyCompatibility && strings.TrimSpace(cfg.Tilt.File) == "" {
		return cperr.New(cperr.CategoryConfig,
			"tilt.file is required; point it at the session Tiltfile "+
				"(see cp9.dev-full.remote-ec2-k3d.example.yaml)")
	}
	if mode != sessionLoadDestroyCompatibility && cfg.Services.SourceBuildParallelism < 1 {
		return cperr.New(cperr.CategoryConfig, "services.source_build_parallelism must be a positive integer")
	}
	if mode != sessionLoadDestroyCompatibility && cfg.Host.Mode == HostModeRemoteEC2K3d &&
		SessionPersona(cfg) == "dev-full" && strings.TrimSpace(cfg.Services.GitHubAppClientID) == "" {
		return cperr.New(cperr.CategoryConfig,
			"services.github_app_client_id is required for remote dev-full (use the value from a cp9.dev-full example config)")
	}
	return nil
}

var (
	dedicatedGCPProjectIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{4,28}[a-z0-9]$`)
	sessionUIEmailPattern        = regexp.MustCompile(`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$`)
	// Route53 hosted zone ids, zone names and ACM certificate ARNs are matched by
	// shape rather than against a list of known placeholder strings: a shape check
	// also rejects placeholders nobody has written yet.
	//
	// hostedZoneNamePattern is separate from sessionUIDomainPattern rather than
	// reusing it: Route53 returns names with a trailing dot and normalizeRoute53Name
	// already trims one, so rejecting cp9.dev.aws.tidbcloud.com. here would refuse a
	// name this repo produces itself. The ARN partition is left open because
	// aws-us-gov and aws-cn certificates are equally well-formed.
	hostedZoneIDPattern   = regexp.MustCompile(`^Z[A-Z0-9]{4,}$`)
	hostedZoneNamePattern = regexp.MustCompile(
		`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.?$`)
	certificateARNPattern = regexp.MustCompile(
		`^arn:aws[a-z-]*:acm:[a-z0-9-]+:\d{12}:certificate/[0-9a-fA-F-]+$`)
	sessionUIDomainPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$`)
	sessionUINamePattern   = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
)

const (
	remoteDevFullUIParentZoneName      = "cp9.dev.aws.tidbcloud.com"
	defaultRemoteDevFullUISeedIdentity = "sabaping@agent.qq.com"
)

func validateSessionUIConfig(cfg *SessionConfig, mode sessionLoadMode) error {
	if cfg == nil {
		return nil
	}
	if cfg.Host.Mode == HostModeRemoteEC2K3d &&
		SessionPersona(cfg) == "dev-full" &&
		!cfg.Host.Exposure.Enabled {
		return cperr.New(cperr.CategoryConfig,
			"Remote dev-full UI requires host.exposure.enabled with the standard CP9 DNS zone and runtime certificate")
	}
	if cfg.Host.Mode == HostModeRemoteEC2K3d && SessionPersona(cfg) == "dev-full" {
		zoneName := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Host.Exposure.ZoneName)), ".")
		if mode != sessionLoadDestroyCompatibility {
			accountID := sessionRoleAccountID(cfg.Target.AWS.IAM.RoleARN)
			if !validAWSAccountID(accountID) {
				return cperr.New(cperr.CategoryConfig,
					"Remote dev-full UI requires target.aws.iam.role_arn to identify its 12-digit runtime account")
			}
			expectedZoneName := accountID + "." + remoteDevFullUIParentZoneName
			if zoneName != expectedZoneName {
				return cperr.Newf(cperr.CategoryConfig,
					"Remote dev-full UI requires host.exposure.zone_name %s", expectedZoneName)
			}
			certificateAccount, certificateRegion := certificateARNAccountRegion(cfg.Host.Exposure.CertificateARN)
			if certificateAccount != accountID || certificateRegion != strings.TrimSpace(cfg.Target.Region) {
				return cperr.Newf(cperr.CategoryConfig,
					"host.exposure.certificate_arn must belong to runtime account %s in target.region %s",
					accountID,
					strings.TrimSpace(cfg.Target.Region),
				)
			}
		}
		cfg.Host.Exposure.ZoneName = zoneName
		if mode != sessionLoadDestroyCompatibility && cfg.UI.O11Y == nil {
			origin := "https://" + cfg.Name + "." + zoneName
			cfg.UI.O11Y = &SessionUIO11YConfig{
				GSHost:           origin,
				GSHostV2:         origin,
				NextGenBizType:   cfg.Name,
				DedicatedBizType: cfg.Name,
				DevTierBizType:   DevTierO11YBizType,
			}
		}
	}
	email := strings.TrimSpace(cfg.UI.SeedIdentity.Email)
	if email == "" && cfg.Host.Mode == HostModeRemoteEC2K3d && SessionPersona(cfg) == "dev-full" {
		email = defaultRemoteDevFullUISeedIdentity
	}
	if email != "" && cfg.Host.Mode != HostModeRemoteEC2K3d {
		return cperr.Newf(cperr.CategoryConfig,
			"ui.seed_identity is supported only for host.mode %s", HostModeRemoteEC2K3d)
	}
	if cfg.UI.O11Y != nil && cfg.Host.Mode != HostModeRemoteEC2K3d {
		return cperr.Newf(cperr.CategoryConfig,
			"ui.o11y is supported only for host.mode %s", HostModeRemoteEC2K3d)
	}
	if email != "" && SessionPersona(cfg) != "dev-full" {
		return cperr.New(cperr.CategoryConfig, "ui.seed_identity is supported only for the dev-full persona")
	}
	if cfg.UI.O11Y != nil && SessionPersona(cfg) != "dev-full" {
		return cperr.New(cperr.CategoryConfig, "ui.o11y is supported only for the dev-full persona")
	}
	if email != "" && !sessionUIEmailPattern.MatchString(email) {
		return cperr.New(cperr.CategoryConfig, "ui.seed_identity.email must be a plain email address")
	}
	cfg.UI.SeedIdentity.Email = email
	if cfg.UI.O11Y != nil {
		if err := validateSessionUIO11YConfig(cfg.UI.O11Y); err != nil {
			return err
		}
	}
	return nil
}

func validateSessionUIO11YConfig(o11y *SessionUIO11YConfig) error {
	if o11y == nil {
		return nil
	}
	for _, item := range []struct {
		field string
		value *string
	}{
		{field: "gs_host", value: &o11y.GSHost},
		{field: "gs_host_v2", value: &o11y.GSHostV2},
	} {
		field, value := item.field, item.value
		*value = strings.TrimSpace(*value)
		parsed, err := url.Parse(*value)
		if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil ||
			parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
			return cperr.Newf(cperr.CategoryConfig, "ui.o11y.%s must be an HTTPS origin without path, query, or credentials", field)
		}
	}
	o11y.TestHostDomainSuffix = strings.TrimSpace(o11y.TestHostDomainSuffix)
	if o11y.TestHostDomainSuffix != "" &&
		(len(o11y.TestHostDomainSuffix) > 253 || !sessionUIDomainPattern.MatchString(o11y.TestHostDomainSuffix)) {
		return cperr.New(cperr.CategoryConfig, "ui.o11y.test_host_domain_suffix must be a plain DNS suffix")
	}
	for _, item := range []struct {
		field string
		value *string
	}{
		{field: "nextgen_biz_type", value: &o11y.NextGenBizType},
		{field: "dedicated_biz_type", value: &o11y.DedicatedBizType},
		{field: "devtier_biz_type", value: &o11y.DevTierBizType},
	} {
		field, value := item.field, item.value
		*value = strings.TrimSpace(*value)
		if *value != "" && !sessionUINamePattern.MatchString(*value) {
			return cperr.Newf(cperr.CategoryConfig, "ui.o11y.%s contains unsupported characters", field)
		}
	}
	return nil
}

func validateHostEC2Config(ec2 *HostEC2Config) error {
	if ec2 == nil {
		return cperr.New(cperr.CategoryConfig, "host.ec2 is required for host.mode remote-ec2-k3d")
	}
	switch {
	case strings.TrimSpace(ec2.KeyPair) == "":
		return cperr.New(cperr.CategoryConfig, "host.ec2.key_pair is required for host.mode remote-ec2-k3d")
	case strings.TrimSpace(ec2.SSHUser) == "":
		return cperr.New(cperr.CategoryConfig, "host.ec2.ssh_user is required for host.mode remote-ec2-k3d")
	case strings.TrimSpace(ec2.SSHPrivateKey) == "":
		return cperr.New(cperr.CategoryConfig, "host.ec2.ssh_private_key is required for host.mode remote-ec2-k3d")
	case strings.TrimSpace(ec2.SubnetID) == "":
		return cperr.New(cperr.CategoryConfig, "host.ec2.subnet_id is required for host.mode remote-ec2-k3d")
	}
	if ec2.RootVolumeSizeGiB < 0 {
		return cperr.New(cperr.CategoryConfig, "host.ec2.root_volume_size_gib must be non-negative when set")
	}
	if 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(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)",
			ec2.InstanceType,
			"m7g.xlarge",
		)
	}
	return nil
}

func validateHostExposureConfig(mode HostMode, exposure *HostExposureConfig) error {
	if !exposure.Enabled {
		return nil
	}
	if mode != HostModeRemoteEC2K3d {
		return cperr.Newf(cperr.CategoryConfig,
			"host.exposure.enabled requires host.mode %q", HostModeRemoteEC2K3d)
	}
	zoneID := strings.TrimSpace(exposure.ZoneID)
	zoneName := strings.TrimSpace(exposure.ZoneName)
	certificateARN := strings.TrimSpace(exposure.CertificateARN)
	switch {
	case zoneID == "":
		return cperr.New(cperr.CategoryConfig, "host.exposure.zone_id is required when host.exposure.enabled is true")
	case zoneName == "":
		return cperr.New(cperr.CategoryConfig, "host.exposure.zone_name is required when host.exposure.enabled is true")
	case certificateARN == "":
		return cperr.New(cperr.CategoryConfig, "host.exposure.certificate_arn is required when host.exposure.enabled is true")
	}
	// A value that is present but never filled in used to pass here and fail much
	// later against Route53 or ACM, where the error names an AWS API rather than the
	// field the operator has to fix. Each message below says where the real value
	// comes from, because that is the question the operator is actually stuck on.
	const accountValueSource = "copy the runtime account value from " + runtimeAccountRegistryPath
	if err := checkExposureValue(
		"zone_id", zoneID, hostedZoneIDPattern, "a Route53 hosted zone id", accountValueSource,
	); err != nil {
		return err
	}
	if err := checkExposureValue(
		"zone_name", zoneName, hostedZoneNamePattern, "a domain name", accountValueSource,
	); err != nil {
		return err
	}
	return checkExposureValue(
		"certificate_arn", certificateARN, certificateARNPattern, "an ACM certificate ARN",
		accountValueSource,
	)
}

// checkExposureValue reports a value left as its placeholder separately from one
// that is merely malformed. Both are rejected either way, but an operator who
// copied a template needs to be told that, rather than told the shape of a field
// they believed they had already filled in.
func checkExposureValue(field, value string, shape *regexp.Regexp, want, source string) error {
	if unreplacedTemplate(value) {
		return cperr.Newf(cperr.CategoryConfig,
			"host.exposure.%s is still the placeholder %q; %s", field, value, source)
	}
	if !shape.MatchString(value) {
		return cperr.Newf(cperr.CategoryConfig,
			"host.exposure.%s %q is not %s; %s", field, value, want, source)
	}
	return nil
}

// unreplacedTemplate reports whether a value still looks like the placeholder it
// was copied from. It covers both shapes in circulation: the angle-bracket
// templates the example configs ship (<runtime-account-id>) and the upper-case
// stand-ins used in the setup docs (YOUR_DNS_ZONE_ID). Shape validation alone
// would reject these too, but naming them lets the error say which mistake was
// made instead of only what the field should look like.
// runtimeAccountRegistryPath is where each account's DNS zone and certificate
// contract lives. The example config points to the same source of truth.
const runtimeAccountRegistryPath = ".github/cp9-cleanup-accounts.json"

func unreplacedTemplate(value string) bool {
	if strings.ContainsAny(value, "<>") {
		return true
	}
	return strings.HasPrefix(strings.ToUpper(value), "YOUR_")
}

func validateRemoteEKSConfig(cfg *SessionConfig, mode sessionLoadMode) error {
	// Destroy must keep working for sessions written before these retirements.
	if mode == sessionLoadDestroyCompatibility {
		cfg.Target.AWS.RemoteEKS.Enabled = false
		cfg.Target.AWS.RemoteEKS.NodeInstanceType = ""
		cfg.Target.AWS.RemoteEKS.NodeCount = 0
	}
	remote := cfg.Target.AWS.RemoteEKS
	if strings.TrimSpace(remote.NodeInstanceType) != "" || remote.NodeCount != 0 {
		return cperr.New(cperr.CategoryConfig,
			"target.aws.remote_eks.node_instance_type/node_count are retired; configure physical roles under starter.activation.topology")
	}
	sessionPrefix := strings.TrimSpace(cfg.Name) + "-"
	if clusterName := strings.TrimSpace(remote.ClusterName); clusterName != "" &&
		!strings.HasPrefix(clusterName, sessionPrefix) {
		return cperr.Newf(
			cperr.CategoryConfig,
			"target.aws.remote_eks.cluster_name %q must start with the top-level name prefix %q for namespace isolation",
			clusterName,
			sessionPrefix,
		)
	}
	if !remote.Enabled {
		return nil
	}
	return cperr.New(cperr.CategoryConfig,
		"target.aws.remote_eks.enabled is managed by cp9 starter activate; do not set it in session config")
}

// BYOCConfigured reports whether the session carries an explicit BYOC customer
// handoff. It does not imply that activation has run or that a BYOC Region is
// ready.
func BYOCConfigured(cfg *SessionConfig) bool {
	return cfg != nil && cfg.BYOC != nil
}

// BYOCEnabled reports whether cp9 up should eagerly activate the configured
// BYOC Region after the remote Dev full baseline is ready.
func BYOCEnabled(cfg *SessionConfig) bool {
	return BYOCConfigured(cfg) && cfg.BYOC.Enabled
}

func validateBYOCConfig(cfg *SessionConfig) error {
	if !BYOCConfigured(cfg) {
		return nil
	}
	if cfg.Host.Mode != HostModeRemoteEC2K3d {
		return cperr.New(cperr.CategoryConfig, "byoc requires host.mode remote-ec2-k3d")
	}
	if persona := SessionPersona(cfg); persona != "dev-full" {
		return cperr.Newf(cperr.CategoryConfig, "byoc requires the dev-full persona, got %q", persona)
	}

	byoc := cfg.BYOC
	customer := &byoc.Customer
	tenantID := strings.TrimSpace(customer.TenantID)
	// No blank check: applySessionDefaults fills this in before validateSession
	// runs, on the same condition and behind the same BYOCConfigured gate, so a
	// blank value cannot reach here. ParseUint below still rejects one if the
	// defaulting phase is ever bypassed.
	parsedTenantID, err := strconv.ParseUint(tenantID, 10, 64)
	if err != nil || parsedTenantID == 0 {
		return cperr.New(cperr.CategoryConfig,
			"byoc.customer.tenant_id must be a positive integer")
	}
	if strconv.FormatUint(parsedTenantID, 10) != tenantID || customer.TenantID != tenantID {
		return cperr.New(cperr.CategoryConfig,
			"byoc.customer.tenant_id must use canonical base-10 form without leading zeros")
	}
	if tenantID != DefaultUIOrganizationID {
		return cperr.Newf(cperr.CategoryConfig,
			"byoc.customer.tenant_id must equal the CP9 Web organization %s that CP9 seeds "+
				"and shares with the BYOC catalog; omit the field to accept it",
			DefaultUIOrganizationID)
	}
	customerAccount := strings.TrimSpace(customer.AccountID)
	if !validAWSAccountID(customerAccount) {
		return cperr.New(cperr.CategoryConfig, "byoc.customer.account_id must be a 12-digit AWS account id")
	}
	if len(customer.AvailabilityZones) < 2 {
		return cperr.New(cperr.CategoryConfig,
			"byoc.customer.availability_zones must contain at least two availability zones")
	}
	zoneNames := make(map[string]struct{}, len(customer.AvailabilityZones))
	zoneIDs := make(map[string]struct{}, len(customer.AvailabilityZones))
	for i := range customer.AvailabilityZones {
		zone := &customer.AvailabilityZones[i]
		name := strings.TrimSpace(zone.Name)
		id := strings.TrimSpace(zone.ID)
		if name == "" {
			return cperr.Newf(cperr.CategoryConfig,
				"byoc.customer.availability_zones[%d].name is required", i)
		}
		if id == "" {
			return cperr.Newf(cperr.CategoryConfig,
				"byoc.customer.availability_zones[%d].id is required", i)
		}
		if _, exists := zoneNames[name]; exists {
			return cperr.Newf(cperr.CategoryConfig,
				"byoc.customer.availability_zones contains duplicate name %q", name)
		}
		if _, exists := zoneIDs[id]; exists {
			return cperr.Newf(cperr.CategoryConfig,
				"byoc.customer.availability_zones contains duplicate id %q", id)
		}
		zoneNames[name] = struct{}{}
		zoneIDs[id] = struct{}{}
	}

	tidb := &customer.TiDB
	prefix, err := netip.ParsePrefix(strings.TrimSpace(tidb.CIDR))
	if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() {
		return cperr.New(cperr.CategoryConfig,
			"byoc.customer.tidb.cidr must be a canonical IPv4 CIDR")
	}
	if strings.TrimSpace(tidb.HostedZoneName) == "" {
		return cperr.New(cperr.CategoryConfig, "byoc.customer.tidb.hosted_zone_name is required")
	}
	if strings.TrimSpace(tidb.HostedZoneID) == "" {
		return cperr.New(cperr.CategoryConfig, "byoc.customer.tidb.hosted_zone_id is required")
	}
	if strings.TrimSpace(tidb.PCAARN) == "" {
		return cperr.New(cperr.CategoryConfig, "byoc.customer.tidb.pca_arn is required")
	}
	if err := validateBYOCPCAARN(tidb, customerAccount, cfg.Target.Region); err != nil {
		return err
	}
	if strings.TrimSpace(customer.O11Y.HostedZoneName) == "" {
		return cperr.New(cperr.CategoryConfig, "byoc.customer.o11y.hosted_zone_name is required")
	}
	if strings.TrimSpace(customer.O11Y.HostedZoneID) == "" {
		return cperr.New(cperr.CategoryConfig, "byoc.customer.o11y.hosted_zone_id is required")
	}
	if strings.TrimSpace(customer.ImageSyncRegion) == "" {
		return cperr.New(cperr.CategoryConfig, "byoc.customer.image_sync_region is required")
	}
	if !customer.QuotaReady {
		return cperr.New(cperr.CategoryConfig, "byoc.customer.quota_ready must be true")
	}

	setupProfile := strings.TrimSpace(byoc.AccountSetup.Profile)
	if setupProfile == "" {
		return cperr.New(cperr.CategoryConfig, "byoc.account_setup.profile is required")
	}
	if setupProfile == strings.TrimSpace(cfg.Target.AWS.IAM.SourceProfile) {
		return cperr.New(cperr.CategoryConfig,
			"byoc.account_setup.profile must differ from target.aws.iam.source_profile")
	}
	if setupProfile == strings.TrimSpace(cfg.Target.AWS.IAM.Profile) {
		return cperr.Newf(cperr.CategoryConfig,
			"byoc.account_setup.profile must differ from Session name %q", cfg.Name)
	}
	if !validAWSAccountID(strings.TrimSpace(byoc.AccountSetup.ClinicAccountID)) {
		return cperr.New(cperr.CategoryConfig,
			"byoc.account_setup.clinic_account_id must be a 12-digit AWS account id")
	}
	return nil
}

func validAWSAccountID(value string) bool {
	if len(value) != 12 {
		return false
	}
	_, err := strconv.ParseUint(value, 10, 64)
	return err == nil
}

func validateBYOCPCAARN(tidb *SessionBYOCServiceConfig, customerAccount, targetRegion string) error {
	parts := strings.SplitN(strings.TrimSpace(tidb.PCAARN), ":", 6)
	if len(parts) != 6 || parts[0] != "arn" || parts[2] != "acm-pca" ||
		!strings.HasPrefix(parts[5], "certificate-authority/") {
		return cperr.New(cperr.CategoryConfig,
			"byoc.customer.tidb.pca_arn must be an ACM PCA certificate-authority ARN")
	}
	if parts[1] != "aws" {
		return cperr.New(cperr.CategoryConfig,
			"byoc.customer.tidb.pca_arn must use the commercial aws partition")
	}
	if !validAWSAccountID(parts[4]) {
		return cperr.New(cperr.CategoryConfig,
			"byoc.customer.tidb.pca_arn must contain a 12-digit AWS account id")
	}
	// A shared certificate authority may live outside the customer account, but
	// that has to be stated. Accepting any well-formed account id makes a mistyped
	// one indistinguishable from a deliberate share, and it then fails much later
	// as an AWS read error against the account-setup profile instead of here.
	if parts[4] != customerAccount && !tidb.PCASharedFromOtherAccount {
		return cperr.Newf(cperr.CategoryConfig,
			"byoc.customer.tidb.pca_arn belongs to account %s, not customer account %s; "+
				"set byoc.customer.tidb.pca_shared_from_other_account to true if the "+
				"certificate authority is deliberately shared from another account",
			parts[4], customerAccount)
	}
	if parts[3] != strings.TrimSpace(targetRegion) {
		return cperr.New(cperr.CategoryConfig,
			"byoc.customer.tidb.pca_arn must use target region")
	}
	return nil
}

// StarterEnabled reports the explicit config-driven eager activation choice.
func StarterEnabled(cfg *SessionConfig) bool {
	return cfg != nil && cfg.Starter.Enabled
}

// StarterActivationRequested reports the effective eager activation choice.
// Observability and AI/FTS are Starter capabilities, so requesting either also
// requests a complete Starter activation during cp9 up.
func StarterActivationRequested(cfg *SessionConfig) bool {
	return cfg != nil && (cfg.Starter.Enabled || cfg.Starter.AIFTS.Enabled || cfg.Starter.Observability.Enabled)
}

// StarterAIFTSEnabled reports whether optional hosted embedding and AI/FTS
// workloads are part of this Starter session.
func StarterAIFTSEnabled(cfg *SessionConfig) bool {
	return cfg != nil && cfg.Starter.AIFTS.Enabled
}

// ResolvedStarterTopology returns the reviewed Minimal defaults for fields not
// explicitly set. LoadSession validates every explicit override before this
// resolved contract reaches Terraform.
func ResolvedStarterTopology(cfg *SessionConfig) SessionStarterTopologyConfig {
	var topology SessionStarterTopologyConfig
	if cfg != nil {
		topology = cfg.Starter.Activation.Topology
	}
	setDefault := func(value *string, fallback string) {
		if strings.TrimSpace(*value) == "" {
			*value = fallback
		}
	}
	setDefault(&topology.PD.InstanceType, "m8g.xlarge")
	setDefault(&topology.TiKV.InstanceType, "r8g.xlarge")
	setDefault(&topology.KeyspaceETCD.InstanceType, "m8g.xlarge")
	setDefault(&topology.Gateway.InstanceType, "m8g.large")
	setDefault(&topology.RegionalControlPlane.InstanceType, "m6g.2xlarge")
	setDefault(&topology.TiDBServing.InstanceType, "m8g.xlarge")
	if topology.TiDBServing.Nodes == nil {
		value := 0
		topology.TiDBServing.Nodes = &value
	}
	return topology
}

// ResolvedStarterTiDBPoolPods returns the independent initial/minimum target
// for the product-selected Starter serving lane.
func ResolvedStarterTiDBPoolPods(cfg *SessionConfig) int {
	if cfg != nil && cfg.Starter.Activation.TiDBPoolPods != nil {
		return *cfg.Starter.Activation.TiDBPoolPods
	}
	return 0
}

// ResolvedStarterPDMSEnabled returns the compatibility-preserving PD mode.
// Omitted configuration intentionally defaults to the current Staging model.
func ResolvedStarterPDMSEnabled(cfg *SessionConfig) bool {
	if cfg != nil && cfg.Starter.Activation.PDMS.Enabled != nil {
		return *cfg.Starter.Activation.PDMS.Enabled
	}
	return true
}

func ResolvedStarterPDTopology(cfg *SessionConfig) StarterPDTopologyContract {
	if ResolvedStarterPDMSEnabled(cfg) {
		return StarterPDTopologyContract{
			Mode: StarterPDModePDMS, PDNodes: 3, RootPDReplicas: 1, TSOReplicas: 2, SchedulerReplicas: 2, ResourceManagerReplicas: 2,
			RequestCPU: "1", RequestMemory: "12Gi", LimitCPU: "7", LimitMemory: "12Gi",
		}
	}
	const (
		machineMillicores = 4000
		systemMillicores  = 500
		machineMemoryGiB  = 16
		systemMemoryGiB   = 2
	)
	workloadCPU := fmt.Sprintf("%dm", machineMillicores-systemMillicores)
	workloadMemory := fmt.Sprintf("%dGi", machineMemoryGiB-systemMemoryGiB)
	return StarterPDTopologyContract{
		Mode: StarterPDModeMonolithic, PDNodes: 3, RootPDReplicas: 3,
		RequestCPU: workloadCPU, RequestMemory: workloadMemory, LimitCPU: workloadCPU, LimitMemory: workloadMemory,
	}
}

// StarterInstancePlatform maps only the reviewed instance allowlist to the EKS
// platform contract. It deliberately does not infer architecture from an
// arbitrary instance-type string.
func StarterInstancePlatform(instanceType string) (architecture, amiType string, ok bool) {
	switch strings.TrimSpace(instanceType) {
	case "m8g.xlarge",
		"r8g.xlarge",
		"r8g.2xlarge",
		"m8g.large",
		"m8g.2xlarge",
		"m8g.4xlarge",
		"m6g.2xlarge",
		"m6g.16xlarge",
		"r6g.2xlarge",
		"t4g.2xlarge":
		return "arm64", "AL2023_ARM_64_STANDARD", true
	case "m7i.xlarge", "r7i.xlarge", "m7i.large", "m6i.2xlarge", "i7ie.large":
		return "amd64", "AL2023_x86_64_STANDARD", true
	default:
		return "", "", false
	}
}

func starterIntentRequested(cfg *SessionConfig) bool {
	if cfg == nil {
		return false
	}
	starter := &cfg.Starter
	topology := &starter.Activation.Topology
	return starter.Enabled || starter.Activation.PreProvision != nil ||
		starter.Activation.PDMS.Enabled != nil ||
		starter.AIFTS.Enabled ||
		starter.Observability.Enabled || starter.Observability.PrometheusRetention != nil ||
		strings.TrimSpace(topology.PD.InstanceType) != "" ||
		strings.TrimSpace(topology.TiKV.InstanceType) != "" ||
		strings.TrimSpace(topology.KeyspaceETCD.InstanceType) != "" ||
		strings.TrimSpace(topology.Gateway.InstanceType) != "" ||
		strings.TrimSpace(topology.RegionalControlPlane.InstanceType) != "" ||
		strings.TrimSpace(topology.TiDBServing.InstanceType) != "" ||
		topology.TiDBServing.Nodes != nil || starter.Activation.TiDBPoolPods != nil
}

func applyStarterDefaults(starter *SessionStarterConfig) {
	if starter == nil {
		return
	}
	resolved := ResolvedStarterTopology(&SessionConfig{Starter: *starter})
	starter.Activation.Topology = resolved
	if starter.Activation.TiDBPoolPods == nil {
		value := 0
		starter.Activation.TiDBPoolPods = &value
	}
	if starter.Activation.PDMS.Enabled == nil {
		value := true
		starter.Activation.PDMS.Enabled = &value
	}
	if starter.Observability.PrometheusRetention == nil {
		starter.Observability.PrometheusRetention = &Duration{Duration: 24 * time.Hour}
	}
}

func validateStarterConfig(cfg *SessionConfig, mode sessionLoadMode) error {
	if cfg == nil || !starterIntentRequested(cfg) {
		return nil
	}
	starter := &cfg.Starter
	// Destroy must keep working for sessions written before the retirement.
	if starter.Activation.PreProvision != nil && mode == sessionLoadDestroyCompatibility {
		starter.Activation.PreProvision = nil
	}
	if starter.Activation.PreProvision != nil {
		return cperr.New(
			cperr.CategoryConfig,
			"starter.activation.pre_provision is retired; use starter.enabled: true for eager full activation, or remove the field and run cp9 starter activate after cp9 up",
		)
	}
	applyStarterDefaults(starter)
	topology := &starter.Activation.Topology
	roles := []struct {
		path    string
		value   string
		allowed []string
	}{
		{path: "pd", value: topology.PD.InstanceType, allowed: []string{"m8g.4xlarge", "m8g.xlarge", "m7i.xlarge"}},
		{path: "tikv", value: topology.TiKV.InstanceType, allowed: []string{"i7ie.large", "r8g.xlarge", "r7i.xlarge"}},
		{path: "keyspace_etcd", value: topology.KeyspaceETCD.InstanceType, allowed: []string{"m8g.xlarge", "m8g.2xlarge", "m8g.large", "m7i.large"}},
		{path: "gateway", value: topology.Gateway.InstanceType, allowed: []string{"m8g.large", "m7i.large"}},
		{path: "regional_control_plane", value: topology.RegionalControlPlane.InstanceType, allowed: []string{"m6g.2xlarge", "m6i.2xlarge"}},
		{path: "tidb_serving", value: topology.TiDBServing.InstanceType, allowed: []string{"m8g.xlarge", "m7i.xlarge"}},
	}
	for _, role := range roles {
		valid := slices.Contains(role.allowed, role.value)
		if !valid {
			return cperr.Newf(cperr.CategoryConfig,
				"starter.activation.topology.%s.instance_type %q is unsupported (allowed: %s)",
				role.path, role.value, strings.Join(role.allowed, ", "))
		}
	}
	if topology.TiDBServing.Nodes == nil || *topology.TiDBServing.Nodes < 0 || *topology.TiDBServing.Nodes > 10 {
		return cperr.New(cperr.CategoryConfig,
			"starter.activation.topology.tidb_serving.nodes must be between 0 and 10")
	}
	if starter.Activation.TiDBPoolPods == nil || *starter.Activation.TiDBPoolPods != 0 {
		return cperr.New(cperr.CategoryConfig,
			"starter.activation.tidb_pool_pods must be 0 for staging regional parity")
	}
	retention := starter.Observability.PrometheusRetention
	if retention == nil || retention.Duration < time.Hour || retention.Duration > 72*time.Hour {
		return cperr.New(cperr.CategoryConfig,
			"starter.observability.prometheus_retention must be between 1h and 72h")
	}
	return nil
}

// EssentialV2Enabled reports whether cp9 up should activate the Essential V2
// shared pool before nextgen-global-service starts.
func EssentialV2Enabled(cfg *SessionConfig) bool {
	if cfg == nil || cfg.EssentialV2 == nil {
		return false
	}
	return cfg.EssentialV2.Enabled
}

// EssentialV2EnabledTiKVRemoteWorkers returns the compatibility-preserving
// initial shared-pool topology when the session does not override it.
func EssentialV2EnabledTiKVRemoteWorkers(cfg *SessionConfig) []string {
	if cfg == nil || cfg.EssentialV2 == nil || len(cfg.EssentialV2.EnabledTiKVRemoteWorkers) == 0 {
		return []string{"TIKV_WORKER", "COPROCESSOR_WORKER"}
	}
	return slices.Clone(cfg.EssentialV2.EnabledTiKVRemoteWorkers)
}

func validateEssentialV2Config(cfg *SessionConfig) error {
	if cfg == nil || cfg.EssentialV2 == nil {
		return nil
	}
	if cfg.EssentialV2.EnabledTiKVRemoteWorkers != nil &&
		len(cfg.EssentialV2.EnabledTiKVRemoteWorkers) == 0 {
		return cperr.New(cperr.CategoryConfig,
			"essential_v2.enabled_tikv_remote_workers must contain at least one worker when configured")
	}
	seen := map[string]struct{}{}
	for _, worker := range cfg.EssentialV2.EnabledTiKVRemoteWorkers {
		if worker != "TIKV_WORKER" && worker != "COPROCESSOR_WORKER" {
			return cperr.Newf(cperr.CategoryConfig,
				"essential_v2.enabled_tikv_remote_workers contains unsupported worker %q (allowed: TIKV_WORKER, COPROCESSOR_WORKER)",
				worker)
		}
		if _, ok := seen[worker]; ok {
			return cperr.Newf(cperr.CategoryConfig,
				"essential_v2.enabled_tikv_remote_workers contains duplicate worker %q", worker)
		}
		seen[worker] = struct{}{}
	}
	return nil
}

// DedicatedGCPEnabled reports whether this session adds the isolated GCP
// Dedicated regional runtime alongside its unchanged AWS primary runtime.
func DedicatedGCPEnabled(cfg *SessionConfig) bool {
	return cfg != nil && cfg.Dedicated != nil && cfg.Dedicated.GCP != nil && cfg.Dedicated.GCP.Enabled
}

func validateDedicatedGCPConfig(cfg *SessionConfig) error {
	if !DedicatedGCPEnabled(cfg) {
		return nil
	}
	gcp := cfg.Dedicated.GCP
	if SessionPersona(cfg) != "dev-full" {
		return cperr.New(cperr.CategoryConfig, "dedicated.gcp requires persona dev-full")
	}
	if cfg.Host.Mode != HostModeRemoteEC2K3d {
		return cperr.New(cperr.CategoryConfig,
			"dedicated.gcp requires the existing AWS primary host.mode remote-ec2-k3d")
	}
	if cfg.Target.Cloud != "aws" || cfg.Target.Region != "us-west-2" {
		return cperr.Newf(cperr.CategoryConfig,
			"dedicated.gcp requires the unchanged primary target aws/us-west-2 (got %s/%s)",
			cfg.Target.Cloud, cfg.Target.Region)
	}
	projects := []struct{ field, value string }{
		{field: "dedicated.gcp.seed_project_id", value: gcp.SeedProjectID},
		{field: "dedicated.gcp.dataplane_project_id", value: gcp.DataplaneProjectID},
	}
	for _, project := range projects {
		if !dedicatedGCPProjectIDPattern.MatchString(strings.TrimSpace(project.value)) {
			return cperr.Newf(cperr.CategoryConfig,
				"%s %q must be a lowercase GCP project ID with 6-30 characters", project.field, project.value)
		}
	}
	if gcp.Region != DedicatedGCPRegion {
		return cperr.Newf(cperr.CategoryConfig,
			"dedicated.gcp.region %q is unsupported in the first slice (expected %q)",
			gcp.Region, DedicatedGCPRegion)
	}
	if !slices.Contains([]string{"us-west1-a", "us-west1-b", "us-west1-c"}, gcp.Zone) {
		return cperr.Newf(cperr.CategoryConfig,
			"dedicated.gcp.zone %q is unsupported (expected us-west1-a, us-west1-b, or us-west1-c)",
			gcp.Zone)
	}
	return nil
}

// ValidatePersonaKey rejects path-like values where the active design expects a
// persona/business key such as "premium-reserved".
func ValidatePersonaKey(field, value string) error {
	trimmed := strings.TrimSpace(value)
	if trimmed == "" {
		return nil
	}
	if trimmed == "." || trimmed == ".." || strings.Contains(trimmed, "/") || strings.Contains(trimmed, `\`) {
		return cperr.Newf(cperr.CategoryConfig, `%s %q must be a persona key, not a path`, field, value)
	}
	return nil
}

func validateTerraformVarFileName(path string) error {
	base := filepath.Base(strings.TrimSpace(path))
	if !strings.HasSuffix(base, ".tfvars") {
		return cperr.Newf(cperr.CategoryConfig, "terraform.var_file %q must be an HCL .tfvars file", path)
	}
	persona := strings.TrimSuffix(base, ".tfvars")
	if err := ValidatePersonaKey("terraform.var_file", persona); err != nil {
		return err
	}
	if _, _, err := validateSessionNameSyntax(persona); err != nil {
		return cperr.Newf(cperr.CategoryConfig, "terraform.var_file %q must use <persona-name-id>.tfvars format: %v", path, err)
	}
	return nil
}

func sessionRolePath(roleARN string) string {
	roleARN = strings.TrimSpace(roleARN)
	parts := strings.SplitN(roleARN, ":", 6)
	if len(parts) != 6 {
		return ""
	}
	return strings.TrimPrefix(parts[5], "role/")
}

func sessionRoleAccountID(roleARN string) string {
	parts := strings.SplitN(strings.TrimSpace(roleARN), ":", 6)
	if len(parts) != 6 || parts[0] != "arn" || parts[2] != "iam" ||
		!validAWSAccountID(parts[4]) || !strings.HasPrefix(parts[5], "role/") {
		return ""
	}
	return parts[4]
}

func certificateARNAccountRegion(certificateARN string) (accountID, region string) {
	parts := strings.SplitN(strings.TrimSpace(certificateARN), ":", 6)
	if len(parts) != 6 || parts[0] != "arn" || parts[2] != "acm" ||
		!validAWSAccountID(parts[4]) || !strings.HasPrefix(parts[5], "certificate/") {
		return "", ""
	}
	return parts[4], parts[3]
}

func validateSessionKnownFields(root *yaml.Node, mode sessionLoadMode) error {
	if len(root.Content) == 0 {
		return cperr.New(cperr.CategoryConfig, "session config must not be empty")
	}
	return walkSessionSchema("root", root.Content[0], sessionConfigSchema, mode)
}

func walkSessionSchema(path string, node *yaml.Node, schema *sessionSchema, mode sessionLoadMode) error {
	if schema == nil || len(schema.fields) == 0 && schema.dynamic == nil {
		return nil
	}
	if node.Kind != yaml.MappingNode {
		return nil
	}
	for i := 0; i < len(node.Content); i += 2 {
		key := node.Content[i]
		value := node.Content[i+1]
		keyPath := key.Value
		if path != "root" {
			keyPath = path + "." + key.Value
		}
		child, ok := schema.fields[key.Value]
		if !ok {
			child = schema.dynamic
		}
		if child == nil {
			if migration, deprecated := derivedFieldMigration(keyPath); deprecated {
				if mode == sessionLoadDestroyCompatibility {
					continue
				}
				return cperr.Newf(cperr.CategoryConfig, "%s is deprecated; remove it because %s", keyPath, migration)
			}
			return cperr.Newf(cperr.CategoryConfig, "unknown field %s", keyPath)
		}
		if err := walkSessionSchema(keyPath, value, child, mode); err != nil {
			return err
		}
	}
	return nil
}

func derivedFieldMigration(path string) (string, bool) {
	switch path {
	case "appDelivery":
		return "the Session Tiltfile owns the runtime graph and reaches app-delivery itself; cp9 no longer takes a repo/ref/env here", true
	case "services.source", "services.live", "services.stub":
		return "the Session Tiltfile owns the service graph; declare source, live and stub services there instead", true
	case "target.aws.account_allowlist":
		return "the effective AWS account is derived from the authenticated target.aws.iam profile/role identity and persisted by cp9", true
	case "target.aws.iam.session_tags":
		return "the value is derived by cp9: cp9-owner comes from the authenticated AWS identity and cp9-session comes from the top-level Session name", true
	case "target.aws.iam.profile":
		return "the runtime AWS profile name is derived from the top-level Session name", true
	case "target.aws.remote_eks.enabled":
		return "Starter's regional EKS cluster is activated by cp9 starter activate, not by session config", true
	case "target.aws.remote_eks.node_instance_type", "target.aws.remote_eks.node_count":
		return "physical node roles are configured under starter.activation.topology", true
	case "terraform.state":
		return "local is the only supported Terraform state backend and cp9 selects it", true
	case "starter.activation.pre_provision":
		return "eager activation is starter.enabled: true, and lazy activation is cp9 starter activate after cp9 up", true
	case "starter.activation.tidb_pool_pods":
		return "the serving pod count is fixed at 0 for staging regional parity", true
	case "host.ec2.instance_profile":
		return "the instance profile is the one Terraform generates for this session's remote runtime, and cp9 overwrites anything set here", true
	case "byoc.customer.o11y.cidr", "byoc.customer.o11y.pca_arn":
		return "BYOC o11y onboarding consumes only hosted_zone_name and hosted_zone_id; a CIDR and PCA are required for tidb, not for o11y", true
	case "host.ec2.ami":
		return "the value is derived by cp9 as a regional Amazon Linux 2023 AMI from host.ec2.instance_type and target.region", true
	default:
		return "", false
	}
}

func makeAbsPath(base, value string) string {
	if strings.TrimSpace(value) == "" {
		return value
	}
	if filepath.IsAbs(value) {
		return filepath.Clean(value)
	}
	return filepath.Clean(filepath.Join(base, value))
}

func requirePathWithin(root, value, field string) error {
	root = filepath.Clean(root)
	value = filepath.Clean(value)
	rel, err := filepath.Rel(root, value)
	if err != nil {
		return cperr.Newf(cperr.CategoryConfig, "%s %q must stay within terraform.path root %q: %v", field, value, root, err)
	}
	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
		return cperr.Newf(cperr.CategoryConfig, "%s %q must stay within terraform.path root %q", field, value, root)
	}
	return nil
}

// encodeSessionYAML renders a session config for resolved artifacts and tests.
func encodeSessionYAML(cfg *SessionConfig) ([]byte, error) {
	var buf bytes.Buffer
	enc := yaml.NewEncoder(&buf)
	enc.SetIndent(2)
	if err := enc.Encode(cfg); err != nil {
		return nil, err
	}
	if err := enc.Close(); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}
