package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	mapset "github.com/deckarep/golang-set/v2"
	"github.com/labstack/echo/v4"
	"github.com/samber/mo"
	"gorm.io/gorm"
	"log"
	"net/http"
	"strings"
	"time"
)

type PlayerNameToIDResponse struct {
	Name string `json:"name"`
	ID   string `json:"id"`
}

type playerNameToIDJob struct {
	LowerName string
	ReturnCh  chan mo.Option[PlayerNameToIDResponse]
}

func (fallbackAPIServer *FallbackAPIServer) PlayerNamesToIDs(remainingLowerNames mapset.Set[string]) []PlayerNameToIDResponse {
	responses := make([]PlayerNameToIDResponse, 0, remainingLowerNames.Cardinality())

	// Use responses from the cache, if available.
	if fallbackAPIServer.PlayerNameToIDCache != nil {
		for _, lowerName := range remainingLowerNames.ToSlice() {
			cachedResponse, found := fallbackAPIServer.PlayerNameToIDCache.Get(lowerName)
			if found {
				remainingLowerNames.Remove(lowerName)
				if response, isPresent := cachedResponse.(mo.Option[PlayerNameToIDResponse]).Get(); isPresent {
					responses = append(responses, response)
				}
			}
		}
	}

	playerNameToIDJobs := make([]playerNameToIDJob, 0, remainingLowerNames.Cardinality())
	for lowerName := range mapset.Elements(remainingLowerNames) {
		playerNameToIDJobs = append(playerNameToIDJobs, playerNameToIDJob{
			LowerName: lowerName,
			ReturnCh:  make(chan mo.Option[PlayerNameToIDResponse], 1),
		})
	}
	fallbackAPIServer.PlayerNameToIDJobCh <- playerNameToIDJobs

	for _, job := range playerNameToIDJobs {
		maybeRes := <-job.ReturnCh
		if res, ok := maybeRes.Get(); ok {
			responses = append(responses, res)
		}
	}
	return responses
}

func (app *App) PlayerNamesToIDsWorker(fallbackAPIServer *FallbackAPIServer) {
	// All communication with the POST /profiles/minecraft (a.k.a. POST
	// /minecraft/profile/lookup/bulk/byname) route on a fallback API server is
	// done by a single goroutine running this function. It buffers a queue of
	// requested (lowercase) player names and makes requests to the fallback
	// API server in batches of MAX_PLAYER_NAMES_TO_IDS, waiting at least
	// MAX_PLAYER_NAMES_TO_IDS_INTERVAL in between requests, in order to avoid
	// rate-limiting.

	url := fallbackAPIServer.Config.AccountURL + "/profiles/minecraft"

	// Queue of player names to fetch that may exceed MAX_PLAYER_NAMES_TO_IDS
	// in size
	lowerNameQueue := make([]*string, 0)

	// Map lowercase player name to a list of return channels where we should
	// send the result of the query for that lowercase player name
	lowerNameToResponseChs := make(map[string][]chan mo.Option[PlayerNameToIDResponse])

	var timeout <-chan time.Time = nil

	for {
		select {
		case jobs := <-fallbackAPIServer.PlayerNameToIDJobCh:
			for _, job := range PtrSlice(jobs) {
				// Double-check the cache
				if fallbackAPIServer.PlayerNameToIDCache != nil {
					cachedResponse, found := fallbackAPIServer.PlayerNameToIDCache.Get(job.LowerName)
					if found {
						job.ReturnCh <- cachedResponse.(mo.Option[PlayerNameToIDResponse])
						continue
					}
				}

				// Double-check for validity, invalid player names will spoil
				// the entire batch. We will assume that if a player name is
				// valid to Drasl, it is valid on all fallback API servers (if
				// this becomes a problem in the future, we may need a
				// FallbackAPIServer.ValidPlayerNameRegex.
				if app.ValidatePlayerName(job.LowerName) != nil {
					job.ReturnCh <- mo.None[PlayerNameToIDResponse]()
					continue
				}

				if _, ok := lowerNameToResponseChs[job.LowerName]; !ok {
					lowerNameQueue = append(lowerNameQueue, &job.LowerName)
				}
				lowerNameToResponseChs[job.LowerName] = append(lowerNameToResponseChs[job.LowerName], job.ReturnCh)
			}
		case <-timeout:
			timeout = nil
		}

		// Wait until we have player names in the queue AND have waited long
		// enough to make another request
		if !(len(lowerNameQueue) > 0 && timeout == nil) {
			continue
		}

		// Dequeue the next batch of MAX_PLAYER_NAMES_TO_IDS lowercase player names
		batchSize := min(len(lowerNameQueue), MAX_PLAYER_NAMES_TO_IDS)
		batch := lowerNameQueue[:batchSize]
		lowerNameQueue = lowerNameQueue[batchSize:]

		fallbackResponses, fallbackError := (func() ([]PlayerNameToIDResponse, error) {
			body, err := json.Marshal(batch)
			if err != nil {
				return nil, err
			}

			res, err := MakeHTTPClient().Post(url, "application/json", bytes.NewBuffer(body))
			if err != nil {
				return nil, err
			}
			defer res.Body.Close()

			if res.StatusCode != http.StatusOK {
				return nil, fmt.Errorf("received status code %d", res.StatusCode)
			}

			buf := new(bytes.Buffer)
			_, err = buf.ReadFrom(res.Body)
			if err != nil {
				return nil, err
			}

			var fallbackResponses []PlayerNameToIDResponse
			err = json.Unmarshal(buf.Bytes(), &fallbackResponses)
			if err != nil {
				return nil, err
			}
			return fallbackResponses, nil
		})()

		timeout = time.After(MAX_PLAYER_NAMES_TO_IDS_INTERVAL)

		lowerNameToResponse := make(map[string]*PlayerNameToIDResponse)
		if fallbackError != nil {
			log.Printf("Error requesting player IDs from fallback API server at %s: %s", url, fallbackError)
		} else {
			for _, fallbackResponse := range PtrSlice(fallbackResponses) {
				lowerName := strings.ToLower(fallbackResponse.Name)
				lowerNameToResponse[lowerName] = fallbackResponse
			}
		}

		for _, lowerName := range batch {
			if fallbackError == nil && fallbackAPIServer.PlayerNameToIDCache != nil {
				ttl := time.Duration(fallbackAPIServer.Config.CacheTTLSeconds) * time.Second
				if res, ok := lowerNameToResponse[*lowerName]; ok {
					fallbackAPIServer.PlayerNameToIDCache.SetWithTTL(*lowerName, mo.Some(*res), 0, ttl)
				} else {
					fallbackAPIServer.PlayerNameToIDCache.SetWithTTL(*lowerName, mo.None[PlayerNameToIDResponse](), 0, ttl)
				}
				fallbackAPIServer.PlayerNameToIDCache.Wait()
			}
			for _, responseCh := range lowerNameToResponseChs[*lowerName] {
				if res, ok := lowerNameToResponse[*lowerName]; ok {
					responseCh <- mo.Some(*res)
				} else {
					responseCh <- mo.None[PlayerNameToIDResponse]()
				}
			}
		}
		clear(lowerNameToResponseChs)
	}
}

// GET /users/profiles/minecraft/:playerName
// GET /minecraft/profile/lookup/name/:playerName
// https://minecraft.wiki/w/Mojang_API#Query_player's_UUID
func AccountPlayerNameToID(app *App) func(c echo.Context) error {
	return func(c echo.Context) error {
		playerName := c.Param("playerName")

		if len(playerName) > Constants.MaxPlayerNameLength {
			// This error message is consistent with GET
			// https://api.mojang.com/users/profiles/minecraft/:playerName as
			// of 2025-04-02
			errorMessage := fmt.Sprintf("getProfileName.name: Invalid profile name, getProfileName.name: size must be between 1 and %d", Constants.MaxPlayerNameLength)
			return &YggdrasilError{
				Code:         http.StatusBadRequest,
				Error_:       mo.Some("CONSTRAINT_VIOLATION"),
				ErrorMessage: mo.Some(errorMessage),
			}
		}

		lowerName := strings.ToLower(playerName)
		if app.ValidatePlayerName(lowerName) != nil {
			// This error message is consistent with POST
			// https://api.mojang.com/users/profiles/minecraft/:playerName as
			// of 2025-04-03
			return &YggdrasilError{
				Code:         http.StatusBadRequest,
				Error_:       mo.Some("CONSTRAINT_VIOLATION"),
				ErrorMessage: mo.Some("getProfileName.name: Invalid profile name"),
			}
		}

		var player Player
		result := app.DB.First(&player, "name = ?", lowerName)
		if result.Error != nil {
			if errors.Is(result.Error, gorm.ErrRecordNotFound) {
				for _, fallbackAPIServer := range app.FallbackAPIServers {
					fallbackResponses := fallbackAPIServer.PlayerNamesToIDs(mapset.NewSet(lowerName))
					if len(fallbackResponses) == 1 && strings.EqualFold(lowerName, fallbackResponses[0].Name) {
						return c.JSON(http.StatusOK, fallbackResponses[0])
					}
				}
				errorMessage := fmt.Sprintf("Couldn't find any profile with name %s", playerName)
				return &YggdrasilError{Code: http.StatusNotFound, ErrorMessage: mo.Some(errorMessage)}
			}
			return result.Error
		}

		id, err := UUIDToID(player.UUID)
		if err != nil {
			return err
		}
		res := PlayerNameToIDResponse{
			Name: player.Name,
			ID:   id,
		}

		return c.JSON(http.StatusOK, res)
	}
}

// POST /profiles/minecraft
// POST /minecraft/profile/lookup/bulk/byname
// https://minecraft.wiki/w/Mojang_API#Query_player_UUIDs_in_batch
func AccountPlayerNamesToIDs(app *App) func(c echo.Context) error {
	return func(c echo.Context) error {
		var playerNames []string
		if err := json.NewDecoder(c.Request().Body).Decode(&playerNames); err != nil {
			return err
		}

		if len(playerNames) == 0 {
			// This error message is consistent with POST
			// https://api.mojang.com/profiles/minecraft as of 2025-04-02
			return &YggdrasilError{
				Code:         http.StatusBadRequest,
				Error_:       mo.Some("CONSTRAINT_VIOLATION"),
				ErrorMessage: mo.Some("getProfileName.profileNames: must not be empty"),
			}
		}
		if len(playerNames) > MAX_PLAYER_NAMES_TO_IDS {
			// This error message is consistent with POST
			// https://api.mojang.com/profiles/minecraft as of 2025-04-02
			errorMessage := fmt.Sprintf("getProfileName.profileNames: size must be between 0 and %d", MAX_PLAYER_NAMES_TO_IDS)
			return &YggdrasilError{
				Code:         http.StatusBadRequest,
				Error_:       mo.Some("CONSTRAINT_VIOLATION"),
				ErrorMessage: mo.Some(errorMessage),
			}
		}

		response := make([]PlayerNameToIDResponse, 0, len(playerNames))

		remainingLowerNames := mapset.NewSet[string]()
		for i, playerName := range playerNames {
			if !(1 <= len(playerName) && len(playerName) <= Constants.MaxPlayerNameLength) {
				// This error message is consistent with POST
				// https://api.mojang.com/profiles/minecraft as of 2025-04-02
				errorMessage := fmt.Sprintf("getProfileName.profileNames[%d].<list element>: size must be between 1 and %d, getProfileName.profileNames[%d].<list element>: Invalid profile name", i, Constants.MaxPlayerNameLength, 1)
				return &YggdrasilError{
					Code:         http.StatusBadRequest,
					Error_:       mo.Some("CONSTRAINT_VIOLATION"),
					ErrorMessage: mo.Some(errorMessage),
				}
			}

			lowerName := strings.ToLower(playerName)
			if app.ValidatePlayerName(lowerName) != nil {
				// This error message is consistent with POST
				// https://api.mojang.com/profiles/minecraft as of 2025-04-03
				errorMessage := fmt.Sprintf("getProfileName.profileNames[%d].<list element>: Invalid profile name", i)
				return &YggdrasilError{
					Code:         http.StatusBadRequest,
					Error_:       mo.Some("CONSTRAINT_VIOLATION"),
					ErrorMessage: mo.Some(errorMessage),
				}
			}

			remainingLowerNames.Add(lowerName)
		}

		for _, lowerName := range remainingLowerNames.ToSlice() {
			var player Player
			result := app.DB.First(&player, "name = ?", lowerName)
			if result.Error != nil {
				if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
					return result.Error
				}
			} else {
				id, err := UUIDToID(player.UUID)
				if err != nil {
					return err
				}
				playerRes := PlayerNameToIDResponse{
					Name: player.Name,
					ID:   id,
				}
				response = append(response, playerRes)
				remainingLowerNames.Remove(lowerName)
			}
		}

		for _, fallbackAPIServer := range app.FallbackAPIServers {
			if remainingLowerNames.Cardinality() == 0 {
				break
			}

			fallbackResponses := fallbackAPIServer.PlayerNamesToIDs(remainingLowerNames)
			for _, fallbackResponse := range fallbackResponses {
				lowerName := strings.ToLower(fallbackResponse.Name)
				if remainingLowerNames.Contains(lowerName) {
					response = append(response, fallbackResponse)
					remainingLowerNames.Remove(lowerName)
				}
			}
		}

		return c.JSON(http.StatusOK, response)
	}
}

// GET /user/security/location
func AccountVerifySecurityLocation(app *App) func(c echo.Context) error {
	return func(c echo.Context) error {
		return c.NoContent(http.StatusNoContent)
	}
}
