// Package config loads process-level backend configuration from environment
// variables.
//
// The variable set is the canonical inventory documented in
// `backend/README.md` §4. LoadFromEnv populates a Config from environment,
// applies the documented defaults, then runs Validate. Validate fails fast on
// any required-but-missing variable so the process never starts in a partially
// configured state.
package config

import (
	"fmt"
	netmail "net/mail"
	"os"
	"slices"
	"strconv"
	"strings"
	"time"
)

// Environment variable names. The exhaustive set follows README §4.
const (
	envShutdownTimeout = "BACKEND_SHUTDOWN_TIMEOUT"

	envLoggingLevel = "BACKEND_LOGGING_LEVEL"

	envHTTPListenAddr = "BACKEND_HTTP_LISTEN_ADDR"
	envHTTPReadTimeout = "BACKEND_HTTP_READ_TIMEOUT"
	envHTTPWriteTimeout = "BACKEND_HTTP_WRITE_TIMEOUT"
	envHTTPShutdownTimeout = "BACKEND_HTTP_SHUTDOWN_TIMEOUT"

	envGRPCPushListenAddr = "BACKEND_GRPC_PUSH_LISTEN_ADDR"
	envGRPCPushShutdownTimeout = "BACKEND_GRPC_PUSH_SHUTDOWN_TIMEOUT"

	envPostgresDSN = "BACKEND_POSTGRES_DSN"
	envPostgresMaxConns = "BACKEND_POSTGRES_MAX_CONNS"
	envPostgresMinConns = "BACKEND_POSTGRES_MIN_CONNS"
	envPostgresOperationTimeout = "BACKEND_POSTGRES_OPERATION_TIMEOUT"

	envSMTPHost = "BACKEND_SMTP_HOST"
	envSMTPPort = "BACKEND_SMTP_PORT"
	envSMTPUsername = "BACKEND_SMTP_USERNAME"
	envSMTPPassword = "BACKEND_SMTP_PASSWORD"
	envSMTPFrom = "BACKEND_SMTP_FROM"
	envSMTPTLSMode = "BACKEND_SMTP_TLS_MODE"

	envMailWorkerInterval = "BACKEND_MAIL_WORKER_INTERVAL"
	envMailMaxAttempts = "BACKEND_MAIL_MAX_ATTEMPTS"

	envDockerHost = "BACKEND_DOCKER_HOST"
	envDockerNetwork = "BACKEND_DOCKER_NETWORK"

	envGameStateRoot = "BACKEND_GAME_STATE_ROOT"

	envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
	envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"

	envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"

	envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
	envOTelMetricsExporter = "BACKEND_OTEL_METRICS_EXPORTER"
	envOTelProtocol = "BACKEND_OTEL_PROTOCOL"
	envOTelEndpoint = "BACKEND_OTEL_ENDPOINT"
	envOTelPrometheusListenAddr = "BACKEND_OTEL_PROMETHEUS_LISTEN_ADDR"
	envServiceName = "BACKEND_SERVICE_NAME"

	envFreshnessWindow = "BACKEND_FRESHNESS_WINDOW"

	envAuthChallengeTTL = "BACKEND_AUTH_CHALLENGE_TTL"
	envAuthChallengeMaxAttempts = "BACKEND_AUTH_CHALLENGE_MAX_ATTEMPTS"
	envAuthChallengeThrottleWindow = "BACKEND_AUTH_CHALLENGE_THROTTLE_WINDOW"
	envAuthChallengeThrottleMax = "BACKEND_AUTH_CHALLENGE_THROTTLE_MAX"
	envAuthUserNameMaxRetries = "BACKEND_AUTH_USERNAME_MAX_RETRIES"
	envAuthDevFixedCode = "BACKEND_AUTH_DEV_FIXED_CODE"

	envLobbySweeperInterval = "BACKEND_LOBBY_SWEEPER_INTERVAL"
	envLobbyPendingRegistrationTTL = "BACKEND_LOBBY_PENDING_REGISTRATION_TTL"
	envLobbyInviteDefaultTTL = "BACKEND_LOBBY_INVITE_DEFAULT_TTL"

	envEngineCallTimeout = "BACKEND_ENGINE_CALL_TIMEOUT"
	envEngineProbeTimeout = "BACKEND_ENGINE_PROBE_TIMEOUT"

	envRuntimeWorkerPoolSize = "BACKEND_RUNTIME_WORKER_POOL_SIZE"
	envRuntimeJobQueueSize = "BACKEND_RUNTIME_JOB_QUEUE_SIZE"
	envRuntimeReconcileInterval = "BACKEND_RUNTIME_RECONCILE_INTERVAL"
	envRuntimeImagePullPolicy = "BACKEND_RUNTIME_IMAGE_PULL_POLICY"
	envRuntimeContainerLogDriver = "BACKEND_RUNTIME_CONTAINER_LOG_DRIVER"
	envRuntimeContainerLogOpts = "BACKEND_RUNTIME_CONTAINER_LOG_OPTS"
	envRuntimeContainerCPUQuota = "BACKEND_RUNTIME_CONTAINER_CPU_QUOTA"
	envRuntimeContainerMemory = "BACKEND_RUNTIME_CONTAINER_MEMORY"
	envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
	envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
	envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"

	envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
	envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
	envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"

	envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES"
	envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES"
	envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL"
	envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
	envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
	envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"

	envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
	envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
	envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
	envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
)

// Default values applied when an environment variable is absent.
const (
	defaultShutdownTimeout = 30 * time.Second

	defaultLoggingLevel = "info"

	defaultHTTPListenAddr = ":8080"
	defaultHTTPReadTimeout = 30 * time.Second
	defaultHTTPWriteTimeout = 30 * time.Second
	defaultHTTPShutdownTimeout = 15 * time.Second

	defaultGRPCPushListenAddr = ":8081"
	defaultGRPCPushShutdownTimeout = 10 * time.Second

	defaultPostgresMaxConns = 25
	defaultPostgresMinConns = 2
	defaultPostgresOperationTimeout = 5 * time.Second

	defaultSMTPPort = 587
	defaultSMTPTLSMode = "starttls"

	defaultMailWorkerInterval = 2 * time.Second
	defaultMailMaxAttempts = 8

	defaultDockerHost = "unix:///var/run/docker.sock"

	defaultOTelTracesExporter = "otlp"
	defaultOTelMetricsExporter = "otlp"
	defaultOTelProtocol = "grpc"
	defaultOTelPrometheusListenAddr = ":9100"
	defaultServiceName = "galaxy-backend"

	defaultFreshnessWindow = 5 * time.Minute

	defaultAuthChallengeTTL = 10 * time.Minute
	defaultAuthChallengeMaxAttempts = 5
	defaultAuthChallengeThrottleWindow = 60 * time.Second
	defaultAuthChallengeThrottleMax = 3
	defaultAuthUserNameMaxRetries = 10

	defaultLobbySweeperInterval = 60 * time.Second
	defaultLobbyPendingRegistrationTTL = 30 * 24 * time.Hour
	defaultLobbyInviteDefaultTTL = 7 * 24 * time.Hour

	defaultEngineCallTimeout = 60 * time.Second
	defaultEngineProbeTimeout = 5 * time.Second

	defaultRuntimeWorkerPoolSize = 4
	defaultRuntimeJobQueueSize = 64
	defaultRuntimeReconcileInterval = 60 * time.Second
	defaultRuntimeImagePullPolicy = "if_missing"
	defaultRuntimeContainerLogDriver = "json-file"
	defaultRuntimeContainerCPUQuota = 2.0
	defaultRuntimeContainerMemory = "512m"
	defaultRuntimeContainerPIDsLimit = 256
	defaultRuntimeContainerStateMount = "/var/lib/galaxy-game"
	defaultRuntimeStopGracePeriod = 10 * time.Second

	defaultNotificationWorkerInterval = 5 * time.Second
	defaultNotificationMaxAttempts = 8

	defaultDiplomailMaxBodyBytes = 4096
	defaultDiplomailMaxSubjectBytes = 256
	defaultDiplomailTranslatorTimeout = 10 * time.Second
	defaultDiplomailTranslatorMaxAttempts = 5
	defaultDiplomailWorkerInterval = 2 * time.Second

	defaultDevSandboxEngineVersion = "0.1.0"
	defaultDevSandboxPlayerCount = 20
)

// Allowed values for the closed-set string options.
var (
	allowedTracesExporters = []string{"none", "otlp", "stdout"}
	allowedMetricsExporters = []string{"none", "otlp", "stdout", "prometheus"}
	allowedOTelProtocols = []string{"grpc", "http/protobuf"}
	allowedSMTPTLSModes = []string{"none", "starttls", "tls"}
	allowedPullPolicies = []string{"if_missing", "always", "never"}
)

// Config is the top-level backend configuration assembled from environment
// variables. The zero value is not usable; callers must obtain a Config via
// DefaultConfig or LoadFromEnv.
type Config struct {
	// ShutdownTimeout bounds each component's Shutdown call coordinated by
	// the process App lifecycle. Per-listener timeouts (HTTP, gRPC) bound the
	// inner server stop and may be smaller than ShutdownTimeout.
	ShutdownTimeout time.Duration

	Logging LoggingConfig
	HTTP HTTPConfig
	GRPCPush GRPCPushConfig
	Postgres PostgresConfig
	SMTP SMTPConfig
	Mail MailConfig
	Docker DockerConfig
	Game GameConfig
	Admin AdminBootstrapConfig
	GeoIP GeoIPConfig
	Telemetry TelemetryConfig
	Auth AuthConfig
	Lobby LobbyConfig
	Engine EngineConfig
	Runtime RuntimeConfig
	Notification NotificationConfig
	Diplomail DiplomailConfig
	DevSandbox DevSandboxConfig

	// FreshnessWindow mirrors the gateway freshness window and is used by the
	// push server to bound the cursor TTL.
	FreshnessWindow time.Duration
}

// DevSandboxConfig configures the boot-time bootstrap implemented in
// `backend/internal/devsandbox`. When Email is empty the bootstrap
// is a no-op, which is the production posture. When Email is set —
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
// the bootstrap idempotently provisions a real user, the configured
// number of dummy participants, a private "Dev Sandbox" game, the
// matching memberships, and drives the lifecycle to `running`. The
// engine image and engine version refer to a row that the bootstrap
// also seeds in `engine_versions`.
type DevSandboxConfig struct {
	Email string
	EngineImage string
	EngineVersion string
	PlayerCount int
}

// LoggingConfig stores the parameters used by the structured logger.
type LoggingConfig struct {
	// Level is the zap level name (e.g. "debug", "info", "warn", "error").
	Level string
}

// HTTPConfig configures the public HTTP listener.
type HTTPConfig struct {
	Addr string
	ReadTimeout time.Duration
	WriteTimeout time.Duration
	ShutdownTimeout time.Duration
}

// GRPCPushConfig configures the gRPC push listener.
type GRPCPushConfig struct {
	Addr string
	ShutdownTimeout time.Duration
}

// PostgresConfig configures the primary Postgres pool.
//
// MinConns mirrors README §4 BACKEND_POSTGRES_MIN_CONNS and is interpreted as
// the maximum number of idle connections kept warm in the pool — database/sql
// has no real minimum-pool concept, so this is the closest equivalent. The
// mapping is documented in `backend/README.md` and `backend/docs/`.
type PostgresConfig struct {
	DSN string
	MaxConns int
	MinConns int
	OperationTimeout time.Duration
}

// SMTPConfig configures the SMTP relay used by the mail outbox.
type SMTPConfig struct {
	Host string
	Port int
	Username string
	Password string
	From string
	TLSMode string
}

// MailConfig configures the mail outbox worker.
type MailConfig struct {
	WorkerInterval time.Duration
	MaxAttempts int
}

// DockerConfig configures the Docker client used by the runtime module.
type DockerConfig struct {
	Host string
	Network string
}

// GameConfig configures the runtime engine container layout.
type GameConfig struct {
	StateRoot string
}

// AdminBootstrapConfig configures the optional first-admin bootstrap.
// At startup the admin module inserts a row in `backend.admin_accounts`
// when User is non-empty and no row with that username exists yet; the
// insert is idempotent across restarts.
type AdminBootstrapConfig struct {
	User string
	Password string
}

// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
type GeoIPConfig struct {
	DBPath string
}

// TelemetryConfig configures the OpenTelemetry runtime.
type TelemetryConfig struct {
	ServiceName string
	TracesExporter string
	MetricsExporter string
	Protocol string
	Endpoint string
	PrometheusListenAddr string
}

// AuthConfig configures the email-code authentication flow implemented in
// `backend/internal/auth`. ChallengeTTL bounds the lifetime of an issued
// `auth_challenges` row, ChallengeMaxAttempts caps confirm-email-code
// attempts per challenge, ChallengeThrottle bounds new-challenge issuance
// per email, and UserNameMaxRetries caps the retry budget for synthesising
// a unique `accounts.user_name` at registration.
type AuthConfig struct {
	ChallengeTTL time.Duration
	ChallengeMaxAttempts int
	ChallengeThrottle AuthChallengeThrottleConfig
	UserNameMaxRetries int

	// DevFixedCode, when non-empty, makes ConfirmEmailCode accept this
	// literal as a valid code in addition to the bcrypt-verified one
	// stored on the challenge row. The override is intended for the
	// `tools/local-dev` stack so a developer can log in without
	// reading codes out of Mailpit. The variable MUST stay unset in
	// production: validation requires a six-digit decimal value, and
	// the auth service emits a loud startup warning when it picks the
	// override up.
	DevFixedCode string
}

// AuthChallengeThrottleConfig bounds how many un-consumed, non-expired
// challenges a single email may hold inside a sliding window before the
// auth service starts reusing the most recent existing challenge instead
// of issuing a new one.
type AuthChallengeThrottleConfig struct {
	Window time.Duration
	Max int
}

// EngineConfig configures the per-call timeouts of `engineclient` against
// running game-engine containers. CallTimeout bounds turn-generation-class
// operations (init, turn, banish, command, order); ProbeTimeout bounds
// inspect-style reads (status, report, healthz).
type EngineConfig struct {
	CallTimeout time.Duration
	ProbeTimeout time.Duration
}

// RuntimeConfig configures the runtime module: worker pool, reconciliation
// cadence, image-pull policy, and per-container resource defaults applied
// at engine container creation time.
type RuntimeConfig struct {
	// WorkerPoolSize bounds the number of concurrent long-running runtime
	// jobs (image pull, container start, restart, patch).
	WorkerPoolSize int

	// JobQueueSize is the buffered job channel capacity. Once full, new
	// runtime requests block briefly until a worker frees a slot.
	JobQueueSize int

	// ReconcileInterval bounds how often the runtime reconciler reads the
	// Docker daemon's labelled containers and reconciles them against
	// `runtime_records`.
	ReconcileInterval time.Duration

	// ImagePullPolicy selects the dockerclient pull behaviour:
	// `if_missing`, `always`, or `never`.
	ImagePullPolicy string

	// ContainerLogDriver is the Docker log driver applied to every engine
	// container created by the runtime (e.g., `json-file`).
	ContainerLogDriver string

	// ContainerLogOpts is the comma-separated `key=value` list passed to
	// the log driver. May be empty.
	ContainerLogOpts string

	// ContainerCPUQuota is the `--cpus` value applied as a resource limit
	// on each engine container.
	ContainerCPUQuota float64

	// ContainerMemory is the `--memory` value (e.g. `512m`).
	ContainerMemory string

	// ContainerPIDsLimit is the `--pids-limit` value.
	ContainerPIDsLimit int

	// ContainerStateMount is the absolute in-container path the per-game
	// state directory is bind-mounted at.
	ContainerStateMount string

	// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
	// applied during stop / cancel / restart / patch.
	StopGracePeriod time.Duration
}

// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits
// are enforced in the service layer, so they can be tuned at runtime
// without a schema migration. Body and subject are stored as plain
// UTF-8 text; HTML is neither parsed nor sanitised on the server.
type DiplomailConfig struct {
	// MaxBodyBytes caps the length of `diplomail_messages.body` in
	// bytes (not runes). A send whose body exceeds the limit is
	// rejected with ErrInvalidInput.
	MaxBodyBytes int

	// MaxSubjectBytes caps the length of `diplomail_messages.subject`
	// in bytes. Subjects are optional; the empty-string default
	// passes the limit trivially.
	MaxSubjectBytes int

	// TranslatorURL is the base URL of the LibreTranslate-compatible
	// instance the async translation worker calls. When empty, the
	// worker still runs but falls through to "deliver original"
	// (the noop translator returns engine=noop).
	TranslatorURL string

	// TranslatorTimeout bounds a single HTTP request to the
	// translator. Worker retries (exponential backoff up to
	// TranslatorMaxAttempts) layer on top.
	TranslatorTimeout time.Duration

	// TranslatorMaxAttempts is the number of times the worker tries
	// to translate one (message, target_lang) pair before falling
	// back to delivering the original body.
	TranslatorMaxAttempts int

	// WorkerInterval bounds how often the async translation worker
	// scans for pending pairs. The worker handles one pair per tick.
	WorkerInterval time.Duration
}

// NotificationConfig configures the notification fan-out module
// implemented in `backend/internal/notification`. AdminEmail receives
// admin-channel kinds (the `runtime.*` set in `backend/README.md` §10);
// when empty, admin-email routes are recorded as `skipped`. WorkerInterval
// bounds how often the route worker scans for due rows; MaxAttempts caps
// route delivery retries before dead-lettering.
type NotificationConfig struct {
	AdminEmail string
	WorkerInterval time.Duration
	MaxAttempts int
}

// LobbyConfig configures the lobby module: the periodic sweeper interval,
// the lifetime of `pending_registration` Race Name Directory entries, and
// the default expiry applied to invites that omit `expires_at`.
type LobbyConfig struct {
	// SweeperInterval bounds how often the lobby sweeper goroutine wakes
	// up to release expired pending_registration rows and to auto-close
	// enrollment-expired games.
	SweeperInterval time.Duration

	// PendingRegistrationTTL bounds how long a `pending_registration`
	// Race Name Directory row stays available for promotion via
	// `lobby.race_name.register` before the sweeper releases it.
	PendingRegistrationTTL time.Duration

	// InviteDefaultTTL is the expiry applied to invites whose request body
	// omits an explicit `expires_at`.
	InviteDefaultTTL time.Duration
}

// DefaultConfig returns a Config pre-filled with the defaults documented in
// README §4. The required string fields (Postgres.DSN, SMTP.Host, SMTP.From,
// Docker.Network, Game.StateRoot, GeoIP.DBPath) remain zero-valued and must be
// supplied by callers (or by LoadFromEnv).
func DefaultConfig() Config {
	return Config{
		ShutdownTimeout: defaultShutdownTimeout,
		Logging: LoggingConfig{
			Level: defaultLoggingLevel,
		},
		HTTP: HTTPConfig{
			Addr: defaultHTTPListenAddr,
			ReadTimeout: defaultHTTPReadTimeout,
			WriteTimeout: defaultHTTPWriteTimeout,
			ShutdownTimeout: defaultHTTPShutdownTimeout,
		},
		GRPCPush: GRPCPushConfig{
			Addr: defaultGRPCPushListenAddr,
			ShutdownTimeout: defaultGRPCPushShutdownTimeout,
		},
		Postgres: PostgresConfig{
			MaxConns: defaultPostgresMaxConns,
			MinConns: defaultPostgresMinConns,
			OperationTimeout: defaultPostgresOperationTimeout,
		},
		SMTP: SMTPConfig{
			Port: defaultSMTPPort,
			TLSMode: defaultSMTPTLSMode,
		},
		Mail: MailConfig{
			WorkerInterval: defaultMailWorkerInterval,
			MaxAttempts: defaultMailMaxAttempts,
		},
		Docker: DockerConfig{
			Host: defaultDockerHost,
		},
		Telemetry: TelemetryConfig{
			ServiceName: defaultServiceName,
			TracesExporter: defaultOTelTracesExporter,
			MetricsExporter: defaultOTelMetricsExporter,
			Protocol: defaultOTelProtocol,
			PrometheusListenAddr: defaultOTelPrometheusListenAddr,
		},
		FreshnessWindow: defaultFreshnessWindow,
		Auth: AuthConfig{
			ChallengeTTL: defaultAuthChallengeTTL,
			ChallengeMaxAttempts: defaultAuthChallengeMaxAttempts,
			ChallengeThrottle: AuthChallengeThrottleConfig{
				Window: defaultAuthChallengeThrottleWindow,
				Max: defaultAuthChallengeThrottleMax,
			},
			UserNameMaxRetries: defaultAuthUserNameMaxRetries,
		},
		Lobby: LobbyConfig{
			SweeperInterval: defaultLobbySweeperInterval,
			PendingRegistrationTTL: defaultLobbyPendingRegistrationTTL,
			InviteDefaultTTL: defaultLobbyInviteDefaultTTL,
		},
		Engine: EngineConfig{
			CallTimeout: defaultEngineCallTimeout,
			ProbeTimeout: defaultEngineProbeTimeout,
		},
		Notification: NotificationConfig{
			WorkerInterval: defaultNotificationWorkerInterval,
			MaxAttempts: defaultNotificationMaxAttempts,
		},
		Diplomail: DiplomailConfig{
			MaxBodyBytes: defaultDiplomailMaxBodyBytes,
			MaxSubjectBytes: defaultDiplomailMaxSubjectBytes,
			TranslatorTimeout: defaultDiplomailTranslatorTimeout,
			TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
			WorkerInterval: defaultDiplomailWorkerInterval,
		},
		DevSandbox: DevSandboxConfig{
			EngineVersion: defaultDevSandboxEngineVersion,
			PlayerCount: defaultDevSandboxPlayerCount,
		},
		Runtime: RuntimeConfig{
			WorkerPoolSize: defaultRuntimeWorkerPoolSize,
			JobQueueSize: defaultRuntimeJobQueueSize,
			ReconcileInterval: defaultRuntimeReconcileInterval,
			ImagePullPolicy: defaultRuntimeImagePullPolicy,
			ContainerLogDriver: defaultRuntimeContainerLogDriver,
			ContainerCPUQuota: defaultRuntimeContainerCPUQuota,
			ContainerMemory: defaultRuntimeContainerMemory,
			ContainerPIDsLimit: defaultRuntimeContainerPIDsLimit,
			ContainerStateMount: defaultRuntimeContainerStateMount,
			StopGracePeriod: defaultRuntimeStopGracePeriod,
		},
	}
}

// LoadFromEnv loads Config from environment variables, applying the
// DefaultConfig values for any variable that is not set, and validates the
// result. The returned Config is safe to use without further modification.
func LoadFromEnv() (Config, error) {
	cfg := DefaultConfig()

	shutdownTimeout, err := loadDuration(envShutdownTimeout, cfg.ShutdownTimeout)
	if err != nil {
		return Config{}, err
	}
	cfg.ShutdownTimeout = shutdownTimeout

	cfg.Logging.Level = loadString(envLoggingLevel, cfg.Logging.Level)

	cfg.HTTP.Addr = loadString(envHTTPListenAddr, cfg.HTTP.Addr)
	if cfg.HTTP.ReadTimeout, err = loadDuration(envHTTPReadTimeout, cfg.HTTP.ReadTimeout); err != nil {
		return Config{}, err
	}
	if cfg.HTTP.WriteTimeout, err = loadDuration(envHTTPWriteTimeout, cfg.HTTP.WriteTimeout); err != nil {
		return Config{}, err
	}
	if cfg.HTTP.ShutdownTimeout, err = loadDuration(envHTTPShutdownTimeout, cfg.HTTP.ShutdownTimeout); err != nil {
		return Config{}, err
	}

	cfg.GRPCPush.Addr = loadString(envGRPCPushListenAddr, cfg.GRPCPush.Addr)
	if cfg.GRPCPush.ShutdownTimeout, err = loadDuration(envGRPCPushShutdownTimeout, cfg.GRPCPush.ShutdownTimeout); err != nil {
		return Config{}, err
	}

	cfg.Postgres.DSN = loadString(envPostgresDSN, cfg.Postgres.DSN)
	if cfg.Postgres.MaxConns, err = loadInt(envPostgresMaxConns, cfg.Postgres.MaxConns); err != nil {
		return Config{}, err
	}
	if cfg.Postgres.MinConns, err = loadInt(envPostgresMinConns, cfg.Postgres.MinConns); err != nil {
		return Config{}, err
	}
	if cfg.Postgres.OperationTimeout, err = loadDuration(envPostgresOperationTimeout, cfg.Postgres.OperationTimeout); err != nil {
		return Config{}, err
	}

	cfg.SMTP.Host = loadString(envSMTPHost, cfg.SMTP.Host)
	if cfg.SMTP.Port, err = loadInt(envSMTPPort, cfg.SMTP.Port); err != nil {
		return Config{}, err
	}
	cfg.SMTP.Username = loadString(envSMTPUsername, cfg.SMTP.Username)
	cfg.SMTP.Password = loadString(envSMTPPassword, cfg.SMTP.Password)
	cfg.SMTP.From = loadString(envSMTPFrom, cfg.SMTP.From)
	cfg.SMTP.TLSMode = loadString(envSMTPTLSMode, cfg.SMTP.TLSMode)

	if cfg.Mail.WorkerInterval, err = loadDuration(envMailWorkerInterval, cfg.Mail.WorkerInterval); err != nil {
		return Config{}, err
	}
	if cfg.Mail.MaxAttempts, err = loadInt(envMailMaxAttempts, cfg.Mail.MaxAttempts); err != nil {
		return Config{}, err
	}

	cfg.Docker.Host = loadString(envDockerHost, cfg.Docker.Host)
	cfg.Docker.Network = loadString(envDockerNetwork, cfg.Docker.Network)

	cfg.Game.StateRoot = loadString(envGameStateRoot, cfg.Game.StateRoot)

	cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
	cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)

	cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)

	cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
	cfg.Telemetry.MetricsExporter = strings.ToLower(loadString(envOTelMetricsExporter, cfg.Telemetry.MetricsExporter))
	cfg.Telemetry.Protocol = strings.ToLower(loadString(envOTelProtocol, cfg.Telemetry.Protocol))
	cfg.Telemetry.Endpoint = loadString(envOTelEndpoint, cfg.Telemetry.Endpoint)
	cfg.Telemetry.PrometheusListenAddr = loadString(envOTelPrometheusListenAddr, cfg.Telemetry.PrometheusListenAddr)
	cfg.Telemetry.ServiceName = loadString(envServiceName, cfg.Telemetry.ServiceName)

	if cfg.FreshnessWindow, err = loadDuration(envFreshnessWindow, cfg.FreshnessWindow); err != nil {
		return Config{}, err
	}

	if cfg.Auth.ChallengeTTL, err = loadDuration(envAuthChallengeTTL, cfg.Auth.ChallengeTTL); err != nil {
		return Config{}, err
	}
	if cfg.Auth.ChallengeMaxAttempts, err = loadInt(envAuthChallengeMaxAttempts, cfg.Auth.ChallengeMaxAttempts); err != nil {
		return Config{}, err
	}
	if cfg.Auth.ChallengeThrottle.Window, err = loadDuration(envAuthChallengeThrottleWindow, cfg.Auth.ChallengeThrottle.Window); err != nil {
		return Config{}, err
	}
	if cfg.Auth.ChallengeThrottle.Max, err = loadInt(envAuthChallengeThrottleMax, cfg.Auth.ChallengeThrottle.Max); err != nil {
		return Config{}, err
	}
	if cfg.Auth.UserNameMaxRetries, err = loadInt(envAuthUserNameMaxRetries, cfg.Auth.UserNameMaxRetries); err != nil {
		return Config{}, err
	}
	cfg.Auth.DevFixedCode = loadString(envAuthDevFixedCode, cfg.Auth.DevFixedCode)

	if cfg.Lobby.SweeperInterval, err = loadDuration(envLobbySweeperInterval, cfg.Lobby.SweeperInterval); err != nil {
		return Config{}, err
	}
	if cfg.Lobby.PendingRegistrationTTL, err = loadDuration(envLobbyPendingRegistrationTTL, cfg.Lobby.PendingRegistrationTTL); err != nil {
		return Config{}, err
	}
	if cfg.Lobby.InviteDefaultTTL, err = loadDuration(envLobbyInviteDefaultTTL, cfg.Lobby.InviteDefaultTTL); err != nil {
		return Config{}, err
	}

	if cfg.Engine.CallTimeout, err = loadDuration(envEngineCallTimeout, cfg.Engine.CallTimeout); err != nil {
		return Config{}, err
	}
	if cfg.Engine.ProbeTimeout, err = loadDuration(envEngineProbeTimeout, cfg.Engine.ProbeTimeout); err != nil {
		return Config{}, err
	}

	if cfg.Runtime.WorkerPoolSize, err = loadInt(envRuntimeWorkerPoolSize, cfg.Runtime.WorkerPoolSize); err != nil {
		return Config{}, err
	}
	if cfg.Runtime.JobQueueSize, err = loadInt(envRuntimeJobQueueSize, cfg.Runtime.JobQueueSize); err != nil {
		return Config{}, err
	}
	if cfg.Runtime.ReconcileInterval, err = loadDuration(envRuntimeReconcileInterval, cfg.Runtime.ReconcileInterval); err != nil {
		return Config{}, err
	}
	cfg.Runtime.ImagePullPolicy = strings.ToLower(loadString(envRuntimeImagePullPolicy, cfg.Runtime.ImagePullPolicy))
	cfg.Runtime.ContainerLogDriver = loadString(envRuntimeContainerLogDriver, cfg.Runtime.ContainerLogDriver)
	cfg.Runtime.ContainerLogOpts = loadString(envRuntimeContainerLogOpts, cfg.Runtime.ContainerLogOpts)
	if cfg.Runtime.ContainerCPUQuota, err = loadFloat(envRuntimeContainerCPUQuota, cfg.Runtime.ContainerCPUQuota); err != nil {
		return Config{}, err
	}
	cfg.Runtime.ContainerMemory = loadString(envRuntimeContainerMemory, cfg.Runtime.ContainerMemory)
	if cfg.Runtime.ContainerPIDsLimit, err = loadInt(envRuntimeContainerPIDsLimit, cfg.Runtime.ContainerPIDsLimit); err != nil {
		return Config{}, err
	}
	cfg.Runtime.ContainerStateMount = loadString(envRuntimeContainerStateMount, cfg.Runtime.ContainerStateMount)
	if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
		return Config{}, err
	}

	cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
	if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
		return Config{}, err
	}
	if cfg.Notification.MaxAttempts, err = loadInt(envNotificationMaxAttempts, cfg.Notification.MaxAttempts); err != nil {
		return Config{}, err
	}

	if cfg.Diplomail.MaxBodyBytes, err = loadInt(envDiplomailMaxBodyBytes, cfg.Diplomail.MaxBodyBytes); err != nil {
		return Config{}, err
	}
	if cfg.Diplomail.MaxSubjectBytes, err = loadInt(envDiplomailMaxSubjectBytes, cfg.Diplomail.MaxSubjectBytes); err != nil {
		return Config{}, err
	}
	cfg.Diplomail.TranslatorURL = loadString(envDiplomailTranslatorURL, cfg.Diplomail.TranslatorURL)
	if cfg.Diplomail.TranslatorTimeout, err = loadDuration(envDiplomailTranslatorTimeout, cfg.Diplomail.TranslatorTimeout); err != nil {
		return Config{}, err
	}
	if cfg.Diplomail.TranslatorMaxAttempts, err = loadInt(envDiplomailTranslatorMaxAttempts, cfg.Diplomail.TranslatorMaxAttempts); err != nil {
		return Config{}, err
	}
	if cfg.Diplomail.WorkerInterval, err = loadDuration(envDiplomailWorkerInterval, cfg.Diplomail.WorkerInterval); err != nil {
		return Config{}, err
	}

	cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
	cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
	cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
	if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
		return Config{}, err
	}

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

// Validate enforces the documented invariants from README §4. Required string
// fields must be non-empty; closed-set string options must match the allowed
// values; numeric and duration fields must be positive.
func (c Config) Validate() error {
	if c.ShutdownTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envShutdownTimeout)
	}
	if strings.TrimSpace(c.Logging.Level) == "" {
		return fmt.Errorf("%s must not be empty", envLoggingLevel)
	}

	if strings.TrimSpace(c.HTTP.Addr) == "" {
		return fmt.Errorf("%s must not be empty", envHTTPListenAddr)
	}
	if c.HTTP.ReadTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envHTTPReadTimeout)
	}
	if c.HTTP.WriteTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envHTTPWriteTimeout)
	}
	if c.HTTP.ShutdownTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envHTTPShutdownTimeout)
	}

	if strings.TrimSpace(c.GRPCPush.Addr) == "" {
		return fmt.Errorf("%s must not be empty", envGRPCPushListenAddr)
	}
	if c.GRPCPush.ShutdownTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envGRPCPushShutdownTimeout)
	}

	if strings.TrimSpace(c.Postgres.DSN) == "" {
		return fmt.Errorf("%s must be set", envPostgresDSN)
	}
	if c.Postgres.MaxConns <= 0 {
		return fmt.Errorf("%s must be positive", envPostgresMaxConns)
	}
	if c.Postgres.MinConns < 0 {
		return fmt.Errorf("%s must not be negative", envPostgresMinConns)
	}
	if c.Postgres.MinConns > c.Postgres.MaxConns {
		return fmt.Errorf("%s must not exceed %s", envPostgresMinConns, envPostgresMaxConns)
	}
	if c.Postgres.OperationTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envPostgresOperationTimeout)
	}

	if strings.TrimSpace(c.SMTP.Host) == "" {
		return fmt.Errorf("%s must be set", envSMTPHost)
	}
	if c.SMTP.Port <= 0 || c.SMTP.Port > 65535 {
		return fmt.Errorf("%s must be a valid TCP port (got %d)", envSMTPPort, c.SMTP.Port)
	}
	if strings.TrimSpace(c.SMTP.From) == "" {
		return fmt.Errorf("%s must be set", envSMTPFrom)
	}
	if !containsString(allowedSMTPTLSModes, c.SMTP.TLSMode) {
		return fmt.Errorf("%s must be one of %v (got %q)", envSMTPTLSMode, allowedSMTPTLSModes, c.SMTP.TLSMode)
	}

	if c.Mail.WorkerInterval <= 0 {
		return fmt.Errorf("%s must be positive", envMailWorkerInterval)
	}
	if c.Mail.MaxAttempts <= 0 {
		return fmt.Errorf("%s must be positive", envMailMaxAttempts)
	}

	if strings.TrimSpace(c.Docker.Host) == "" {
		return fmt.Errorf("%s must not be empty", envDockerHost)
	}
	if strings.TrimSpace(c.Docker.Network) == "" {
		return fmt.Errorf("%s must be set", envDockerNetwork)
	}

	if strings.TrimSpace(c.Game.StateRoot) == "" {
		return fmt.Errorf("%s must be set", envGameStateRoot)
	}

	if c.Admin.User != "" && c.Admin.Password == "" {
		return fmt.Errorf("%s requires %s", envAdminBootstrapUser, envAdminBootstrapPassword)
	}

	if strings.TrimSpace(c.GeoIP.DBPath) == "" {
		return fmt.Errorf("%s must be set", envGeoIPDBPath)
	}

	if !containsString(allowedTracesExporters, c.Telemetry.TracesExporter) {
		return fmt.Errorf("%s must be one of %v (got %q)", envOTelTracesExporter, allowedTracesExporters, c.Telemetry.TracesExporter)
	}
	if !containsString(allowedMetricsExporters, c.Telemetry.MetricsExporter) {
		return fmt.Errorf("%s must be one of %v (got %q)", envOTelMetricsExporter, allowedMetricsExporters, c.Telemetry.MetricsExporter)
	}
	if c.Telemetry.TracesExporter == "otlp" || c.Telemetry.MetricsExporter == "otlp" {
		if !containsString(allowedOTelProtocols, c.Telemetry.Protocol) {
			return fmt.Errorf("%s must be one of %v (got %q)", envOTelProtocol, allowedOTelProtocols, c.Telemetry.Protocol)
		}
	}
	if c.Telemetry.MetricsExporter == "prometheus" && strings.TrimSpace(c.Telemetry.PrometheusListenAddr) == "" {
		return fmt.Errorf("%s must be set when %s is %q", envOTelPrometheusListenAddr, envOTelMetricsExporter, "prometheus")
	}
	if strings.TrimSpace(c.Telemetry.ServiceName) == "" {
		return fmt.Errorf("%s must not be empty", envServiceName)
	}

	if c.FreshnessWindow <= 0 {
		return fmt.Errorf("%s must be positive", envFreshnessWindow)
	}

	if c.Auth.ChallengeTTL <= 0 {
		return fmt.Errorf("%s must be positive", envAuthChallengeTTL)
	}
	if c.Auth.ChallengeMaxAttempts <= 0 {
		return fmt.Errorf("%s must be positive", envAuthChallengeMaxAttempts)
	}
	if c.Auth.ChallengeThrottle.Window <= 0 {
		return fmt.Errorf("%s must be positive", envAuthChallengeThrottleWindow)
	}
	if c.Auth.ChallengeThrottle.Max <= 0 {
		return fmt.Errorf("%s must be positive", envAuthChallengeThrottleMax)
	}
	if c.Auth.UserNameMaxRetries <= 0 {
		return fmt.Errorf("%s must be positive", envAuthUserNameMaxRetries)
	}
	if c.Auth.DevFixedCode != "" {
		if !isDecimalString(c.Auth.DevFixedCode, 6) {
			return fmt.Errorf("%s must be a six-digit decimal string when set", envAuthDevFixedCode)
		}
	}

	if c.Lobby.SweeperInterval <= 0 {
		return fmt.Errorf("%s must be positive", envLobbySweeperInterval)
	}
	if c.Lobby.PendingRegistrationTTL <= 0 {
		return fmt.Errorf("%s must be positive", envLobbyPendingRegistrationTTL)
	}
	if c.Lobby.InviteDefaultTTL <= 0 {
		return fmt.Errorf("%s must be positive", envLobbyInviteDefaultTTL)
	}

	if c.Engine.CallTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envEngineCallTimeout)
	}
	if c.Engine.ProbeTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envEngineProbeTimeout)
	}

	if c.Runtime.WorkerPoolSize <= 0 {
		return fmt.Errorf("%s must be positive", envRuntimeWorkerPoolSize)
	}
	if c.Runtime.JobQueueSize <= 0 {
		return fmt.Errorf("%s must be positive", envRuntimeJobQueueSize)
	}
	if c.Runtime.ReconcileInterval <= 0 {
		return fmt.Errorf("%s must be positive", envRuntimeReconcileInterval)
	}
	if !containsString(allowedPullPolicies, c.Runtime.ImagePullPolicy) {
		return fmt.Errorf("%s must be one of %v (got %q)", envRuntimeImagePullPolicy, allowedPullPolicies, c.Runtime.ImagePullPolicy)
	}
	if strings.TrimSpace(c.Runtime.ContainerLogDriver) == "" {
		return fmt.Errorf("%s must not be empty", envRuntimeContainerLogDriver)
	}
	if c.Runtime.ContainerCPUQuota <= 0 {
		return fmt.Errorf("%s must be positive", envRuntimeContainerCPUQuota)
	}
	if strings.TrimSpace(c.Runtime.ContainerMemory) == "" {
		return fmt.Errorf("%s must not be empty", envRuntimeContainerMemory)
	}
	if c.Runtime.ContainerPIDsLimit <= 0 {
		return fmt.Errorf("%s must be positive", envRuntimeContainerPIDsLimit)
	}
	if !strings.HasPrefix(strings.TrimSpace(c.Runtime.ContainerStateMount), "/") {
		return fmt.Errorf("%s must be an absolute path (got %q)", envRuntimeContainerStateMount, c.Runtime.ContainerStateMount)
	}
	if c.Runtime.StopGracePeriod <= 0 {
		return fmt.Errorf("%s must be positive", envRuntimeStopGracePeriod)
	}

	if c.Notification.WorkerInterval <= 0 {
		return fmt.Errorf("%s must be positive", envNotificationWorkerInterval)
	}
	if c.Notification.MaxAttempts <= 0 {
		return fmt.Errorf("%s must be positive", envNotificationMaxAttempts)
	}

	if c.Diplomail.MaxBodyBytes <= 0 {
		return fmt.Errorf("%s must be positive", envDiplomailMaxBodyBytes)
	}
	if c.Diplomail.MaxSubjectBytes < 0 {
		return fmt.Errorf("%s must not be negative", envDiplomailMaxSubjectBytes)
	}
	if c.Diplomail.TranslatorTimeout <= 0 {
		return fmt.Errorf("%s must be positive", envDiplomailTranslatorTimeout)
	}
	if c.Diplomail.TranslatorMaxAttempts <= 0 {
		return fmt.Errorf("%s must be positive", envDiplomailTranslatorMaxAttempts)
	}
	if c.Diplomail.WorkerInterval <= 0 {
		return fmt.Errorf("%s must be positive", envDiplomailWorkerInterval)
	}
	if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" {
		if _, err := netmail.ParseAddress(email); err != nil {
			return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
		}
	}

	if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
		if _, err := netmail.ParseAddress(email); err != nil {
			return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
		}
		if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
			return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
		}
		if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
			return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
		}
		if c.DevSandbox.PlayerCount <= 0 {
			return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
		}
	}

	return nil
}

func isDecimalString(value string, length int) bool {
	if len(value) != length {
		return false
	}
	for _, r := range value {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

func loadString(name, fallback string) string {
	raw, ok := os.LookupEnv(name)
	if !ok {
		return fallback
	}
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return fallback
	}
	return trimmed
}

func loadInt(name string, fallback int) (int, error) {
	raw, ok := os.LookupEnv(name)
	if !ok {
		return fallback, nil
	}
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return fallback, nil
	}
	parsed, err := strconv.Atoi(trimmed)
	if err != nil {
		return 0, fmt.Errorf("%s: %w", name, err)
	}
	return parsed, nil
}

func loadFloat(name string, fallback float64) (float64, error) {
	raw, ok := os.LookupEnv(name)
	if !ok {
		return fallback, nil
	}
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return fallback, nil
	}
	parsed, err := strconv.ParseFloat(trimmed, 64)
	if err != nil {
		return 0, fmt.Errorf("%s: %w", name, err)
	}
	return parsed, nil
}

func loadDuration(name string, fallback time.Duration) (time.Duration, error) {
	raw, ok := os.LookupEnv(name)
	if !ok {
		return fallback, nil
	}
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return fallback, nil
	}
	parsed, err := time.ParseDuration(trimmed)
	if err != nil {
		return 0, fmt.Errorf("%s: %w", name, err)
	}
	return parsed, nil
}

func containsString(set []string, value string) bool {
	return slices.Contains(set, value)
}
