package services_astrologer

import (
	"crypto/rand"
	"errors"
	"fmt"
	"log"
	"math/big"
	"strings"
	"time"

	"astrology-api/constants"
	dto_astrologer "astrology-api/dto_astrologer"
	models "astrology-api/models/astrologermodel"
	repositories_astrologer "astrology-api/repositories_astrologer"

	"golang.org/x/crypto/bcrypt"
	"gorm.io/gorm"
)

type AuthService interface {
	Register(req dto_astrologer.RegisterRequest) (interface{}, error)

	VerifyMobileOTP(req dto_astrologer.VerifyMobileOTPRequest) (interface{}, error)

	VerifyEmailOTP(req dto_astrologer.VerifyEmailOTPRequest) (interface{}, error)

	ResendOTP(req dto_astrologer.ResendOTPRequest) (interface{}, error)

	Login(req dto_astrologer.LoginRequest) (interface{}, error)

	VerifyLoginOTP(req dto_astrologer.VerifyOTPRequest) (interface{}, error)

	RefreshToken(req dto_astrologer.RefreshTokenRequest) (interface{}, error)

	Logout(userID uint) (interface{}, error)

	GetLoggedInProfile(userID uint) (interface{}, error)

	UpdateDeviceToken(userID uint, req dto_astrologer.UpdateDeviceTokenRequest) (interface{}, error)
}

type authService struct {
	repository repositories_astrologer.AuthRepository
}

func NewAuthService() AuthService {

	return &authService{
		repository: repositories_astrologer.NewAuthRepository(),
	}

}

// Register creates the astrologer account and issues its two registration OTPs.
//
// An account that registered and never verified is resumed rather than refused:
// the duplicate check used to reject that mobile forever, and since registration
// is the only place an account is created, those half-registered astrologers had
// no way in at all. The OTP values come from NewOTP, so development keeps the
// static 123456 and production sends a fresh random one over MSG91/OTPLESS.
func (s *authService) Register(req dto_astrologer.RegisterRequest) (interface{}, error) {

	tx, err := s.beginTransaction()
	if err != nil {
		return nil, err
	}

	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	// Rollback on any returned error
	defer func() {
		if err != nil {
			s.rollbackTransaction(tx)
		}
	}()

	// Duplicate validation, which also hands back the unverified account to
	// resume when there is one.
	existing, err := s.findResumableRegistration(tx, req)
	if err != nil {
		return nil, err
	}

	// OTP
	mobileOTP, emailOTP, err := s.generateRegistrationOTP()
	if err != nil {
		return nil, err
	}

	// A half-registered account keeps its id, wallet and role, and only its OTP
	// is refreshed. Its stored mobile and email are what the new OTP is sent to.
	if existing != nil {

		if err = s.refreshRegistrationOTP(tx, existing, mobileOTP, emailOTP); err != nil {
			return nil, err
		}

		if err = s.commitTransaction(tx); err != nil {
			return nil, err
		}

		DeliverMobileOTP(existing.ContactNo, mobileOTP)
		DeliverEmailOTP(existing.Email, emailOTP)

		return dto_astrologer.RegisterResponse{
			UserID:         existing.ID,
			Mobile:         existing.ContactNo,
			Email:          existing.Email,
			MobileVerified: existing.MobileVerified,
			EmailVerified:  existing.EmailVerified,
			NextScreen:     registrationNextScreen(existing),
			ExpiresIn:      OTPExpirySeconds(),
		}, nil
	}

	// Password Hash
	hashedPassword, err := hashPassword(req.Password)
	if err != nil {
		return nil, err
	}

	// Create User
	user := s.buildUser(req, hashedPassword)

	if err = s.repository.CreateUser(tx, &user); err != nil {
		return nil, err
	}

	// User Role
	if err = s.createUserRole(tx, user.ID, user.RoleID); err != nil {
		return nil, err
	}

	// device_type is not a field on the User model, so it is written by column
	// here rather than through buildUser. Register accepted it and dropped it.
	// Stored in the same canonical casing /device-token uses.
	if err = s.repository.UpdateDeviceInfo(tx, user.ID, canonicalDeviceType(req.DeviceType), req.DeviceToken); err != nil {
		return nil, err
	}

	// Wallet
	if err = s.createWallet(tx, user.ID); err != nil {
		return nil, err
	}

	// OTP
	if err = s.createRegistrationOTP(
		tx,
		user.ID,
		req,
		mobileOTP,
		emailOTP,
	); err != nil {
		return nil, err
	}

	if err = s.commitTransaction(tx); err != nil {
		return nil, err
	}

	// Delivery is best effort and happens once the row is safely committed.
	DeliverMobileOTP(user.ContactNo, mobileOTP)
	DeliverEmailOTP(user.Email, emailOTP)

	return dto_astrologer.RegisterResponse{
		UserID:         user.ID,
		Mobile:         user.ContactNo,
		Email:          user.Email,
		MobileVerified: user.MobileVerified,
		EmailVerified:  user.EmailVerified,
		NextScreen:     ScreenVerifyMobile,
		ExpiresIn:      OTPExpirySeconds(),
	}, nil

}

// findResumableRegistration rejects a mobile or email that is genuinely taken,
// and returns the account to resume when the mobile belongs to an astrologer who
// never finished verifying.
//
// Only a completely unverified astrologer is resumable. One that verified either
// channel is a real account and is refused, as is a mobile or email held by a
// user-app account, since both stacks share the users table.
func (s *authService) findResumableRegistration(
	tx *gorm.DB,
	req dto_astrologer.RegisterRequest,
) (*models.User, error) {

	byMobile, err := s.repository.FindUserByMobile(tx, req.Mobile)
	if err != nil {
		return nil, err
	}

	var resumable *models.User

	if byMobile != nil {

		if byMobile.RoleID != constants.RoleAstrologer ||
			byMobile.IsDelete ||
			byMobile.MobileVerified ||
			byMobile.EmailVerified {

			return nil, errors.New("mobile number already registered")
		}

		resumable = byMobile
	}

	byEmail, err := s.repository.FindUserByEmail(tx, req.Email)
	if err != nil {
		return nil, err
	}

	// The email is free, or it belongs to the very account being resumed.
	if byEmail != nil && (resumable == nil || byEmail.ID != resumable.ID) {
		return nil, errors.New("email already registered")
	}

	return resumable, nil
}

// refreshRegistrationOTP puts a new pair of OTPs on a resumed registration, and
// writes the row when the account has none, so a purged OTP row is not a
// dead end either.
func (s *authService) refreshRegistrationOTP(
	tx *gorm.DB,
	user *models.User,
	mobileOTP string,
	emailOTP string,
) error {

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

	otp, err := s.repository.FindOTPByUserID(tx, user.ID)

	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return err
	}

	if otp == nil {

		userID := user.ID

		return s.repository.CreateUserOTP(tx, &models.UserOTP{
			UserID:      &userID,
			Mobile:      user.ContactNo,
			Email:       user.Email,
			MobileOTP:   mobileOTP,
			EmailOTP:    emailOTP,
			MaxAttempts: MaxOTPAttempts,
			ExpiresAt:   &expiry,
		})
	}

	otp.MobileOTP = mobileOTP
	otp.EmailOTP = emailOTP
	otp.MobileAttempts = 0
	otp.EmailAttempts = 0
	otp.ResendCount = 0
	otp.ExpiresAt = &expiry

	return s.repository.UpdateOTP(tx, otp)
}

// registrationNextScreen is the OTP screen a resumed registration reopens on.
func registrationNextScreen(user *models.User) string {

	if screen := RegistrationScreen(user); screen != "" {
		return screen
	}

	return ScreenCreateProfile
}

func (s *authService) generateRegistrationOTP() (
	string,
	string,
	error,
) {

	mobileOTP, err := NewOTP()
	if err != nil {
		return "", "", err
	}

	emailOTP, err := NewOTP()
	if err != nil {
		return "", "", err
	}

	return mobileOTP, emailOTP, nil
}

func (s *authService) buildUser(
	req dto_astrologer.RegisterRequest,
	password string,
) models.User {

	return models.User{
		Name:           req.Name,
		Email:          req.Email,
		Password:       password,
		ContactNo:      req.Mobile,
		CountryCode:    req.CountryCode,
		ReferralCode:   req.ReferralCode,
		DeviceToken:    req.DeviceToken,
		RoleID:         constants.RoleAstrologer,
		MobileVerified: false,
		EmailVerified:  false,
		IsActive:       true,
		IsDelete:       false,
	}
}

func (s *authService) createWallet(
	tx *gorm.DB,
	userID uint,
) error {

	amount := float64(0)

	wallet := models.UserWallet{
		UserID:     userID,
		Amount:     &amount,
		IsActive:   true,
		IsDelete:   false,
		CreatedBy:  int(userID),
		ModifiedBy: int(userID),
	}

	return s.repository.CreateWallet(tx, &wallet)
}

// createUserRole links the account to its role. The roleID argument used to be
// ignored in favour of a literal 2, which happened to match RoleAstrologer but
// silently discarded whatever the caller asked for.
func (s *authService) createUserRole(tx *gorm.DB, userID uint, roleID uint) error {

	if roleID == 0 {
		roleID = constants.RoleAstrologer
	}

	role := models.UserRole{
		UserID: userID,
		RoleID: roleID,
	}

	return s.repository.CreateUserRole(tx, &role)
}

func (s *authService) createRegistrationOTP(
	tx *gorm.DB,
	userID uint,
	req dto_astrologer.RegisterRequest,
	mobileOTP,
	emailOTP string,
) error {

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

	otp := models.UserOTP{
		UserID:           &userID,
		Mobile:           req.Mobile,
		Email:            req.Email,
		MobileOTP:        mobileOTP,
		EmailOTP:         emailOTP,
		MobileAttempts:   0,
		EmailAttempts:    0,
		MaxAttempts:      MaxOTPAttempts,
		ResendCount:      0,
		IsMobileVerified: false,
		IsEmailVerified:  false,
		ExpiresAt:        &expiry,
	}

	return s.repository.CreateUserOTP(tx, &otp)
}

// validateLoginableUser is the account check every token-issuing call shares.
//
// Login had it and /verify-login did not, so an account deactivated between the
// two calls still walked away with a token.
func validateLoginableUser(user *models.User) error {

	if user == nil {
		return errors.New("astrologer not found")
	}

	if user.RoleID != constants.RoleAstrologer {
		return errors.New("invalid astrologer account")
	}

	if user.IsDelete {
		return errors.New("account not available")
	}

	if !user.IsActive {
		return errors.New("your account is inactive")
	}

	return nil
}

// otpExpired reports whether an OTP window has lapsed. A row with no expiry is
// treated as live: reading it unguarded is what used to panic the mobile verify.
func otpExpired(otp *models.UserOTP) bool {

	return otp != nil && otp.ExpiresAt != nil && otp.ExpiresAt.Before(time.Now())
}

// recordFailedOTPAttempt counts one wrong guess, and must be called AFTER the
// verify transaction has been rolled back.
//
// The increment used to be written into that transaction and was rolled back
// with it, so mobile_attempts and email_attempts never left zero and the
// max-attempts lockout could not fire however many wrong OTPs were sent. Writing
// it outside is the whole point of this helper — do not move it back inside.
//
// A failure here only costs one unrecorded attempt, so it is logged rather than
// returned: the caller still has to tell the client the OTP was wrong.
func (s *authService) recordFailedOTPAttempt(otpID uint, channel string) {

	if err := s.repository.IncrementOTPAttempt(otpID, channel); err != nil {
		log.Printf("astrologer auth: recording failed %s otp attempt on otp %d: %v", channel, otpID, err)
	}
}

// VerifyMobileOTP verifies the registration mobile OTP.
//
// It issues the tokens when it is the call that completes both verifications.
// Only the email verify used to do that, so verifying email first and mobile
// second left a fully verified astrologer with no token at all, and no way to
// start the profile steps.
func (s *authService) VerifyMobileOTP(req dto_astrologer.VerifyMobileOTPRequest) (interface{}, error) {

	// Validated before the transaction opens, so a malformed request never
	// takes a connection or counts against the attempt limit.
	mobile, err := validateMobileForCountry(req.CountryCode, req.Mobile)

	if err != nil {
		return nil, err
	}

	code, err := validateOTP(req.OTP)

	if err != nil {
		return nil, err
	}

	req.Mobile = mobile
	req.OTP = code

	tx, err := s.beginTransaction()

	if err != nil {
		return nil, err
	}

	defer func() {

		if r := recover(); r != nil {

			s.rollbackTransaction(tx)

		}

	}()

	otp, err := s.repository.FindOTPByMobile(
		tx,
		req.Mobile,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, errors.New("otp not found")

	}

	if otp == nil {

		s.rollbackTransaction(tx)

		return nil, errors.New("otp not found")

	}

	// Expired. Guarded, because expires_at is nullable and dereferencing it
	// unconditionally panicked the whole request on a row that had none.

	if otpExpired(otp) {

		s.rollbackTransaction(tx)

		return nil, errors.New("otp expired")

	}

	// Already Verified

	if otp.IsMobileVerified {

		s.rollbackTransaction(tx)

		return nil, errors.New("mobile already verified")

	}

	// Max Attempts, as the email verify already does.

	if otp.MaxAttempts > 0 && otp.MobileAttempts >= otp.MaxAttempts {

		s.rollbackTransaction(tx)

		return nil, errors.New("maximum otp attempts exceeded")

	}

	// Wrong OTP

	if otp.MobileOTP != req.OTP {

		s.rollbackTransaction(tx)

		s.recordFailedOTPAttempt(otp.ID, "mobile")

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

	}

	// Update OTP

	otp.IsMobileVerified = true

	err = s.repository.UpdateOTP(
		tx,
		otp,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err

	}

	// Update User

	user, err := s.repository.FindUserByID(
		tx,
		*otp.UserID,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err

	}

	// A deleted or deactivated account must not be able to verify its way to a
	// token, which the email verify does not check either.
	if err = validateLoginableUser(user); err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	user.MobileVerified = true

	err = s.repository.UpdateUser(
		tx,
		user,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err

	}

	// Both OTPs done: this is the call that owes the astrologer a token.

	canCreateProfile := otp.IsMobileVerified && otp.IsEmailVerified

	var token string

	var refreshToken string

	if canCreateProfile {

		token, err = GenerateJWT(user.ID, user.RoleID)

		if err != nil {

			s.rollbackTransaction(tx)

			return nil, err
		}

		refreshToken, err = GenerateRefreshToken(user.ID, user.RoleID)

		if err != nil {

			s.rollbackTransaction(tx)

			return nil, err
		}

		user.Token = token
		user.JwtToken = token
		user.RefreshToken = refreshToken

		err = s.repository.UpdateUserToken(
			tx,
			user,
		)

		if err != nil {

			s.rollbackTransaction(tx)

			return nil, err
		}
	}

	// Where to resume, for a mobile verified after the profile was started.

	astrologer, err := s.repository.FindAstrologerByUserID(tx, user.ID)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err

	}

	err = s.commitTransaction(tx)

	if err != nil {

		return nil, err

	}

	response := dto_astrologer.VerifyOTPResponse{

		UserID: user.ID,

		Status: "Mobile Verified",

		MobileVerified: true,

		EmailVerified: otp.IsEmailVerified,

		CanCreateProfile: canCreateProfile,

		Token: token,

		RefreshToken: refreshToken,

		NextScreen: ScreenVerifyEmail,
	}

	if canCreateProfile {

		pendingStep := PendingProfileStep(astrologer)

		response.NextScreen = ProfileScreen(astrologer)
		response.PendingStep = pendingStep
		response.PendingStepTitle = ProfileStepTitle(pendingStep)
	}

	return response, nil

}

func (s *authService) VerifyEmailOTP(req dto_astrologer.VerifyEmailOTPRequest) (interface{}, error) {

	// Same as VerifyMobileOTP: checked before the transaction opens.
	email, err := validateEmail(req.Email)

	if err != nil {
		return nil, err
	}

	code, err := validateOTP(req.OTP)

	if err != nil {
		return nil, err
	}

	req.Email = email
	req.OTP = code

	tx, err := s.beginTransaction()
	if err != nil {
		return nil, err
	}
	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	// Find OTP Record

	otp, err := s.repository.FindOTPByEmail(
		tx,
		req.Email,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err

	}

	if otp == nil {

		s.rollbackTransaction(tx)

		return nil, errors.New("otp not found")

	}

	// Expired

	if otpExpired(otp) {

		s.rollbackTransaction(tx)

		return nil, errors.New("otp expired")

	}

	// Already Verified

	if otp.IsEmailVerified {

		s.rollbackTransaction(tx)

		return nil, errors.New("email already verified")

	}

	// Max Attempts. Guarded on a positive limit like the mobile verify, so a row
	// written before max_attempts was populated does not refuse every attempt.

	if otp.MaxAttempts > 0 && otp.EmailAttempts >= otp.MaxAttempts {

		s.rollbackTransaction(tx)

		return nil, errors.New("maximum otp attempts exceeded")

	}

	// Validate OTP

	if otp.EmailOTP != req.OTP {

		s.rollbackTransaction(tx)

		s.recordFailedOTPAttempt(otp.ID, "email")

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

	}

	// Update OTP

	otp.IsEmailVerified = true

	err = s.repository.UpdateOTP(
		tx,
		otp,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err

	}

	// Find User

	user, err := s.repository.FindUserByID(
		tx,
		*otp.UserID,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err

	}

	// A deleted or deactivated account must not verify its way to a token.
	if err = validateLoginableUser(user); err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	// Update User

	user.EmailVerified = true

	err = s.repository.UpdateUser(
		tx,
		user,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err

	}

	// Check Verification Status. Read back rather than trusting the local copy,
	// so a mobile verified concurrently is seen. The value was computed twice
	// before, once from otp and once from latestOTP, and the first was discarded.

	latestOTP, err := s.repository.FindOTPByUserID(
		tx,
		user.ID,
	)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	if latestOTP == nil {
		s.rollbackTransaction(tx)
		return nil, errors.New("otp not found")
	}

	isProfileCompleted := latestOTP.IsMobileVerified && latestOTP.IsEmailVerified

	var token string
	var refreshToken string

	if isProfileCompleted {

		token, err = GenerateJWT(user.ID, user.RoleID)
		if err != nil {
			s.rollbackTransaction(tx)
			return nil, err
		}

		refreshToken, err = GenerateRefreshToken(
			user.ID,
			user.RoleID,
		)
		if err != nil {
			s.rollbackTransaction(tx)
			return nil, err
		}
		user.Token = token
		user.JwtToken = token
		user.RefreshToken = refreshToken

		err = s.repository.UpdateUserToken(
			tx,
			user,
		)

		if err != nil {
			s.rollbackTransaction(tx)
			return nil, err
		}
	}

	// Where the profile flow resumes, read while the transaction is still open.
	astrologer, err := s.repository.FindAstrologerByUserID(tx, user.ID)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	// Device and session: token onto the users row, one login_histories row.
	s.recordDeviceSession(tx, user.ID, req.DeviceSession)

	err = s.commitTransaction(tx)
	if err != nil {
		return nil, err
	}

	// token and refreshToken are empty until both OTPs are in. Reading the token
	// off the user row instead leaked whatever token a previous session had left
	// there, to a caller who has not finished verifying.
	response := dto_astrologer.VerifyEmailOTPResponse{
		UserID:           user.ID,
		MobileVerified:   latestOTP.IsMobileVerified,
		EmailVerified:    latestOTP.IsEmailVerified,
		CanCreateProfile: isProfileCompleted,
		Token:            token,
		RefreshToken:     refreshToken,
	}

	if !isProfileCompleted {

		response.NextScreen = ScreenVerifyMobile

		return response, nil
	}

	// Both OTPs are in, so the profile flow starts, or resumes where it stopped.
	pendingStep := PendingProfileStep(astrologer)

	response.NextScreen = ProfileScreen(astrologer)
	response.PendingStep = pendingStep
	response.PendingStepTitle = ProfileStepTitle(pendingStep)

	return response, nil

}

func (s *authService) ResendOTP(req dto_astrologer.ResendOTPRequest) (interface{}, error) {

	tx, err := s.beginTransaction()
	if err != nil {
		return nil, err
	}

	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	//-----------------------------------------
	// Find OTP
	//
	// Resolved by id, then mobile, then email. Only the id was read before, and
	// it is not a required field, so a request carrying just a mobile resolved
	// to user 0 and came back "otp record not found".
	//-----------------------------------------

	otp, err := s.findResendOTP(tx, req)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	if otp == nil {
		s.rollbackTransaction(tx)
		return nil, errors.New("otp record not found")
	}

	//-----------------------------------------
	// Max Resend Validation
	//
	// The counter is cleared once the OTP window has lapsed. It was never reset
	// anywhere, so five resends over the life of an account locked it out of
	// resending permanently.
	//-----------------------------------------

	if otpExpired(otp) {
		otp.ResendCount = 0
	}

	if otp.ResendCount >= MaxOTPResends {

		s.rollbackTransaction(tx)

		return nil, errors.New("maximum resend limit exceeded")
	}

	//-----------------------------------------
	// Generate OTP, for the channel that was asked for
	//-----------------------------------------

	sendMobile, sendEmail := resendChannels(req.Type)

	// This endpoint serves two flows. While either channel is still unverified
	// it is a registration resend, and there is no point reissuing a channel
	// that is already done. Once both are verified it is a login resend, and the
	// mobile OTP is exactly what has to go out again.
	registering := !otp.IsMobileVerified || !otp.IsEmailVerified

	if registering {

		sendMobile = sendMobile && !otp.IsMobileVerified
		sendEmail = sendEmail && !otp.IsEmailVerified

	} else {

		sendMobile = true
		sendEmail = false
	}

	if !sendMobile && !sendEmail {

		s.rollbackTransaction(tx)

		return nil, errors.New("nothing left to verify")
	}

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

	response := dto_astrologer.ResendOTPResponse{
		OTPSent:   true,
		ExpiresIn: OTPExpirySeconds(),
		Mobile:    otp.Mobile,
		Email:     otp.Email,
	}

	if otp.UserID != nil {
		response.UserID = *otp.UserID
	}

	var mobileOTP string
	var emailOTP string

	if sendMobile {

		mobileOTP, err = NewOTP()

		if err != nil {
			s.rollbackTransaction(tx)
			return nil, err
		}

		otp.MobileOTP = mobileOTP
		otp.MobileAttempts = 0

		response.OTP = echoOTP(mobileOTP)
		response.MobileOTPStatus = "SENT"
	}

	if sendEmail {

		emailOTP, err = NewOTP()

		if err != nil {
			s.rollbackTransaction(tx)
			return nil, err
		}

		otp.EmailOTP = emailOTP
		otp.EmailAttempts = 0

		// otp reports the mobile value when both go out, which is what the app
		// has always read out of this field.
		if response.OTP == "" {
			response.OTP = echoOTP(emailOTP)
		}

		response.EmailOTPStatus = "SENT"
	}

	otp.ExpiresAt = &expiry
	otp.ResendCount++

	if err := s.repository.UpdateOTP(
		tx,
		otp,
	); err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	// Where the app goes next: the registration OTP screens while either channel
	// is unverified, the login OTP screen otherwise. It was hardcoded to the
	// login screen, which sent a registering astrologer to the wrong place.
	nextScreen := ScreenVerifyLoginOTP

	if !otp.IsMobileVerified {
		nextScreen = ScreenVerifyMobile
	} else if !otp.IsEmailVerified {
		nextScreen = ScreenVerifyEmail
	}

	response.ResendCount = otp.ResendCount
	response.NextScreen = nextScreen

	//-----------------------------------------

	if err := s.commitTransaction(tx); err != nil {
		return nil, err
	}

	if sendMobile {
		DeliverMobileOTP(otp.Mobile, mobileOTP)
	}

	if sendEmail {
		DeliverEmailOTP(otp.Email, emailOTP)
	}

	return response, nil
}

// findResendOTP locates the OTP row from whichever identifier the request
// carries, so any of id, mobile or email works.
func (s *authService) findResendOTP(
	tx *gorm.DB,
	req dto_astrologer.ResendOTPRequest,
) (*models.UserOTP, error) {

	if req.UserID > 0 {

		otp, err := s.repository.FindOTPByUserID(tx, req.UserID)

		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, err
		}

		if otp != nil {
			return otp, nil
		}
	}

	if req.Mobile != "" {

		otp, err := s.repository.FindOTPByMobile(tx, req.Mobile)

		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, err
		}

		if otp != nil {
			return otp, nil
		}
	}

	if req.Email != "" {

		otp, err := s.repository.FindOTPByEmail(tx, req.Email)

		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, err
		}

		if otp != nil {
			return otp, nil
		}
	}

	return nil, nil
}

// resendChannels reads the request's type. An unrecognised or empty value keeps
// the old behaviour of refreshing both, so nothing that worked stops working.
func resendChannels(otpType string) (bool, bool) {

	switch strings.ToLower(strings.TrimSpace(otpType)) {

	case "mobile":
		return true, false

	case "email":
		return false, true

	default:
		return true, true
	}
}

// Login sends the login OTP to every astrologer account that exists.
//
// It used to refuse before sending anything unless the profile was already
// APPROVED, which locked out everybody mid-registration: they could not log in,
// and registration refuses a mobile that is already taken, so those accounts had
// no way back in at all. Deciding the screen is the app's job, so login reports
// where the account stands and /verify-login hands over the token the profile
// steps need.
func (s *authService) Login(req dto_astrologer.LoginRequest) (interface{}, error) {

	tx, err := s.beginTransaction()
	if err != nil {
		return nil, err
	}

	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	// Find User
	user, err := s.repository.FindLoginUserByMobile(tx, req.Mobile)
	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	// Not registered: the app sends this one to the register screen.
	if user == nil {
		s.rollbackTransaction(tx)
		return nil, errors.New("astrologer not found")
	}

	// Role, active and deleted validation, shared with /verify-login so the two
	// calls cannot disagree about whether an account may log in.
	if err = validateLoginableUser(user); err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	// The astrologer row does not exist until profile step 1 is saved. Missing is
	// a state to report, not a failure: it means the profile flow starts at 1.
	astrologer, err := s.repository.FindAstrologerByUserID(tx, user.ID)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	// Save OTP
	otp, err := s.loginOTP(tx, user)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	// Device
	err = s.repository.UpdateDeviceInfo(tx, user.ID, canonicalDeviceType(req.DeviceType), req.DeviceToken)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	err = s.commitTransaction(tx)

	if err != nil {

		return nil, err
	}

	// An account whose registration OTPs are still pending goes back to those
	// screens, and the OTP just written verifies there too.
	nextScreen := ScreenVerifyLoginOTP

	if pending := RegistrationScreen(user); pending != "" {
		nextScreen = pending
	}

	pendingStep := PendingProfileStep(astrologer)

	response := dto_astrologer.LoginResponse{
		UserID:     user.ID,
		Mobile:     user.ContactNo,
		OTPSent:    true,
		OTP:        echoOTP(otp.MobileOTP),
		ExpiresIn:  OTPExpirySeconds(),
		NextScreen: nextScreen,

		AfterOTPScreen:   ProfileScreen(astrologer),
		MobileVerified:   user.MobileVerified,
		EmailVerified:    user.EmailVerified,
		PendingStep:      pendingStep,
		PendingStepTitle: ProfileStepTitle(pendingStep),
		CanSubmit:        AllProfileStepsDone(astrologer),
		ProfileStatus:    constants.ProfileIncomplete,
	}

	if astrologer != nil {

		response.ProfileStatus = astrologer.ProfileStatus
		response.AdminStatus = astrologer.AdminStatus
		response.CompletionPercentage = astrologer.CompletionPercentage
		response.IsProfileCompleted = astrologer.IsProfileCompleted
	}

	// Delivery is best effort and runs after the commit, so a carrier failure
	// cannot roll back an OTP the row already holds. The email OTP goes out too
	// when that channel is still unverified, since that is the screen this login
	// is about to send the astrologer to.
	DeliverMobileOTP(user.ContactNo, otp.MobileOTP)

	if !user.EmailVerified {
		DeliverEmailOTP(user.Email, otp.EmailOTP)
	}

	return response, nil
}

// loginOTP refreshes the account's OTP row for a login, and writes one when the
// row has gone missing, so a purged OTP row cannot lock an astrologer out.
//
// The value comes from NewOTP: static outside production, random within it. The
// resend counter is cleared with the rest, since this is a brand new window.
func (s *authService) loginOTP(
	tx *gorm.DB,
	user *models.User,
) (*models.UserOTP, error) {

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

	mobileOTP, err := NewOTP()

	if err != nil {
		return nil, err
	}

	otp, err := s.repository.FindOTPByUserID(tx, user.ID)

	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, err
	}

	if otp == nil {

		emailOTP, err := NewOTP()

		if err != nil {
			return nil, err
		}

		userID := user.ID

		otp = &models.UserOTP{
			UserID:           &userID,
			Mobile:           user.ContactNo,
			Email:            user.Email,
			MobileOTP:        mobileOTP,
			EmailOTP:         emailOTP,
			MaxAttempts:      MaxOTPAttempts,
			IsMobileVerified: user.MobileVerified,
			IsEmailVerified:  user.EmailVerified,
			ExpiresAt:        &expiry,
		}

		if err := s.repository.CreateUserOTP(tx, otp); err != nil {
			return nil, err
		}

		return otp, nil
	}

	otp.MobileOTP = mobileOTP
	otp.MobileAttempts = 0
	otp.ResendCount = 0
	otp.ExpiresAt = &expiry

	// An account that never finished registering is sent back to the email
	// verify screen, so that OTP is refreshed too — otherwise login handed out a
	// fresh window while leaving a stale, possibly expired, email OTP behind it.
	if !user.EmailVerified {

		emailOTP, err := NewOTP()

		if err != nil {
			return nil, err
		}

		otp.EmailOTP = emailOTP
		otp.EmailAttempts = 0
	}

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

	return otp, nil
}

// VerifyLoginOTP verifies the login OTP, issues the tokens and reports the screen
// the astrologer belongs on.
//
// It no longer writes an APPROVED profile status at login (those writes were also
// silently dropped, since UpdateUserToken only saves the two tokens and
// last_login), and it no longer dresses the response up as a complete, available,
// online profile. next_screen now comes from the row itself, so an unfinished
// profile resumes at pending_step and a submitted one waits under review.
func (s *authService) VerifyLoginOTP(req dto_astrologer.VerifyOTPRequest) (interface{}, error) {

	tx, err := s.beginTransaction()

	if err != nil {
		return nil, err
	}

	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	// Find User. Role filtered like Login, so a user-app account on the same
	// mobile cannot answer for the astrologer here.
	user, err := s.repository.FindLoginUserByMobile(tx, req.Mobile)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	// Role, active and deleted validation, the same set login applies. Without
	// it an account deactivated between /login and /verify-login still got a
	// token out of this call.
	if err = validateLoginableUser(user); err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	// Find OTP

	otp, err := s.repository.FindOTPByUserID(tx, user.ID)

	if err != nil {

		s.rollbackTransaction(tx)

		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, errors.New("otp not found")
		}

		return nil, err
	}

	if otp == nil {

		s.rollbackTransaction(tx)

		return nil, errors.New("otp not found")
	}

	// OTP Expiry

	if otpExpired(otp) {

		s.rollbackTransaction(tx)

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

	// Max Attempts, as the email verify already does. Login resets the counter
	// every time it sends an OTP, so this only bounds guesses within one attempt.

	if otp.MaxAttempts > 0 && otp.MobileAttempts >= otp.MaxAttempts {

		s.rollbackTransaction(tx)

		return nil, errors.New("maximum otp attempts exceeded")
	}

	// OTP Match

	if otp.MobileOTP != req.OTP {

		s.rollbackTransaction(tx)

		s.recordFailedOTPAttempt(otp.ID, "mobile")

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

	otp.MobileAttempts = 0

	// The mobile has just proved itself, so record it. An account that only ever
	// logged in stayed mobile_verified = false, which sent RegistrationScreen on
	// pushing it back to the verify-mobile screen after every single login.
	otp.IsMobileVerified = true

	if err := s.repository.UpdateOTP(tx, otp); err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	if !user.MobileVerified {

		user.MobileVerified = true

		if err := s.repository.UpdateUser(tx, user); err != nil {

			s.rollbackTransaction(tx)

			return nil, err
		}
	}

	// Generate JWT

	token, err := GenerateJWT(user.ID, user.RoleID)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	refreshToken, err := GenerateRefreshToken(user.ID, user.RoleID)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	user.JwtToken = token
	user.RefreshToken = refreshToken

	err = s.repository.UpdateUserToken(
		tx,
		user,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	// Logging in puts the astrologer online.
	if err := repositories_astrologer.SetAstrologerPresenceByUserID(tx, user.ID, true); err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	// astrologer profile fetch. Absent until profile step 1 is saved, so the
	// preloaded read only runs when there is a row to read.
	astrologer, err := s.repository.FindAstrologerByUserID(tx, user.ID)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	var profile *models.Astrologer

	if astrologer != nil {

		profile, err = s.repository.GetAstrologerProfileByUserID(tx, user.ID)

		if err != nil {

			s.rollbackTransaction(tx)

			return nil, err
		}
	}

	// Last Login

	err = s.repository.UpdateLastLogin(
		tx,
		user.ID,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	// Device and session: token onto the users row, one login_histories row.
	s.recordDeviceSession(tx, user.ID, req.DeviceSession)

	// Commit

	err = s.commitTransaction(tx)

	if err != nil {

		return nil, err
	}

	nextScreen := ProfileScreen(astrologer)

	if pending := RegistrationScreen(user); pending != "" {
		nextScreen = pending
	}

	pendingStep := PendingProfileStep(astrologer)

	response := dto_astrologer.VerifyLoginOTPResponse{
		Token:            token,
		RefreshToken:     refreshToken,
		NextScreen:       nextScreen,
		PendingStep:      pendingStep,
		PendingStepTitle: ProfileStepTitle(pendingStep),
		CanSubmit:        AllProfileStepsDone(astrologer),
		ProfileStatus:    constants.ProfileIncomplete,
	}

	if profile != nil {

		response.ProfileStatus = profile.ProfileStatus
		response.AdminStatus = profile.AdminStatus
		response.IsProfileCompleted = profile.IsProfileCompleted
		response.CompletionPercentage = profile.CompletionPercentage

		response.Astrologer = dto_astrologer.AstrologerResponse{
			ID:                   profile.ID,
			UserID:               profile.UserID,
			Name:                 profile.Name,
			Email:                profile.Email,
			ContactNo:            profile.ContactNo,
			ProfileImage:         profile.ProfileImage,
			Bio:                  profile.Bio,
			ExperienceYears:      profile.ExperienceYears,
			CompletionPercentage: profile.CompletionPercentage,
			CurrentStep:          profile.CurrentStep,
			ProfileStatus:        profile.ProfileStatus,
			VerificationStatus:   profile.VerificationStatus,
			AdminStatus:          profile.AdminStatus,
			IsVerified:           profile.IsVerified,
			IsAvailable:          profile.IsAvailable != 0,

			Languages:     profile.Languages,
			Skills:        profile.Skills,
			Educations:    profile.Educations,
			Professionals: profile.Professionals,
			Experiences:   profile.Experiences,
			BankAccount:   profile.BankAccount,
			Documents:     profile.Documents,
		}
	}

	return response, nil
}

func generateOTP() (string, error) {

	max := big.NewInt(900000)

	n, err := rand.Int(rand.Reader, max)

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

	return fmt.Sprintf("%06d", n.Int64()+100000), nil

}

func hashPassword(password string) (string, error) {

	hash, err := bcrypt.GenerateFromPassword(
		[]byte(password),
		bcrypt.DefaultCost,
	)

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

	return string(hash), nil

}

func (s *authService) beginTransaction() (*gorm.DB, error) {

	tx := s.repository.BeginTx()

	if tx.Error != nil {
		return nil, tx.Error
	}

	return tx, nil

}

func (s *authService) commitTransaction(tx *gorm.DB) error {

	return tx.Commit().Error

}

func (s *authService) rollbackTransaction(tx *gorm.DB) {

	if tx != nil {

		tx.Rollback()

	}

}

func (s *authService) RefreshToken(req dto_astrologer.RefreshTokenRequest) (interface{}, error) {

	tx, err := s.beginTransaction()

	if err != nil {
		return nil, err
	}

	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	//----------------------------------
	// Validate Refresh Token
	//----------------------------------

	claims, err := ValidateRefreshToken(req.RefreshToken)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, errors.New("invalid refresh token")
	}

	userID, err := ClaimUserID(claims)

	if err != nil {

		s.rollbackTransaction(tx)

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

	//----------------------------------
	// Find User
	//----------------------------------

	user, err := s.repository.FindUserByID(
		tx,
		userID,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	if user == nil {

		s.rollbackTransaction(tx)

		return nil, errors.New("user not found")
	}

	// Same account checks as login, so a deleted or demoted account cannot keep
	// renewing its session off an old refresh token.
	if err = validateLoginableUser(user); err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	//----------------------------------
	// Match Refresh Token
	//----------------------------------

	if user.RefreshToken != req.RefreshToken {

		s.rollbackTransaction(tx)

		return nil, errors.New("refresh token mismatch")
	}

	//----------------------------------
	// Generate New Tokens
	//----------------------------------

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

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	refreshToken, err := GenerateRefreshToken(
		user.ID,
		user.RoleID,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	user.JwtToken = accessToken
	user.RefreshToken = refreshToken

	err = s.repository.UpdateUserToken(
		tx,
		user,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	if err := s.commitTransaction(tx); err != nil {
		return nil, err
	}

	// Derived from AccessTokenExpire rather than a literal 2592000, which would
	// go stale the moment the token lifetime is changed.
	return dto_astrologer.RefreshTokenResponse{
		Token:        accessToken,
		RefreshToken: refreshToken,
		ExpiresIn:    int64(AccessTokenExpire.Seconds()),
		TokenType:    "Bearer",
	}, nil
}

func (s *authService) Logout(userID uint) (interface{}, error) {

	tx, err := s.beginTransaction()
	if err != nil {
		return nil, err
	}

	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	//-----------------------------------------
	// Find User
	//-----------------------------------------

	user, err := s.repository.FindUserByID(tx, userID)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	if user == nil {
		s.rollbackTransaction(tx)
		return nil, errors.New("user not found")
	}

	//-----------------------------------------
	// Logout
	//-----------------------------------------

	err = s.repository.LogoutUser(tx, userID)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	if err := repositories_astrologer.SetAstrologerPresenceByUserID(tx, userID, false); err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	//-----------------------------------------

	if err := s.commitTransaction(tx); err != nil {
		return nil, err
	}

	return dto_astrologer.LogoutResponse{
		Message: "Logout successful.",
	}, nil
}

func (s *authService) GetLoggedInProfile(
	userID uint,
) (interface{}, error) {

	tx, err := s.beginTransaction()

	if err != nil {
		return nil, err
	}

	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	profile, err := s.repository.GetLoggedInProfile(
		tx,
		userID,
	)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	// The row alone is not the profile: its skill and language columns hold
	// ids, the pincode lives on the users row, and the rating, follower and
	// session figures are counted elsewhere.
	meta, err := s.repository.GetProfileMeta(tx, profile)

	if err != nil {

		s.rollbackTransaction(tx)

		return nil, err
	}

	if err := s.commitTransaction(tx); err != nil {
		return nil, err
	}

	return dto_astrologer.LoggedInProfileResponse{

		Astrologer: profile,

		PrimarySkill:  meta.PrimarySkillName,
		AllSkill:      meta.AllSkillNames,
		LanguageKnown: meta.LanguageNames,

		DisplayName: meta.DisplayName,

		Pincode: meta.Pincode,
		Address: meta.Address,

		TotalRating:    meta.TotalRating,
		AverageRating:  meta.AverageRating,
		TotalFollowers: meta.TotalFollowers,
		TotalSession:   meta.TotalSession,

		ProfileCompletion: meta.Completion,
		StatusLogs:        meta.StatusLogs,

		InstagramLink: meta.Social.Instagram,
		FacebookLink:  meta.Social.Facebook,
		YoutubeLink:   meta.Social.Youtube,
		TelegramLink:  meta.Social.Telegram,
		LinkedinLink:  meta.Social.Linkedin,
		WebsiteLink:   meta.Social.Website,
	}, nil
}

func (s *authService) UpdateDeviceToken(userID uint, req dto_astrologer.UpdateDeviceTokenRequest) (interface{}, error) {

	tx, err := s.beginTransaction()
	if err != nil {
		return nil, err
	}

	defer func() {
		if r := recover(); r != nil {
			s.rollbackTransaction(tx)
		}
	}()

	user, err := s.repository.FindUserByID(tx, userID)
	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	if user == nil {
		s.rollbackTransaction(tx)
		return nil, errors.New("user not found")
	}

	deviceType, err := normalizeDeviceType(req.DeviceType)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	err = s.repository.UpdateDeviceToken(
		tx,
		userID,
		deviceType,
		req.DeviceToken,
	)

	if err != nil {
		s.rollbackTransaction(tx)
		return nil, err
	}

	if err := s.commitTransaction(tx); err != nil {
		return nil, err
	}

	return dto_astrologer.UpdateDeviceTokenResponse{
		UserID:      userID,
		DeviceType:  deviceType,
		DeviceToken: req.DeviceToken,
	}, nil
}

// normalizeDeviceType accepts a device type in any casing and returns the
// canonical uppercase spelling that gets stored.
//
// The binding tag used to demand uppercase, so a client sending "android" was
// rejected before the handler ran.
func normalizeDeviceType(deviceType string) (string, error) {

	normalized := canonicalDeviceType(deviceType)

	switch normalized {

	case "ANDROID", "IOS", "WEB":
		return normalized, nil

	default:
		return "", errors.New("device_type must be one of ANDROID, IOS or WEB")
	}
}

// canonicalDeviceType is the lenient form used by register and login, where the
// device type is incidental and must never fail the call. It only normalises the
// casing, so all three entry points store the same spelling.
// recordDeviceSession writes what /verify-email and /verify-login receive
// about the handset: the FCM token onto users (device_token, fcm_token, token)
// and one login_histories row with ip_address, device_name and platform.
//
// Not fatal, like the user side's register: a verified OTP must not be refused
// over a history row. An empty token is skipped so it cannot wipe a live one.
func (s *authService) recordDeviceSession(tx *gorm.DB, userID uint, device dto_astrologer.DeviceSession) {

	if token := strings.TrimSpace(device.DeviceToken); token != "" {
		if err := s.repository.UpdateDeviceInfo(tx, userID, device.ResolvedDeviceType(), token); err != nil {
			log.Printf("astrologer verify: saving the device token for user %d failed: %v", userID, err)
		}
	}

	if !device.HasSession() {
		return
	}

	now := time.Now()

	history := models.LoginHistory{
		UserID:     userID,
		IPAddress:  strings.TrimSpace(device.IPAddress),
		DeviceName: strings.TrimSpace(device.DeviceName),
		Platform:   strings.TrimSpace(device.Platform),
		LoginAt:    &now,
	}

	if err := s.repository.SaveLoginHistory(tx, &history); err != nil {
		log.Printf("astrologer verify: saving the login history for user %d failed: %v", userID, err)
	}
}

func canonicalDeviceType(deviceType string) string {

	return strings.ToUpper(strings.TrimSpace(deviceType))
}
