package services

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"os"
	"regexp"
	"strings"
	"time"

	configs "astrology-api/configs"
	dto "astrology-api/dto"
	"astrology-api/helpers"
	usermodel "astrology-api/models/usermodel"
	"astrology-api/repositories"

	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

// Two endpoints, one per client kind: /social/web-login for the web app and
// /social/mobile-login for the mobile app, which serves Android and iOS alike.
// One endpoint covers both mobile platforms because a Firebase ID token is
// verified identically whatever minted it — the token names the project, never
// the app — so splitting them would duplicate a handler to no purpose.
//
// What the platforms are for is telling the clients apart afterwards, in login
// history and reporting. The endpoint sets the default, and a mobile client that
// wants Android and iOS distinguished says so in "platform".
const (
	PlatformMobile = "mobile"

	PlatformWeb = "web"

	PlatformAndroid = "android"

	PlatformIOS = "ios"
)

// Platform values a client may declare, mapped onto what gets stored. Anything
// else falls back to the endpoint's own default rather than being rejected: a
// mislabelled platform is not a reason to refuse someone a login.
var socialPlatforms = map[string]string{
	"web":     PlatformWeb,
	"android": PlatformAndroid,
	"ios":     PlatformIOS,
	"iphone":  PlatformIOS,
	"ipad":    PlatformIOS,
	"mobile":  PlatformMobile,
}

// resolvePlatform picks what to record: the client's declaration when it is one
// we know, the endpoint's default otherwise.
func resolvePlatform(declared string, fallback string) string {

	if platform, found := socialPlatforms[strings.ToLower(strings.TrimSpace(declared))]; found {
		return platform
	}

	return fallback
}

//////////////////////////////////////////////////////////////
// Verification Steps
//////////////////////////////////////////////////////////////

// A social account is only given a session once it has a verified mobile number
// AND a verified email address. Neither is optional: the mobile is how support
// and notifications reach someone, and the email is the account's recovery path,
// so an account missing either is one nobody can help later.
//
// Which steps are outstanding depends on the provider. Google vouches for the
// email, so only the mobile is left. Facebook releases an address only when the
// user granted the email permission and the account has one — an account created
// with a phone number has none — so both steps are often outstanding.
const (
	StepMobileOTP = "mobile_otp"

	StepEmailOTP = "email_otp"
)

// needsMobileVerification and needsEmailVerification read the account, not the
// request: a returning user has already done whichever step they did last time.
func needsMobileVerification(user *usermodel.User) bool {

	return !user.MobileVerified || strings.TrimSpace(user.ContactNo) == ""
}

func needsEmailVerification(user *usermodel.User) bool {

	return !user.EmailVerified || strings.TrimSpace(user.Email) == ""
}

// nextSocialStep is the one thing the client should do next, or "" when the
// account is complete and a session can be issued.
func nextSocialStep(user *usermodel.User) string {

	if needsMobileVerification(user) {
		return StepMobileOTP
	}

	if needsEmailVerification(user) {
		return StepEmailOTP
	}

	return ""
}

// pendingSocialResponse answers a half-finished login: no session, a short-lived
// verification token, and what to do with it.
func pendingSocialResponse(
	user *usermodel.User,
	provider string,
	platform string,
	isNewUser bool,
	step string,
) (*dto.SocialLoginResponse, error) {

	if err := repositories.UpdateUser(user); err != nil {
		return nil, err
	}

	verificationToken, err := GenerateMobileVerificationToken(user.ID)

	if err != nil {
		return nil, err
	}

	message := "Mobile number verification required"

	if step == StepEmailOTP {
		message = "Email verification required"
	}

	return &dto.SocialLoginResponse{
		Success:                    true,
		Message:                    message,
		MobileVerificationRequired: step == StepMobileOTP,
		EmailVerificationRequired:  needsEmailVerification(user),
		NextStep:                   step,
		VerificationToken:          verificationToken,
		IsNewUser:                  isNewUser,
		Provider:                   provider,
		Platform:                   platform,
		User:                       buildSocialUserResponse(user),
	}, nil
}

// socialToken picks whichever credential the client sent. Facebook logins carry
// an opaque access token; the other providers carry a JWT.
func socialToken(req dto.SocialLoginRequest) string {

	if token := strings.TrimSpace(req.AccessToken); token != "" {
		return token
	}

	return strings.TrimSpace(req.IdToken)
}

// mobileVerificationPurpose scopes the token handed out after a social login.
//
// This token is deliberately NOT written to users.jwt_token, so it cannot be
// used against JWTAuthMiddleware (which authenticates by looking the token up on
// the user row). Its only use is the two mobile-verification endpoints.
const mobileVerificationPurpose = "social_mobile_verification"

const mobileVerificationTokenTTL = 15 * time.Minute

var mobileNumberPattern = regexp.MustCompile(`^[0-9]{6,15}$`)

// GenerateMobileVerificationToken mints the short-lived, purpose-scoped token
// that carries a half-finished social login through mobile verification.
func GenerateMobileVerificationToken(userID uint) (string, error) {

	claims := jwt.MapClaims{
		"id":      userID,
		"purpose": mobileVerificationPurpose,
		"exp":     time.Now().Add(mobileVerificationTokenTTL).Unix(),
	}

	token := jwt.NewWithClaims(
		jwt.SigningMethodHS256,
		claims,
	)

	return token.SignedString([]byte(os.Getenv("JWT_SECRET")))
}

// ParseMobileVerificationToken returns the user ID carried by a verification
// token. A normal access token is rejected: it carries no purpose claim.
func ParseMobileVerificationToken(tokenString string) (uint, error) {

	claims, err := ValidateJWT(tokenString)

	if err != nil {
		return 0, errors.New("verification token is invalid or has expired")
	}

	purpose, _ := claims["purpose"].(string)

	if purpose != mobileVerificationPurpose {
		return 0, errors.New("verification token is invalid or has expired")
	}

	id, ok := claims["id"].(float64)

	if !ok || id <= 0 {
		return 0, errors.New("verification token is invalid or has expired")
	}

	return uint(id), nil
}

// normalizeMobile strips the punctuation clients send and keeps the digits.
func normalizeMobile(mobile string) string {

	replacer := strings.NewReplacer(
		" ", "",
		"-", "",
		"(", "",
		")", "",
		"+", "",
	)

	return replacer.Replace(strings.TrimSpace(mobile))
}

// randomPassword gives a socially-created user an unguessable password hash, so
// a NOT NULL password column is satisfied and /login can never match it.
func randomPassword() (string, error) {

	buffer := make([]byte, 32)

	if _, err := rand.Read(buffer); err != nil {
		return "", err
	}

	hash, err := bcrypt.GenerateFromPassword(
		[]byte(hex.EncodeToString(buffer)),
		bcrypt.DefaultCost,
	)

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

	return string(hash), nil
}

func buildSocialUserResponse(user *usermodel.User) *dto.SocialUserResponse {

	response := &dto.SocialUserResponse{
		Id:             user.ID,
		Name:           user.Name,
		Email:          user.Email,
		ContactNo:      user.ContactNo,
		CountryCode:    user.CountryCode,
		ProfileImage:   user.Profile,
		MobileVerified: user.MobileVerified,
		EmailVerified:  user.EmailVerified,
		ReferralCode:   user.ReferralCode,
		RoleID:         user.RoleID,
		LastLogin:      user.LastLogin,
	}

	wallet, err := repositories.FindWalletByUserID(user.ID)

	if err == nil && wallet != nil {

		amount := float64(0)

		if wallet.Amount != nil {
			amount = *wallet.Amount
		}

		response.UserWallet = &dto.WalletResponse{
			ID:     wallet.ID,
			UserID: wallet.UserID,
			Amount: amount,
		}
	}

	return response
}

// createSocialUser provisions a brand new user from Firebase profile data,
// together with the wallet and role that RegisterUser also creates.
func createSocialUser(firebaseUser *FirebaseUser) (*usermodel.User, error) {

	name := strings.TrimSpace(firebaseUser.Name)

	if name == "" && firebaseUser.Email != "" {
		name = strings.Split(firebaseUser.Email, "@")[0]
	}

	if name == "" {
		name = "User"
	}

	password, err := randomPassword()

	if err != nil {
		return nil, err
	}

	user := usermodel.User{
		Name:          name,
		Email:         firebaseUser.Email,
		Password:      password,
		Profile:       firebaseUser.PhotoURL,
		EmailVerified: firebaseUser.EmailVerified,

		// Filled in by the mandatory mobile verification step.
		ContactNo:      "",
		MobileVerified: false,

		IsActive:     true,
		IsDelete:     false,
		RoleID:       3,
		ReferralCode: helpers.GenerateReferralCode(name),
	}

	if err := repositories.CreateUser(&user); err != nil {
		return nil, err
	}

	amount := float64(0)

	wallet := usermodel.UserWallet{
		UserID:   user.ID,
		Amount:   &amount,
		IsActive: true,
		IsDelete: false,
	}

	if err := configs.DB.Create(&wallet).Error; err != nil {
		return nil, err
	}

	role := usermodel.UserRole{UserID: user.ID, RoleID: 3}

	if err := repositories.CreateUserRole(&role); err != nil {
		return nil, err
	}

	return &user, nil
}

// SocialLogin verifies a Firebase ID token and resolves it to a local user.
//
// It never completes a login on its own: a session is only issued when the
// resolved user already has a verified mobile number. Otherwise the caller gets
// a verification token and must go through send-mobile-otp / verify-mobile-otp.
func SocialLogin(req dto.SocialLoginRequest, platform string, ipAddress string) (*dto.SocialLoginResponse, error) {

	platform = resolvePlatform(req.Platform, platform)

	firebaseUser, err := VerifySocialIDToken(socialToken(req))

	if err != nil {
		return nil, err
	}

	var user *usermodel.User

	isNewUser := false

	account, accountErr := repositories.FindSocialAccount(
		firebaseUser.Provider,
		firebaseUser.UID,
	)

	if accountErr != nil {
		account = nil
	}

	if account != nil {

		user, err = repositories.FindUserByID(account.UserID)

		if err != nil {
			return nil, errors.New("linked user account no longer exists")
		}

	} else {

		// No link yet. An existing account with the same email is the same
		// person, so link to it instead of creating a duplicate — but only when
		// the provider vouched for the address. Linking on an unverified email
		// would let anyone who can sign up at that provider with somebody
		// else's address take over an existing account.
		if firebaseUser.Email != "" {

			existing, _ := repositories.FindUserByEmail(firebaseUser.Email)

			if existing != nil && !firebaseUser.EmailVerified {

				return nil, errors.New("an account already exists for this email address; please sign in with your password and link this provider from your profile")
			}

			user = existing
		}

		if user == nil {

			user, err = createSocialUser(firebaseUser)

			if err != nil {
				return nil, err
			}

			isNewUser = true
		}
	}

	if err := CheckUserLoginAllowed(user); err != nil {
		return nil, err
	}

	now := time.Now()

	// Record, or refresh, the provider link.
	if account == nil {

		account = &usermodel.UserSocialAccount{
			UserID:      user.ID,
			Provider:    firebaseUser.Provider,
			ProviderUID: firebaseUser.UID,
			Email:       firebaseUser.Email,
			Name:        firebaseUser.Name,
			PhotoURL:    firebaseUser.PhotoURL,
			Platform:    platform,
			IsActive:    true,
			IsDelete:    false,
			LastLoginAt: &now,
			CreatedAt:   &now,
			UpdatedAt:   &now,
		}

		if err := repositories.CreateSocialAccount(account); err != nil {
			return nil, err
		}

	} else {

		account.Email = firebaseUser.Email
		account.Name = firebaseUser.Name
		account.PhotoURL = firebaseUser.PhotoURL
		account.Platform = platform
		account.LastLoginAt = &now
		account.UpdatedAt = &now

		if err := repositories.UpdateSocialAccount(account); err != nil {
			return nil, err
		}
	}

	// All three token columns together — see repositories.SaveDeviceToken.
	// Set on the in-memory row rather than through that helper because this
	// user is saved a few lines below anyway.
	if token := strings.TrimSpace(req.DeviceToken); token != "" {
		user.DeviceToken = token
		user.FcmToken = token
		user.Token = token
	}

	// A provider that vouched for the address counts as email verification;
	// Google always does, Facebook does when it released one at all.
	if firebaseUser.EmailVerified && strings.TrimSpace(firebaseUser.Email) != "" {

		if strings.EqualFold(strings.TrimSpace(user.Email), strings.TrimSpace(firebaseUser.Email)) {
			user.EmailVerified = true
		}
	}

	// Mobile and email verification are both mandatory: until each is done, no
	// session is issued.
	if step := nextSocialStep(user); step != "" {

		return pendingSocialResponse(
			user,
			firebaseUser.Provider,
			platform,
			isNewUser,
			step,
		)
	}

	return issueSocialSession(
		user,
		firebaseUser.Provider,
		platform,
		req.DeviceName,
		ipAddress,
		isNewUser,
		"Login successful",
	)
}

// issueSocialSession mints the access/refresh pair, persists it (the middleware
// authenticates against users.jwt_token, so this write must not be ignored) and
// records the login.
func issueSocialSession(
	user *usermodel.User,
	provider string,
	platform string,
	deviceName string,
	ipAddress string,
	isNewUser bool,
	message string,
) (*dto.SocialLoginResponse, error) {

	accessToken, err := GenerateJWT(user.ID, user.Name)

	if err != nil {
		return nil, err
	}

	refreshToken, err := GenerateRefreshToken(user.ID)

	if err != nil {
		return nil, err
	}

	now := time.Now()

	user.JwtToken = accessToken
	user.RefreshToken = refreshToken
	user.LastLogin = &now

	if err := repositories.UpdateUser(user); err != nil {
		return nil, errors.New("could not complete login, please try again")
	}

	loginHistory := usermodel.LoginHistory{
		UserID:     user.ID,
		IPAddress:  ipAddress,
		DeviceName: deviceName,
		Platform:   platform,
		LoginAt:    &now,
	}

	if err := repositories.SaveLoginHistory(&loginHistory); err != nil {
		return nil, err
	}

	return &dto.SocialLoginResponse{
		Success:                    true,
		Message:                    message,
		MobileVerificationRequired: false,
		Token:                      accessToken,
		RefreshToken:               refreshToken,
		IsNewUser:                  isNewUser,
		Provider:                   provider,
		Platform:                   platform,
		User:                       buildSocialUserResponse(user),
	}, nil
}

// SendSocialMobileOTP stores and sends the mobile OTP for a social account that
// has not verified a number yet. The OTP is the static one the rest of the auth
// flow uses (services.StaticOTP).
func SendSocialMobileOTP(req dto.SocialSendMobileOTPRequest) (map[string]interface{}, error) {

	userID, err := ParseMobileVerificationToken(req.VerificationToken)

	if err != nil {
		return nil, err
	}

	user, err := repositories.FindUserByID(userID)

	if err != nil {
		return nil, errors.New("user not found")
	}

	if err := CheckUserLoginAllowed(user); err != nil {
		return nil, err
	}

	mobile := normalizeMobile(req.Mobile)

	if !mobileNumberPattern.MatchString(mobile) {
		return nil, errors.New("please provide a valid mobile number")
	}

	// The number must not already belong to somebody else.
	if existing, findErr := repositories.FindOtherUserByMobile(mobile, user.ID); findErr == nil && existing != nil {
		return nil, errors.New("this mobile number is already registered with another account")
	}

	expireTime := time.Now().Add(OTPExpiry())

	// Issued before either branch, so the value stored and the value sent are
	// the same one. The update branch used to derive its own code with
	// `mobileOTP = mobileOTP` in the non-production case - a self-assignment
	// that left it empty, stored an empty OTP and let any blank submission
	// through - while the create branch hardcoded the static code even in
	// production.
	mobileOTP := MustNewOTP()

	otp, err := repositories.GetOTPByUserID(user.ID)

	if err != nil {

		newOTP := usermodel.UserOTP{
			UserID:           &user.ID,
			Mobile:           mobile,
			Email:            user.Email,
			MobileOTP:        mobileOTP,
			MaxAttempts:      MaxOTPAttempts,
			MobileAttempts:   0,
			ResendCount:      0,
			IsMobileVerified: false,
			ExpiresAt:        &expireTime,
		}

		if err := repositories.CreateOTP(&newOTP); err != nil {
			return nil, err
		}

	} else {

		if otp.MaxAttempts > 0 && otp.ResendCount >= otp.MaxAttempts {
			return nil, errors.New("maximum resend limit reached, please try again later")
		}

		otp.Mobile = mobile
		otp.MobileOTP = mobileOTP

		otp.MobileAttempts = 0
		otp.IsMobileVerified = false
		otp.ResendCount++
		otp.ExpiresAt = &expireTime

		if otp.MaxAttempts == 0 {
			otp.MaxAttempts = MaxOTPAttempts
		}

		if err := repositories.UpdateOTP(otp); err != nil {
			return nil, err
		}
	}

	if req.CountryCode != "" {

		user.CountryCode = req.CountryCode

		if err := repositories.UpdateUser(user); err != nil {
			return nil, err
		}
	}

	// Delivered from the local values: `otp` is nil on the create branch above,
	// so reading otp.Mobile here panicked the request for any social account
	// that had no OTP row yet - which is every first-time social login.
	DeliverMobileOTP(mobile, mobileOTP)

	return map[string]interface{}{
		"success": true,
		"message": "OTP sent successfully",
		"userId":  user.ID,
		"mobile":  mobile,
	}, nil
}

// VerifySocialMobileOTP completes the mandatory mobile step and, only then,
// issues the session for the social account.
func VerifySocialMobileOTP(req dto.SocialVerifyMobileOTPRequest, ipAddress string) (*dto.SocialLoginResponse, error) {

	userID, err := ParseMobileVerificationToken(req.VerificationToken)

	if err != nil {
		return nil, err
	}

	user, err := repositories.FindUserByID(userID)

	if err != nil {
		return nil, errors.New("user not found")
	}

	if err := CheckUserLoginAllowed(user); err != nil {
		return nil, err
	}

	otp, err := repositories.GetOTPByUserID(user.ID)

	if err != nil {
		return nil, errors.New("please request an otp first")
	}

	if otp.ExpiresAt != nil && time.Now().After(*otp.ExpiresAt) {
		return nil, errors.New("otp has expired, please request a new one")
	}

	if otp.MaxAttempts > 0 && otp.MobileAttempts >= otp.MaxAttempts {
		return nil, errors.New("too many incorrect attempts, please request a new otp")
	}

	// When the client echoes the number back it must match the one the OTP was
	// sent to, so a verified OTP cannot be redirected onto a different number.
	if req.Mobile != "" && normalizeMobile(req.Mobile) != otp.Mobile {
		return nil, errors.New("mobile number does not match the number the otp was sent to")
	}

	if req.OTP != otp.MobileOTP {

		otp.MobileAttempts++

		_ = repositories.UpdateOTP(otp)

		return nil, errors.New("invalid otp")
	}

	// Re-check ownership: the number could have been taken between the two calls.
	if existing, findErr := repositories.FindOtherUserByMobile(otp.Mobile, user.ID); findErr == nil && existing != nil {
		return nil, errors.New("this mobile number is already registered with another account")
	}

	otp.IsMobileVerified = true

	if err := repositories.UpdateOTP(otp); err != nil {
		return nil, err
	}

	user.ContactNo = otp.Mobile
	user.MobileVerified = true

	provider, platform := latestSocialAccountContext(user.ID)

	platform = resolvePlatform(req.Platform, platform)

	if needsEmailVerification(user) {

		return pendingSocialResponse(user, provider, platform, false, StepEmailOTP)
	}

	return issueSocialSession(
		user,
		provider,
		platform,
		req.DeviceName,
		ipAddress,
		false,
		"Mobile verified successfully",
	)
}

//////////////////////////////////////////////////////////////
// Email Verification
//////////////////////////////////////////////////////////////

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]{2,}$`)

// SendSocialEmailOTP sends the email OTP for a social account that has no
// verified address yet. Authenticated by the verification token from the login,
// not by a session — the account does not have one until this step is done.
func SendSocialEmailOTP(req dto.SocialSendEmailOTPRequest) (map[string]interface{}, error) {

	userID, err := ParseMobileVerificationToken(req.VerificationToken)

	if err != nil {
		return nil, err
	}

	user, err := repositories.FindUserByID(userID)

	if err != nil {
		return nil, errors.New("user not found")
	}

	if err := CheckUserLoginAllowed(user); err != nil {
		return nil, err
	}

	email := strings.ToLower(strings.TrimSpace(req.Email))

	if !emailPattern.MatchString(email) {
		return nil, errors.New("please provide a valid email address")
	}

	// The address must not already belong to somebody else.
	if existing, findErr := repositories.FindOtherUserByEmail(email, user.ID); findErr == nil && existing != nil {
		return nil, errors.New("this email address is already registered with another account")
	}

	expireTime := time.Now().Add(OTPExpiry())

	// This path was the last one still writing the compile-time StaticOTP, so
	// production stored 123456 here while every other flow stored a random
	// code. Because the verify endpoints accept either channel, that one row
	// also let 123456 through /verify-login-otp on the same account.
	emailOTP := MustNewOTP()

	otp, err := repositories.GetOTPByUserID(user.ID)

	if err != nil {

		newOTP := usermodel.UserOTP{
			UserID:          &user.ID,
			Mobile:          user.ContactNo,
			Email:           email,
			EmailOTP:        emailOTP,
			MaxAttempts:     MaxOTPAttempts,
			EmailAttempts:   0,
			ResendCount:     0,
			IsEmailVerified: false,
			ExpiresAt:       &expireTime,
		}

		if err := repositories.CreateOTP(&newOTP); err != nil {
			return nil, err
		}

	} else {

		if otp.MaxAttempts > 0 && otp.ResendCount >= otp.MaxAttempts {
			return nil, errors.New("maximum resend limit reached, please try again later")
		}

		otp.Email = email
		otp.EmailOTP = emailOTP
		otp.EmailAttempts = 0
		otp.IsEmailVerified = false
		otp.ResendCount++
		otp.ExpiresAt = &expireTime

		if otp.MaxAttempts == 0 {
			otp.MaxAttempts = MaxOTPAttempts
		}

		if err := repositories.UpdateOTP(otp); err != nil {
			return nil, err
		}
	}

	// The address is not written to the account until it is verified, so an
	// unverified one never becomes the account's recovery path.
	//
	// Delivered through DeliverEmailOTP rather than SendEmailOTP, so this path
	// honours the environment and the kill switch like every other one. It used
	// to make a real OTPLESS call from a development build.
	DeliverEmailOTP(email, emailOTP)

	return map[string]interface{}{
		"success": true,
		"message": "OTP sent successfully",
		"userId":  user.ID,
		"email":   email,
	}, nil
}

// VerifySocialEmailOTP completes email verification. It issues the session only
// if the mobile step is also done; otherwise it hands back the mobile step.
func VerifySocialEmailOTP(req dto.SocialVerifyEmailOTPRequest, ipAddress string) (*dto.SocialLoginResponse, error) {

	userID, err := ParseMobileVerificationToken(req.VerificationToken)

	if err != nil {
		return nil, err
	}

	user, err := repositories.FindUserByID(userID)

	if err != nil {
		return nil, errors.New("user not found")
	}

	if err := CheckUserLoginAllowed(user); err != nil {
		return nil, err
	}

	otp, err := repositories.GetOTPByUserID(user.ID)

	if err != nil {
		return nil, errors.New("please request an otp first")
	}

	if otp.ExpiresAt != nil && time.Now().After(*otp.ExpiresAt) {
		return nil, errors.New("otp has expired, please request a new one")
	}

	if otp.MaxAttempts > 0 && otp.EmailAttempts >= otp.MaxAttempts {
		return nil, errors.New("too many incorrect attempts, please request a new otp")
	}

	// When the client echoes the address back it must match the one the OTP was
	// sent to, so a verified OTP cannot be redirected onto a different address.
	if strings.TrimSpace(req.Email) != "" &&
		!strings.EqualFold(strings.ToLower(strings.TrimSpace(req.Email)), otp.Email) {

		return nil, errors.New("email address does not match the address the otp was sent to")
	}

	if req.OTP != otp.EmailOTP {

		otp.EmailAttempts++

		_ = repositories.UpdateOTP(otp)

		return nil, errors.New("invalid otp")
	}

	// Re-check ownership: the address could have been taken between the two calls.
	if existing, findErr := repositories.FindOtherUserByEmail(otp.Email, user.ID); findErr == nil && existing != nil {
		return nil, errors.New("this email address is already registered with another account")
	}

	otp.IsEmailVerified = true

	if err := repositories.UpdateOTP(otp); err != nil {
		return nil, err
	}

	user.Email = otp.Email
	user.EmailVerified = true

	provider, platform := latestSocialAccountContext(user.ID)

	platform = resolvePlatform(req.Platform, platform)

	// The email is done; the mobile may not be.
	if needsMobileVerification(user) {

		return pendingSocialResponse(user, provider, platform, false, StepMobileOTP)
	}

	return issueSocialSession(
		user,
		provider,
		platform,
		req.DeviceName,
		ipAddress,
		false,
		"Email verified successfully",
	)
}

// latestSocialAccountContext reports which provider and platform the account was
// last seen from, for the login history written when the session is issued.
func latestSocialAccountContext(userID uint) (string, string) {

	provider := "firebase"

	platform := PlatformMobile

	accounts, err := repositories.FindSocialAccountsByUserID(userID)

	if err == nil && len(accounts) > 0 {

		latest := accounts[len(accounts)-1]

		provider = latest.Provider

		if latest.Platform != "" {
			platform = latest.Platform
		}
	}

	return provider, platform
}
