package server

import (
	"context"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"go.uber.org/zap"

	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/gamelimits"
)

// The handlers below cover the game and chat operations the UI needs. They follow
// the same pattern as handlers_user.go: X-User-ID identity, the domain service
// call, a JSON DTO mapped from the result.

// hintResultDTO is the top-ranked move plus the remaining hint budget.
type hintResultDTO struct {
	Move           moveRecordDTO `json:"move"`
	HintsRemaining int           `json:"hints_remaining"`
	WalletBalance  int           `json:"wallet_balance"`
}

// evalResultDTO is an unlimited move preview: legality, score, the words formed
// (main word first) and the orientation the engine inferred ("H"/"V", empty when
// illegal).
type evalResultDTO struct {
	Legal bool     `json:"legal"`
	Score int      `json:"score"`
	Words []string `json:"words"`
	Dir   string   `json:"dir"`
}

// wordCheckDTO is the result of the unlimited dictionary lookup tool.
type wordCheckDTO struct {
	Word  string `json:"word"`
	Legal bool   `json:"legal"`
}

// historyDTO is a game's decoded move journal, the source for client board replay.
type historyDTO struct {
	GameID string          `json:"game_id"`
	Moves  []moveRecordDTO `json:"moves"`
}

// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
// reports whether the caller has reached its tier's active-game cap for the random
// (quick auto-match) kind (the per-tier, per-kind limits in backend.config); while
// it is true the lobby disables "New Game" and shows a notice. It rides the lobby
// response — which the lobby re-fetches on every game event — instead of a separate
// request.
type gameListDTO struct {
	Games       []gameDTO `json:"games"`
	AtGameLimit bool      `json:"at_game_limit"`
}

// chatListDTO is a game's chat history.
type chatListDTO struct {
	Messages []chatDTO `json:"messages"`
}

// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet
// indices; a blank is engine.BlankIndex.
type exchangeRequest struct {
	Tiles []int `json:"tiles"`
}

// complaintRequest disputes a word-check result.
type complaintRequest struct {
	Word string `json:"word"`
	Note string `json:"note"`
}

// fillSeatNames fills each seat's display name from the account store — memoising across
// seats and games within one request — for the seats that carry no per-game snapshot yet
// (a pre-snapshot legacy row). A seat whose snapshot is already set is left untouched, so
// a disguised robot keeps its per-game name rather than reverting to its account name.
func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]string) {
	for i := range g.Seats {
		if g.Seats[i].DisplayName != "" {
			continue // the seat already carries its per-game snapshot
		}
		id := g.Seats[i].AccountID
		name, ok := memo[id]
		if !ok {
			if uid, err := uuid.Parse(id); err == nil {
				if acc, err := s.accounts.GetByID(ctx, uid); err == nil {
					name = acc.DisplayName
				}
			}
			memo[id] = name
		}
		g.Seats[i].DisplayName = name
	}
}

// fillSeatDeleted marks each seat whose account has been deleted, memoising across seats and
// games within one request (the same shape as fillSeatNames) and resolving the seats still
// unknown in a single batch query. A seat with no account (an open game's empty seat) is
// skipped. It is best-effort: on a store error the seats keep the false the projection left,
// which only leaves the social controls as they were before.
func (s *Server) fillSeatDeleted(ctx context.Context, g *gameDTO, memo map[string]bool) {
	var ask []uuid.UUID
	for _, seat := range g.Seats {
		if seat.AccountID == "" {
			continue
		}
		if _, ok := memo[seat.AccountID]; ok {
			continue
		}
		uid, err := uuid.Parse(seat.AccountID)
		if err != nil {
			memo[seat.AccountID] = false
			continue
		}
		memo[seat.AccountID] = false // resolved below; a failed lookup stays false
		ask = append(ask, uid)
	}
	if len(ask) > 0 {
		deleted, err := s.accounts.DeletedIDs(ctx, ask)
		if err != nil {
			s.log.Warn("seat deleted marks failed", zap.Error(err))
		}
		for id := range deleted {
			memo[id.String()] = true
		}
	}
	for i := range g.Seats {
		g.Seats[i].Deleted = memo[g.Seats[i].AccountID]
	}
}

// moveRecordDTOFromHistory projects a journal move into the shared move DTO.
func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO {
	tiles := make([]tileDTO, 0, len(m.Tiles))
	for _, t := range m.Tiles {
		tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
	}
	return moveRecordDTO{
		Player:  m.Seat,
		Action:  m.Action,
		Dir:     m.Dir,
		MainRow: m.MainRow,
		MainCol: m.MainCol,
		Tiles:   tiles,
		Words:   m.Words,
		Count:   len(m.Words),
		Score:   m.Score,
		Total:   m.RunningTotal,
	}
}

// handlePass forfeits the player's turn.
func (s *Server) handlePass(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	res, err := s.games.Pass(c.Request.Context(), gameID, uid)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	s.writeMoveResult(c, res)
}

// handleExchange swaps the chosen rack tiles back into the bag.
func (s *Server) handleExchange(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	var req exchangeRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		abortBadRequest(c, "invalid request body")
		return
	}
	variant, err := s.games.GameVariant(c.Request.Context(), gameID)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	tiles, err := engine.DecodeTiles(variant, req.Tiles)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	res, err := s.games.Exchange(c.Request.Context(), gameID, uid, tiles)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	s.writeMoveResult(c, res)
}

// handleResign resigns the player from the game.
func (s *Server) handleResign(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	res, err := s.games.Resign(c.Request.Context(), gameID, uid)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	s.writeMoveResult(c, res)
}

// handleHint reveals the top-ranked move and spends a hint.
func (s *Server) handleHint(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	h, err := s.games.Hint(c.Request.Context(), gameID, uid)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, hintResultDTO{
		Move:           moveRecordDTOFrom(h.Move),
		HintsRemaining: h.HintsRemaining,
		WalletBalance:  h.WalletBalance,
	})
}

// handleEvaluate previews a tentative play's legality and score.
func (s *Server) handleEvaluate(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	var req submitPlayRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		abortBadRequest(c, "invalid request body")
		return
	}
	variant, err := s.games.GameVariant(c.Request.Context(), gameID)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	tiles, err := tilesFromRequest(variant, req)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, tiles)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words, Dir: ev.Dir})
}

// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as
// repeated ?idx= alphabet indices; the backend decodes them to the concrete
// word for the lookup and echoes that concrete word back for the client's result cache.
func (s *Server) handleCheckWord(c *gin.Context) {
	_, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	idx, err := queryIndexes(c, "idx")
	if err != nil {
		abortBadRequest(c, "invalid word")
		return
	}
	variant, err := s.games.GameVariant(c.Request.Context(), gameID)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	word, err := engine.DecodeWord(variant, idx)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	legal, err := s.games.CheckWord(c.Request.Context(), gameID, word)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal})
}

// queryIndexes parses repeated integer query parameters (e.g. ?idx=2&idx=0) into a slice.
// It carries a word-check query as alphabet indices on a GET.
func queryIndexes(c *gin.Context, key string) ([]int, error) {
	raw := c.QueryArray(key)
	out := make([]int, 0, len(raw))
	for _, s := range raw {
		n, err := strconv.Atoi(s)
		if err != nil {
			return nil, err
		}
		out = append(out, n)
	}
	return out, nil
}

// handleComplaint files a word-check complaint into the admin review queue.
func (s *Server) handleComplaint(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	var req complaintRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		abortBadRequest(c, "invalid request body")
		return
	}
	if _, err := s.games.FileComplaint(c.Request.Context(), gameID, uid, req.Word, req.Note); err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, okResponse{OK: true})
}

// handleHistory returns a game's decoded move journal for board replay / history.
func (s *Server) handleHistory(c *gin.Context) {
	_, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	view, err := s.games.History(c.Request.Context(), gameID)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	moves := make([]moveRecordDTO, 0, len(view.Moves))
	for _, m := range view.Moves {
		moves = append(moves, moveRecordDTOFromHistory(m))
	}
	c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves})
}

// gcgDTO is a game's GCG export: a suggested filename plus the GCG text.
type gcgDTO struct {
	GameID   string `json:"game_id"`
	Filename string `json:"filename"`
	Content  string `json:"content"`
}

// handleExportGCG returns a finished game's GCG transcript for download/share. The
// service refuses an active game (ErrGameActive) to avoid leaking the live journal.
func (s *Server) handleExportGCG(c *gin.Context) {
	_, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, gcgDTO{
		GameID:   gameID.String(),
		Filename: "game-" + gameID.String() + ".gcg",
		Content:  gcg,
	})
}

// draftTileDTO is one tile a player has laid on the board but not yet submitted.
type draftTileDTO struct {
	Row    int    `json:"row"`
	Col    int    `json:"col"`
	Letter string `json:"letter"`
	Blank  bool   `json:"blank"`
}

// draftDTO is a player's persisted client-side composition for a game: the
// preferred rack tile order (an opaque client string) and the board tiles laid but not yet
// submitted. The gateway forwards the JSON verbatim; this layer owns its shape.
type draftDTO struct {
	RackOrder  string         `json:"rack_order"`
	BoardTiles []draftTileDTO `json:"board_tiles"`
}

// draftDTOFrom projects a stored draft into its wire DTO.
func draftDTOFrom(d game.Draft) draftDTO {
	tiles := make([]draftTileDTO, 0, len(d.BoardTiles))
	for _, t := range d.BoardTiles {
		tiles = append(tiles, draftTileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
	}
	return draftDTO{RackOrder: d.RackOrder, BoardTiles: tiles}
}

// toDomain maps an inbound draft DTO to the domain draft.
func (d draftDTO) toDomain() game.Draft {
	tiles := make([]game.DraftTile, 0, len(d.BoardTiles))
	for _, t := range d.BoardTiles {
		tiles = append(tiles, game.DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
	}
	return game.Draft{RackOrder: d.RackOrder, BoardTiles: tiles}
}

// handleGetDraft returns the player's saved composition for a game, or an empty
// draft when none is stored.
func (s *Server) handleGetDraft(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	d, err := s.games.GetDraft(c.Request.Context(), gameID, uid)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, draftDTOFrom(d))
}

// handleSaveDraft upserts the player's composition for a game. The service
// rejects a non-player with ErrNotAPlayer.
func (s *Server) handleSaveDraft(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	var req draftDTO
	if err := c.ShouldBindJSON(&req); err != nil {
		abortBadRequest(c, "invalid request body")
		return
	}
	if err := s.games.SaveDraft(c.Request.Context(), gameID, uid, req.toDomain()); err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, okResponse{OK: true})
}

// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid has reached its
// tier's active-game cap for kind (the per-tier, per-kind limits in backend.config), and reports
// whether the caller may proceed. It guards the quick new-game entry points — auto-match (random)
// and AI (vs_ai). Friend-invitation limits are enforced in the lobby's CreateInvitation, and
// accepting an incoming invitation is deliberately exempt.
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID, kind gamelimits.Kind) bool {
	atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, kind)
	if err != nil {
		s.abortErr(c, err)
		return false
	}
	if atLimit {
		s.abortErr(c, game.ErrGameLimitReached)
		return false
	}
	return true
}

// handleListGames returns the caller's active and finished games for the lobby. It omits
// the honest-AI games the caller left (resigned or abandoned to timeout); see
// game.Service.ListForLobby.
func (s *Server) handleListGames(c *gin.Context) {
	uid, ok := userID(c)
	if !ok {
		abortBadRequest(c, "missing identity")
		return
	}
	games, err := s.games.ListForLobby(c.Request.Context(), uid)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, gamelimits.KindRandom)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	memo := map[string]string{}
	delMemo := map[string]bool{}
	// Two queries seed the lobby's per-card unread badge for every listed game: which games have
	// any unread entry, and which of those have a real message (so the badge can colour
	// message-bearing games apart from nudge-only ones).
	var unread, unreadMsg map[uuid.UUID]bool
	if s.social != nil {
		unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
		unreadMsg, _ = s.social.UnreadMessageGames(c.Request.Context(), uid)
	}
	out := make([]gameDTO, 0, len(games))
	for _, g := range games {
		dto := gameDTOFromGame(g)
		s.fillSeatNames(c.Request.Context(), &dto, memo)
		s.fillSeatDeleted(c.Request.Context(), &dto, delMemo)
		dto.UnreadChat = unread[g.ID]
		dto.UnreadMessages = unreadMsg[g.ID]
		out = append(out, dto)
	}
	c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
}

// handleChatList returns a game's chat history for the viewer.
func (s *Server) handleChatList(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	msgs, err := s.social.Messages(c.Request.Context(), gameID, uid)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	out := make([]chatDTO, 0, len(msgs))
	for _, m := range msgs {
		out = append(out, chatDTOFrom(m))
	}
	c.JSON(http.StatusOK, chatListDTO{Messages: out})
}

// handleNudge posts a nudge to the player whose turn is awaited.
func (s *Server) handleNudge(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	msg, err := s.social.Nudge(c.Request.Context(), gameID, uid)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, chatDTOFrom(msg))
}

// handleChatRead marks every chat entry the caller has not yet read in the game as read
// — the acknowledgement the client sends when the player opens the move history or chat
// — and records each entry's publish-to-read latency. The client calls it only when it
// believes something is unread, so the backend is not hit on every history open.
func (s *Server) handleChatRead(c *gin.Context) {
	uid, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	if _, err := s.social.MarkRead(c.Request.Context(), gameID, uid); err != nil {
		s.abortErr(c, err)
		return
	}
	c.JSON(http.StatusOK, okResponse{OK: true})
}

// userGame reads the authenticated account and the :id game param, aborting with the
// right status when either is missing. ok is false when the request was aborted.
func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
	uid, ok := userID(c)
	if !ok {
		abortBadRequest(c, "missing identity")
		return uuid.UUID{}, uuid.UUID{}, false
	}
	gameID, ok := gameIDParam(c)
	if !ok {
		abortBadRequest(c, "invalid game id")
		return uuid.UUID{}, uuid.UUID{}, false
	}
	return uid, gameID, true
}

// writeMoveResult emits a committed move with seat display names filled in.
func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
	dto, err := moveResultDTOFrom(res)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
	s.fillSeatDeleted(c.Request.Context(), &dto.Game, map[string]bool{})
	if uid, ok := userID(c); ok {
		s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
	}
	c.JSON(http.StatusOK, dto)
}

// setUnreadChat fills the per-game unread-chat flags for viewer uid on one game's DTO, when the
// social domain is present: UnreadChat for any unread entry and UnreadMessages when one of them is
// a real message (so the badge can be coloured). It is best-effort: a lookup error leaves the flag
// false (a missing badge) rather than failing the surrounding game response.
func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
	if s.social == nil {
		return
	}
	gid, err := uuid.Parse(g.ID)
	if err != nil {
		return
	}
	if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil {
		g.UnreadChat = unread
	}
	if msg, err := s.social.HasUnreadMessage(ctx, gid, uid); err == nil {
		g.UnreadMessages = msg
	}
}
