// Package config provides configuration loading and management for the registry server.
package config

import (
	"errors"
	"fmt"
	"log/slog"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgpassfile"
	"github.com/spf13/viper"
	"github.com/stacklok/toolhive-core/postgres"

	"github.com/stacklok/toolhive-registry-server/internal/db"
	"github.com/stacklok/toolhive-registry-server/internal/telemetry"
)

// SourceType represents the type of registry data source
type SourceType string

const (
	// SourceTypeGit is the type for registry data stored in Git repositories
	SourceTypeGit SourceType = "git"

	// SourceTypeAPI is the type for registry data fetched from API endpoints
	SourceTypeAPI SourceType = "api"

	// SourceTypeFile is the type for registry data stored in local files
	SourceTypeFile SourceType = "file"

	// SourceTypeManaged is the type for registries directly managed via API
	// Managed registries do not sync from external sources
	SourceTypeManaged SourceType = "managed"

	// SourceTypeKubernetes is the type for registries that query Kubernetes deployments
	// Kubernetes registries discover MCP servers from running Kubernetes resources
	SourceTypeKubernetes SourceType = "kubernetes"
)

const (
	// EnvPrefix is the prefix used for environment variables that override config values.
	// For example, THV_REGISTRY_REGISTRYNAME overrides registryName in the config file.
	EnvPrefix = "THV_REGISTRY"

	// DefaultMaxMetaSize is the default maximum allowed size in bytes for
	// publisher-provided metadata extensions (_meta). 262144 bytes = 256KB.
	DefaultMaxMetaSize = 262144
)

// Option defines the interface for configuration options
type Option func(*loaderConfig) error

// loaderConfig defines the configuration for loading a configuration
type loaderConfig struct {
	path string
}

// WithConfigPath loads configuration from a YAML file
func WithConfigPath(path string) Option {
	return func(cfg *loaderConfig) error {
		if path == "" {
			return fmt.Errorf("path is required")
		}

		// Resolve symlinks to prevent symlink attacks.
		// Note that this calls filepath.Clean internally.
		realPath, err := filepath.EvalSymlinks(path)
		if err != nil {
			return fmt.Errorf("failed to evaluate symlinks: %w", err)
		}

		// Validate the path to prevent path traversal attacks
		if !filepath.IsAbs(realPath) {
			if !filepath.IsLocal(realPath) {
				return fmt.Errorf("path is not local or contains invalid traversal: %s", path)
			}
		}

		cfg.path = realPath
		return nil
	}
}

// Default audit configuration values.
const (
	// DefaultAuditMaxDataSize is the default maximum size (in bytes) for
	// captured request bodies in audit events.
	DefaultAuditMaxDataSize = 1024

	// MaxAuditDataSize is the hard upper bound (1 MB) for captured request
	// bodies to prevent memory exhaustion from misconfiguration.
	MaxAuditDataSize = 1 << 20
)

// AuditConfig defines audit logging configuration.
type AuditConfig struct {
	// Enabled controls whether audit logging is active.
	// When false (the default), API operations are not audit-logged.
	Enabled bool `yaml:"enabled"`

	// LogFile is the path to a dedicated audit log file.
	// When empty (default), audit events are written to stdout.
	LogFile string `yaml:"logFile,omitempty"`

	// EventTypes is a whitelist of event types to audit.
	// When empty (default), all event types are audited.
	EventTypes []string `yaml:"eventTypes,omitempty"`

	// ExcludeEventTypes is a blacklist of event types to skip.
	// Takes precedence over EventTypes if both are set.
	ExcludeEventTypes []string `yaml:"excludeEventTypes,omitempty"`

	// IncludeRequestData controls whether request bodies are captured
	// in audit events. Disabled by default for privacy.
	IncludeRequestData bool `yaml:"includeRequestData,omitempty"`

	// MaxDataSize is the maximum size (in bytes) for captured
	// request/response bodies. Defaults to 1024.
	MaxDataSize int `yaml:"maxDataSize,omitempty"`
}

// GetMaxDataSize returns the configured max data size or the default.
func (a *AuditConfig) GetMaxDataSize() int {
	if a == nil || a.MaxDataSize <= 0 {
		return DefaultAuditMaxDataSize
	}
	return a.MaxDataSize
}

// Config represents the root configuration structure
type Config struct {
	Sources    []SourceConfig    `yaml:"sources"`
	Registries []RegistryConfig  `yaml:"registries,omitempty"`
	Database   *DatabaseConfig   `yaml:"database,omitempty"`
	Auth       *AuthConfig       `yaml:"auth,omitempty"`
	Telemetry  *telemetry.Config `yaml:"telemetry,omitempty"`
	Audit      *AuditConfig      `yaml:"audit,omitempty"`

	// insecureAllowHTTP allows HTTP URLs for OAuth issuer URLs (development only)
	// Can be set via THV_REGISTRY_INSECURE_URL environment variable
	// Not loaded from YAML file - environment variable only
	insecureAllowHTTP bool

	// compressResponse enables gzip compression of HTTP responses.
	// Can be set via THV_REGISTRY_COMPRESS_RESPONSE environment variable
	// Not loaded from YAML file - environment variable only
	compressResponse bool

	// WatchNamespace is the namespace to watch for MCP servers.
	// Can be set via THV_REGISTRY_WATCH_NAMESPACE environment variable
	// Not loaded from YAML file - environment variable only
	WatchNamespace string

	// LeaderElectionID is the unique identifier for the leader election lease.
	// Can be set via THV_REGISTRY_LEADER_ELECTION_ID environment variable
	// Not loaded from YAML file - environment variable only
	LeaderElectionID string
}

// SourceConfig defines a single data source configuration
type SourceConfig struct {
	// Name is the identifier for this source
	Name string `yaml:"name"`

	// Type-specific configurations (only one should be set)
	Git        *GitConfig        `yaml:"git,omitempty"`
	API        *APIConfig        `yaml:"api,omitempty"`
	File       *FileConfig       `yaml:"file,omitempty"`
	Managed    *ManagedConfig    `yaml:"managed,omitempty"`
	Kubernetes *KubernetesConfig `yaml:"kubernetes,omitempty"`

	// Per-source sync policy
	// Note: Not applicable for non-synced sources (managed and kubernetes) - will be ignored if set
	SyncPolicy *SyncPolicyConfig `yaml:"syncPolicy,omitempty"`

	// Per-source filtering rules
	// Note: Not applicable for non-synced sources (managed and kubernetes) - will be ignored if set
	Filter *FilterConfig `yaml:"filter,omitempty"`

	// Claims are key-value pairs attached to this source for authorization purposes
	// Values must be string or []string
	Claims map[string]any `yaml:"claims,omitempty"`
}

// RegistryConfig defines a lightweight registry view that aggregates sources
type RegistryConfig struct {
	// Name is the identifier for this registry
	Name string `yaml:"name"`

	// Claims are key-value pairs attached to this registry for authorization purposes
	// Values must be string or []string
	Claims map[string]any `yaml:"claims,omitempty"`

	// Sources is an ordered list of source names that feed this registry
	Sources []string `yaml:"sources"`
}

// GitConfig defines Git source settings
type GitConfig struct {
	// Repository is the Git repository URL (HTTP/HTTPS/SSH)
	Repository string `yaml:"repository" json:"repository"`

	// Branch is the Git branch to use (mutually exclusive with Tag and Commit)
	Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`

	// Tag is the Git tag to use (mutually exclusive with Branch and Commit)
	Tag string `yaml:"tag,omitempty" json:"tag,omitempty"`

	// Commit is the Git commit SHA to use (mutually exclusive with Branch and Tag)
	Commit string `yaml:"commit,omitempty" json:"commit,omitempty"`

	// Path is the path to the registry file within the repository
	Path string `yaml:"path,omitempty" json:"path,omitempty"`

	// Auth contains optional authentication for private repositories
	Auth *GitAuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"`
}

// GitAuthConfig defines authentication settings for Git repositories
type GitAuthConfig struct {
	// Username is the Git username for HTTP Basic authentication
	Username string `yaml:"username,omitempty" json:"username,omitempty"`

	// PasswordFile is the path to a file containing the Git password/token
	// Must be an absolute path; whitespace is trimmed from the content
	PasswordFile string `yaml:"passwordFile,omitempty" json:"passwordFile,omitempty"`
}

// GetPassword reads the password from PasswordFile using the secure file reader.
// Returns empty string if the receiver is nil or PasswordFile is empty.
// Returns an error if the file cannot be read.
func (a *GitAuthConfig) GetPassword() (string, error) {
	if a == nil {
		return "", nil
	}
	password, err := readSecretFromFile(a.PasswordFile)
	if err != nil {
		return "", fmt.Errorf("failed to read git password: %w", err)
	}
	return password, nil
}

// Validate validates the GitAuthConfig.
// It checks that both username and passwordFile are specified together,
// that passwordFile is an absolute path, and that the file exists and is readable.
func (a *GitAuthConfig) Validate() error {
	if a == nil {
		return nil
	}

	hasUsername := a.Username != ""
	hasPasswordFile := a.PasswordFile != ""

	// Both must be set together, or neither
	if hasUsername != hasPasswordFile {
		return fmt.Errorf("git.auth.username and git.auth.passwordFile must both be specified")
	}

	if hasPasswordFile {
		// Must be absolute path
		if !filepath.IsAbs(a.PasswordFile) {
			return fmt.Errorf("git.auth.passwordFile must be an absolute path")
		}

		// Verify the file exists and is readable
		if _, err := os.Stat(a.PasswordFile); err != nil {
			return fmt.Errorf("git.auth.passwordFile is not accessible: %w", err)
		}
	}

	return nil
}

// APIConfig defines API source configuration for upstream MCP Registry APIs
type APIConfig struct {
	// Endpoint is the base API URL (without path)
	// The registry handler will append the appropriate paths for the MCP Registry API v0.1:
	//   - /v0.1/servers - List all servers
	//   - /v0.1/servers/{name}/versions - List server versions
	//   - /v0.1/servers/{name}/versions/{version} - Get specific version
	// Example: "http://my-registry-api.default.svc.cluster.local/registry"
	Endpoint string `yaml:"endpoint" json:"endpoint"`
}

// FileConfig defines file source configuration
// Supports local files, URL-hosted files, or inline data
type FileConfig struct {
	// Path is the path to the registry.json file on the local filesystem
	// Can be absolute or relative to the working directory
	// Mutually exclusive with URL and Data - exactly one must be specified
	Path string `yaml:"path,omitempty" json:"path,omitempty"`

	// URL is the HTTP/HTTPS URL to fetch the registry file from
	// Mutually exclusive with Path and Data - exactly one must be specified
	// HTTPS is required unless the host is localhost or THV_REGISTRY_INSECURE_URL=true
	URL string `yaml:"url,omitempty" json:"url,omitempty"`

	// Data is the inline registry data as a JSON string
	// Mutually exclusive with Path and URL - exactly one must be specified
	// Useful for API-created registries where the data is provided directly
	Data string `yaml:"data,omitempty" json:"data,omitempty"`

	// Timeout is the timeout for HTTP requests when using URL
	// Defaults to 30s if not specified
	// Only applicable when URL is set
	Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"`
}

// ManagedConfig defines configuration for managed registries
// Managed registries are directly manipulated via API and do not sync from external sources
// Note: Initially empty, may be used as a placeholder for future configuration options
type ManagedConfig struct {
	// Future fields can be added here as needed
}

// KubernetesConfig defines configuration for Kubernetes-based registries.
// Kubernetes registries discover MCP servers from running Kubernetes resources.
// Per-entry claims are set directly on CRDs via the toolhive.stacklok.dev/authz-claims
// JSON annotation, which is merged with the source's base claims.
type KubernetesConfig struct {
	// Namespaces is a list of Kubernetes namespaces to watch for MCP servers
	// If empty, watches the namespace configured via WatchNamespace environment variable
	Namespaces []string `yaml:"namespaces,omitempty"`
}

// SyncPolicyConfig defines synchronization settings
type SyncPolicyConfig struct {
	Interval string `yaml:"interval" json:"interval"`
}

// FilterConfig defines filtering rules for registry entries
type FilterConfig struct {
	Names *NameFilterConfig `yaml:"names,omitempty" json:"names,omitempty"`
	Tags  *TagFilterConfig  `yaml:"tags,omitempty" json:"tags,omitempty"`
}

// NameFilterConfig defines name-based filtering
type NameFilterConfig struct {
	Include []string `yaml:"include,omitempty" json:"include,omitempty"`
	Exclude []string `yaml:"exclude,omitempty" json:"exclude,omitempty"`
}

// TagFilterConfig defines tag-based filtering
type TagFilterConfig struct {
	Include []string `yaml:"include,omitempty" json:"include,omitempty"`
	Exclude []string `yaml:"exclude,omitempty" json:"exclude,omitempty"`
}

// AuthMode represents the authentication mode
type AuthMode string

const (
	// AuthModeAnonymous allows unauthenticated access
	AuthModeAnonymous AuthMode = "anonymous"

	// AuthModeOAuth requires OAuth/OIDC authentication
	AuthModeOAuth AuthMode = "oauth"

	// DefaultAuthMode is the auth mode used when not explicitly configured.
	// OAuth is the default for a secure-by-default posture.
	DefaultAuthMode AuthMode = AuthModeOAuth
)

// AuthzConfig defines authorization configuration for role-based access control
type AuthzConfig struct {
	Roles RolesConfig `yaml:"roles,omitempty"`
}

// RolesConfig defines role-based authorization rules
type RolesConfig struct {
	SuperAdmin       []map[string]any `yaml:"superAdmin,omitempty"`
	ManageSources    []map[string]any `yaml:"manageSources,omitempty"`
	ManageRegistries []map[string]any `yaml:"manageRegistries,omitempty"`
	ManageEntries    []map[string]any `yaml:"manageEntries,omitempty"`
}

// AuthConfig defines authentication configuration for the registry server
type AuthConfig struct {
	// Mode specifies the authentication mode (anonymous or oauth)
	// Defaults to "oauth" if not specified (security-by-default).
	// Use "anonymous" to explicitly disable authentication for development.
	Mode AuthMode `yaml:"mode,omitempty"`

	// PublicPaths defines additional paths that bypass authentication
	// These extend the default public paths (health, docs, swagger, well-known)
	// Example: ["/custom/public", "/metrics"]
	PublicPaths []string `yaml:"publicPaths,omitempty"`

	// OAuth contains OAuth/OIDC specific configuration
	// Required when Mode is "oauth"
	OAuth *OAuthConfig `yaml:"oauth,omitempty"`

	// Authz contains authorization configuration for role-based access control
	Authz *AuthzConfig `yaml:"authz,omitempty"`

	// InsecureAllowHTTP allows HTTP issuer URLs for development/testing.
	// Populated from the THV_REGISTRY_INSECURE_URL environment variable.
	// Not loaded from YAML.
	InsecureAllowHTTP bool `yaml:"-"`
}

// OAuthConfig defines OAuth/OIDC specific authentication settings
type OAuthConfig struct {
	// ResourceURL is the URL identifying this protected resource (RFC 9728)
	// Used in the /.well-known/oauth-protected-resource endpoint
	ResourceURL string `yaml:"resourceUrl,omitempty"`

	// Providers defines the OAuth/OIDC providers for authentication
	// Multiple providers can be configured (e.g., Kubernetes + external IDP)
	Providers []OAuthProviderConfig `yaml:"providers,omitempty"`

	// Realm is the protection space identifier for WWW-Authenticate header (RFC 7235)
	// Defaults to "mcp-registry" if not specified
	Realm string `yaml:"realm,omitempty"`
}

// OAuthProviderConfig defines configuration for an OAuth/OIDC provider
type OAuthProviderConfig struct {
	// Name is a unique identifier for this provider (e.g., "kubernetes", "keycloak")
	Name string `yaml:"name"`

	// IssuerURL is the OIDC issuer URL (e.g., https://accounts.google.com)
	// The JWKS URL will be discovered automatically from .well-known/openid-configuration
	// unless JwksUrl is explicitly specified
	IssuerURL string `yaml:"issuerUrl"`

	// JwksUrl is the URL to fetch the JSON Web Key Set (JWKS) from
	// If specified, OIDC discovery is skipped and this URL is used directly
	// Example: https://kubernetes.default.svc/openid/v1/jwks
	JwksUrl string `yaml:"jwksUrl,omitempty"`

	// Audience is the expected audience claim in the token (REQUIRED)
	// Per RFC 6749 Section 4.1.3, tokens must be validated against expected audience
	// For Kubernetes, this is typically the API server URL
	Audience string `yaml:"audience"`

	// ClientID is the OAuth client ID for token introspection (optional)
	ClientID string `yaml:"clientId,omitempty"`

	// ClientSecretFile is the path to a file containing the client secret
	// The file should contain only the secret with optional trailing whitespace
	ClientSecretFile string `yaml:"clientSecretFile,omitempty"`

	// CACertPath is the path to a CA certificate bundle for verifying the provider's TLS certificate
	// Required for Kubernetes in-cluster authentication or self-signed certificates
	CACertPath string `yaml:"caCertPath,omitempty"`

	// AuthTokenFile is the path to a file containing a bearer token for authenticating to OIDC/JWKS endpoints
	// Useful when the OIDC discovery or JWKS endpoint requires authentication
	// Example: /var/run/secrets/kubernetes.io/serviceaccount/token
	AuthTokenFile string `yaml:"authTokenFile,omitempty"`

	// IntrospectionURL is the OAuth 2.0 Token Introspection endpoint (RFC 7662)
	// Used for validating opaque (non-JWT) tokens
	// If not specified, only JWT tokens can be validated via JWKS
	IntrospectionURL string `yaml:"introspectionUrl,omitempty"`

	// AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses
	// Required when the OAuth provider (e.g., Kubernetes API server) is running on a private network
	// Example: Set to true when using https://kubernetes.default.svc as the issuer URL
	AllowPrivateIP bool `yaml:"allowPrivateIP,omitempty"`
}

// GetClientSecret returns the client secret by reading from the file specified in ClientSecretFile.
// Returns empty string if ClientSecretFile is not configured.
// Returns an error if the file cannot be read.
func (p *OAuthProviderConfig) GetClientSecret() (string, error) {
	secret, err := readSecretFromFile(p.ClientSecretFile)
	if err != nil {
		return "", fmt.Errorf("failed to read client secret: %w", err)
	}
	return secret, nil
}

// validateProvider validates a single OAuth provider configuration.
// index is used for error message formatting to identify which provider failed validation.
// insecureAllowHTTP allows HTTP URLs for development (when THV_REGISTRY_INSECURE_URL is set).
func (p *OAuthProviderConfig) validateProvider(index int, insecureAllowHTTP bool) error {
	if p.Name == "" {
		return fmt.Errorf("auth.oauth.providers[%d].name is required", index)
	}
	if p.IssuerURL == "" {
		return fmt.Errorf("auth.oauth.providers[%d].issuerUrl is required", index)
	}

	// Validate IssuerURL format
	issuerURL, err := url.Parse(p.IssuerURL)
	if err != nil {
		return fmt.Errorf("auth.oauth.providers[%d].issuerUrl is invalid: %w", index, err)
	}

	if !issuerURL.IsAbs() || issuerURL.Host == "" {
		return fmt.Errorf("auth.oauth.providers[%d].issuerUrl must be an absolute URL with host", index)
	}

	// Enforce HTTPS unless THV_REGISTRY_INSECURE_URL=true or localhost
	if issuerURL.Scheme != "https" && !insecureAllowHTTP {
		host := issuerURL.Hostname()
		if !isLoopbackHost(host) {
			const msg = "must use HTTPS (set THV_REGISTRY_INSECURE_URL=true to allow HTTP)"
			return fmt.Errorf("auth.oauth.providers[%d].issuerUrl %s", index, msg)
		}
	}

	if p.Audience == "" {
		return fmt.Errorf("auth.oauth.providers[%d].audience is required", index)
	}

	return nil
}

// Validate performs validation on the auth configuration.
// This method assumes Mode has already been resolved to a valid value
// (either explicitly set or defaulted by resolveAuthMode in serve.go).
// insecureAllowHTTP allows HTTP URLs for development (when THV_REGISTRY_INSECURE_URL is set).
func (a *AuthConfig) Validate(insecureAllowHTTP bool) error {
	switch a.Mode {
	case AuthModeAnonymous:
		// Anonymous mode doesn't require OAuth config
		return nil
	case AuthModeOAuth:
		// OAuth mode requires OAuth config
		if a.OAuth == nil {
			return fmt.Errorf("auth.oauth is required when mode is oauth")
		}
		if len(a.OAuth.Providers) == 0 {
			return fmt.Errorf("auth.oauth.providers is required when mode is oauth")
		}

		// Validate each provider
		for i, provider := range a.OAuth.Providers {
			if err := provider.validateProvider(i, insecureAllowHTTP); err != nil {
				return err
			}
		}

		return nil
	default:
		return fmt.Errorf("invalid auth.mode: %s (must be 'anonymous' or 'oauth')", a.Mode)
	}
}

// DynamicAuthAWSRDSIAM defines configuration for AWS RDS IAM dynamic authentication
type DynamicAuthAWSRDSIAM struct {
	// Region is the AWS region to use for authentication.
	// If "detect", the region will be automatically detected from the
	// instance metadata.
	Region string `yaml:"region,omitempty"`
}

// DynamicAuthConfig defines configuration for dynamic database authentication
type DynamicAuthConfig struct {
	// AWSRDSIAM is configuration for AWS RDS IAM dynamic authentication
	AWSRDSIAM *DynamicAuthAWSRDSIAM `yaml:"awsRdsIam,omitempty"`
}

// DatabaseConfig defines database connection settings
type DatabaseConfig struct {
	// Host is the database server hostname or IP address
	Host string `yaml:"host"`

	// Port is the database server port
	Port int `yaml:"port"`

	// User is the database username for normal operations (SELECT, INSERT, UPDATE, DELETE)
	User string `yaml:"user"`

	// MigrationUser is the database username for schema migrations (optional)
	// This user typically has elevated privileges (CREATE, ALTER, DROP)
	// If not specified, defaults to User for backward compatibility
	MigrationUser string `yaml:"migrationUser,omitempty"`

	// Password is the database password for the application user (optional).
	// When set, it is embedded in the connection string.
	// When empty, pgx falls back to PGPASSFILE / ~/.pgpass.
	// Mutually exclusive with dynamicAuth.
	// Can be overridden via THV_REGISTRY_DATABASE_PASSWORD environment variable.
	Password string `yaml:"password,omitempty" json:"-"`

	// MigrationPassword is the database password for the migration user (optional).
	// When set, it is embedded in the migration connection string.
	// When empty and migrationUser equals user, falls back to Password.
	// When empty and migrationUser differs from user, falls back to PGPASSFILE / ~/.pgpass.
	// Mutually exclusive with dynamicAuth.
	// Can be overridden via THV_REGISTRY_DATABASE_MIGRATIONPASSWORD environment variable.
	MigrationPassword string `yaml:"migrationPassword,omitempty" json:"-"`

	// DynamicAuth is configuration for dynamic database authentication
	DynamicAuth *DynamicAuthConfig `yaml:"dynamicAuth,omitempty"`

	// Database is the database name
	Database string `yaml:"database"`

	// SSLMode is the SSL mode for the connection (disable, require, verify-ca, verify-full)
	SSLMode string `yaml:"sslMode,omitempty"`

	// MaxOpenConns is the maximum number of open connections to the database
	MaxOpenConns int32 `yaml:"maxOpenConns,omitempty"`

	// MaxIdleConns is the maximum number of idle connections in the pool
	MaxIdleConns int32 `yaml:"maxIdleConns,omitempty"`

	// ConnMaxLifetime is the maximum lifetime of a connection (e.g., "1h", "30m")
	ConnMaxLifetime string `yaml:"connMaxLifetime,omitempty"`

	// MaxMetaSize is the maximum allowed size in bytes for publisher-provided
	// metadata extensions (_meta). Must be greater than zero.
	// Defaults to 262144 (256KB) if not specified.
	// Can be overridden via THV_REGISTRY_DATABASE_MAXMETASIZE environment variable.
	MaxMetaSize *int `yaml:"maxMetaSize,omitempty"`
}

// LogValue implements slog.LogValuer to prevent accidental logging of passwords.
func (d *DatabaseConfig) LogValue() slog.Value {
	return slog.GroupValue(
		slog.String("host", d.Host),
		slog.Int("port", d.Port),
		slog.String("user", d.User),
		slog.String("database", d.Database),
		slog.Bool("has_password", d.Password != ""),
		slog.Bool("has_migration_password", d.MigrationPassword != ""),
		slog.Bool("dynamic_auth", d.DynamicAuth != nil),
	)
}

// GetPassword returns the database password for the application user.
// Returns the configured Password field, or empty string if not set.
// When empty, pgx falls back to PGPASSFILE env var or ~/.pgpass.
//
// Passwords can be provided via:
//   - The password config field or THV_REGISTRY_DATABASE_PASSWORD env var
//   - PostgreSQL's pgpass file (PGPASSFILE env var or ~/.pgpass)
//   - Dynamic auth (e.g., AWS RDS IAM) via the dynamicAuth config block
//
// See: https://www.postgresql.org/docs/current/libpq-pgpass.html
func (d *DatabaseConfig) GetPassword() string {
	return d.Password
}

// GetMigrationUser returns the database user for running migrations.
// If MigrationUser is not set, returns the regular User for backward compatibility.
func (d *DatabaseConfig) GetMigrationUser() string {
	if d.MigrationUser != "" {
		return d.MigrationUser
	}
	return d.User
}

// GetMigrationPassword returns the database password for the migration user.
// Returns MigrationPassword if set. If not set and the migration user is the
// same as the application user, falls back to Password. Otherwise returns
// empty string to let pgx use PGPASSFILE env var or ~/.pgpass.
func (d *DatabaseConfig) GetMigrationPassword() string {
	if d.MigrationPassword != "" {
		return d.MigrationPassword
	}
	if d.GetMigrationUser() == d.User {
		return d.Password
	}
	return ""
}

// GetMaxMetaSize returns the configured maximum meta size in bytes.
// Returns DefaultMaxMetaSize (256KB) if not explicitly configured.
// The returned value is always positive — validation rejects non-positive values at startup.
func (d *DatabaseConfig) GetMaxMetaSize() int {
	if d == nil || d.MaxMetaSize == nil {
		return DefaultMaxMetaSize
	}
	return *d.MaxMetaSize
}

// ToCorePostgresConfig maps the registry DatabaseConfig onto the shared
// toolhive-core postgres.Config. Connection mechanics — pool creation,
// connection-string assembly, and dynamic-auth token resolution — are owned by
// the shared package; this config layer remains responsible for secret
// resolution (env/pgpass) and the password/dynamicAuth mutual-exclusion check
// enforced in validateStorageConfig.
//
// connMaxLifetime is parsed from its string form here so a malformed duration
// surfaces as a configuration error rather than at pool-build time. maxIdleConns
// maps onto pgxpool's MinConns floor, preserving the previous pool behaviour.
func (d *DatabaseConfig) ToCorePostgresConfig() (*postgres.Config, error) {
	if d == nil {
		return nil, fmt.Errorf("database configuration is required")
	}

	cfg := &postgres.Config{
		Host:              d.Host,
		Port:              d.Port,
		User:              d.User,
		Password:          d.GetPassword(),
		MigrationUser:     d.MigrationUser,
		MigrationPassword: d.MigrationPassword,
		Database:          d.Database,
		SSLMode:           d.SSLMode,
		MaxOpenConns:      d.MaxOpenConns,
		MinConns:          d.MaxIdleConns,
	}

	if d.ConnMaxLifetime != "" {
		lifetime, err := time.ParseDuration(d.ConnMaxLifetime)
		if err != nil {
			return nil, fmt.Errorf("failed to parse connMaxLifetime: %w", err)
		}
		cfg.ConnMaxLifetime = lifetime
	}

	if d.DynamicAuth != nil && d.DynamicAuth.AWSRDSIAM != nil {
		cfg.DynamicAuth = &postgres.DynamicAuthConfig{
			AWSRDSIAM: &postgres.DynamicAuthAWSRDSIAM{
				Region: d.DynamicAuth.AWSRDSIAM.Region,
			},
		}
		// Dynamic auth supplies credentials at connect time; leave the password
		// fields empty so the shared pool installs its BeforeConnect hook.
		return cfg, nil
	}

	// The shared pool builder assembles its pgx config structurally and treats
	// Password as already resolved, so — unlike a full libpq DSN handed to pgx —
	// it does not consult the PostgreSQL password file itself. Resolve pgpass
	// here so deployments that rely on a pgpass file (e.g. a pgpass-init
	// container) instead of an inline password continue to authenticate.
	if cfg.Password == "" {
		cfg.Password = resolvePgpassPassword(cfg.Host, cfg.Port, cfg.Database, cfg.User)
	}
	if migrationUser := cfg.GetMigrationUser(); cfg.MigrationPassword == "" && migrationUser != cfg.User {
		cfg.MigrationPassword = resolvePgpassPassword(cfg.Host, cfg.Port, cfg.Database, migrationUser)
	}

	return cfg, nil
}

// resolvePgpassPassword looks up a password for the given connection target in
// the PostgreSQL password file (PGPASSFILE, or ~/.pgpass when unset). It returns
// an empty string when no file or matching entry is found, mirroring pgx's own
// pgpass fallback for callers that hand it a full DSN.
func resolvePgpassPassword(host string, port int, database, user string) string {
	path := os.Getenv("PGPASSFILE")
	if path == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			return ""
		}
		path = filepath.Join(home, ".pgpass")
	}

	passfile, err := pgpassfile.ReadPassfile(path)
	if err != nil {
		return ""
	}

	return passfile.FindPassword(host, strconv.Itoa(port), database, user)
}

// LoadConfig loads and parses configuration from a YAML file with environment variable support.
// Configuration values can be overridden using environment variables with the THV_REGISTRY_ prefix.
// Nested keys use underscores as separators (e.g., THV_REGISTRY_DATABASE_HOST for database.host).
func LoadConfig(opts ...Option) (*Config, error) {
	loaderCfg := &loaderConfig{}
	for _, opt := range opts {
		if err := opt(loaderCfg); err != nil {
			return nil, err
		}
	}

	// As of now, this is required because there's no other options to load
	// configuration. Once we add more options, we can remove this check.
	if loaderCfg.path == "" {
		return nil, fmt.Errorf("path is required")
	}

	// Create a new Viper instance (don't use global)
	v := viper.New()

	// Set config file path
	v.SetConfigFile(loaderCfg.path)

	// Configure environment variable support
	// All env vars use the THV_REGISTRY_ prefix (e.g., THV_REGISTRY_REGISTRYNAME, THV_REGISTRY_DATABASE_HOST)
	v.SetEnvPrefix(EnvPrefix)

	// Enable automatic environment variable binding
	// This allows any config value to be overridden via environment variables
	v.AutomaticEnv()

	// Replace dots with underscores for nested keys
	// e.g., database.host becomes THV_REGISTRY_DATABASE_HOST
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

	// Register password keys so AutomaticEnv can override them even when
	// they are absent from the YAML config file. Without this, Viper does
	// not recognize the keys and THV_REGISTRY_DATABASE_PASSWORD /
	// THV_REGISTRY_DATABASE_MIGRATIONPASSWORD would be silently ignored.
	v.SetDefault("database.password", "")
	v.SetDefault("database.migrationPassword", "")

	// Read config file
	if err := v.ReadInConfig(); err != nil {
		return nil, fmt.Errorf("failed to read config file: %w", err)
	}

	// Unmarshal into struct
	var config Config
	if err := v.Unmarshal(&config); err != nil {
		return nil, fmt.Errorf("failed to unmarshal config: %w", err)
	}

	// Set insecureAllowHTTP from environment variable (THV_REGISTRY_INSECURE_URL)
	// This is not loaded from YAML - environment variable only for security
	config.insecureAllowHTTP = v.GetBool("insecure_url")

	// Propagate insecureAllowHTTP to AuthConfig so the auth factory can pass it
	// to the token validator (which enforces HTTPS on OIDC discovery endpoints).
	if config.Auth != nil {
		config.Auth.InsecureAllowHTTP = config.insecureAllowHTTP
	}

	// Set compressResponse from environment variable (THV_REGISTRY_COMPRESS_RESPONSE)
	// This is not loaded from YAML - environment variable only
	config.compressResponse = v.GetBool("compress_response")

	// Set watchNamespace from environment variable (THV_REGISTRY_WATCH_NAMESPACE)
	// This is not loaded from YAML - environment variable only
	config.WatchNamespace = v.GetString("watch_namespace")

	// Set leaderElectionID from environment variable (THV_REGISTRY_LEADER_ELECTION_ID)
	// This is not loaded from YAML - environment variable only
	config.LeaderElectionID = v.GetString("leader_election_id")

	// Validate the config
	if err := config.validate(); err != nil {
		return nil, fmt.Errorf("invalid configuration: %w", err)
	}

	return &config, nil
}

// IsAuditEnabled returns true when audit logging is enabled in the config.
func (c *Config) IsAuditEnabled() bool {
	return c != nil && c.Audit != nil && c.Audit.Enabled
}

// IsCompressionEnabled returns true when HTTP response compression is enabled
// via the THV_REGISTRY_COMPRESS_RESPONSE environment variable.
func (c *Config) IsCompressionEnabled() bool {
	return c != nil && c.compressResponse
}

// Validate performs validation on the configuration
func (c *Config) validate() error {
	if c == nil {
		return fmt.Errorf("config cannot be nil")
	}

	// Validate at least one source is configured
	if len(c.Sources) == 0 {
		return fmt.Errorf("at least one source must be configured")
	}

	// Validate each source configuration
	sourceNames := make(map[string]bool)
	managedCount := 0
	for i, src := range c.Sources {
		// Validate source name
		if src.Name == "" {
			return fmt.Errorf("source[%d]: name is required", i)
		}
		if !IsValidDNSSubdomain(src.Name) {
			return fmt.Errorf("source[%d]: name '%s' must be a valid DNS subdomain "+
				"(lowercase alphanumeric and hyphens, max 63 chars)", i, src.Name)
		}

		// Check for duplicate source names
		if sourceNames[src.Name] {
			return fmt.Errorf("source[%d]: duplicate source name '%s'", i, src.Name)
		}
		sourceNames[src.Name] = true

		// Track managed source count
		if src.Managed != nil {
			managedCount++
		}

		// Validate source-specific configuration
		if err := c.validateSourceConfig(&src, i); err != nil {
			return err
		}

		// Validate claims if present
		if err := validateClaims(src.Claims, fmt.Sprintf("source[%d] (%s)", i, src.Name)); err != nil {
			return err
		}
	}

	// Validate at most one managed source is configured
	if managedCount > 1 {
		return fmt.Errorf("at most one managed source is allowed, found %d", managedCount)
	}

	// Validate at least one registry is configured
	if len(c.Registries) == 0 {
		return fmt.Errorf("at least one registry must be configured")
	}

	// Validate registries
	if err := c.validateRegistries(sourceNames); err != nil {
		return err
	}

	// Validate storage configuration
	if err := c.validateStorageConfig(); err != nil {
		return err
	}

	// Validate audit configuration if present
	if err := c.validateAudit(); err != nil {
		return err
	}

	// Validate auth configuration if present
	return c.validateAuth()
}

// validateSourceConfig validates a single source configuration
func (*Config) validateSourceConfig(src *SourceConfig, index int) error {
	prefix := fmt.Sprintf("source[%d] (%s)", index, src.Name)

	// Validate exactly one source type is configured
	if err := validateSourceTypeCount(src, prefix); err != nil {
		return err
	}

	// Non-synced sources (managed and kubernetes) don't require sync policy or filter
	// If syncPolicy or filter are set for these sources, they will be silently ignored
	if src.IsNonSyncedSource() {
		return nil
	}

	// Synced sources require sync policy
	if err := validateSyncPolicy(src.SyncPolicy, prefix); err != nil {
		return err
	}

	// Validate type-specific settings
	return validateSourceSpecificConfig(src, prefix)
}

// validateRegistries validates the registries section of the configuration
func (c *Config) validateRegistries(sourceNames map[string]bool) error {
	registryNames := make(map[string]bool)
	for i, reg := range c.Registries {
		if reg.Name == "" {
			return fmt.Errorf("registries[%d]: name is required", i)
		}

		if registryNames[reg.Name] {
			return fmt.Errorf("registries[%d]: duplicate registry name '%s'", i, reg.Name)
		}
		registryNames[reg.Name] = true

		// Validate that at least one source is referenced
		if len(reg.Sources) == 0 {
			return fmt.Errorf("registries[%d] (%s): at least one source is required", i, reg.Name)
		}

		// Validate that each referenced source exists
		for _, srcName := range reg.Sources {
			if !sourceNames[srcName] {
				return fmt.Errorf("registries[%d] (%s): references unknown source '%s'", i, reg.Name, srcName)
			}
		}

		// Validate claims if present
		if err := validateClaims(reg.Claims, fmt.Sprintf("registries[%d] (%s)", i, reg.Name)); err != nil {
			return err
		}
	}
	return nil
}

// dnsSubdomainRegex matches valid DNS subdomain labels per RFC 1123:
// lowercase alphanumeric, hyphens allowed (not leading/trailing), max 63 chars.
var dnsSubdomainRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)

// IsValidDNSSubdomain checks whether s is a valid DNS subdomain label per RFC 1123.
// Used for source names (K8s lease name suffixes) and K8s namespace validation.
func IsValidDNSSubdomain(s string) bool {
	return dnsSubdomainRegex.MatchString(s)
}

// validateSyncPolicy validates the sync policy configuration
func validateSyncPolicy(policy *SyncPolicyConfig, prefix string) error {
	if policy == nil || policy.Interval == "" {
		return fmt.Errorf("%s: syncPolicy.interval is required", prefix)
	}

	// Try to parse the interval to ensure it's valid
	if _, err := time.ParseDuration(policy.Interval); err != nil {
		return fmt.Errorf("%s: syncPolicy.interval must be a valid duration (e.g., '30m', '1h'): %w", prefix, err)
	}

	return nil
}

// validateSourceTypeCount ensures exactly one source type is configured
func validateSourceTypeCount(src *SourceConfig, prefix string) error {
	configCount := 0
	if src.Git != nil {
		configCount++
	}
	if src.API != nil {
		configCount++
	}
	if src.File != nil {
		configCount++
	}
	if src.Managed != nil {
		configCount++
	}
	if src.Kubernetes != nil {
		configCount++
	}

	if configCount == 0 {
		return fmt.Errorf("%s: one of git, api, file, managed, or kubernetes configuration must be specified", prefix)
	}
	if configCount > 1 {
		return fmt.Errorf("%s: only one of git, api, file, managed, or kubernetes configuration may be specified", prefix)
	}

	return nil
}

// validateSourceSpecificConfig validates the configuration for each source type
func validateSourceSpecificConfig(src *SourceConfig, prefix string) error {
	if src.Git != nil {
		return validateGitConfig(src.Git, prefix)
	}

	if src.API != nil {
		return validateAPIConfig(src.API, prefix)
	}

	if src.File != nil {
		return validateFileConfig(src.File, prefix)
	}

	return nil
}

// validateClaims validates that all claim values are string or []string
func validateClaims(claims map[string]any, prefix string) error {
	if err := db.ValidateClaimValues(claims); err != nil {
		return fmt.Errorf("%s: %w", prefix, err)
	}
	return nil
}

// validateGitConfig validates Git-specific configuration
func validateGitConfig(git *GitConfig, prefix string) error {
	if git.Repository == "" {
		return fmt.Errorf("%s: git.repository is required", prefix)
	}

	// Validate auth if present
	if git.Auth != nil {
		if err := git.Auth.Validate(); err != nil {
			return fmt.Errorf("%s: %w", prefix, err)
		}
	}
	return nil
}

// validateAPIConfig validates API-specific configuration
func validateAPIConfig(api *APIConfig, prefix string) error {
	if api.Endpoint == "" {
		return fmt.Errorf("%s: api.endpoint is required", prefix)
	}
	return nil
}

// validateFileConfig validates File-specific configuration
func validateFileConfig(file *FileConfig, prefix string) error {
	// Exactly one of Path or URL must be specified
	hasPath := file.Path != ""
	hasURL := file.URL != ""

	if !hasPath && !hasURL {
		return fmt.Errorf("%s: file.path or file.url is required", prefix)
	}
	if hasPath && hasURL {
		return fmt.Errorf("%s: file.path and file.url are mutually exclusive", prefix)
	}

	// Validate URL if specified
	if hasURL {
		if err := validateFileURL(file.URL, prefix); err != nil {
			return err
		}

		// Validate timeout if specified
		if file.Timeout != "" {
			if _, err := time.ParseDuration(file.Timeout); err != nil {
				return fmt.Errorf("%s: file.timeout must be a valid duration (e.g., '30s', '1m'): %w", prefix, err)
			}
		}
	}

	return nil
}

// validateFileURL validates the URL for file sources
func validateFileURL(rawURL string, prefix string) error {
	parsedURL, err := url.Parse(rawURL)
	if err != nil {
		return fmt.Errorf("%s: file.url is invalid: %w", prefix, err)
	}

	if !parsedURL.IsAbs() || parsedURL.Host == "" {
		return fmt.Errorf("%s: file.url must be an absolute URL with host", prefix)
	}

	// Only allow HTTP and HTTPS schemes
	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
		return fmt.Errorf("%s: file.url must use http or https scheme", prefix)
	}

	return nil
}

// validateStorageConfig validates the storage configuration
func (c *Config) validateStorageConfig() error {
	if c.Database == nil {
		return fmt.Errorf("database configuration is required")
	}

	if c.Database.Host == "" {
		return fmt.Errorf("database.host is required")
	}
	if c.Database.Port == 0 {
		return fmt.Errorf("database.port is required")
	}
	if c.Database.User == "" {
		return fmt.Errorf("database.user is required")
	}
	if c.Database.Database == "" {
		return fmt.Errorf("database.database is required")
	}
	if c.Database.MaxMetaSize != nil && *c.Database.MaxMetaSize <= 0 {
		return fmt.Errorf("database.maxMetaSize must be greater than zero")
	}

	if c.Database.DynamicAuth != nil {
		if c.Database.Password != "" {
			return fmt.Errorf("database.password and database.dynamicAuth are mutually exclusive; use one authentication method")
		}
		if c.Database.MigrationPassword != "" {
			return fmt.Errorf("database.migrationPassword and database.dynamicAuth are mutually exclusive; use one authentication method")
		}
	}

	return nil
}

// readSecretFromFile reads a secret from a file with proper path validation.
// It resolves symlinks, requires absolute paths, and trims whitespace from content.
func readSecretFromFile(filePath string) (string, error) {
	if filePath == "" {
		return "", nil
	}

	// Resolve symlinks to get real path
	realPath, err := filepath.EvalSymlinks(filePath)
	if err != nil {
		return "", fmt.Errorf("failed to resolve path: %w", err)
	}

	// Require absolute paths for security
	if !filepath.IsAbs(realPath) {
		return "", fmt.Errorf("path must be absolute: %s", filePath)
	}

	data, err := os.ReadFile(realPath)
	if err != nil {
		return "", fmt.Errorf("failed to read file: %w", err)
	}

	return strings.TrimSpace(string(data)), nil
}

// GetType returns the type of the source config, inferred from which configuration field is present.
func (s *SourceConfig) GetType() SourceType {
	if s.Git != nil {
		return SourceTypeGit
	}
	if s.API != nil {
		return SourceTypeAPI
	}
	if s.File != nil {
		return SourceTypeFile
	}
	if s.Managed != nil {
		return SourceTypeManaged
	}
	if s.Kubernetes != nil {
		return SourceTypeKubernetes
	}
	return ""
}

// IsNonSyncedSource returns true if the source type doesn't sync from external sources.
// This includes managed sources (manipulated via API) and kubernetes sources (query live deployments).
// Non-synced sources do not require sync policy configuration and skip the sync loop.
func (s *SourceConfig) IsNonSyncedSource() bool {
	srcType := s.GetType()
	return srcType == SourceTypeManaged || srcType == SourceTypeKubernetes
}

// validateAudit validates the audit logging configuration if present.
func (c *Config) validateAudit() error {
	if c.Audit == nil {
		return nil // audit is optional
	}
	if c.Audit.MaxDataSize < 0 {
		return fmt.Errorf("audit.maxDataSize must be non-negative, got %d", c.Audit.MaxDataSize)
	}
	if c.Audit.MaxDataSize > MaxAuditDataSize {
		return fmt.Errorf("audit.maxDataSize must not exceed %d bytes, got %d", MaxAuditDataSize, c.Audit.MaxDataSize)
	}
	if c.Audit.LogFile != "" {
		// Resolve symlinks to prevent writing to unexpected locations,
		// consistent with how the config file path itself is validated.
		realDir, err := filepath.EvalSymlinks(filepath.Dir(c.Audit.LogFile))
		if err != nil {
			return fmt.Errorf("audit.logFile directory does not exist: %w", err)
		}
		info, err := os.Stat(realDir)
		if err != nil {
			return fmt.Errorf("audit.logFile directory is not accessible: %w", err)
		}
		if !info.IsDir() {
			return fmt.Errorf("audit.logFile parent path is not a directory: %s", realDir)
		}
	}
	return nil
}

func (c *Config) validateAuth() error {
	if c.Auth == nil {
		return errors.New("auth configuration is required")
	}
	if err := c.Auth.Validate(c.insecureAllowHTTP); err != nil {
		return fmt.Errorf("invalid auth configuration: %w", err)
	}

	return nil
}

// hostLocalhost is the DNS loopback alias accepted alongside 127.0.0.1 and ::1.
const hostLocalhost = "localhost"

// isLoopbackHost reports whether host is a loopback identifier for which
// HTTP issuer URLs are accepted without THV_REGISTRY_INSECURE_URL.
func isLoopbackHost(host string) bool {
	switch host {
	case hostLocalhost, "127.0.0.1", "::1":
		return true
	default:
		return false
	}
}
