package main

import (
	"fmt"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/itchyny/timefmt-go"
	"github.com/lithammer/shortuuid/v3"
	"github.com/timshannon/badgerhold/v4"
)

const (
	CAPTCHA_VALIDITY = 20 * 60 // Seconds
)

// GenerateInviteCode generates an invite code in the correct format.
func GenerateInviteCode() string {
	// make sure code doesn't begin with number
	inviteCode := shortuuid.New()
	_, err := strconv.Atoi(string(inviteCode[0]))
	for err == nil {
		inviteCode = shortuuid.New()
		_, err = strconv.Atoi(string(inviteCode[0]))
	}
	return inviteCode
}

func (app *appContext) checkInvites() {
	currentTime := time.Now()
	for _, data := range app.storage.GetInvites() {
		captchas := data.Captchas
		captchasExpired := false
		for key, capt := range data.Captchas {
			if time.Now().After(capt.Generated.Add(CAPTCHA_VALIDITY * time.Second)) {
				delete(captchas, key)
				captchasExpired = true
			}
		}
		if captchasExpired {
			data.Captchas = captchas
			app.storage.SetInvitesKey(data.Code, data)
		}

		if data.IsReferral {
			continue
		}
		expiry := data.ValidTill
		if !currentTime.After(expiry) {
			continue
		}
		app.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)
		notify := data.Notify
		if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
			app.debug.Printf("%s: Expiry notification", data.Code)
			var wait sync.WaitGroup
			for address, settings := range notify {
				if !settings["notify-expiry"] {
					continue
				}
				wait.Add(1)
				go func(addr string) {
					defer wait.Done()
					msg, err := app.email.constructExpiry(data.Code, data, app, false)
					if err != nil {
						app.err.Printf("%s: Failed to construct expiry notification: %v", data.Code, err)
					} else {
						// Check whether notify "address" is an email address of Jellyfin ID
						if strings.Contains(addr, "@") {
							err = app.email.send(msg, addr)
						} else {
							err = app.sendByID(msg, addr)
						}
						if err != nil {
							app.err.Printf("%s: Failed to send expiry notification: %v", data.Code, err)
						} else {
							app.info.Printf("Sent expiry notification to %s", addr)
						}
					}
				}(address)
			}
			wait.Wait()
		}
		app.storage.DeleteInvitesKey(data.Code)
	}
}

func (app *appContext) checkInvite(code string, used bool, username string) bool {
	currentTime := time.Now()
	inv, match := app.storage.GetInvitesKey(code)
	if !match {
		return false
	}
	expiry := inv.ValidTill
	if currentTime.After(expiry) {
		app.debug.Printf("Housekeeping: Deleting old invite %s", code)
		notify := inv.Notify
		if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
			app.debug.Printf("%s: Expiry notification", code)
			var wait sync.WaitGroup
			for address, settings := range notify {
				if !settings["notify-expiry"] {
					continue
				}
				wait.Add(1)
				go func(addr string) {
					defer wait.Done()
					msg, err := app.email.constructExpiry(code, inv, app, false)
					if err != nil {
						app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
					} else {
						// Check whether notify "address" is an email address of Jellyfin ID
						if strings.Contains(addr, "@") {
							err = app.email.send(msg, addr)
						} else {
							err = app.sendByID(msg, addr)
						}
						if err != nil {
							app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
						} else {
							app.info.Printf("Sent expiry notification to %s", addr)
						}
					}
				}(address)
			}
			wait.Wait()
		}
		match = false
		app.storage.DeleteInvitesKey(code)
	} else if used {
		del := false
		newInv := inv
		if newInv.RemainingUses == 1 {
			del = true
			app.storage.DeleteInvitesKey(code)
		} else if newInv.RemainingUses != 0 {
			// 0 means infinite i guess?
			newInv.RemainingUses--
		}
		newInv.UsedBy = append(newInv.UsedBy, []string{username, strconv.FormatInt(currentTime.Unix(), 10)})
		if !del {
			app.storage.SetInvitesKey(code, newInv)
		}
	}
	return match
}

// @Summary Create a new invite.
// @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
// @Success 200 {object} boolResponse
// @Router /invites [post]
// @Security Bearer
// @tags Invites
func (app *appContext) GenerateInvite(gc *gin.Context) {
	var req generateInviteDTO
	app.debug.Println("Generating new invite")
	gc.BindJSON(&req)
	currentTime := time.Now()
	validTill := currentTime.AddDate(0, req.Months, req.Days)
	validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
	var invite Invite
	invite.Code = GenerateInviteCode()
	if req.Label != "" {
		invite.Label = req.Label
	}
	if req.UserLabel != "" {
		invite.UserLabel = req.UserLabel
	}
	invite.Created = currentTime
	if req.MultipleUses {
		if req.NoLimit {
			invite.NoLimit = true
		} else {
			invite.RemainingUses = req.RemainingUses
		}
	} else {
		invite.RemainingUses = 1
	}
	invite.UserExpiry = req.UserExpiry
	if invite.UserExpiry {
		invite.UserMonths = req.UserMonths
		invite.UserDays = req.UserDays
		invite.UserHours = req.UserHours
		invite.UserMinutes = req.UserMinutes
	}
	invite.ValidTill = validTill
	if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
		addressValid := false
		discord := ""
		app.debug.Printf("%s: Sending invite message", invite.Code)
		if discordEnabled && !strings.Contains(req.SendTo, "@") {
			users := app.discord.GetUsers(req.SendTo)
			if len(users) == 0 {
				invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
			} else if len(users) > 1 {
				invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
			} else {
				invite.SendTo = req.SendTo
				addressValid = true
				discord = users[0].User.ID
			}
		} else if emailEnabled {
			addressValid = true
			invite.SendTo = req.SendTo
		}
		if addressValid {
			msg, err := app.email.constructInvite(invite.Code, invite, app, false)
			if err != nil {
				invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
				app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
			} else {
				var err error
				if discord != "" {
					err = app.discord.SendDM(msg, discord)
				} else {
					err = app.email.send(msg, req.SendTo)
				}
				if err != nil {
					invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
					app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
				} else {
					app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, req.SendTo)
				}
			}
		}
	}
	if req.Profile != "" {
		if _, ok := app.storage.GetProfileKey(req.Profile); ok {
			invite.Profile = req.Profile
		} else {
			invite.Profile = "Default"
		}
	}
	app.storage.SetInvitesKey(invite.Code, invite)
	respondBool(200, true, gc)
}

// @Summary Get invites.
// @Produce json
// @Success 200 {object} getInvitesDTO
// @Router /invites [get]
// @Security Bearer
// @tags Invites
func (app *appContext) GetInvites(gc *gin.Context) {
	app.debug.Println("Invites requested")
	currentTime := time.Now()
	app.checkInvites()
	var invites []inviteDTO
	for _, inv := range app.storage.GetInvites() {
		if inv.IsReferral {
			continue
		}
		_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
		invite := inviteDTO{
			Code:        inv.Code,
			Months:      months,
			Days:        days,
			Hours:       hours,
			Minutes:     minutes,
			UserExpiry:  inv.UserExpiry,
			UserMonths:  inv.UserMonths,
			UserDays:    inv.UserDays,
			UserHours:   inv.UserHours,
			UserMinutes: inv.UserMinutes,
			Created:     inv.Created.Unix(),
			Profile:     inv.Profile,
			NoLimit:     inv.NoLimit,
			Label:       inv.Label,
			UserLabel:   inv.UserLabel,
		}
		if len(inv.UsedBy) != 0 {
			invite.UsedBy = map[string]int64{}
			for _, pair := range inv.UsedBy {
				// These used to be stored formatted instead of as a unix timestamp.
				unix, err := strconv.ParseInt(pair[1], 10, 64)
				if err != nil {
					date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
					if err != nil {
						app.err.Printf("Failed to parse usedBy time: %v", err)
					}
					unix = date.Unix()
				}
				invite.UsedBy[pair[0]] = unix
			}
		}
		invite.RemainingUses = 1
		if inv.RemainingUses != 0 {
			invite.RemainingUses = inv.RemainingUses
		}
		if inv.SendTo != "" {
			invite.SendTo = inv.SendTo
		}
		if len(inv.Notify) != 0 {
			// app.err.Printf("%s has notify section: %+v, you are %s\n", inv.Code, inv.Notify, gc.GetString("jfId"))
			var addressOrID string
			if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
				addressOrID = gc.GetString("jfId")
			} else {
				addressOrID = app.config.Section("ui").Key("email").String()
			}
			if _, ok := inv.Notify[addressOrID]; ok {
				if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok {
					invite.NotifyExpiry = inv.Notify[addressOrID]["notify-expiry"]
				}
				if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
					invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"]
				}
			}
		}
		invites = append(invites, invite)
	}
	fullProfileList := app.storage.GetProfiles()
	profiles := make([]string, len(fullProfileList))
	if len(profiles) != 0 {
		defaultProfile := app.storage.GetDefaultProfile()
		profiles[0] = defaultProfile.Name
		i := 1
		if len(fullProfileList) > 1 {
			app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
				profiles[i] = p.Name
				i++
				return nil
			})
		}
	}
	resp := getInvitesDTO{
		Profiles: profiles,
		Invites:  invites,
	}
	gc.JSON(200, resp)
}

// @Summary Set profile for an invite
// @Produce json
// @Param inviteProfileDTO body inviteProfileDTO true "Invite profile object"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /invites/profile [post]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) SetProfile(gc *gin.Context) {
	var req inviteProfileDTO
	gc.BindJSON(&req)
	app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
	// "" means "Don't apply profile"
	if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
		app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile)
		respond(500, "Profile not found", gc)
		return
	}
	inv, _ := app.storage.GetInvitesKey(req.Invite)
	inv.Profile = req.Profile
	app.storage.SetInvitesKey(req.Invite, inv)
	respondBool(200, true, gc)
}

// @Summary Set notification preferences for an invite.
// @Produce json
// @Param setNotifyDTO body setNotifyDTO true "Map of invite codes to notification settings objects"
// @Success 200
// @Failure 400 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /invites/notify [post]
// @Security Bearer
// @tags Other
func (app *appContext) SetNotify(gc *gin.Context) {
	var req map[string]map[string]bool
	gc.BindJSON(&req)
	changed := false
	for code, settings := range req {
		app.debug.Printf("%s: Notification settings change requested", code)
		invite, ok := app.storage.GetInvitesKey(code)
		if !ok {
			app.err.Printf("%s Notification setting change failed: Invalid code", code)
			respond(400, "Invalid invite code", gc)
			return
		}
		var address string
		jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false)
		if jellyfinLogin {
			var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
			if !addressAvailable {
				app.err.Printf("%s: Couldn't find contact method for admin. Make sure one is set.", code)
				app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
				respond(500, "Missing user contact method", gc)
				return
			}
			address = gc.GetString("jfId")
		} else {
			address = app.config.Section("ui").Key("email").String()
		}
		if invite.Notify == nil {
			invite.Notify = map[string]map[string]bool{}
		}
		if _, ok := invite.Notify[address]; !ok {
			invite.Notify[address] = map[string]bool{}
		} /*else {
		if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
		*/
		if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] {
			invite.Notify[address]["notify-expiry"] = settings["notify-expiry"]
			app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address)
			changed = true
		}
		if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] {
			invite.Notify[address]["notify-creation"] = settings["notify-creation"]
			app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address)
			changed = true
		}
		if changed {
			app.storage.SetInvitesKey(code, invite)
		}
	}
}

// @Summary Delete an invite.
// @Produce json
// @Param deleteInviteDTO body deleteInviteDTO true "Delete invite object"
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Router /invites [delete]
// @Security Bearer
// @tags Invites
func (app *appContext) DeleteInvite(gc *gin.Context) {
	var req deleteInviteDTO
	gc.BindJSON(&req)
	app.debug.Printf("%s: Deletion requested", req.Code)
	var ok bool
	_, ok = app.storage.GetInvitesKey(req.Code)
	if ok {
		app.storage.DeleteInvitesKey(req.Code)
		app.info.Printf("%s: Invite deleted", req.Code)
		respondBool(200, true, gc)
		return
	}
	app.err.Printf("%s: Deletion failed: Invalid code", req.Code)
	respond(400, "Code doesn't exist", gc)
}
