package server

import (
	"errors"
	"net/http"
	"strings"

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

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/accountmerge"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/feedback"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/lobby"
	"scrabble/backend/internal/payments"
	"scrabble/backend/internal/session"
	"scrabble/backend/internal/social"
)

// registerRoutes wires the REST handlers onto the /api/v1 groups. The
// internal group is gateway-only (the gateway authenticates and forwards); the
// user group requires X-User-ID; the admin group is reached through the gateway's
// Basic-Auth proxy.
func (s *Server) registerRoutes() {
	if s.sessions != nil && s.accounts != nil {
		in := s.internal
		in.POST("/sessions/telegram", s.handleTelegramAuth)
		in.POST("/sessions/vk", s.handleVKAuth)
		in.POST("/sessions/guest", s.handleGuestAuth)
		in.POST("/sessions/email/request", s.handleEmailRequest)
		in.POST("/sessions/email/login", s.handleEmailLogin)
		in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
		in.POST("/sessions/resolve", s.handleResolveSession)
		in.POST("/sessions/revoke", s.handleRevokeSession)
		// Out-of-app push routing for the platform side-service: the
		// gateway resolves a recipient's Telegram chat + language + in-app-only flag
		// before delivering an out-of-app notification.
		in.POST("/push-target", s.handlePushTarget)
	}
	if s.accounts != nil {
		// Moderated-chat write eligibility for the Telegram bot: resolve a Telegram
		// identity (the bot's join-time query) or an account id (a chat-access-changed
		// event) to whether the user may write in the discussion chat. It needs only the
		// account store, not the session service, so it registers independently.
		s.internal.POST("/chat-access", s.handleChatAccess)
	}
	if s.ratewatch != nil {
		// The gateway's periodic rate-limiter rejection summary: feeds the
		// admin console's throttled view and the high-rate auto-flag.
		s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
	}
	if s.banview != nil {
		// The gateway's periodic active-ban sync: feeds the admin console's
		// active-bans panel and returns the operator's pending unbans.
		s.internal.POST("/bans/sync", s.handleBanSync)
	}
	u := s.user
	if s.accounts != nil {
		u.GET("/profile", s.handleProfile)
		u.PUT("/profile", s.handleUpdateProfile)
		u.GET("/stats", s.handleStats)
		// Exempt from the suspension gate (see requireNotSuspended): the one endpoint a blocked
		// client may still reach, to fetch the block's expiry and reason for the blocked screen.
		u.GET("/block-status", s.handleBlockStatus)
	}
	if s.payments != nil {
		// The wallet: the context-visible chip segments + benefits, the storefront catalog, and a
		// chip spend on a value.
		u.GET("/wallet", s.handleWallet)
		u.GET("/wallet/catalog", s.handleWalletCatalog)
		u.POST("/wallet/buy", s.handleWalletBuy)
		// A rewarded-video credit (VK ads): client-attested + a config daily cap.
		u.POST("/wallet/reward", s.handleWalletReward)
		// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
		// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
		s.internal.GET("/offer/pricing", s.handleOfferPricing)
	}
	if s.payments != nil {
		// The money order endpoint dispatches by rail (direct → YooKassa, vk → VK); an
		// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
		// gateway-only (the single writer): the VK payment callback (both phases handled here), the
		// YooKassa notification, and the retired Robokassa Result callback if a merchant is ever
		// configured again.
		u.POST("/wallet/order", s.handleWalletOrder)
		s.internal.POST("/payments/vk/callback", s.handleVKCallback)
		// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
		// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
		s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
		s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
		if s.yookassa.Configured() {
			s.internal.POST("/payments/yookassa/notify", s.handleYooKassaNotify)
		}
		if s.robokassa.Configured() {
			s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
		}
	}
	if s.links != nil {
		// Account linking & merge. The request step always mails a code;
		// a required merge is revealed only after the code is verified, and the
		// irreversible merge is an explicit second step.
		u.POST("/link/email/request", s.handleLinkEmailRequest)
		u.POST("/link/email/confirm", s.handleLinkEmailConfirm)
		u.POST("/link/email/merge", s.handleLinkEmailMerge)
		u.POST("/link/telegram", s.handleLinkTelegram)
		u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
		u.POST("/link/vk", s.handleLinkVK)
		u.POST("/link/vk/merge", s.handleLinkVKMerge)
		u.POST("/link/unlink", s.handleUnlink)
		// Change the account's confirmed email: mail a code to the new address, then
		// atomically switch on confirm (a new address owned by another account is
		// refused without disclosure, never merged).
		u.POST("/link/email/change/request", s.handleChangeEmailRequest)
		u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm)
		// Account deletion (legal retention, not erasure): step-up via a mailed code
		// (email accounts) or a typed phrase (platform-only), then tombstone + free creds.
		u.POST("/delete/request", s.handleRequestDelete)
		u.POST("/delete/confirm", s.handleConfirmDelete)
	}
	if s.games != nil {
		u.GET("/games", s.handleListGames)
		u.POST("/games/:id/play", s.handleSubmitPlay)
		u.GET("/games/:id/state", s.handleGameState)
		u.POST("/games/:id/pass", s.handlePass)
		u.POST("/games/:id/exchange", s.handleExchange)
		u.POST("/games/:id/resign", s.handleResign)
		u.POST("/games/:id/hint", s.handleHint)
		u.POST("/games/:id/evaluate", s.handleEvaluate)
		u.GET("/games/:id/check_word", s.handleCheckWord)
		u.POST("/games/:id/complaint", s.handleComplaint)
		u.GET("/games/:id/history", s.handleHistory)
		u.GET("/games/:id/gcg", s.handleExportGCG)
		u.GET("/games/:id/export-url", s.handleExportURL)
		u.GET("/games/:id/draft", s.handleGetDraft)
		u.PUT("/games/:id/draft", s.handleSaveDraft)
		u.POST("/games/:id/hide", s.handleHideGame)
		// Raw dictionary download for the client-side local move preview, keyed by
		// the game's pinned (variant, version); immutable, so cached hard.
		u.GET("/dict/:variant/:version", s.handleDictBytes)
		// The signed finished-game export download — the only public data route:
		// the URL's HMAC + expiry are the whole grant (export.go), because the
		// platforms' native download calls carry no credentials.
		s.public.GET("/dl/:id/:kind", s.handleExportDownload)
	}
	if s.feedback != nil {
		u.POST("/feedback", s.handleFeedbackSubmit)
		u.GET("/feedback", s.handleFeedbackState)
		u.GET("/feedback/unread", s.handleFeedbackUnread)
	}
	if s.matchmaker != nil {
		u.POST("/lobby/enqueue", s.handleEnqueue)
	}
	if s.invitations != nil {
		u.GET("/invitations", s.handleListInvitations)
		u.POST("/invitations", s.handleCreateInvitation)
		u.POST("/invitations/:id/accept", s.handleAcceptInvitation)
		u.POST("/invitations/:id/decline", s.handleDeclineInvitation)
		u.DELETE("/invitations/:id", s.handleCancelInvitation)
	}
	if s.social != nil {
		u.POST("/games/:id/chat", s.handleChatPost)
		u.GET("/games/:id/chat", s.handleChatList)
		u.POST("/games/:id/chat/read", s.handleChatRead)
		u.POST("/games/:id/nudge", s.handleNudge)
		u.GET("/friends", s.handleListFriends)
		u.GET("/friends/incoming", s.handleIncomingRequests)
		u.GET("/friends/outgoing", s.handleOutgoingRequests)
		u.POST("/friends/request", s.handleFriendRequest)
		u.POST("/friends/respond", s.handleFriendRespond)
		u.POST("/friends/cancel", s.handleFriendCancel)
		u.DELETE("/friends/:id", s.handleUnfriend)
		u.POST("/friends/code", s.handleIssueFriendCode)
		u.POST("/friends/code/redeem", s.handleRedeemFriendCode)
		u.GET("/blocks", s.handleListBlocks)
		u.POST("/blocks", s.handleBlock)
		u.DELETE("/blocks/:id", s.handleUnblock)
	}
}

// userID returns the authenticated account id stored by RequireUserID. The user
// group always runs that middleware, so absence is a programming error.
func userID(c *gin.Context) (uuid.UUID, bool) {
	return UserIDFromContext(c.Request.Context())
}

// gameIDParam parses the :id path parameter as a game UUID.
func gameIDParam(c *gin.Context) (uuid.UUID, bool) {
	id, err := uuid.Parse(c.Param("id"))
	if err != nil {
		return uuid.UUID{}, false
	}
	return id, true
}

// clientIP returns the originating client IP the gateway forwarded in
// X-Forwarded-For (the first hop), falling back to the direct peer.
func clientIP(c *gin.Context) string {
	if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
		first, _, _ := strings.Cut(xff, ",")
		return strings.TrimSpace(first)
	}
	return c.ClientIP()
}

// abortBadRequest rejects a malformed request body or parameter.
func abortBadRequest(c *gin.Context, msg string) {
	c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "bad_request", Message: msg}})
}

// abortErr maps a domain error to its HTTP status and a stable code. Server-side
// (5xx) errors are logged with the real cause and reported generically.
func (s *Server) abortErr(c *gin.Context, err error) {
	status, code := statusForError(err)
	msg := err.Error()
	if status >= http.StatusInternalServerError {
		s.log.Error("request failed", zap.String("path", c.FullPath()), zap.Error(err))
		msg = "internal error"
	}
	c.AbortWithStatusJSON(status, errorResponse{Error: errorBody{Code: code, Message: msg}})
}

// statusForError maps a known domain sentinel to an HTTP status and code,
// defaulting to 500/internal for anything unrecognised.
func statusForError(err error) (int, string) {
	switch {
	case errors.Is(err, game.ErrNotFound), errors.Is(err, account.ErrNotFound):
		return http.StatusNotFound, "not_found"
	case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
		return http.StatusForbidden, "not_a_player"
	case errors.Is(err, game.ErrNotYourTurn):
		return http.StatusConflict, "not_your_turn"
	case errors.Is(err, social.ErrNudgeOnOwnTurn):
		return http.StatusConflict, "nudge_own_turn"
	case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
		return http.StatusConflict, "game_finished"
	case errors.Is(err, social.ErrGameVsAI):
		return http.StatusConflict, "ai_game"
	case errors.Is(err, game.ErrNoOpponentYet):
		return http.StatusConflict, "no_opponent_yet"
	case errors.Is(err, game.ErrGameActive):
		return http.StatusConflict, "game_active"
	case errors.Is(err, game.ErrGameLimitReached):
		return http.StatusConflict, "game_limit_reached"
	case errors.Is(err, account.ErrInvalidProfile):
		return http.StatusBadRequest, "invalid_profile"
	case errors.Is(err, account.ErrAlreadyConfirmed):
		return http.StatusConflict, "already_confirmed"
	case errors.Is(err, lobby.ErrInvalidInvitation):
		return http.StatusBadRequest, "invalid_invitation"
	case errors.Is(err, lobby.ErrInvitationBlocked):
		return http.StatusForbidden, "invitation_blocked"
	case errors.Is(err, lobby.ErrInvitationNotFound):
		return http.StatusNotFound, "invitation_not_found"
	case errors.Is(err, lobby.ErrInvitationNotPending):
		return http.StatusConflict, "invitation_not_pending"
	case errors.Is(err, lobby.ErrInvitationExpired):
		return http.StatusConflict, "invitation_expired"
	case errors.Is(err, lobby.ErrNotInvited):
		return http.StatusForbidden, "not_invited"
	case errors.Is(err, lobby.ErrAlreadyResponded):
		return http.StatusConflict, "already_responded"
	case errors.Is(err, lobby.ErrNotInviter):
		return http.StatusForbidden, "not_inviter"
	case errors.Is(err, game.ErrInvalidConfig):
		return http.StatusBadRequest, "invalid_config"
	case errors.Is(err, game.ErrNoHintAvailable):
		// No legal move for the rack — distinct from a budget/disabled hint so the UI
		// can say "no options" (and the service spends nothing in this case).
		return http.StatusConflict, "no_hint_available"
	case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
		return http.StatusConflict, "hint_unavailable"
	case errors.Is(err, game.ErrHintLocked):
		// A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock.
		return http.StatusConflict, "hint_locked"
	case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
		return http.StatusUnprocessableEntity, "illegal_play"
	case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
		return http.StatusConflict, "email_taken"
	case errors.Is(err, account.ErrLastIdentity):
		return http.StatusConflict, "last_identity"
	case errors.Is(err, accountmerge.ErrActiveGameConflict):
		return http.StatusConflict, "merge_active_game_conflict"
	case errors.Is(err, payments.ErrUntrusted):
		return http.StatusForbidden, "payments_untrusted"
	case errors.Is(err, payments.ErrInsufficientChips):
		return http.StatusConflict, "insufficient_chips"
	case errors.Is(err, payments.ErrProductNotFound):
		return http.StatusNotFound, "product_not_found"
	case errors.Is(err, payments.ErrNotAValue):
		return http.StatusBadRequest, "not_a_value"
	case errors.Is(err, payments.ErrNotAPack):
		return http.StatusBadRequest, "not_a_pack"
	case errors.Is(err, account.ErrInvalidEmail):
		return http.StatusBadRequest, "invalid_email"
	case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
		errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
		return http.StatusUnauthorized, "code_invalid"
	case errors.Is(err, account.ErrTooManyRequests):
		return http.StatusTooManyRequests, "too_many_requests"
	case errors.Is(err, session.ErrNotFound):
		return http.StatusUnauthorized, "session_invalid"
	case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
		errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent):
		return http.StatusUnprocessableEntity, "chat_rejected"
	case errors.Is(err, social.ErrNudgeTooSoon):
		// A too-frequent nudge is a distinct, non-content rejection — the UI must say
		// "don't rush the player so often", not the chat content-rejection message.
		return http.StatusConflict, "nudge_too_soon"
	case errors.Is(err, social.ErrChatNotYourTurn):
		return http.StatusConflict, "chat_not_your_turn"
	case errors.Is(err, social.ErrChatAlreadySentThisTurn):
		return http.StatusConflict, "chat_already_sent"
	case errors.Is(err, social.ErrSelfRelation):
		return http.StatusBadRequest, "self_relation"
	case errors.Is(err, social.ErrRequestExists):
		return http.StatusConflict, "request_exists"
	case errors.Is(err, social.ErrRequestBlocked):
		return http.StatusForbidden, "request_blocked"
	case errors.Is(err, social.ErrAccountDeleted):
		return http.StatusGone, "account_deleted"
	case errors.Is(err, social.ErrRequestNotFound):
		return http.StatusNotFound, "request_not_found"
	case errors.Is(err, social.ErrNoSharedGame):
		return http.StatusForbidden, "no_shared_game"
	case errors.Is(err, social.ErrRequestDeclined):
		return http.StatusConflict, "request_declined"
	case errors.Is(err, social.ErrFriendCodeInvalid):
		return http.StatusUnprocessableEntity, "friend_code_invalid"
	case errors.Is(err, social.ErrGuestForbidden), errors.Is(err, lobby.ErrGuestForbidden):
		return http.StatusForbidden, "guest_forbidden"
	case errors.Is(err, feedback.ErrGuestForbidden):
		return http.StatusForbidden, "feedback_guest_forbidden"
	case errors.Is(err, feedback.ErrBanned):
		return http.StatusForbidden, "feedback_banned"
	case errors.Is(err, feedback.ErrPendingReview):
		return http.StatusConflict, "feedback_pending"
	case errors.Is(err, feedback.ErrEmptyMessage):
		return http.StatusUnprocessableEntity, "feedback_empty"
	case errors.Is(err, feedback.ErrMessageTooLong):
		return http.StatusUnprocessableEntity, "feedback_too_long"
	case errors.Is(err, feedback.ErrAttachmentTooLarge):
		return http.StatusUnprocessableEntity, "feedback_attachment_too_large"
	case errors.Is(err, feedback.ErrAttachmentType):
		return http.StatusUnprocessableEntity, "feedback_attachment_type"
	default:
		return http.StatusInternalServerError, "internal"
	}
}
