package server

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"

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

	"scrabble/backend/internal/payments"
	"scrabble/backend/internal/yookassa"
)

// maxNotifyBytes caps the notification body the backend will read. The gateway already forwards only
// what it read from the provider; this is the backend's own ceiling on an untrusted body.
const maxNotifyBytes = 1 << 20

// yooKassaReceipt builds the fiscal receipt for a sale, or nil when receipts are off. They are off
// by default: the merchant runs on НПД, which is outside 54-ФЗ, so nothing is registered through the
// provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code turns them
// back on — the switch a lost НПД regime would need — without touching this code.
func (s *Server) yooKassaReceipt(email, title string, amount yookassa.Amount) *yookassa.Receipt {
	if !yookassa.ReceiptEnabled(s.vatCode) {
		return nil
	}
	return yookassa.SingleItemReceipt(email, title, amount, s.vatCode)
}

// orderYooKassa opens a pending order on the direct rail and mints the YooKassa payment the customer
// is redirected to. shop is the merchant shop the caller resolved for this platform channel, and
// email is the account's confirmed address (D36) — the recovery anchor a direct purchase requires,
// and the delivery address of a fiscal receipt when receipts are switched on. No chips are credited
// here — only later, by the verified notification (or the reconcile sweep).
func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Context, present []payments.Source, productID uuid.UUID, shop yookassa.Config, email string) {
	ctx := c.Request.Context()
	res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerYooKassa)
	if err != nil {
		s.abortErr(c, err)
		return
	}
	amount := yookassa.Amount{Value: res.Amount.Major(), Currency: string(res.Amount.Currency())}
	payment, err := shop.CreatePayment(ctx, yookassa.PaymentRequest{
		Amount:  amount,
		Capture: true,
		Confirmation: yookassa.Confirmation{
			Type:      yookassa.ConfirmationRedirect,
			ReturnURL: strings.TrimSuffix(s.publicURL, "/") + yookassaReturnPath,
		},
		Description: yookassa.TruncateDescription(res.Title),
		Metadata:    map[string]string{yookassa.MetadataOrderID: res.OrderID.String()},
		// Off unless a VAT rate is configured: the merchant is outside 54-ФЗ and reports each
		// operation itself, so no fiscal receipt is registered through the provider.
		Receipt: s.yooKassaReceipt(email, res.Title, amount),
	}, res.OrderID.String())
	if err != nil {
		// The order stays pending and unpaid; it expires on its own. The customer sees the generic
		// error, and the log carries the provider's own description (which names the offending
		// parameter when a receipt is at fault).
		s.log.Error("yookassa create payment failed", zap.String("order", res.OrderID.String()), zap.Error(err))
		c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
		return
	}
	if payment.Confirmation.ConfirmationURL == "" {
		s.log.Error("yookassa payment has no confirmation url",
			zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID))
		c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
		return
	}
	// Record the provider's payment id so the reconcile sweep and any refund can address it. A
	// failure here is logged and tolerated: the credit path matches on the order id carried in the
	// payment metadata, so the purchase still completes — only the safety net and refunds lose their
	// handle on this one order.
	if err := s.payments.AttachProviderPayment(ctx, res.OrderID, providerYooKassa, payment.ID); err != nil {
		s.log.Error("yookassa attach payment id failed",
			zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID), zap.Error(err))
	}
	c.JSON(http.StatusOK, walletOrderResponse{
		OrderID:     res.OrderID.String(),
		RedirectURL: payment.Confirmation.ConfirmationURL,
		Rail:        providerYooKassa,
	})
}

// handleYooKassaNotify is the YooKassa webhook, reached only through the gateway (which checks the
// sender address and forwards the raw JSON body on the internal, gateway-only route).
//
// YooKassa does not sign notifications, so the body is never evidence: it only names a payment to
// re-read. Everything acted on comes from the confirming GetPayment. A succeeded payment credits its
// order exactly once (idempotent, and honoured even if the order expired); a canceled one records a
// failed event so the customer is told the attempt did not go through.
//
// The sender address is deliberately NOT checked. It would not add security — the confirming read is
// the whole boundary — and the one thing it did buy, keeping a forger from turning each fabricated
// notification into an outbound call of ours, is already bought earlier and far more tightly: the
// order is resolved from the notification's metadata first, and an id that matches no order costs a
// single indexed read and nothing else. Guessing a live order id means guessing a uuid. Meanwhile an
// address check is actively harmful in a deployment that cannot observe real client addresses (a
// contour behind a tunnel sees only its own), where it rejects genuine notifications.
//
// The reply tells the provider whether to redeliver: 200 for anything durably decided — including a
// duplicate and a permanent rejection, which no retry could fix — and 5xx only for a transient
// failure we want redelivered (YooKassa retries for 24 hours).
func (s *Server) handleYooKassaNotify(c *gin.Context) {
	raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes))
	if err != nil {
		c.String(http.StatusInternalServerError, "error")
		return
	}
	note, err := yookassa.ParseNotification(raw)
	if err != nil {
		s.log.Warn("yookassa notify: malformed body", zap.Error(err))
		c.String(http.StatusOK, "ignored")
		return
	}
	switch note.Event {
	case yookassa.EventPaymentSucceeded, yookassa.EventPaymentCanceled:
	case yookassa.EventRefundSucceeded:
		s.handleYooKassaRefundNotification(c, note)
		return
	default:
		// A subscription we do not act on.
		c.String(http.StatusOK, "ignored")
		return
	}
	claimed, err := note.Payment()
	if err != nil {
		s.log.Warn("yookassa notify: unusable payment object", zap.String("event", note.Event), zap.Error(err))
		c.String(http.StatusOK, "ignored")
		return
	}
	orderID, err := uuid.Parse(claimed.OrderID())
	if err != nil {
		s.log.Warn("yookassa notify: no order in payment metadata", zap.String("payment", claimed.ID))
		c.String(http.StatusOK, "ignored")
		return
	}

	ctx := c.Request.Context()
	ref, err := s.payments.OrderProviderRef(ctx, orderID)
	if errors.Is(err, payments.ErrOrderNotFound) {
		s.log.Warn("yookassa notify: unknown order", zap.String("order", orderID.String()), zap.String("payment", claimed.ID))
		c.String(http.StatusOK, "ignored")
		return
	}
	if err != nil {
		s.log.Error("yookassa notify: order lookup failed", zap.String("order", orderID.String()), zap.Error(err))
		c.String(http.StatusInternalServerError, "error")
		return
	}

	payment, shop, err := s.confirmYooKassaPayment(ctx, claimed.ID, claimed.Recipient.AccountID, ref)
	if err != nil {
		var apiErr *yookassa.APIError
		if errors.As(err, &apiErr) && !apiErr.Retryable() {
			// The provider says this payment is not ours or not readable — a forged or stale
			// notification. Nothing to redeliver.
			s.log.Warn("yookassa notify: payment not confirmed",
				zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
			c.String(http.StatusOK, "ignored")
			return
		}
		s.log.Error("yookassa notify: confirm failed",
			zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
		c.String(http.StatusInternalServerError, "error")
		return
	}
	if !s.yooKassaPaymentMatchesOrder(payment, shop, orderID) {
		c.String(http.StatusOK, "ignored")
		return
	}

	switch payment.Status {
	case yookassa.StatusSucceeded:
		if err := s.creditYooKassaPayment(ctx, orderID, payment); err != nil {
			if isPermanentFundError(err) {
				s.log.Warn("yookassa notify rejected", zap.String("order", orderID.String()), zap.Error(err))
				c.String(http.StatusOK, "rejected")
				return
			}
			s.log.Error("yookassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
			c.String(http.StatusInternalServerError, "error")
			return
		}
	case yookassa.StatusCanceled:
		// An active decline (§9): the customer is told the attempt failed. An order abandoned without
		// a decline never reaches here — it just expires, invisibly.
		s.recordYooKassaFailure(ctx, ref.AccountID, orderID, payment)
	default:
		// Still pending: the notification raced ahead of the payment, or was forged. Either way the
		// reconcile sweep will settle the order, so there is nothing to redeliver.
		s.log.Info("yookassa notify: payment not final",
			zap.String("order", orderID.String()), zap.String("status", payment.Status))
	}
	c.String(http.StatusOK, "ok")
}

// handleYooKassaRefundNotification reverses a refund the provider reports as completed. Its reason
// for existing is the merchant cabinet: an operator can refund there, and such a refund never passes
// through our API, so without this the money would go back while the chips stayed credited.
//
// As with a payment, the body is only a hint — the refund is re-read from the API before anything is
// recorded. The reversal is idempotent on (yookassa, refund id), so the notification for a refund the
// console already recorded reverses nothing a second time.
func (s *Server) handleYooKassaRefundNotification(c *gin.Context, note yookassa.Notification) {
	claimed, err := note.Refund()
	if err != nil {
		s.log.Warn("yookassa notify: unusable refund object", zap.Error(err))
		c.String(http.StatusOK, "ignored")
		return
	}
	ctx := c.Request.Context()
	// The refund object names the payment, not our order, so the order is found by the payment id we
	// recorded when the payment was minted. The claim is untrusted, but it only selects which shop's
	// credentials perform the confirming read; the confirmed refund is then bound back to this order.
	ref, err := s.payments.OrderByProviderPayment(ctx, providerYooKassa, claimed.PaymentID)
	if errors.Is(err, payments.ErrOrderNotFound) {
		s.log.Warn("yookassa refund notify: no order for the payment",
			zap.String("payment", claimed.PaymentID), zap.String("refund", claimed.ID))
		c.String(http.StatusOK, "ignored")
		return
	}
	if err != nil {
		s.log.Error("yookassa refund notify: order lookup failed", zap.String("payment", claimed.PaymentID), zap.Error(err))
		c.String(http.StatusInternalServerError, "error")
		return
	}
	shop, ok := s.yookassa.Shop(ref.Shop)
	if !ok {
		s.log.Warn("yookassa refund notify: no shop for the order's channel", zap.String("order", ref.OrderID.String()))
		c.String(http.StatusOK, "ignored")
		return
	}
	refund, err := shop.GetRefund(ctx, claimed.ID)
	if err != nil {
		var apiErr *yookassa.APIError
		if errors.As(err, &apiErr) && !apiErr.Retryable() {
			s.log.Warn("yookassa refund notify: refund not confirmed",
				zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
			c.String(http.StatusOK, "ignored")
			return
		}
		s.log.Error("yookassa refund notify: confirm failed",
			zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
		c.String(http.StatusInternalServerError, "error")
		return
	}
	if refund.Status != yookassa.StatusSucceeded || refund.PaymentID != ref.PaymentID {
		s.log.Warn("yookassa refund notify: confirmed refund does not settle this order",
			zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
			zap.String("status", refund.Status), zap.String("refund_payment", refund.PaymentID))
		c.String(http.StatusOK, "ignored")
		return
	}
	// The reversal engine is full-refund-only by design: it revokes exactly what the pack funded and
	// rejects any other amount. A partial refund therefore cannot be recorded without guessing how
	// many chips it should cost, so it is left to an operator — loudly, because the money has moved.
	paid, err := payments.ParseMoney(refund.Amount.Value, payments.Currency(refund.Amount.Currency))
	if err != nil || paid.Currency() != ref.Amount.Currency() || paid.Minor() != ref.Amount.Minor() {
		s.log.Error("yookassa refund notify: partial refund needs an operator; nothing was reversed",
			zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
			zap.String("refunded", refund.Amount.Value), zap.String("order_amount", ref.Amount.Major()))
		c.String(http.StatusOK, "ignored")
		return
	}
	out, err := s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refund.ID)
	if err != nil {
		if errors.Is(err, payments.ErrOrderNotPaid) {
			s.log.Warn("yookassa refund notify: the order was never credited",
				zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID))
			c.String(http.StatusOK, "ignored")
			return
		}
		s.log.Error("yookassa refund notify: reversal failed",
			zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID), zap.Error(err))
		c.String(http.StatusInternalServerError, "error")
		return
	}
	if !out.AlreadyRefunded {
		// This is the first record of the refund. It is usually one an operator made in the merchant
		// cabinet, but it can equally be a console refund whose notification outran the console's own
		// write — both name the same refund id, so either way it is recorded exactly once.
		s.log.Info("yookassa refund notify: reversed a refund",
			zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
			zap.Int("revoked", out.Revoked), zap.Int("loss", out.Loss))
	}
	c.String(http.StatusOK, "ok")
}

// confirmYooKassaPayment re-reads a payment from the API — the authenticity check for the whole rail,
// since notifications are unsigned. The shop is chosen by the shop id the payment claims to have been
// paid to, falling back to the merchant channel recorded on the order; a claim naming a shop we do
// not run is not honoured with our own credentials.
func (s *Server) confirmYooKassaPayment(ctx context.Context, paymentID, claimedShopID string, ref payments.OrderRef) (yookassa.Payment, yookassa.Config, error) {
	_, shop, ok := s.yookassa.ByShopID(claimedShopID)
	if !ok {
		shop, ok = s.yookassa.Shop(ref.Shop)
	}
	if !ok {
		return yookassa.Payment{}, yookassa.Config{}, yookassa.ErrUnconfigured
	}
	payment, err := shop.GetPayment(ctx, paymentID)
	return payment, shop, err
}

// yooKassaPaymentMatchesOrder checks the confirmed payment really belongs to the order being
// credited and to the kind of shop we think it is. It rejects a payment whose metadata names a
// different order, and — the guard that keeps play money out of the live ledger — a live payment
// arriving on test credentials or a test payment on live ones.
func (s *Server) yooKassaPaymentMatchesOrder(payment yookassa.Payment, shop yookassa.Config, orderID uuid.UUID) bool {
	if payment.OrderID() != orderID.String() {
		s.log.Warn("yookassa: confirmed payment belongs to another order",
			zap.String("order", orderID.String()), zap.String("payment", payment.ID),
			zap.String("payment_order", payment.OrderID()))
		return false
	}
	if payment.Test != shop.IsTest {
		s.log.Error("yookassa: payment test flag does not match the shop; refusing to credit",
			zap.String("order", orderID.String()), zap.String("payment", payment.ID),
			zap.Bool("payment_test", payment.Test), zap.Bool("shop_test", shop.IsTest))
		return false
	}
	return true
}

// creditYooKassaPayment credits a confirmed succeeded payment to its order exactly once and records
// the succeeded event a duplicate must not re-emit.
func (s *Server) creditYooKassaPayment(ctx context.Context, orderID uuid.UUID, payment yookassa.Payment) error {
	paid, err := payments.ParseMoney(payment.Amount.Value, payments.Currency(payment.Amount.Currency))
	if err != nil {
		return err
	}
	outcome, err := s.payments.Fund(ctx, orderID, providerYooKassa, payment.ID, paid)
	if err != nil {
		return err
	}
	if outcome.AlreadyCredited {
		return nil
	}
	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 {
		// The credit is already committed; only the in-app notification is lost, and the wallet still
		// refreshes on the client's return.
		s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
	}
	return nil
}

// recordYooKassaFailure records the failed payment event for an actively declined payment, so the
// customer learns the attempt did not go through instead of watching a balance that never moves.
func (s *Server) recordYooKassaFailure(ctx context.Context, accountID uuid.UUID, orderID uuid.UUID, payment yookassa.Payment) {
	reason := ""
	if payment.CancellationDetails != nil {
		reason = payment.CancellationDetails.Reason
	}
	s.log.Info("yookassa payment canceled",
		zap.String("order", orderID.String()), zap.String("payment", payment.ID), zap.String("reason", reason))
	payload, _ := json.Marshal(map[string]any{"reason": reason})
	if err := s.payments.RecordPaymentEvent(ctx, accountID, &orderID, "failed", payload); err != nil {
		s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
	}
}

// isPermanentFundError reports whether a credit failed for a reason no retry can fix — an order that
// does not exist, an amount that does not match, or a product that is no longer a chip pack.
func isPermanentFundError(err error) bool {
	return errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
		errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound)
}

// refundYooKassa returns the money for a paid order through the YooKassa refund API and reports the
// provider's own refund id, which the ledger then records. It sends the order id as the idempotency
// key, so a repeated attempt returns the original refund instead of paying twice, and a refund
// receipt when receipts are switched on (the rail registers one the same way it does for a payment).
//
// It is called before anything is recorded: if the money does not move, nothing is written, and the
// ledger never claims a refund that did not happen.
func (s *Server) refundYooKassa(ctx context.Context, ref payments.OrderRef) (string, error) {
	shop, ok := s.yookassa.Shop(ref.Shop)
	if !ok {
		return "", yookassa.ErrUnconfigured
	}
	if ref.PaymentID == "" {
		return "", errors.New("the order carries no provider payment id to refund")
	}
	title, _, err := s.payments.OrderItem(ctx, ref.OrderID)
	if err != nil {
		return "", err
	}
	// The buyer's address is read only to deliver a refund receipt; with receipts off there is
	// nothing to deliver and nothing to read.
	email := ""
	if yookassa.ReceiptEnabled(s.vatCode) {
		if email, _, err = s.accounts.ConfirmedEmail(ctx, ref.AccountID); err != nil {
			return "", err
		}
	}
	amount := yookassa.Amount{Value: ref.Amount.Major(), Currency: string(ref.Amount.Currency())}
	refund, err := shop.CreateRefund(ctx, yookassa.RefundRequest{
		PaymentID: ref.PaymentID,
		Amount:    amount,
		Receipt:   s.yooKassaReceipt(email, title, amount),
	}, ref.OrderID.String())
	if err != nil {
		return "", err
	}
	if refund.ID == "" {
		return "", errors.New("the provider returned a refund with no id")
	}
	// Only a completed refund is recorded. A refund can still be canceled while pending, and the
	// ledger is append-only, so recording early would mean revoking a customer's chips for money that
	// then stayed with us — with no way to take the row back.
	if refund.Status != yookassa.StatusSucceeded {
		return "", fmt.Errorf("%w (status %s)", errRefundNotFinal, refund.Status)
	}
	return refund.ID, nil
}

// errRefundNotFinal reports that the provider accepted the refund but has not completed it. Retrying
// is safe and is the way out: the idempotency key returns the same refund rather than paying twice,
// so the operator can press again until it settles.
var errRefundNotFinal = errors.New("the provider has not completed the refund yet")

// ReconcileYooKassaOrders asks YooKassa what became of every pending order that reached its expiry
// age while carrying a payment id, and credits the ones that were in fact paid. It is the safety net
// for a notification that was lost for good: YooKassa redelivers for 24 hours, so a short outage
// heals itself, but a notification that never arrived at all would otherwise leave the money taken
// and the chips unowed. It runs once per order, just before the order is written off as expired.
//
// It returns how many orders it credited. Failures are logged and skipped: the next sweep retries.
func (s *Server) ReconcileYooKassaOrders(ctx context.Context) int {
	if !s.yookassa.Configured() || s.payments == nil {
		return 0
	}
	refs, err := s.payments.PendingForReconcile(ctx)
	if err != nil {
		s.log.Warn("yookassa reconcile: read pending orders failed", zap.Error(err))
		return 0
	}
	credited := 0
	for _, ref := range refs {
		if ref.Provider != providerYooKassa || ref.PaymentID == "" {
			continue
		}
		shop, ok := s.yookassa.Shop(ref.Shop)
		if !ok {
			continue
		}
		payment, err := shop.GetPayment(ctx, ref.PaymentID)
		if err != nil {
			s.log.Warn("yookassa reconcile: payment lookup failed",
				zap.String("order", ref.OrderID.String()), zap.String("payment", ref.PaymentID), zap.Error(err))
			continue
		}
		if payment.Status != yookassa.StatusSucceeded || !s.yooKassaPaymentMatchesOrder(payment, shop, ref.OrderID) {
			continue
		}
		if err := s.creditYooKassaPayment(ctx, ref.OrderID, payment); err != nil {
			s.log.Error("yookassa reconcile: credit failed",
				zap.String("order", ref.OrderID.String()), zap.Error(err))
			continue
		}
		s.log.Info("yookassa reconcile: credited an order no notification confirmed",
			zap.String("order", ref.OrderID.String()), zap.String("payment", payment.ID))
		credited++
	}
	return credited
}
