package auth

// Some helpful comments from: https://waters.me/internet/google-authenticator-implementation-note-key-length-token-reuse/

import (
	"strings"
	"time"

	"git.mstar.dev/mstar/goutils/other"
	"git.mstar.dev/mstar/goutils/sliceutils"
	"github.com/pquerna/otp"
	"github.com/pquerna/otp/totp"
	"gorm.io/gorm/clause"

	"git.mstar.dev/mstar/linstrom/config"
	"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
	"git.mstar.dev/mstar/linstrom/storage-new/models"
)

const totpUnverifiedSuffix = "-NOT_VERIFIED"
const totpTokenNoLongerRecentlyUsed = time.Second * 90

// Perform a 2nd factor totp based login
func (a *Authenticator) PerformTotpLogin(
	username string,
	sessionId uint64,
	totpToken string,
) (LoginNextState, string, error) {
	// First check if that token has been seen recently for that user
	if timestamp, found := a.recentlyUsedTotpTokens[totpToken+"+"+username]; found {
		if timestamp.Add(totpTokenNoLongerRecentlyUsed).After(time.Now()) {
			return LoginNextFailure, "", ErrTotpRecentlyUsed
		} else {
			delete(a.recentlyUsedTotpTokens, totpToken+"+"+username)
		}
	}
	// Then ensure user is allowed to log in
	if ok, err := a.canUsernameLogin(username); !ok {
		return 0, "", other.Error("auth", "user may not login", err)
	}
	// DO NOT fetch the account directly. Go via session id
	// otherwise you could in theory log in with only the totp token
	// which obviously is a huge risk with it being at most 1'000'000 unique values.
	// And even with rate limiting (performed by one of the upper layers)
	// this wouldn't take too long
	loginSession, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.ID.Eq(sessionId)).
		First()
	if err != nil {
		return 0, "", other.Error("auth", "no login session with this id", err)
	}
	acc := loginSession.User
	dbSecrets := sliceutils.Filter(acc.AuthMethods, func(t models.UserAuthMethod) bool {
		return t.AuthMethod == models.AuthMethodGAuth
	})
	encryptedSecrets := sliceutils.Map(
		dbSecrets,
		func(t models.UserAuthMethod) string {
			return string(t.Token)
		},
	)
	secrets := []string{}
	for _, key := range encryptedSecrets {
		decrypted, err := Decrypt([]byte(config.GlobalConfig.Storage.EncryptionKey), []byte(key))
		if err != nil {
			return 0, "", other.Error(
				"auth",
				"failed to decrypt secret",
				&CombinedError{ErrDecryptionFailure, err},
			)
		}
		secrets = append(secrets, string(decrypted))
	}
	found := false
	foundIndex := -1
	for i, secret := range secrets {
		if totp.Validate(totpToken, secret) {
			found = true
			foundIndex = i
			break
		}
	}
	if !found {
		return LoginNextFailure, "", other.Error(
			"auth",
			"no fitting credential found",
			ErrInvalidCombination,
		)
	}
	// If not verified yet, mark as verified
	if strings.HasSuffix(dbSecrets[foundIndex].Name, totpUnverifiedSuffix) {
		dbgen.UserAuthMethod.
			Where(dbgen.UserAuthMethod.ID.
				Eq(dbSecrets[foundIndex].ID)).
			Update(dbgen.UserAuthMethod.Name, strings.TrimSuffix(dbSecrets[foundIndex].Name, totpUnverifiedSuffix))
	}

	// store this token and username combination as recently used
	a.recentlyUsedTotpTokens[totpToken+"+"+username] = time.Now()

	// Generate access token and return since totp would be the end station for 2fa
	token := models.AccessToken{
		User:      acc,
		UserId:    acc.ID,
		ExpiresAt: time.Now().Add(time.Hour * 24 * 365),
	}
	err = dbgen.AccessToken.
		// technically, the chance of a conflict is so incredibly low that it should never ever occur
		// since both the username and a randomly generated uuid would need to be created the same. Twice
		// But, just in case, do this
		Clauses(clause.OnConflict{DoNothing: true}).
		Omit(dbgen.AccessToken.Token).
		Create(&token)
	if err != nil {
		return LoginNextFailure, "", other.Error(
			"auth",
			"failed to create new access token",
			err,
		)
	}
	return LoginNextSucess, token.Token, nil
}

// Create a new totp key for a user.
// The key is marked as not verified until it is sucessfully used once
func (a *Authenticator) StartTotpRegistration(
	username string,
	tokenName string,
) (*otp.Key, error) {
	if ok, err := a.canUsernameLogin(username); !ok {
		return nil, other.Error("auth", "user may not login", err)
	}
	acc, err := dbgen.User.GetByUsername(username)
	if err != nil {
		return nil, other.Error("auth", "failed to find account", err)
	}
	key, err := totp.Generate(totp.GenerateOpts{
		Issuer:      config.GlobalConfig.General.GetFullDomain(),
		AccountName: username,
		SecretSize:  160,
	})
	if err != nil {
		return nil, err
	}
	secret := key.Secret()
	encryptedSecret, err := Encrypt(
		[]byte(config.GlobalConfig.Storage.EncryptionKey),
		[]byte(secret),
	)
	if err != nil {
		return nil, other.Error("auth", "failed to encrypt secret", err)
	}
	authToken := models.UserAuthMethod{
		UserId:     acc.ID,
		User:       *acc,
		Token:      encryptedSecret,
		AuthMethod: models.AuthMethodGAuth,
		Name:       tokenName + totpUnverifiedSuffix,
	}
	err = dbgen.UserAuthMethod.Create(&authToken)
	if err != nil {
		return nil, err
	}
	return key, nil
}
