package config

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/pingcap/agent-git-service/internal/tenant"
)

// TenantConfig contains all identity, policy, and resource placement owned by
// one AGS tenant. Structured multitenant configuration must populate each
// tenant independently; values are not inherited from another tenant.
type TenantConfig struct {
	ID            string
	BaseURL       string
	DomainAliases []string
	Disabled      bool

	DBdsn               string
	GitRepoDir          string
	SecretEncryptionKey string
	AllowAnyToken       bool
	AdminLogin          string
	AdminToken          string

	Browser        TenantBrowserConfig
	Embedding      TenantEmbeddingConfig
	OIDC           TenantOIDCConfig
	ConnectedLogin TenantConnectedLoginConfig
	Workflow       TenantWorkflowConfig
	RepositorySync TenantRepositorySyncConfig
}

type TenantBrowserConfig struct {
	ConsoleBaseURL             string   `json:"console_base_url,omitempty" yaml:"console_base_url,omitempty"`
	OAuthDeviceVerificationURL string   `json:"oauth_device_verification_url,omitempty" yaml:"oauth_device_verification_url,omitempty"`
	OAuthLoopbackRedirects     bool     `json:"oauth_loopback_redirects,omitempty" yaml:"oauth_loopback_redirects,omitempty"`
	CORSAllowedOrigins         []string `json:"cors_allowed_origins,omitempty" yaml:"cors_allowed_origins,omitempty"`
}

type TenantEmbeddingConfig struct {
	APIKey     string
	BaseURL    string
	Model      string
	Dimensions int
}

type TenantOIDCConfig struct {
	Enabled           bool   `json:"enabled,omitempty"`
	Provider          string `json:"provider,omitempty"`
	Issuer            string `json:"issuer,omitempty"`
	DiscoveryURL      string `json:"discovery_url,omitempty"`
	ClientID          string
	ClientSecret      string
	Audience          string `json:"audience,omitempty"`
	Scopes            string `json:"scopes,omitempty"`
	AllowInsecureHTTP bool   `json:"allow_insecure_http,omitempty"`
}

type TenantConnectedLoginConfig struct {
	Enabled                   bool   `json:"enabled,omitempty"`
	Provider                  string `json:"provider,omitempty"`
	Origin                    string `json:"origin,omitempty"`
	APIOrigin                 string `json:"api_origin,omitempty"`
	ClientID                  string
	ClientSecret              string
	LoginPath                 string `json:"login_path,omitempty"`
	TokenPath                 string `json:"token_path,omitempty"`
	UserinfoPath              string `json:"userinfo_path,omitempty"`
	ReturnToParam             string `json:"return_to_param,omitempty"`
	SubjectClaim              string `json:"subject_claim,omitempty"`
	SubjectNamespaceClaim     string `json:"subject_namespace_claim,omitempty"`
	SubjectNamespaceSlugClaim string `json:"subject_namespace_slug_claim,omitempty"`
	ActorTypeClaim            string `json:"actor_type_claim,omitempty"`
	HumanTypeValue            string `json:"human_type_value,omitempty"`
	AgentTypeValue            string `json:"agent_type_value,omitempty"`
	ClientIDClaim             string `json:"client_id_claim,omitempty"`
	ClientNameClaim           string `json:"client_name_claim,omitempty"`
	PreferredUsernameClaim    string `json:"preferred_username_claim,omitempty"`
	NameClaim                 string `json:"name_claim,omitempty"`
	PictureClaim              string `json:"picture_claim,omitempty"`
	AvatarURLClaim            string `json:"avatar_url_claim,omitempty"`
	DescriptionClaim          string `json:"description_claim,omitempty"`
	ScopeClaim                string `json:"scope_claim,omitempty"`
}

type TenantWorkflowConfig struct {
	Enabled   bool
	Image     string
	Timeout   time.Duration
	CPUs      string
	Memory    string
	PidsLimit int
	NoFile    int
	TmpfsSize string
}

type TenantRepositorySyncConfig struct {
	Enabled            bool
	GitHubAPIURL       string
	GitHubWebURL       string
	GitHubAppSlug      string
	GitHubAppID        int64
	GitHubPrivateKey   string
	GitHubClientID     string
	GitHubClientSecret string
	PollInterval       time.Duration
	LeaseTTL           time.Duration
	FullAuditInterval  time.Duration
	MaxConcurrency     int
	MaxAssetBytes      int64
}

type rawTenantConfig struct {
	ID            string   `json:"id" yaml:"id"`
	BaseURL       string   `json:"base_url" yaml:"base_url"`
	DomainAliases []string `json:"domain_aliases,omitempty" yaml:"domain_aliases,omitempty"`
	Enabled       *bool    `json:"enabled,omitempty" yaml:"enabled,omitempty"`

	DBdsn                  *string `json:"db_dsn" yaml:"db_dsn"`
	DBdsnEnv               *string `json:"db_dsn_env" yaml:"-"`
	GitRepoDir             string  `json:"git_repo_dir" yaml:"git_repo_dir"`
	SecretEncryptionKey    *string `json:"secret_encryption_key" yaml:"secret_encryption_key"`
	SecretEncryptionKeyEnv *string `json:"secret_encryption_key_env" yaml:"-"`
	AllowAnyToken          bool    `json:"allow_any_token,omitempty" yaml:"allow_any_token,omitempty"`
	AdminLogin             string  `json:"admin_login,omitempty" yaml:"admin_login,omitempty"`
	AdminToken             *string `json:"admin_token" yaml:"admin_token,omitempty"`
	AdminTokenEnv          *string `json:"admin_token_env" yaml:"-"`

	Browser        TenantBrowserConfig           `json:"browser" yaml:"browser,omitempty"`
	Embedding      rawTenantEmbeddingConfig      `json:"embedding" yaml:"embedding,omitempty"`
	OIDC           rawTenantOIDCConfig           `json:"oidc" yaml:"oidc,omitempty"`
	ConnectedLogin rawTenantConnectedLoginConfig `json:"connected_login" yaml:"connected_login,omitempty"`
	Workflow       rawTenantWorkflowConfig       `json:"workflow" yaml:"workflow,omitempty"`
	RepositorySync rawTenantRepositorySyncConfig `json:"repository_sync" yaml:"repository_sync,omitempty"`
}

type rawTenantEmbeddingConfig struct {
	APIKey     *string `json:"api_key" yaml:"api_key,omitempty"`
	APIKeyEnv  *string `json:"api_key_env" yaml:"-"`
	BaseURL    string  `json:"base_url,omitempty" yaml:"base_url,omitempty"`
	Model      string  `json:"model,omitempty" yaml:"model,omitempty"`
	Dimensions int     `json:"dimensions,omitempty" yaml:"dimensions,omitempty"`
}

type rawTenantOIDCConfig struct {
	Enabled           bool    `json:"enabled,omitempty" yaml:"enabled,omitempty"`
	Provider          string  `json:"provider,omitempty" yaml:"provider,omitempty"`
	Issuer            string  `json:"issuer,omitempty" yaml:"issuer,omitempty"`
	DiscoveryURL      string  `json:"discovery_url,omitempty" yaml:"discovery_url,omitempty"`
	ClientID          *string `json:"client_id" yaml:"client_id,omitempty"`
	ClientIDEnv       *string `json:"client_id_env" yaml:"-"`
	ClientSecret      *string `json:"client_secret" yaml:"client_secret,omitempty"`
	ClientSecretEnv   *string `json:"client_secret_env" yaml:"-"`
	Audience          string  `json:"audience,omitempty" yaml:"audience,omitempty"`
	Scopes            string  `json:"scopes,omitempty" yaml:"scopes,omitempty"`
	AllowInsecureHTTP bool    `json:"allow_insecure_http,omitempty" yaml:"allow_insecure_http,omitempty"`
}

type rawTenantConnectedLoginConfig struct {
	Enabled                   bool    `json:"enabled,omitempty" yaml:"enabled,omitempty"`
	Provider                  string  `json:"provider,omitempty" yaml:"provider,omitempty"`
	Origin                    string  `json:"origin,omitempty" yaml:"origin,omitempty"`
	APIOrigin                 string  `json:"api_origin,omitempty" yaml:"api_origin,omitempty"`
	ClientID                  *string `json:"client_id" yaml:"client_id,omitempty"`
	ClientIDEnv               *string `json:"client_id_env" yaml:"-"`
	ClientSecret              *string `json:"client_secret" yaml:"client_secret,omitempty"`
	ClientSecretEnv           *string `json:"client_secret_env" yaml:"-"`
	LoginPath                 string  `json:"login_path,omitempty" yaml:"login_path,omitempty"`
	TokenPath                 string  `json:"token_path,omitempty" yaml:"token_path,omitempty"`
	UserinfoPath              string  `json:"userinfo_path,omitempty" yaml:"userinfo_path,omitempty"`
	ReturnToParam             string  `json:"return_to_param,omitempty" yaml:"return_to_param,omitempty"`
	SubjectClaim              string  `json:"subject_claim,omitempty" yaml:"subject_claim,omitempty"`
	SubjectNamespaceClaim     string  `json:"subject_namespace_claim,omitempty" yaml:"subject_namespace_claim,omitempty"`
	SubjectNamespaceSlugClaim string  `json:"subject_namespace_slug_claim,omitempty" yaml:"subject_namespace_slug_claim,omitempty"`
	ActorTypeClaim            string  `json:"actor_type_claim,omitempty" yaml:"actor_type_claim,omitempty"`
	HumanTypeValue            string  `json:"human_type_value,omitempty" yaml:"human_type_value,omitempty"`
	AgentTypeValue            string  `json:"agent_type_value,omitempty" yaml:"agent_type_value,omitempty"`
	ClientIDClaim             string  `json:"client_id_claim,omitempty" yaml:"client_id_claim,omitempty"`
	ClientNameClaim           string  `json:"client_name_claim,omitempty" yaml:"client_name_claim,omitempty"`
	PreferredUsernameClaim    string  `json:"preferred_username_claim,omitempty" yaml:"preferred_username_claim,omitempty"`
	NameClaim                 string  `json:"name_claim,omitempty" yaml:"name_claim,omitempty"`
	PictureClaim              string  `json:"picture_claim,omitempty" yaml:"picture_claim,omitempty"`
	AvatarURLClaim            string  `json:"avatar_url_claim,omitempty" yaml:"avatar_url_claim,omitempty"`
	DescriptionClaim          string  `json:"description_claim,omitempty" yaml:"description_claim,omitempty"`
	ScopeClaim                string  `json:"scope_claim,omitempty" yaml:"scope_claim,omitempty"`
}

type rawTenantWorkflowConfig struct {
	Enabled   bool   `json:"enabled,omitempty" yaml:"enabled,omitempty"`
	Image     string `json:"image,omitempty" yaml:"image,omitempty"`
	Timeout   string `json:"timeout,omitempty" yaml:"timeout,omitempty"`
	CPUs      string `json:"cpus,omitempty" yaml:"cpus,omitempty"`
	Memory    string `json:"memory,omitempty" yaml:"memory,omitempty"`
	PidsLimit int    `json:"pids_limit,omitempty" yaml:"pids_limit,omitempty"`
	NoFile    int    `json:"nofile,omitempty" yaml:"nofile,omitempty"`
	TmpfsSize string `json:"tmpfs_size,omitempty" yaml:"tmpfs_size,omitempty"`
}

type rawTenantRepositorySyncConfig struct {
	Enabled               bool    `json:"enabled,omitempty" yaml:"enabled,omitempty"`
	GitHubAPIURL          string  `json:"github_api_url,omitempty" yaml:"github_api_url,omitempty"`
	GitHubWebURL          string  `json:"github_web_url,omitempty" yaml:"github_web_url,omitempty"`
	GitHubAppSlug         string  `json:"github_app_slug,omitempty" yaml:"github_app_slug,omitempty"`
	GitHubAppID           int64   `json:"github_app_id,omitempty" yaml:"github_app_id,omitempty"`
	GitHubPrivateKey      *string `json:"github_private_key" yaml:"github_private_key,omitempty"`
	GitHubPrivateKeyEnv   *string `json:"github_private_key_env" yaml:"-"`
	GitHubClientID        string  `json:"github_client_id,omitempty" yaml:"github_client_id,omitempty"`
	GitHubClientSecret    *string `json:"github_client_secret" yaml:"github_client_secret,omitempty"`
	GitHubClientSecretEnv *string `json:"github_client_secret_env" yaml:"-"`
	PollInterval          string  `json:"poll_interval,omitempty" yaml:"poll_interval,omitempty"`
	LeaseTTL              string  `json:"lease_ttl,omitempty" yaml:"lease_ttl,omitempty"`
	FullAuditInterval     string  `json:"full_audit_interval,omitempty" yaml:"full_audit_interval,omitempty"`
	MaxConcurrency        int     `json:"max_concurrency,omitempty" yaml:"max_concurrency,omitempty"`
	MaxAssetBytes         int64   `json:"max_asset_bytes,omitempty" yaml:"max_asset_bytes,omitempty"`
}

func configuredLegacyTenantEnvironment() []string {
	var configured []string
	for _, name := range configuredLegacyTenantEnvironmentNames() {
		if value, ok := os.LookupEnv(name); ok && strings.TrimSpace(value) != "" {
			configured = append(configured, name)
		}
	}
	return configured
}

func configuredLegacyTenantEnvironmentNames() []string {
	return []string{
		"TENANT_ID",
		"BASE_URL",
		"TENANT_DOMAIN_ALIASES",
		"DB_DSN",
		"GIT_REPO_DIR",
		"SECRET_ENCRYPTION_KEY",
		"ALLOW_ANY_TOKEN",
		"ADMIN_LOGIN",
		"ADMIN_TOKEN",
		"CONSOLE_BASE_URL",
		"CORS_ALLOWED_ORIGINS",
		"OAUTH_DEVICE_VERIFICATION_URL",
		"EMBEDDING_API_KEY",
		"EMBEDDING_BASE_URL",
		"EMBEDDING_MODEL",
		"EMBEDDING_DIMENSIONS",
		"OIDC_PROVIDER",
		"OIDC_ISSUER",
		"OIDC_DISCOVERY_URL",
		"OIDC_CLIENT_ID",
		"OIDC_CLIENT_SECRET",
		"OIDC_AUDIENCE",
		"OIDC_SCOPES",
		"OIDC_ALLOW_INSECURE_HTTP",
		"CONNECTED_LOGIN_PROVIDER",
		"CONNECTED_LOGIN_ORIGIN",
		"CONNECTED_LOGIN_API_ORIGIN",
		"CONNECTED_LOGIN_CLIENT_ID",
		"CONNECTED_LOGIN_CLIENT_SECRET",
		"CONNECTED_LOGIN_LOGIN_PATH",
		"CONNECTED_LOGIN_TOKEN_PATH",
		"CONNECTED_LOGIN_USERINFO_PATH",
		"CONNECTED_LOGIN_RETURN_TO_PARAM",
		"CONNECTED_LOGIN_SUBJECT_CLAIM",
		"CONNECTED_LOGIN_SUBJECT_NAMESPACE_CLAIM",
		"CONNECTED_LOGIN_SUBJECT_NAMESPACE_SLUG_CLAIM",
		"CONNECTED_LOGIN_ACTOR_TYPE_CLAIM",
		"CONNECTED_LOGIN_HUMAN_TYPE_VALUE",
		"CONNECTED_LOGIN_AGENT_TYPE_VALUE",
		"CONNECTED_LOGIN_CLIENT_ID_CLAIM",
		"CONNECTED_LOGIN_CLIENT_NAME_CLAIM",
		"CONNECTED_LOGIN_PREFERRED_USERNAME_CLAIM",
		"CONNECTED_LOGIN_NAME_CLAIM",
		"CONNECTED_LOGIN_PICTURE_CLAIM",
		"CONNECTED_LOGIN_AVATAR_URL_CLAIM",
		"CONNECTED_LOGIN_DESCRIPTION_CLAIM",
		"CONNECTED_LOGIN_SCOPE_CLAIM",
		"ENABLE_WORKFLOW_EXEC",
		"WORKFLOW_EXEC_IMAGE",
		"WORKFLOW_EXEC_TIMEOUT",
		"WORKFLOW_EXEC_CPUS",
		"WORKFLOW_EXEC_MEMORY",
		"WORKFLOW_EXEC_PIDS_LIMIT",
		"WORKFLOW_EXEC_NOFILE",
		"WORKFLOW_EXEC_TMPFS_SIZE",
		"ENABLE_REPOSITORY_SYNC",
		"REPOSITORY_SYNC_GITHUB_API_URL",
		"REPOSITORY_SYNC_GITHUB_WEB_URL",
		"REPOSITORY_SYNC_GITHUB_APP_SLUG",
		"REPOSITORY_SYNC_GITHUB_APP_ID",
		"REPOSITORY_SYNC_GITHUB_APP_PRIVATE_KEY",
		"REPOSITORY_SYNC_GITHUB_CLIENT_ID",
		"REPOSITORY_SYNC_GITHUB_CLIENT_SECRET",
		"REPOSITORY_SYNC_POLL_INTERVAL",
		"REPOSITORY_SYNC_LEASE_TTL",
		"REPOSITORY_SYNC_FULL_AUDIT_INTERVAL",
		"REPOSITORY_SYNC_MAX_CONCURRENCY",
		"REPOSITORY_SYNC_MAX_ASSET_BYTES",
	}
}

func configuredLegacyTenantFields(cfg Config) []string {
	var fields []string
	addString := func(name, value string) {
		if strings.TrimSpace(value) != "" {
			fields = append(fields, name)
		}
	}
	addString("TenantID", cfg.TenantID)
	addString("BaseURL", cfg.BaseURL)
	addString("DBdsn", cfg.DBdsn)
	addString("GitRepoDir", cfg.GitRepoDir)
	addString("SecretEncryptionKey", cfg.SecretEncryptionKey)
	addString("AdminLogin", cfg.AdminLogin)
	addString("AdminToken", cfg.AdminToken)
	addString("ConsoleBaseURL", cfg.ConsoleBaseURL)
	addString("OAuthDeviceVerificationURL", cfg.OAuthDeviceVerificationURL)
	addString("EmbeddingAPIKey", cfg.EmbeddingAPIKey)
	addString("EmbeddingBaseURL", cfg.EmbeddingBaseURL)
	addString("EmbeddingModel", cfg.EmbeddingModel)
	addString("OIDCProvider", cfg.OIDCProvider)
	addString("OIDCIssuer", cfg.OIDCIssuer)
	addString("OIDCDiscoveryURL", cfg.OIDCDiscoveryURL)
	addString("OIDCClientID", cfg.OIDCClientID)
	addString("OIDCClientSecret", cfg.OIDCClientSecret)
	addString("OIDCAudience", cfg.OIDCAudience)
	addString("OIDCScopes", cfg.OIDCScopes)
	addString("ConnectedLoginProvider", cfg.ConnectedLoginProvider)
	addString("ConnectedLoginOrigin", cfg.ConnectedLoginOrigin)
	addString("ConnectedLoginAPIOrigin", cfg.ConnectedLoginAPIOrigin)
	addString("ConnectedLoginClientID", cfg.ConnectedLoginClientID)
	addString("ConnectedLoginClientSecret", cfg.ConnectedLoginClientSecret)
	addString("ConnectedLoginLoginPath", cfg.ConnectedLoginLoginPath)
	addString("ConnectedLoginTokenPath", cfg.ConnectedLoginTokenPath)
	addString("ConnectedLoginUserinfoPath", cfg.ConnectedLoginUserinfoPath)
	addString("ConnectedLoginReturnToParam", cfg.ConnectedLoginReturnToParam)
	addString("ConnectedLoginSubjectClaim", cfg.ConnectedLoginSubjectClaim)
	addString("ConnectedLoginSubjectNamespaceClaim", cfg.ConnectedLoginSubjectNamespaceClaim)
	addString("ConnectedLoginSubjectNamespaceSlugClaim", cfg.ConnectedLoginSubjectNamespaceSlugClaim)
	addString("ConnectedLoginActorTypeClaim", cfg.ConnectedLoginActorTypeClaim)
	addString("ConnectedLoginHumanTypeValue", cfg.ConnectedLoginHumanTypeValue)
	addString("ConnectedLoginAgentTypeValue", cfg.ConnectedLoginAgentTypeValue)
	addString("ConnectedLoginClientIDClaim", cfg.ConnectedLoginClientIDClaim)
	addString("ConnectedLoginClientNameClaim", cfg.ConnectedLoginClientNameClaim)
	addString("ConnectedLoginPreferredUsernameClaim", cfg.ConnectedLoginPreferredUsernameClaim)
	addString("ConnectedLoginNameClaim", cfg.ConnectedLoginNameClaim)
	addString("ConnectedLoginPictureClaim", cfg.ConnectedLoginPictureClaim)
	addString("ConnectedLoginAvatarURLClaim", cfg.ConnectedLoginAvatarURLClaim)
	addString("ConnectedLoginDescriptionClaim", cfg.ConnectedLoginDescriptionClaim)
	addString("ConnectedLoginScopeClaim", cfg.ConnectedLoginScopeClaim)
	addString("WorkflowExecImage", cfg.WorkflowExecImage)
	addString("WorkflowExecCPUs", cfg.WorkflowExecCPUs)
	addString("WorkflowExecMemory", cfg.WorkflowExecMemory)
	addString("WorkflowExecTmpfsSize", cfg.WorkflowExecTmpfsSize)
	addString("RepositorySyncGitHubAPIURL", cfg.RepositorySyncGitHubAPIURL)
	addString("RepositorySyncGitHubWebURL", cfg.RepositorySyncGitHubWebURL)
	addString("RepositorySyncGitHubAppSlug", cfg.RepositorySyncGitHubAppSlug)
	addString("RepositorySyncGitHubPrivateKey", cfg.RepositorySyncGitHubPrivateKey)
	addString("RepositorySyncGitHubClientID", cfg.RepositorySyncGitHubClientID)
	addString("RepositorySyncGitHubClientSecret", cfg.RepositorySyncGitHubClientSecret)
	if len(cfg.TenantDomainAliases) > 0 {
		fields = append(fields, "TenantDomainAliases")
	}
	if len(cfg.CORSAllowedOrigins) > 0 {
		fields = append(fields, "CORSAllowedOrigins")
	}
	if cfg.AllowAnyToken {
		fields = append(fields, "AllowAnyToken")
	}
	if cfg.EmbeddingDimensions != 0 {
		fields = append(fields, "EmbeddingDimensions")
	}
	if cfg.OIDCAllowInsecureHTTP {
		fields = append(fields, "OIDCAllowInsecureHTTP")
	}
	if cfg.EnableWorkflowExec {
		fields = append(fields, "EnableWorkflowExec")
	}
	if cfg.WorkflowExecTimeout != 0 {
		fields = append(fields, "WorkflowExecTimeout")
	}
	if cfg.WorkflowExecPidsLimit != 0 {
		fields = append(fields, "WorkflowExecPidsLimit")
	}
	if cfg.WorkflowExecNoFile != 0 {
		fields = append(fields, "WorkflowExecNoFile")
	}
	if cfg.EnableRepositorySync {
		fields = append(fields, "EnableRepositorySync")
	}
	if cfg.RepositorySyncGitHubAppID != 0 {
		fields = append(fields, "RepositorySyncGitHubAppID")
	}
	if cfg.RepositorySyncPollInterval != 0 {
		fields = append(fields, "RepositorySyncPollInterval")
	}
	if cfg.RepositorySyncLeaseTTL != 0 {
		fields = append(fields, "RepositorySyncLeaseTTL")
	}
	if cfg.RepositorySyncFullAuditInterval != 0 {
		fields = append(fields, "RepositorySyncFullAuditInterval")
	}
	if cfg.RepositorySyncMaxConcurrency != 0 {
		fields = append(fields, "RepositorySyncMaxConcurrency")
	}
	if cfg.RepositorySyncMaxAssetBytes != 0 {
		fields = append(fields, "RepositorySyncMaxAssetBytes")
	}
	return fields
}

func parseTenantConfigJSON(raw string) ([]TenantConfig, error) {
	decoder := json.NewDecoder(strings.NewReader(raw))
	decoder.DisallowUnknownFields()

	var values []rawTenantConfig
	if err := decoder.Decode(&values); err != nil {
		return nil, fmt.Errorf("invalid TENANT_CONFIG: %w", err)
	}
	if err := decoder.Decode(&struct{}{}); err != io.EOF {
		if err == nil {
			return nil, fmt.Errorf("invalid TENANT_CONFIG: multiple JSON values")
		}
		return nil, fmt.Errorf("invalid TENANT_CONFIG: %w", err)
	}
	if len(values) == 0 {
		return nil, fmt.Errorf("invalid TENANT_CONFIG: at least one tenant is required")
	}
	return tenantConfigsFromRaw(values, "TENANT_CONFIG")
}

func tenantConfigsFromRaw(values []rawTenantConfig, source string) ([]TenantConfig, error) {
	out := make([]TenantConfig, 0, len(values))
	for _, value := range values {
		disabled := value.Enabled != nil && !*value.Enabled
		if disabled {
			out = append(out, TenantConfig{
				ID:            value.ID,
				BaseURL:       value.BaseURL,
				DomainAliases: append([]string(nil), value.DomainAliases...),
				Disabled:      true,
			})
			continue
		}

		dbDSN, _, err := resolveTenantValue(source, value.ID, "db_dsn", value.DBdsn, value.DBdsnEnv)
		if err != nil {
			return nil, err
		}
		secretKey, _, err := resolveTenantValue(source, value.ID, "secret_encryption_key", value.SecretEncryptionKey, value.SecretEncryptionKeyEnv)
		if err != nil {
			return nil, err
		}
		adminToken, _, err := resolveTenantValue(source, value.ID, "admin_token", value.AdminToken, value.AdminTokenEnv)
		if err != nil {
			return nil, err
		}
		embeddingKey, _, err := resolveTenantValue(source, value.ID, "embedding.api_key", value.Embedding.APIKey, value.Embedding.APIKeyEnv)
		if err != nil {
			return nil, err
		}
		oidcClientID, _, err := resolveTenantValue(source, value.ID, "oidc.client_id", value.OIDC.ClientID, value.OIDC.ClientIDEnv)
		if err != nil {
			return nil, err
		}
		oidcClientSecret, _, err := resolveTenantValue(source, value.ID, "oidc.client_secret", value.OIDC.ClientSecret, value.OIDC.ClientSecretEnv)
		if err != nil {
			return nil, err
		}
		connectedClientID, _, err := resolveTenantValue(source, value.ID, "connected_login.client_id", value.ConnectedLogin.ClientID, value.ConnectedLogin.ClientIDEnv)
		if err != nil {
			return nil, err
		}
		connectedClientSecret, _, err := resolveTenantValue(source, value.ID, "connected_login.client_secret", value.ConnectedLogin.ClientSecret, value.ConnectedLogin.ClientSecretEnv)
		if err != nil {
			return nil, err
		}
		syncPrivateKey, _, err := resolveTenantValue(source, value.ID, "repository_sync.github_private_key", value.RepositorySync.GitHubPrivateKey, value.RepositorySync.GitHubPrivateKeyEnv)
		if err != nil {
			return nil, err
		}
		syncClientSecret, _, err := resolveTenantValue(source, value.ID, "repository_sync.github_client_secret", value.RepositorySync.GitHubClientSecret, value.RepositorySync.GitHubClientSecretEnv)
		if err != nil {
			return nil, err
		}

		workflowTimeout := time.Duration(0)
		if strings.TrimSpace(value.Workflow.Timeout) != "" {
			workflowTimeout, err = time.ParseDuration(strings.TrimSpace(value.Workflow.Timeout))
			if err != nil {
				return nil, fmt.Errorf("invalid %s for tenant %q: workflow.timeout must be a positive duration", source, value.ID)
			}
		}
		syncPollInterval, syncLeaseTTL, syncFullAuditInterval := time.Duration(0), time.Duration(0), time.Duration(0)
		if strings.TrimSpace(value.RepositorySync.PollInterval) != "" {
			syncPollInterval, err = time.ParseDuration(strings.TrimSpace(value.RepositorySync.PollInterval))
			if err != nil {
				return nil, fmt.Errorf("invalid %s for tenant %q: repository_sync.poll_interval", source, value.ID)
			}
		}
		if strings.TrimSpace(value.RepositorySync.LeaseTTL) != "" {
			syncLeaseTTL, err = time.ParseDuration(strings.TrimSpace(value.RepositorySync.LeaseTTL))
			if err != nil {
				return nil, fmt.Errorf("invalid %s for tenant %q: repository_sync.lease_ttl", source, value.ID)
			}
		}
		if strings.TrimSpace(value.RepositorySync.FullAuditInterval) != "" {
			syncFullAuditInterval, err = time.ParseDuration(strings.TrimSpace(value.RepositorySync.FullAuditInterval))
			if err != nil {
				return nil, fmt.Errorf("invalid %s for tenant %q: repository_sync.full_audit_interval", source, value.ID)
			}
		}

		out = append(out, TenantConfig{
			ID:                  value.ID,
			BaseURL:             value.BaseURL,
			DomainAliases:       append([]string(nil), value.DomainAliases...),
			DBdsn:               dbDSN,
			GitRepoDir:          value.GitRepoDir,
			SecretEncryptionKey: secretKey,
			AllowAnyToken:       value.AllowAnyToken,
			AdminLogin:          value.AdminLogin,
			AdminToken:          adminToken,
			Browser: TenantBrowserConfig{
				ConsoleBaseURL:             value.Browser.ConsoleBaseURL,
				OAuthDeviceVerificationURL: value.Browser.OAuthDeviceVerificationURL,
				OAuthLoopbackRedirects:     value.Browser.OAuthLoopbackRedirects,
				CORSAllowedOrigins:         append([]string(nil), value.Browser.CORSAllowedOrigins...),
			},
			Embedding: TenantEmbeddingConfig{
				APIKey:     embeddingKey,
				BaseURL:    value.Embedding.BaseURL,
				Model:      value.Embedding.Model,
				Dimensions: value.Embedding.Dimensions,
			},
			OIDC: TenantOIDCConfig{
				Enabled:           value.OIDC.Enabled,
				Provider:          value.OIDC.Provider,
				Issuer:            value.OIDC.Issuer,
				DiscoveryURL:      value.OIDC.DiscoveryURL,
				ClientID:          oidcClientID,
				ClientSecret:      oidcClientSecret,
				Audience:          value.OIDC.Audience,
				Scopes:            value.OIDC.Scopes,
				AllowInsecureHTTP: value.OIDC.AllowInsecureHTTP,
			},
			ConnectedLogin: TenantConnectedLoginConfig{
				Enabled:                   value.ConnectedLogin.Enabled,
				Provider:                  value.ConnectedLogin.Provider,
				Origin:                    value.ConnectedLogin.Origin,
				APIOrigin:                 value.ConnectedLogin.APIOrigin,
				ClientID:                  connectedClientID,
				ClientSecret:              connectedClientSecret,
				LoginPath:                 value.ConnectedLogin.LoginPath,
				TokenPath:                 value.ConnectedLogin.TokenPath,
				UserinfoPath:              value.ConnectedLogin.UserinfoPath,
				ReturnToParam:             value.ConnectedLogin.ReturnToParam,
				SubjectClaim:              value.ConnectedLogin.SubjectClaim,
				SubjectNamespaceClaim:     value.ConnectedLogin.SubjectNamespaceClaim,
				SubjectNamespaceSlugClaim: value.ConnectedLogin.SubjectNamespaceSlugClaim,
				ActorTypeClaim:            value.ConnectedLogin.ActorTypeClaim,
				HumanTypeValue:            value.ConnectedLogin.HumanTypeValue,
				AgentTypeValue:            value.ConnectedLogin.AgentTypeValue,
				ClientIDClaim:             value.ConnectedLogin.ClientIDClaim,
				ClientNameClaim:           value.ConnectedLogin.ClientNameClaim,
				PreferredUsernameClaim:    value.ConnectedLogin.PreferredUsernameClaim,
				NameClaim:                 value.ConnectedLogin.NameClaim,
				PictureClaim:              value.ConnectedLogin.PictureClaim,
				AvatarURLClaim:            value.ConnectedLogin.AvatarURLClaim,
				DescriptionClaim:          value.ConnectedLogin.DescriptionClaim,
				ScopeClaim:                value.ConnectedLogin.ScopeClaim,
			},
			Workflow: TenantWorkflowConfig{
				Enabled:   value.Workflow.Enabled,
				Image:     value.Workflow.Image,
				Timeout:   workflowTimeout,
				CPUs:      value.Workflow.CPUs,
				Memory:    value.Workflow.Memory,
				PidsLimit: value.Workflow.PidsLimit,
				NoFile:    value.Workflow.NoFile,
				TmpfsSize: value.Workflow.TmpfsSize,
			},
			RepositorySync: TenantRepositorySyncConfig{Enabled: value.RepositorySync.Enabled, GitHubAPIURL: value.RepositorySync.GitHubAPIURL, GitHubWebURL: value.RepositorySync.GitHubWebURL, GitHubAppSlug: value.RepositorySync.GitHubAppSlug, GitHubAppID: value.RepositorySync.GitHubAppID, GitHubPrivateKey: syncPrivateKey, GitHubClientID: value.RepositorySync.GitHubClientID, GitHubClientSecret: syncClientSecret, PollInterval: syncPollInterval, LeaseTTL: syncLeaseTTL, FullAuditInterval: syncFullAuditInterval, MaxConcurrency: value.RepositorySync.MaxConcurrency, MaxAssetBytes: value.RepositorySync.MaxAssetBytes},
		})
	}
	return out, nil
}

func resolveTenantValue(source, tenantID, field string, direct, envName *string) (string, bool, error) {
	if direct != nil && envName != nil {
		return "", false, fmt.Errorf("invalid %s for tenant %q: %s and %s_env cannot both be set", source, tenantID, field, field)
	}
	if envName != nil {
		name := strings.TrimSpace(*envName)
		if name == "" {
			return "", false, fmt.Errorf("invalid %s for tenant %q: %s_env must name an environment variable", source, tenantID, field)
		}
		value, ok := os.LookupEnv(name)
		if !ok || strings.TrimSpace(value) == "" {
			return "", false, fmt.Errorf("invalid %s for tenant %q: environment variable %s referenced by %s_env is not set", source, tenantID, name, field)
		}
		return strings.TrimSpace(value), true, nil
	}
	if direct != nil {
		return strings.TrimSpace(*direct), true, nil
	}
	return "", false, nil
}

func normalizeTenantConfigs(cfg Config, explicit bool) ([]TenantConfig, error) {
	tenants := append([]TenantConfig(nil), cfg.Tenants...)
	if !explicit {
		tenants = []TenantConfig{tenantConfigFromLegacy(cfg)}
	}

	seenIDs := make(map[string]struct{}, len(tenants))
	hostOwners := make(map[string]string)
	cookieHostOwners := make(map[string]string)
	dbOwners := make(map[string]string)
	keyOwners := make(map[string]string)
	adminTokenOwners := make(map[string]string)
	connectedClientOwners := make(map[string]string)
	connectedSecretOwners := make(map[string]string)
	oidcClientOwners := make(map[string]string)
	oidcSecretOwners := make(map[string]string)
	type rootOwner struct {
		tenantID string
		path     string
	}
	var roots []rootOwner
	enabledTenants := 0

	for index := range tenants {
		normalized, err := normalizeTenantConfig(tenants[index], cfg.Environment, len(tenants) > 1)
		if err != nil {
			return nil, err
		}
		tenants[index] = normalized
		if _, ok := seenIDs[normalized.ID]; ok {
			return nil, fmt.Errorf("duplicate tenant ID %q", normalized.ID)
		}
		seenIDs[normalized.ID] = struct{}{}

		for _, origin := range append([]string{normalized.BaseURL}, normalized.DomainAliases...) {
			host, err := normalizedOriginAuthority(origin)
			if err != nil {
				return nil, fmt.Errorf("tenant %q domain %q: %w", normalized.ID, origin, err)
			}
			if owner, ok := hostOwners[host]; ok && owner != normalized.ID {
				return nil, fmt.Errorf("tenant host %q is configured for both %q and %q", host, owner, normalized.ID)
			}
			hostOwners[host] = normalized.ID

			cookieHost, err := normalizedOriginHostname(origin)
			if err != nil {
				return nil, fmt.Errorf("tenant %q domain %q: %w", normalized.ID, origin, err)
			}
			if owner, ok := cookieHostOwners[cookieHost]; ok && owner != normalized.ID {
				return nil, fmt.Errorf(
					"tenant cookie host %q is configured for both %q and %q",
					cookieHost,
					owner,
					normalized.ID,
				)
			}
			cookieHostOwners[cookieHost] = normalized.ID
		}
		if normalized.Disabled {
			continue
		}
		enabledTenants++

		dbIdentity := strings.TrimSpace(normalized.DBdsn)
		if owner, ok := dbOwners[dbIdentity]; ok {
			return nil, fmt.Errorf("tenants %q and %q use the same DB DSN", owner, normalized.ID)
		}
		dbOwners[dbIdentity] = normalized.ID

		root, err := filepath.Abs(normalized.GitRepoDir)
		if err != nil {
			return nil, fmt.Errorf("tenant %q git root: %w", normalized.ID, err)
		}
		root = filepath.Clean(root)
		for _, existing := range roots {
			if tenantPathsOverlap(existing.path, root) {
				return nil, fmt.Errorf("tenant Git roots for %q and %q overlap", existing.tenantID, normalized.ID)
			}
		}
		roots = append(roots, rootOwner{tenantID: normalized.ID, path: root})

		if normalized.SecretEncryptionKey != "" {
			keyBytes, err := base64.StdEncoding.DecodeString(normalized.SecretEncryptionKey)
			if err != nil || len(keyBytes) != 32 {
				return nil, fmt.Errorf("tenant %q secret encryption key must be base64-encoded 32-byte material", normalized.ID)
			}
			keyIdentity := string(keyBytes)
			if owner, ok := keyOwners[keyIdentity]; ok {
				return nil, fmt.Errorf("tenants %q and %q share a secret encryption key", owner, normalized.ID)
			}
			keyOwners[keyIdentity] = normalized.ID
		}
		if normalized.AdminToken != "" {
			if owner, ok := adminTokenOwners[normalized.AdminToken]; ok {
				return nil, fmt.Errorf("tenants %q and %q share a development admin token", owner, normalized.ID)
			}
			adminTokenOwners[normalized.AdminToken] = normalized.ID
		}
		if normalized.ConnectedLogin.Enabled {
			if owner, ok := connectedClientOwners[normalized.ConnectedLogin.ClientID]; ok {
				return nil, fmt.Errorf("tenants %q and %q share a connected-login client ID", owner, normalized.ID)
			}
			connectedClientOwners[normalized.ConnectedLogin.ClientID] = normalized.ID
			if owner, ok := connectedSecretOwners[normalized.ConnectedLogin.ClientSecret]; ok {
				return nil, fmt.Errorf("tenants %q and %q share a connected-login client secret", owner, normalized.ID)
			}
			connectedSecretOwners[normalized.ConnectedLogin.ClientSecret] = normalized.ID
		}
		if normalized.OIDC.Enabled {
			if owner, ok := oidcClientOwners[normalized.OIDC.ClientID]; ok {
				return nil, fmt.Errorf("tenants %q and %q share an OIDC client ID", owner, normalized.ID)
			}
			oidcClientOwners[normalized.OIDC.ClientID] = normalized.ID
			if normalized.OIDC.ClientSecret != "" {
				if owner, ok := oidcSecretOwners[normalized.OIDC.ClientSecret]; ok {
					return nil, fmt.Errorf("tenants %q and %q share an OIDC client secret", owner, normalized.ID)
				}
				oidcSecretOwners[normalized.OIDC.ClientSecret] = normalized.ID
			}
		}
	}
	if enabledTenants == 0 {
		return nil, fmt.Errorf("at least one tenant must be enabled")
	}
	return tenants, nil
}

func normalizeTenantConfig(value TenantConfig, environment string, multitenant bool) (TenantConfig, error) {
	id, err := tenant.ParseID(value.ID)
	if err != nil {
		return TenantConfig{}, fmt.Errorf("invalid tenant ID %q: use 1-64 lowercase letters, numbers, dots, underscores, or hyphens", value.ID)
	}
	value.ID = string(id)

	baseURL, err := normalizeTenantOrigin(value.BaseURL)
	if err != nil {
		return TenantConfig{}, fmt.Errorf("tenant %q base URL: %w", value.ID, err)
	}
	value.BaseURL = baseURL
	value.DomainAliases, err = normalizeTenantOrigins(value.DomainAliases)
	if err != nil {
		return TenantConfig{}, fmt.Errorf("tenant %q domain aliases: %w", value.ID, err)
	}
	if value.Disabled {
		return TenantConfig{
			ID:            value.ID,
			BaseURL:       value.BaseURL,
			DomainAliases: append([]string(nil), value.DomainAliases...),
			Disabled:      true,
		}, nil
	}
	value.DBdsn = strings.TrimSpace(value.DBdsn)
	if value.DBdsn == "" {
		return TenantConfig{}, fmt.Errorf("tenant %q requires a database DSN", value.ID)
	}
	value.GitRepoDir = strings.TrimSpace(value.GitRepoDir)
	if value.GitRepoDir == "" {
		return TenantConfig{}, fmt.Errorf("tenant %q requires a Git repository root", value.ID)
	}
	value.SecretEncryptionKey = strings.TrimSpace(value.SecretEncryptionKey)
	if multitenant && value.SecretEncryptionKey == "" {
		return TenantConfig{}, fmt.Errorf("tenant %q requires a persistent secret encryption key in multitenant mode", value.ID)
	}
	value.AdminLogin = strings.TrimSpace(value.AdminLogin)
	value.AdminToken = strings.TrimSpace(value.AdminToken)
	if environment == "production" && value.AllowAnyToken {
		return TenantConfig{}, fmt.Errorf("tenant %q cannot enable allow_any_token in production", value.ID)
	}
	if environment == "development" && multitenant && value.AdminToken == "" {
		return TenantConfig{}, fmt.Errorf("tenant %q requires an admin token in development multitenant mode", value.ID)
	}

	if value.Browser.ConsoleBaseURL != "" {
		value.Browser.ConsoleBaseURL, err = normalizeTenantURLPrefix(value.Browser.ConsoleBaseURL)
		if err != nil {
			return TenantConfig{}, fmt.Errorf("tenant %q console base URL: %w", value.ID, err)
		}
	}
	if value.Browser.OAuthDeviceVerificationURL != "" {
		value.Browser.OAuthDeviceVerificationURL, err = normalizeTenantAbsoluteURL(value.Browser.OAuthDeviceVerificationURL)
		if err != nil {
			return TenantConfig{}, fmt.Errorf("tenant %q OAuth device verification URL: %w", value.ID, err)
		}
	}
	value.Browser.CORSAllowedOrigins, err = normalizeTenantOrigins(value.Browser.CORSAllowedOrigins)
	if err != nil {
		return TenantConfig{}, fmt.Errorf("tenant %q CORS origins: %w", value.ID, err)
	}

	value.Embedding.BaseURL = firstNonEmpty(strings.TrimSpace(value.Embedding.BaseURL), "https://api.openai.com")
	value.Embedding.Model = firstNonEmpty(strings.TrimSpace(value.Embedding.Model), "text-embedding-3-small")
	value.Embedding.APIKey = strings.TrimSpace(value.Embedding.APIKey)
	if value.Embedding.Dimensions < 0 {
		return TenantConfig{}, fmt.Errorf("tenant %q embedding dimensions must be non-negative", value.ID)
	}

	if err := normalizeTenantOIDC(&value.OIDC); err != nil {
		return TenantConfig{}, fmt.Errorf("tenant %q OIDC: %w", value.ID, err)
	}
	if err := normalizeTenantConnectedLogin(&value.ConnectedLogin); err != nil {
		return TenantConfig{}, fmt.Errorf("tenant %q connected login: %w", value.ID, err)
	}
	normalizeTenantWorkflow(&value.Workflow)
	if value.Workflow.Timeout <= 0 {
		return TenantConfig{}, fmt.Errorf("tenant %q workflow timeout must be positive", value.ID)
	}
	if value.Workflow.PidsLimit <= 0 || value.Workflow.NoFile <= 0 {
		return TenantConfig{}, fmt.Errorf("tenant %q workflow limits must be positive", value.ID)
	}
	if err := normalizeTenantRepositorySync(&value.RepositorySync); err != nil {
		return TenantConfig{}, fmt.Errorf("tenant %q repository sync: %w", value.ID, err)
	}
	return value, nil
}

func normalizeTenantRepositorySync(value *TenantRepositorySyncConfig) error {
	value.GitHubAPIURL = firstNonEmpty(strings.TrimRight(strings.TrimSpace(value.GitHubAPIURL), "/"), "https://api.github.com")
	value.GitHubPrivateKey = strings.TrimSpace(value.GitHubPrivateKey)
	if value.PollInterval == 0 {
		value.PollInterval = 5 * time.Second
	}
	if value.LeaseTTL == 0 {
		value.LeaseTTL = 2 * time.Minute
	}
	if value.FullAuditInterval == 0 {
		value.FullAuditInterval = 7 * 24 * time.Hour
	}
	if value.MaxConcurrency == 0 {
		value.MaxConcurrency = 8
	}
	if value.MaxAssetBytes == 0 {
		value.MaxAssetBytes = 2 << 30
	}
	if value.PollInterval <= 0 || value.LeaseTTL <= 0 || value.FullAuditInterval <= 0 || value.MaxConcurrency <= 0 || value.MaxAssetBytes <= 0 {
		return fmt.Errorf("poll, lease, concurrency, and asset limits must be positive")
	}
	if value.Enabled && (value.GitHubAppID <= 0 || value.GitHubPrivateKey == "") {
		return fmt.Errorf("enabled sync requires GitHub App ID and private key")
	}
	return nil
}

func normalizeTenantOIDC(value *TenantOIDCConfig) error {
	value.Provider = strings.TrimSpace(value.Provider)
	value.Issuer = strings.TrimSpace(value.Issuer)
	value.DiscoveryURL = strings.TrimSpace(value.DiscoveryURL)
	value.ClientID = strings.TrimSpace(value.ClientID)
	value.ClientSecret = strings.TrimSpace(value.ClientSecret)
	value.Audience = strings.TrimSpace(value.Audience)
	value.Scopes = firstNonEmpty(strings.TrimSpace(value.Scopes), "openid profile email")
	if !value.Enabled {
		return nil
	}
	if value.ClientID == "" || (value.Issuer == "" && value.DiscoveryURL == "") {
		return fmt.Errorf("enabled provider requires client ID and issuer or discovery URL")
	}
	if value.Provider == "" {
		value.Provider = defaultOIDCProvider(value.Issuer, value.DiscoveryURL)
	}
	return nil
}

func normalizeTenantConnectedLogin(value *TenantConnectedLoginConfig) error {
	value.Provider = strings.TrimSpace(value.Provider)
	value.Origin = strings.TrimSpace(value.Origin)
	value.APIOrigin = strings.TrimSpace(value.APIOrigin)
	value.ClientID = strings.TrimSpace(value.ClientID)
	value.ClientSecret = strings.TrimSpace(value.ClientSecret)
	if !value.Enabled {
		return nil
	}
	if value.Origin == "" || value.APIOrigin == "" || value.ClientID == "" || value.ClientSecret == "" {
		return fmt.Errorf("enabled provider requires origin, API origin, client ID, and client secret")
	}
	var err error
	if value.Origin, err = normalizeTenantOrigin(value.Origin); err != nil {
		return fmt.Errorf("origin: %w", err)
	}
	if value.APIOrigin, err = normalizeTenantOrigin(value.APIOrigin); err != nil {
		return fmt.Errorf("API origin: %w", err)
	}
	value.Provider = firstNonEmpty(value.Provider, "connected")
	value.LoginPath = firstNonEmpty(strings.TrimSpace(value.LoginPath), "/oauth/login")
	value.TokenPath = firstNonEmpty(strings.TrimSpace(value.TokenPath), "/api/oauth/token")
	value.UserinfoPath = firstNonEmpty(strings.TrimSpace(value.UserinfoPath), "/api/oauth/userinfo")
	value.ReturnToParam = firstNonEmpty(strings.TrimSpace(value.ReturnToParam), "return_to")
	value.SubjectClaim = firstNonEmpty(strings.TrimSpace(value.SubjectClaim), "sub")
	value.ActorTypeClaim = firstNonEmpty(strings.TrimSpace(value.ActorTypeClaim), "type")
	value.HumanTypeValue = firstNonEmpty(strings.TrimSpace(value.HumanTypeValue), "human")
	value.AgentTypeValue = firstNonEmpty(strings.TrimSpace(value.AgentTypeValue), "agent")
	value.ClientIDClaim = firstNonEmpty(strings.TrimSpace(value.ClientIDClaim), "client_id")
	value.ClientNameClaim = firstNonEmpty(strings.TrimSpace(value.ClientNameClaim), "client_name")
	value.PreferredUsernameClaim = firstNonEmpty(strings.TrimSpace(value.PreferredUsernameClaim), "preferred_username")
	value.NameClaim = firstNonEmpty(strings.TrimSpace(value.NameClaim), "name")
	value.PictureClaim = firstNonEmpty(strings.TrimSpace(value.PictureClaim), "picture")
	value.AvatarURLClaim = firstNonEmpty(strings.TrimSpace(value.AvatarURLClaim), "avatar_url")
	value.DescriptionClaim = firstNonEmpty(strings.TrimSpace(value.DescriptionClaim), "description")
	value.ScopeClaim = firstNonEmpty(strings.TrimSpace(value.ScopeClaim), "scope")
	return nil
}

func normalizeTenantWorkflow(value *TenantWorkflowConfig) {
	value.Image = firstNonEmpty(strings.TrimSpace(value.Image), "bash:5.2")
	if value.Timeout == 0 {
		value.Timeout = 2 * time.Minute
	}
	value.CPUs = firstNonEmpty(strings.TrimSpace(value.CPUs), "1.0")
	value.Memory = firstNonEmpty(strings.TrimSpace(value.Memory), "256m")
	if value.PidsLimit == 0 {
		value.PidsLimit = 128
	}
	if value.NoFile == 0 {
		value.NoFile = 1024
	}
	value.TmpfsSize = firstNonEmpty(strings.TrimSpace(value.TmpfsSize), "64m")
}

func tenantConfigFromLegacy(cfg Config) TenantConfig {
	return TenantConfig{
		ID:                  strings.TrimSpace(cfg.TenantID),
		BaseURL:             cfg.BaseURL,
		DomainAliases:       append([]string(nil), cfg.TenantDomainAliases...),
		DBdsn:               cfg.DBdsn,
		GitRepoDir:          cfg.GitRepoDir,
		SecretEncryptionKey: cfg.SecretEncryptionKey,
		AllowAnyToken:       cfg.AllowAnyToken,
		AdminLogin:          cfg.AdminLogin,
		AdminToken:          cfg.AdminToken,
		Browser: TenantBrowserConfig{
			ConsoleBaseURL:             cfg.ConsoleBaseURL,
			OAuthDeviceVerificationURL: cfg.OAuthDeviceVerificationURL,
			OAuthLoopbackRedirects:     true,
			CORSAllowedOrigins:         append([]string(nil), cfg.CORSAllowedOrigins...),
		},
		Embedding: TenantEmbeddingConfig{
			APIKey:     cfg.EmbeddingAPIKey,
			BaseURL:    cfg.EmbeddingBaseURL,
			Model:      cfg.EmbeddingModel,
			Dimensions: cfg.EmbeddingDimensions,
		},
		OIDC: TenantOIDCConfig{
			Enabled:           cfg.OIDCEnabled(),
			Provider:          cfg.OIDCProvider,
			Issuer:            cfg.OIDCIssuer,
			DiscoveryURL:      cfg.OIDCDiscoveryURL,
			ClientID:          cfg.OIDCClientID,
			ClientSecret:      cfg.OIDCClientSecret,
			Audience:          cfg.OIDCAudience,
			Scopes:            cfg.OIDCScopes,
			AllowInsecureHTTP: cfg.OIDCAllowInsecureHTTP,
		},
		ConnectedLogin: TenantConnectedLoginConfig{
			Enabled:                   cfg.ConnectedLoginEnabled(),
			Provider:                  cfg.ConnectedLoginProvider,
			Origin:                    cfg.ConnectedLoginOrigin,
			APIOrigin:                 cfg.ConnectedLoginAPIOrigin,
			ClientID:                  cfg.ConnectedLoginClientID,
			ClientSecret:              cfg.ConnectedLoginClientSecret,
			LoginPath:                 cfg.ConnectedLoginLoginPath,
			TokenPath:                 cfg.ConnectedLoginTokenPath,
			UserinfoPath:              cfg.ConnectedLoginUserinfoPath,
			ReturnToParam:             cfg.ConnectedLoginReturnToParam,
			SubjectClaim:              cfg.ConnectedLoginSubjectClaim,
			SubjectNamespaceClaim:     cfg.ConnectedLoginSubjectNamespaceClaim,
			SubjectNamespaceSlugClaim: cfg.ConnectedLoginSubjectNamespaceSlugClaim,
			ActorTypeClaim:            cfg.ConnectedLoginActorTypeClaim,
			HumanTypeValue:            cfg.ConnectedLoginHumanTypeValue,
			AgentTypeValue:            cfg.ConnectedLoginAgentTypeValue,
			ClientIDClaim:             cfg.ConnectedLoginClientIDClaim,
			ClientNameClaim:           cfg.ConnectedLoginClientNameClaim,
			PreferredUsernameClaim:    cfg.ConnectedLoginPreferredUsernameClaim,
			NameClaim:                 cfg.ConnectedLoginNameClaim,
			PictureClaim:              cfg.ConnectedLoginPictureClaim,
			AvatarURLClaim:            cfg.ConnectedLoginAvatarURLClaim,
			DescriptionClaim:          cfg.ConnectedLoginDescriptionClaim,
			ScopeClaim:                cfg.ConnectedLoginScopeClaim,
		},
		Workflow: TenantWorkflowConfig{
			Enabled:   cfg.EnableWorkflowExec,
			Image:     cfg.WorkflowExecImage,
			Timeout:   cfg.WorkflowExecTimeout,
			CPUs:      cfg.WorkflowExecCPUs,
			Memory:    cfg.WorkflowExecMemory,
			PidsLimit: cfg.WorkflowExecPidsLimit,
			NoFile:    cfg.WorkflowExecNoFile,
			TmpfsSize: cfg.WorkflowExecTmpfsSize,
		},
		RepositorySync: TenantRepositorySyncConfig{Enabled: cfg.EnableRepositorySync, GitHubAPIURL: cfg.RepositorySyncGitHubAPIURL, GitHubWebURL: cfg.RepositorySyncGitHubWebURL, GitHubAppSlug: cfg.RepositorySyncGitHubAppSlug, GitHubAppID: cfg.RepositorySyncGitHubAppID, GitHubPrivateKey: cfg.RepositorySyncGitHubPrivateKey, GitHubClientID: cfg.RepositorySyncGitHubClientID, GitHubClientSecret: cfg.RepositorySyncGitHubClientSecret, PollInterval: cfg.RepositorySyncPollInterval, LeaseTTL: cfg.RepositorySyncLeaseTTL, FullAuditInterval: cfg.RepositorySyncFullAuditInterval, MaxConcurrency: cfg.RepositorySyncMaxConcurrency, MaxAssetBytes: cfg.RepositorySyncMaxAssetBytes},
	}
}

func (c Config) OIDCEnabled() bool {
	return strings.TrimSpace(c.OIDCClientID) != "" &&
		(strings.TrimSpace(c.OIDCIssuer) != "" || strings.TrimSpace(c.OIDCDiscoveryURL) != "")
}

func normalizeTenantOrigin(raw string) (string, error) {
	trimmed := strings.TrimRight(strings.TrimSpace(raw), "/")
	parsed, err := url.Parse(trimmed)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
		return "", fmt.Errorf("must be an absolute HTTP(S) origin")
	}
	if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
		return "", fmt.Errorf("must use HTTP or HTTPS")
	}
	if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
		return "", fmt.Errorf("must not include a path, query, or fragment")
	}
	parsed.Scheme = strings.ToLower(parsed.Scheme)
	parsed.Host = strings.ToLower(parsed.Host)
	parsed.Path = ""
	return parsed.String(), nil
}

func normalizeTenantOrigins(values []string) ([]string, error) {
	seen := make(map[string]struct{})
	out := make([]string, 0, len(values))
	for _, raw := range values {
		origin, err := normalizeTenantOrigin(raw)
		if err != nil {
			return nil, err
		}
		if _, ok := seen[origin]; ok {
			continue
		}
		seen[origin] = struct{}{}
		out = append(out, origin)
	}
	return out, nil
}

func normalizeTenantURLPrefix(raw string) (string, error) {
	trimmed := strings.TrimRight(strings.TrimSpace(raw), "/")
	parsed, err := url.Parse(trimmed)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
		return "", fmt.Errorf("must be an absolute HTTP(S) URL")
	}
	if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
		return "", fmt.Errorf("must use HTTP or HTTPS")
	}
	if parsed.RawQuery != "" || parsed.Fragment != "" {
		return "", fmt.Errorf("must not include a query or fragment")
	}
	parsed.Scheme = strings.ToLower(parsed.Scheme)
	parsed.Host = strings.ToLower(parsed.Host)
	return strings.TrimRight(parsed.String(), "/"), nil
}

func normalizeTenantAbsoluteURL(raw string) (string, error) {
	parsed, err := url.Parse(strings.TrimSpace(raw))
	if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
		return "", fmt.Errorf("must be an absolute HTTP(S) URL")
	}
	if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
		return "", fmt.Errorf("must use HTTP or HTTPS")
	}
	parsed.Scheme = strings.ToLower(parsed.Scheme)
	parsed.Host = strings.ToLower(parsed.Host)
	return parsed.String(), nil
}

func normalizedOriginAuthority(origin string) (string, error) {
	parsed, err := url.Parse(origin)
	if err != nil || parsed.Host == "" {
		return "", fmt.Errorf("invalid origin")
	}
	host := strings.ToLower(parsed.Hostname())
	port := parsed.Port()
	if (strings.EqualFold(parsed.Scheme, "https") && port == "443") ||
		(strings.EqualFold(parsed.Scheme, "http") && port == "80") {
		port = ""
	}
	if port != "" {
		return net.JoinHostPort(host, port), nil
	}
	return host, nil
}

func normalizedOriginHostname(origin string) (string, error) {
	parsed, err := url.Parse(origin)
	if err != nil || parsed.Host == "" {
		return "", fmt.Errorf("invalid origin")
	}
	host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
	if host == "" {
		return "", fmt.Errorf("invalid origin")
	}
	return host, nil
}

func tenantPathsOverlap(left, right string) bool {
	if left == right {
		return true
	}
	leftToRight, err := filepath.Rel(left, right)
	if err == nil && leftToRight != ".." && !strings.HasPrefix(leftToRight, ".."+string(filepath.Separator)) {
		return true
	}
	rightToLeft, err := filepath.Rel(right, left)
	return err == nil && rightToLeft != ".." && !strings.HasPrefix(rightToLeft, ".."+string(filepath.Separator))
}

func splitTenantCSV(raw string) []string {
	var out []string
	for _, value := range strings.Split(raw, ",") {
		if value = strings.TrimSpace(value); value != "" {
			out = append(out, value)
		}
	}
	return out
}

func parsePositiveInt(raw, name string) (int, error) {
	if strings.TrimSpace(raw) == "" {
		return 0, nil
	}
	value, err := strconv.Atoi(strings.TrimSpace(raw))
	if err != nil || value <= 0 {
		return 0, fmt.Errorf("%s must be a positive integer", name)
	}
	return value, nil
}
