package stacks

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"log"
	"math/big"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
	"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
	"gitea.dooplex.hu/admin/felhom-controller/internal/system"
	"gopkg.in/yaml.v3"
)

// reservedSubdomains lists subdomains reserved for system use.
var reservedSubdomains = map[string]bool{
	"felhom":  true, // controller dashboard
	"files":   true, // filebrowser
	"traefik": true, // reverse proxy
	"api":     true,
	"www":     true,
	"mail":    true,
	"smtp":    true,
	"ftp":     true,
	"admin":   true,
	"portal":  true,
	"ssh":     true,
	"ns1":     true,
	"ns2":     true,
	"mx":      true,
	"pop":     true,
	"imap":    true,
}

var subdomainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)

// validateSubdomain checks that a subdomain is DNS-safe.
func validateSubdomain(s string) error {
	if s == "" {
		return fmt.Errorf("az aldomain nem lehet üres")
	}
	if len(s) > 63 {
		return fmt.Errorf("az aldomain legfeljebb 63 karakter lehet")
	}
	if !subdomainRe.MatchString(s) {
		return fmt.Errorf("az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel")
	}
	return nil
}

// SubdomainInUse checks if a subdomain is already used by any deployed stack
// other than excludeStack.
func (m *Manager) SubdomainInUse(subdomain, excludeStack string) bool {
	// Collect stack dirs and metadata under lock, then do I/O outside the lock.
	type candidate struct {
		dir           string
		metaSubdomain string
	}
	var candidates []candidate

	m.mu.RLock()
	for name, stack := range m.stacks {
		if name == excludeStack || !stack.Deployed {
			continue
		}
		candidates = append(candidates, candidate{
			dir:           filepath.Dir(stack.ComposePath),
			metaSubdomain: stack.Meta.Subdomain,
		})
	}
	m.mu.RUnlock()

	for _, c := range candidates {
		appCfg := LoadAppConfig(c.dir)
		if appCfg == nil {
			continue
		}
		if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
			return true
		}
		if _, hasSub := appCfg.Env["SUBDOMAIN"]; !hasSub {
			if c.metaSubdomain == subdomain {
				return true
			}
		}
	}
	return false
}

// AppConfig holds the per-app deployment configuration.
// Saved as app.yaml in each stack directory after first deployment.
type AppConfig struct {
	Deployed     bool              `yaml:"deployed" json:"deployed"`
	DeployedAt   string            `yaml:"deployed_at" json:"deployed_at"`
	Env          map[string]string `yaml:"env" json:"env"`
	LockedFields []string          `yaml:"locked_fields" json:"locked_fields"`
	// EmailEnabled is the per-app app-email toggle (default off). When on AND the global toggle is
	// on AND the app has an smtp_mapping, the controller injects the relay SMTP env at compose time.
	EmailEnabled bool `yaml:"email_enabled,omitempty" json:"email_enabled,omitempty"`
}

// DeployRequest contains the user-provided values from the deploy form.
type DeployRequest struct {
	StackName string            `json:"stack_name"`
	Values    map[string]string `json:"values"` // env_var -> user-provided value
}

// DeployStack handles first-time deployment of an app.
// Returns a warning message (empty if none) and an error if deployment is blocked.
// 1. Check available memory against app requirements
// 2. Load metadata (.felhom.yml) to know what fields exist
// 3. Auto-generate secrets for secret fields (hidden from user)
// 4. Auto-fill domain from controller config
// 5. Validate all user-provided values (password, path, required fields)
// 6. Save app.yaml
// 7. Run docker compose up -d with env vars
// 8. Update in-memory stack state
func (m *Manager) DeployStack(req DeployRequest) (string, error) {
	// Atomically check and set the Deploying flag to prevent concurrent deploys (H1 fix).
	m.mu.Lock()
	sPtr, sOk := m.stacks[req.StackName]
	if !sOk {
		m.mu.Unlock()
		return "", fmt.Errorf("stack %q not found", req.StackName)
	}
	if sPtr.Deploying {
		m.mu.Unlock()
		return "", fmt.Errorf("stack %q is already being deployed — please wait", req.StackName)
	}
	if sPtr.Deployed {
		m.mu.Unlock()
		return "", fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
	}
	sPtr.Deploying = true
	sPtr.DeployError = ""
	m.mu.Unlock()

	// If any validation below fails, clear the Deploying flag.
	clearDeploying := func() {
		m.mu.Lock()
		if s, ok := m.stacks[req.StackName]; ok {
			s.Deploying = false
		}
		m.mu.Unlock()
	}

	stack, ok := m.GetStack(req.StackName)
	if !ok {
		clearDeploying()
		return "", fmt.Errorf("stack %q not found", req.StackName)
	}

	stackDir := filepath.Dir(stack.ComposePath)
	meta := LoadMetadata(stackDir)

	// --- Memory validation ---
	var deployWarning string
	reservedMB := m.cfg.System.ReservedMemoryMB
	totalMB, usedMB, memErr := system.GetMemoryMB()
	// F1: the controller container cannot read the guest's RAM cap from /proc (no lxcfs) or its own
	// cgroup (the cap is on the LXC ancestor). Prefer the guest cap from the Docker daemon (runs in the
	// LXC). And use the controller's OWN committed-memory accounting for "used" — accurate and cheap —
	// rather than host /proc RSS, which is unobservable-per-guest and would otherwise make this guard
	// either never fire (host total) or always fire (host used > guest cap).
	if gt, ok := system.GuestMemTotalMB(); ok && gt > 0 {
		totalMB = gt
		memErr = nil
	}
	if committedReqMB, _ := m.CommittedMemory(); committedReqMB > 0 || memErr == nil {
		usedMB = committedReqMB
	}
	if memErr != nil {
		m.logger.Printf("[WARN] [stacks] Cannot read system memory: %v — skipping memory check", memErr)
	} else {
		usableMB := totalMB - reservedMB
		newReqMB := ParseMemoryMB(meta.Resources.MemRequest)

		m.logger.Printf("[INFO] [stacks] Memory check: total=%dMB, reserved=%dMB, usable=%dMB, committed_used=%dMB, new_req=%dMB, remaining=%dMB",
			totalMB, reservedMB, usableMB, usedMB, newReqMB, usableMB-usedMB-newReqMB)

		// Hard block: committed + new request exceeds usable memory
		if newReqMB > 0 && usedMB+newReqMB > usableMB {
			clearDeploying()
			return "", fmt.Errorf(
				"Nincs elég memória az alkalmazás telepítéséhez. "+
					"Szükséges: %d MB, Elérhető: %d MB "+
					"(összesen: %d MB, ebből %d MB használt, %d MB rendszer számára fenntartva)",
				newReqMB,
				usableMB-usedMB,
				totalMB,
				usedMB,
				reservedMB,
			)
		}

		// Soft warning: limits exceed total (overcommit)
		_, currentLimitMB := m.CommittedMemory()
		newLimitMB := ParseMemoryMB(meta.Resources.MemLimit)
		if newLimitMB > 0 && currentLimitMB+newLimitMB > totalMB {
			deployWarning = "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. " +
				"Normál használat mellett ez nem okoz problémát."
		}
	}

	// Debug: log received values (redact passwords/secrets)
	m.logger.Printf("[DEBUG] Deploy %s: received %d user values", req.StackName, len(req.Values))
	for k, v := range req.Values {
		if strings.Contains(strings.ToLower(k), "password") || strings.Contains(strings.ToLower(k), "secret") {
			m.logger.Printf("[DEBUG]   %s = [REDACTED, len=%d]", k, len(v))
		} else {
			m.logger.Printf("[DEBUG]   %s = %q", k, v)
		}
	}

	// Build the full env map
	env := make(map[string]string)
	var lockedFields []string

	for _, field := range meta.DeployFields {
		var value string

		switch field.Type {
		case "domain":
			// Auto-fill from controller config
			value = m.cfg.Customer.Domain

		case "subdomain":
			// User-editable with default from metadata
			if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
				value = strings.ToLower(strings.TrimSpace(userVal))
			} else if field.Default != "" {
				value = field.Default
			}
			if err := validateSubdomain(value); err != nil {
				clearDeploying()
				return "", err
			}
			if reservedSubdomains[value] {
				clearDeploying()
				return "", fmt.Errorf("a(z) %q aldomain foglalt rendszer számára", value)
			}
			if m.SubdomainInUse(value, req.StackName) {
				clearDeploying()
				return "", fmt.Errorf("a(z) %q aldomain már használatban van egy másik alkalmazásban", value)
			}

		case "secret":
			// Use pre-generated value if provided by the deploy page (same value the user saw),
			// otherwise fall back to generating a fresh one.
			if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
				value = userVal
			} else {
				generated, err := generateValue(field.Generate)
				if err != nil {
					clearDeploying()
					return "", fmt.Errorf("generating %s: %w", field.EnvVar, err)
				}
				value = generated
			}

		case "password":
			// Password fields MUST be filled by the user (via typing or Generálás button).
			// We never silently auto-generate — the user needs to know their password.
			if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
				value = userVal
			} else {
				clearDeploying()
				return "", fmt.Errorf("a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", field.Label)
			}

		default:
			// text, path, select, boolean — use user value or default
			if userVal, ok := req.Values[field.EnvVar]; ok {
				value = userVal
			} else if field.Default != "" {
				value = field.Default
			}
		}

		// Validate required fields
		if field.Required && value == "" {
			clearDeploying()
			return "", fmt.Errorf("a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar)
		}

		// Validate path fields exist on the host filesystem
		if field.Type == "path" && value != "" {
			if _, err := os.Stat(value); os.IsNotExist(err) {
				clearDeploying()
				return "", fmt.Errorf("path %q does not exist for field %q", value, field.Label)
			}
		}

		if value != "" {
			env[field.EnvVar] = value
		}

		if field.LockedAfterDeploy {
			lockedFields = append(lockedFields, field.EnvVar)
		}
	}

	// Save app.yaml.
	// CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false
	// until `docker compose up -d` actually succeeds (done in runComposeDeploy).
	// A crash/power-loss during the image-pull window must NOT leave a
	// ghost-deployed stack on disk (Deployed:true with no containers), which
	// DeployStack would then refuse to redeploy. The IN-MEMORY Deployed flag is
	// still set true below to preserve the "no stale Telepítés button during
	// pull" UX; only the durable record waits for success.
	appCfg := &AppConfig{
		Deployed:     true, // in-memory truth (see below); the disk write overrides to false
		DeployedAt:   time.Now().UTC().Format(time.RFC3339),
		Env:          env,
		LockedFields: lockedFields,
	}

	diskCfg := *appCfg
	diskCfg.Deployed = false // transitional: env saved, not yet marked deployed
	if err := SaveAppConfig(stackDir, &diskCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
		clearDeploying()
		return "", fmt.Errorf("saving app config: %w", err)
	}

	// Debug: log final env var keys (not values)
	envKeys := make([]string, 0, len(env))
	for k := range env {
		envKeys = append(envKeys, k)
	}
	m.logger.Printf("[INFO] [stacks] Deploying stack %s with %d env vars: [%s]", req.StackName, len(env), strings.Join(envKeys, ", "))

	// Check which images are available locally before pulling
	if m.isDebug() {
		m.checkLocalImages(req.StackName, stackDir)
	}

	// Update in-memory stack state. Deploying was already set at the top (H1 fix).
	// The compose-up runs in a goroutine so the API can return immediately
	// and the UI shows progress via polling (image pull can take 30-60s).
	m.mu.Lock()
	if s, ok := m.stacks[req.StackName]; ok {
		s.Deployed = true
		s.AppConfig = appCfg
	}
	m.mu.Unlock()

	// Run docker compose up -d asynchronously
	go m.runComposeDeploy(req.StackName, stackDir, env, appCfg)

	return deployWarning, nil
}

// runComposeDeploy executes docker compose up -d in background.
// On success it refreshes status; on failure it reverts the deploy state.
func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string, appCfg *AppConfig) {
	start := time.Now()
	_, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d")

	if composeErr != nil {
		m.logger.Printf("[ERROR] [stacks] Stack %s deploy failed after %.1fs: %v", name, time.Since(start).Seconds(), composeErr)
		// Revert in-memory and disk state
		m.mu.Lock()
		if s, ok := m.stacks[name]; ok {
			s.Deployed = false
			s.Deploying = false
			s.DeployError = composeErr.Error()
			s.AppConfig = nil
		}
		// Also revert the shared appCfg under lock (C03 fix)
		appCfg.Deployed = false
		m.mu.Unlock()
		// Save reverted state to disk with encryption (H05 fix)
		meta := LoadMetadata(stackDir)
		_ = SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta))
		return
	}

	m.logger.Printf("[INFO] [stacks] Stack %s deployed successfully (took %.1fs)", name, time.Since(start).Seconds())

	// CTRL-T2-1: compose up -d succeeded — only NOW mark deployed on disk.
	// (DeployStack wrote the env with Deployed:false; flip it true here so the
	// durable record matches reality and survives a restart.)
	meta := LoadMetadata(stackDir)
	if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
		// Running but not durably recorded as deployed. Revert so the customer
		// can cleanly redeploy rather than be stuck with a half-recorded stack.
		m.logger.Printf("[ERROR] [stacks] Stack %s: compose succeeded but persisting deployed state failed: %v — reverting", name, err)
		m.mu.Lock()
		if s, ok := m.stacks[name]; ok {
			s.Deployed = false
			s.Deploying = false
			s.DeployError = "deploy succeeded but state could not be saved: " + err.Error()
			s.AppConfig = nil
		}
		m.mu.Unlock()
		return
	}

	// Clear deploying flag
	m.mu.Lock()
	if s, ok := m.stacks[name]; ok {
		s.Deploying = false
	}
	m.mu.Unlock()

	// Post-deploy container state check (async, non-blocking)
	deployEnv := m.stackEnv(stackDir)
	m.logPostStartStatus(name, stackDir, deployEnv)

	_ = m.RefreshStatus()
}

// UpdateStackConfig updates non-locked fields for a deployed stack.
func (m *Manager) UpdateStackConfig(name string, values map[string]string) error {
	m.logger.Printf("[INFO] [stacks] Updating config for stack %s", name)
	if m.isDebug() {
		m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig called: name=%q, %d values to update", name, len(values))
	}

	stack, ok := m.GetStack(name)
	if !ok {
		return fmt.Errorf("stack %q not found", name)
	}

	stackDir := filepath.Dir(stack.ComposePath)
	appCfg := LoadAppConfig(stackDir)
	if appCfg == nil || !appCfg.Deployed {
		return fmt.Errorf("stack %q is not deployed yet", name)
	}

	if appCfg.Env == nil {
		appCfg.Env = make(map[string]string)
	}

	lockedSet := make(map[string]bool)
	for _, f := range appCfg.LockedFields {
		lockedSet[f] = true
	}

	meta := LoadMetadata(stackDir)
	var changedKeys []string
	for key, val := range values {
		if lockedSet[key] {
			return fmt.Errorf("field %q is locked and cannot be changed after deployment", key)
		}
		if appCfg.Env[key] != val {
			changedKeys = append(changedKeys, key)
		}
		appCfg.Env[key] = val
	}

	if m.isDebug() {
		m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig %s: changed keys: [%s], locked keys: %d", name, strings.Join(changedKeys, ", "), len(lockedSet))
	}

	if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
		return fmt.Errorf("saving updated config: %w", err)
	}

	// Use stackEnv which loads decrypted values for docker compose (C01 fix).
	// appCfg.Env may contain encrypted values from LoadAppConfig.
	env := m.stackEnv(stackDir)
	if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil {
		return fmt.Errorf("restarting with new config: %w", err)
	}

	m.logger.Printf("[INFO] [stacks] Stack %s config updated and restarted", name)
	return m.RefreshStatus()
}

// RedeployFromEnv writes app.yaml from the given FULL env (encrypting secret fields) and (re-)deploys
// the stack with `docker compose up -d`, which re-pulls the pinned image. Used by the restore-from-unit
// flow (Phase 2b): unlike UpdateStackConfig it sets the full env INCLUDING locked secrets — which were
// recovered from the guest's own app.yaml, never regenerated. Caller is responsible for the gate.
func (m *Manager) RedeployFromEnv(name string, env map[string]string) error {
	stack, ok := m.GetStack(name)
	if !ok {
		return fmt.Errorf("stack %q not found", name)
	}
	stackDir := filepath.Dir(stack.ComposePath)
	meta := LoadMetadata(stackDir)

	cfg := &AppConfig{
		Deployed:   true,
		DeployedAt: time.Now().UTC().Format(time.RFC3339),
		Env:        env,
	}
	for _, f := range meta.DeployFields {
		if f.LockedAfterDeploy {
			cfg.LockedFields = append(cfg.LockedFields, f.EnvVar)
		}
	}
	if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
		return fmt.Errorf("saving app config: %w", err)
	}

	m.mu.Lock()
	if s, ok := m.stacks[name]; ok {
		s.Deployed = true
		s.AppConfig = cfg
	}
	m.mu.Unlock()

	m.logger.Printf("[INFO] [stacks] Redeploying %s from recovery unit with %d env vars", name, len(env))
	deployEnv := m.stackEnv(stackDir) // decrypts secrets back for compose
	if _, err := m.composeExecCustomEnv(stackDir, deployEnv, "up", "-d"); err != nil {
		return fmt.Errorf("compose up: %w", err)
	}
	m.logPostStartStatus(name, stackDir, deployEnv)
	return m.RefreshStatus()
}

// composeExecWithEnv runs a compose command with custom env vars injected. Used by the initial deploy
// path (DeployStack), which builds env from the deploy values rather than from app.yaml via stackEnv —
// so USERDATA_PATH must be injected here too (mirrors stackEnv), else the FIRST deploy resolves
// ${USERDATA_PATH} to "" and binds a bogus root-owned dir at the container root.
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
	cmdEnv := os.Environ()
	for k, v := range env {
		cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
	}
	cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
	cmdEnv = withUserdataPath(cmdEnv, env["HDD_PATH"])
	return m.composeExecCustomEnv(dir, cmdEnv, args...)
}

// withUserdataPath appends USERDATA_PATH=<hdd>/userdata to a "K=V" env slice when hdd is non-empty.
// Shared by BOTH compose-env builders (stackEnv for start/redeploy, composeExecWithEnv for the initial
// deploy) so ${USERDATA_PATH} always resolves — the initial-deploy path missing it bound a bogus
// root-owned dir at the container root.
func withUserdataPath(cmdEnv []string, hdd string) []string {
	if hdd != "" {
		cmdEnv = append(cmdEnv, "USERDATA_PATH="+appbackup.UserdataDir(hdd))
	}
	return cmdEnv
}

// GetDeployFields returns the deployment fields for a stack (for the deploy form).
func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
	stack, ok := m.GetStack(name)
	if !ok {
		return nil, nil, fmt.Errorf("stack %q not found", name)
	}

	stackDir := filepath.Dir(stack.ComposePath)
	meta := LoadMetadata(stackDir)
	appCfg := LoadAppConfig(stackDir)

	return &meta, appCfg, nil
}

// UpdateOptionalConfig updates optional env vars in app.yaml and restarts the stack if deployed.
// Only updates env vars that are listed in the metadata's optional_config sections.
func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]string) error {
	m.logger.Printf("[INFO] [stacks] Updating optional config for stack %s", stackName)
	if m.isDebug() {
		m.logger.Printf("[DEBUG] [stacks] UpdateOptionalConfig called: stack=%q, %d values provided", stackName, len(values))
	}

	stack, ok := m.GetStack(stackName)
	if !ok {
		return fmt.Errorf("stack %q not found", stackName)
	}

	// Build a set of allowed env vars from optional_config
	allowed := make(map[string]bool)
	for _, group := range stack.Meta.OptionalConfig {
		for _, field := range group.Fields {
			allowed[field.EnvVar] = true
		}
	}
	if len(allowed) == 0 {
		return fmt.Errorf("no optional config fields defined for %s", stackName)
	}

	if m.isDebug() {
		allowedKeys := make([]string, 0, len(allowed))
		for k := range allowed {
			allowedKeys = append(allowedKeys, k)
		}
		m.logger.Printf("[DEBUG] [stacks] UpdateOptionalConfig %s: allowed fields: [%s]", stackName, strings.Join(allowedKeys, ", "))
	}

	// Load existing app.yaml (or create empty one)
	stackDir := filepath.Dir(stack.ComposePath)
	appCfg := LoadAppConfig(stackDir)
	if appCfg == nil {
		appCfg = &AppConfig{
			Env: make(map[string]string),
		}
	}
	if appCfg.Env == nil {
		appCfg.Env = make(map[string]string)
	}

	// Update only allowed env vars
	changed := false
	for key, val := range values {
		if !allowed[key] {
			m.logger.Printf("[WARN] [stacks] Ignoring non-optional env var: %s", key)
			continue
		}
		if appCfg.Env[key] != val {
			appCfg.Env[key] = val
			changed = true
			m.logger.Printf("[INFO] [stacks] Updated optional config %s for %s", key, stackName)
		}
	}

	if !changed {
		return nil
	}

	// Save app.yaml
	meta := LoadMetadata(stackDir)
	if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
		return fmt.Errorf("saving app config: %w", err)
	}
	m.logger.Printf("[INFO] [stacks] Saved updated app.yaml for %s", stackName)

	// If deployed, recreate containers to pick up new env vars
	// (docker compose restart does NOT pick up new env vars — must use up -d)
	if stack.Deployed {
		m.logger.Printf("[INFO] [stacks] Restarting %s to apply new optional config", stackName)
		env := m.stackEnv(stackDir)
		if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil {
			return fmt.Errorf("restart after config update: %w", err)
		}
		m.logPostStartStatus(stackName, stackDir, env)
	}

	return m.RefreshStatus()
}

// LoadAppConfigByName reads app.yaml for a named stack. Returns nil if not found.
func (m *Manager) LoadAppConfigByName(stackName string) *AppConfig {
	stack, ok := m.GetStack(stackName)
	if !ok {
		return nil
	}
	stackDir := filepath.Dir(stack.ComposePath)
	return LoadAppConfig(stackDir)
}

// PreviewDeployValues generates the auto-field values that will be used at deploy time:
// domain from controller config and freshly-generated secrets. These values are shown
// on the deploy page so the user can see (and note down) their passwords before deploying.
// Pass them back in DeployRequest.Values so the same values are saved to app.yaml.
func (m *Manager) PreviewDeployValues(name string) (map[string]string, error) {
	stack, ok := m.GetStack(name)
	if !ok {
		return nil, fmt.Errorf("stack %q not found", name)
	}
	stackDir := filepath.Dir(stack.ComposePath)
	meta := LoadMetadata(stackDir)

	result := make(map[string]string)
	for _, field := range meta.DeployFields {
		switch field.Type {
		case "domain":
			// Show the base domain. The subdomain is now a separate user-editable field.
			result[field.EnvVar] = m.cfg.Customer.Domain
		case "secret":
			if field.Generate == "" {
				continue
			}
			val, err := generateValue(field.Generate)
			if err != nil {
				return nil, fmt.Errorf("generating preview for %s: %w", field.EnvVar, err)
			}
			result[field.EnvVar] = val
		}
	}
	return result, nil
}

// --- App config persistence ---

func LoadAppConfig(stackDir string) *AppConfig {
	path := filepath.Join(stackDir, "app.yaml")
	data, err := os.ReadFile(path)
	if err != nil {
		return nil
	}
	cfg := &AppConfig{}
	if err := yaml.Unmarshal(data, cfg); err != nil {
		log.Printf("[WARN] [stacks] LoadAppConfig: %v", err)
		return nil
	}
	return cfg
}

func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error {
	encryptedCount := 0
	// Clone env and encrypt sensitive values
	saveCfg := &AppConfig{
		Deployed:     cfg.Deployed,
		DeployedAt:   cfg.DeployedAt,
		Env:          make(map[string]string, len(cfg.Env)),
		LockedFields: cfg.LockedFields,
		EmailEnabled: cfg.EmailEnabled,
	}
	sensitiveSet := make(map[string]bool, len(sensitiveVars))
	for _, v := range sensitiveVars {
		sensitiveSet[v] = true
	}
	for k, v := range cfg.Env {
		if encKey != nil && sensitiveSet[k] && !crypto.IsEncrypted(v) && v != "" {
			enc, err := crypto.Encrypt(encKey, v)
			if err != nil {
				// H10 (fail-closed): NEVER persist a sensitive value in plaintext.
				// Earlier code logged a WARN and fell through to a plaintext write;
				// that leaked the secret to disk. Abort the save instead — callers
				// already propagate this error and the deploy fails cleanly.
				return fmt.Errorf("encrypting sensitive env var %q (refusing to persist plaintext): %w", k, err)
			}
			saveCfg.Env[k] = enc
			encryptedCount++
			continue
		}
		saveCfg.Env[k] = v
	}

	log.Printf("[DEBUG] [stacks] SaveAppConfig: saving %s — %d env vars, %d encrypted, %d sensitive fields",
		stackDir, len(saveCfg.Env), encryptedCount, len(sensitiveVars))

	data, err := yaml.Marshal(saveCfg)
	if err != nil {
		log.Printf("[ERROR] [stacks] SaveAppConfig: failed to marshal config for %s: %v", stackDir, err)
		return fmt.Errorf("marshaling app config: %w", err)
	}
	path := filepath.Join(stackDir, "app.yaml")
	header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n"
	content := header + string(data)

	// Atomic write: write to .tmp then rename (H04 fix)
	tmpPath := path + ".tmp"
	if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
		log.Printf("[ERROR] [stacks] SaveAppConfig: failed to save %s: %v", path, err)
		return fmt.Errorf("writing %s: %w", tmpPath, err)
	}
	if err := os.Rename(tmpPath, path); err != nil {
		_ = os.Remove(tmpPath)
		log.Printf("[ERROR] [stacks] SaveAppConfig: failed to save %s: %v", path, err)
		return fmt.Errorf("renaming %s to %s: %w", tmpPath, path, err)
	}
	log.Printf("[INFO] [stacks] SaveAppConfig: saved config for %s", filepath.Base(stackDir))
	return nil
}

// LoadAppConfigDecrypted loads app.yaml and decrypts any encrypted values.
func LoadAppConfigDecrypted(stackDir string, encKey []byte) *AppConfig {
	cfg := LoadAppConfig(stackDir)
	if cfg == nil {
		return cfg
	}
	if encKey == nil {
		log.Printf("[DEBUG] [stacks] LoadAppConfigDecrypted: no encryption key, returning raw config for %s", stackDir)
		return cfg
	}
	cfg.Env = crypto.DecryptMap(encKey, cfg.Env)
	return cfg
}

// SensitiveEnvVars returns the env var names for secret/password fields from metadata.
func SensitiveEnvVars(meta *Metadata) []string {
	var vars []string
	for _, f := range meta.DeployFields {
		if f.Type == "secret" || f.Type == "password" {
			vars = append(vars, f.EnvVar)
		}
	}
	return vars
}

// --- Secret generation ---

const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

func generateValue(spec string) (string, error) {
	if spec == "" {
		return "", fmt.Errorf("empty generator spec")
	}
	parts := strings.SplitN(spec, ":", 2)
	if len(parts) != 2 {
		return "", fmt.Errorf("invalid generator spec: %q (expected type:param)", spec)
	}

	switch parts[0] {
	case "password":
		length := 0
		if _, err := fmt.Sscanf(parts[1], "%d", &length); err != nil || length <= 0 {
			return "", fmt.Errorf("invalid password length: %q", parts[1])
		}
		return randomAlphanumeric(length)
	case "hex":
		byteLen := 0
		if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 {
			return "", fmt.Errorf("invalid hex length: %q", parts[1])
		}
		b := make([]byte, byteLen)
		if _, err := rand.Read(b); err != nil {
			return "", fmt.Errorf("reading random bytes: %w", err)
		}
		return hex.EncodeToString(b), nil
	case "base64key":
		byteLen := 0
		if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 {
			return "", fmt.Errorf("invalid base64key length: %q", parts[1])
		}
		b := make([]byte, byteLen)
		if _, err := rand.Read(b); err != nil {
			return "", fmt.Errorf("reading random bytes: %w", err)
		}
		return "base64:" + base64.StdEncoding.EncodeToString(b), nil
	case "static":
		return parts[1], nil
	default:
		return "", fmt.Errorf("unknown generator type: %q", parts[0])
	}
}

// GenerateSecretForField generates a replacement value for a stack's RESETTABLE secret deploy-field
// (O4: the restore-from-unit path uses this — via backup.SetSecretGenerator — when a resettable
// secret cannot be recovered from the guest's app.yaml, so the app redeploys with a fresh credential
// instead of a blank one that fails compose-up).
//
// Returns ok=false when the field is unknown, has no generator spec, or — deliberately — is a
// DATA-ENCRYPTING key: data-keys are NEVER generated (regenerating one would render stored data
// unreadable; the restore's fail-closed gate refuses before this point, this is defense-in-depth).
// The generated VALUE is never logged — names only.
func (m *Manager) GenerateSecretForField(stackName, envVar string) (string, bool) {
	s, ok := m.GetStack(stackName)
	if !ok {
		return "", false
	}
	meta := LoadMetadata(filepath.Dir(s.ComposePath))
	for _, f := range meta.DeployFields {
		if f.EnvVar != envVar {
			continue
		}
		if f.DataKey {
			m.logger.Printf("[WARN] [stacks] GenerateSecretForField(%s/%s): refusing — field is a data-encrypting key", stackName, envVar)
			return "", false
		}
		if (f.Type != "secret" && f.Type != "password") || f.Generate == "" {
			return "", false
		}
		value, err := generateValue(f.Generate)
		if err != nil || value == "" {
			m.logger.Printf("[ERROR] [stacks] GenerateSecretForField(%s/%s): generator %q failed: %v", stackName, envVar, f.Generate, err)
			return "", false
		}
		return value, true
	}
	return "", false
}

// InjectMissingFields checks deployed stacks for new deploy_fields that are not
// yet in app.yaml and auto-generates values for secret/domain fields.
// Called after sync (for updated stacks) and on startup (for all deployed stacks).
func (m *Manager) InjectMissingFields(stackNames []string) {
	if m.isDebug() {
		m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: checking %d stacks", len(stackNames))
	}

	count := 0
	for _, name := range stackNames {
		stack, ok := m.GetStack(name)
		if !ok {
			continue
		}
		count++

		stackDir := filepath.Dir(stack.ComposePath)
		meta := LoadMetadata(stackDir)
		appCfg := LoadAppConfig(stackDir)
		if appCfg == nil || !appCfg.Deployed {
			if m.isDebug() {
				m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: skipping %s (not deployed or no app config)", name)
			}
			continue
		}

		if m.isDebug() {
			m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: checking stack %s — %d deploy fields, %d existing env vars",
				name, len(meta.DeployFields), len(appCfg.Env))
		}

		var injected []string
		for _, field := range meta.DeployFields {
			if _, exists := appCfg.Env[field.EnvVar]; exists {
				continue // already present
			}

			switch field.Type {
			case "secret":
				if field.Generate == "" {
					m.logger.Printf("[WARN] [stacks] Stack %s: new secret field %s has no generator — skipping", name, field.EnvVar)
					continue
				}
				value, err := generateValue(field.Generate)
				if err != nil {
					m.logger.Printf("[ERROR] [stacks] Stack %s: failed to generate %s: %v", name, field.EnvVar, err)
					continue
				}
				appCfg.Env[field.EnvVar] = value
				if field.LockedAfterDeploy {
					appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar)
				}
				injected = append(injected, field.EnvVar)

			case "domain":
				appCfg.Env[field.EnvVar] = m.cfg.Customer.Domain
				if field.LockedAfterDeploy && !containsStr(appCfg.LockedFields, field.EnvVar) {
					appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar)
				}
				injected = append(injected, field.EnvVar)

			case "subdomain":
				// Auto-fill from field default or metadata subdomain
				val := field.Default
				if val == "" {
					val = meta.Subdomain
				}
				if val == "" {
					m.logger.Printf("[WARN] [stacks] Stack %s: new subdomain field %s has no default — skipping", name, field.EnvVar)
					continue
				}
				appCfg.Env[field.EnvVar] = val
				if field.LockedAfterDeploy && !containsStr(appCfg.LockedFields, field.EnvVar) {
					appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar)
				}
				injected = append(injected, field.EnvVar)

			default:
				m.logger.Printf("[WARN] [stacks] Stack %s: new field %s (type=%s) requires manual configuration", name, field.EnvVar, field.Type)
			}
		}

		if len(injected) > 0 {
			if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
				m.logger.Printf("[ERROR] [stacks] Stack %s: failed to save app.yaml after injection: %v", name, err)
				continue
			}
			m.logger.Printf("[INFO] [stacks] Stack %s: injected missing fields: %s", name, strings.Join(injected, ", "))
		}
	}
	m.logger.Printf("[INFO] [stacks] InjectMissingFields: processed %d stacks", count)
}

func containsStr(slice []string, s string) bool {
	for _, v := range slice {
		if v == s {
			return true
		}
	}
	return false
}

func randomAlphanumeric(length int) (string, error) {
	result := make([]byte, length)
	for i := range result {
		n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumChars))))
		if err != nil {
			return "", err
		}
		result[i] = alphanumChars[n.Int64()]
	}
	return string(result), nil
}
