package main

import (
	"crypto/md5"
	"database/sql"
	"errors"
	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
	"github.com/samber/mo"
	"golang.org/x/crypto/scrypt"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
	"net/mail"
	"net/url"
	"strings"
	"time"
)

const (
	SkinModelSlim    string = "slim"
	SkinModelClassic string = "classic"
)

const (
	TextureTypeSkin string = "skin"
	TextureTypeCape string = "cape"
)

const (
	PlayerUUIDGenerationRandom  string = "random"
	PlayerUUIDGenerationOffline string = "offline"
)

func MakeNullString(s *string) sql.NullString {
	if s == nil {
		return sql.NullString{Valid: false}
	}
	new_string := *s
	return sql.NullString{
		String: new_string,
		Valid:  true,
	}
}

func UnmakeNullString(ns *sql.NullString) *string {
	if ns.Valid {
		new_string := ns.String
		return &new_string
	}
	return nil
}

func NullStringToOption(ns *sql.NullString) mo.Option[string] {
	if ns.Valid {
		return mo.Some(ns.String)
	}
	return mo.None[string]()
}

func OptionToNullString(option mo.Option[string]) sql.NullString {
	if s, ok := option.Get(); ok {
		return sql.NullString{
			String: s,
			Valid:  true,
		}
	}
	return sql.NullString{Valid: false}
}

func IsValidSkinModel(model string) bool {
	switch model {
	case SkinModelSlim, SkinModelClassic:
		return true
	default:
		return false
	}
}

func UUIDToID(uuid string) (string, error) {
	if len(uuid) != 36 {
		return "", NewUserError("invalid UUID")
	}
	return strings.ReplaceAll(uuid, "-", ""), nil
}

func IDToUUID(id string) (string, error) {
	if len(id) != 32 {
		return "", NewUserError("invalid ID")
	}
	return id[0:8] + "-" + id[8:12] + "-" + id[12:16] + "-" + id[16:20] + "-" + id[20:], nil
}

func ParseUUID(idOrUUID string) (string, error) {
	if len(idOrUUID) == 32 {
		uuid_, err := IDToUUID(idOrUUID)
		if err != nil {
			return "", err
		}
		if _, err := uuid.Parse(uuid_); err != nil {
			return "", err
		}
		return uuid_, nil
	}
	if len(idOrUUID) == 36 {
		if _, err := uuid.Parse(idOrUUID); err != nil {
			return "", err
		}
		return idOrUUID, nil
	}
	return "", NewUserError("invalid ID or UUID")
}

type Plural struct {
	Message string
	N       int
}

func (app *App) ValidatePlayerName(playerName string) error {
	if app.TransientLoginEligible(playerName) {
		return NewUserError("name is reserved for transient login")
	}
	maxLength := Constants.MaxPlayerNameLength
	if playerName == "" {
		return NewUserError("can't be blank")
	}
	if len(playerName) > maxLength {
		return &UserError{
			Message: "can't be longer than %d character",
			Plural: mo.Some(Plural{
				Message: "can't be longer than %d characters",
				N:       maxLength,
			}),
			Params: []any{maxLength},
		}
	}

	if !app.ValidPlayerNameRegex.MatchString(playerName) {
		return NewUserError("must match the following regular expression: %s", app.Config.ValidPlayerNameRegex)
	}
	return nil
}

func (app *App) ValidateUsername(username string) error {
	// Valid username are either valid player names or valid email addresses
	playerNameErr := app.ValidatePlayerName(username)
	if playerNameErr == nil {
		return nil
	}
	_, emailErr := mail.ParseAddress(username)
	if emailErr == nil {
		return nil
	}
	return NewUserError("neither a valid player name (%s) nor an email address", playerNameErr)
}

func (app *App) ValidatePlayerNameOrUUID(player string) error {
	err := app.ValidatePlayerName(player)
	if err != nil {
		_, uuidErr := uuid.Parse(player)
		if uuidErr != nil {
			return NewUserError("not a valid player name or UUID")
		}
		return nil
	}
	return nil
}

func (app *App) ValidateMaxPlayerCount(maxPlayerCount int) error {
	if maxPlayerCount < 0 && maxPlayerCount != app.Constants.MaxPlayerCountUnlimited && maxPlayerCount != app.Constants.MaxPlayerCountUseDefault {
		return NewUserError("must be greater than 0, or use -1 to indicate unlimited players, or use -2 to use the system default")
	}
	return nil
}

// func MakeTransientUser(app *App, playerName string) (User, error) {
// 	preimage := bytes.Join([][]byte{
// 		[]byte("uuid"),
// 		[]byte(playerName),
// 		app.KeyB3Sum512,
// 	}, []byte{})
// 	sum := blake3.Sum512(preimage)
// 	accountUUID, err := uuid.FromBytes(sum[:16])
// 	if err != nil {
// 		return User{}, err
// 	}
//
// 	apiToken, err := MakeAPIToken()
// 	if err != nil {
// 		return User{}, err
// 	}
//
// 	user := User{
// 		UUID:              accountUUID.String(),
// 		Username:          playerName,
// 		FallbackPlayer:    playerName,
// 		PasswordSalt:      []byte{},
// 		PasswordHash:      []byte{},
// 		Clients:           []Client{},
// 		PlayerName:        playerName,
// 		PreferredLanguage: app.Config.DefaultPreferredLanguage,
// 		SkinModel:         SkinModelClassic,
// 		BrowserToken:      MakeNullString(nil),
// 		APIToken:          apiToken,
// 		CreatedAt:         time.Now(),
// 		NameLastChangedAt: time.Now(),
// 	}
// 	return user, nil
// }

func (app *App) TransientLoginEligible(playerName string) bool {
	return app.Config.TransientUsers.Allow &&
		app.TransientUsernameRegex.MatchString(playerName) &&
		len(playerName) <= Constants.MaxPlayerNameLength
}

func (app *App) ValidatePassword(password string) error {
	if password == "" {
		return NewUserError("can't be blank")
	}
	if len(password) < app.Config.MinPasswordLength {
		return &UserError{
			Message: "must be longer than %d character",
			Plural: mo.Some(Plural{
				Message: "must be longer than %d characters",
				N:       app.Config.MinPasswordLength,
			}),
			Params: []any{app.Config.MinPasswordLength},
		}
	}
	return nil
}

func OfflineUUID(playerName string) (string, error) {
	str := "OfflinePlayer:" + playerName

	hasher := md5.New()
	hasher.Write([]byte(str))
	md5Bytes := hasher.Sum(nil)

	// https://hg.openjdk.org/jdk8/jdk8/jdk/file/tip/src/share/classes/java/util/UUID.java#l162
	md5Bytes[6] &= 0x0f // clear version
	md5Bytes[6] |= 0x30 // set to version 3
	md5Bytes[8] &= 0x3f // clear variant
	md5Bytes[8] |= 0x80 // set to IETF variant

	offlineUUID, err := uuid.FromBytes(md5Bytes)
	if err != nil {
		return "", err
	}

	return offlineUUID.String(), nil
}

func IsValidPreferredLanguage(preferredLanguage string) bool {
	switch preferredLanguage {
	case "sq",
		"ar",
		"be",
		"bg",
		"ca",
		"zh",
		"hr",
		"cs",
		"da",
		"nl",
		"en",
		"et",
		"fi",
		"fr",
		"de",
		"el",
		"iw",
		"hi",
		"hu",
		"is",
		"in",
		"ga",
		"it",
		"ja",
		"ko",
		"lv",
		"lt",
		"mk",
		"ms",
		"mt",
		"no",
		"nb",
		"nn",
		"pl",
		"pt",
		"ro",
		"ru",
		"sr",
		"sk",
		"sl",
		"es",
		"sv",
		"th",
		"tr",
		"uk",
		"vi":
		return true
	default:
		return false
	}
}

const SCRYPT_N = 32768
const SCRYPT_r = 8
const SCRYPT_p = 1
const SCRYPT_BYTES = 32

func HashPassword(password string, salt []byte) ([]byte, error) {
	return scrypt.Key(
		[]byte(password),
		salt,
		SCRYPT_N,
		SCRYPT_r,
		SCRYPT_p,
		SCRYPT_BYTES,
	)
}

func (app *App) SkinURL(hash string) (string, error) {
	return url.JoinPath(app.FrontEndURL, "web/texture/skin/"+hash+".png")
}

func (app *App) InviteURL(invite *Invite) (string, error) {
	url, err := url.JoinPath(app.FrontEndURL, "web/registration")
	if err != nil {
		return "", err
	}
	return url + "?invite=" + invite.Code, nil
}

func (app *App) CapeURL(hash string) (string, error) {
	return url.JoinPath(app.FrontEndURL, "web/texture/cape/"+hash+".png")
}

func MakeAPIToken() (string, error) {
	return RandomBase62(16)
}

func MakeMinecraftToken() (string, error) {
	random, err := RandomBase62(16)
	if err != nil {
		return "", err
	}
	return "MC_" + random, nil
}

type TokenClaims struct {
	jwt.RegisteredClaims
	Version int              `json:"version"`
	StaleAt *jwt.NumericDate `json:"staleAt"`
}

var DISTANT_FUTURE time.Time = Unwrap(time.Parse(time.RFC3339Nano, "2038-01-01T00:00:00.000000000Z"))

func (app *App) MakeAccessToken(client Client) (string, error) {
	var expiresAt time.Time
	if app.Config.TokenExpireSec > 0 {
		expiresAt = time.Now().Add(time.Duration(app.Config.TokenExpireSec) * time.Second)
	} else {
		expiresAt = DISTANT_FUTURE
	}
	var staleAt time.Time
	if app.Config.TokenStaleSec > 0 {
		staleAt = time.Now().Add(time.Duration(app.Config.TokenStaleSec) * time.Second)
	} else {
		staleAt = DISTANT_FUTURE
	}
	token := jwt.NewWithClaims(jwt.SigningMethodRS512,
		TokenClaims{
			RegisteredClaims: jwt.RegisteredClaims{
				Subject:   client.UUID,
				IssuedAt:  jwt.NewNumericDate(time.Now()),
				ExpiresAt: jwt.NewNumericDate(expiresAt),
				Audience:  nil,
				Issuer:    "drasl",
			},
			Version: client.Version,
			StaleAt: jwt.NewNumericDate(staleAt),
		})
	return token.SignedString(app.PrivateKey)
}

type StaleTokenPolicy int

const (
	StalePolicyAllow StaleTokenPolicy = iota
	StalePolicyDeny
)

func (app *App) GetClient(accessToken string, stalePolicy StaleTokenPolicy, requirePlayer bool) (*Client, error) {
	token, err := jwt.ParseWithClaims(accessToken, &TokenClaims{}, func(token *jwt.Token) (any, error) {
		return app.PrivateKey.Public(), nil
	})
	if err != nil {
		return nil, NewUserError("couldn't parse JWT")
	}
	if !token.Valid {
		return nil, NewUserError("JWT is invalid")
	}
	claims, ok := token.Claims.(*TokenClaims)
	if !ok {
		return nil, NewUserError("JWT does not contain expected claims")
	}

	var client Client
	result := app.DB.Preload("User").Preload("Player").First(&client, "uuid = ?", claims.Subject)
	if result.Error != nil {
		return nil, NewUserError("client not found")
	}
	if stalePolicy == StalePolicyDeny && time.Now().After(claims.StaleAt.Time) {
		return nil, NewUserError("token is stale")
	}
	if claims.Subject != client.UUID {
		return nil, NewUserError("token subject does not match client UUID")
	}
	if claims.Version != client.Version {
		return nil, NewUserError("token version is not current")
	}
	client.LastUsedAt = time.Now()
	if err := app.DB.Save(&client).Error; err != nil {
		return nil, errors.New("couldn't update client LastUsedAt")
	}

	if requirePlayer && client.Player == nil {
		return nil, NewUserError("client is not bound to a player")
	}
	return &client, nil
}

func (app *App) GetMaxPlayerCount(user *User) int {
	if user.IsAdmin {
		return Constants.MaxPlayerCountUnlimited
	}
	if user.MaxPlayerCount == Constants.MaxPlayerCountUseDefault {
		return app.Config.DefaultMaxPlayerCount
	}
	return user.MaxPlayerCount
}

type User struct {
	IsAdmin           bool
	IsLocked          bool
	UUID              string `gorm:"primaryKey"`
	Username          string `gorm:"unique;not null"`
	PasswordSalt      []byte
	PasswordHash      []byte
	BrowserToken      sql.NullString `gorm:"index"`
	MinecraftToken    string
	APIToken          string
	PreferredLanguage string
	Players           []Player
	MaxPlayerCount    int
	Clients           []Client
	OIDCIdentities    []UserOIDCIdentity
}

func (user *User) BeforeDelete(tx *gorm.DB) error {
	if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&Player{}).Error; err != nil {
		return err
	}
	if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&Client{}).Error; err != nil {
		return err
	}
	if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&UserOIDCIdentity{}).Error; err != nil {
		return err
	}
	return nil
}

type UserOIDCIdentity struct {
	ID       uint `gorm:"primaryKey"`
	User     User
	UserUUID string `gorm:"index;not null"`
	Subject  string `gorm:"uniqueIndex:subject_issuer_unique_index;not null"`
	Issuer   string `gorm:"uniqueIndex:subject_issuer_unique_index;not null"`
}

func (UserOIDCIdentity) TableName() string {
	return "user_oidc_identities"
}

func (player *Player) AfterFind(tx *gorm.DB) error {
	if err := tx.Find(&player.Clients, "player_uuid = ?", player.UUID).Error; err != nil {
		return err
	}
	return nil
}

func (user *User) AfterFind(tx *gorm.DB) error {
	if err := tx.Find(&user.OIDCIdentities, "user_uuid = ?", user.UUID).Error; err != nil {
		return err
	}
	if err := tx.Find(&user.Players, "user_uuid = ?", user.UUID).Error; err != nil {
		return err
	}
	if err := tx.Find(&user.Clients, "user_uuid = ?", user.UUID).Error; err != nil {
		return err
	}
	return nil
}

type Player struct {
	UUID              string `gorm:"primaryKey"`
	Name              string `gorm:"unique;not null;type:text collate nocase"`
	OfflineUUID       string `gorm:"not null"`
	CreatedAt         time.Time
	NameLastChangedAt time.Time
	SkinHash          sql.NullString `gorm:"index"`
	SkinModel         string
	CapeHash          sql.NullString `gorm:"index"`
	ServerID          sql.NullString
	FallbackPlayer    string
	User              User
	UserUUID          string   `gorm:"not null"`
	Clients           []Client `gorm:"constraint:OnDelete:CASCADE"`
}

func (player *Player) BeforeDelete(tx *gorm.DB) error {
	return tx.Clauses(clause.Returning{}).Where("player_uuid = ?", player.UUID).Delete(&Client{}).Error
}

type Client struct {
	UUID        string `gorm:"primaryKey"`
	ClientToken string
	Version     int
	UserUUID    string `gorm:"not null"`
	User        User
	PlayerUUID  sql.NullString `gorm:"index"`
	Player      *Player
	LastUsedAt  time.Time
}

func NewClient(user *User, clientToken string, playerUUID mo.Option[string]) Client {
	return Client{
		UserUUID:    user.UUID,
		UUID:        uuid.New().String(),
		ClientToken: clientToken,
		Version:     0,
		PlayerUUID:  OptionToNullString(playerUUID),
		LastUsedAt:  time.Now(),
	}
}

func (app *App) GetSkinURL(player *Player) (*string, error) {
	if !player.SkinHash.Valid {
		return nil, nil
	}
	url, err := app.SkinURL(player.SkinHash.String)
	if err != nil {
		return nil, err
	}
	return &url, nil
}

func (app *App) GetCapeURL(player *Player) (*string, error) {
	if !player.CapeHash.Valid {
		return nil, nil
	}
	url, err := app.CapeURL(player.CapeHash.String)
	if err != nil {
		return nil, err
	}
	return &url, nil
}

type Invite struct {
	Code      string `gorm:"primaryKey"`
	CreatedAt time.Time
}
