package models

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	"code.nonshy.com/nonshy/website/pkg/config"
	"code.nonshy.com/nonshy/website/pkg/log"
	"code.nonshy.com/nonshy/website/pkg/utility"
	"golang.org/x/crypto/bcrypt"
	"gorm.io/gorm"
)

// User account table.
type User struct {
	ID             uint64 `gorm:"primaryKey"`
	Username       string `gorm:"uniqueIndex"`
	Email          string `gorm:"uniqueIndex"`
	HashedPassword string
	IsAdmin        bool           `gorm:"index"`
	Status         UserStatus     `gorm:"index"` // active, disabled
	Visibility     UserVisibility `gorm:"index"` // public, private
	Name           *string
	Birthdate      time.Time
	Certified      bool
	Explicit       bool      `gorm:"index"` // user has opted-in to see explicit content
	InnerCircle    bool      `gorm:"index"` // user is in the inner circle
	CreatedAt      time.Time `gorm:"index"`
	UpdatedAt      time.Time `gorm:"index"`
	LastLoginAt    time.Time `gorm:"index"`

	// Relational tables.
	ProfileField   []ProfileField
	ProfilePhotoID *uint64
	ProfilePhoto   Photo         `gorm:"foreignKey:profile_photo_id"`
	AdminGroups    []*AdminGroup `gorm:"many2many:admin_group_users;"`

	// Current user's relationship to this user -- not stored in DB.
	UserRelationship UserRelationship `gorm:"-"`

	// Caches
	cachePhotoTypes     map[PhotoVisibility]struct{}
	cacheBlockedUserIDs []uint64
	cachePhotoIDs       []uint64
}

type UserVisibility string

const (
	UserVisibilityPublic   UserVisibility = "public"
	UserVisibilityExternal UserVisibility = "external"
	UserVisibilityPrivate  UserVisibility = "private"
)

// All visibility options.
var UserVisibilityOptions = []UserVisibility{
	UserVisibilityPublic,
	UserVisibilityExternal,
	UserVisibilityPrivate,
}

// Preload related tables for the user (classmethod).
func (u *User) Preload() *gorm.DB {
	return DB.Preload("ProfileField").Preload("ProfilePhoto").Preload("AdminGroups.Scopes")
}

// UserStatus options.
type UserStatus string

const (
	UserStatusActive   = "active"
	UserStatusDisabled = "disabled"
	UserStatusBanned   = "banned"
)

// CreateUser. It is assumed username and email are correctly formatted.
func CreateUser(username, email, password string) (*User, error) {
	// Verify username and email are unique.
	if _, err := FindUser(username); err == nil {
		return nil, errors.New("That username already exists. Please try a different username.")
	} else if _, err := FindUser(email); err == nil {
		return nil, errors.New("That email address is already registered.")
	}

	u := &User{
		Username:   username,
		Email:      email,
		Status:     UserStatusActive,
		Visibility: UserVisibilityPublic,
	}

	if err := u.HashPassword(password); err != nil {
		return nil, err
	}

	result := DB.Create(u)
	return u, result.Error
}

// GetUser by ID.
func GetUser(userId uint64) (*User, error) {
	user := &User{}
	result := user.Preload().First(&user, userId)
	return user, result.Error
}

// GetUsers queries for multiple user IDs and returns users in the same order.
func GetUsers(currentUser *User, userIDs []uint64) ([]*User, error) {
	userMap, err := MapUsers(currentUser, userIDs)
	if err != nil {
		return nil, err
	}

	// Re-order them per the original sequence.
	var users = []*User{}
	for _, uid := range userIDs {
		if user, ok := userMap[uid]; ok {
			users = append(users, user)
		}
	}

	return users, nil
}

// GetUsersByUsernames queries for multiple usernames and returns users in the same order.
func GetUsersByUsernames(currentUser *User, usernames []string) ([]*User, error) {
	// Map the usernames.
	var (
		usermap  = map[string]*User{}
		set      = map[string]interface{}{}
		distinct = []string{}
	)

	// Uniqueify usernames.
	for _, name := range usernames {
		if _, ok := set[name]; ok {
			continue
		}
		set[name] = nil
		distinct = append(distinct, name)
	}

	var (
		users  = []*User{}
		result = (&User{}).Preload().Where("username IN ?", distinct).Find(&users)
	)
	if result.Error != nil {
		return nil, result.Error
	}

	// Map users.
	for _, user := range users {
		usermap[user.Username] = user
	}

	// Inject relationships.
	SetUserRelationships(currentUser, users)

	// Re-order them per the original sequence.
	var ordered = []*User{}
	for _, name := range usernames {
		if user, ok := usermap[name]; ok {
			ordered = append(ordered, user)
		}
	}

	return ordered, nil
}

// FindUser by username or email.
func FindUser(username string) (*User, error) {
	if username == "" {
		return nil, errors.New("username is required")
	}

	u := &User{}
	if strings.ContainsRune(username, '@') {
		result := u.Preload().Where("email = ?", username).Limit(1).First(u)
		return u, result.Error
	}
	result := u.Preload().Where("username = ?", username).Limit(1).First(u)
	return u, result.Error
}

// IsShyFrom tells whether the user is shy from the perspective of the other user.
//
// That is, depending on our profile visibility and friendship status.
func (u *User) IsShyFrom(other *User) bool {
	// If we are not a private profile, we're shy from nobody.
	if u.Visibility != UserVisibilityPrivate {
		return false
	}

	// Not shy from our friends.
	if AreFriends(u.ID, other.ID) {
		return false
	}

	// Our profile must be private & we are not friended, we are shy.
	return true
}

// UserSearch config.
type UserSearch struct {
	Username      string
	Gender        string
	Orientation   string
	MaritalStatus string
	HereFor       string
	Certified     bool
	NotCertified  bool
	InnerCircle   bool
	ShyAccounts   bool
	IsBanned      bool
	Friends       bool
	AgeMin        int
	AgeMax        int
}

// SearchUsers from the perspective of a given user.
func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, error) {
	if search == nil {
		search = &UserSearch{}
	}

	var (
		users          = []*User{}
		query          *gorm.DB
		joins          string // GPS location join.
		wheres         = []string{}
		placeholders   = []interface{}{}
		blockedUserIDs = BlockedUserIDs(user)
		myLocation     = GetUserLocation(user.ID)
	)

	// Sort by distance? Requires PostgreSQL.
	if pager.Sort == "distance" {
		if !config.Current.Database.IsPostgres {
			return users, errors.New("ordering by distance requires PostgreSQL with the PostGIS extension")
		}

		// If the current user doesn't have their location on file, they can't do this.
		if myLocation.Source == LocationSourceNone || (myLocation.Latitude == 0 && myLocation.Longitude == 0) {
			return users, errors.New("can not order by distance because your location is not known")
		}

		// Only query for users who have locations.
		joins = "JOIN user_locations ON (user_locations.user_id = users.id)"
		wheres = append(wheres,
			"user_locations.latitude IS NOT NULL",
			"user_locations.longitude IS NOT NULL",
			"user_locations.latitude <> 0",
			"user_locations.longitude <> 0",
		)

		pager.Sort = fmt.Sprintf(`ST_Distance(
				ST_MakePoint(user_locations.longitude, user_locations.latitude)::geography,
				ST_MakePoint(%f, %f)::geography)`,
			myLocation.Longitude, myLocation.Latitude,
		)
	}

	if len(blockedUserIDs) > 0 {
		wheres = append(wheres, "id NOT IN ?")
		placeholders = append(placeholders, blockedUserIDs)
	}

	if search.Username != "" {
		ilike := "%" + strings.TrimSpace(strings.ToLower(search.Username)) + "%"
		wheres = append(wheres, "(username LIKE ? OR name ILIKE ?)")
		placeholders = append(placeholders, ilike, ilike)
	}

	if search.Gender != "" {
		wheres = append(wheres, `
			EXISTS (
				SELECT 1 FROM profile_fields
				WHERE user_id = users.id AND name = ? AND value = ?
			)
		`)
		placeholders = append(placeholders, "gender", search.Gender)
	}

	if search.Orientation != "" {
		wheres = append(wheres, `
			EXISTS (
				SELECT 1 FROM profile_fields
				WHERE user_id = users.id AND name = ? AND value = ?
			)
		`)
		placeholders = append(placeholders, "orientation", search.Orientation)
	}

	if search.MaritalStatus != "" {
		wheres = append(wheres, `
			EXISTS (
				SELECT 1 FROM profile_fields
				WHERE user_id = users.id AND name = ? AND value = ?
			)
		`)
		placeholders = append(placeholders, "marital_status", search.MaritalStatus)
	}

	if search.HereFor != "" {
		wheres = append(wheres, `
			EXISTS (
				SELECT 1 FROM profile_fields
				WHERE user_id = users.id AND name = ? AND value LIKE ?
			)
		`)
		placeholders = append(placeholders, "here_for", "%"+search.HereFor+"%")
	}

	// All user searches will show active accounts only, unless we are admin.
	if user.IsAdmin && search.IsBanned {
		wheres = append(wheres, "status IN ?")
		placeholders = append(placeholders, []string{
			UserStatusBanned,
			UserStatusDisabled,
		})
	} else if !user.IsAdmin {
		wheres = append(wheres, "status = ?")
		placeholders = append(placeholders, UserStatusActive)
	}

	// Certified filter (including if Shy Accounts are asked for)
	if search.Certified {
		wheres = append(wheres, "certified = ?")
		placeholders = append(placeholders, search.Certified)
	}

	// Expressly Not Certified filtering
	if search.NotCertified {
		wheres = append(wheres, "certified = ?")
		placeholders = append(placeholders, false)
	}

	if search.InnerCircle {
		wheres = append(wheres, "inner_circle = ? OR is_admin = ?")
		placeholders = append(placeholders, true, true)
	}

	if search.ShyAccounts {
		a, b := WhereClauseShyAccounts()
		wheres = append(wheres, a)
		placeholders = append(placeholders, b...)
	}

	if search.Friends {
		wheres = append(wheres, `
			EXISTS (
				SELECT 1 FROM friends
				WHERE source_user_id = ?
				AND target_user_id = users.id
				AND approved = ?
			)
		`)
		placeholders = append(placeholders,
			user.ID,
			true,
		)
	}

	if search.AgeMin > 0 {
		date := time.Now().AddDate(-search.AgeMin, 0, 0)
		wheres = append(wheres, `
			birthdate <= ? AND NOT EXISTS (
				SELECT 1
				FROM profile_fields
				WHERE user_id = users.id
				AND name = 'hide_age'
				AND value = 'true'
			)
		`)
		placeholders = append(placeholders, date)
	}

	if search.AgeMax > 0 {
		date := time.Now().AddDate(-search.AgeMax-1, 0, 0)
		wheres = append(wheres, `
			birthdate >= ? AND NOT EXISTS (
				SELECT 1
				FROM profile_fields
				WHERE user_id = users.id
				AND name = 'hide_age'
				AND value = 'true'
			)
		`)
		placeholders = append(placeholders, date)
	}

	query = (&User{}).Preload()
	if joins != "" {
		query = query.Joins(joins)
	}
	query = query.Where(
		strings.Join(wheres, " AND "),
		placeholders...,
	).Order(pager.Sort)
	query.Model(&User{}).Count(&pager.Total)
	result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users)

	// Inject relationship booleans.
	SetUserRelationships(user, users)

	return users, result.Error
}

// UserMap helps map a set of users to look up by ID.
type UserMap map[uint64]*User

// MapUsers looks up a set of user IDs in bulk and returns a UserMap suitable for templates.
// Useful to avoid circular reference issues with Photos especially; the Site Gallery queries
// photos of ALL users and MapUsers helps stitch them together for the frontend.
func MapUsers(user *User, userIDs []uint64) (UserMap, error) {
	var (
		usermap  = UserMap{}
		set      = map[uint64]interface{}{}
		distinct = []uint64{}
	)

	// Uniqueify users.
	for _, uid := range userIDs {
		if _, ok := set[uid]; ok {
			continue
		}
		set[uid] = nil
		distinct = append(distinct, uid)
	}

	var (
		users  = []*User{}
		result = (&User{}).Preload().Where("id IN ?", distinct).Find(&users)
	)

	// Inject user relationships.
	if user != nil {
		SetUserRelationships(user, users)
	}

	if result.Error == nil {
		for _, row := range users {
			usermap[row.ID] = row
		}
	}

	return usermap, result.Error
}

// Has a user ID in the map?
func (um UserMap) Has(id uint64) bool {
	_, ok := um[id]
	return ok
}

// Get a user from the UserMap.
func (um UserMap) Get(id uint64) *User {
	if user, ok := um[id]; ok {
		return user
	}
	return nil
}

// MapUsersByUsername looks up a set of users in bulk and returns a UsernameMap suitable for templates.
//
// It is like MapUsers but by username instead of ID.
func MapUsersByUsername(usernames []string) (UsernameMap, error) {
	var (
		usermap  = UsernameMap{}
		set      = map[string]interface{}{}
		distinct = []string{}
	)

	// Uniqueify users.
	for _, uid := range usernames {
		if _, ok := set[uid]; ok {
			continue
		}
		set[uid] = nil
		distinct = append(distinct, uid)
	}

	var (
		users  = []*User{}
		result = (&User{}).Preload().Where("username IN ?", distinct).Find(&users)
	)

	if result.Error == nil {
		for _, row := range users {
			usermap[row.Username] = row
		}
	}

	// Assert we got the expected count.
	if len(usermap) != len(distinct) {
		return usermap, fmt.Errorf("didn't get all expected users (expected %d, got %d)", len(distinct), len(usermap))
	}

	return usermap, result.Error
}

// UsernameMap helps map a set of users to look up by ID.
type UsernameMap map[string]*User

// NameOrUsername returns the name (if not null or empty) or the username.
func (u *User) NameOrUsername() string {
	if u.Name != nil && len(*u.Name) > 0 {
		return *u.Name
	} else {
		return u.Username
	}
}

// VisibleAvatarURL returns a URL to the user's avatar taking into account
// their relationship with the current user. For example, if the avatar is
// friends-only and the current user can't see it, returns the path to the
// yellow placeholder avatar instead.
//
// Expects that UserRelationships are available on the user.
func (u *User) VisibleAvatarURL(currentUser *User) string {
	if u.ProfilePhoto.Visibility == PhotoPrivate && !u.UserRelationship.IsPrivateGranted {
		return "/static/img/shy-private.png"
	} else if u.ProfilePhoto.Visibility == PhotoFriends && !u.UserRelationship.IsFriend {
		return "/static/img/shy-friends.png"
	} else if u.ProfilePhoto.CroppedFilename != "" {
		return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
	}
	return "/static/img/shy.png"
}

// HashPassword sets the user's hashed (bcrypt) password.
func (u *User) HashPassword(password string) error {
	passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost)
	if err != nil {
		return err
	}
	u.HashedPassword = string(passwd)
	return nil
}

// CheckPassword verifies the password is correct. Returns nil on success.
func (u *User) CheckPassword(password string) error {
	return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password))
}

// SetProfileField sets or creates a named profile field.
func (u *User) SetProfileField(name, value string) {
	// Check if it exists.
	log.Debug("User(%s).SetProfileField(%s, %s)", u.Username, name, value)
	var exists bool
	for _, field := range u.ProfileField {
		log.Debug("\tCheck existing field %s", field.Name)
		if field.Name == name {
			log.Debug("\tFound existing field!")
			changed := field.Value != value
			field.Value = value
			exists = true

			// Save it now. TODO: otherwise gorm doesn't know we changed
			// it and it won't be inserted when the User is saved. But
			// this is probably not performant to do!
			if changed {
				DB.Save(&field)
			}
			break
		}
	}

	if exists {
		return
	}

	log.Debug("User(%s): append ProfileField %s", u.Username, name)
	u.ProfileField = append(u.ProfileField, ProfileField{
		Name:  name,
		Value: value,
	})
}

// GetProfileField returns the value of a profile field or blank string.
func (u *User) GetProfileField(name string) string {
	for _, field := range u.ProfileField {
		if field.Name == name {
			return field.Value
		}
	}
	return ""
}

// GetProfileFieldOr returns a default string (like "n/a") if the profile field is not set.
func (u *User) GetProfileFieldOr(name, or string) string {
	if value := u.GetProfileField(name); value != "" {
		return value
	}
	return or
}

// GetDisplayAge returns the user's age dependent on their hide-my-age setting.
func (u *User) GetDisplayAge() string {
	if !u.Birthdate.IsZero() && u.GetProfileField("hide_age") != "true" {
		return fmt.Sprintf("%dyo", utility.Age(u.Birthdate))
	}

	return "n/a"
}

// IsBirthday returns whether it is currently the user's birthday (+- a day for time zones).
func (u *User) IsBirthday() bool {
	if u.Birthdate.IsZero() {
		return false
	}

	// Window of time to be valid.
	var (
		now, _    = time.Parse(time.DateOnly, time.Now().Format(time.DateOnly))
		bday, _   = time.Parse(time.DateOnly, fmt.Sprintf("%d-%d-%d", now.Year(), u.Birthdate.Month(), u.Birthdate.Day()))
		startDate = now.Add(-6 * time.Hour)
		endDate   = now.Add(60 * time.Hour)
	)

	return bday.Before(endDate) && bday.After(startDate)
}

// ProfileFieldIn checks if a substring is IN a profile field. Currently
// does a naive strings.Contains(), intended for the "here_for" field.
func (u *User) ProfileFieldIn(field, substr string) bool {
	value := u.GetProfileField(field)
	return strings.Contains(value, substr)
}

// RemoveProfilePhoto sets profile_photo_id=null to unset the foreign key.
func (u *User) RemoveProfilePhoto() error {
	result := DB.Model(&User{}).Where("id = ?", u.ID).Update("profile_photo_id", nil)
	return result.Error
}

// Save user.
func (u *User) Save() error {
	result := DB.Save(u)
	return result.Error
}

// Delete a user. NOTE: use the models/deletion/DeleteUser() function
// instead of this to do a deep scrub of all related data!
func (u *User) Delete() error {
	return DB.Delete(u).Error
}

// Print user object as pretty JSON.
func (u *User) Print() string {
	var (
		buf bytes.Buffer
		enc = json.NewEncoder(&buf)
	)
	enc.SetIndent("", "    ")
	enc.Encode(u)
	return buf.String()
}
