// Package provisioner is a thin wrapper around `tofu` (OpenTofu) — the
// canonical Phase 0 IaC layer per docs/ARCHITECTURE.md §10 and
// docs/SOVEREIGN-PROVISIONING.md §3.
//
// Per docs/INVIOLABLE-PRINCIPLES.md principle #3: OpenTofu provisions Phase 0
// cloud resources, Crossplane is the ONLY day-2 IaC, Flux is the ONLY GitOps
// reconciler, Blueprints are the ONLY install unit. This package therefore
// does NOT call cloud APIs directly, does NOT exec helm/kubectl, does NOT
// construct cloud-init inline. All of that lives in the OpenTofu module at
// infra/hetzner/ and in Crossplane Compositions / Flux Kustomizations the
// module bootstraps into the cluster.
//
// What this package DOES:
//   - validate the wizard's request (well-formed inputs)
//   - write a tofu.auto.tfvars.json file for the OpenTofu module
//   - exec `tofu init && tofu apply -auto-approve` and stream stdout to the
//     wizard via SSE events
//   - return tofu output values (control_plane_ip, load_balancer_ip)
//     as the Result the wizard's success screen consumes. The
//     kubeconfig is NOT a tofu output — the new Sovereign's cloud-init
//     PUTs it back over the bearer-token endpoint (issue #183), and
//     the handler writes the path onto Result.KubeconfigPath.
//
// Crossplane adoption (Phase 1 hand-off) and bootstrap-kit installation
// (Cilium → cert-manager → Flux → Crossplane → ... → bp-catalyst-platform)
// happen INSIDE the cluster via Flux reconciling clusters/<sovereign-fqdn>/
// in this monorepo — NOT from this Go process. By the time `tofu apply`
// returns, the cluster is bootstrapping itself.
package provisioner

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"time"
)

// ParentDomain is one entry in Request.ParentDomains — a registered
// parent zone the Sovereign-side PowerDNS becomes authoritative for
// after NS-flip lands at the registrar.
//
// Issue #826 (sub-1 of epic #825 — Multi-domain Sovereign) introduces
// this shape so a single Sovereign can own N parent domains rather
// than the legacy one-FQDN model. Day-1 (wizard provision) populates
// the slice with exactly ONE entry carrying Role="primary"; Day-2
// add-domain calls (issue #829) append additional entries with
// Role="sme-pool". The provisioning pipeline iterates over the slice
// applying the same per-domain steps to each (NS-flip via registrar,
// PowerDNS zone create, cert-manager Certificate create) — see
// ProvisionParentDomain below.
//
// The wizard UI stays single-FQDN (per the SCOPE CORRECTION on issue
// #826: multi-domain capability is a Day-2 admin-console action). The
// catalyst-api translates the wizard's single FQDN into a 1-element
// ParentDomains array internally — operators never see this struct in
// the wizard.
//
// Field semantics:
//
//   - Name is the registered domain at the registrar
//     (e.g. "omani.works"). Must match parentDomainNamePattern.
//   - Role is one of ParentDomainRolePrimary | ParentDomainRoleSMEPool.
//     Exactly one entry in the slice carries the primary role; zero or
//     more carry sme-pool. Validation enforces this at Validate() time.
//   - RegistrarKind is the adapter id used to flip the NS records at
//     the registrar (today: "dynadot"; future: "namecheap" / "godaddy"
//     / etc). Inviolable Principle #4 ("never hardcode") requires this
//     to be a runtime value, not a compile-time switch.
//   - RegistrarCredsRef is the name of a SealedSecret in
//     catalyst-system holding the registrar credentials. The
//     catalyst-api resolves this at provision time via the K8s API
//     (issue #829 wires the resolver). Empty means "fall back to the
//     same operator-supplied env-mounted credentials the catalyst-api
//     uses for the primary domain" — the typical case where one
//     Dynadot account holds every domain in the operator's portfolio.
//   - AddedAt is the UTC timestamp the domain was added to the pool.
//     Day-1 entries carry the deployment's StartedAt; Day-2 entries
//     carry the moment the admin console add-domain handler was
//     called. Stored on the persisted Record so the admin console can
//     render "added 3 days ago" on the parent-domains panel.
type ParentDomain struct {
	Name              string    `json:"name"`
	Role              string    `json:"role"`
	RegistrarKind     string    `json:"registrarKind,omitempty"`
	RegistrarCredsRef string    `json:"registrarCredsRef,omitempty"`
	AddedAt           time.Time `json:"addedAt,omitempty"`
}

// ParentDomain role constants. Exactly one ParentDomain in
// Request.ParentDomains carries ParentDomainRolePrimary; zero or more
// carry ParentDomainRoleSMEPool. The wizard's Day-1 path always
// produces a single primary entry — sme-pool entries are appended
// post-handover via the admin console (issue #829).
const (
	// ParentDomainRolePrimary marks the unique parent domain that
	// hosts the Sovereign's own URLs (console.<primary>,
	// api.<primary>, marketplace.<primary>, ...). Its zone is the
	// authoritative source for the Sovereign's bootstrap-kit
	// HTTPRoutes.
	ParentDomainRolePrimary = "primary"

	// ParentDomainRoleSMEPool marks a parent domain offered to SME
	// tenants for free-subdomain allocation. When an SME signs up
	// under a Sovereign, they pick from the sme-pool entries to
	// receive a free console.<sme>.<sme-pool> subdomain.
	ParentDomainRoleSMEPool = "sme-pool"
)

// parentDomainNamePattern is the FQDN regex applied to ParentDomain.Name.
// Matches the wizard's isValidDomain helper (RFC 1035 labels, ≥ 2
// labels). Lower-cased before the test runs.
var parentDomainNamePattern = regexp.MustCompile(`^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$`)

// defaultRegistrarKind is the registrar adapter used when a Day-1
// ParentDomain entry is synthesised from the legacy SovereignFQDN
// payload (the wizard captures a single FQDN + relies on the
// catalyst-api's env-mounted Dynadot credentials). Centralised here
// rather than scattered as a magic string — Inviolable Principle #4.
//
// Future operators on registrars other than Dynadot override this via
// CATALYST_DEFAULT_REGISTRAR_KIND on the catalyst-api Pod. The
// wizard's StepDomain stays unchanged: the operator ships their
// registrar credentials as env vars + sets this env to the matching
// adapter id.
const defaultRegistrarKind = "dynadot"

// s3BucketNamePattern enforces RFC-compliant S3 bucket naming on
// Request.ObjectStorageBucket per the rules at
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
// (Hetzner Object Storage applies the same lexical rules — they're a
// superset of the original S3 spec). Used by Validate(); also documented
// in infra/hetzner/variables.tf §object_storage_bucket_name's validation
// block so the same rule applies whether OpenTofu rejects it client-side
// or the catalyst-api does so server-side.
var s3BucketNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$`)

// RegionSpec is one entry in Request.Regions — the per-region sizing
// payload the wizard's StepProvider produces. Each topology slot has its
// own provider, its own cloud-region, and its own SKU vocabulary, so the
// canonical request shape carries one of these per slot.
//
// SKU strings are the provider's NATIVE instance-type identifier (cx32,
// VM.Standard.E5.Flex.4.32, m6i.xlarge, Standard_D4s_v5, ...). The
// OpenTofu module receives them verbatim via tofu.auto.tfvars.json and
// the provider's API validates them at apply time. The wizard reads
// every legal id from products/catalyst/bootstrap/ui/src/shared/constants/
// providerSizes.ts (PROVIDER_NODE_SIZES) — there is no SKU literal
// anywhere else in the wizard.
type RegionSpec struct {
	Provider         string `json:"provider"`
	CloudRegion      string `json:"cloudRegion"`
	ControlPlaneSize string `json:"controlPlaneSize"`
	WorkerSize       string `json:"workerSize"`
	WorkerCount      int    `json:"workerCount"`
}

// Request carries the wizard inputs the OpenTofu module needs.
type Request struct {
	OrgName  string `json:"orgName"`
	OrgEmail string `json:"orgEmail"`

	SovereignFQDN       string `json:"sovereignFQDN"`
	SovereignDomainMode string `json:"sovereignDomainMode"` // pool | byo
	SovereignPoolDomain string `json:"sovereignPoolDomain"`
	SovereignSubdomain  string `json:"sovereignSubdomain"`

	// ParentDomains — the canonical multi-domain payload introduced by
	// issue #826 (sub-1 of epic #825 — Multi-domain Sovereign). The
	// Sovereign-side PowerDNS becomes authoritative for every entry
	// here; exactly one entry carries Role="primary" (hosts
	// console/api/marketplace), zero-or-more carry Role="sme-pool"
	// (offered to SME tenants for free subdomains).
	//
	// Backward compatibility: when the wizard ships a payload without
	// this field (every wizard payload as of issue #826 — the wizard
	// stays single-FQDN per the scope correction), Validate()
	// synthesises a single primary entry from SovereignPoolDomain (or
	// SovereignFQDN when domain_mode is byo). Existing on-disk
	// deployment records persisted under the legacy single-FQDN shape
	// thus deserialize cleanly + on next Save() round-trip the
	// synthesised array, achieving the migration the issue's DoD calls
	// for without a one-shot migration step.
	//
	// Day-2 add-domain (issue #829) appends to this slice via the same
	// per-domain abstraction the Day-1 path uses — see
	// ProvisionParentDomain.
	ParentDomains []ParentDomain `json:"parentDomains,omitempty"`

	HetznerToken     string `json:"hetznerToken"`
	HetznerProjectID string `json:"hetznerProjectID"`

	// Legacy singular fields. When Regions is non-empty Validate()
	// derives these from Regions[0] so writeTfvars()'s single-region
	// apply path keeps working unchanged. When Regions is empty the
	// wizard is from before the per-provider rework migrated, or the
	// payload is hand-crafted (e.g. handler/load_test.go), and these
	// carry the request directly.
	Region           string `json:"region"`
	ControlPlaneSize string `json:"controlPlaneSize"`
	WorkerSize       string `json:"workerSize"`
	WorkerCount      int    `json:"workerCount"`

	HAEnabled bool `json:"haEnabled"`

	// MarketplaceEnabled — when true, bp-catalyst-platform 1.3.0+ renders the
	// marketplace + tenant-wildcard HTTPRoutes (issue #710). Threaded into
	// tofu via var.marketplace_enabled, then into the cloud-init Flux
	// Kustomization postBuild.substitute.MARKETPLACE_ENABLED. Default false
	// — operator opts in via wizard's "Enable Marketplace" component
	// checkbox.
	MarketplaceEnabled bool `json:"marketplaceEnabled"`

	// Per-region sizing payload — canonical from the per-provider rework
	// onwards. The wizard always emits this. Multi-region tofu wiring is
	// structural-correct (variables.tf and the cloud-init templates
	// accept the per-region SKU values), but only Regions[0] is end-to-end
	// exercised today against a real Hetzner project: writeTfvars()
	// renders the singular fields below, mirrored from Regions[0]. The
	// for_each iteration that activates the rest lives in the OpenTofu
	// module — this Go struct's role is to carry the data, intact, for
	// that iteration to pick up.
	Regions []RegionSpec `json:"regions,omitempty"`

	SSHPublicKey string `json:"sshPublicKey"`

	// Dynadot DNS credentials are passed through to the OpenTofu module as
	// variables when SovereignDomainMode is "pool" (the module only writes
	// DNS for managed pool domains; BYO Sovereigns require the customer to
	// point their own CNAME at the LB IP shown in the success screen).
	DynadotAPIKey    string `json:"-"`
	DynadotAPISecret string `json:"-"`

	// GHCRPullToken is a long-lived GHCR pull token (GitHub PAT or fine-
	// grained token with `packages:read` on `openova-io`) handed to the
	// new Sovereign at cloud-init time so it can pull the private bp-*
	// OCI artifacts from `ghcr.io/openova-io/`. Without this, every
	// HelmRepository CR in clusters/<sovereign-fqdn>/bootstrap-kit/
	// (each carrying `secretRef: name: ghcr-pull`) errors with
	// `failed to get authentication secret 'flux-system/ghcr-pull':
	// secrets "ghcr-pull" not found` on a fresh Sovereign — Phase 1
	// stalls at bp-cilium and the bootstrap kit never lands.
	//
	// Source of truth: the catalyst-api Pod mounts this from the
	// `catalyst-ghcr-pull-token` Kubernetes Secret in the `catalyst`
	// namespace as the env var CATALYST_GHCR_PULL_TOKEN. New() reads
	// the env var at startup; Provision() stamps it onto the Request
	// before writing tofu.auto.tfvars.json so OpenTofu can render the
	// cloud-init template with the token interpolated into the
	// flux-system/ghcr-pull Secret manifest.
	//
	// json:"-" is load-bearing: the field MUST NOT serialize to disk.
	// Persisted deployment records (internal/store) are redacted of
	// every credential, but redaction only fires on fields the store's
	// RedactedRequest projection knows about — keeping this off the
	// wire entirely is the simpler invariant. The value is only ever
	// in memory between New()'s env read and the tofu.auto.tfvars.json
	// write, both of which sit on the catalyst-api Pod's local FS at
	// 0o600.
	GHCRPullToken string `json:"-"`

	// HarborRobotToken — central Harbor proxy-cache robot account secret
	// (issue #557). Stamped server-side from Provisioner.HarborRobotToken
	// (env CATALYST_HARBOR_ROBOT_TOKEN). Interpolated by
	// cloudinit-control-plane.tftpl into /etc/rancher/k3s/registries.yaml
	// so containerd authenticates against harbor.openova.io's proxy
	// projects (proxy-dockerhub, proxy-gcr, proxy-quay, proxy-k8s,
	// proxy-ghcr) on every image pull. Without this, containerd falls
	// through to the upstream registry on a fresh Hetzner IP — Docker Hub
	// returns rate-limit HTML and pods stick at Init:0/6 (caught live
	// during otech24). json:"-" — never accepted from the wizard payload.
	HarborRobotToken string `json:"-"`

	// PowerDNSAPIKey — contabo PowerDNS API key (PR #681 followup).
	// The Sovereign-side bp-cert-manager-powerdns-webhook calls
	// pdns.openova.io to write DNS-01 challenge TXT records into
	// contabo's authoritative omani.works zone. The webhook reads its
	// API key from cert-manager/powerdns-api-credentials Secret on the
	// Sovereign — that Secret MUST be created by cloud-init at boot
	// (mirroring the harbor-robot-token pattern from PR #680) because
	// Reflector cannot bridge across clusters. Caught live on otech47.
	// Stamped server-side from Provisioner.PowerDNSAPIKey (env
	// CATALYST_POWERDNS_API_KEY). json:"-" — never accepted from
	// wizard payload.
	PowerDNSAPIKey string `json:"-"`

	// PDMBasicAuthUser / PDMBasicAuthPass — credentials for the public
	// PDM ingress at pool.openova.io (issue #879 Bug 2). cloudinit-
	// control-plane.tftpl writes them into the Sovereign's `flux-system/
	// pdm-basicauth` Secret so the catalyst-api Pod (mounted via
	// Reflector mirror into catalyst-system) can `Authorization: Basic …`
	// against the Traefik basicAuth Middleware in front of PDM.
	// Stamped server-side from Provisioner.PDMBasicAuthUser /
	// PDMBasicAuthPass (envs CATALYST_PDM_BASIC_AUTH_USER /
	// CATALYST_PDM_BASIC_AUTH_PASS). json:"-" — never accepted from
	// wizard payload. Empty falls through to a Secret with empty values;
	// the Sovereign's catalyst-api skips SetBasicAuth and degrades to
	// PDM 401 (clear log line) instead of crashlooping.
	PDMBasicAuthUser string `json:"-"`
	PDMBasicAuthPass string `json:"-"`

	// DeploymentID — catalyst-api's per-deployment identifier (16-char
	// hex). Stamped onto the Request by the handler before tfvars are
	// emitted so the OpenTofu cloud-init template can render the URL
	// the new Sovereign's control plane PUTs its kubeconfig to:
	//
	//   PUT https://console.openova.io/sovereign/api/v1/deployments/{id}/kubeconfig
	//
	// json:"-" so the wizard's create-payload never carries this from
	// the browser; the handler owns it. Persisting it on disk happens
	// via the store.Record's own ID field — we don't duplicate the
	// value into the redacted request.
	DeploymentID string `json:"-"`

	// KubeconfigBearerToken — 32-byte cryptographic-random token
	// generated at CreateDeployment time, stamped here by the handler
	// before tfvars are written. The plaintext value flows ONLY into
	// the OpenTofu workdir (encrypted PVC, deleted on `tofu destroy`)
	// and into the new Sovereign's cloud-init runcmd. The catalyst-api
	// side persists ONLY the SHA-256 hash on the deployment record;
	// the plaintext NEVER lands in the JSON store, never gets logged.
	//
	// On the new Sovereign, cloud-init renders this as a Bearer header
	// on a single PUT to /api/v1/deployments/{id}/kubeconfig. The
	// handler verifies SHA-256 of the received bearer matches the
	// persisted hash via constant-time compare, then writes the
	// kubeconfig file to /var/lib/catalyst/kubeconfigs/<id>.yaml.
	//
	// json:"-" so even if a future logging line marshals the Request
	// wholesale, the token does not leak.
	KubeconfigBearerToken string `json:"-"`

	// HandoverJWTPublicKey — RFC 7517 JWK JSON of the Catalyst-Zero
	// RS256 handover-JWT public key. Stamped by the handler from
	// h.handoverSigner.PublicJWK() before tfvars are written. Cloud-init
	// writes the JWK to /var/lib/catalyst/handover-jwt-public.jwk on the
	// new Sovereign control-plane (issue #605, Phase-8b) so Agent-C can
	// verify the one-time handover JWT without a cross-cluster RPC back
	// to Catalyst-Zero. Empty when CATALYST_HANDOVER_KEY_PATH is unset
	// (variables.tf default ""). json:"-" to keep it out of API request
	// JSON — it's stamped server-side, never accepted from the client.
	HandoverJWTPublicKey string `json:"-"`

	// ── Hetzner Object Storage (Phase 0b — issue #371) ──────────────────
	//
	// Per-Sovereign S3 backing for Harbor (#383) and Velero (#384). The
	// wizard's StepCredentials Object-Storage section captures these from
	// the operator (one-time-issued in the Hetzner Console — there is no
	// Cloud API to mint them; see infra/hetzner/variables.tf §Object
	// Storage for the constraint analysis).
	//
	// Region: fsn1 / nbg1 / hel1 (Object Storage availability is
	// European-only as of 2026-04). Independent of compute region;
	// ash/hil compute Sovereigns pick a European Object Storage region.
	//
	// AccessKey + SecretKey: standard AWS-S3-style credentials. The
	// catalyst-api validates them via S3 ListBuckets BEFORE
	// CreateDeployment returns 201 so the operator sees a typo'd key at
	// the wizard step, not 5 minutes into `tofu apply`.
	//
	// BucketName: deterministic per-Sovereign — the catalyst-api derives
	// it from the FQDN slug. We compute and stamp this in
	// CreateDeployment (writeTfvars never sees an empty bucket name);
	// the wizard does not surface it as a free-form input. Hetzner bucket
	// names share a global namespace across all Hetzner Object Storage
	// tenants, so a deterministic per-FQDN slug minimises collision risk.
	//
	// All four fields carry `json:"objectStorage*"` because the wizard's
	// browser ships them in the deployment-create POST body. The plaintext
	// lives in the per-deployment OpenTofu workdir (encrypted PVC, mode
	// 0600) until `tofu destroy` removes the workdir; the durable copy
	// is the K8s Secret cloud-init writes into the new Sovereign's
	// flux-system namespace. The catalyst-api's on-disk deployment
	// record is redacted via store.Redact.
	ObjectStorageRegion    string `json:"objectStorageRegion"`
	ObjectStorageAccessKey string `json:"objectStorageAccessKey"`
	ObjectStorageSecretKey string `json:"objectStorageSecretKey"`
	ObjectStorageBucket    string `json:"objectStorageBucket"`
}

// Validate ensures the wizard payload is complete enough for OpenTofu to run.
//
// Pointer receiver: when Regions is non-empty, Validate mirrors Regions[0]
// into the legacy singular fields so writeTfvars() can keep using them
// without conditional logic. Callers (handler.CreateDeployment) operate
// on the *Request anyway because the same instance is stored on
// Deployment.Request, so the mutation is intentional and persistent.
func (r *Request) Validate() error {
	if len(r.Regions) > 0 {
		// Each region must carry a provider + cloudRegion + control-plane
		// SKU. Worker SKU is required only when WorkerCount > 0.
		for i, rs := range r.Regions {
			if strings.TrimSpace(rs.Provider) == "" {
				return fmt.Errorf("region[%d] provider is required", i)
			}
			if strings.TrimSpace(rs.CloudRegion) == "" {
				return fmt.Errorf("region[%d] cloudRegion is required", i)
			}
			if strings.TrimSpace(rs.ControlPlaneSize) == "" {
				return fmt.Errorf("region[%d] controlPlaneSize is required", i)
			}
			if rs.WorkerCount < 0 {
				return fmt.Errorf("region[%d] workerCount must be non-negative", i)
			}
			if rs.WorkerCount > 0 && strings.TrimSpace(rs.WorkerSize) == "" {
				return fmt.Errorf("region[%d] workerSize is required when workerCount > 0", i)
			}

			// Issue #916 — region/SKU availability gate. The wizard
			// already filters its dropdowns by isSkuAvailableInRegion
			// (providerSizes.ts), but we MUST re-check here at the
			// catalyst-api boundary so a stale wizard build OR a
			// direct API caller bypassing the UI cannot dispatch
			// otech109's exact failure mode (cpx32 + ash → tofu
			// rejected after CP + LB + firewall already created).
			// See sku_availability.go for the matrix + rationale.
			if !IsSkuAvailableInRegion(rs.Provider, rs.ControlPlaneSize, rs.CloudRegion) {
				return fmt.Errorf(
					"region[%d]: %s",
					i,
					formatSkuRegionError("controlPlaneSize", rs.Provider, rs.ControlPlaneSize, rs.CloudRegion),
				)
			}
			if rs.WorkerCount > 0 && !IsSkuAvailableInRegion(rs.Provider, rs.WorkerSize, rs.CloudRegion) {
				return fmt.Errorf(
					"region[%d]: %s",
					i,
					formatSkuRegionError("workerSize", rs.Provider, rs.WorkerSize, rs.CloudRegion),
				)
			}
		}

		// Mirror Regions[0] into the legacy singular fields for the
		// single-region apply path inside writeTfvars().
		r.Region = r.Regions[0].CloudRegion
		r.ControlPlaneSize = r.Regions[0].ControlPlaneSize
		r.WorkerSize = r.Regions[0].WorkerSize
		r.WorkerCount = r.Regions[0].WorkerCount
	}

	if strings.TrimSpace(r.HetznerToken) == "" {
		return errors.New("hetzner token is required")
	}
	if strings.TrimSpace(r.HetznerProjectID) == "" {
		return errors.New("hetzner project ID is required")
	}
	if strings.TrimSpace(r.Region) == "" {
		return errors.New("region is required (runtime parameter, never hardcoded)")
	}
	if strings.TrimSpace(r.SovereignFQDN) == "" {
		return errors.New("sovereign FQDN is required")
	}

	// Issue #916 — legacy single-region path SKU/region check. When
	// the request did NOT supply r.Regions[] (back-compat payload from
	// a pre-multi-region wizard or a direct API caller), the singular
	// ControlPlaneSize / WorkerSize / Region fields ARE the canonical
	// SKU+region pair. The legacy path is hetzner-only — there is no
	// per-Sovereign provider field outside r.Regions, and the singular
	// fields feed `infra/hetzner/main.tf` exclusively (every other
	// provider's tofu module reads from r.Regions[]).
	if len(r.Regions) == 0 && strings.TrimSpace(r.ControlPlaneSize) != "" {
		if !IsSkuAvailableInRegion("hetzner", r.ControlPlaneSize, r.Region) {
			return errors.New(formatSkuRegionError(
				"controlPlaneSize", "hetzner", r.ControlPlaneSize, r.Region,
			))
		}
		if r.WorkerCount > 0 && strings.TrimSpace(r.WorkerSize) != "" &&
			!IsSkuAvailableInRegion("hetzner", r.WorkerSize, r.Region) {
			return errors.New(formatSkuRegionError(
				"workerSize", "hetzner", r.WorkerSize, r.Region,
			))
		}
	}

	// ParentDomains migration (issue #826 — backward compat with the
	// legacy single-FQDN payload). When the slice is empty, synthesise
	// a single primary entry from SovereignPoolDomain (managed-pool
	// mode) or SovereignFQDN (BYO mode) so the downstream provisioner
	// always operates on the array shape regardless of how the request
	// arrived. This is the migration step for in-flight wizard
	// deployments and for on-disk records persisted under the old
	// shape — the next Save() round-trip emits the array form. New
	// wizard payloads (none today; #826 scope correction holds the
	// wizard at single-FQDN) would populate the slice directly.
	//
	// When the slice IS supplied, validate every entry: name, role,
	// and "exactly one primary" invariant. The catalyst-api's
	// add-domain handler (#829) calls Validate() on the same request
	// shape after appending — re-running the check here is idempotent.
	if len(r.ParentDomains) == 0 {
		// Pick the synthesis source: pool-domain mode uses
		// SovereignPoolDomain (the operator-owned registered domain;
		// SovereignFQDN is the per-Sovereign sub-zone like
		// "omantel.omani.works"), BYO-or-empty falls back to
		// SovereignFQDN. The role is always "primary" — Day-1 has
		// exactly one entry.
		name := strings.TrimSpace(r.SovereignPoolDomain)
		if name == "" {
			name = strings.TrimSpace(r.SovereignFQDN)
		}
		name = strings.ToLower(name)
		// AddedAt is left zero on the synthesis path so the migration
		// is observable from the on-disk record (a non-zero
		// AddedAt indicates the entry was created via the explicit
		// admin add-domain flow). Callers stamp a real timestamp
		// when they construct a ParentDomain themselves — see
		// internal/handler's add-domain handler in #829.
		r.ParentDomains = []ParentDomain{{
			Name:          name,
			Role:          ParentDomainRolePrimary,
			RegistrarKind: defaultRegistrarKindFromEnv(),
		}}
	}
	if err := validateParentDomains(&r.ParentDomains); err != nil {
		return err
	}

	if strings.TrimSpace(r.OrgName) == "" {
		return errors.New("organisation name is required")
	}
	if strings.TrimSpace(r.OrgEmail) == "" {
		return errors.New("organisation email is required (becomes initial sovereign-admin)")
	}
	if strings.TrimSpace(r.SSHPublicKey) == "" {
		return errors.New("SSH public key is required (sovereign-admin break-glass + cluster bootstrap)")
	}

	// GHCR pull token: required for managed-pool deployments. Phase 1
	// (Flux reconciling clusters/<sovereign-fqdn>/bootstrap-kit) pulls
	// the bp-* OCI artifacts from `ghcr.io/openova-io/` which is a
	// PRIVATE registry path; without a token the source-controller
	// errors `secrets "ghcr-pull" not found` and bp-cilium never
	// installs.
	//
	// Why scoped to domain_mode=pool: managed-pool Sovereigns are the
	// catalyst-platform's reference deployment shape (omani.works,
	// follow-on franchise pool domains). BYO Sovereigns go through
	// the same Phase 1, but the BYO registrar proxy + Flow B path
	// (issue #169) is the orthogonal track for that work; the
	// requirement here is to land the durable-secret fix without
	// blocking BYO catalyst-api Pod startup when the operator has
	// not yet created `catalyst-ghcr-pull-token`. BYO deployments
	// will still fail Phase 1 if the token is missing — the failure
	// surfaces from the new cluster's Flux logs instead of from this
	// validator. The BYO+token requirement gets enforced once the
	// Flow B work also lands a token-on-Request stamp. See section F
	// in the fix-cloudinit-ghcr-pull-secret-durable rollout notes.
	//
	// We surface the pool-mode error from Validate() rather than from
	// runProvisioning() so a misconfigured catalyst-api Pod fails
	// fast at /api/v1/deployments POST time instead of after 5 min
	// of `tofu apply`.
	if r.SovereignDomainMode == "pool" && strings.TrimSpace(r.GHCRPullToken) == "" {
		return errors.New("GHCR pull token is required for managed-pool deployments (CATALYST_GHCR_PULL_TOKEN missing on catalyst-api — see docs/SECRET-ROTATION.md)")
	}
	// Harbor robot token (issue #557) — REQUIRED, no exceptions. The
	// architecture mandate is that every Sovereign image pull goes
	// through harbor.openova.io's proxy projects (proxy-dockerhub,
	// proxy-gcr, proxy-quay, proxy-k8s, proxy-ghcr). An empty token
	// means containerd will fail authentication against Harbor and
	// fall through to upstream registries — Docker Hub then
	// rate-limits a fresh Hetzner IP and pods stick at Init:0/6
	// forever (caught live during otech24). Fail fast at /api/v1/
	// deployments POST so a misconfigured catalyst-api Pod surfaces
	// the missing CATALYST_HARBOR_ROBOT_TOKEN env immediately
	// instead of after 5 min of tofu apply.
	if strings.TrimSpace(r.HarborRobotToken) == "" {
		return errors.New("Harbor robot token is required (CATALYST_HARBOR_ROBOT_TOKEN missing on catalyst-api — every Sovereign image pull MUST go through harbor.openova.io; falling through to docker.io is not allowed)")
	}

	// Hetzner Object Storage (issue #371) — Phase 0b. All four fields are
	// required for any Hetzner-backed Sovereign: the bucket exists at
	// `tofu apply` time (minio_s3_bucket in main.tf) so Harbor (#383) and
	// Velero (#384) find their backing store ready when Phase 1
	// reconciles their HelmReleases. The bucket name is computed by the
	// handler from the Sovereign FQDN slug — wizards never surface it.
	//
	// Validation here is fail-fast at /api/v1/deployments POST time so a
	// missing/typo'd credential pair surfaces as 400 with a clear pointer
	// rather than 5 minutes into `tofu apply`. The catalyst-api's
	// /api/v1/credentials/object-storage/validate endpoint is the wizard's
	// upstream gate — by the time the deployment payload arrives here,
	// the keys SHOULD already have been validated against ListBuckets.
	if strings.TrimSpace(r.ObjectStorageRegion) == "" {
		return errors.New("object storage region is required (Hetzner Object Storage region: fsn1 | nbg1 | hel1)")
	}
	switch r.ObjectStorageRegion {
	case "fsn1", "nbg1", "hel1":
		// OK — Hetzner Object Storage availability as of 2026-04.
	default:
		return fmt.Errorf("object storage region %q is not a valid Hetzner Object Storage region (must be fsn1, nbg1, or hel1)", r.ObjectStorageRegion)
	}
	if strings.TrimSpace(r.ObjectStorageAccessKey) == "" {
		return errors.New("object storage access key is required (issued in Hetzner Console → Object Storage → Manage Credentials)")
	}
	if strings.TrimSpace(r.ObjectStorageSecretKey) == "" {
		return errors.New("object storage secret key is required (paired with the access key; Hetzner shows the secret half exactly once at issue time)")
	}
	if strings.TrimSpace(r.ObjectStorageBucket) == "" {
		return errors.New("object storage bucket name is required (catalyst-api derives this deterministically from the Sovereign FQDN; an empty value here is a wizard or handler bug)")
	}
	// Bucket name validity mirrors the S3 RFC: 3-63 chars, lowercase
	// alphanumeric + hyphens, must start and end alphanumeric. The
	// wizard derives it from the FQDN slug so the rule should always
	// pass — but a hand-crafted POST (e.g. load test) could violate it,
	// so we re-enforce here.
	if !s3BucketNamePattern.MatchString(r.ObjectStorageBucket) {
		return fmt.Errorf("object storage bucket name %q does not match S3 naming rules (3-63 chars, lowercase alphanumeric + hyphens, start/end alphanumeric)", r.ObjectStorageBucket)
	}
	return nil
}

// Event is a single progress event streamed back to the wizard via SSE.
//
// Component / State are populated for Phase-1 component events emitted by
// the HelmRelease watch loop (internal/helmwatch). For Phase-0 OpenTofu
// events these stay empty so the existing wire format is unchanged — no
// existing field is removed or renamed; only two optional fields are
// added. The Admin shell's "logs filtered by event.component === id"
// path keys off Component; the per-app status pill keys off State.
//
// State semantics (Phase-1 watch only):
//
//   - "pending"    — HelmRelease appeared in the cluster but Ready
//     condition not yet observed, OR Ready=False with a
//     `dependency 'X' is not ready` message (the
//     component is waiting upstream of itself)
//   - "installing" — Ready=Unknown, or Ready=False with reason
//     `Progressing` / message `Reconciliation in progress`
//   - "installed"  — Ready=True
//   - "degraded"   — Ready=True transitioned to Ready=False without
//     InstallFailed/UpgradeFailed (a healthy install
//     that lost readiness post-install)
//   - "failed"     — Ready=False with reason InstallFailed /
//     UpgradeFailed / ChartPullError /
//     ArtifactFailed (the install actually broke,
//     not waiting on deps)
//
// For phase: "component-log" events, Component is set, State is empty,
// Level carries the helm-controller log level, and Message is the raw
// log line.
type Event struct {
	Time    string `json:"time"`
	Phase   string `json:"phase"`
	Level   string `json:"level"` // info | warn | error
	Message string `json:"message"`

	// Component is the normalised component id for Phase-1 events
	// ("bp-cilium" → "cilium"). Empty for Phase-0 OpenTofu events.
	Component string `json:"component,omitempty"`

	// State is one of pending|installing|installed|degraded|failed for
	// phase: "component" events; empty for Phase-0 events and for
	// phase: "component-log" events (which carry the original log
	// level instead).
	State string `json:"state,omitempty"`
}

// Result captures the OpenTofu outputs the wizard's success screen needs
// PLUS the Phase-1 component watch terminal state.
//
// ComponentStates and Phase1FinishedAt are populated by the HelmRelease
// watch loop in internal/helmwatch. They are the durable per-component
// outcome the Admin shell renders ("X of Y components installed") long
// after the live SSE stream has closed.
//
// KubeconfigPath holds the absolute path to the new Sovereign's k3s
// kubeconfig file on the catalyst-api PVC (typically
// /var/lib/catalyst/kubeconfigs/<id>.yaml, mode 0600). It is populated
// when the new Sovereign's cloud-init PUTs the kubeconfig back via the
// bearer-token endpoint (issue #183, Option D). The HelmRelease watch
// loop, the wizard's "Download kubeconfig" button, and the operator's
// GET /api/v1/deployments/<id>/kubeconfig all read from this file.
//
// The plaintext kubeconfig contents NEVER land in the JSON record on
// disk — only the file path is persisted. Per
// docs/INVIOLABLE-PRINCIPLES.md #10 (credential hygiene) the file is
// chmod 0600 and the directory is owned by the catalyst-api process
// UID on the PVC.
type Result struct {
	SovereignFQDN  string `json:"sovereignFQDN"`
	ControlPlaneIP string `json:"controlPlaneIP"`
	LoadBalancerIP string `json:"loadBalancerIP"`
	ConsoleURL     string `json:"consoleURL"`
	GitOpsRepoURL  string `json:"gitopsRepoURL"`

	// KubeconfigPath — absolute path to the kubeconfig YAML on the
	// catalyst-api PVC. Empty until cloud-init PUTs the bytes back
	// over the bearer-token endpoint. Persisted to the per-deployment
	// store record so a catalyst-api Pod restart can re-read the file
	// from disk and resume the Phase-1 HelmRelease watch.
	KubeconfigPath string `json:"kubeconfigPath,omitempty"`

	// ComponentStates — final state of every bp-* HelmRelease the
	// Phase-1 watch observed, keyed by normalised component id. Set
	// when the watch loop terminates (all-installed, all-installed-or-
	// failed, or timeout).
	ComponentStates map[string]string `json:"componentStates,omitempty"`

	// Phase1FinishedAt — UTC timestamp the watch loop terminated.
	// nil while Phase 1 is in flight or has not started.
	Phase1FinishedAt *time.Time `json:"phase1FinishedAt,omitempty"`

	// Phase1Substate — live sub-status while Phase 1 is in flight
	// (issue #923). Set by helmwatch.Watcher.OnSubstate as the watch
	// progresses through pre-flight reachability + cache-sync. Cleared
	// (set to "") once the watch terminates and Phase1Outcome is
	// stamped. The Sovereign Admin's wizard banner reads this to
	// render a granular status pill while Status itself stays
	// "phase1-watching":
	//
	//   - "watcher-reconnecting" — apiserver was unreachable and the
	//     pre-flight probe is retrying with backoff (typical after a
	//     catalyst-api Pod restart while the LB / kube-vip warms up)
	//   - "watcher-watching"     — apiserver reachable, informer
	//     attached, observing per-component HelmRelease transitions
	//
	// Empty while Phase 1 has not started, has terminated, or the
	// build of catalyst-api predates the substate field — the wizard
	// falls back to rendering the bare Status pill.
	Phase1Substate string `json:"phase1Substate,omitempty"`

	// Phase1Outcome — terminal classification of the Phase-1 watch.
	// One of:
	//
	//   - "ready"                — all observed components installed,
	//                              ≥ MinBootstrapKitHRs were observed
	//   - "failed"                — all observed components terminal
	//                              AND ≥ MinBootstrapKitHRs were
	//                              observed, but at least one failed
	//   - "timeout"               — overall WatchTimeout elapsed with
	//                              partial state (≥1 HR observed)
	//   - "flux-not-reconciling"  — overall WatchTimeout elapsed with
	//                              ZERO HelmReleases ever observed.
	//                              The bootstrap-kit Kustomization on
	//                              the new Sovereign isn't reconciling.
	//                              Operator playbook in
	//                              docs/RUNBOOK-PROVISIONING.md
	//                              §"Phase 1 watch shows 0 HelmReleases".
	//
	// Empty while Phase 1 is in flight or was skipped (no kubeconfig).
	// The Sovereign Admin's wizard banner reads this to render the
	// right operator-actionable diagnostic instead of an opaque
	// "stuck" pill.
	Phase1Outcome string `json:"phase1Outcome,omitempty"`

	// HandoverFiredAt — UTC timestamp the catalyst-api auto-fired the
	// handover JWT mint after the Phase-1 watch terminated with
	// OutcomeReady (issues #764 + #768). Nil until the auto-fire
	// happens; non-nil afterward. Tests + the wizard's provision page
	// gate on (status=="ready" && HandoverURL != "" && HandoverFiredAt
	// != nil) to render the "Open your Sovereign console →" button +
	// the 5-second auto-redirect timer.
	HandoverFiredAt *time.Time `json:"handoverFiredAt,omitempty"`

	// HandoverURL — fully-qualified handover redirect URL (issues
	// #764 + #768). Shape:
	//
	//   https://console.<sovereignFqdn>/auth/handover?token=<jwt>
	//
	// The token is RS256-signed by catalyst-api's handoverjwt.Signer
	// (claims contract documented in internal/handoverjwt/signer.go);
	// the Sovereign-side /auth/handover handler validates it, mints a
	// session, and 302s to /console/dashboard. The URL is durable on
	// the deployment record so a Pod restart between the mint and the
	// browser-side redirect still surfaces the same URL on the next
	// /deployments/{id} poll. Empty until the auto-fire happens; the
	// JWT inside expires after 5 minutes per
	// handoverjwt.DefaultTTL — re-mint is the operator's manual
	// "Open Sovereign console" path (POST
	// /deployments/{id}/mint-handover-token).
	HandoverURL string `json:"handoverURL,omitempty"`
}

// Provisioner runs `tofu init && tofu apply` against the canonical
// infra/hetzner/ module.
type Provisioner struct {
	// ModulePath is the absolute path to the OpenTofu module directory.
	// In the deployed catalyst-api container this is /infra/hetzner/.
	ModulePath string
	// WorkDir is where per-deployment tofu state is kept. In production
	// this is a per-Sovereign subdirectory, persisted via the catalyst-api
	// PVC so re-runs (`tofu apply` again with same vars) are idempotent.
	WorkDir string
	// GHCRPullToken is the long-lived GHCR pull token mounted from the
	// `catalyst-ghcr-pull-token` Secret in the catalyst namespace as the
	// env var CATALYST_GHCR_PULL_TOKEN. Stamped onto every Request in
	// Provision() before writeTfvars(). Empty when the env var is
	// missing — Validate() rejects such deployments with a fail-fast
	// error so the operator notices the misconfiguration before
	// `tofu apply` runs.
	GHCRPullToken string

	// HarborRobotToken is the central Harbor proxy-cache robot account
	// secret (`robot$openova-bot` on harbor.openova.io). Mounted from
	// the Reflector-mirrored `harbor-robot-token` K8s Secret in the
	// catalyst namespace as env CATALYST_HARBOR_ROBOT_TOKEN.
	// cloudinit-control-plane.tftpl interpolates it into the new
	// Sovereign's /etc/rancher/k3s/registries.yaml so containerd
	// authenticates against harbor.openova.io's docker.io / gcr / quay /
	// k8s / ghcr proxy projects on every image pull (issue #557).
	// Empty falls through to anonymous Harbor pulls; if the proxy is
	// configured for public access this still works, but rate-limited
	// upstream (Docker Hub) pulls will fail when the proxy can't
	// authenticate either. Stamped onto every Request before tfvars.
	HarborRobotToken string

	// PowerDNSAPIKey is the contabo PowerDNS API key — the same value
	// living in the contabo cluster's `openova-system/powerdns-api-
	// credentials` Secret (key `api-key`). The catalyst-api Pod mounts
	// it via a Reflector-mirrored copy in the `catalyst` namespace as
	// env CATALYST_POWERDNS_API_KEY. cloudinit-control-plane.tftpl
	// interpolates it into the Sovereign's `cert-manager/powerdns-api-
	// credentials` Secret so bp-cert-manager-powerdns-webhook can write
	// DNS-01 challenge TXT records to contabo's authoritative omani.works
	// zone. PR #681 followup — without this the wildcard cert never
	// issues and the Sovereign Console TLS handshake fails (caught
	// live on otech47).
	PowerDNSAPIKey string

	// PDMBasicAuthUser / PDMBasicAuthPass — credentials for the public
	// PDM ingress at pool.openova.io (issue #879 Bug 2). Mounted from
	// the Reflector-mirrored `pdm-basicauth` Secret as envs
	// CATALYST_PDM_BASIC_AUTH_USER / CATALYST_PDM_BASIC_AUTH_PASS.
	// cloudinit-control-plane.tftpl interpolates them into the new
	// Sovereign's flux-system/pdm-basicauth Secret so its catalyst-api
	// inherits the same auth posture (Reflector mirrors them into
	// catalyst-system). Empty values render an empty Secret and the
	// Sovereign-side pdmFlipNS skips SetBasicAuth — same degradation
	// posture as the harbor-robot-token Empty-Token path.
	PDMBasicAuthUser string
	PDMBasicAuthPass string
}

// New returns a Provisioner with paths read from environment.
//
// CATALYST_GHCR_PULL_TOKEN is read here once at startup. If the env var is
// missing the Provisioner is still constructed (so the catalyst-api Pod
// comes up cleanly and the wizard endpoints not requiring it — the BYO
// flows that do not invoke Phase-1 bootstrap-kit, and the diagnostic
// endpoints — keep working). Provision() stamps the token onto every
// Request, and Validate() rejects deployments where the field is empty
// with a clear pointer to docs/SECRET-ROTATION.md.
func New() *Provisioner {
	return &Provisioner{
		ModulePath:       env("CATALYST_TOFU_MODULE_PATH", "/infra/hetzner"),
		WorkDir:          env("CATALYST_TOFU_WORKDIR", "/var/lib/catalyst/tofu"),
		GHCRPullToken:    os.Getenv("CATALYST_GHCR_PULL_TOKEN"),
		HarborRobotToken: os.Getenv("CATALYST_HARBOR_ROBOT_TOKEN"),
		PowerDNSAPIKey:   os.Getenv("CATALYST_POWERDNS_API_KEY"),
		PDMBasicAuthUser: os.Getenv("CATALYST_PDM_BASIC_AUTH_USER"),
		PDMBasicAuthPass: os.Getenv("CATALYST_PDM_BASIC_AUTH_PASS"),
	}
}

// Provision runs the full sequence. Emits events into the channel; returns
// Result on success.
func (p *Provisioner) Provision(ctx context.Context, req Request, events chan<- Event) (*Result, error) {
	// Stamp the GHCR pull token from the Provisioner (read once from
	// CATALYST_GHCR_PULL_TOKEN at New()) onto every Request BEFORE
	// validation. The handler does not — and must not — accept this
	// from the wizard payload, so the env-loaded value is the only
	// source. Stamping before Validate() lets the validator reject
	// the deployment with a clear error when the env var is missing.
	if strings.TrimSpace(req.GHCRPullToken) == "" {
		req.GHCRPullToken = p.GHCRPullToken
	}
	if strings.TrimSpace(req.HarborRobotToken) == "" {
		req.HarborRobotToken = p.HarborRobotToken
	}
	if strings.TrimSpace(req.PowerDNSAPIKey) == "" {
		req.PowerDNSAPIKey = p.PowerDNSAPIKey
	}
	if strings.TrimSpace(req.PDMBasicAuthUser) == "" {
		req.PDMBasicAuthUser = p.PDMBasicAuthUser
	}
	if req.PDMBasicAuthPass == "" {
		req.PDMBasicAuthPass = p.PDMBasicAuthPass
	}

	if err := req.Validate(); err != nil {
		return nil, err
	}

	emit := func(phase, level, msg string) {
		select {
		case events <- Event{Time: time.Now().UTC().Format(time.RFC3339), Phase: phase, Level: level, Message: msg}:
		default:
		}
	}

	// Per-deployment workdir keyed by Sovereign FQDN — re-running with the
	// same FQDN is idempotent (tofu apply on existing state).
	deployDir := filepath.Join(p.WorkDir, req.sovereignName())
	if err := os.MkdirAll(deployDir, 0o700); err != nil {
		return nil, fmt.Errorf("create workdir: %w", err)
	}

	// Stage the module by symlinking — keeps state isolated per deployment
	// while sharing the canonical module source.
	if err := stageModule(p.ModulePath, deployDir); err != nil {
		return nil, fmt.Errorf("stage tofu module: %w", err)
	}

	// Write tofu.auto.tfvars.json — OpenTofu auto-loads any *.auto.tfvars.json
	// in the working directory at apply time.
	if err := writeTfvars(deployDir, req); err != nil {
		return nil, fmt.Errorf("write tfvars: %w", err)
	}

	emit("tofu-init", "info", "Initialising OpenTofu working directory")
	if err := p.runTofu(ctx, deployDir, []string{"init", "-input=false", "-no-color"}, emit); err != nil {
		return nil, fmt.Errorf("tofu init: %w", err)
	}

	emit("tofu-plan", "info", "Planning Hetzner resources (network, firewall, server, LB, DNS)")
	if err := p.runTofu(ctx, deployDir, []string{"plan", "-input=false", "-no-color", "-out=tfplan"}, emit); err != nil {
		return nil, fmt.Errorf("tofu plan: %w", err)
	}

	emit("tofu-apply", "info", "Applying — this provisions real Hetzner resources, please wait")
	if err := p.runTofu(ctx, deployDir, []string{"apply", "-input=false", "-no-color", "-auto-approve", "tfplan"}, emit); err != nil {
		return nil, fmt.Errorf("tofu apply: %w", err)
	}

	emit("tofu-output", "info", "Reading OpenTofu outputs")
	out, err := p.readOutputs(ctx, deployDir)
	if err != nil {
		return nil, fmt.Errorf("read tofu outputs: %w", err)
	}

	emit("flux-bootstrap", "info", "Cloud-init has bootstrapped Flux + Crossplane in the new cluster — Flux will now reconcile clusters/"+req.SovereignFQDN+"/ from the public OpenOva monorepo, installing the 11-component bootstrap kit and bp-catalyst-platform umbrella in dependency order. The wizard's progress page will poll Flux Kustomizations on the new cluster for steady-state.")

	return &Result{
		SovereignFQDN:  req.SovereignFQDN,
		ControlPlaneIP: out.ControlPlaneIP,
		LoadBalancerIP: out.LoadBalancerIP,
		ConsoleURL:     fmt.Sprintf("https://console.%s", req.SovereignFQDN),
		GitOpsRepoURL:  fmt.Sprintf("https://gitea.%s", req.SovereignFQDN),
	}, nil
}

// Destroy runs `tofu destroy -auto-approve` against the per-deployment
// workdir for req.SovereignFQDN. Idempotent — re-running on a partially-
// destroyed state cleans up whatever's left. Streams stdout/stderr as
// Events to the wizard so the operator sees progress.
//
// On success the per-deployment workdir is REMOVED so the next
// re-provision starts fresh. On failure the workdir is preserved so the
// operator can inspect state — they MUST then run a force-purge against
// the cloud account directly to remove orphans, since `tofu destroy`
// failing partway leaves resources behind.
func (p *Provisioner) Destroy(ctx context.Context, req Request, events chan<- Event) error {
	if strings.TrimSpace(req.GHCRPullToken) == "" {
		req.GHCRPullToken = p.GHCRPullToken
	}
	if strings.TrimSpace(req.HarborRobotToken) == "" {
		req.HarborRobotToken = p.HarborRobotToken
	}
	if strings.TrimSpace(req.PowerDNSAPIKey) == "" {
		req.PowerDNSAPIKey = p.PowerDNSAPIKey
	}
	if strings.TrimSpace(req.PDMBasicAuthUser) == "" {
		req.PDMBasicAuthUser = p.PDMBasicAuthUser
	}
	if req.PDMBasicAuthPass == "" {
		req.PDMBasicAuthPass = p.PDMBasicAuthPass
	}

	emit := func(phase, level, msg string) {
		select {
		case events <- Event{Time: time.Now().UTC().Format(time.RFC3339), Phase: phase, Level: level, Message: msg}:
		default:
		}
	}

	deployDir := filepath.Join(p.WorkDir, req.sovereignName())

	// If the workdir doesn't exist, there's no tofu state to destroy —
	// either the deployment never made it past CreateDeployment, or it
	// was already cleaned up. Nothing to do; let the caller continue
	// with the post-tofu cleanup steps (Hetzner orphan purge, PDM
	// release, local state cleanup).
	if _, err := os.Stat(deployDir); os.IsNotExist(err) {
		emit("tofu-destroy", "info", "no tofu workdir for "+req.SovereignFQDN+" — nothing to destroy")
		return nil
	} else if err != nil {
		return fmt.Errorf("stat tofu workdir: %w", err)
	}

	// Re-stage the module + tfvars so a partially-cleaned workdir still
	// has what tofu needs to destroy.
	if err := stageModule(p.ModulePath, deployDir); err != nil {
		return fmt.Errorf("stage tofu module: %w", err)
	}
	if err := writeTfvars(deployDir, req); err != nil {
		return fmt.Errorf("write tfvars: %w", err)
	}

	emit("tofu-init", "info", "Re-initialising OpenTofu working directory for destroy")
	if err := p.runTofu(ctx, deployDir, []string{"init", "-input=false", "-no-color"}, emit); err != nil {
		return fmt.Errorf("tofu init: %w", err)
	}

	emit("tofu-destroy", "info", "Destroying Hetzner resources for "+req.SovereignFQDN+" (network, firewall, ssh-key, server, lb)")
	if err := p.runTofu(ctx, deployDir, []string{"destroy", "-input=false", "-no-color", "-auto-approve"}, emit); err != nil {
		// Don't remove the workdir — operator may want to inspect.
		return fmt.Errorf("tofu destroy: %w", err)
	}

	// Remove the workdir on success — next re-provision starts fresh.
	if err := os.RemoveAll(deployDir); err != nil {
		emit("tofu-destroy", "warn", "could not remove workdir "+deployDir+": "+err.Error())
		// Non-fatal — destroy itself succeeded.
	}

	emit("tofu-destroy", "info", "Tofu destroy complete; workdir removed")
	return nil
}

// runTofu executes `tofu <args>` in deployDir, streaming stdout/stderr lines
// as Events to the wizard.
func (p *Provisioner) runTofu(ctx context.Context, deployDir string, args []string, emit func(string, string, string)) error {
	cmd := exec.CommandContext(ctx, "tofu", args...)
	cmd.Dir = deployDir
	cmd.Env = append(os.Environ(),
		// HCLOUD_TOKEN must be in the environment for the hcloud provider —
		// OpenTofu's variable system does NOT pass tfvars to the provider's
		// auth flow, only to the module's variable references. So we duplicate
		// it as both a tfvar (module references it) AND env (provider auth).
		// The tfvar value is what gets serialized; we keep it short-lived.
		"TF_INPUT=false",
		"TF_IN_AUTOMATION=true",
	)

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	stderr, err := cmd.StderrPipe()
	if err != nil {
		return err
	}
	if err := cmd.Start(); err != nil {
		return err
	}

	go streamLines(stdout, "tofu", "info", emit)
	go streamLines(stderr, "tofu", "warn", emit)

	if err := cmd.Wait(); err != nil {
		return fmt.Errorf("tofu %s failed: %w", strings.Join(args, " "), err)
	}
	return nil
}

func streamLines(r io.Reader, phase, level string, emit func(string, string, string)) {
	scanner := bufio.NewScanner(r)
	scanner.Buffer(make([]byte, 64*1024), 1024*1024)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}
		emit(phase, level, line)
	}
}

type tofuOutputs struct {
	ControlPlaneIP string `json:"control_plane_ip"`
	LoadBalancerIP string `json:"load_balancer_ip"`
}

func (p *Provisioner) readOutputs(ctx context.Context, deployDir string) (*tofuOutputs, error) {
	cmd := exec.CommandContext(ctx, "tofu", "output", "-json", "-no-color")
	cmd.Dir = deployDir
	out, err := cmd.Output()
	if err != nil {
		return nil, err
	}
	// tofu output -json wraps each output in {"value": ..., "type": ..., "sensitive": ...}
	var raw map[string]struct {
		Value any `json:"value"`
	}
	if err := json.Unmarshal(out, &raw); err != nil {
		return nil, fmt.Errorf("parse tofu output: %w", err)
	}
	asString := func(key string) string {
		v, ok := raw[key]
		if !ok {
			return ""
		}
		if s, ok := v.Value.(string); ok {
			return s
		}
		return ""
	}
	return &tofuOutputs{
		ControlPlaneIP: asString("control_plane_ip"),
		LoadBalancerIP: asString("load_balancer_ip"),
	}, nil
}

// writeTfvars renders tofu.auto.tfvars.json from the wizard request. The
// module's variables.tf declares every key here; the JSON format is auto-
// loaded by tofu without any -var-file flag.
func writeTfvars(deployDir string, req Request) error {
	vars := map[string]any{
		// Identity
		"sovereign_fqdn":      req.SovereignFQDN,
		"sovereign_subdomain": req.SovereignSubdomain,
		"org_name":            req.OrgName,
		"org_email":           req.OrgEmail,

		// Marketplace exposure toggle (issue #710). Stringified for the tofu
		// var (declared as type string with "true"/"false" validation) so
		// drone-envsubst can interpolate it directly into the Flux
		// Kustomization postBuild.substitute block at cloud-init render
		// time without quoting surprises.
		"marketplace_enabled": map[bool]string{true: "true", false: "false"}[req.MarketplaceEnabled],

		// Hetzner — token gets baked into the state file unless the operator
		// configures a remote backend with encryption-at-rest. Per Catalyst
		// the production catalyst-api container's PVC is encrypted; for
		// air-gap installs the operator MUST configure remote backend.
		"hcloud_token":      req.HetznerToken,
		"hcloud_project_id": req.HetznerProjectID,
		"region":            req.Region,

		// Topology — singular fields drive today's solo apply path. The
		// per-region payload (regions, below) is structurally available
		// to the OpenTofu module's for_each iteration when the multi-
		// region wiring is activated; collapsing it back to single-SKU
		// here would break the architectural shape the wizard intends.
		//
		// IMPORTANT: control_plane_size / worker_size are conditionally
		// inserted below (after the literal map) when non-empty. An empty
		// string written to tofu.auto.tfvars.json OVERRIDES the variables.tf
		// default ("cpx21" / "cpx31") with "" — and "" fails the SKU regex
		// validator at plan time ("control_plane_size must match Hetzner
		// server-type naming"). Writing the keys only when set lets the
		// default-cost-optimized variables.tf defaults take effect for
		// zero-override request bodies.
		"worker_count": req.WorkerCount,
		"ha_enabled":   req.HAEnabled,

		// Per-region payload — emitted as a list of objects so the
		// OpenTofu module can iterate (variable "regions" in
		// infra/hetzner/variables.tf accepts this shape and is currently
		// unused by main.tf; the for_each that consumes it lives behind
		// the multi-region activation work). Structural-correct today;
		// no-op at apply time for solo deployments where len(regions)<=1.
		//
		// IMPORTANT: emit empty slice [] (not nil) when the request has
		// no per-region overrides. Go's nil slice marshals as JSON null
		// — and OpenTofu's validation block (`for r in var.regions`)
		// chokes on null with "Error: Iteration over null value" at
		// `tofu plan`. Live failure on otech86 (DID 103c52d08510006f,
		// 2026-05-04 11:12:43Z). The variables.tf default = [] is what
		// the validator expects; emit that shape explicitly.
		"regions": coalesceRegions(req.Regions),

		// SSH key — module creates an hcloud_ssh_key from this and attaches
		// to all servers. We never generate keys here; sovereign-admin
		// supplies the public half from their secrets manager.
		"ssh_public_key": req.SSHPublicKey,

		// Domain
		// The wizard exposes three modes (pool / byo-manual / byo-api) so
		// the StepDomain UX can branch the operator into the right
		// flow. The OpenTofu module only cares about the binary
		// pool/byo distinction (pool means "we own the parent zone via
		// Dynadot"; byo means "operator is bringing their own
		// domain"). Collapse byo-manual + byo-api → "byo" before
		// writing the tfvars. Caught live on omantel.biz 2026-05-07
		// — provisioning failed at `tofu plan` with
		// `Invalid value for variable: var.domain_mode is "byo-manual" — Domain mode must be 'pool' or 'byo'`.
		"domain_mode":    mapDomainModeForTofu(req.SovereignDomainMode),
		"pool_domain":    req.SovereignPoolDomain,
		"dynadot_key":    req.DynadotAPIKey, // empty when domain_mode != "pool"
		"dynadot_secret": req.DynadotAPISecret,

		// Multi-domain payload (issue #826) — emitted as a list of
		// objects so the OpenTofu module can iterate per-domain.
		// At Day-1 the slice has length 1 (the primary entry). The
		// OpenTofu module's `variable "parent_domains"` declaration
		// (when added in a follow-up) consumes this shape directly;
		// today the field is structural-correct + harmless if the
		// module's variables.tf has not yet declared it (OpenTofu
		// ignores unknown variables in tofu.auto.tfvars.json with a
		// warning, not an error). Emitting it now lets the Day-2
		// add-domain flow (#829) ship the same shape for every
		// re-render.
		//
		// IMPORTANT: emit empty slice [] (not nil) to mirror the
		// regions field's failure mode — `for r in var.parent_domains`
		// in a future module would fail on null.
		"parent_domains": coalesceParentDomains(req.ParentDomains),

		// Contabo PowerDNS API key — interpolated by
		// cloudinit-control-plane.tftpl into the Sovereign's
		// `cert-manager/powerdns-api-credentials` Secret so
		// bp-cert-manager-powerdns-webhook can write DNS-01 challenge
		// TXT records to contabo's authoritative omani.works zone.
		// PR #681 followup. Empty here = wildcard cert never issues
		// (caught live on otech47).
		"powerdns_api_key": req.PowerDNSAPIKey,

		// GitOps source — Flux on the new cluster watches this for
		// clusters/<sovereign-fqdn>/. Defaults to the public OpenOva monorepo;
		// override for air-gap (operator-mirrored Gitea).
		"gitops_repo_url": env("CATALYST_GITOPS_REPO_URL", "https://github.com/openova-io/openova"),
		"gitops_branch":   env("CATALYST_GITOPS_BRANCH", "main"),

		// GHCR pull token — interpolated into the cloud-init template so
		// the new Sovereign's Flux source-controller can pull private
		// bp-* OCI artifacts from `ghcr.io/openova-io/`. Marked sensitive
		// in variables.tf; OpenTofu never logs the value to stdout. The
		// tofu.auto.tfvars.json file containing it is 0o600 on the
		// catalyst-api Pod's local FS and is wiped when the deployment
		// directory is purged. Per docs/SECRET-ROTATION.md the token
		// rotates yearly and is stored in 1Password — never in git.
		"ghcr_pull_token": req.GHCRPullToken,

		// Harbor proxy-cache robot token (issue #557). Stamped server-
		// side. cloudinit-control-plane.tftpl writes it into
		// /etc/rancher/k3s/registries.yaml so containerd authenticates
		// against harbor.openova.io's proxy projects. Empty falls
		// through to anonymous Harbor pulls.
		"harbor_robot_token": req.HarborRobotToken,

		// PDM basic-auth credentials (issue #879 Bug 2). Stamped server-
		// side. cloudinit-control-plane.tftpl writes them into the
		// new Sovereign's flux-system/pdm-basicauth Secret so its
		// catalyst-api can call PDM via Authorization: Basic ….
		// Empty falls through to a Secret with empty values; the
		// Sovereign's pdmFlipNS skips SetBasicAuth and degrades to
		// PDM 401 with a clear log line, matching the harbor-robot-
		// token degradation posture.
		"pdm_basic_auth_user": req.PDMBasicAuthUser,
		"pdm_basic_auth_pass": req.PDMBasicAuthPass,

		// Cloud-init kubeconfig postback (issue #183, Option D). The
		// catalyst-api stamps deployment_id + kubeconfig_bearer_token
		// onto the Request before writeTfvars is called: deployment_id
		// keys the URL path /api/v1/deployments/<id>/kubeconfig the
		// new Sovereign PUTs to; kubeconfig_bearer_token is the
		// 32-byte random secret the new Sovereign attaches in the
		// Authorization header. The catalyst-api persists ONLY the
		// SHA-256 hash on the on-disk record; the plaintext lives in
		// the per-deployment OpenTofu workdir (encrypted PVC) until
		// `tofu destroy` removes it.
		//
		// catalyst_api_url is the public URL the new Sovereign PUTs
		// back to — runtime variable per docs/INVIOLABLE-PRINCIPLES.md
		// #4 so air-gapped franchises override without code changes.
		"deployment_id":           req.DeploymentID,
		"kubeconfig_bearer_token": req.KubeconfigBearerToken,
		"catalyst_api_url": env(
			"CATALYST_API_PUBLIC_URL",
			"https://console.openova.io/sovereign",
		),

		// Phase-8b handover JWK (issue #605). Stamped server-side from
		// h.handoverSigner.PublicJWK() in CreateDeployment. cloud-init
		// renders it into /var/lib/catalyst/handover-jwt-public.jwk on
		// the new Sovereign so Agent-C verifies the one-time handover
		// JWT without a cross-cluster RPC. variables.tf default "" keeps
		// the var optional — if the signer is unavailable the file lands
		// empty and the handover flow is simply not yet wired on that
		// Sovereign (caught at /auth/handover, not at provision time).
		"handover_jwt_public_key": req.HandoverJWTPublicKey,

		// ── Hetzner Object Storage (issue #371) ─────────────────────────
		// Per-Sovereign S3 backing for Harbor + Velero. variables.tf in
		// infra/hetzner/ declares all four keys; main.tf creates the bucket
		// idempotently via the aminueza/minio provider; cloudinit-control-
		// plane.tftpl writes the credentials into a flux-system Secret on
		// the new Sovereign so Phase 1 (Flux reconciling bp-harbor +
		// bp-velero) finds them already present.
		//
		// Persistence boundary: the tofu.auto.tfvars.json file containing
		// these values is mode 0600 on the catalyst-api Pod's encrypted
		// PVC and is wiped by the Destroy() flow on `tofu destroy`. The
		// in-cluster K8s Secret on the new Sovereign is the only durable
		// destination. The credentials NEVER live in the public openova
		// monorepo — that would violate docs/INVIOLABLE-PRINCIPLES.md #10.
		"object_storage_region":      req.ObjectStorageRegion,
		"object_storage_access_key":  req.ObjectStorageAccessKey,
		"object_storage_secret_key":  req.ObjectStorageSecretKey,
		"object_storage_bucket_name": req.ObjectStorageBucket,
	}

	// Conditionally include singular SKU fields. variables.tf in
	// infra/hetzner/ declares "cpx21" / "cpx31" defaults for the
	// cost-optimized 1× CP + 2× worker topology; writing an empty
	// string here would override the default with "" and fail the
	// SKU regex validator at `tofu plan`. Only emit when set.
	if strings.TrimSpace(req.ControlPlaneSize) != "" {
		vars["control_plane_size"] = req.ControlPlaneSize
	}
	if strings.TrimSpace(req.WorkerSize) != "" {
		vars["worker_size"] = req.WorkerSize
	}

	raw, err := json.MarshalIndent(vars, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(deployDir, "tofu.auto.tfvars.json"), raw, 0o600)
}

// stageModule copies the canonical module's *.tf files into the per-deployment
// workdir. We copy rather than symlink so each deployment can have its own
// .terraform/ + state, and so OpenTofu's working-directory model works as
// expected.
func stageModule(src, dst string) error {
	entries, err := os.ReadDir(src)
	if err != nil {
		return err
	}
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".tf") && !strings.HasSuffix(e.Name(), ".tftpl") {
			continue
		}
		from := filepath.Join(src, e.Name())
		to := filepath.Join(dst, e.Name())
		// Skip if already there and unchanged — re-runs of the same wizard
		// flow shouldn't re-copy module files.
		srcInfo, _ := os.Stat(from)
		dstInfo, _ := os.Stat(to)
		if dstInfo != nil && dstInfo.ModTime() == srcInfo.ModTime() && dstInfo.Size() == srcInfo.Size() {
			continue
		}
		raw, err := os.ReadFile(from)
		if err != nil {
			return err
		}
		if err := os.WriteFile(to, raw, 0o600); err != nil {
			return err
		}
	}
	return nil
}

func (r Request) sovereignName() string {
	return strings.ReplaceAll(r.SovereignFQDN, ".", "-")
}

func env(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

// ParentDomainStep is one named operation in the per-domain
// provisioning pipeline. Day-1 (wizard signup) runs every registered
// step against the single primary domain; Day-2 (admin add-domain
// from issue #829) runs the same steps against each newly-added
// sme-pool domain.
//
// Today the steps are:
//
//  1. registrar-flip — point the registered parent zone at the
//     Sovereign's PowerDNS by writing ns1/ns2/ns3 NS records (and a
//     glue A record for ns1) at the registrar's API.
//  2. powerdns-zone-create — POST /api/v1/servers/localhost/zones to
//     the Sovereign's PowerDNS so it serves the zone authoritatively.
//  3. cert-manager-cert — apply a Certificate CR for *.<domain>
//     against the Sovereign so cert-manager issues a wildcard.
//
// At Day-1 these are wired structurally inside the OpenTofu cloud-
// init template (the catalyst-api's role is to write tofu.auto.tfvars.
// json with the parent-domains list; the cloud-init renders the
// per-domain Job manifests). At Day-2 (#829) the catalyst-api
// implements them in-process — running each step against a freshly-
// added domain post-handover. Same interface, same semantics; only
// the executor differs.
//
// The interface lives here so #829's add-domain handler imports
// THIS package's contract — not a parallel one. Issue #826's DoD
// calls out "reusable per-domain function/interface ready for #829";
// this is that contract.
type ParentDomainStep interface {
	// Name is a stable identifier emitted in SSE events
	// (event.phase = "parent-domain:" + Name). Examples:
	// "registrar-flip", "powerdns-zone-create", "cert-manager-cert".
	Name() string

	// Apply executes the step against pd. Implementations MUST be
	// idempotent: re-running with the same (pd, lbIP) is a no-op
	// when the desired state is already in place. Idempotency is the
	// load-bearing property — the wipe + restart loop documented in
	// the Catalyst-Zero memory ("FAILURE = WIPE + RESTART FROM
	// ZERO") relies on every per-domain step being safe to re-run.
	//
	// The lbIP is the Sovereign's load-balancer IPv4, returned by
	// the Phase-0 OpenTofu apply. Day-1 has it on the Result struct;
	// Day-2 reads it from the persisted Record. Threading it through
	// the interface keeps the steps independent of how the IP got
	// computed.
	Apply(ctx context.Context, pd ParentDomain, lbIP string) error
}

// ProvisionParentDomain runs the full per-domain pipeline (every
// supplied ParentDomainStep, in order) against pd. Used by:
//
//   - Day-1 (wizard signup): the catalyst-api's runProvisioning
//     iterates Request.ParentDomains and calls this once per entry
//     (the slice is length 1 today). The OpenTofu module handles the
//     same fan-out structurally inside cloud-init; this Go-side path
//     exists for any domain step the catalyst-api owns directly
//     (e.g. a future "verify NS propagation" probe that runs
//     post-handover).
//   - Day-2 (#829 admin add-domain): the catalyst-api's
//     /api/v1/sovereign/parent-domains POST handler iterates the
//     freshly-appended entries and calls this for each. Same steps
//     as Day-1 — the abstraction is the entire point of #826.
//
// Per-step events are emitted via the supplied emit function so the
// SSE stream surfaces "parent-domain:registrar-flip start
// omani.works" → "parent-domain:registrar-flip ok omani.works" etc.
// The event shape supports per-domain emission so #829's add-domain
// flow surfaces progress per added domain.
//
// Stops on the first step error; subsequent steps are skipped
// (failing partway through is the operator's signal to wipe and
// restart per the wipe-and-rerun rule in the Catalyst-Zero memory).
// The error wraps the step name + domain name so the operator's log
// readout names the failure unambiguously.
func ProvisionParentDomain(ctx context.Context, pd ParentDomain, lbIP string, steps []ParentDomainStep, emit func(phase, level, msg string)) error {
	if emit == nil {
		emit = func(string, string, string) {}
	}
	for _, step := range steps {
		phase := "parent-domain:" + step.Name()
		emit(phase, "info", fmt.Sprintf("%s start %s", step.Name(), pd.Name))
		if err := step.Apply(ctx, pd, lbIP); err != nil {
			emit(phase, "error", fmt.Sprintf("%s failed for %s: %s", step.Name(), pd.Name, err.Error()))
			return fmt.Errorf("parent-domain step %q for %q: %w", step.Name(), pd.Name, err)
		}
		emit(phase, "info", fmt.Sprintf("%s ok %s", step.Name(), pd.Name))
	}
	return nil
}

// ProvisionParentDomains is the slice-flavoured ProvisionParentDomain.
// Iterates pds applying every step against each entry, in order.
// Day-1 (length 1) and Day-2 (length 1+) take the same path — the
// Day-1 caller passes Request.ParentDomains, the Day-2 caller passes
// a single-element slice with the freshly-added entry.
//
// Stops on first per-domain failure (the per-domain ProvisionParentDomain
// call). The caller (catalyst-api handler) decides whether the whole
// deployment should fail or whether to mark the failed entry and
// continue — neither policy lives here.
func ProvisionParentDomains(ctx context.Context, pds []ParentDomain, lbIP string, steps []ParentDomainStep, emit func(phase, level, msg string)) error {
	for i := range pds {
		if err := ProvisionParentDomain(ctx, pds[i], lbIP, steps, emit); err != nil {
			return err
		}
	}
	return nil
}

// validateParentDomains enforces the parent-domain pool invariants
// expressed by the multi-domain epic (#825):
//
//   - every entry's Name is a syntactically valid FQDN ≥ 2 labels
//     (parentDomainNamePattern, mirroring the wizard's isValidDomain
//     helper)
//   - every entry's Role is one of the two known roles
//     (ParentDomainRolePrimary | ParentDomainRoleSMEPool)
//   - exactly ONE entry carries the primary role — neither zero nor
//     two-or-more is acceptable
//   - duplicate Names within the slice are rejected (an operator
//     adding the same domain twice via the admin console is a wizard
//     bug; surfacing the error from Validate() lets #829's handler
//     reject with a clear 400 instead of a half-applied NS-flip)
//
// Pure function — no side effects, called by Request.Validate after
// the migration synthesis path runs so it sees the canonical array
// shape on every request.
// validateParentDomains uses a *[]ParentDomain so it can normalise
// each entry's Name in place (lowercase + trim) — the on-disk record
// then carries the canonical form, and downstream consumers (the
// registrar adapter, the CRD projection, the SME-signup pool
// dropdown) all see the same string. Mutating callers' slice
// through a pointer mirrors how Validate() already mutates the
// per-region singular fields on the same Request.
func validateParentDomains(pds *[]ParentDomain) error {
	if pds == nil || len(*pds) == 0 {
		return errors.New("parent domains list is empty (migration path failed to synthesise an entry — check SovereignFQDN / SovereignPoolDomain)")
	}
	primaryCount := 0
	seen := make(map[string]struct{}, len(*pds))
	for i := range *pds {
		pd := &(*pds)[i]
		name := strings.ToLower(strings.TrimSpace(pd.Name))
		if name == "" {
			return fmt.Errorf("parent domain[%d] name is required", i)
		}
		if !parentDomainNamePattern.MatchString(name) {
			return fmt.Errorf("parent domain[%d] name %q is not a valid FQDN (must be lowercase, ≥ 2 labels, RFC 1035)", i, pd.Name)
		}
		if _, dup := seen[name]; dup {
			return fmt.Errorf("parent domain[%d] name %q is a duplicate of an earlier entry", i, pd.Name)
		}
		seen[name] = struct{}{}
		// Normalise in-place so downstream consumers see the
		// canonical lowercase form.
		pd.Name = name

		switch pd.Role {
		case ParentDomainRolePrimary:
			primaryCount++
		case ParentDomainRoleSMEPool:
			// OK — zero-or-more allowed.
		case "":
			return fmt.Errorf("parent domain[%d] role is required (one of %q | %q)", i, ParentDomainRolePrimary, ParentDomainRoleSMEPool)
		default:
			return fmt.Errorf("parent domain[%d] role %q is not recognised (one of %q | %q)", i, pd.Role, ParentDomainRolePrimary, ParentDomainRoleSMEPool)
		}
	}
	if primaryCount != 1 {
		return fmt.Errorf("parent domains must contain exactly one entry with role=%q (found %d)", ParentDomainRolePrimary, primaryCount)
	}
	return nil
}

// PrimaryParentDomain returns the single entry in r.ParentDomains
// carrying ParentDomainRolePrimary, or nil if the slice has not yet
// been validated/synthesised. Callers use this to read the primary
// domain name when threading it through the OpenTofu module without
// duplicating the "find the primary" loop at every call site. After
// Validate() has run successfully, this is guaranteed non-nil.
func (r Request) PrimaryParentDomain() *ParentDomain {
	for i := range r.ParentDomains {
		if r.ParentDomains[i].Role == ParentDomainRolePrimary {
			return &r.ParentDomains[i]
		}
	}
	return nil
}

// SMEPoolParentDomains returns every entry in r.ParentDomains carrying
// ParentDomainRoleSMEPool. Day-1 always returns an empty slice; Day-2
// add-domain (issue #829) populates the result.
//
// The order matches r.ParentDomains so callers iterating with the
// catalyst-api's "add-order" semantics (oldest first) get a stable
// list — useful for the admin console's "added 3 days ago" rendering
// + for the SME wizard's parent-domain dropdown choosing the most
// recently added pool domain when nothing is selected.
func (r Request) SMEPoolParentDomains() []ParentDomain {
	out := make([]ParentDomain, 0, len(r.ParentDomains))
	for _, pd := range r.ParentDomains {
		if pd.Role == ParentDomainRoleSMEPool {
			out = append(out, pd)
		}
	}
	return out
}

// defaultRegistrarKindFromEnv returns the registrar adapter id used
// when synthesising a Day-1 ParentDomain entry from the legacy
// single-FQDN payload. Reads CATALYST_DEFAULT_REGISTRAR_KIND with
// fallback to defaultRegistrarKind ("dynadot"). Inviolable Principle
// #4: never hardcode the registrar kind in a code path; let
// operators on registrars other than Dynadot override via env.
func defaultRegistrarKindFromEnv() string {
	if v := strings.TrimSpace(os.Getenv("CATALYST_DEFAULT_REGISTRAR_KIND")); v != "" {
		return strings.ToLower(v)
	}
	return defaultRegistrarKind
}

// coalesceRegions normalises a nil RegionSpec slice to an empty slice so
// JSON marshalling emits `[]` instead of `null`. The OpenTofu module's
// `variable "regions"` validator runs `for r in var.regions` which fails
// on null with "Error: Iteration over null value" but accepts an empty
// list (the variables.tf default). Live failure on otech86 (DID
// 103c52d08510006f, 2026-05-04 11:12:43Z) when the autopilot zero-touch
// cycle launched without any per-region overrides.
func coalesceRegions(rs []RegionSpec) []RegionSpec {
	if rs == nil {
		return []RegionSpec{}
	}
	return rs
}

// coalesceParentDomains is the parent-domain analogue of coalesceRegions.
// Mirrors the same nil → empty slice contract so future OpenTofu
// modules iterating `var.parent_domains` get `[]` (validator-friendly)
// instead of `null` (validator-fatal). Issue #826.
func coalesceParentDomains(pds []ParentDomain) []ParentDomain {
	if pds == nil {
		return []ParentDomain{}
	}
	return pds
}

// mapDomainModeForTofu collapses the wizard's three-mode domain
// selector (`pool` / `byo-manual` / `byo-api`) into the binary
// pool/byo enum the OpenTofu module's `variable "domain_mode"`
// validation accepts.
//
// The wizard's tri-state exists for UX branching — pool flows through
// the OpenOva PDM + Dynadot path; byo-manual asks the operator to
// paste the NS records into their registrar manually; byo-api drives
// the registrar API automatically. From the cloud-infrastructure
// layer (Hetzner servers, network, LB) NONE of those distinctions
// matter — the only thing tofu needs to know is "do I need to call
// Dynadot at apply time?" which is `pool` only.
//
// An empty string maps to "byo" so the test path that leaves
// SovereignDomainMode unset doesn't accidentally trigger the pool
// branch.
func mapDomainModeForTofu(wizardMode string) string {
	if wizardMode == "pool" {
		return "pool"
	}
	return "byo"
}
