package auth

import (
	"encoding/json"
	"net/http"
	"strings"
	"time"

	"git.mstar.dev/mstar/goutils/other"
	"github.com/go-webauthn/webauthn/protocol"
	"github.com/go-webauthn/webauthn/webauthn"
	"gorm.io/gorm/clause"

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

// TODO: Check if passkey encryption is viable
// Check if encryption for passkey info data is viable to implement
// and if we should do it.
// Encrypting it would probably require making a custom wrapper struct,
// if even possible. Reason being that login completion still requires to update
// the data post-creation, including matching on unique and stable elements
// of said data

// Start the login process via passkey for a given username.
// Returns the credential options the passkey needs to sign
func (a *Authenticator) StartPasskeyLogin(
	username string,
) (*protocol.CredentialAssertion, string, 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 acquire user for login", err)
	}
	wrappedAcc := fakeUser{acc}
	options, session, err := a.webauthn.BeginLogin(&wrappedAcc)
	if err != nil {
		return nil, "", other.Error("auth", "failed to initiate passkey login", err)
	}
	pkeySession := models.LoginProcessToken{
		User:      *acc,
		UserId:    acc.ID,
		ExpiresAt: time.Now().Add(time.Minute * 3),
		// Abuse name for session storage since token must be a uuid
		Name: string(other.Must(json.Marshal(session))),
	}
	err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).
		Omit(dbgen.LoginProcessToken.Token).
		Create(&pkeySession)
	if err != nil {
		return nil, "", other.Error("auth", "failed to create login process token", err)
	}
	return options, pkeySession.Token, nil
}

// Complete a passkey login request
// Takes the username logging in as well as the raw request containing the passkey response
func (a *Authenticator) CompletePasskeyLogin(
	username string,
	sessionId string,
	response *http.Request,
) (accessToken string, err error) {
	// Get user in question
	acc, err := dbgen.User.GetByUsername(username)
	if err != nil {
		return "", other.Error("auth", "failed to get user for passkey login completion", err)
	}
	// Get latest login token data
	loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.Token.Eq(sessionId)).
		First()
	if err != nil {
		return "", other.Error(
			"auth",
			"failed to get user's login token for passkey login completion",
			err,
		)
	}
	// Check if that token has expired
	if loginToken.ExpiresAt.Before(time.Now()) {
		return "", ErrProcessTimeout
	}
	var pkeySession webauthn.SessionData
	err = json.Unmarshal([]byte(loginToken.Name), &pkeySession)
	if err != nil {
		return "", other.Error("auth", "failed to unmarshal passkey session for user", err)
	}
	// Hand data to webauthn for completion
	newSession, err := a.webauthn.FinishLogin(&fakeUser{acc}, pkeySession, response)
	if err != nil {
		return "", other.Error("auth", "passkey completion failed", err)
	}
	// TODO: Utilise clone warning
	// newSession.Authenticator.CloneWarning

	jsonSessionId, err := json.Marshal(newSession.ID)
	if err != nil {
		return "", other.Error("auth", "failed to marshal session", err)
	}
	jsonSession, err := json.Marshal(newSession.ID)
	if err != nil {
		return "", other.Error("auth", "failed to marshal session", err)
	}
	// Update credentials
	// WARN: I am not sure if this will work
	// Using the ID of the passkey session *should* be unique enough to identify the correct one
	// Of course, even then, there's still the problem of matching as
	// I can't yet guarantee that the parsed json content for the ID would be the same
	_, err = dbgen.UserAuthMethod.Where(dbgen.UserAuthMethod.Token.Like("%"+string(jsonSessionId)+"%")).
		Update(dbgen.UserAuthMethod.Token, jsonSession)
	if err != nil {
		return "", other.Error("auth", "failed to update credentials", err)
	}
	// And delete the login token
	_, err = dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.UserId.Eq(acc.ID)).
		Delete(loginToken)
	if err != nil {
		return "", other.Error("auth", "failed to delete login process", err)
	}
	dbAccessToken := models.AccessToken{
		User:      *acc,
		UserId:    acc.ID,
		ExpiresAt: calcAccessExpirationTimestamp(),
	}
	err = dbgen.AccessToken.Omit(dbgen.AccessToken.Token).Create(&dbAccessToken)
	if err != nil {
		return "", other.Error("auth", "failed to generate access token", err)
	}

	return dbAccessToken.Token, nil
}

// Start the process of registrating a passkey to an account
func (a *Authenticator) StartPasskeyRegistration(
	username string,
	passkeyName string,
) (*protocol.CredentialCreation, string, 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 acquire user for login", err)
	}
	wrappedAcc := fakeUser{acc}
	options, session, err := a.webauthn.BeginRegistration(&wrappedAcc)
	jsonSession, err := json.Marshal(session)
	if err != nil {
		return nil, "", other.Error("auth", "failed to marshal session to json", err)
	}
	pkeySession := models.LoginProcessToken{
		User:      *acc,
		UserId:    acc.ID,
		ExpiresAt: time.Now().Add(time.Minute * 3),
		// Abuse name for storing session and passkey name since token must be a uuid
		Name: passkeyName + "---" + string(jsonSession),
	}
	err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).
		Omit(dbgen.LoginProcessToken.Token).
		Create(&pkeySession)
	if err != nil {
		return nil, "", other.Error("auth", "failed to create login process token", err)
	}
	return options, pkeySession.Token, nil
}

func (a *Authenticator) CompletePasskeyRegistration(
	username string,
	sessionId string,
	response *http.Request,
) error {
	// Get latest login token data
	loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.Token.Eq(sessionId)).
		First()
	if err != nil {
		return other.Error(
			"auth",
			"failed to get user's login token for passkey login completion",
			err,
		)
	}
	// Check if that token has expired
	if loginToken.ExpiresAt.Before(time.Now()) {
		return ErrProcessTimeout
	}
	var pkeySession webauthn.SessionData
	passkeyName, jsonSession, found := strings.Cut(loginToken.Name, "---")
	if !found {
		return ErrInvalidPasskeyRegistrationData
	}
	err = json.Unmarshal([]byte(jsonSession), &pkeySession)
	if err != nil {
		return other.Error("auth", "failed to unmarshal passkey session for user", err)
	}
	wrappedAcc := fakeUser{&loginToken.User}
	credential, err := a.webauthn.FinishRegistration(&wrappedAcc, pkeySession, response)
	if err != nil {
		return other.Error("auth", "failed to complete passkey registration", err)
	}
	jsonCredential, err := json.Marshal(credential)
	if err != nil {
		return other.Error("auth", "failed to marshal credential to json", err)
	}
	authData := models.UserAuthMethod{
		User:       loginToken.User,
		UserId:     loginToken.UserId,
		AuthMethod: models.AuthMethodPasskey,
		Name:       passkeyName,
		Token:      jsonCredential,
	}
	err = dbgen.UserAuthMethod.Create(&authData)
	if err != nil {
		return other.Error("auth", "failed to insert new auth method into db", err)
	}
	return nil
}
