package records

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"strings"

	appErrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/oauthroutes"
	"github.com/nexuscrm/kernel/pkg/passwordhash"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

const (
	connectedAppClientTypePublic       = "public"
	connectedAppClientTypeConfidential = "confidential"

	connectedAppTokenAuthNone             = "none"
	connectedAppTokenAuthClientSecretPost = "client_secret_post"

	connectedAppGrantAuthorizationCode = "authorization_code"
	connectedAppGrantPassword          = "password"
	connectedAppGrantClientCredentials = "client_credentials"
	connectedAppGrantRefreshToken      = "refresh_token"

	connectedAppSessionIssuerPlatformBrowser = "platform_browser"

	connectedAppConsentPrompt           = "prompt"
	connectedAppConsentTrusted          = "trusted"
	connectedAppConsentAdminPreapproved = "admin_preapproved"
)

type ConnectedAppPublicScopePolicyProvider interface {
	AllowsPublicOAuthScope(scope string) bool
}

// ConnectedAppPublicScopePolicyResolver selects the policy already bound to
// the current request. Independent Runtime composition uses this instead of a
// process-global registry so Connected App validation cannot cross Catalog
// revisions. The legacy monolith continues to install its static provider.
type ConnectedAppPublicScopePolicyResolver func(context.Context) (ConnectedAppPublicScopePolicyProvider, error)

type connectedAppPackPublicScopePolicyProvider interface {
	AllowsPublicOAuthScopeForPack(scope, packName string) bool
}

type ConnectedAppSessionIssuerClassPolicyProvider interface {
	AllowsConnectedAppSessionIssuerClassForPack(class, packName string) bool
}

type SourceBackedConnectedAppPolicyOptions struct {
	PackName                 string
	PublicScopePolicy        ConnectedAppPublicScopePolicyProvider
	SessionIssuerClassPolicy ConnectedAppSessionIssuerClassPolicyProvider
}

// connectedAppScopeOwnerContextKey carries the already-authorized pack owner
// only while a trusted control-plane writer materializes a Connected App. The
// record has no mutable owner field; the trusted caller records the durable L2
// authorization separately in _System_OAuthDynamicClientRegistration in the
// same transaction.
type connectedAppScopeOwnerContextKey struct{}

// WithConnectedAppScopeOwnerPack binds a source-backed Connected App write to
// the installed pack that owns its scopes. It is intentionally an internal
// control-plane context value, not request metadata supplied by a client.
func WithConnectedAppScopeOwnerPack(ctx context.Context, packName string) context.Context {
	packName = strings.TrimSpace(packName)
	if packName == "" {
		return ctx
	}
	return context.WithValue(ctx, connectedAppScopeOwnerContextKey{}, packName)
}

func connectedAppScopeOwnerPack(ctx context.Context) string {
	if ctx == nil {
		return ""
	}
	packName, _ := ctx.Value(connectedAppScopeOwnerContextKey{}).(string)
	return strings.TrimSpace(packName)
}

func normalizeConnectedAppCreate(data models.SObject, scopePolicy ConnectedAppPublicScopePolicyProvider) error {
	return normalizeConnectedAppCreateWithContext(context.Background(), data, scopePolicy)
}

func normalizeConnectedAppCreateWithContext(ctx context.Context, data models.SObject, scopePolicy ConnectedAppPublicScopePolicyProvider) error {
	if err := canonicalizeConnectedAppCreateWithContext(ctx, data); err != nil {
		return err
	}
	return validateConnectedAppSecurityPolicy(data, scopePolicy)
}

func canonicalizeConnectedAppCreateWithContext(ctx context.Context, data models.SObject) error {
	if data == nil {
		return nil
	}
	allowSessionIssuerClass := constants.GetSecurityContext(ctx) >= constants.SystemContext
	if err := normalizeConnectedAppSecurityDefaults(data, nil, allowSessionIssuerClass); err != nil {
		return err
	}
	return hashConnectedAppSecretWrite(data)
}

func normalizeConnectedAppUpdate(final, effectiveUpdates models.SObject, scopePolicy ConnectedAppPublicScopePolicyProvider) error {
	return normalizeConnectedAppUpdateWithContext(context.Background(), final, effectiveUpdates, scopePolicy)
}

func normalizeConnectedAppUpdateWithContext(ctx context.Context, final, effectiveUpdates models.SObject, scopePolicy ConnectedAppPublicScopePolicyProvider) error {
	if err := canonicalizeConnectedAppUpdateWithContext(ctx, final, effectiveUpdates); err != nil {
		return err
	}
	return validateConnectedAppSecurityPolicy(final, scopePolicy)
}

func canonicalizeConnectedAppUpdateWithContext(ctx context.Context, final, effectiveUpdates models.SObject) error {
	if final == nil || effectiveUpdates == nil {
		return nil
	}
	allowSessionIssuerClass := constants.GetSecurityContext(ctx) >= constants.SystemContext
	if err := normalizeConnectedAppSecurityDefaults(final, effectiveUpdates, allowSessionIssuerClass); err != nil {
		return err
	}
	return hashConnectedAppSecretUpdate(final, effectiveUpdates)
}

func NormalizeSourceBackedConnectedAppPolicy(record models.SObject, opts SourceBackedConnectedAppPolicyOptions) error {
	if record == nil {
		return nil
	}
	normalizeConnectedAppKeywordField(record, nil, constants.ColSysConnectedApp_ClientType)
	normalizeConnectedAppKeywordField(record, nil, constants.ColSysConnectedApp_TokenEndpointAuthMethod)
	normalizeConnectedAppKeywordField(record, nil, constants.ColSysConnectedApp_ConsentPolicy)
	normalizeConnectedAppCapabilityTokenField(record, nil, constants.ColSysConnectedApp_SessionIssuerClass)
	normalizeConnectedAppTokenSetFields(record, nil)
	if err := validateSourceBackedConnectedAppPolicy(record, opts); err != nil {
		return err
	}
	return hashConnectedAppSecretWrite(record)
}

func normalizeConnectedAppSecurityDefaults(record, effectiveUpdates models.SObject, allowSessionIssuerClass bool) error {
	clientType := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_ClientType])
	if clientType == "" {
		clientType = connectedAppClientTypeConfidential
		setConnectedAppPolicyValue(record, effectiveUpdates, constants.ColSysConnectedApp_ClientType, clientType)
	}

	consentPolicy := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_ConsentPolicy])
	if consentPolicy == "" {
		defaultConsentPolicy := connectedAppConsentPrompt
		if clientType == connectedAppClientTypePublic {
			defaultConsentPolicy = connectedAppConsentTrusted
		}
		setConnectedAppPolicyValue(record, effectiveUpdates, constants.ColSysConnectedApp_ConsentPolicy, defaultConsentPolicy)
	}

	if err := normalizeConnectedAppSessionIssuerClass(record, effectiveUpdates, allowSessionIssuerClass); err != nil {
		return err
	}
	if clientType == connectedAppClientTypePublic {
		clearConnectedAppSecretIfPresent(record, effectiveUpdates, constants.ColSysConnectedApp_ConsumerSecret)
		clearConnectedAppSecretIfPresent(record, effectiveUpdates, constants.ColSysConnectedApp_ConsumerSecretHash)
		if _, ok := record[constants.ColSysConnectedApp_RequirePkce]; !ok {
			setConnectedAppPolicyValue(record, effectiveUpdates, constants.ColSysConnectedApp_RequirePkce, true)
		}
		if normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_TokenEndpointAuthMethod]) == "" {
			setConnectedAppPolicyValue(record, effectiveUpdates, constants.ColSysConnectedApp_TokenEndpointAuthMethod, connectedAppTokenAuthNone)
		}
		if normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_AllowedGrantTypes]) == "" {
			setConnectedAppPolicyValue(record, effectiveUpdates, constants.ColSysConnectedApp_AllowedGrantTypes, connectedAppGrantAuthorizationCode)
		}
		normalizeConnectedAppTokenSetFields(record, effectiveUpdates)
		return nil
	}

	if normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_TokenEndpointAuthMethod]) == "" {
		setConnectedAppPolicyValue(record, effectiveUpdates, constants.ColSysConnectedApp_TokenEndpointAuthMethod, connectedAppTokenAuthClientSecretPost)
	}
	if normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_AllowedGrantTypes]) == "" {
		setConnectedAppPolicyValue(record, effectiveUpdates, constants.ColSysConnectedApp_AllowedGrantTypes, strings.Join([]string{
			connectedAppGrantAuthorizationCode,
			connectedAppGrantPassword,
			connectedAppGrantClientCredentials,
		}, " "))
	}
	normalizeConnectedAppTokenSetFields(record, effectiveUpdates)
	return nil
}

func setConnectedAppPolicyValue(record, effectiveUpdates models.SObject, field string, value interface{}) {
	record[field] = value
	if effectiveUpdates != nil {
		effectiveUpdates[field] = value
	}
}

func clearConnectedAppSecretIfPresent(record, effectiveUpdates models.SObject, field string) {
	if effectiveUpdates == nil {
		return
	}
	if _, writeAttempted := effectiveUpdates[field]; writeAttempted || normalizeConnectedAppString(record[field]) != "" {
		setConnectedAppPolicyValue(record, effectiveUpdates, field, "")
	}
}

func normalizeConnectedAppTokenSetFields(record, effectiveUpdates models.SObject) {
	normalizeConnectedAppTokenSetField(record, effectiveUpdates, constants.ColSysConnectedApp_AllowedGrantTypes)
	normalizeConnectedAppTokenSetField(record, effectiveUpdates, constants.ColSysConnectedApp_Scopes)
}

func normalizeConnectedAppTokenSetField(record, effectiveUpdates models.SObject, field string) {
	raw := normalizeConnectedAppString(record[field])
	if raw == "" {
		return
	}
	canonical := connectedAppCanonicalWordSet(raw)
	if canonical != raw {
		setConnectedAppPolicyValue(record, effectiveUpdates, field, canonical)
	}
}

func normalizeConnectedAppKeywordField(record, effectiveUpdates models.SObject, field string) {
	raw := normalizeConnectedAppString(record[field])
	if raw == "" {
		return
	}
	canonical := normalizeConnectedAppKeyword(raw)
	if canonical != raw {
		setConnectedAppPolicyValue(record, effectiveUpdates, field, canonical)
	}
}

func normalizeConnectedAppCapabilityTokenField(record, effectiveUpdates models.SObject, field string) {
	raw := normalizeConnectedAppString(record[field])
	if raw == "" {
		return
	}
	canonical := strings.ReplaceAll(normalizeConnectedAppKeyword(raw), "-", "_")
	if canonical != raw {
		setConnectedAppPolicyValue(record, effectiveUpdates, field, canonical)
	}
}

func normalizeConnectedAppSessionIssuerClass(record, effectiveUpdates models.SObject, allow bool) error {
	field := constants.ColSysConnectedApp_SessionIssuerClass
	writeAttempted := false
	if effectiveUpdates == nil {
		_, writeAttempted = record[field]
	} else {
		_, writeAttempted = effectiveUpdates[field]
	}
	if writeAttempted && !allow {
		return appErrors.NewInvalidFieldForWriteError(
			[]string{field},
			"SessionIssuerClass is reserved for platform bootstrap metadata",
		)
	}
	if !allow {
		return nil
	}
	normalizeConnectedAppKeywordField(record, effectiveUpdates, field)
	return nil
}

func validateConnectedAppSecurityPolicy(record models.SObject, scopePolicy ConnectedAppPublicScopePolicyProvider) error {
	return validateConnectedAppSecurityPolicyForPack(record, scopePolicy, "")
}

func validateConnectedAppSecurityPolicyForPack(record models.SObject, scopePolicy ConnectedAppPublicScopePolicyProvider, packName string) error {
	clientType := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_ClientType])
	switch clientType {
	case connectedAppClientTypePublic, connectedAppClientTypeConfidential:
	default:
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ClientType, "ClientType must be public or confidential")
	}

	tokenAuth := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_TokenEndpointAuthMethod])
	switch tokenAuth {
	case connectedAppTokenAuthNone, connectedAppTokenAuthClientSecretPost:
	default:
		return appErrors.NewValidationError(constants.ColSysConnectedApp_TokenEndpointAuthMethod, "TokenEndpointAuthMethod must be none or client_secret_post")
	}

	sessionIssuerClass := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_SessionIssuerClass])
	if sessionIssuerClass != "" && sessionIssuerClass != connectedAppSessionIssuerPlatformBrowser {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_SessionIssuerClass, "SessionIssuerClass must be platform_browser when set")
	}
	if clientType == connectedAppClientTypePublic && sessionIssuerClass != "" {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_SessionIssuerClass, "Public Connected Apps cannot issue platform browser sessions")
	}

	consentPolicy := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_ConsentPolicy])
	switch consentPolicy {
	case connectedAppConsentPrompt, connectedAppConsentTrusted, connectedAppConsentAdminPreapproved:
	default:
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ConsentPolicy, "ConsentPolicy must be prompt, trusted, or admin_preapproved")
	}

	if err := validateConnectedAppRedirects(record); err != nil {
		return err
	}

	if clientType == connectedAppClientTypePublic {
		return validatePublicConnectedAppPolicyForPack(record, tokenAuth, scopePolicy, packName)
	}

	if tokenAuth == connectedAppTokenAuthNone {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_TokenEndpointAuthMethod, "Confidential Connected Apps must authenticate at the token endpoint")
	}
	if !connectedAppHasUsableSecret(record) {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ConsumerSecret, "Confidential Connected Apps must have a client secret")
	}
	return nil
}

func validateSourceBackedConnectedAppPolicy(record models.SObject, opts SourceBackedConnectedAppPolicyOptions) error {
	if err := validateSourceBackedConnectedAppRequiredPolicyFields(record); err != nil {
		return err
	}
	if strings.TrimSpace(opts.PackName) == "" {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_SessionIssuerClass, "source-backed Connected App policy requires pack name")
	}
	clientType := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_ClientType])
	sessionIssuerClass := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_SessionIssuerClass])
	if sessionIssuerClass != "" && sessionIssuerClass != connectedAppSessionIssuerPlatformBrowser {
		if opts.SessionIssuerClassPolicy == nil || !opts.SessionIssuerClassPolicy.AllowsConnectedAppSessionIssuerClassForPack(sessionIssuerClass, opts.PackName) {
			return appErrors.NewValidationError(constants.ColSysConnectedApp_SessionIssuerClass, "SessionIssuerClass must be owned by the authoring app pack")
		}
	}
	if clientType == connectedAppClientTypePublic && sessionIssuerClass != "" {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_SessionIssuerClass, "Public Connected Apps cannot issue named runtime sessions")
	}
	if _, ok := connectedAppWordSet(record[constants.ColSysConnectedApp_AllowedGrantTypes])[connectedAppGrantClientCredentials]; ok {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_AllowedGrantTypes, "source-backed Connected Apps do not support client_credentials in v1")
	}
	if normalizeConnectedAppString(record[constants.ColSysConnectedApp_RunAsUserID]) != "" {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_RunAsUserID, "source-backed Connected Apps do not support RunAsUserId until client_credentials is supported")
	}
	if strings.EqualFold(normalizeConnectedAppString(record[constants.ColSysConnectedApp_PermittedUsers]), "AdminApproved") {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_PermittedUsers, "source-backed Connected Apps do not support PermittedUsers=AdminApproved in v1")
	}
	// Refresh tokens are permitted now that issuance stores only hashes and the
	// token endpoint rotates on every use with family replay detection + revoke
	// (oauth-pkce-external-apps "Decision 7" gate). A source-backed app that opts
	// in must still declare refresh_token in its grant allowlist, enforced below.
	if connectedAppBool(record[constants.ColSysConnectedApp_IssueRefreshTokens]) {
		if _, ok := connectedAppWordSet(record[constants.ColSysConnectedApp_AllowedGrantTypes])[connectedAppGrantRefreshToken]; !ok {
			return appErrors.NewValidationError(constants.ColSysConnectedApp_AllowedGrantTypes, "Connected Apps issuing refresh tokens must allow the refresh_token grant")
		}
	}
	if err := validateConnectedAppSecurityPolicyAllowingPackSessionIssuer(record, opts.PublicScopePolicy, opts.PackName); err != nil {
		return err
	}
	return nil
}

func validateConnectedAppSecurityPolicyAllowingPackSessionIssuer(record models.SObject, scopePolicy ConnectedAppPublicScopePolicyProvider, packName string) error {
	clientType := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_ClientType])
	tokenAuth := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_TokenEndpointAuthMethod])
	consentPolicy := normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_ConsentPolicy])
	switch clientType {
	case connectedAppClientTypePublic, connectedAppClientTypeConfidential:
	default:
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ClientType, "ClientType must be public or confidential")
	}
	switch tokenAuth {
	case connectedAppTokenAuthNone, connectedAppTokenAuthClientSecretPost:
	default:
		return appErrors.NewValidationError(constants.ColSysConnectedApp_TokenEndpointAuthMethod, "TokenEndpointAuthMethod must be none or client_secret_post")
	}
	switch consentPolicy {
	case connectedAppConsentPrompt, connectedAppConsentTrusted, connectedAppConsentAdminPreapproved:
	default:
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ConsentPolicy, "ConsentPolicy must be prompt, trusted, or admin_preapproved")
	}
	if err := validateConnectedAppRedirects(record); err != nil {
		return err
	}
	if clientType == connectedAppClientTypePublic {
		return validatePublicConnectedAppPolicyForPack(record, tokenAuth, scopePolicy, packName)
	}
	if tokenAuth == connectedAppTokenAuthNone {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_TokenEndpointAuthMethod, "Confidential Connected Apps must authenticate at the token endpoint")
	}
	if !connectedAppHasUsableSecret(record) {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ConsumerSecret, "Confidential Connected Apps must have a client secret")
	}
	return nil
}

func validateSourceBackedConnectedAppRequiredPolicyFields(record models.SObject) error {
	required := []string{
		constants.ColSysConnectedApp_CallbackURL,
		constants.ColSysConnectedApp_RedirectURIs,
		constants.ColSysConnectedApp_Scopes,
		constants.ColSysConnectedApp_ClientType,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod,
		constants.ColSysConnectedApp_AllowedGrantTypes,
		constants.ColSysConnectedApp_ConsentPolicy,
		constants.ColSysConnectedApp_PermittedUsers,
	}
	for _, field := range required {
		if normalizeConnectedAppString(record[field]) == "" {
			return appErrors.NewValidationError(field, "source-backed Connected App policy requires explicit "+field)
		}
	}
	requiredPresence := []string{
		constants.ColSysConnectedApp_IsActive,
		constants.ColSysConnectedApp_RequirePkce,
		constants.ColSysConnectedApp_ConsentVersion,
		constants.ColSysConnectedApp_IssueRefreshTokens,
	}
	for _, field := range requiredPresence {
		if _, ok := record[field]; !ok {
			return appErrors.NewValidationError(field, "source-backed Connected App policy requires explicit "+field)
		}
	}
	if connectedAppInt(record[constants.ColSysConnectedApp_ConsentVersion]) < 1 {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ConsentVersion, "ConsentVersion must be at least 1")
	}
	return nil
}

func validatePublicConnectedAppPolicyForPack(record models.SObject, tokenAuth string, scopePolicy ConnectedAppPublicScopePolicyProvider, packName string) error {
	if tokenAuth != connectedAppTokenAuthNone {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_TokenEndpointAuthMethod, "Public Connected Apps must use token endpoint auth method none")
	}
	if len(connectedAppRedirects(record)) == 0 {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_RedirectURIs, "Public Connected Apps must register at least one redirect URI")
	}
	if normalizeConnectedAppKeyword(record[constants.ColSysConnectedApp_ConsentPolicy]) == connectedAppConsentPrompt {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ConsentPolicy, "Public Connected Apps cannot use prompt consent until the consent engine is available")
	}
	if !connectedAppBool(record[constants.ColSysConnectedApp_RequirePkce]) {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_RequirePkce, "Public Connected Apps must require PKCE")
	}
	issuesRefresh := connectedAppBool(record[constants.ColSysConnectedApp_IssueRefreshTokens])
	if normalizeConnectedAppString(record[constants.ColSysConnectedApp_ConsumerSecret]) != "" ||
		normalizeConnectedAppString(record[constants.ColSysConnectedApp_ConsumerSecretHash]) != "" {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_ConsumerSecret, "Public Connected Apps cannot have a client secret")
	}

	// Public clients must always run authorization_code + PKCE. They may additionally
	// use the refresh_token grant now that public-client refresh tokens rotate with
	// family replay detection (oauth-pkce-external-apps "Decision 7"); refresh_token
	// is only accepted when the app also opts into issuing refresh tokens.
	allowedGrants := connectedAppWordSet(record[constants.ColSysConnectedApp_AllowedGrantTypes])
	if _, ok := allowedGrants[connectedAppGrantAuthorizationCode]; len(allowedGrants) == 0 || !ok {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_AllowedGrantTypes, "Public Connected Apps must allow authorization_code")
	}
	for grant := range allowedGrants {
		switch grant {
		case connectedAppGrantAuthorizationCode:
		case connectedAppGrantRefreshToken:
			if !issuesRefresh {
				return appErrors.NewValidationError(constants.ColSysConnectedApp_AllowedGrantTypes, "Public Connected Apps may only use refresh_token when IssueRefreshTokens is enabled")
			}
		default:
			return appErrors.NewValidationError(constants.ColSysConnectedApp_AllowedGrantTypes, "Public Connected Apps may only use authorization_code and refresh_token")
		}
	}
	if _, ok := allowedGrants[connectedAppGrantRefreshToken]; issuesRefresh && !ok {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_AllowedGrantTypes, "Public Connected Apps issuing refresh tokens must allow the refresh_token grant")
	}

	scopes := connectedAppWordSet(record[constants.ColSysConnectedApp_Scopes])
	if len(scopes) == 0 {
		return appErrors.NewValidationError(constants.ColSysConnectedApp_Scopes, "Public Connected Apps must declare an explicit scope")
	}
	for scope := range scopes {
		if connectedAppPublicScopeIsReserved(scope) {
			return appErrors.NewValidationError(constants.ColSysConnectedApp_Scopes, "Public Connected Apps cannot declare reserved OAuth scopes")
		}
		if !connectedAppPublicScopeAllowed(scopePolicy, scope, packName) {
			return appErrors.NewValidationError(constants.ColSysConnectedApp_Scopes, "Public Connected App scopes must be backed by active public scope policy")
		}
	}
	return nil
}

func connectedAppPublicScopeAllowed(scopePolicy ConnectedAppPublicScopePolicyProvider, scope, packName string) bool {
	if scopePolicy == nil {
		return false
	}
	packName = strings.TrimSpace(packName)
	if packName == "" {
		return scopePolicy.AllowsPublicOAuthScope(scope)
	}
	packPolicy, ok := scopePolicy.(connectedAppPackPublicScopePolicyProvider)
	return ok && packPolicy.AllowsPublicOAuthScopeForPack(scope, packName)
}

func connectedAppHasUsableSecret(record models.SObject) bool {
	return normalizeConnectedAppString(record[constants.ColSysConnectedApp_ConsumerSecret]) != "" ||
		normalizeConnectedAppString(record[constants.ColSysConnectedApp_ConsumerSecretHash]) != ""
}

func connectedAppPublicScopeIsReserved(scope string) bool {
	switch oauthroutes.NormalizeScope(scope) {
	case "api", "full", "api.read":
		return true
	default:
		return oauthroutes.ScopePolicy{}.Allows(scope)
	}
}

func validateConnectedAppRedirects(record models.SObject) error {
	redirects := connectedAppRedirects(record)
	if len(redirects) == 0 {
		return nil
	}
	for _, redirect := range redirects {
		if err := validateConnectedAppRedirectURIShape(redirect); err != nil {
			return appErrors.NewValidationError(constants.ColSysConnectedApp_RedirectURIs, err.Error())
		}
	}
	return nil
}

func connectedAppRedirects(record models.SObject) []string {
	raw := normalizeConnectedAppString(record[constants.ColSysConnectedApp_RedirectURIs])
	if raw == "" {
		raw = normalizeConnectedAppString(record[constants.ColSysConnectedApp_CallbackURL])
	}
	if raw == "" {
		return nil
	}
	if strings.HasPrefix(raw, "[") {
		var parsed []string
		if json.Unmarshal([]byte(raw), &parsed) == nil {
			return compactConnectedAppRedirects(parsed)
		}
	}
	parts := strings.FieldsFunc(raw, func(r rune) bool {
		return r == ',' || r == '\n' || r == '\r' || r == '\t'
	})
	return compactConnectedAppRedirects(parts)
}

func compactConnectedAppRedirects(parts []string) []string {
	out := make([]string, 0, len(parts))
	for _, part := range parts {
		if trimmed := strings.TrimSpace(part); trimmed != "" {
			out = append(out, trimmed)
		}
	}
	return out
}

func validateConnectedAppRedirectURIShape(raw string) error {
	if strings.TrimSpace(raw) == "" || strings.Contains(raw, "*") {
		return fmt.Errorf("invalid redirect URI")
	}
	u, err := url.Parse(raw)
	if err != nil || !u.IsAbs() || u.Host == "" || u.Fragment != "" || u.User != nil {
		return fmt.Errorf("invalid redirect URI")
	}
	switch u.Scheme {
	case "https":
		return nil
	case "http":
		host := strings.ToLower(u.Hostname())
		if host == "localhost" || host == "127.0.0.1" || host == "::1" {
			return nil
		}
	}
	return fmt.Errorf("redirect URI must use HTTPS except exact registered loopback HTTP callbacks")
}

func hashConnectedAppSecretWrite(updates models.SObject) error {
	if updates == nil {
		return nil
	}
	raw, ok := updates[constants.ColSysConnectedApp_ConsumerSecret]
	if !ok {
		return nil
	}
	secret := normalizeConnectedAppString(raw)
	if secret == "" {
		return nil
	}
	hash, err := hashConnectedAppSecret(secret)
	if err != nil {
		return err
	}
	updates[constants.ColSysConnectedApp_ConsumerSecretHash] = hash
	updates[constants.ColSysConnectedApp_ConsumerSecret] = ""
	return nil
}

func hashConnectedAppSecretUpdate(final, effectiveUpdates models.SObject) error {
	if effectiveUpdates == nil {
		return nil
	}
	raw, ok := effectiveUpdates[constants.ColSysConnectedApp_ConsumerSecret]
	if !ok {
		return nil
	}
	secret := normalizeConnectedAppString(raw)
	if secret == "" {
		return nil
	}
	existingHash := normalizeConnectedAppString(final[constants.ColSysConnectedApp_ConsumerSecretHash])
	if existingHash != "" && passwordhash.Verify(secret, existingHash) {
		delete(effectiveUpdates, constants.ColSysConnectedApp_ConsumerSecret)
		delete(effectiveUpdates, constants.ColSysConnectedApp_ConsumerSecretHash)
		if final != nil {
			final[constants.ColSysConnectedApp_ConsumerSecretHash] = existingHash
			delete(final, constants.ColSysConnectedApp_ConsumerSecret)
		}
		return nil
	}
	hash, err := hashConnectedAppSecret(secret)
	if err != nil {
		return err
	}
	effectiveUpdates[constants.ColSysConnectedApp_ConsumerSecretHash] = hash
	effectiveUpdates[constants.ColSysConnectedApp_ConsumerSecret] = ""
	if final != nil {
		final[constants.ColSysConnectedApp_ConsumerSecretHash] = hash
		final[constants.ColSysConnectedApp_ConsumerSecret] = ""
	}
	return nil
}

func hashConnectedAppSecret(secret string) (string, error) {
	hash, err := passwordhash.Hash(secret)
	if err != nil {
		return "", appErrors.NewInternalError("failed to hash Connected App secret", err)
	}
	return hash, nil
}

func normalizeConnectedAppString(value interface{}) string {
	switch v := value.(type) {
	case nil:
		return ""
	case string:
		return strings.TrimSpace(v)
	case *string:
		if v == nil {
			return ""
		}
		return strings.TrimSpace(*v)
	default:
		return strings.TrimSpace(fmt.Sprint(v))
	}
}

func normalizeConnectedAppKeyword(value interface{}) string {
	return strings.ToLower(normalizeConnectedAppString(value))
}

func connectedAppBool(value interface{}) bool {
	switch v := value.(type) {
	case bool:
		return v
	case int:
		return v != 0
	case int64:
		return v != 0
	case float64:
		return v != 0
	case string:
		switch strings.ToLower(strings.TrimSpace(v)) {
		case "1", "true", "yes", "on":
			return true
		default:
			return false
		}
	default:
		return false
	}
}

func connectedAppInt(value interface{}) int {
	switch v := value.(type) {
	case int:
		return v
	case int64:
		return int(v)
	case float64:
		return int(v)
	case string:
		var parsed int
		if _, err := fmt.Sscanf(strings.TrimSpace(v), "%d", &parsed); err == nil {
			return parsed
		}
	}
	return 0
}

func connectedAppWordSet(value interface{}) map[string]struct{} {
	raw := normalizeConnectedAppKeyword(value)
	out := map[string]struct{}{}
	for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
		return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
	}) {
		if trimmed := strings.TrimSpace(part); trimmed != "" {
			out[trimmed] = struct{}{}
		}
	}
	return out
}

func connectedAppCanonicalWordSet(value interface{}) string {
	raw := normalizeConnectedAppKeyword(value)
	seen := map[string]struct{}{}
	words := []string{}
	for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
		return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
	}) {
		trimmed := strings.TrimSpace(part)
		if trimmed == "" {
			continue
		}
		if _, ok := seen[trimmed]; ok {
			continue
		}
		seen[trimmed] = struct{}{}
		words = append(words, trimmed)
	}
	return strings.Join(words, " ")
}
