package server

import (
	"encoding/json"
	"errors"
	"net/http"
	"net/url"
	"strconv"

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

	"scrabble/backend/internal/payments"
	"scrabble/backend/internal/robokassa"
)

// Ledger/order provider tags per rail. providerRobokassa is retired but never renamed or reused:
// historical ledger rows carry it, and it is load-bearing for the (provider, provider_payment_id)
// idempotency index.
const (
	providerYooKassa  = "yookassa"  // the direct (RUB) rail
	providerRobokassa = "robokassa" // the retired direct (RUB) rail
	providerVK        = "vk"        // the VK Votes rail
	providerTelegram  = "telegram"  // the Telegram Stars (XTR) rail
)

// yookassaReturnPath is where YooKassa returns the customer's browser after the hosted payment page.
// The credit never rides this redirect — it rides the server notification — so the page only closes
// the payment window and drops the customer back into the live app.
const yookassaReturnPath = "/pay/yookassa/return"

// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
type walletOrderRequest struct {
	ProductID string `json:"product_id"`
}

// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
// the provider's hosted-payment page for the direct rail (empty for VK/Telegram, which settle
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
// via the bot and returns it in RedirectURL.
type walletOrderResponse struct {
	OrderID       string `json:"order_id"`
	RedirectURL   string `json:"redirect_url"`
	Rail          string `json:"rail"`
	InvoiceTitle  string `json:"invoice_title,omitempty"`
	InvoiceAmount int64  `json:"invoice_amount,omitempty"`
}

// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
// details for the client: the provider's hosted-payment URL (direct), the order id for
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
// provider callback.
func (s *Server) handleWalletOrder(c *gin.Context) {
	uid, ok := userID(c)
	if !ok {
		return
	}
	var req walletOrderRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
		return
	}
	productID, err := uuid.Parse(req.ProductID)
	if err != nil {
		c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
		return
	}
	ctx := c.Request.Context()
	cxt, present, err := s.walletGate(ctx, uid)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	// Operational availability gate (D45/D46): the per-rail kill switch + the per-account override,
	// before any order is opened. Orthogonal to the security gates in the rail branches below — a
	// per-account "allow" override bypasses only this switch, never those. The reason is localized to
	// the account's language for the user.
	lang := ""
	if acc, aerr := s.accounts.GetByID(ctx, uid); aerr == nil {
		lang = acc.PreferredLanguage
	}
	if ok, reason, aerr := s.payments.CanPurchase(ctx, uid, payments.RailKey(cxt.Kind, cxt.Subtype), lang); aerr != nil {
		s.abortErr(c, aerr)
		return
	} else if !ok {
		c.AbortWithStatusJSON(http.StatusServiceUnavailable, errorResponse{Error: errorBody{Code: "payment_unavailable", Message: reason}})
		return
	}
	switch cxt.Kind {
	case payments.SourceDirect:
		// YooKassa settles the direct rail. Robokassa is retired and normally unconfigured, so it is
		// only reached when YooKassa has no shop for this channel — which is what makes reviving the
		// old rail a credentials change rather than a code change. The rail is resolved before any
		// account-level gate, so an unconfigured rail still reads as unavailable rather than as
		// something the customer could fix.
		ykShop, onYooKassa := s.yookassa.Shop(cxt.Subtype)
		rkShop, onRobokassa := s.robokassa.Shop(cxt.Subtype)
		if !onYooKassa && !onRobokassa {
			c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
			return
		}
		// D36: a direct purchase requires a confirmed email anchor, which is also where the fiscal
		// receipt is delivered.
		email, hasEmail, err := s.accounts.ConfirmedEmail(ctx, uid)
		if err != nil {
			s.abortErr(c, err)
			return
		}
		if !hasEmail {
			c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
			return
		}
		if onYooKassa {
			s.orderYooKassa(c, uid, cxt, present, productID, ykShop, email)
			return
		}
		res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
		if err != nil {
			s.abortErr(c, err)
			return
		}
		c.JSON(http.StatusOK, walletOrderResponse{
			OrderID:     res.OrderID.String(),
			RedirectURL: rkShop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
			Rail:        providerRobokassa,
		})
	case payments.SourceVK:
		res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
		if err != nil {
			s.abortErr(c, err)
			return
		}
		// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
		c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String(), Rail: providerVK})
	case payments.SourceTelegram:
		res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerTelegram)
		if err != nil {
			s.abortErr(c, err)
			return
		}
		// The gateway mints the Stars invoice link from the title and amount via the bot (only
		// the bot reaches Telegram) and returns it to the client as RedirectURL; the amount is in
		// whole stars (the XTR minor unit is the star). No chips are credited until the verified
		// successful_payment is forwarded back through the bot.
		c.JSON(http.StatusOK, walletOrderResponse{
			OrderID:       res.OrderID.String(),
			Rail:          providerTelegram,
			InvoiceTitle:  res.Title,
			InvoiceAmount: res.Amount.Minor(),
		})
	default:
		c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
	}
}

// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway
// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only
// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent,
// and honoured even if the order expired), records a succeeded event, and answers Robokassa's
// expected "OK<InvId>". A duplicate callback credits nothing but still answers OK.
func (s *Server) handleRobokassaResult(c *gin.Context) {
	var params map[string]string
	if err := c.ShouldBindJSON(&params); err != nil {
		c.String(http.StatusBadRequest, "bad request")
		return
	}
	v := url.Values{}
	for k, val := range params {
		v.Set(k, val)
	}
	// The per-shop Result route carries the channel as ?channel=; the legacy bare route defaults to
	// the web shop. Each channel is verified only by its own shop's Password2 (Verifier is strict).
	channel := c.DefaultQuery("channel", robokassa.ChannelWeb)
	shop, ok := s.robokassa.Verifier(channel)
	if !ok {
		s.log.Warn("robokassa result: unknown shop channel", zap.String("channel", channel))
		c.String(http.StatusBadRequest, "bad sign")
		return
	}
	orderID, outSum, ok := shop.VerifyResult(v)
	if !ok {
		s.log.Warn("robokassa result: bad signature")
		c.String(http.StatusBadRequest, "bad sign")
		return
	}
	paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB)
	if err != nil {
		c.String(http.StatusBadRequest, "bad amount")
		return
	}
	ctx := c.Request.Context()
	outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), paid)
	if err != nil {
		if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
			errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
			s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err))
			c.String(http.StatusBadRequest, "rejected")
			return
		}
		s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
		c.String(http.StatusInternalServerError, "error")
		return
	}
	if !outcome.AlreadyCredited {
		payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
		if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
			s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
		}
	}
	// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
	c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
}

// vkErrorResponse builds VK's error envelope for a payment callback.
func vkErrorResponse(code int, msg string, critical bool) gin.H {
	return gin.H{"error": gin.H{"error_code": code, "error_msg": msg, "critical": critical}}
}

// handleVKCallback is the VK Mini Apps payment callback, reached only through the gateway (which
// verifies the VK signature and forwards the provider's parameters as a JSON object on the internal
// route). It answers VK's two phases: get_item returns the ordered pack's title and vote price; a
// chargeable order_status_change credits the matched order exactly once (idempotent on VK's order
// id) and records a succeeded event. Both phases use VK's response envelope; the _test variants are
// the sandbox notifications and are handled identically.
func (s *Server) handleVKCallback(c *gin.Context) {
	var params map[string]string
	if err := c.ShouldBindJSON(&params); err != nil {
		c.JSON(http.StatusOK, vkErrorResponse(1, "bad request", true))
		return
	}
	ctx := c.Request.Context()
	switch params["notification_type"] {
	case "get_item", "get_item_test":
		orderID, err := uuid.Parse(params["item"])
		if err != nil {
			c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
			return
		}
		title, amount, err := s.payments.OrderItem(ctx, orderID)
		if err != nil {
			s.log.Warn("vk get_item lookup failed", zap.String("order", orderID.String()), zap.Error(err))
			c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
			return
		}
		c.JSON(http.StatusOK, gin.H{"response": gin.H{
			"item_id": orderID.String(),
			"title":   title,
			"price":   amount.Minor(),
		}})
	case "order_status_change", "order_status_change_test":
		if params["status"] != "chargeable" {
			c.JSON(http.StatusOK, vkErrorResponse(100, "unsupported status", false))
			return
		}
		orderID, err := uuid.Parse(params["item"])
		if err != nil {
			c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
			return
		}
		price, err := strconv.ParseInt(params["item_price"], 10, 64)
		if err != nil {
			c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
			return
		}
		paid, err := payments.MoneyFromMinor(price, payments.CurrencyVote)
		if err != nil {
			c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
			return
		}
		vkOrderID := params["order_id"]
		outcome, err := s.payments.Fund(ctx, orderID, providerVK, vkOrderID, paid)
		if err != nil {
			if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
				errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
				s.log.Warn("vk order rejected", zap.String("order", orderID.String()), zap.Error(err))
				c.JSON(http.StatusOK, vkErrorResponse(100, "cannot process the order", false))
				return
			}
			s.log.Error("vk fund failed", zap.String("order", orderID.String()), zap.Error(err))
			c.JSON(http.StatusOK, vkErrorResponse(100, "internal error", false))
			return
		}
		if !outcome.AlreadyCredited {
			payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
			if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
				s.log.Error("record vk payment event failed", zap.String("order", orderID.String()), zap.Error(err))
			}
		}
		// Echo VK's own order id; app_order_id is optional and our order id is a uuid, so omit it.
		appOrderID, _ := strconv.ParseInt(vkOrderID, 10, 64)
		c.JSON(http.StatusOK, gin.H{"response": gin.H{"order_id": appOrderID}})
	default:
		c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
	}
}

// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
// the order in the invoice payload and the amount and currency Telegram is about to charge.
type telegramPreCheckoutRequest struct {
	OrderID  string `json:"order_id"`
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
}

// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
// short message the bot surfaces to the payer on a decline.
type telegramPreCheckoutResponse struct {
	OK     bool   `json:"ok"`
	Reason string `json:"reason,omitempty"`
}

// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
// is refused here, before any star moves) and whose amount and currency match. A malformed
// reference is a decline, not an error.
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
	var req telegramPreCheckoutRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		abortBadRequest(c, "invalid request body")
		return
	}
	ctx := c.Request.Context()
	orderID, err := uuid.Parse(req.OrderID)
	if err != nil {
		c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
		return
	}
	amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
	if err != nil {
		c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
		return
	}
	out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
	if err != nil {
		s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
		c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
		return
	}
	reason := ""
	if !out.OK {
		// Localise the decline to the order account's preferred language (the reason shows in the
		// Telegram payment sheet). An unknown order has no account, so it falls back to English.
		lang := ""
		if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
			if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
				lang = acc.PreferredLanguage
			}
		}
		reason = telegramDeclineText(out.Reason, lang)
	}
	c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
}

// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
// anything else falls back to English), for display in the Telegram payment sheet.
func telegramDeclineText(code, lang string) string {
	ru := lang == "ru"
	switch code {
	case payments.PreCheckoutAlreadyPaid:
		if ru {
			return "Этот заказ уже оплачен."
		}
		return "This order has already been paid."
	case payments.PreCheckoutPriceChanged:
		if ru {
			return "Цена изменилась — начните покупку заново."
		}
		return "The price has changed; please start the purchase again."
	default:
		if ru {
			return "Этот заказ больше недоступен."
		}
		return "This order is no longer available."
	}
}

// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
type telegramPaymentRequest struct {
	OrderID                 string `json:"order_id"`
	TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
	Amount                  int64  `json:"amount"`
	TelegramUserID          int64  `json:"telegram_user_id"`
}

// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
// (the bot drops it); a 5xx means a transient failure the bot retries.
type telegramPaymentResponse struct {
	Credited bool `json:"credited"`
}

// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
// the bot retries.
func (s *Server) handleTelegramPayment(c *gin.Context) {
	var req telegramPaymentRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		abortBadRequest(c, "invalid request body")
		return
	}
	orderID, err := uuid.Parse(req.OrderID)
	if err != nil {
		s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
		c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
		return
	}
	paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
	if err != nil {
		c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
		return
	}
	ctx := c.Request.Context()
	outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
	if err != nil {
		if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
			errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
			// The star charge already happened but the order cannot be placed; record it loudly for
			// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
			s.log.Error("telegram payment rejected (charge taken, not credited)",
				zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
			c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
			return
		}
		s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
		c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
		return
	}
	if !outcome.AlreadyCredited {
		payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
		if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
			s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
		}
	}
	c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
}
