package auth

import (
	"bytes"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"time"

	"git.mstar.dev/mstar/goutils/sliceutils"
	"golang.org/x/crypto/argon2"

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

// Len of salt for passwords in bytes
const saltLen = 32

// Generate a random salt with the given nr of bytes
func generateSalt(length int) ([]byte, error) {
	salt := make([]byte, length)
	if _, err := rand.Read(salt); err != nil {
		return nil, err
	}
	return salt, nil
}

// Hash a password with salt
func hashPassword(password string) (hash []byte, err error) {
	salt, err := generateSalt(saltLen)
	if err != nil {
		return nil, err
	}
	hash = argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
	hash = append(hash, salt...)
	// return bcrypt.GenerateFromPassword([]byte(password), 14)
	return hash, nil
}

// Check wether a password matches a hash
func comparePassword(password string, hash []byte) bool {
	// Hash is actually hash(password)+salt
	salt := hash[len(hash)-saltLen:]

	return bytes.Equal(argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32), hash)
}

// Copied and adjusted from: https://bruinsslot.jp/post/golang-crypto/

// Encrypt some data using a key
func Encrypt(key, data []byte) ([]byte, error) {
	key, salt, err := deriveKey(key, nil)
	if err != nil {
		return nil, err
	}

	blockCipher, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}

	gcm, err := cipher.NewGCM(blockCipher)
	if err != nil {
		return nil, err
	}

	nonce := make([]byte, gcm.NonceSize())
	if _, err = rand.Read(nonce); err != nil {
		return nil, err
	}

	ciphertext := gcm.Seal(nonce, nonce, data, nil)

	ciphertext = append(ciphertext, salt...)

	return ciphertext, nil
}

// Decrypt some data using a key
func Decrypt(key, data []byte) ([]byte, error) {
	salt, data := data[len(data)-32:], data[:len(data)-32]

	key, _, err := deriveKey(key, salt)
	if err != nil {
		return nil, err
	}

	blockCipher, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}

	gcm, err := cipher.NewGCM(blockCipher)
	if err != nil {
		return nil, err
	}

	nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]

	plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return nil, err
	}

	return plaintext, nil
}

// Derive a key from a password and optionally salt.
// Returns the hash and salt used
func deriveKey(password, salt []byte) ([]byte, []byte, error) {
	if salt == nil {
		salt = make([]byte, 32)
		if _, err := rand.Read(salt); err != nil {
			return nil, nil, err
		}
	}

	key := argon2.IDKey(password, salt, 1, 64*1024, 4, 32)
	return key, salt, nil
}

// Calculate the expiration timestamp from the call of the function
func calcAccessExpirationTimestamp() time.Time {
	// For now, the default expiration is one month after creation
	// though "never" might also be a good option
	return time.Now().Add(time.Hour * 24 * 30)
}

// Convert a list of authentication methods into a [LoginNextState] bitflag.
// [isStart] determines whether to allow authentication methods that start
// a login process or complete one
func ConvertNewStorageAuthMethodsToLoginState(
	methods []models.AuthenticationMethodType,
	isStart bool,
) LoginNextState {
	translatedMethods := sliceutils.Map(methods, oneStorageAuthToLoginState)
	// Filter out only the valid methods for the current request
	valids := sliceutils.Filter(translatedMethods, func(t LoginNextState) bool {
		if isStart {
			return t == LoginStartPasskey || t == LoginStartPassword
		} else {
			return t == LoginNext2FaTotp || t == LoginNext2FaPasskey || t == LoginNext2FaMail
		}
	})
	// And then compact them down into one bit flag
	return sliceutils.Compact(
		valids,
		func(acc, next LoginNextState) LoginNextState { return acc | next },
	)
}

// Translate one [models.AuthenticationMethodType] to one [LoginNextState]
func oneStorageAuthToLoginState(in models.AuthenticationMethodType) LoginNextState {
	switch in {
	case models.AuthMethodGAuth:
		return LoginNext2FaTotp
	case models.AuthMethodMail:
		return LoginNext2FaMail
	case models.AuthMethodPasskey:
		return LoginStartPasskey
	case models.AuthMethodPasskey2fa:
		return LoginNext2FaPasskey
	case models.AuthMethodPassword:
		return LoginStartPassword
	default:
		return LoginUnknown
	}
}

// Check whether a given username can log in.
// It only provides a yes/no answer and an error in case some check failed
// due to unforseen circumstances. Though the error will always be ErrCantLogin
// in case the known information (excluding database or other failures) prevents
// a login
//
// TODO: Decide whether to include the reason for disallowed login
func (a *Authenticator) canUsernameLogin(username string) (bool, error) {
	acc, err := dbgen.User.GetByUsername(username)
	if err != nil {
		return false, err
	}
	if !acc.FinishedRegistration {
		return false, ErrCantLogin
	}

	finalRole := storage.CollapseRolesIntoOne(
		sliceutils.Map(acc.Roles, func(t models.UserToRole) models.Role {
			return t.Role
		})...)
	if finalRole.CanLogin != nil && !*finalRole.CanLogin {
		return false, ErrCantLogin
	}

	return true, nil
}
