package api

import (
	"context"
	"errors"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/fatih/structs"
	"github.com/sethvargo/go-password/password"
	"github.com/supabase/auth/internal/api/provider"
	"github.com/supabase/auth/internal/api/sms_provider"
	"github.com/supabase/auth/internal/crypto"
	mail "github.com/supabase/auth/internal/mailer"
	"github.com/supabase/auth/internal/models"
	"github.com/supabase/auth/internal/observability"
	"github.com/supabase/auth/internal/storage"
	"github.com/supabase/auth/internal/utilities"
)

const (
	smsVerification         = "sms"
	phoneChangeVerification = "phone_change"
	// includes signupVerification and magicLinkVerification
)

const (
	zeroConfirmation int = iota
	singleConfirmation
)

// Only applicable when SECURE_EMAIL_CHANGE_ENABLED
const singleConfirmationAccepted = "Confirmation link accepted. Please proceed to confirm link sent to the other email"

// VerifyParams are the parameters the Verify endpoint accepts
type VerifyParams struct {
	Type       string `json:"type"`
	Token      string `json:"token"`
	TokenHash  string `json:"token_hash"`
	Email      string `json:"email"`
	Phone      string `json:"phone"`
	RedirectTo string `json:"redirect_to"`
}

func (p *VerifyParams) Validate(r *http.Request, a *API) error {
	var err error
	if p.Type == "" {
		return badRequestError(ErrorCodeValidationFailed, "Verify requires a verification type")
	}
	switch r.Method {
	case http.MethodGet:
		if p.Token == "" {
			return badRequestError(ErrorCodeValidationFailed, "Verify requires a token or a token hash")
		}
		// TODO: deprecate the token query param from GET /verify and use token_hash instead (breaking change)
		p.TokenHash = p.Token
	case http.MethodPost:
		if (p.Token == "" && p.TokenHash == "") || (p.Token != "" && p.TokenHash != "") {
			return badRequestError(ErrorCodeValidationFailed, "Verify requires either a token or a token hash")
		}
		if p.Token != "" {
			if isPhoneOtpVerification(p) {
				p.Phone, err = validatePhone(p.Phone)
				if err != nil {
					return err
				}
				p.TokenHash = crypto.GenerateTokenHash(p.Phone, p.Token)
			} else if isEmailOtpVerification(p) {
				p.Email, err = a.validateEmail(p.Email)
				if err != nil {
					return unprocessableEntityError(ErrorCodeValidationFailed, "Invalid email format").WithInternalError(err)
				}
				p.TokenHash = crypto.GenerateTokenHash(p.Email, p.Token)
			} else {
				return badRequestError(ErrorCodeValidationFailed, "Only an email address or phone number should be provided on verify")
			}
		} else if p.TokenHash != "" {
			if p.Email != "" || p.Phone != "" || p.RedirectTo != "" {
				return badRequestError(ErrorCodeValidationFailed, "Only the token_hash and type should be provided")
			}
		}
	default:
		return nil
	}
	return nil
}

// Verify exchanges a confirmation or recovery token to a refresh token
func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
	params := &VerifyParams{}
	switch r.Method {
	case http.MethodGet:
		params.Token = r.FormValue("token")
		params.Type = r.FormValue("type")
		params.RedirectTo = utilities.GetReferrer(r, a.config)
		if err := params.Validate(r, a); err != nil {
			return err
		}
		return a.verifyGet(w, r, params)
	case http.MethodPost:
		if err := retrieveRequestParams(r, params); err != nil {
			return err
		}
		if err := params.Validate(r, a); err != nil {
			return err
		}
		return a.verifyPost(w, r, params)
	default:
		// this should have been handled by Chi
		panic("Only GET and POST methods allowed")
	}
}

func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyParams) error {
	ctx := r.Context()
	db := a.db.WithContext(ctx)

	var (
		user        *models.User
		grantParams models.GrantParams
		err         error
		token       *AccessTokenResponse
		authCode    string
		rurl        string
	)

	grantParams.FillGrantParams(r)

	flowType := models.ImplicitFlow
	var authenticationMethod models.AuthenticationMethod
	if strings.HasPrefix(params.Token, PKCEPrefix) {
		flowType = models.PKCEFlow
		authenticationMethod, err = models.ParseAuthenticationMethod(params.Type)
		if err != nil {
			return err
		}
	}

	err = db.Transaction(func(tx *storage.Connection) error {
		var terr error
		user, terr = a.verifyTokenHash(tx, params)
		if terr != nil {
			return terr
		}
		switch params.Type {
		case mail.SignupVerification, mail.InviteVerification:
			user, terr = a.signupVerify(r, ctx, tx, user)
		case mail.RecoveryVerification, mail.MagicLinkVerification:
			user, terr = a.recoverVerify(r, tx, user)
		case mail.EmailChangeVerification:
			user, terr = a.emailChangeVerify(r, tx, params, user)
			if user == nil && terr == nil {
				// only one OTP is confirmed at this point, so we return early and ask the user to confirm the second OTP
				rurl, terr = a.prepRedirectURL(singleConfirmationAccepted, params.RedirectTo, flowType)
				if terr != nil {
					return terr
				}
				return nil
			}
		default:
			return badRequestError(ErrorCodeValidationFailed, "Unsupported verification type")
		}

		if terr != nil {
			return terr
		}

		if terr := user.UpdateAppMetaDataProviders(tx); terr != nil {
			return terr
		}

		// Reload user model from db.
		// This is important for refreshing the data in any generated columns like IsAnonymous.
		if terr := tx.Reload(user); err != nil {
			return terr
		}

		if isImplicitFlow(flowType) {
			token, terr = a.issueRefreshToken(r, tx, user, models.OTP, grantParams)
			if terr != nil {
				return terr
			}

		} else if isPKCEFlow(flowType) {
			if authCode, terr = issueAuthCode(tx, user, authenticationMethod); terr != nil {
				return badRequestError(ErrorCodeFlowStateNotFound, "No associated flow state found. %s", terr)
			}
		}
		return nil
	})

	if err != nil {
		var herr *HTTPError
		if errors.As(err, &herr) {
			rurl, err = a.prepErrorRedirectURL(herr, r, params.RedirectTo, flowType)
			if err != nil {
				return err
			}
		}
	}
	if rurl != "" {
		http.Redirect(w, r, rurl, http.StatusSeeOther)
		return nil
	}
	rurl = params.RedirectTo
	if isImplicitFlow(flowType) && token != nil {
		q := url.Values{}
		q.Set("type", params.Type)
		rurl = token.AsRedirectURL(rurl, q)
	} else if isPKCEFlow(flowType) {
		rurl, err = a.prepPKCERedirectURL(rurl, authCode)
		if err != nil {
			return err
		}
	}
	http.Redirect(w, r, rurl, http.StatusSeeOther)
	return nil
}

func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyParams) error {
	ctx := r.Context()
	db := a.db.WithContext(ctx)

	var (
		user        *models.User
		grantParams models.GrantParams
		token       *AccessTokenResponse
	)
	var isSingleConfirmationResponse = false

	grantParams.FillGrantParams(r)

	err := db.Transaction(func(tx *storage.Connection) error {
		var terr error
		aud := a.requestAud(ctx, r)

		if isUsingTokenHash(params) {
			user, terr = a.verifyTokenHash(tx, params)
		} else {
			user, terr = a.verifyUserAndToken(tx, params, aud)
		}
		if terr != nil {
			return terr
		}

		switch params.Type {
		case mail.SignupVerification, mail.InviteVerification:
			user, terr = a.signupVerify(r, ctx, tx, user)
		case mail.RecoveryVerification, mail.MagicLinkVerification:
			user, terr = a.recoverVerify(r, tx, user)
		case mail.EmailChangeVerification:
			user, terr = a.emailChangeVerify(r, tx, params, user)
			if user == nil && terr == nil {
				isSingleConfirmationResponse = true
				return nil
			}
		case smsVerification, phoneChangeVerification:
			user, terr = a.smsVerify(r, tx, user, params)
		default:
			return badRequestError(ErrorCodeValidationFailed, "Unsupported verification type")
		}

		if terr != nil {
			return terr
		}

		if terr := user.UpdateAppMetaDataProviders(tx); terr != nil {
			return terr
		}

		// Reload user model from db.
		// This is important for refreshing the data in any generated columns like IsAnonymous.
		if terr := tx.Reload(user); terr != nil {
			return terr
		}
		token, terr = a.issueRefreshToken(r, tx, user, models.OTP, grantParams)
		if terr != nil {
			return terr
		}
		return nil
	})
	if err != nil {
		return err
	}
	if isSingleConfirmationResponse {
		return sendJSON(w, http.StatusOK, map[string]string{
			"msg":  singleConfirmationAccepted,
			"code": strconv.Itoa(http.StatusOK),
		})
	}
	return sendJSON(w, http.StatusOK, token)
}

func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) {
	config := a.config

	shouldUpdatePassword := false
	if !user.HasPassword() && user.InvitedAt != nil {
		// sign them up with temporary password, and require application
		// to present the user with a password set form
		password, err := password.Generate(64, 10, 0, false, true)
		if err != nil {
			// password generation must succeed
			panic(err)
		}

		if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil {
			return nil, err
		}
		shouldUpdatePassword = true
	}

	err := conn.Transaction(func(tx *storage.Connection) error {
		var terr error
		if shouldUpdatePassword {
			if terr = user.UpdatePassword(tx, nil); terr != nil {
				return internalServerError("Error storing password").WithInternalError(terr)
			}
		}

		if terr = models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", nil); terr != nil {
			return terr
		}

		if terr = user.Confirm(tx); terr != nil {
			return internalServerError("Error confirming user").WithInternalError(terr)
		}

		for _, identity := range user.Identities {
			if identity.Email == "" || user.Email == "" || identity.Email != user.Email {
				continue
			}

			if terr = identity.UpdateIdentityData(tx, map[string]interface{}{
				"email_verified": true,
			}); terr != nil {
				return internalServerError("Error setting email_verified to true on identity").WithInternalError(terr)
			}
		}

		return nil
	})
	if err != nil {
		return nil, err
	}
	return user, nil
}

func (a *API) recoverVerify(r *http.Request, conn *storage.Connection, user *models.User) (*models.User, error) {
	err := conn.Transaction(func(tx *storage.Connection) error {
		var terr error
		if terr = user.Recover(tx); terr != nil {
			return terr
		}
		if !user.IsConfirmed() {
			if terr = models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", nil); terr != nil {
				return terr
			}

			if terr = user.Confirm(tx); terr != nil {
				return terr
			}
		} else {
			if terr = models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", nil); terr != nil {
				return terr
			}
		}
		return nil
	})

	if err != nil {
		return nil, internalServerError("Database error updating user").WithInternalError(err)
	}
	return user, nil
}

func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.User, params *VerifyParams) (*models.User, error) {

	err := conn.Transaction(func(tx *storage.Connection) error {

		if params.Type == smsVerification {
			if terr := models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", nil); terr != nil {
				return terr
			}
			if terr := user.ConfirmPhone(tx); terr != nil {
				return internalServerError("Error confirming user").WithInternalError(terr)
			}
		} else if params.Type == phoneChangeVerification {
			if terr := models.NewAuditLogEntry(r, tx, user, models.UserModifiedAction, "", nil); terr != nil {
				return terr
			}
			if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "phone"); terr != nil {
				if !models.IsNotFoundError(terr) {
					return terr
				}
				// confirming the phone change should create a new phone identity if the user doesn't have one
				if _, terr = a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{
					Subject:       user.ID.String(),
					Phone:         params.Phone,
					PhoneVerified: true,
				})); terr != nil {
					return terr
				}
			} else {
				if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
					"phone":          params.Phone,
					"phone_verified": true,
				}); terr != nil {
					return terr
				}
			}
			if terr := user.ConfirmPhoneChange(tx); terr != nil {
				return internalServerError("Error confirming user").WithInternalError(terr)
			}
		}

		if user.IsAnonymous {
			user.IsAnonymous = false
			if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil {
				return terr
			}
		}

		if terr := tx.Load(user, "Identities"); terr != nil {
			return internalServerError("Error refetching identities").WithInternalError(terr)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	return user, nil
}

func (a *API) prepErrorRedirectURL(err *HTTPError, r *http.Request, rurl string, flowType models.FlowType) (string, error) {
	u, perr := url.Parse(rurl)
	if perr != nil {
		return "", err
	}
	q := u.Query()

	// Maintain separate query params for hash and query
	hq := url.Values{}
	log := observability.GetLogEntry(r).Entry
	errorID := utilities.GetRequestID(r.Context())
	err.ErrorID = errorID
	log.WithError(err.Cause()).Info(err.Error())
	if str, ok := oauthErrorMap[err.HTTPStatus]; ok {
		hq.Set("error", str)
		q.Set("error", str)
	}
	hq.Set("error_code", err.ErrorCode)
	hq.Set("error_description", err.Message)

	q.Set("error_code", err.ErrorCode)
	q.Set("error_description", err.Message)
	if flowType == models.PKCEFlow {
		// Additionally, may override existing error query param if set to PKCE.
		u.RawQuery = q.Encode()
	}
	// Left as hash fragment to comply with spec.
	u.Fragment = hq.Encode()
	return u.String(), nil
}

func (a *API) prepRedirectURL(message string, rurl string, flowType models.FlowType) (string, error) {
	u, perr := url.Parse(rurl)
	if perr != nil {
		return "", perr
	}
	hq := url.Values{}
	q := u.Query()
	hq.Set("message", message)
	if flowType == models.PKCEFlow {
		q.Set("message", message)
	}
	u.RawQuery = q.Encode()
	u.Fragment = hq.Encode()
	return u.String(), nil
}

func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) {
	u, err := url.Parse(rurl)
	if err != nil {
		return "", err
	}
	q := u.Query()
	q.Set("code", code)
	u.RawQuery = q.Encode()
	return u.String(), nil
}

func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, params *VerifyParams, user *models.User) (*models.User, error) {
	config := a.config
	if !config.Mailer.Autoconfirm &&
		config.Mailer.SecureEmailChangeEnabled &&
		user.EmailChangeConfirmStatus == zeroConfirmation &&
		user.GetEmail() != "" {
		err := conn.Transaction(func(tx *storage.Connection) error {
			currentOTT, terr := models.FindOneTimeToken(tx, params.TokenHash, models.EmailChangeTokenCurrent)
			if terr != nil && !models.IsNotFoundError(terr) {
				return terr
			}

			newOTT, terr := models.FindOneTimeToken(tx, params.TokenHash, models.EmailChangeTokenNew)
			if terr != nil && !models.IsNotFoundError(terr) {
				return terr
			}

			user.EmailChangeConfirmStatus = singleConfirmation

			if params.Token == user.EmailChangeTokenCurrent || params.TokenHash == user.EmailChangeTokenCurrent || (currentOTT != nil && params.TokenHash == currentOTT.TokenHash) {
				user.EmailChangeTokenCurrent = ""
				if terr := models.ClearOneTimeTokenForUser(tx, user.ID, models.EmailChangeTokenCurrent); terr != nil {
					return terr
				}
			} else if params.Token == user.EmailChangeTokenNew || params.TokenHash == user.EmailChangeTokenNew || (newOTT != nil && params.TokenHash == newOTT.TokenHash) {
				user.EmailChangeTokenNew = ""
				if terr := models.ClearOneTimeTokenForUser(tx, user.ID, models.EmailChangeTokenNew); terr != nil {
					return terr
				}
			}
			if terr := tx.UpdateOnly(user, "email_change_confirm_status", "email_change_token_current", "email_change_token_new"); terr != nil {
				return terr
			}
			return nil
		})
		if err != nil {
			return nil, err
		}
		return nil, nil
	}

	// one email is confirmed at this point if GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED is enabled
	err := conn.Transaction(func(tx *storage.Connection) error {
		if terr := models.NewAuditLogEntry(r, tx, user, models.UserModifiedAction, "", nil); terr != nil {
			return terr
		}

		if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email"); terr != nil {
			if !models.IsNotFoundError(terr) {
				return terr
			}
			// confirming the email change should create a new email identity if the user doesn't have one
			if _, terr = a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{
				Subject:       user.ID.String(),
				Email:         user.EmailChange,
				EmailVerified: true,
			})); terr != nil {
				return terr
			}
		} else {
			if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
				"email":          user.EmailChange,
				"email_verified": true,
			}); terr != nil {
				return terr
			}
		}
		if user.IsAnonymous {
			user.IsAnonymous = false
			if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil {
				return terr
			}
		}
		if terr := tx.Load(user, "Identities"); terr != nil {
			return internalServerError("Error refetching identities").WithInternalError(terr)
		}
		if terr := user.ConfirmEmailChange(tx, zeroConfirmation); terr != nil {
			return internalServerError("Error confirm email").WithInternalError(terr)
		}

		return nil
	})
	if err != nil {
		return nil, err
	}

	return user, nil
}

func (a *API) verifyTokenHash(conn *storage.Connection, params *VerifyParams) (*models.User, error) {
	config := a.config

	var user *models.User
	var err error
	switch params.Type {
	case mail.EmailOTPVerification:
		// need to find user by confirmation token or recovery token with the token hash
		user, err = models.FindUserByConfirmationOrRecoveryToken(conn, params.TokenHash)
	case mail.SignupVerification, mail.InviteVerification:
		user, err = models.FindUserByConfirmationToken(conn, params.TokenHash)
	case mail.RecoveryVerification, mail.MagicLinkVerification:
		user, err = models.FindUserByRecoveryToken(conn, params.TokenHash)
	case mail.EmailChangeVerification:
		user, err = models.FindUserByEmailChangeToken(conn, params.TokenHash)
	default:
		return nil, badRequestError(ErrorCodeValidationFailed, "Invalid email verification type")
	}

	if err != nil {
		if models.IsNotFoundError(err) {
			return nil, forbiddenError(ErrorCodeOTPExpired, "Email link is invalid or has expired").WithInternalError(err)
		}
		return nil, internalServerError("Database error finding user from email link").WithInternalError(err)
	}

	if user.IsBanned() {
		return nil, forbiddenError(ErrorCodeUserBanned, "User is banned")
	}

	var isExpired bool
	switch params.Type {
	case mail.EmailOTPVerification:
		sentAt := user.ConfirmationSentAt
		params.Type = "signup"
		if user.RecoveryToken == params.TokenHash {
			sentAt = user.RecoverySentAt
			params.Type = "magiclink"
		}
		isExpired = isOtpExpired(sentAt, config.Mailer.OtpExp)
	case mail.SignupVerification, mail.InviteVerification:
		isExpired = isOtpExpired(user.ConfirmationSentAt, config.Mailer.OtpExp)
	case mail.RecoveryVerification, mail.MagicLinkVerification:
		isExpired = isOtpExpired(user.RecoverySentAt, config.Mailer.OtpExp)
	case mail.EmailChangeVerification:
		isExpired = isOtpExpired(user.EmailChangeSentAt, config.Mailer.OtpExp)
	}

	if isExpired {
		return nil, forbiddenError(ErrorCodeOTPExpired, "Email link is invalid or has expired").WithInternalMessage("email link has expired")
	}

	return user, nil
}

// verifyUserAndToken verifies the token associated to the user based on the verify type
func (a *API) verifyUserAndToken(conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {
	config := a.config

	var user *models.User
	var err error
	tokenHash := params.TokenHash

	switch params.Type {
	case phoneChangeVerification:
		user, err = models.FindUserByPhoneChangeAndAudience(conn, params.Phone, aud)
	case smsVerification:
		user, err = models.FindUserByPhoneAndAudience(conn, params.Phone, aud)
	case mail.EmailChangeVerification:
		// Since the email change could be trigger via the implicit or PKCE flow,
		// the query used has to also check if the token saved in the db contains the pkce_ prefix
		user, err = models.FindUserForEmailChange(conn, params.Email, tokenHash, aud, config.Mailer.SecureEmailChangeEnabled)
	default:
		user, err = models.FindUserByEmailAndAudience(conn, params.Email, aud)
	}

	if err != nil {
		if models.IsNotFoundError(err) {
			return nil, forbiddenError(ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalError(err)
		}
		return nil, internalServerError("Database error finding user").WithInternalError(err)
	}

	if user.IsBanned() {
		return nil, forbiddenError(ErrorCodeUserBanned, "User is banned")
	}

	var isValid bool

	smsProvider, _ := sms_provider.GetSmsProvider(*config)
	switch params.Type {
	case mail.EmailOTPVerification:
		// if the type is emailOTPVerification, we'll check both the confirmation_token and recovery_token columns
		if isOtpValid(tokenHash, user.ConfirmationToken, user.ConfirmationSentAt, config.Mailer.OtpExp) {
			isValid = true
			params.Type = mail.SignupVerification
		} else if isOtpValid(tokenHash, user.RecoveryToken, user.RecoverySentAt, config.Mailer.OtpExp) {
			isValid = true
			params.Type = mail.MagicLinkVerification
		} else {
			isValid = false
		}
	case mail.SignupVerification, mail.InviteVerification:
		isValid = isOtpValid(tokenHash, user.ConfirmationToken, user.ConfirmationSentAt, config.Mailer.OtpExp)
	case mail.RecoveryVerification, mail.MagicLinkVerification:
		isValid = isOtpValid(tokenHash, user.RecoveryToken, user.RecoverySentAt, config.Mailer.OtpExp)
	case mail.EmailChangeVerification:
		isValid = isOtpValid(tokenHash, user.EmailChangeTokenCurrent, user.EmailChangeSentAt, config.Mailer.OtpExp) ||
			isOtpValid(tokenHash, user.EmailChangeTokenNew, user.EmailChangeSentAt, config.Mailer.OtpExp)
	case phoneChangeVerification, smsVerification:
		if testOTP, ok := config.Sms.GetTestOTP(params.Phone, time.Now()); ok {
			if params.Token == testOTP {
				return user, nil
			}
		}

		phone := params.Phone
		sentAt := user.ConfirmationSentAt
		expectedToken := user.ConfirmationToken
		if params.Type == phoneChangeVerification {
			phone = user.PhoneChange
			sentAt = user.PhoneChangeSentAt
			expectedToken = user.PhoneChangeToken
		}

		if !config.Hook.SendSMS.Enabled && config.Sms.IsTwilioVerifyProvider() {
			if err := smsProvider.(*sms_provider.TwilioVerifyProvider).VerifyOTP(phone, params.Token); err != nil {
				return nil, forbiddenError(ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalError(err)
			}
			return user, nil
		}
		isValid = isOtpValid(tokenHash, expectedToken, sentAt, config.Sms.OtpExp)
	}

	if !isValid {
		return nil, forbiddenError(ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalMessage("token has expired or is invalid")
	}
	return user, nil
}

// isOtpValid checks the actual otp sent against the expected otp and ensures that it's within the valid window
func isOtpValid(actual, expected string, sentAt *time.Time, otpExp uint) bool {
	if expected == "" || sentAt == nil {
		return false
	}
	return !isOtpExpired(sentAt, otpExp) && ((actual == expected) || ("pkce_"+actual == expected))
}

func isOtpExpired(sentAt *time.Time, otpExp uint) bool {
	return time.Now().After(sentAt.Add(time.Second * time.Duration(otpExp))) // #nosec G115
}

// isPhoneOtpVerification checks if the verification came from a phone otp
func isPhoneOtpVerification(params *VerifyParams) bool {
	return params.Phone != "" && params.Email == ""
}

// isEmailOtpVerification checks if the verification came from an email otp
func isEmailOtpVerification(params *VerifyParams) bool {
	return params.Phone == "" && params.Email != ""
}

func isUsingTokenHash(params *VerifyParams) bool {
	return params.TokenHash != "" && params.Token == "" && params.Phone == "" && params.Email == ""
}
