// Finished-game export downloads: a signed, short-lived, relative URL minted for a
// participant and later fetched with no credentials at all — the platforms' native
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain browser
// anchor) strip cookies and headers, so the HMAC in the URL is the whole grant.
// The client resolves the relative path against its own origin; the edge routes
// /dl/* to the gateway, which forwards it here (/api/v1/public/dl/...).
package server

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

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

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

// exportURLTTL is how long a minted download URL stays valid. Long enough for the
// platform's download dialog and a retry, short enough that a leaked link dies fast.
const exportURLTTL = 10 * time.Minute

// Query/label size caps: the presentation params ride the signed URL, so they are
// bounded defensively (they only ever hold a BCP-47 tag and four short UI words).
const (
	maxDateLocaleLen = 35
	maxLabelsLen     = 120
	maxTimeZoneLen   = 50
)

// exportActionOrder fixes the position of each non-play move label in the `t` CSV.
var exportActionOrder = [4]string{"pass", "exchange", "resign", "timeout"}

// signExport computes the URL signature over the canonical parameter string. The
// canonical form is independent of the public path prefix, so the gateway may mount
// the route anywhere.
func (s *Server) signExport(gameID, kind, exp, dateLocale, timeZone, labels string) string {
	mac := hmac.New(sha256.New, s.exportKey)
	fmt.Fprintf(mac, "%s|%s|%s|%s|%s|%s", gameID, kind, exp, dateLocale, timeZone, labels)
	return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

// exportURLDTO is the minted link: a relative signed path plus the download filename.
type exportURLDTO struct {
	Path     string `json:"path"`
	Filename string `json:"filename"`
}

// handleExportURL mints the signed download path for a finished game's artifact.
// GET /api/v1/user/games/:id/export-url?kind=png|gcg&dl=<date-locale>&tz=<IANA zone>&t=<labels CSV>
func (s *Server) handleExportURL(c *gin.Context) {
	if len(s.exportKey) == 0 {
		c.AbortWithStatusJSON(http.StatusServiceUnavailable,
			errorResponse{Error: errorBody{Code: "export_unavailable", Message: "export signing is not configured"}})
		return
	}
	_, gameID, ok := s.userGame(c)
	if !ok {
		return
	}
	kind := c.Query("kind")
	if kind != "png" && kind != "gcg" {
		abortBadRequest(c, "invalid kind")
		return
	}
	dateLocale := c.Query("dl")
	timeZone := c.Query("tz")
	labels := c.Query("t")
	if len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
		abortBadRequest(c, "presentation params too long")
		return
	}
	if err := s.games.EnsureExportable(c.Request.Context(), gameID); err != nil {
		s.abortErr(c, err)
		return
	}
	exp := strconv.FormatInt(time.Now().Add(exportURLTTL).Unix(), 10)
	sig := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
	q := url.Values{"e": {exp}, "s": {sig}}
	if dateLocale != "" {
		q.Set("l", dateLocale)
	}
	if timeZone != "" {
		q.Set("z", timeZone)
	}
	if labels != "" {
		q.Set("t", labels)
	}
	c.JSON(http.StatusOK, exportURLDTO{
		Path:     "/dl/" + gameID.String() + "/" + kind + "?" + q.Encode(),
		Filename: exportFilename(gameID, kind),
	})
}

func exportFilename(gameID uuid.UUID, kind string) string {
	return "game-" + gameID.String()[:8] + "." + kind
}

// handleExportDownload serves a minted artifact. It is a public route: the signature
// and expiry are the only authorization (see the package comment). Every failure is
// a plain 404 so the endpoint is not a validity oracle.
// GET /api/v1/public/dl/:id/:kind?e=&l=&z=&t=&s=
func (s *Server) handleExportDownload(c *gin.Context) {
	if len(s.exportKey) == 0 {
		c.String(http.StatusNotFound, "not found")
		return
	}
	gameID, err := uuid.Parse(c.Param("id"))
	kind := c.Param("kind")
	exp := c.Query("e")
	dateLocale := c.Query("l")
	timeZone := c.Query("z")
	labels := c.Query("t")
	sig := c.Query("s")
	if err != nil || (kind != "png" && kind != "gcg") ||
		len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
		c.String(http.StatusNotFound, "not found")
		return
	}
	want := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
	if subtleNeq(sig, want) {
		c.String(http.StatusNotFound, "not found")
		return
	}
	if unix, err := strconv.ParseInt(exp, 10, 64); err != nil || time.Now().Unix() > unix {
		c.String(http.StatusNotFound, "not found")
		return
	}

	filename := exportFilename(gameID, kind)
	if kind == "gcg" {
		gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
		if err != nil {
			c.String(http.StatusNotFound, "not found")
			return
		}
		serveAttachment(c, filename, "text/plain; charset=utf-8", []byte(gcg))
		return
	}

	if s.renderer == nil {
		c.String(http.StatusNotFound, "not found")
		return
	}
	g, moves, names, err := s.games.ExportView(c.Request.Context(), gameID)
	if err != nil {
		c.String(http.StatusNotFound, "not found")
		return
	}
	payload, err := renderPayload(g, moves, names, dateLocale, timeZone, labels, c.GetHeader("X-Public-Host"))
	if err != nil {
		s.log.Warn("export render payload", zap.Error(err))
		c.String(http.StatusNotFound, "not found")
		return
	}
	png, err := s.renderer.Render(c.Request.Context(), payload)
	if err != nil {
		s.log.Warn("export render", zap.Error(err))
		c.String(http.StatusServiceUnavailable, "render unavailable")
		return
	}
	serveAttachment(c, filename, "image/png", png)
}

// subtleNeq compares two signature strings in constant time.
func subtleNeq(got, want string) bool {
	return !hmac.Equal([]byte(got), []byte(want))
}

// serveAttachment writes bytes as a named file download. Content-Length is set
// explicitly: a body over net/http's write buffer otherwise goes out chunked, and
// Android's system DownloadManager (which VKWebAppDownloadFile delegates to) hangs
// forever on a download of unknown length.
func serveAttachment(c *gin.Context, filename, contentType string, data []byte) {
	c.Header("X-Content-Type-Options", "nosniff")
	c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(filename)+`"`)
	c.Header("Cache-Control", "private, max-age=0")
	c.Header("Content-Length", strconv.Itoa(len(data)))
	c.Data(http.StatusOK, contentType, data)
}

// --- the render-sidecar request payload (the ui-model shapes drawGameImage consumes) ---

type renderSeatJSON struct {
	Seat        int    `json:"seat"`
	AccountID   string `json:"accountId"`
	DisplayName string `json:"displayName"`
	Score       int    `json:"score"`
	HintsUsed   int    `json:"hintsUsed"`
	IsWinner    bool   `json:"isWinner"`
}

type renderGameJSON struct {
	ID               string           `json:"id"`
	Variant          string           `json:"variant"`
	Status           string           `json:"status"`
	Players          int              `json:"players"`
	LastActivityUnix int64            `json:"lastActivityUnix"`
	Seats            []renderSeatJSON `json:"seats"`
}

type renderTileJSON struct {
	Row    int    `json:"row"`
	Col    int    `json:"col"`
	Letter string `json:"letter"`
	Blank  bool   `json:"blank"`
}

type renderMoveJSON struct {
	Player  int              `json:"player"`
	Action  string           `json:"action"`
	Dir     string           `json:"dir"`
	MainRow int              `json:"mainRow"`
	MainCol int              `json:"mainCol"`
	Tiles   []renderTileJSON `json:"tiles"`
	Words   []string         `json:"words"`
	Count   int              `json:"count"`
	Score   int              `json:"score"`
	Total   int              `json:"total"`
}

type renderAlphaJSON struct {
	Index  int    `json:"index"`
	Letter string `json:"letter"`
	Value  int    `json:"value"`
}

type renderRequestJSON struct {
	Game       renderGameJSON    `json:"game"`
	Moves      []renderMoveJSON  `json:"moves"`
	Alphabet   []renderAlphaJSON `json:"alphabet"`
	Labels     map[string]string `json:"labels"`
	DateLocale string            `json:"dateLocale"`
	TimeZone   string            `json:"timeZone"`
	Hostname   string            `json:"hostname"`
}

// renderPayload assembles the sidecar request from the export view. Letters are
// upper-cased for display exactly as the client codec does; the localized non-play
// labels arrive as a positional CSV (see exportActionOrder) minted into the URL.
func renderPayload(g game.Game, moves []game.HistoryMove, names []string, dateLocale, timeZone, labelsCSV, host string) (renderRequestJSON, error) {
	alpha, err := engine.AlphabetTable(g.Variant)
	if err != nil {
		return renderRequestJSON{}, fmt.Errorf("alphabet for %s: %w", g.Variant, err)
	}
	req := renderRequestJSON{
		Game: renderGameJSON{
			ID:      g.ID.String(),
			Variant: g.Variant.String(),
			Status:  g.Status,
			Players: g.Players,
		},
		Labels:     map[string]string{},
		DateLocale: dateLocale,
		TimeZone:   timeZone,
		Hostname:   host,
	}
	finished := g.UpdatedAt
	if g.FinishedAt != nil {
		finished = *g.FinishedAt
	}
	req.Game.LastActivityUnix = finished.Unix()
	for _, st := range g.Seats {
		name := st.DisplayName
		if st.Seat < len(names) {
			name = names[st.Seat]
		}
		req.Game.Seats = append(req.Game.Seats, renderSeatJSON{
			Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: name,
			Score: st.Score, HintsUsed: st.HintsUsed, IsWinner: st.IsWinner,
		})
	}
	for _, m := range moves {
		mv := renderMoveJSON{
			Player: m.Seat, Action: m.Action, Dir: m.Dir,
			MainRow: m.MainRow, MainCol: m.MainCol,
			Words: make([]string, 0, len(m.Words)),
			Count: len(m.Exchanged), Score: m.Score, Total: m.RunningTotal,
			Tiles: make([]renderTileJSON, 0, len(m.Tiles)),
		}
		for _, w := range m.Words {
			mv.Words = append(mv.Words, strings.ToUpper(w))
		}
		for _, t := range m.Tiles {
			mv.Tiles = append(mv.Tiles, renderTileJSON{Row: t.Row, Col: t.Col, Letter: strings.ToUpper(t.Letter), Blank: t.Blank})
		}
		req.Moves = append(req.Moves, mv)
	}
	for _, a := range alpha {
		req.Alphabet = append(req.Alphabet, renderAlphaJSON{Index: int(a.Index), Letter: strings.ToUpper(a.Letter), Value: a.Value})
	}
	if labelsCSV != "" {
		parts := strings.Split(labelsCSV, ",")
		for i, action := range exportActionOrder {
			if i < len(parts) && parts[i] != "" {
				req.Labels[action] = parts[i]
			}
		}
	}
	return req, nil
}
