package handlers

import (
	"crypto/subtle"
	"encoding/base64"
	"fmt"
	"path"
	"strings"

	"github.com/google/uuid"
	"github.com/valyala/fasthttp"

	"github.com/authelia/authelia/v4/internal/authentication"
	"github.com/authelia/authelia/v4/internal/middlewares"
	"github.com/authelia/authelia/v4/internal/model"
	"github.com/authelia/authelia/v4/internal/session"
	"github.com/authelia/authelia/v4/internal/templates"
)

// UserSessionElevationGET returns the session elevation status.
//
//nolint:gocyclo
func UserSessionElevationGET(ctx *middlewares.AutheliaCtx) {
	var (
		userSession session.UserSession
		err         error
	)

	response := &bodyGETUserSessionElevate{}

	if userSession, err = ctx.GetSession(); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred retrieving user session elevation state: %s", errStrUserSessionData)

		ctx.SetJSONError(messageOperationFailed)
		ctx.SetStatusCode(fasthttp.StatusForbidden)

		return
	}

	if userSession.IsAnonymous() {
		ctx.Logger.WithError(errUserAnonymous).Error("Error occurred retrieving user session elevation state")

		ctx.SetJSONError(messageOperationFailed)
		ctx.SetStatusCode(fasthttp.StatusForbidden)

		return
	}

	switch level := userSession.AuthenticationLevel(ctx.Configuration.WebAuthn.EnablePasskey2FA); {
	case level >= authentication.TwoFactor:
		if ctx.Configuration.IdentityValidation.ElevatedSession.SkipSecondFactor {
			response.SkipSecondFactor = true
		}
	case level == authentication.OneFactor:
		var (
			has  bool
			info model.UserInfo
		)

		response.FactorKnowledge = userSession.AuthenticationMethodRefs.FactorKnowledge()

		info, err = ctx.Providers.StorageProvider.LoadUserInfo(ctx, userSession.Username)

		has = info.HasTOTP || info.HasWebAuthn || info.HasDuo

		if ctx.Configuration.IdentityValidation.ElevatedSession.RequireSecondFactor {
			if err != nil || has {
				response.RequireSecondFactor = true
			}
		} else if ctx.Configuration.IdentityValidation.ElevatedSession.SkipSecondFactor && has {
			response.CanSkipSecondFactor = true
		}
	}

	if userSession.Elevations.User != nil {
		var deleted bool

		response.Elevated = true
		response.Expires = int(userSession.Elevations.User.Expires.Sub(ctx.Clock.Now()).Seconds())

		if userSession.Elevations.User.Expires.Before(ctx.Clock.Now()) {
			ctx.Logger.WithFields(map[string]any{"username": userSession.Username, "expired": userSession.Elevations.User.Expires.Unix()}).
				Info("The user session elevation has already expired so it has been destroyed")

			response.Elevated, deleted = false, true
		}

		if !userSession.Elevations.User.RemoteIP.Equal(ctx.RemoteIP()) {
			ctx.Logger.WithFields(map[string]any{"username": userSession.Username, "elevation_ip": userSession.Elevations.User.RemoteIP.String()}).
				Warn("The user session elevation was created from a different remote IP so it has been destroyed")

			response.Expires, response.Elevated, deleted = 0, false, true
		}

		if deleted {
			userSession.Elevations.User = nil

			if err = ctx.SaveSession(userSession); err != nil {
				ctx.Logger.WithError(err).Error("Error occurred retrieving the user session elevation state: error occurred saving the user session data")

				ctx.SetJSONError(messageOperationFailed)
				ctx.SetStatusCode(fasthttp.StatusForbidden)

				return
			}
		}
	}

	if err = ctx.ReplyJSON(middlewares.OKResponse{Status: "OK", Data: response}, fasthttp.StatusOK); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred retrieving the user session elevation state for user '%s': %s", userSession.Username, errStrRespBody)

		ctx.SetJSONError(messageOperationFailed)

		ctx.SetStatusCode(fasthttp.StatusForbidden)

		return
	}
}

// UserSessionElevationPOST creates a new elevation session to be validated.
func UserSessionElevationPOST(ctx *middlewares.AutheliaCtx) {
	var (
		userSession session.UserSession
		err         error
	)
	if userSession, err = ctx.GetSession(); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred creating user session elevation One-Time Code challenge: %s", errStrUserSessionData)

		ctx.SetJSONError(messageOperationFailed)
		ctx.SetStatusCode(fasthttp.StatusForbidden)

		return
	}

	if userSession.IsAnonymous() {
		ctx.Logger.WithError(errUserAnonymous).Error("Error occurred creating user session elevation One-Time Code challenge")

		ctx.SetJSONError(messageOperationFailed)
		ctx.SetStatusCode(fasthttp.StatusForbidden)

		return
	}

	var (
		otp *model.OneTimeCode
	)

	if otp, err = model.NewOneTimeCode(ctx, userSession.Username, ctx.Configuration.IdentityValidation.ElevatedSession.Characters, ctx.Configuration.IdentityValidation.ElevatedSession.CodeLifespan); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred creating user session elevation One-Time Code challenge for user '%s': error occurred generating the challenge", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	var signature string

	if signature, err = ctx.Providers.StorageProvider.SaveOneTimeCode(ctx, *otp); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred creating user session elevation One-Time Code challenge for user '%s': error occurred saving the challenge to the storage backend", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	deleteID := base64.RawURLEncoding.EncodeToString(otp.PublicID[:])

	linkURL := ctx.RootURL()

	query := linkURL.Query()

	query.Set("id", deleteID)

	linkURL.Path = path.Join(linkURL.Path, "/revoke/one-time-code")
	linkURL.RawQuery = query.Encode()

	identity := userSession.Identity()

	domain, _ := ctx.GetCookieDomain()

	data := templates.EmailIdentityVerificationOTCValues{
		Title:              "Confirm your identity",
		RevocationLinkURL:  linkURL.String(),
		RevocationLinkText: "Revoke",
		DisplayName:        identity.DisplayName,
		RemoteIP:           ctx.RemoteIP().String(),
		Domain:             domain,
		OneTimeCode:        string(otp.Code),
	}

	ctx.Logger.WithFields(map[string]any{"signature": signature, "id": otp.PublicID.String(), "username": identity.Username}).
		Debug("Sending an email to user to confirm identity for session elevation")

	if err = ctx.Providers.Notifier.Send(ctx, identity.Address(), data.Title, ctx.Providers.Templates.GetIdentityVerificationOTCEmailTemplate(), data); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred creating user session elevation One-Time Code challenge for user '%s': error occurred sending the user the notification", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if err = ctx.SetJSONBody(&bodyPOSTUserSessionElevate{
		DeleteID: deleteID,
	}); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred creating user session elevation One-Time Code challenge for user '%s': %s", userSession.Username, errStrRespBody)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}
}

// UserSessionElevationPUT validates an elevation session and puts it into effect.
func UserSessionElevationPUT(ctx *middlewares.AutheliaCtx) {
	bodyJSON := bodyPUTUserSessionElevate{}

	var (
		userSession session.UserSession
		code        *model.OneTimeCode
		err         error
	)
	if userSession, err = ctx.GetSession(); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred validating user session elevation One-Time Code challenge: %s", errStrUserSessionData)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if userSession.IsAnonymous() {
		ctx.Logger.WithError(errUserAnonymous).Error("Error occurred validating user session elevation One-Time Code challenge")

		ctx.SetJSONError(messageOperationFailed)
		ctx.SetStatusCode(fasthttp.StatusForbidden)

		return
	}

	if err = ctx.ParseBody(&bodyJSON); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s': %s", userSession.Username, errStrReqBodyParse)

		ctx.SetStatusCode(fasthttp.StatusBadRequest)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	bodyJSON.OneTimeCode = strings.TrimSpace(strings.ToUpper(bodyJSON.OneTimeCode))

	if n := len(bodyJSON.OneTimeCode); n > 20 {
		ctx.Logger.Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s': expected maximum code length is %d but the user provided code was %d characters in length", userSession.Username, 20, n)

		ctx.SetStatusCode(fasthttp.StatusBadRequest)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code, err = ctx.Providers.StorageProvider.LoadOneTimeCode(ctx, userSession.Username, model.OTCIntentUserSessionElevation, bodyJSON.OneTimeCode); err != nil {
		ctx.Logger.WithError(err).
			Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s': error occurred retrieving the code challenge from the storage backend", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	} else if code == nil {
		ctx.Logger.WithError(fmt.Errorf("the code didn't match any recorded code challenges")).
			Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s': error occurred retrieving the code challenge from the storage backend", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code.ExpiresAt.Before(ctx.Clock.Now()) {
		ctx.Logger.WithError(fmt.Errorf("the code challenge has expired")).Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s'", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code.RevokedAt.Valid {
		ctx.Logger.WithError(fmt.Errorf("the code challenge has been revoked")).Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s'", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code.ConsumedAt.Valid {
		ctx.Logger.WithError(fmt.Errorf("the code challenge has already been consumed")).Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s'", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code.Intent != model.OTCIntentUserSessionElevation {
		ctx.Logger.WithError(fmt.Errorf("the code challenge has the '%s' intent but the '%s' intent is required", code.Intent, model.OTCIntentUserSessionElevation)).Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s'", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if subtle.ConstantTimeCompare(code.Code, []byte(bodyJSON.OneTimeCode)) != 1 {
		ctx.Logger.WithError(fmt.Errorf("the code does not match the code stored in the challenge")).Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s'", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	code.Consume(ctx)

	if err = ctx.Providers.StorageProvider.ConsumeOneTimeCode(ctx, code); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s': error occurred saving the consumption of the code to storage", userSession.Username)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	userSession.Elevations.User = &session.Elevation{
		ID:       code.ID,
		RemoteIP: ctx.RemoteIP(),
		Expires:  ctx.Clock.Now().Add(ctx.Configuration.IdentityValidation.ElevatedSession.ElevationLifespan),
	}

	if err = ctx.SaveSession(userSession); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred validating user session elevation One-Time Code challenge for user '%s': %s", userSession.Username, errStrUserSessionDataSave)

		ctx.SetStatusCode(fasthttp.StatusForbidden)
		ctx.SetJSONError(messageOperationFailed)

		return
	}

	ctx.ReplyOK()
}

// UserSessionElevateDELETE marks a pending elevation session as revoked.
func UserSessionElevateDELETE(ctx *middlewares.AutheliaCtx) {
	value := ctx.UserValue("id").(string)

	decoded := make([]byte, base64.RawURLEncoding.DecodedLen(len(value)))

	var (
		id   uuid.UUID
		code *model.OneTimeCode
		err  error
	)
	if _, err = base64.RawURLEncoding.Decode(decoded, []byte(value)); err != nil {
		ctx.Logger.WithError(err).
			Error("Error occurred revoking user session elevation One-Time Code challenge: error occurred decoding the identifier")

		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if id, err = uuid.FromBytes(decoded); err != nil {
		ctx.Logger.WithError(err).
			Error("Error occurred revoking user session elevation One-Time Code challenge: error occurred parsing the identifier")

		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code, err = ctx.Providers.StorageProvider.LoadOneTimeCodeByPublicID(ctx, id); err != nil {
		ctx.Logger.WithError(err).
			Error("Error occurred revoking user session elevation One-Time Code challenge: error occurred retrieving the code challenge from the storage backend")

		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code.RevokedAt.Valid {
		ctx.Logger.WithError(fmt.Errorf("the code challenge has already been revoked")).Errorf("Error occurred revoking user session elevation One-Time Code challenge")

		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code.ConsumedAt.Valid {
		ctx.Logger.WithError(fmt.Errorf("the code challenge has already been consumed")).Errorf("Error occurred revoking user session elevation One-Time Code challenge")

		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if code.Intent != model.OTCIntentUserSessionElevation {
		ctx.Logger.WithError(fmt.Errorf("the code challenge has the '%s' intent but the '%s' intent is required", code.Intent, model.OTCIntentUserSessionElevation)).Errorf("Error occurred revoking user session elevation One-Time Code challenge")

		ctx.SetJSONError(messageOperationFailed)

		return
	}

	if err = ctx.Providers.StorageProvider.RevokeOneTimeCode(ctx, id, model.NewIP(ctx.RemoteIP())); err != nil {
		ctx.Logger.WithError(err).Errorf("Error occurred revoking user session elevation One-Time Code challenge: error occurred saving the revocation to the storage backend")

		ctx.SetJSONError(messageOperationFailed)

		return
	}

	ctx.ReplyOK()
}
