package main

import (
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt"
	"github.com/timshannon/badgerhold/v4"
)

const (
	REFERRAL_EXPIRY_DAYS = 90
)

// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details.
// @Produce json
// @Success 200 {object} MyDetailsDTO
// @Router /my/details [get]
// @tags User Page
func (app *appContext) MyDetails(gc *gin.Context) {
	resp := MyDetailsDTO{
		Id: gc.GetString("jfId"),
	}

	user, status, err := app.jf.UserByID(resp.Id, false)
	if status != 200 || err != nil {
		app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err)
		respond(500, "Failed to get user", gc)
		return
	}
	resp.Username = user.Name
	resp.Admin = user.Policy.IsAdministrator
	resp.AccountsAdmin = false
	if !app.config.Section("ui").Key("allow_all").MustBool(false) {
		adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
		if emailStore, ok := app.storage.GetEmailsKey(resp.Id); ok {
			resp.AccountsAdmin = emailStore.Admin
		}
		resp.AccountsAdmin = resp.AccountsAdmin || (adminOnly && resp.Admin)
	}
	resp.Disabled = user.Policy.IsDisabled

	if exp, ok := app.storage.GetUserExpiryKey(user.ID); ok {
		resp.Expiry = exp.Expiry.Unix()
	}

	if emailEnabled {
		resp.Email = &MyDetailsContactMethodsDTO{}
		if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" {
			resp.Email.Value = email.Addr
			resp.Email.Enabled = email.Contact
		}
	}

	if discordEnabled {
		resp.Discord = &MyDetailsContactMethodsDTO{}
		if discord, ok := app.storage.GetDiscordKey(user.ID); ok {
			resp.Discord.Value = RenderDiscordUsername(discord)
			resp.Discord.Enabled = discord.Contact
		}
	}

	if telegramEnabled {
		resp.Telegram = &MyDetailsContactMethodsDTO{}
		if telegram, ok := app.storage.GetTelegramKey(user.ID); ok {
			resp.Telegram.Value = telegram.Username
			resp.Telegram.Enabled = telegram.Contact
		}
	}

	if matrixEnabled {
		resp.Matrix = &MyDetailsContactMethodsDTO{}
		if matrix, ok := app.storage.GetMatrixKey(user.ID); ok {
			resp.Matrix.Value = matrix.UserID
			resp.Matrix.Enabled = matrix.Contact
		}
	}

	if app.config.Section("user_page").Key("referrals").MustBool(false) {
		// 1. Look for existing template bound to this Jellyfin ID
		//    If one exists, that means its just for us and so we
		//    can use it directly.
		inv := Invite{}
		err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(resp.Id))
		if err == nil {
			resp.HasReferrals = true
		} else {
			// 2. Look for a template matching the key found in the user storage
			//    Since this key is shared between users in a profile, we make a copy.
			user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
			err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
			if ok && err == nil {
				resp.HasReferrals = true
			}
		}
	}

	gc.JSON(200, resp)
}

// @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not.
// @Produce json
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
// @Router /my/contact [post]
// @Security Bearer
// @tags User Page
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
	var req SetContactMethodsDTO
	gc.BindJSON(&req)
	req.ID = gc.GetString("jfId")
	if req.ID == "" {
		respondBool(400, false, gc)
		return
	}
	app.setContactMethods(req, gc)
}

// @Summary Logout by deleting refresh token from cookies.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /my/logout [post]
// @Security Bearer
// @tags User Page
func (app *appContext) LogoutUser(gc *gin.Context) {
	cookie, err := gc.Cookie("user-refresh")
	if err != nil {
		app.debug.Printf("Couldn't get cookies: %s", err)
		respond(500, "Couldn't fetch cookies", gc)
		return
	}
	app.invalidTokens = append(app.invalidTokens, cookie)
	gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
	respondBool(200, true, gc)
}

// @Summary confirm an action (e.g. changing an email address.)
// @Produce json
// @Param jwt path string true "jwt confirmation code"
// @Router /my/confirm/{jwt} [post]
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 404
// @Success 303
// @Failure 500 {object} stringResponse
// @tags User Page
func (app *appContext) ConfirmMyAction(gc *gin.Context) {
	app.confirmMyAction(gc, "")
}

func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
	var claims jwt.MapClaims
	var target ConfirmationTarget
	var id string
	fail := func() {
		gcHTML(gc, 404, "404.html", gin.H{
			"cssClass":       app.cssClass,
			"cssVersion":     cssVersion,
			"contactMessage": app.config.Section("ui").Key("contact_message").String(),
		})
	}

	// Validate key
	if key == "" {
		key = gc.Param("jwt")
	}
	token, err := jwt.Parse(key, checkToken)
	if err != nil {
		app.err.Printf("Failed to parse key: %s", err)
		fail()
		// respond(500, "unknownError", gc)
		return
	}
	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		app.err.Printf("Failed to parse key: %s", err)
		fail()
		// respond(500, "unknownError", gc)
		return
	}
	expiry := time.Unix(int64(claims["exp"].(float64)), 0)
	if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
		app.err.Printf("Invalid key")
		fail()
		// respond(400, "invalidKey", gc)
		return
	}
	target = ConfirmationTarget(int(claims["target"].(float64)))
	id = claims["id"].(string)

	// Perform an Action
	if target == NoOp {
		gc.Redirect(http.StatusSeeOther, "/my/account")
		return
	} else if target == UserEmailChange {
		emailStore, ok := app.storage.GetEmailsKey(id)
		if !ok {
			emailStore = EmailAddress{
				Contact: true,
			}
		}
		emailStore.Addr = claims["email"].(string)
		app.storage.SetEmailsKey(id, emailStore)
		if app.config.Section("ombi").Key("enabled").MustBool(false) {
			ombiUser, code, err := app.getOmbiUser(id)
			if code == 200 && err == nil {
				ombiUser["emailAddress"] = claims["email"].(string)
				code, err = app.ombi.ModifyUser(ombiUser)
				if code != 200 || err != nil {
					app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
				}
			}
		}

		app.info.Println("Email list modified")
		gc.Redirect(http.StatusSeeOther, "/my/account")
		return
	}
}

// @Summary Modify your email address.
// @Produce json
// @Param ModifyMyEmailDTO body ModifyMyEmailDTO true "New email address."
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 401 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /my/email [post]
// @Security Bearer
// @tags User Page
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
	var req ModifyMyEmailDTO
	gc.BindJSON(&req)
	app.debug.Println("Email modification requested")
	if !strings.ContainsRune(req.Email, '@') {
		respond(400, "Invalid Email Address", gc)
		return
	}
	id := gc.GetString("jfId")

	// We'll use the ConfirmMyAction route to do the work, even if we don't need to confirm the address.
	claims := jwt.MapClaims{
		"valid":  true,
		"id":     id,
		"email":  req.Email,
		"type":   "confirmation",
		"target": UserEmailChange,
		"exp":    time.Now().Add(time.Hour).Unix(),
	}
	tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))

	if err != nil {
		app.err.Printf("Failed to generate confirmation token: %v", err)
		respond(500, "errorUnknown", gc)
		return
	}

	if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
		user, status, err := app.jf.UserByID(id, false)
		name := ""
		if status == 200 && err == nil {
			name = user.Name
		}
		app.debug.Printf("%s: Email confirmation required", id)
		respond(401, "confirmEmail", gc)
		msg, err := app.email.constructConfirmation("", name, key, app, false)
		if err != nil {
			app.err.Printf("%s: Failed to construct confirmation email: %v", name, err)
		} else if err := app.email.send(msg, req.Email); err != nil {
			app.err.Printf("%s: Failed to send user confirmation email: %v", name, err)
		} else {
			app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email)
		}
		return
	}

	app.confirmMyAction(gc, key)
	return
}

// @Summary Returns a 10-minute, one-use Discord server invite
// @Produce json
// @Success 200 {object} DiscordInviteDTO
// @Failure 400 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param invCode path string true "invite Code"
// @Router /my/discord/invite [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
	if app.discord.inviteChannelName == "" {
		respondBool(400, false, gc)
		return
	}
	invURL, iconURL := app.discord.NewTempInvite(10*60, 1)
	if invURL == "" {
		respondBool(500, false, gc)
		return
	}
	gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
}

// @Summary Returns a linking PIN for discord/telegram
// @Produce json
// @Success 200 {object} GetMyPINDTO
// @Failure 400 {object} stringResponse
// Param service path string true "discord/telegram"
// @Router /my/pin/{service} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) GetMyPIN(gc *gin.Context) {
	service := gc.Param("service")
	resp := GetMyPINDTO{}
	switch service {
	case "discord":
		resp.PIN = app.discord.NewAssignedAuthToken(gc.GetString("jfId"))
		break
	case "telegram":
		resp.PIN = app.telegram.NewAssignedAuthToken(gc.GetString("jfId"))
		break
	default:
		respond(400, "invalid service", gc)
		return
	}
	gc.JSON(200, resp)
}

// @Summary Returns true/false on whether or not your discord PIN was verified, and assigns the discord user to you.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Router /my/discord/verified/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
	pin := gc.Param("pin")
	dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
	app.discord.DeleteVerifiedUser(pin)
	if !ok {
		respondBool(200, false, gc)
		return
	}
	if app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(dcUser.ID) {
		respondBool(400, false, gc)
		return
	}
	existingUser, ok := app.storage.GetDiscordKey(gc.GetString("jfId"))
	if ok {
		dcUser.Lang = existingUser.Lang
		dcUser.Contact = existingUser.Contact
	}
	app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
	respondBool(200, true, gc)
}

// @Summary Returns true/false on whether or not your telegram PIN was verified, and assigns the telegram user to you.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Router /my/telegram/verified/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
	pin := gc.Param("pin")
	token, ok := app.telegram.AssignedTokenVerified(pin, gc.GetString("jfId"))
	app.telegram.DeleteVerifiedToken(pin)
	if !ok {
		respondBool(200, false, gc)
		return
	}
	if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
		respondBool(400, false, gc)
		return
	}
	tgUser := TelegramUser{
		ChatID:   token.ChatID,
		Username: token.Username,
		Contact:  true,
	}
	if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
		tgUser.Lang = lang
	}

	existingUser, ok := app.storage.GetTelegramKey(gc.GetString("jfId"))
	if ok {
		tgUser.Lang = existingUser.Lang
		tgUser.Contact = existingUser.Contact
	}
	app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
	respondBool(200, true, gc)
}

// @Summary Generate and send a new PIN to your given matrix user.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID."
// @Router /my/matrix/user [post]
// @Security Bearer
// @tags User Page
func (app *appContext) MatrixSendMyPIN(gc *gin.Context) {
	var req MatrixSendPINDTO
	gc.BindJSON(&req)
	if req.UserID == "" {
		respond(400, "errorNoUserID", gc)
		return
	}
	if app.config.Section("matrix").Key("require_unique").MustBool(false) {
		for _, u := range app.storage.GetMatrix() {
			if req.UserID == u.UserID {
				respondBool(400, false, gc)
				return
			}
		}
	}

	ok := app.matrix.SendStart(req.UserID)
	if !ok {
		respondBool(500, false, gc)
		return
	}
	respondBool(200, true, gc)
}

// @Summary Check whether your matrix PIN is valid, and link the account to yours if so.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Param userID path string true "Matrix User ID"
// @Router /my/matrix/verified/{userID}/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
	userID := gc.Param("userID")
	pin := gc.Param("pin")
	user, ok := app.matrix.tokens[pin]
	if !ok {
		app.debug.Println("Matrix: PIN not found")
		respondBool(200, false, gc)
		return
	}
	if user.User.UserID != userID {
		app.debug.Println("Matrix: User ID of PIN didn't match")
		respondBool(200, false, gc)
		return
	}

	mxUser := *user.User
	mxUser.Contact = true
	existingUser, ok := app.storage.GetMatrixKey(gc.GetString("jfId"))
	if ok {
		mxUser.Lang = existingUser.Lang
		mxUser.Contact = existingUser.Contact
	}

	app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
	delete(app.matrix.tokens, pin)
	respondBool(200, true, gc)
}

// @Summary unlink the Discord account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/discord [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
	app.storage.DeleteDiscordKey(gc.GetString("jfId"))
	respondBool(200, true, gc)
}

// @Summary unlink the Telegram account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/telegram [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
	app.storage.DeleteTelegramKey(gc.GetString("jfId"))
	respondBool(200, true, gc)
}

// @Summary unlink the Matrix account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/matrix [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
	app.storage.DeleteMatrixKey(gc.GetString("jfId"))
	respondBool(200, true, gc)
}

// @Summary Generate & send a password reset link if the given username/email/contact method exists. Doesn't give you any info about it's success.
// @Produce json
// @Param address path string true "address/contact method associated w/ your account."
// @Success 204 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/password/reset/{address} [post]
// @Tags User Page
func (app *appContext) ResetMyPassword(gc *gin.Context) {
	// All requests should take 1 second, to make it harder to tell if a success occured or not.
	timerWait := make(chan bool)
	cancel := time.AfterFunc(1*time.Second, func() {
		timerWait <- true
	})
	address := gc.Param("address")
	if address == "" {
		app.debug.Println("Ignoring empty request for PWR")
		cancel.Stop()
		respondBool(400, false, gc)
		return
	}
	var pwr InternalPWR
	var err error

	jfUser, ok := app.ReverseUserSearch(address)
	if !ok {
		app.debug.Printf("Ignoring PWR request: User not found")

		for range timerWait {
			respondBool(204, true, gc)
			return
		}
		return
	}
	pwr, err = app.GenInternalReset(jfUser.ID)
	if err != nil {
		app.err.Printf("Failed to get user from Jellyfin: %v", err)
		for range timerWait {
			respondBool(204, true, gc)
			return
		}
		return
	}
	if app.internalPWRs == nil {
		app.internalPWRs = map[string]InternalPWR{}
	}
	app.internalPWRs[pwr.PIN] = pwr
	// FIXME: Send to all contact methods
	msg, err := app.email.constructReset(
		PasswordReset{
			Pin:      pwr.PIN,
			Username: pwr.Username,
			Expiry:   pwr.Expiry,
			Internal: true,
		}, app, false,
	)
	if err != nil {
		app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err)
		for range timerWait {
			respondBool(204, true, gc)
			return
		}
		return
	} else if err := app.sendByID(msg, jfUser.ID); err != nil {
		app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err)
	} else {
		app.info.Printf("Sent password reset message to \"%s\"", address)
	}
	for range timerWait {
		respondBool(204, true, gc)
		return
	}
}

// @Summary Change your password, given the old one and the new one.
// @Produce json
// @Param ChangeMyPasswordDTO body ChangeMyPasswordDTO true "User's old & new passwords."
// @Success 204 {object} boolResponse
// @Failure 400 {object} PasswordValidation
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/password [post]
// @Security Bearer
// @Tags User Page
func (app *appContext) ChangeMyPassword(gc *gin.Context) {
	var req ChangeMyPasswordDTO
	gc.BindJSON(&req)
	if req.Old == "" || req.New == "" {
		respondBool(400, false, gc)
	}
	validation := app.validator.validate(req.New)
	for _, val := range validation {
		if !val {
			app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
			gc.JSON(400, validation)
			return
		}
	}
	user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
	if status != 200 || err != nil {
		app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err)
		respondBool(500, false, gc)
		return
	}
	// Authenticate as user to confirm old password.
	user, status, err = app.authJf.Authenticate(user.Name, req.Old)
	if status != 200 || err != nil {
		respondBool(401, false, gc)
		return
	}
	status, err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
	if (status != 200 && status != 204) || err != nil {
		respondBool(500, false, gc)
		return
	}
	if app.config.Section("ombi").Key("enabled").MustBool(false) {
		func() {
			ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
			if status != 200 || err != nil {
				app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err)
				return
			}
			ombiUser["password"] = req.New
			status, err = app.ombi.ModifyUser(ombiUser)
			if status != 200 || err != nil {
				app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
				return
			}
			app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
		}()
	}
	cookie, err := gc.Cookie("user-refresh")
	if err == nil {
		app.invalidTokens = append(app.invalidTokens, cookie)
		gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
	} else {
		app.debug.Printf("Couldn't get cookies: %s", err)
	}
	respondBool(204, true, gc)
}

// @Summary Get or generate a new referral code.
// @Produce json
// @Success 200 {object} GetMyReferralRespDTO
// @Failure 400 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/referral [get]
// @Security Bearer
// @Tags User Page
func (app *appContext) GetMyReferral(gc *gin.Context) {
	// 1. Look for existing template bound to this Jellyfin ID
	//    If one exists, that means its just for us and so we
	//    can use it directly.
	inv := Invite{}
	err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(gc.GetString("jfId")))
	if err != nil {
		// 2. Look for a template matching the key found in the user storage
		//    Since this key is shared between users in a profile, we make a copy.
		user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
		err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
		if !ok || err != nil {
			app.debug.Printf("Ignoring referral request, couldn't find template.")
			respondBool(400, false, gc)
			return
		}
		inv.Code = GenerateInviteCode()
		inv.Created = time.Now()
		inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
		inv.IsReferral = true
		inv.ReferrerJellyfinID = gc.GetString("jfId")
		app.storage.SetInvitesKey(inv.Code, inv)
	} else if time.Now().After(inv.ValidTill) {
		// 3. We found an invite for us, but it's expired.
		//    We delete it from storage, and put it back with a fresh code and expiry.
		app.storage.DeleteInvitesKey(inv.Code)
		inv.Code = GenerateInviteCode()
		inv.Created = time.Now()
		inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
		app.storage.SetInvitesKey(inv.Code, inv)
	}
	gc.JSON(200, GetMyReferralRespDTO{
		Code:          inv.Code,
		RemainingUses: inv.RemainingUses,
		NoLimit:       inv.NoLimit,
		Expiry:        inv.ValidTill.Unix(),
	})
}
