package auth

import (
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/nexuscrm/shared/pkg/models"
)

const (
	OAuthClientTypePublic       = "public"
	OAuthClientTypeConfidential = "confidential"

	OAuthTokenAuthNone             = "none"
	OAuthTokenAuthClientSecretPost = "client_secret_post"

	OAuthGrantAuthorizationCode = "authorization_code"
	OAuthGrantPassword          = "password"
	OAuthGrantClientCredentials = "client_credentials"
	OAuthGrantRefreshToken      = "refresh_token"

	// OAuthRefreshTokenPrefix marks an opaque OAuth refresh token. Refresh
	// tokens are presented only to the token endpoint, never as a bearer, so
	// this prefix stays distinct from the PAT ("nxc_") and JWT bearer forms.
	OAuthRefreshTokenPrefix = "nxr_"

	OAuthSessionClassPlatformBrowser = "platform_browser"

	OAuthCodeChallengeMethodS256 = "S256"

	OAuthConsentPolicyPrompt           = "prompt"
	OAuthConsentPolicyTrusted          = "trusted"
	OAuthConsentPolicyAdminPreapproved = "admin_preapproved"

	publicOAuthPKCEEnabledEnv = "NEXUSCRM_PUBLIC_OAUTH_PKCE_ENABLED"
	publicOAuthIssuerEnv      = "API_BASE_URL"
)

// AuthorizationCodeRequest captures the request context that must be bound to
// the generated one-time authorization code.
type AuthorizationCodeRequest struct {
	ClientID            string
	RedirectURI         string
	Scope               string
	UserID              string
	CodeChallenge       string
	CodeChallengeMethod string
	Issuer              string
	// Resource is the exact protected resource selected at authorization time.
	// It is empty for ordinary OAuth flows and canonical for the MCP resource
	// flow; it must survive the browser login resume and code exchange intact.
	Resource string
}

// AuthorizeRequestCreateRequest captures the public authorize request that
// should survive a browser login round-trip before code issuance.
type AuthorizeRequestCreateRequest struct {
	ClientID            string
	RedirectURI         string
	ResponseType        string
	Scope               string
	State               string
	CodeChallenge       string
	CodeChallengeMethod string
	Issuer              string
	Resource            string
	// ResourcePresent distinguishes a missing resource parameter from an
	// explicitly supplied empty parameter. The latter must fail closed as an
	// OAuth invalid_target result instead of silently becoming an ordinary flow.
	ResourcePresent bool
}

// AuthorizeRequestCreateResult contains the opaque browser state needed to
// resume a public authorize request after interactive login.
type AuthorizeRequestCreateResult struct {
	Handle    string
	ExpiresAt time.Time
}

// AuthorizeRequestResumeRequest consumes a pre-login authorize request handle
// with a platform browser user.
type AuthorizeRequestResumeRequest struct {
	Handle string
	UserID string
}

// AuthorizeRequestResumeResult contains the data the REST handler needs to
// send the browser to the original OAuth client callback.
type AuthorizeRequestResumeResult struct {
	Code        string
	RedirectURI string
	State       string
}

// TokenExchangeRequest captures token endpoint parameters for auth-code
// exchange. Public clients use CodeVerifier and no client secret.
type TokenExchangeRequest struct {
	ClientID     string
	ClientSecret string
	Code         string
	RedirectURI  string
	CodeVerifier string
	Issuer       string
	// ResourcePresent distinguishes a missing parameter from a supplied empty
	// one. Resource-bound authorization codes require exactly one matching value.
	Resource        string
	ResourcePresent bool
}

// ClientCredentialsExchangeRequest contains the non-browser inputs needed by
// the durable Connected App owner to issue one run-as session. The client
// secret remains an in-process credential and is never copied into session
// state or a response.
type ClientCredentialsExchangeRequest struct {
	ClientID        string
	ClientSecret    string
	RequestedScope  string
	Issuer          string
	Resource        string
	ResourcePresent bool
	SourceAddress   string
	UserAgent       string
}

type ClientCredentialsExchangeResult struct {
	Token              *models.SessionToken
	UserID             string
	ConnectedAppID     string
	SessionIssuerClass string
}

type OAuthPolicyUseCase string

const (
	OAuthPolicyAuthorizeCodeIssue OAuthPolicyUseCase = "authorize_code_issue"
	OAuthPolicyAuthorizeCodeToken OAuthPolicyUseCase = "authorize_code_token"
	OAuthPolicyPasswordGrant      OAuthPolicyUseCase = "password"
	OAuthPolicyClientCredentials  OAuthPolicyUseCase = "client_credentials"
	OAuthPolicyRefreshToken       OAuthPolicyUseCase = "refresh_token"
)

// OAuthPolicyRequest is the single input shape for Connected App OAuth policy
// decisions across authorize and token grant entrypoints.
type OAuthPolicyRequest struct {
	UseCase             OAuthPolicyUseCase
	ClientID            string
	GrantType           string
	ResponseType        string
	RedirectURI         string
	RequestedScope      string
	CodeChallenge       string
	CodeChallengeMethod string
	PresentedSecret     string
	Issuer              string
}

// OAuthPolicyDecision is the resolved Connected App OAuth policy outcome for a
// request. Callers should use these derived fields instead of re-deriving app
// client type, grant, token-auth, issuer, or scope policy locally.
type OAuthPolicyDecision struct {
	App                            *models.SystemConnectedApp
	ClientType                     string
	TokenAuthMethod                string
	GrantType                      string
	RedirectURI                    string
	EffectiveScope                 string
	RequiresPKCE                   bool
	PublicIssuer                   string
	ConsentPolicy                  string
	CanIssueWithoutConsentRecord   bool
	SessionIssuerClass             string
	PresentedClientSecretValidated bool
}

// OAuthProtocolError carries a sanitized OAuth error for the REST boundary
// while preserving a reason category for audit/logging.
type OAuthProtocolError struct {
	Status          int
	Code            string
	SafeDescription string
	Reason          string
}

func (e *OAuthProtocolError) Error() string {
	if e == nil {
		return ""
	}
	if strings.TrimSpace(e.Reason) != "" {
		return e.Reason
	}
	return e.SafeDescription
}

func newOAuthProtocolError(status int, code, description, reason string) *OAuthProtocolError {
	if status == 0 {
		status = http.StatusBadRequest
	}
	return &OAuthProtocolError{
		Status:          status,
		Code:            code,
		SafeDescription: description,
		Reason:          reason,
	}
}

func connectedAppClientType(app *models.SystemConnectedApp) string {
	if app == nil || app.ClientType == nil || strings.TrimSpace(*app.ClientType) == "" {
		return OAuthClientTypeConfidential
	}
	return strings.ToLower(strings.TrimSpace(*app.ClientType))
}

func ConnectedAppClientType(app *models.SystemConnectedApp) string {
	return connectedAppClientType(app)
}

func connectedAppTokenAuthMethod(app *models.SystemConnectedApp) string {
	if app == nil || app.TokenEndpointAuthMethod == nil || strings.TrimSpace(*app.TokenEndpointAuthMethod) == "" {
		if connectedAppClientType(app) == OAuthClientTypePublic {
			return OAuthTokenAuthNone
		}
		return OAuthTokenAuthClientSecretPost
	}
	return strings.ToLower(strings.TrimSpace(*app.TokenEndpointAuthMethod))
}

func ConnectedAppTokenAuthMethod(app *models.SystemConnectedApp) string {
	return connectedAppTokenAuthMethod(app)
}

func connectedAppRequiresPKCE(app *models.SystemConnectedApp) bool {
	return connectedAppClientType(app) == OAuthClientTypePublic || (app != nil && app.RequirePkce)
}

func ConnectedAppRequiresPKCE(app *models.SystemConnectedApp) bool {
	return connectedAppRequiresPKCE(app)
}

func connectedAppAllowsGrant(app *models.SystemConnectedApp, grant string) bool {
	grant = strings.TrimSpace(grant)
	if grant == "" {
		return false
	}
	if app == nil {
		return grant == OAuthGrantAuthorizationCode
	}
	if app.AllowedGrantTypes == nil || strings.TrimSpace(*app.AllowedGrantTypes) == "" {
		return connectedAppClientType(app) != OAuthClientTypePublic && connectedAppAllowsLegacyConfidentialGrant(grant)
	}
	for _, allowed := range strings.FieldsFunc(*app.AllowedGrantTypes, func(r rune) bool {
		return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
	}) {
		if strings.TrimSpace(allowed) == grant {
			return true
		}
	}
	return false
}

func connectedAppAllowsLegacyConfidentialGrant(grant string) bool {
	switch grant {
	case OAuthGrantAuthorizationCode, OAuthGrantPassword, OAuthGrantClientCredentials:
		return true
	default:
		return false
	}
}

func ConnectedAppAllowsGrant(app *models.SystemConnectedApp, grant string) bool {
	return connectedAppAllowsGrant(app, grant)
}

func connectedAppSessionIssuerClass(app *models.SystemConnectedApp) string {
	if app == nil || app.SessionIssuerClass == nil {
		return ""
	}
	return strings.ToLower(strings.TrimSpace(*app.SessionIssuerClass))
}

func ConnectedAppSessionIssuerClass(app *models.SystemConnectedApp) string {
	return connectedAppSessionIssuerClass(app)
}

func connectedAppConsentPolicy(app *models.SystemConnectedApp) string {
	if app == nil || app.ConsentPolicy == nil || strings.TrimSpace(*app.ConsentPolicy) == "" {
		return OAuthConsentPolicyPrompt
	}
	return strings.ToLower(strings.TrimSpace(*app.ConsentPolicy))
}

func ConnectedAppConsentPolicy(app *models.SystemConnectedApp) string {
	return connectedAppConsentPolicy(app)
}

func connectedAppAllowsPublicIssueWithoutConsentRecord(app *models.SystemConnectedApp) bool {
	switch connectedAppConsentPolicy(app) {
	case OAuthConsentPolicyTrusted, OAuthConsentPolicyAdminPreapproved:
		return true
	default:
		return false
	}
}

func PublicOAuthPKCEEnabled() bool {
	switch strings.ToLower(strings.TrimSpace(os.Getenv(publicOAuthPKCEEnabledEnv))) {
	case "1", "true", "yes", "on":
		return true
	default:
		return false
	}
}

func publicOAuthCanonicalIssuerFromLookup(lookup func(string) string) (string, error) {
	if lookup == nil {
		lookup = os.Getenv
	}
	raw := strings.TrimSpace(lookup(publicOAuthIssuerEnv))
	if raw == "" {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "public OAuth requires a configured issuer", "public_oauth_issuer_missing")
	}
	u, err := url.Parse(raw)
	if err != nil || !u.IsAbs() || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "public OAuth issuer is invalid", "public_oauth_issuer_invalid")
	}
	switch u.Scheme {
	case "https":
	case "http":
		host := strings.ToLower(u.Hostname())
		if host != "localhost" && host != "127.0.0.1" && host != "::1" {
			return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "public OAuth issuer is invalid", "public_oauth_issuer_non_https")
		}
	default:
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "public OAuth issuer is invalid", "public_oauth_issuer_bad_scheme")
	}
	return strings.TrimRight(raw, "/"), nil
}

func PublicOAuthCanonicalIssuer() (string, error) {
	return publicOAuthCanonicalIssuerFromLookup(os.Getenv)
}

// CanonicalMCPResource returns the one deployment-owned MCP protected-resource
// identifier. It deliberately uses the public OAuth issuer's host-only origin,
// matching the request-issuer and discovery contracts, then fixes the endpoint
// path to /mcp. This keeps resource comparison independent of an untrusted
// request Host and of cosmetic API_BASE_URL path prefixes.
func CanonicalMCPResource() (string, error) {
	issuer, err := PublicOAuthCanonicalIssuer()
	if err != nil {
		return "", err
	}
	u, err := url.Parse(issuer)
	if err != nil || u.Host == "" {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "MCP resource is not configured", "mcp_resource_canonical_invalid")
	}
	return normalizeMCPResource(strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + "/mcp")
}

// NormalizeMCPResource accepts only the canonical MCP endpoint shape. It is
// intentionally strict: no credentials, fragment, query, encoded path, or
// path variation can be normalized into the configured resource by accident.
func NormalizeMCPResource(raw string) (string, error) {
	return normalizeMCPResource(raw)
}

func normalizeMCPResource(raw string) (string, error) {
	u, err := url.Parse(strings.TrimSpace(raw))
	if err != nil || !u.IsAbs() || u.Host == "" || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery || u.Opaque != "" {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "MCP resource is invalid", "mcp_resource_invalid")
	}
	if strings.ToLower(u.Scheme) != "https" && strings.ToLower(u.Scheme) != "http" {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "MCP resource is invalid", "mcp_resource_bad_scheme")
	}
	if u.Path != "/mcp" || u.RawPath != "" {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "MCP resource is invalid", "mcp_resource_path_invalid")
	}
	return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + "/mcp", nil
}

// ValidateMCPResourceRequest validates a resource parameter and returns the
// canonical stored value. A missing parameter means an ordinary OAuth flow;
// a present one must identify exactly the configured MCP resource.
func ValidateMCPResourceRequest(resource string, present bool) (string, error) {
	if !present {
		return "", nil
	}
	normalized, err := NormalizeMCPResource(resource)
	if err != nil {
		return "", err
	}
	canonical, err := CanonicalMCPResource()
	if err != nil {
		return "", err
	}
	if normalized != canonical {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "MCP resource does not match this deployment", "mcp_resource_mismatch")
	}
	return canonical, nil
}

// resolveDeploymentResource returns this deployment's resource identifier for
// token audience binding: the host-only origin (scheme://host[:port]) of the
// canonical OAuth issuer, or "" when unset/invalid. It is host-only to match the
// rest of the platform's OAuth identity model — the request-issuer check
// (resolveRequestOriginFromRequest) and the discovery documents
// (mcp.validateCanonicalOrigin) are both host-only and strip any path. A
// path-prefixed API_BASE_URL is therefore not a usable public-OAuth configuration
// at all (the flow rejects it with issuer_mismatch), so a host-only audience loses
// no separation that the platform actually supports, while staying consistent so
// the token aud equals the advertised/validated issuer origin.
func resolveDeploymentResource() string {
	issuer, err := PublicOAuthCanonicalIssuer()
	if err != nil {
		return ""
	}
	u, perr := url.Parse(issuer)
	if perr != nil || u.Host == "" {
		return ""
	}
	// The resource identity is the ORIGIN (scheme://host). Any path is dropped, not
	// rejected: API_BASE_URL may legitimately carry a path for callback/URL building
	// even where public OAuth is off, so rejecting path-prefixed values would break
	// valid deployments. The origin matches the platform's host-only OAuth issuer and
	// discovery identity; environments are separated by host (enforced for prod by
	// resourceIsSecurePublicOrigin), so dropping the path loses no supported
	// separation.
	return u.Scheme + "://" + u.Host
}

// resourceIsSecurePublicOrigin reports whether res is an https origin whose host is
// a PUBLIC DNS hostname. Production/staging require this so the deployment's token
// audience is a real, externally-distinct identity. It rejects, in one rule:
//   - non-https schemes;
//   - any IP-literal host (net.ParseIP), which covers loopback (127.0.0.0/8, ::1),
//     private (10/8, 172.16/12, 192.168/16), link-local, and unspecified addresses
//     — none unique across isolated deployments;
//   - "localhost" and shorthand/numeric hosts like "127.1" or single-label names
//     (no alphabetic TLD), which resolvers expand to loopback/private addresses.
//
// Guaranteeing GLOBAL uniqueness is partly operational: two deployments configured
// with the SAME public hostname would still collide, exactly as two deployments
// sharing a database would — distinct deployments must use distinct hostnames. This
// guard enforces the code-checkable part (a public, non-ambiguous origin).
func resourceIsSecurePublicOrigin(res string) bool {
	u, err := url.Parse(res)
	if err != nil || u.Scheme != "https" || u.Host == "" {
		return false
	}
	host := u.Hostname()
	if host == "" || strings.EqualFold(host, "localhost") {
		return false
	}
	if net.ParseIP(host) != nil {
		return false // IP-literal host (loopback/private/link-local/public alike): require DNS
	}
	labels := strings.Split(host, ".")
	if len(labels) < 2 || !hasLetter(labels[len(labels)-1]) {
		return false // require a dotted DNS name with an alphabetic TLD (rejects "127.1", "internal")
	}
	return true
}

func hasLetter(s string) bool {
	for _, r := range s {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
			return true
		}
	}
	return false
}

func normalizeScopeSet(scope string) []string {
	seen := map[string]bool{}
	for _, raw := range strings.Fields(scope) {
		s := strings.TrimSpace(raw)
		if s != "" {
			seen[s] = true
		}
	}
	out := make([]string, 0, len(seen))
	for s := range seen {
		out = append(out, s)
	}
	sort.Strings(out)
	return out
}

func scopeSetContains(set []string, value string) bool {
	for _, s := range set {
		if s == value {
			return true
		}
	}
	return false
}

// PublicScopePolicyProvider is implemented by metadata-backed scope registries
// that can explicitly allow scope issuance to public OAuth clients.
type PublicScopePolicyProvider interface {
	AllowsPublicOAuthScope(scope string) bool
}

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

func computePublicEffectiveScope(app *models.SystemConnectedApp, requestedScopes string, provider PublicScopePolicyProvider) (string, error) {
	appScopesRaw := ""
	if app != nil && app.Scopes != nil {
		appScopesRaw = *app.Scopes
	}
	sourcePackName := connectedAppSourcePackName(app)
	appScopes := normalizeScopeSet(appScopesRaw)
	if len(appScopes) == 0 {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_scope", "invalid scope", "public_app_empty_allowed_scope")
	}
	for _, s := range appScopes {
		if !publicScopePolicyAllows(provider, s, sourcePackName) {
			return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_scope", "invalid scope", "public_app_unsupported_allowed_scope:"+s)
		}
	}
	requested := normalizeScopeSet(requestedScopes)
	if len(requested) == 0 {
		// An omitted request is unambiguous only for a single-scope public client.
		if len(appScopes) == 1 {
			return appScopes[0], nil
		}
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_scope", "invalid scope", "public_request_empty_scope")
	}
	for _, s := range requested {
		if !publicScopePolicyAllows(provider, s, sourcePackName) || !scopeSetContains(appScopes, s) {
			return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_scope", "invalid scope", "public_request_unsupported_scope:"+s)
		}
	}
	return strings.Join(requested, " "), nil
}

func connectedAppSourcePackName(app *models.SystemConnectedApp) string {
	if app == nil || app.SourcePackName == nil {
		return ""
	}
	return strings.TrimSpace(*app.SourcePackName)
}

func publicScopePolicyAllows(provider PublicScopePolicyProvider, scope, packName string) bool {
	if provider == nil {
		return false
	}
	packName = strings.TrimSpace(packName)
	if packName == "" {
		return provider.AllowsPublicOAuthScope(scope)
	}
	packProvider, ok := provider.(PackPublicScopePolicyProvider)
	return ok && packProvider.AllowsPublicOAuthScopeForPack(scope, packName)
}

func ComputePublicEffectiveScope(app *models.SystemConnectedApp, requestedScopes string) (string, error) {
	return computePublicEffectiveScope(app, requestedScopes, nil)
}

func validatePKCEChallenge(method, challenge string) error {
	if method != OAuthCodeChallengeMethodS256 {
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "invalid PKCE challenge", "pkce_method_not_s256")
	}
	if !validPKCEString(challenge) {
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "invalid PKCE challenge", "pkce_challenge_malformed")
	}
	return nil
}

func ValidatePKCEChallenge(method, challenge string) error {
	return validatePKCEChallenge(method, challenge)
}

func verifyPKCEVerifier(verifier, challenge string) error {
	if !validPKCEString(verifier) {
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "pkce_verifier_malformed")
	}
	sum := sha256.Sum256([]byte(verifier))
	expected := base64.RawURLEncoding.EncodeToString(sum[:])
	if len(expected) != len(challenge) || subtle.ConstantTimeCompare([]byte(expected), []byte(challenge)) != 1 {
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "pkce_verifier_mismatch")
	}
	return nil
}

func VerifyPKCEVerifier(verifier, challenge string) error {
	return verifyPKCEVerifier(verifier, challenge)
}

// hashRefreshToken derives the stored, unique lookup key for a plaintext OAuth
// refresh token. Deterministic (sha256) so validation finds the row by hash;
// the plaintext is never persisted.
func hashRefreshToken(token string) string {
	sum := sha256.Sum256([]byte(token))
	return base64.RawURLEncoding.EncodeToString(sum[:])
}

func validPKCEString(value string) bool {
	if len(value) < 43 || len(value) > 128 {
		return false
	}
	for _, r := range value {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
			continue
		}
		switch r {
		case '-', '.', '_', '~':
			continue
		default:
			return false
		}
	}
	return true
}

func ValidPKCEString(value string) bool {
	return validPKCEString(value)
}

func registeredRedirectURIs(app *models.SystemConnectedApp) []string {
	if app == nil {
		return nil
	}
	if app.RedirectURIs != nil && strings.TrimSpace(*app.RedirectURIs) != "" {
		raw := strings.TrimSpace(*app.RedirectURIs)
		var parsed []string
		if strings.HasPrefix(raw, "[") && json.Unmarshal([]byte(raw), &parsed) == nil {
			return parsed
		}
		return strings.FieldsFunc(raw, func(r rune) bool {
			return r == ',' || r == '\n' || r == '\r' || r == '\t'
		})
	}
	if app.CallbackURL != nil && strings.TrimSpace(*app.CallbackURL) != "" {
		return []string{*app.CallbackURL}
	}
	return nil
}

func RegisteredRedirectURIs(app *models.SystemConnectedApp) []string {
	return registeredRedirectURIs(app)
}

func validateRedirectURIShape(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" || host == "[::1]" {
			return nil
		}
	}
	return fmt.Errorf("invalid redirect_uri")
}

func ValidateRedirectURIShape(raw string) error {
	return validateRedirectURIShape(raw)
}

func validateRegisteredRedirectURI(app *models.SystemConnectedApp, redirectURI string) error {
	if err := validateRedirectURIShape(redirectURI); err != nil {
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "invalid redirect_uri", "redirect_uri_invalid_shape")
	}
	for _, registered := range registeredRedirectURIs(app) {
		registered = strings.TrimSpace(registered)
		if registered == "" {
			continue
		}
		if err := validateRedirectURIShape(registered); err != nil {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "invalid redirect_uri", "registered_redirect_uri_invalid_shape")
		}
		if registered == redirectURI {
			return nil
		}
	}
	return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "invalid redirect_uri", "redirect_uri_mismatch")
}

func ValidateRegisteredRedirectURI(app *models.SystemConnectedApp, redirectURI string) error {
	return validateRegisteredRedirectURI(app, redirectURI)
}
