package main

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	mapset "github.com/deckarep/golang-set/v2"
	"github.com/google/uuid"
	"github.com/jxskiss/base62"
	"github.com/labstack/echo/v4"
	"github.com/leonelquinteros/gotext"
	"github.com/samber/mo"
	"github.com/zitadel/oidc/v3/pkg/client/rp"
	"github.com/zitadel/oidc/v3/pkg/oidc"
	"golang.org/x/text/language"
	"gorm.io/gorm"
	"html/template"
	"io"
	"log"
	"net/http"
	"net/url"
	"path"
	"strconv"
	"strings"
)

/*
Web front end for creating user accounts, changing passwords, skins, player names, etc.
*/

const CONTEXT_KEY_LOCALE = "DraslLocale"
const BROWSER_TOKEN_AGE_SEC = 24 * 60 * 60
const COOKIE_PREFIX = "__Host-"
const BROWSER_TOKEN_COOKIE_NAME = COOKIE_PREFIX + "browserToken"
const SUCCESS_MESSAGE_COOKIE_NAME = COOKIE_PREFIX + "successMessage"
const WARNING_MESSAGE_COOKIE_NAME = COOKIE_PREFIX + "warningMessage"
const ERROR_MESSAGE_COOKIE_NAME = COOKIE_PREFIX + "errorMessage"
const OIDC_STATE_COOKIE_NAME = COOKIE_PREFIX + "state"
const ID_TOKEN_COOKIE_NAME = COOKIE_PREFIX + "idToken"
const CHALLENGE_TOKEN_COOKIE_NAME = COOKIE_PREFIX + "challengeToken"

// https://echo.labstack.com/guide/templates/
// https://stackoverflow.com/questions/36617949/how-to-use-base-template-file-for-golang-html-template/69244593#69244593
type Template struct {
	Templates map[string]*template.Template
}

func NewTemplate(app *App) *Template {
	t := &Template{
		Templates: make(map[string]*template.Template),
	}

	templateDir := path.Join(app.Config.DataDirectory, "view")

	names := []string{
		"root",
		"error",
		"user",
		"player",
		"registration",
		"complete-registration",
		"challenge",
		"admin",
	}

	funcMap := template.FuncMap{
		"render":               RenderHTML,
		"PrimaryPlayerSkinURL": app.PrimaryPlayerSkinURL,
		"PlayerSkinURL":        app.PlayerSkinURL,
		"InviteURL":            app.InviteURL,
		"IsDefaultAdmin":       app.IsDefaultAdmin,
	}

	for _, name := range names {
		tmpl := Unwrap(template.New("").Funcs(funcMap).ParseFiles(
			path.Join(templateDir, "layout.tmpl"),
			path.Join(templateDir, name+".tmpl"),
			path.Join(templateDir, "header.tmpl"),
			path.Join(templateDir, "footer.tmpl"),
		))
		t.Templates[name] = tmpl
	}

	return t
}

func (app *App) GetLanguageMiddleware() func(echo.HandlerFunc) echo.HandlerFunc {
	matcher := language.NewMatcher(app.LocaleTags)
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			header := c.Request().Header.Get("Accept-Language")
			t, _, _ := language.ParseAcceptLanguage(header)
			// Use only the returned index, not the returned tag: https://github.com/golang/go/issues/24211
			_, localeTagIndex, _ := matcher.Match(t...)
			l := app.Locales[app.LocaleTags[localeTagIndex]]
			c.Set(CONTEXT_KEY_LOCALE, l)
			return next(c)
		}
	}
}

func (t *Template) Render(w io.Writer, name string, data any, c echo.Context) error {
	return t.Templates[name].ExecuteTemplate(w, "base", data)
}

func (app *App) setMessageCookie(c *echo.Context, cookieName string, message string) {
	(*c).SetCookie(&http.Cookie{
		Name:     cookieName,
		Value:    url.QueryEscape(message),
		Path:     "/",
		SameSite: http.SameSiteLaxMode,
		HttpOnly: true,
		Secure:   true,
	})
}

func (app *App) setSuccessMessage(c *echo.Context, message string) {
	app.setMessageCookie(c, SUCCESS_MESSAGE_COOKIE_NAME, message)
}

// func (app *App) setWarningMessage(c *echo.Context, message string) {
// 	app.setMessageCookie(c, WARNING_MESSAGE_COOKIE_NAME, message)
// }

func (app *App) setErrorMessage(c *echo.Context, message string) {
	app.setMessageCookie(c, ERROR_MESSAGE_COOKIE_NAME, message)
}

func (app *App) setBrowserToken(c *echo.Context, browserToken string) {
	(*c).SetCookie(&http.Cookie{
		Name:     BROWSER_TOKEN_COOKIE_NAME,
		Value:    browserToken,
		MaxAge:   BROWSER_TOKEN_AGE_SEC,
		Path:     "/",
		SameSite: http.SameSiteLaxMode,
		HttpOnly: true,
		Secure:   true,
	})
}

type WebError struct {
	/// Wrap a UserError with a ReturnURL
	Err       *UserError
	ReturnURL string
}

func (e *WebError) Error() string {
	return e.Err.Error()
}

func (e *WebError) TranslatedError(l *gotext.Locale) string {
	return e.Err.TranslatedError(l)
}

func NewWebError(returnURL string, message string, args ...any) error {
	return &WebError{
		Err:       &UserError{Message: message, Params: args},
		ReturnURL: returnURL,
	}
}

func RenderHTML(templateString string, args ...any) (template.HTML, error) {
	// If there are no args, skip parsing and return the "template" as-is
	if len(args) == 0 {
		return template.HTML(templateString), nil
	}

	t, err := template.New("").Parse(templateString)
	if err != nil {
		return "", err
	}

	var buf bytes.Buffer
	err = t.Execute(&buf, args)
	if err != nil {
		return "", err
	}

	return template.HTML(buf.String()), nil
}

type baseContext struct {
	App            *App
	L              *gotext.Locale
	T              func(string, ...any) string
	TN             func(string, string, int, ...any) string
	URL            string
	SuccessMessage string
	WarningMessage string
	ErrorMessage   string
}

func (app *App) NewBaseContext(c *echo.Context) baseContext {
	l := app.getLocale(c)
	T := l.Get
	TN := l.GetN
	return baseContext{
		App:            app,
		L:              l,
		T:              T,
		TN:             TN,
		URL:            (*c).Request().URL.RequestURI(),
		SuccessMessage: app.lastSuccessMessage(c),
		WarningMessage: app.lastWarningMessage(c),
		ErrorMessage:   app.lastErrorMessage(c),
	}
}

func (app *App) getLocale(c *echo.Context) *gotext.Locale {
	if l := (*c).Get(CONTEXT_KEY_LOCALE); l != nil {
		return (*c).Get(CONTEXT_KEY_LOCALE).(*gotext.Locale)
	} else {
		return app.DefaultLocale
	}
}

type errorContext struct {
	baseContext
	User       *User
	Message    string
	StatusCode int
}

// Set error message and redirect
func (app *App) HandleWebError(err error, c *echo.Context) error {
	l := app.getLocale(c)

	var webError *WebError
	var userError *UserError
	if errors.As(err, &webError) {
		app.setErrorMessage(c, webError.TranslatedError(l))
		return (*c).Redirect(http.StatusSeeOther, webError.ReturnURL)
	} else if errors.As(err, &userError) {
		returnURL := getReturnURL(app, c)
		app.setErrorMessage(c, userError.TranslatedError(l))
		return (*c).Redirect(http.StatusSeeOther, returnURL)
	}

	code := http.StatusInternalServerError
	message := l.Get("Internal server error")
	var httpError *echo.HTTPError
	if errors.As(err, &httpError) {
		code = httpError.Code
		if m, ok := httpError.Message.(string); ok {
			message = m
		}
	}

	LogError(err, c)

	safeMethods := []string{
		"GET",
		"HEAD",
		"OPTIONS",
		"TRACE",
	}
	if Contains(safeMethods, (*c).Request().Method) {
		return (*c).Render(code, "error", errorContext{
			baseContext: app.NewBaseContext(c),
			User:        nil,
			Message:     message,
			StatusCode:  code,
		})
	} else {
		returnURL := getReturnURL(app, c)
		app.setErrorMessage(c, message)
		return (*c).Redirect(http.StatusSeeOther, returnURL)
	}
}

// Read and clear the message cookie
func (app *App) lastMessageCookie(c *echo.Context, cookieName string) string {
	cookie, err := (*c).Cookie(cookieName)
	if err != nil || cookie.Value == "" {
		return ""
	}
	decoded, err := url.QueryUnescape(cookie.Value)
	if err != nil {
		return ""
	}
	app.setMessageCookie(c, cookieName, "")
	return decoded
}

func (app *App) lastSuccessMessage(c *echo.Context) string {
	return app.lastMessageCookie(c, SUCCESS_MESSAGE_COOKIE_NAME)
}

func (app *App) lastWarningMessage(c *echo.Context) string {
	return app.lastMessageCookie(c, WARNING_MESSAGE_COOKIE_NAME)
}

func (app *App) lastErrorMessage(c *echo.Context) string {
	return app.lastMessageCookie(c, ERROR_MESSAGE_COOKIE_NAME)
}

func getReturnURL(app *App, c *echo.Context) string {
	if (*c).FormValue("returnUrl") != "" {
		return (*c).FormValue("returnUrl")
	}
	return app.FrontEndURL
}

// Authenticate a user using the `browserToken` cookie, and call `f` with a
// reference to the user
func withBrowserAuthentication(app *App, requireLogin bool, f func(c echo.Context, user *User) error) func(c echo.Context) error {
	return func(c echo.Context) error {
		destination := c.Request().URL.String()
		if c.Request().Method != "GET" {
			destination = getReturnURL(app, &c)
		}
		returnURL, err := addDestination(app.FrontEndURL, destination)
		if err != nil {
			return err
		}

		cookie, err := c.Cookie(BROWSER_TOKEN_COOKIE_NAME)

		var user User
		if err != nil || cookie.Value == "" {
			if requireLogin {
				return NewWebError(returnURL, "You are not logged in.")
			}
			return f(c, nil)
		} else {
			result := app.DB.First(&user, "browser_token = ?", cookie.Value)
			if result.Error != nil {
				if errors.Is(result.Error, gorm.ErrRecordNotFound) {
					if requireLogin {
						app.setBrowserToken(&c, "")
						return NewWebError(returnURL, "You are not logged in.")
					}
					return f(c, nil)
				}
				return err
			}
			if user.IsLocked {
				app.setBrowserToken(&c, "")
				return NewWebError(returnURL, "That account is locked.")
			}
			return f(c, &user)
		}
	}
}

func withBrowserAdmin(app *App, f func(c echo.Context, user *User) error) func(c echo.Context) error {
	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		returnURL := getReturnURL(app, &c)

		if !user.IsAdmin {
			return NewWebError(returnURL, "You are not an admin.")
		}

		return f(c, user)
	})
}

func EncodeOIDCState(state oidcState) (string, error) {
	nonce, err := RandomHex(32)
	if err != nil {
		return "", err
	}
	state.Nonce = nonce
	stateBytes, err := json.Marshal(state)
	if err != nil {
		return "", err
	}
	return base64.StdEncoding.EncodeToString(stateBytes), nil
}

// GET /
func FrontRoot(app *App) func(c echo.Context) error {
	type rootContext struct {
		baseContext
		User             *User
		Destination      string
		WebOIDCProviders []webOIDCProvider
	}

	return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
		destination := c.QueryParam("destination")
		webOIDCProviders := make([]webOIDCProvider, 0, len(app.OIDCProvidersByName))
		if len(app.OIDCProvidersByName) > 0 {
			stateBase64, err := EncodeOIDCState(oidcState{
				Action:      OIDCActionSignIn,
				Destination: destination,
				ReturnURL:   c.Request().URL.RequestURI(),
			})
			if err != nil {
				return err
			}

			c.SetCookie(&http.Cookie{
				Name:     OIDC_STATE_COOKIE_NAME,
				Value:    stateBase64,
				Path:     "/",
				SameSite: http.SameSiteLaxMode,
				HttpOnly: true,
				Secure:   true,
			})

			for _, name := range app.OIDCProviderNames {
				provider := app.OIDCProvidersByName[name]
				authURL, err := makeOIDCAuthURL(&c, provider, stateBase64)
				if err != nil {
					return err
				}
				webOIDCProviders = append(webOIDCProviders, webOIDCProvider{
					Name:          name,
					RequireInvite: provider.Config.RequireInvite,
					AuthURL:       authURL,
				})
			}
		}

		return c.Render(http.StatusOK, "root", rootContext{
			baseContext:      app.NewBaseContext(&c),
			User:             user,
			Destination:      destination,
			WebOIDCProviders: webOIDCProviders,
		})
	})
}

type WebManifestIcon struct {
	Src   string `json:"src"`
	Type  string `json:"type"`
	Sizes string `json:"sizes"`
}

type WebManifest struct {
	Icons []WebManifestIcon `json:"icons"`
}

func FrontWebManifest(app *App) func(c echo.Context) error {
	iconURL := Unwrap(url.JoinPath(app.PublicURL, "icon.png"))

	manifest := WebManifest{
		Icons: []WebManifestIcon{{
			Src:   iconURL,
			Type:  "image/png",
			Sizes: "512x512",
		}},
	}
	manifestBlob := Unwrap(json.Marshal(manifest))
	return func(c echo.Context) error {
		return c.JSONBlob(http.StatusOK, manifestBlob)
	}
}

type webOIDCProvider struct {
	Name          string
	RequireInvite bool
	AuthURL       string
}

const (
	OIDCActionSignIn string = "sign-in"
	OIDCActionLink   string = "link"
)

type oidcState struct {
	Nonce       string `json:"nonce"`
	Action      string `json:"action"`
	Destination string `json:"destination,omitempty"`
	InviteCode  string `json:"inviteCode,omitempty"`
	ReturnURL   string `json:"returnUrl"`
}

// GET /registration
func FrontRegistration(app *App) func(c echo.Context) error {
	type registrationContext struct {
		baseContext
		User             *User
		InviteCode       string
		WebOIDCProviders []webOIDCProvider
	}

	return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
		inviteCode := c.QueryParam("invite")
		webOIDCProviders := make([]webOIDCProvider, 0, len(app.OIDCProvidersByName))

		stateBase64, err := EncodeOIDCState(oidcState{
			Action:     OIDCActionSignIn,
			InviteCode: inviteCode,
			ReturnURL:  c.Request().URL.RequestURI(),
		})
		if err != nil {
			return err
		}

		c.SetCookie(&http.Cookie{
			Name:     OIDC_STATE_COOKIE_NAME,
			Value:    stateBase64,
			Path:     "/",
			SameSite: http.SameSiteLaxMode,
			HttpOnly: true,
			Secure:   true,
		})

		for _, name := range app.OIDCProviderNames {
			provider := app.OIDCProvidersByName[name]
			authURL, err := makeOIDCAuthURL(&c, provider, stateBase64)
			if err != nil {
				return err
			}
			webOIDCProviders = append(webOIDCProviders, webOIDCProvider{
				Name:          name,
				RequireInvite: provider.Config.RequireInvite,
				AuthURL:       authURL,
			})
		}

		return c.Render(http.StatusOK, "registration", registrationContext{
			baseContext:      app.NewBaseContext(&c),
			User:             user,
			InviteCode:       inviteCode,
			WebOIDCProviders: webOIDCProviders,
		})
	})
}

func (app *App) getPreferredPlayerName(userInfo *oidc.UserInfo) mo.Option[string] {
	preferredPlayerName := userInfo.PreferredUsername
	if preferredPlayerName == "" {
		return mo.None[string]()
	}
	if index := strings.IndexByte(userInfo.PreferredUsername, '@'); index >= 0 {
		preferredPlayerName = userInfo.PreferredUsername[:index]
	}
	if app.ValidatePlayerName(preferredPlayerName) != nil {
		return mo.None[string]()
	}
	return mo.Some(preferredPlayerName)
}

func (app *App) getIDTokenCookie(c *echo.Context) (*OIDCProvider, string, oidc.IDTokenClaims, error) {
	cookie, err := (*c).Cookie(ID_TOKEN_COOKIE_NAME)
	if err != nil || cookie.Value == "" {
		return nil, "", oidc.IDTokenClaims{}, NewUserError("Missing ID token cookie")
	}

	idTokenBytes, err := app.DecryptCookieValue(cookie.Value)
	if err != nil {
		return nil, "", oidc.IDTokenClaims{}, NewUserError("Invalid ID token")
	}
	idToken := string(idTokenBytes)

	oidcProvider, claims, err := app.ValidateIDToken(idToken)
	if err != nil {
		return nil, "", oidc.IDTokenClaims{}, err
	}

	return oidcProvider, idToken, claims, nil
}

func FrontCompleteRegistration(app *App) func(c echo.Context) error {
	type completeRegistrationContext struct {
		baseContext
		User                *User
		InviteCode          string
		OIDCProvider        *OIDCProvider
		AnyUnmigratedUsers  bool
		PreferredPlayerName string
	}

	returnURL := Unwrap(url.JoinPath(app.FrontEndURL, "web/registration"))

	return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
		inviteCode := c.QueryParam("invite")

		provider, _, claims, err := app.getIDTokenCookie(&c)
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: returnURL, Err: userError}
			}
			return err
		}

		preferredPlayerName := app.getPreferredPlayerName(claims.GetUserInfo()).OrElse("")
		if preferredPlayerName == "" && !provider.Config.AllowChoosingPlayerName {
			return NewWebError(returnURL, "That %s account does not have a preferred username.", provider.Config.Name)
		}

		var anyUnmigratedUsers bool
		err = app.DB.Raw(`
			SELECT EXISTS (
				SELECT 1 from users u
				WHERE NOT EXISTS (
					SELECT 1 FROM user_oidc_identities uoi WHERE uoi.user_uuid = u.uuid
				)
			)
		`).Scan(&anyUnmigratedUsers).Error
		if err != nil {
			return err
		}

		return c.Render(http.StatusOK, "complete-registration", completeRegistrationContext{
			baseContext:         app.NewBaseContext(&c),
			User:                user,
			InviteCode:          inviteCode,
			PreferredPlayerName: preferredPlayerName,
			OIDCProvider:        provider,
			AnyUnmigratedUsers:  anyUnmigratedUsers,
		})
	})
}

func (app *App) FrontOIDCUnlink() func(c echo.Context) error {
	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		l := app.getLocale(&c)
		returnURL := getReturnURL(app, &c)

		targetUUID := c.FormValue("userUuid")
		providerName := c.FormValue("providerName")

		if err := app.DeleteOIDCIdentity(user, targetUUID, providerName); err != nil {
			return err
		}

		app.setSuccessMessage(&c, l.Get("%s account unlinked.", providerName))
		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

func pkceCookieName(provider *OIDCProvider) string {
	return "__Host-pkce-" + base62.EncodeToString([]byte(provider.Config.Issuer))
}

func makeOIDCAuthURL(c *echo.Context, provider *OIDCProvider, stateBase64 string) (string, error) {
	w := (*c).Response().Unwrap()

	var opts []rp.AuthURLOpt
	if provider.RelyingParty.IsPKCE() {
		codeVerifier := base64.RawURLEncoding.EncodeToString([]byte(uuid.New().String()))
		if err := provider.RelyingParty.CookieHandler().SetCookie(w, pkceCookieName(provider), codeVerifier); err != nil {
			return "", err
		}
		codeChallenge := oidc.NewSHACodeChallenge(codeVerifier)
		opts = append(opts, rp.WithCodeChallenge(codeChallenge))
	}

	return rp.AuthURL(stateBase64, provider.RelyingParty, opts...), nil
}

func (app *App) oidcLink(c echo.Context, oidcProvider *OIDCProvider, tokens *oidc.Tokens[*oidc.IDTokenClaims], state oidcState, user *User) error {
	l := app.getLocale(&c)

	returnURL := state.ReturnURL

	if user == nil {
		return NewWebError(app.FrontEndURL, "You are not logged in.")
	}

	_, claims, err := app.ValidateIDToken(tokens.IDToken)
	if err != nil {
		var userError *UserError
		if errors.As(err, &userError) {
			return &WebError{ReturnURL: returnURL, Err: userError}
		}
		return err
	}

	_, err = app.CreateOIDCIdentity(user, user.UUID, claims.Issuer, claims.Subject)
	if err != nil {
		var userError *UserError
		if errors.As(err, &userError) {
			return &WebError{ReturnURL: returnURL, Err: userError}
		}
		return err
	}

	app.setSuccessMessage(&c, l.Get("Successfully linked your %s account.", oidcProvider.Config.Name))

	return c.Redirect(http.StatusSeeOther, returnURL)
}

func (app *App) oidcSignIn(c echo.Context, _ *OIDCProvider, tokens *oidc.Tokens[*oidc.IDTokenClaims], state oidcState) error {
	failureURL := state.ReturnURL
	completeRegistrationURL, err := url.JoinPath(app.FrontEndURL, "web/complete-registration")
	if err != nil {
		return err
	}

	if state.InviteCode != "" {
		var err error
		completeRegistrationURL, err = SetQueryParam(completeRegistrationURL, "invite", state.InviteCode)
		if err != nil {
			return err
		}
		failureURL, err = SetQueryParam(failureURL, "invite", state.InviteCode)
		if err != nil {
			return err
		}
	}

	var claims oidc.IDTokenClaims
	_, err = oidc.ParseToken(tokens.IDToken, &claims)
	if err != nil {
		return err
	}

	var oidcIdentity UserOIDCIdentity
	result := app.DB.Preload("User").First(&oidcIdentity, "subject = ? AND issuer = ?", claims.Subject, claims.Issuer)
	if result.Error == nil {
		// User already exists, log in
		user := oidcIdentity.User

		if user.IsLocked {
			return NewWebError(failureURL, "Account is locked.")
		}

		browserToken, err := RandomHex(32)
		if err != nil {
			return err
		}
		user.BrowserToken = MakeNullString(&browserToken)
		if err := app.DB.Save(&user).Error; err != nil {
			return err
		}
		app.setBrowserToken(&c, browserToken)

		returnURL, err := url.JoinPath(app.FrontEndURL, "web/user")
		if err != nil {
			return err
		}

		if state.Destination != "" {
			returnURL = state.Destination
		}
		return c.Redirect(http.StatusSeeOther, returnURL)
	} else {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return result.Error
		}
	}

	encryptedIDToken, err := app.EncryptCookieValue(tokens.IDToken)
	if err != nil {
		return err
	}

	// User doesn't already exist, set ID token cookie and complete registration
	c.SetCookie(&http.Cookie{
		Name:     ID_TOKEN_COOKIE_NAME,
		Value:    encryptedIDToken,
		Path:     "/",
		SameSite: http.SameSiteLaxMode,
		HttpOnly: true,
		Secure:   true,
	})

	return c.Redirect(http.StatusSeeOther, completeRegistrationURL)
}

// GET /oidc-callback/:providerName
func FrontOIDCCallback(app *App) func(c echo.Context) error {
	failureURL := app.FrontEndURL

	return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
		providerName := c.Param("providerName")
		oidcProvider, ok := app.OIDCProvidersByName[providerName]
		if !ok {
			return NewWebError(failureURL, "Unknown OIDC provider: %s", providerName)
		}

		stateCookie, err := c.Cookie(OIDC_STATE_COOKIE_NAME)
		if err != nil {
			return NewWebError(failureURL, "Missing state cookie")
		}
		c.SetCookie(&http.Cookie{
			Name:     OIDC_STATE_COOKIE_NAME,
			Value:    "",
			Path:     "/",
			SameSite: http.SameSiteLaxMode,
			HttpOnly: true,
			Secure:   true,
		})

		stateParam := c.QueryParam("state")
		if stateCookie.Value != stateParam {
			fmt.Println("stateCookie.Value", stateCookie.Value, "stateParam", stateParam)
			return NewWebError(failureURL, "\"state\" param doesn't match \"%s\" cookie.", OIDC_STATE_COOKIE_NAME)
		}

		stateBytes, err := base64.StdEncoding.DecodeString(stateParam)
		if err != nil {
			return NewWebError(failureURL, "Invalid OIDC state cookie")
		}

		var state oidcState
		err = json.Unmarshal(stateBytes, &state)
		if err != nil {
			return NewWebError(failureURL, "Invalid OIDC state cookie")
		}

		failureURL := state.ReturnURL
		var opts []rp.CodeExchangeOpt
		if oidcProvider.RelyingParty.IsPKCE() {
			codeVerifier, err := oidcProvider.RelyingParty.CookieHandler().CheckCookie(c.Request(), pkceCookieName(oidcProvider))
			if err != nil {
				return err
			}
			opts = append(opts, rp.WithCodeVerifier(codeVerifier))
		}
		tokens, err := rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), c.FormValue("code"), oidcProvider.RelyingParty, opts...)
		if err != nil {
			log.Printf("OIDC code exchange failed with provider %s: %s", oidcProvider.Config.Name, err)
			return NewWebError(failureURL, "OIDC code exchange failed.")
		}

		switch state.Action {
		case OIDCActionSignIn:
			return app.oidcSignIn(c, oidcProvider, tokens, state)
		case OIDCActionLink:
			return app.oidcLink(c, oidcProvider, tokens, state, user)
		default:
			return NewWebError(failureURL, "Unknown OIDC action: %s", state.Action)
		}
	})
}

// GET /web/admin
func FrontAdmin(app *App) func(c echo.Context) error {
	type adminContext struct {
		baseContext
		User    *User
		Users   []User
		Invites []Invite
	}

	return withBrowserAdmin(app, func(c echo.Context, user *User) error {
		var users []User
		result := app.DB.Find(&users)
		if result.Error != nil {
			return result.Error
		}

		var invites []Invite
		result = app.DB.Find(&invites)
		if result.Error != nil {
			return result.Error
		}

		return c.Render(http.StatusOK, "admin", adminContext{
			baseContext: app.NewBaseContext(&c),
			User:        user,
			Users:       users,
			Invites:     invites,
		})
	})
}

// POST /web/admin/delete-invite
func FrontDeleteInvite(app *App) func(c echo.Context) error {
	returnURL := Unwrap(url.JoinPath(app.FrontEndURL, "web/admin"))

	return withBrowserAdmin(app, func(c echo.Context, user *User) error {
		inviteCode := c.FormValue("inviteCode")

		var invite Invite
		result := app.DB.Where("code = ?", inviteCode).Delete(&invite)
		if result.Error != nil {
			return result.Error
		}

		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

// POST /web/admin/update-users
func FrontUpdateUsers(app *App) func(c echo.Context) error {
	return withBrowserAdmin(app, func(c echo.Context, user *User) error {
		l := app.getLocale(&c)

		returnURL := getReturnURL(app, &c)

		var users []User
		result := app.DB.Find(&users)
		if result.Error != nil {
			return result.Error
		}

		tx := app.DB.Begin()
		defer tx.Rollback()

		anyUnlockedAdmins := false
		for _, targetUser := range users {
			shouldBeAdmin := c.FormValue("admin-"+targetUser.UUID) == "on"
			if app.IsDefaultAdmin(&targetUser) {
				shouldBeAdmin = true
			}

			shouldBeLocked := c.FormValue("locked-"+targetUser.UUID) == "on"
			if shouldBeAdmin && !shouldBeLocked {
				anyUnlockedAdmins = true
			}

			maxPlayerCountString := c.FormValue("max-player-count-" + targetUser.UUID)
			maxPlayerCount := targetUser.MaxPlayerCount
			if maxPlayerCountString == "" {
				maxPlayerCount = app.Constants.MaxPlayerCountUseDefault
			} else {
				var err error
				maxPlayerCount, err = strconv.Atoi(maxPlayerCountString)
				if err != nil {
					return NewWebError(returnURL, "Max player count must be an integer.")
				}
			}

			if targetUser.IsAdmin != shouldBeAdmin || targetUser.IsLocked != shouldBeLocked || targetUser.MaxPlayerCount != maxPlayerCount {
				_, err := app.UpdateUser(
					tx,
					user,       // caller
					targetUser, // user
					nil,
					&shouldBeAdmin,  // isAdmin
					&shouldBeLocked, // isLocked
					false,
					false,
					nil,
					&maxPlayerCount,
				)
				if err != nil {
					var userError *UserError
					if errors.As(err, &userError) {
						return &WebError{ReturnURL: returnURL, Err: userError}
					}
					return err
				}
			}
		}

		if !anyUnlockedAdmins {
			return NewWebError(returnURL, "There must be at least one unlocked admin account.")
		}

		err := tx.Commit().Error
		if err != nil {
			return err
		}

		app.setSuccessMessage(&c, l.Get("Changes saved."))
		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

// POST /web/admin/new-invite
func FrontNewInvite(app *App) func(c echo.Context) error {
	return withBrowserAdmin(app, func(c echo.Context, user *User) error {
		returnURL := getReturnURL(app, &c)

		_, err := app.CreateInvite()
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: returnURL, Err: userError}
			}
			return err
		}

		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

// GET /web/user
// GET /web/user/:uuid
func FrontUser(app *App) func(c echo.Context) error {
	type userContext struct {
		baseContext
		User                    *User
		TargetUser              *User
		TargetUserID            string
		SkinURL                 *string
		CapeURL                 *string
		AdminView               bool
		MaxPlayerCount          int
		LinkedOIDCProviderNames []string
		UnlinkedOIDCProviders   []webOIDCProvider
	}

	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		var targetUser *User
		targetUUID := c.Param("uuid")
		adminView := false
		if targetUUID == "" || targetUUID == user.UUID {
			var targetUserStruct User
			result := app.DB.First(&targetUserStruct, "uuid = ?", user.UUID)
			if result.Error != nil {
				return result.Error
			}
			targetUser = &targetUserStruct
		} else {
			if !user.IsAdmin {
				return NewWebError(app.FrontEndURL, "You are not an admin.")
			}
			adminView = true
			var targetUserStruct User
			result := app.DB.First(&targetUserStruct, "uuid = ?", targetUUID)
			if result.Error != nil {
				returnURL, err := url.JoinPath(app.FrontEndURL, "web/admin")
				if err != nil {
					return err
				}
				return NewWebError(returnURL, "User not found.")
			}
			targetUser = &targetUserStruct
		}

		maxPlayerCount := app.GetMaxPlayerCount(targetUser)

		linkedOIDCProviderNames := mapset.NewSet[string]()
		unlinkedOIDCProviders := make([]webOIDCProvider, 0, len(app.OIDCProvidersByName))

		if len(app.OIDCProvidersByName) > 0 {
			stateBase64, err := EncodeOIDCState(oidcState{
				Action:    OIDCActionLink,
				ReturnURL: c.Request().URL.RequestURI(),
			})
			if err != nil {
				return err
			}

			c.SetCookie(&http.Cookie{
				Name:     OIDC_STATE_COOKIE_NAME,
				Value:    stateBase64,
				Path:     "/",
				SameSite: http.SameSiteLaxMode,
				HttpOnly: true,
				Secure:   true,
			})

			for _, oidcIdentity := range targetUser.OIDCIdentities {
				if oidcProvider, ok := app.OIDCProvidersByIssuer[oidcIdentity.Issuer]; ok {
					linkedOIDCProviderNames.Add(oidcProvider.Config.Name)
				}
			}

			for _, name := range app.OIDCProviderNames {
				provider := app.OIDCProvidersByName[name]
				if !linkedOIDCProviderNames.Contains(name) {
					authURL, err := makeOIDCAuthURL(&c, provider, stateBase64)
					if err != nil {
						return err
					}
					unlinkedOIDCProviders = append(unlinkedOIDCProviders, webOIDCProvider{
						Name:    name,
						AuthURL: authURL,
					})
				}
			}
		}

		return c.Render(http.StatusOK, "user", userContext{
			baseContext:             app.NewBaseContext(&c),
			User:                    user,
			TargetUser:              targetUser,
			AdminView:               adminView,
			LinkedOIDCProviderNames: linkedOIDCProviderNames.ToSlice(),
			UnlinkedOIDCProviders:   unlinkedOIDCProviders,
			MaxPlayerCount:          maxPlayerCount,
		})
	})
}

// GET /web/player/:uuid
func FrontPlayer(app *App) func(c echo.Context) error {
	type playerContext struct {
		baseContext
		User       *User
		PlayerUser *User
		Player     *Player
		PlayerID   string
		SkinURL    *string
		CapeURL    *string
		AdminView  bool
	}

	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		returnURL := getReturnURL(app, &c)

		playerUUID := c.Param("uuid")

		var player Player
		result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID)
		if result.Error != nil {
			if errors.Is(result.Error, gorm.ErrRecordNotFound) {
				return NewWebError(returnURL, "Player not found.")
			}
			return result.Error
		}
		playerUser := player.User

		if !user.IsAdmin && (player.User.UUID != user.UUID) {
			return NewWebError(app.FrontEndURL, "You don't own that player.")
		}
		adminView := playerUser.UUID != user.UUID

		skinURL, err := app.GetSkinURL(&player)
		if err != nil {
			return err
		}
		capeURL, err := app.GetCapeURL(&player)
		if err != nil {
			return err
		}

		id, err := UUIDToID(player.UUID)
		if err != nil {
			return err
		}

		return c.Render(http.StatusOK, "player", playerContext{
			baseContext: app.NewBaseContext(&c),
			User:        user,
			PlayerUser:  &playerUser,
			Player:      &player,
			PlayerID:    id,
			SkinURL:     skinURL,
			CapeURL:     capeURL,
			AdminView:   adminView,
		})
	})
}

func nilIfEmpty(str string) *string {
	if str == "" {
		return nil
	}
	return &str
}

func getFormValue(c *echo.Context, key string) mo.Option[string] {
	// Call FormValue first to parse the form appropriately
	value := (*c).FormValue(key)
	if (*c).Request().Form.Has(key) {
		return mo.Some(value)
	}
	return mo.None[string]()
}

// POST /update-user
func FrontUpdateUser(app *App) func(c echo.Context) error {
	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		l := app.getLocale(&c)
		returnURL := getReturnURL(app, &c)

		targetUUID := nilIfEmpty(c.FormValue("uuid"))
		password := nilIfEmpty(c.FormValue("password"))
		resetAPIToken := c.FormValue("resetApiToken") == "on"
		resetMinecraftToken := c.FormValue("resetMinecraftToken") == "on"
		preferredLanguage := nilIfEmpty(c.FormValue("preferredLanguage"))
		maybeMaxPlayerCountString := getFormValue(&c, "maxPlayerCount")

		var targetUser *User
		if targetUUID == nil || *targetUUID == user.UUID {
			targetUser = user
		} else {
			if !user.IsAdmin {
				return NewWebError(app.FrontEndURL, "You are not an admin.")
			}
			var targetUserStruct User
			result := app.DB.First(&targetUserStruct, "uuid = ?", targetUUID)
			targetUser = &targetUserStruct
			if result.Error != nil {
				return NewWebError(returnURL, "User not found.")
			}
		}

		maybeMaxPlayerCount := mo.None[int]()
		if maxPlayerCountString, ok := maybeMaxPlayerCountString.Get(); ok {
			if maxPlayerCountString == "" {
				maybeMaxPlayerCount = mo.Some(app.Constants.MaxPlayerCountUseDefault)
			} else {
				var err error
				maxPlayerCount, err := strconv.Atoi(maxPlayerCountString)
				if err != nil {
					return NewWebError(returnURL, "Max player count must be an integer.")
				}
				maybeMaxPlayerCount = mo.Some(maxPlayerCount)
			}
		}

		_, err := app.UpdateUser(
			app.DB,
			user,        // caller
			*targetUser, // user
			password,
			nil, // isAdmin
			nil, // isLocked
			resetAPIToken,
			resetMinecraftToken,
			preferredLanguage,
			maybeMaxPlayerCount.ToPointer(),
		)
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: returnURL, Err: userError}
			}
			return err
		}

		app.setSuccessMessage(&c, l.Get("Changes saved."))
		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

// POST /web/update-player
func FrontUpdatePlayer(app *App) func(c echo.Context) error {
	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		l := app.getLocale(&c)
		returnURL := getReturnURL(app, &c)

		playerUUID := c.FormValue("uuid")
		playerName := nilIfEmpty(c.FormValue("playerName"))
		fallbackPlayer := nilIfEmpty(c.FormValue("fallbackPlayer"))
		skinModel := nilIfEmpty(c.FormValue("skinModel"))
		skinURL := nilIfEmpty(c.FormValue("skinUrl"))
		deleteSkin := c.FormValue("deleteSkin") == "on"
		capeURL := nilIfEmpty(c.FormValue("capeUrl"))
		deleteCape := c.FormValue("deleteCape") == "on"

		var player Player
		result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID)
		if result.Error != nil {
			return NewWebError(returnURL, "Player not found.")
		}

		// Skin
		var skinReader *io.Reader
		skinFile, skinFileErr := c.FormFile("skinFile")
		if skinFileErr == nil {
			var err error
			skinHandle, err := skinFile.Open()
			if err != nil {
				return err
			}
			defer skinHandle.Close()
			var skinFileReader io.Reader = skinHandle
			skinReader = &skinFileReader
		}

		// Cape
		var capeReader *io.Reader
		capeFile, capeFileErr := c.FormFile("capeFile")
		if capeFileErr == nil {
			var err error
			capeHandle, err := capeFile.Open()
			if err != nil {
				return err
			}
			defer capeHandle.Close()
			var capeFileReader io.Reader = capeHandle
			capeReader = &capeFileReader
		}

		_, err := app.UpdatePlayer(
			user, // caller
			player,
			playerName,
			fallbackPlayer,
			skinModel,
			skinReader,
			skinURL,
			deleteSkin,
			capeReader,
			capeURL,
			deleteCape,
		)
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: returnURL, Err: userError}
			}
			return err
		}

		app.setSuccessMessage(&c, l.Get("Changes saved."))
		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

// POST /web/logout
func FrontLogout(app *App) func(c echo.Context) error {
	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		returnURL := app.FrontEndURL
		user.BrowserToken = MakeNullString(nil)
		if err := app.DB.Save(user).Error; err != nil {
			return err
		}
		app.setBrowserToken(&c, "")
		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

const (
	ChallengeActionRegister     string = "register"
	ChallengeActionCreatePlayer string = "create-player"
)

// GET /web/create-player-challenge
func FrontCreatePlayerChallenge(app *App) func(c echo.Context) error {
	return frontChallenge(app, ChallengeActionCreatePlayer)
}

// GET /web/register-challenge
func FrontRegisterChallenge(app *App) func(c echo.Context) error {
	return frontChallenge(app, ChallengeActionRegister)
}

func frontChallenge(app *App, action string) func(c echo.Context) error {
	type challengeContext struct {
		baseContext
		User                 *User
		PlayerName           string
		RegistrationProvider string
		SkinBase64           string
		SkinFilename         string
		ChallengeToken       string
		InviteCode           string
		UseIDToken           bool
		Action               string
		UserUUID             *string
	}

	return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
		returnURL := getReturnURL(app, &c)

		useIDToken := c.QueryParam("useIdToken") == "on"

		var playerName string
		var userUUID *string
		switch action {
		case ChallengeActionRegister:
			if useIDToken {
				provider, _, claims, err := app.getIDTokenCookie(&c)
				if err != nil {
					var userError *UserError
					if errors.As(err, &userError) {
						return &WebError{ReturnURL: returnURL, Err: userError}
					}
					return err
				}

				if provider.Config.AllowChoosingPlayerName {
					playerName = c.QueryParam("playerName")
				} else {
					if preferredPlayerName, ok := app.getPreferredPlayerName(claims.GetUserInfo()).Get(); ok {
						playerName = preferredPlayerName
					} else {
						return NewWebError(returnURL, "That %s account does not have a preferred username.", provider.Config.Name)
					}
				}
			} else {
				playerName = c.QueryParam("playerName")
			}
		case ChallengeActionCreatePlayer:
			playerName = c.QueryParam("playerName")
			userUUID = Ptr(c.QueryParam("userUuid"))
		}

		if err := app.ValidatePlayerName(playerName); err != nil {
			return NewWebError(returnURL, "Invalid player name: %s", err)
		}

		inviteCode := c.QueryParam("inviteCode")

		var challengeToken string
		cookie, err := c.Cookie(CHALLENGE_TOKEN_COOKIE_NAME)
		if err != nil || cookie.Value == "" {
			challengeToken, err = MakeChallengeToken()
			if err != nil {
				return err
			}
			c.SetCookie(&http.Cookie{
				Name:     CHALLENGE_TOKEN_COOKIE_NAME,
				Value:    challengeToken,
				MaxAge:   BROWSER_TOKEN_AGE_SEC,
				Path:     "/",
				SameSite: http.SameSiteLaxMode,
				HttpOnly: true,
				Secure:   true,
			})
		} else {
			challengeToken = cookie.Value
		}

		challengeSkinBytes, err := app.GetChallengeSkin(playerName, challengeToken)
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return NewWebError(returnURL, "Error: %s", userError)
			}
			return err
		}
		skinBase64 := base64.StdEncoding.EncodeToString(challengeSkinBytes)

		return c.Render(http.StatusOK, "challenge", challengeContext{
			baseContext:    app.NewBaseContext(&c),
			User:           user,
			PlayerName:     playerName,
			SkinBase64:     skinBase64,
			SkinFilename:   playerName + "-challenge.png",
			ChallengeToken: challengeToken,
			InviteCode:     inviteCode,
			UseIDToken:     useIDToken,
			Action:         action,
			UserUUID:       userUUID,
		})
	})
}

// POST /web/create-player
func FrontCreatePlayer(app *App) func(c echo.Context) error {
	return withBrowserAuthentication(app, true, func(c echo.Context, caller *User) error {
		l := app.getLocale(&c)

		userUUID := c.FormValue("userUuid")

		playerName := c.FormValue("playerName")
		chosenUUID := nilIfEmpty(c.FormValue("playerUuid"))
		existingPlayer := c.FormValue("existingPlayer") == "on"
		challengeToken := nilIfEmpty(c.FormValue("challengeToken"))

		failureURL := getReturnURL(app, &c)

		player, err := app.CreatePlayer(
			caller,
			userUUID,
			playerName,
			chosenUUID,
			existingPlayer,
			challengeToken,
			nil, // fallbackPlayer
			nil, // skinModel
			nil, // skinReader
			nil, // skinURL
			nil, // capeReader
			nil, // capeURL
		)
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: failureURL, Err: userError}
			}
			return err
		}

		returnURL, err := url.JoinPath(app.FrontEndURL, "web/player", player.UUID)
		if err != nil {
			return err
		}

		app.setSuccessMessage(&c, l.Get("Player created."))
		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

// POST /web/register
func FrontRegister(app *App) func(c echo.Context) error {
	returnURL := Unwrap(url.JoinPath(app.FrontEndURL, "web/user"))
	return func(c echo.Context) error {
		l := app.getLocale(&c)

		useIDToken := c.FormValue("useIdToken") == "on"
		honeypot := c.FormValue("email")
		chosenUUID := nilIfEmpty(c.FormValue("uuid"))
		existingPlayer := c.FormValue("existingPlayer") == "on"
		challengeToken := nilIfEmpty(c.FormValue("challengeToken"))
		inviteCode := nilIfEmpty(c.FormValue("inviteCode"))

		failureURL := getReturnURL(app, &c)
		noInviteFailureURL, err := UnsetQueryParam(failureURL, "invite")
		if err != nil {
			return err
		}

		if honeypot != "" {
			return NewWebError(failureURL, "You are now covered in bee stings.")
		}

		var username string
		var playerName string
		var password mo.Option[string]
		oidcIdentitySpecs := []OIDCIdentitySpec{}
		if useIDToken {
			provider, _, claims, err := app.getIDTokenCookie(&c)
			if err != nil {
				var userError *UserError
				if errors.As(err, &userError) {
					return &WebError{ReturnURL: failureURL, Err: userError}
				}
				return err
			}
			if claims.Email == "" {
				return NewWebError(failureURL, "That %s account does not have an email address.", provider.Config.Name)
			}
			username = claims.Email

			if provider.Config.AllowChoosingPlayerName {
				playerName = c.FormValue("playerName")
			} else {
				if preferredPlayerName, ok := app.getPreferredPlayerName(claims.GetUserInfo()).Get(); ok {
					playerName = preferredPlayerName
				} else {
					return NewWebError(failureURL, "That %s account does not have a preferred username.", provider.Config.Name)
				}
			}

			claims.GetUserInfo()
			oidcIdentitySpecs = []OIDCIdentitySpec{{
				Issuer:  claims.Issuer,
				Subject: claims.Subject,
			}}
		} else {
			playerName = c.FormValue("playerName")
			username = playerName
			password = mo.Some(c.FormValue("password"))
		}

		user, err := app.CreateUser(
			nil, // caller
			username,
			password.ToPointer(),
			PotentiallyInsecure[[]OIDCIdentitySpec]{Value: oidcIdentitySpecs},
			false, // isAdmin
			false, // isLocked
			inviteCode,
			nil, // preferredLanguage
			&playerName,
			chosenUUID,
			existingPlayer,
			challengeToken,
			nil, // fallbackPlayer
			nil, // maxPlayerCount
			nil, // skinModel
			nil, // skinReader
			nil, // skinURL
			nil, // capeReader
			nil, // capeURL
		)
		if err != nil {
			if err == InviteNotFoundError || err == InviteMissingError {
				return &WebError{ReturnURL: noInviteFailureURL, Err: err.(*UserError)}
			}

			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: failureURL, Err: userError}
			}
			return err
		}

		browserToken, err := RandomHex(32)
		if err != nil {
			return err
		}
		user.BrowserToken = MakeNullString(&browserToken)
		if err := app.DB.Save(&user).Error; err != nil {
			return err
		}
		app.setBrowserToken(&c, browserToken)

		if useIDToken {
			c.SetCookie(&http.Cookie{
				Name:     ID_TOKEN_COOKIE_NAME,
				Value:    "",
				Path:     "/",
				SameSite: http.SameSiteLaxMode,
				HttpOnly: true,
				Secure:   true,
			})
		}

		app.setSuccessMessage(&c, l.Get("Account created."))

		return c.Redirect(http.StatusSeeOther, returnURL)
	}
}

func addDestination(url_ string, destination string) (string, error) {
	if destination == "" {
		return url_, nil
	} else if url_ == destination {
		return url_, nil
	} else {
		urlStruct, err := url.Parse(url_)
		if err != nil {
			return "", err
		}
		query := urlStruct.Query()
		query.Set("destination", destination)
		urlStruct.RawQuery = query.Encode()
		return urlStruct.String(), nil
	}
}

// POST /web/oidc-migrate
func (app *App) FrontOIDCMigrate() func(c echo.Context) error {
	return func(c echo.Context) error {
		l := app.getLocale(&c)
		failureURL := getReturnURL(app, &c)

		username := c.FormValue("username")
		password := c.FormValue("password")

		oidcProvider, _, claims, err := app.getIDTokenCookie(&c)
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: failureURL, Err: userError}
			}
			return err
		}

		user, err := app.AuthenticateUserForMigration(username, password)
		if err != nil {
			var userError *UserError
			if err == PasswordLoginNotAllowedError {
				return NewWebError(failureURL, "That account is already migrated. Log in via OpenID Connect.")
			}
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: failureURL, Err: userError}
			}
		}

		_, err = app.CreateOIDCIdentity(&user, user.UUID, claims.Issuer, claims.Subject)
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: failureURL, Err: userError}
			}
			return err
		}

		browserToken, err := RandomHex(32)
		if err != nil {
			return err
		}
		user.BrowserToken = MakeNullString(&browserToken)
		if err := app.DB.Save(&user).Error; err != nil {
			return err
		}
		app.setBrowserToken(&c, browserToken)

		returnURL, err := url.JoinPath(app.FrontEndURL, "web/user")
		if err != nil {
			return err
		}

		app.setSuccessMessage(&c, l.Get("Successfully migrated account. From now on, log in with %s.", oidcProvider.Config.Name))
		return c.Redirect(http.StatusSeeOther, returnURL)
	}
}

// POST /web/login
func FrontLogin(app *App) func(c echo.Context) error {
	return func(c echo.Context) error {
		failureURL := getReturnURL(app, &c)

		username := c.FormValue("username")
		password := c.FormValue("password")

		user, err := app.AuthenticateUser(username, password)
		if err != nil {
			var userError *UserError
			if err == PasswordLoginNotAllowedError {
				return NewWebError(failureURL, "Password login is not allowed. Log in via OpenID Connect instead.")
			}
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: failureURL, Err: userError}
			}
			return err
		}

		browserToken, err := RandomHex(32)
		if err != nil {
			return err
		}
		user.BrowserToken = MakeNullString(&browserToken)
		if err := app.DB.Save(&user).Error; err != nil {
			return err
		}
		app.setBrowserToken(&c, browserToken)

		returnURL, err := url.JoinPath(app.FrontEndURL, "web/user")
		if err != nil {
			return err
		}
		destination := c.FormValue("destination")
		if destination != "" {
			returnURL = destination
		}
		return c.Redirect(http.StatusSeeOther, returnURL)
	}
}

// POST /web/delete-user
func FrontDeleteUser(app *App) func(c echo.Context) error {
	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		l := app.getLocale(&c)
		returnURL := getReturnURL(app, &c)

		var targetUser *User
		targetUUID := c.FormValue("uuid")
		if targetUUID == "" || targetUUID == user.UUID {
			targetUser = user
		} else {
			if !user.IsAdmin {
				return NewWebError(app.FrontEndURL, "You are not an admin.")
			}
			var targetUserStruct User
			if err := app.DB.First(&targetUserStruct, "uuid = ?", targetUUID).Error; err != nil {
				if errors.Is(err, gorm.ErrRecordNotFound) {
					return NewWebError(returnURL, "User not found.")
				}
				return err
			}
			targetUser = &targetUserStruct
		}

		err := app.DeleteUser(user, targetUser)
		if err != nil {
			return err
		}

		if targetUser == user {
			app.setBrowserToken(&c, "")
		}
		app.setSuccessMessage(&c, l.Get("Account deleted"))

		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}

// POST /web/delete-player
func FrontDeletePlayer(app *App) func(c echo.Context) error {
	return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
		l := app.getLocale(&c)
		returnURL := getReturnURL(app, &c)

		playerUUID := c.FormValue("uuid")

		var player Player
		result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID)
		if result.Error != nil {
			if errors.Is(result.Error, gorm.ErrRecordNotFound) {
				return NewWebError(returnURL, "Player not found.")
			}
			return result.Error
		}

		err := app.DeletePlayer(user, &player)
		if err != nil {
			var userError *UserError
			if errors.As(err, &userError) {
				return &WebError{ReturnURL: returnURL, Err: userError}
			}
			return err
		}

		app.setSuccessMessage(&c, l.Get("Player “%s” deleted", player.Name))

		return c.Redirect(http.StatusSeeOther, returnURL)
	})
}
