package storage

import (
	"crypto/rand"
	"errors"
	"strings"
	"time"

	"github.com/go-webauthn/webauthn/webauthn"
	"github.com/mstarongithub/passkey"
	"github.com/rs/zerolog/log"
	"gitlab.com/mstarongitlab/linstrom/ap"
	"gitlab.com/mstarongitlab/linstrom/config"
	"gorm.io/gorm"
)

// Database representation of a user account
// This can be a bot, remote or not
// If remote, this is used for caching the account
type Account struct {
	ID string `gorm:"primarykey"` // ID is a uuid for this account
	// Handle of the user (eg "max" if the full username is @max@example.com)
	// Assume unchangable (once set by a user) to be kind to other implementations
	// Would be an easy avenue to fuck with them though
	Handle    string
	CreatedAt time.Time // When this entry was created. Automatically set by gorm
	// When this account was last updated. Will also be used for refreshing remote accounts. Automatically set by gorm
	UpdatedAt time.Time
	// When this entry was deleted (for soft deletions)
	// Soft delete means that this entry still exists in the db, but gorm won't include it anymore unless specifically told to
	// If not null, this entry is marked as deleted
	DeletedAt    gorm.DeletedAt `gorm:"index"`
	Remote       bool           // Whether the account is a local or remote one
	Server       string         // The url of the server this account is from
	DisplayName  string         // The display name of the user. Can be different from the handle
	CustomFields []uint         `gorm:"serializer:json"` // IDs to the custom fields a user has
	Description  string         // The description of a user account
	Tags         []string       `gorm:"serializer:json"` // Hashtags
	IsBot        bool           // Whether to mark this account as a script controlled one
	Follows      []string       `gorm:"serializer:json"` // List of handles this account follows
	Followers    []string       `gorm:"serializer:json"` // List of handles that follow this account
	Icon         string         // ID of a media file used as icon
	Background   string         // ID of a media file used as background image
	Banner       string         // ID of a media file used as banner
	Indexable    bool           // Whether this account can be found by crawlers
	PublicKeyPem *string        // The public key of the account
	// Whether this account restricts following
	// If true, the owner must approve of a follow request first
	RestrictedFollow bool
	// List of things the owner identifies as
	// Example [cat human robot] means that the owner probably identifies as
	// a cyborg-catgirl/boy/human or a cathuman shaped robot, refer to Gender for pronouns
	IdentifiesAs []Being `gorm:"serializer:json"`
	// List of pronouns the owner identifies with
	// An unordered list since the owner can freely set it
	// Examples: [she her], [it they its them] or, if you want to go fancy, [this is super serious]
	Gender []string `gorm:"serializer:json"`
	// The roles assocciated with an account
	Roles []string `gorm:"serializer:json"`

	// --- And internal account stuff ---
	// Still public fields since they wouldn't be able to be stored in the db otherwise
	PrivateKeyPem *string // The private key of the account. Nil if remote user
	WebAuthnId    []byte  // The unique and random ID of this account used for passkey authentication
	// Whether the account got verified and is allowed to be active
	// For local accounts being active means being allowed to login and perform interactions
	// For remote users, if an account is not verified, any interactions it sends are discarded
	Verified           bool
	PasskeyCredentials []webauthn.Credential `gorm:"serializer:json"` // Webauthn credentials data
	// Has a RemoteAccountLinks included if remote user
	RemoteLinks *RemoteAccountLinks
}

// Contains static and cached info about a remote account, mostly links
type RemoteAccountLinks struct {
	// ---- Section: gorm
	// Sets this struct up as a value that an Account may have
	gorm.Model
	AccountID string

	// Just about every link here is optional to accomodate for servers with only minimal accounts
	// Minimal being handle, ap link and inbox
	ApLink           string
	ViewLink         *string
	FollowersLink    *string
	FollowingLink    *string
	InboxLink        string
	OutboxLink       *string
	FeaturedLink     *string
	FeaturedTagsLink *string
}

// Find an account in the db using a given full handle (@max@example.com)
// Returns an account and nil if an account is found, otherwise nil and the error
func (s *Storage) FindAccountByFullHandle(handle string) (*Account, error) {
	log.Trace().Caller().Send()
	log.Debug().Str("account-handle", handle).Msg("Looking for account by handle")
	log.Debug().Str("account-handle", handle).Msg("Checking if there's a cache hit")

	// Try and find the account in cache first
	cacheAccId, err := s.cacheHandleToAccUid(handle)
	if err == nil {
		log.Info().Str("account-handle", handle).Msg("Hit account handle in cache")
		// Then always load via id since unique key access should be faster than string matching
		return s.FindAccountById(*cacheAccId)
	} else {
		if !errors.Is(err, errCacheNotFound) {
			log.Error().Err(err).Str("account-handle", handle).Msg("Problem while checking cache for account")
			return nil, err
		}
	}

	// Failed to find in cache, go the slow route of hitting the db
	log.Debug().Str("account-handle", handle).Msg("Didn't hit account in cache, going to db")
	name, server, err := ap.SplitFullHandle(handle)
	if err != nil {
		log.Warn().Err(err).Str("account-handle", handle).Msg("Failed to split up account handle")
		return nil, err
	}

	acc := Account{}
	res := s.db.Where("name = ?", name).Where("server = ?", server).First(&acc)
	if res.Error != nil {
		if errors.Is(res.Error, gorm.ErrRecordNotFound) {
			log.Info().Str("account-handle", handle).Msg("Account with handle not found")
		} else {
			log.Error().Err(err).Str("account-handle", handle).Msg("Failed to get account with handle")
		}
		return nil, res.Error
	}
	log.Info().Str("account-handle", handle).Msg("Found account, also inserting into cache")
	if err = s.cache.Set(cacheUserIdToAccPrefix+acc.ID, &acc); err != nil {
		log.Warn().
			Err(err).
			Str("account-handle", handle).
			Msg("Found account but failed to insert into cache")
	}
	if err = s.cache.Set(cacheUserHandleToIdPrefix+strings.TrimLeft(handle, "@"), acc.ID); err != nil {
		log.Warn().
			Err(err).
			Str("account-handle", handle).
			Msg("Failed to store handle to id in cache")
	}
	return &acc, nil
}

// Find an account given a specific ID
func (s *Storage) FindAccountById(id string) (*Account, error) {
	log.Trace().Caller().Send()
	log.Debug().Str("account-id", id).Msg("Looking for account by id")
	log.Debug().Str("account-id", id).Msg("First trying to hit cache")
	acc, err := s.cacheAccIdToData(id)
	if err == nil {
		log.Info().Str("account-id", id).Msg("Found account in cache")
		return acc, nil
	} else if !errors.Is(err, errCacheNotFound) {
		log.Error().Err(err).Str("account-id", id).Msg("Error while looking for account in cache")
		return nil, err
	}

	log.Debug().Str("account-id", id).Msg("Didn't hit account in cache, checking db")
	res := s.db.First(acc, id)
	if res.Error != nil {
		if errors.Is(res.Error, gorm.ErrRecordNotFound) {
			log.Warn().Str("account-id", id).Msg("Account not found")
		} else {
			log.Error().Err(res.Error).Str("account-id", id).Msg("Failed to look for account")
		}
		return nil, res.Error
	}
	log.Info().Str("account-id", id).Msg("Found account in db, also adding to cache")
	if err = s.cache.Set(cacheUserIdToAccPrefix+id, acc); err != nil {
		log.Warn().Err(err).Str("account-id", id).Msg("Failed to add account to cache")
	}
	return acc, nil
}

// Update a given account in storage and cache
func (s *Storage) UpdateAccount(acc *Account) error {
	// If the account is nil or doesn't have an id, error out
	if acc == nil || acc.ID == "" {
		return ErrInvalidData
	}
	res := s.db.Save(acc)
	if res.Error != nil {
		return res.Error
	}
	if err := s.cache.Set(cacheUserIdToAccPrefix+acc.ID, acc); err != nil {
		return err
	}
	return nil
}

// Create a new empty account for future use
func (s *Storage) NewEmptyAccount() (*Account, error) {
	log.Trace().Caller().Send()
	log.Debug().Msg("Creating new empty account")
	acc := Account{}
	// Generate the 64 bit id for passkey and webauthn stuff
	data := make([]byte, 64)
	c, err := rand.Read(data)
	for err != nil || c != len(data) || c < 64 {
		data = make([]byte, 64)
		c, err = rand.Read(data)
	}
	acc.WebAuthnId = data
	acc.Followers = []string{}
	acc.Tags = []string{}
	acc.Follows = []string{}
	acc.Gender = []string{}
	acc.CustomFields = []uint{}
	acc.IdentifiesAs = []Being{}
	acc.PasskeyCredentials = []webauthn.Credential{}
	res := s.db.Save(acc)
	if res.Error != nil {
		log.Error().Err(res.Error).Msg("Failed to safe new account")
		return nil, res.Error
	}
	log.Info().Str("account-id", acc.ID).Msg("Created new account")
	return &acc, nil
}

// Create a new local account using the given handle
// The handle in this case is only the part before the domain (example: @bob@example.com would have a handle of bob)
// It also sets up a bunch of values that tend to be obvious for local accounts
func (s *Storage) NewLocalAccount(handle string) (*Account, error) {
	log.Trace().Caller().Send()
	log.Debug().Str("account-handle", handle).Msg("Creating new local account")
	acc, err := s.NewEmptyAccount()
	if err != nil {
		log.Error().Err(err).Msg("Failed to create empty account for use")
		return nil, err
	}
	acc.Handle = handle
	acc.Server = config.GlobalConfig.General.GetFullDomain()
	acc.Remote = false
	acc.DisplayName = handle

	log.Debug().
		Str("account-handle", handle).
		Str("account-id", acc.ID).
		Msg("Saving new local account")
	res := s.db.Save(acc)
	if res.Error != nil {
		log.Error().Err(res.Error).Any("account-full", acc).Msg("Failed to save local account")
		return nil, res.Error
	}
	log.Info().
		Str("account-handle", handle).
		Str("account-id", acc.ID).
		Msg("Created new local account")
	return acc, nil
}

// ---- Section WebAuthn.User
// Implements the webauthn.User interface for interaction with passkeys

func (a *Account) WebAuthnID() []byte {
	log.Trace().Caller().Send()
	return a.WebAuthnId
}

func (u *Account) WebAuthnName() string {
	log.Trace().Caller().Send()
	return u.Handle
}

func (u *Account) WebAuthnDisplayName() string {
	log.Trace().Caller().Send()
	return u.DisplayName
}

func (u *Account) WebAuthnCredentials() []webauthn.Credential {
	log.Trace().Caller().Send()
	return u.PasskeyCredentials
}

func (u *Account) WebAuthnIcon() string {
	log.Trace().Caller().Send()
	return ""
}

// ---- Section passkey.User
// Implements the passkey.User interface

func (u *Account) PutCredential(new webauthn.Credential) {
	log.Trace().Caller().Send()
	u.PasskeyCredentials = append(u.PasskeyCredentials, new)
}

// Section passkey.UserStore
// Implements the passkey.UserStore interface

func (s *Storage) GetOrCreateUser(userID string) passkey.User {
	log.Trace().Caller().Send()
	log.Debug().
		Str("account-handle", userID).
		Msg("Looking for or creating account for passkey stuff")
	acc := &Account{}
	res := s.db.Where(Account{Handle: userID, Server: config.GlobalConfig.General.GetFullDomain()}).
		First(acc)
	if errors.Is(res.Error, gorm.ErrRecordNotFound) {
		log.Debug().Str("account-handle", userID)
		var err error
		acc, err = s.NewLocalAccount(userID)
		if err != nil {
			log.Error().
				Err(err).
				Str("account-handle", userID).
				Msg("Failed to create new account for webauthn request")
			return nil
		}
	}
	return acc
}

func (s *Storage) GetUserByWebAuthnId(id []byte) passkey.User {
	log.Trace().Caller().Send()
	log.Debug().Bytes("webauthn-id", id).Msg("Looking for account with webauthn id")
	acc := Account{}
	res := s.db.Where(Account{WebAuthnId: id}).First(&acc)
	if res.Error != nil {
		log.Error().
			Err(res.Error).
			Bytes("webauthn-id", id).
			Msg("Failed to find user with webauthn ID")
		return nil
	}
	log.Info().Msg("Found account with given webauthn id")
	return &acc
}

func (s *Storage) SaveUser(rawUser passkey.User) {
	log.Trace().Caller().Send()
	user, ok := rawUser.(*Account)
	if !ok {
		log.Error().Any("raw-user", rawUser).Msg("Failed to cast raw user to db account")
	}
	s.db.Save(user)
}
