//go:build integration

package inttest

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"

	"github.com/google/uuid"
	"go.uber.org/zap"

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

// The test shop the fake API answers for. IsTest is true throughout, matching a YooKassa test shop.
const (
	ykShopID    = "100500"
	ykSecretKey = "test_secret"
	// ykSenderIP is inside YooKassa's published notification ranges; the intake rejects anything else.
	ykSenderIP = "185.71.76.1"
)

// fakeYooKassa is a stand-in for the YooKassa API. Tests set the payment it reports and whether a
// refund succeeds, then assert on what the intake did with that answer.
type fakeYooKassa struct {
	mu sync.Mutex
	// payments answers GET /payments/{id}; a missing id is a 404, as it is for a payment we never made.
	payments map[string]yookassa.Payment
	// refundFails makes POST /refunds answer 500, standing in for a provider that did not move money.
	refundFails bool
	// refundStatus is the status a created refund reports; empty means succeeded.
	refundStatus string
	// refundCalls counts the refunds actually requested.
	refundCalls int
	// paymentGets counts the confirming reads, so a test can assert a forged notification never
	// reached the provider at all.
	paymentGets int
	// refunds answers GET /refunds/{id}; a missing id is a 404.
	refunds map[string]yookassa.Refund
	// createdIdempotenceKey records the key sent on the last create-payment call.
	createdIdempotenceKey string
	// lastReceipt records the receipt object of the last create-payment call.
	lastReceipt map[string]any
}

// newFakeYooKassa starts the fake API and returns it with a shop config pointed at it.
func newFakeYooKassa(t *testing.T) (*fakeYooKassa, yookassa.Config) {
	t.Helper()
	f := &fakeYooKassa{payments: map[string]yookassa.Payment{}, refunds: map[string]yookassa.Refund{}}
	srv := httptest.NewServer(http.HandlerFunc(f.serve))
	t.Cleanup(srv.Close)
	return f, yookassa.Config{ShopID: ykShopID, SecretKey: ykSecretKey, IsTest: true, BaseURL: srv.URL}
}

func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) {
	f.mu.Lock()
	defer f.mu.Unlock()
	w.Header().Set("Content-Type", "application/json")
	switch {
	case r.Method == http.MethodPost && r.URL.Path == "/payments":
		var body struct {
			Amount   yookassa.Amount   `json:"amount"`
			Metadata map[string]string `json:"metadata"`
			Receipt  map[string]any    `json:"receipt"`
		}
		_ = json.NewDecoder(r.Body).Decode(&body)
		f.createdIdempotenceKey = r.Header.Get("Idempotence-Key")
		f.lastReceipt = body.Receipt
		id := "pay-" + body.Metadata[yookassa.MetadataOrderID]
		p := yookassa.Payment{
			ID: id, Status: yookassa.StatusPending, Test: true, Amount: body.Amount,
			Confirmation: yookassa.Confirmation{Type: yookassa.ConfirmationRedirect, ConfirmationURL: "https://yoomoney.test/pay/" + id},
			Metadata:     body.Metadata,
			Recipient:    yookassa.Recipient{AccountID: ykShopID},
		}
		f.payments[id] = p
		_ = json.NewEncoder(w).Encode(p)
	case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"):
		f.paymentGets++
		p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")]
		if !ok {
			w.WriteHeader(http.StatusNotFound)
			_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
			return
		}
		_ = json.NewEncoder(w).Encode(p)
	case r.Method == http.MethodPost && r.URL.Path == "/refunds":
		if f.refundFails {
			w.WriteHeader(http.StatusInternalServerError)
			_, _ = w.Write([]byte(`{"type":"error","description":"acquirer unavailable"}`))
			return
		}
		f.refundCalls++
		var body yookassa.RefundRequest
		_ = json.NewDecoder(r.Body).Decode(&body)
		status := f.refundStatus
		if status == "" {
			status = yookassa.StatusSucceeded
		}
		// The ledger dedupes refunds on (provider, refund id) across the whole database, so the id has
		// to be unique per payment or one test's refund looks like another's duplicate.
		refund := yookassa.Refund{ID: "refund-" + body.PaymentID, Status: status, PaymentID: body.PaymentID, Amount: body.Amount}
		f.refunds[refund.ID] = refund
		_ = json.NewEncoder(w).Encode(refund)
	case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/refunds/"):
		refund, ok := f.refunds[strings.TrimPrefix(r.URL.Path, "/refunds/")]
		if !ok {
			w.WriteHeader(http.StatusNotFound)
			_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
			return
		}
		_ = json.NewEncoder(w).Encode(refund)
	default:
		w.WriteHeader(http.StatusNotFound)
	}
}

// setRefund publishes a refund the API will report — how a test stands up a refund that was issued
// somewhere other than through our own API, such as in the merchant cabinet.
func (f *fakeYooKassa) setRefund(id string, refund yookassa.Refund) {
	f.mu.Lock()
	defer f.mu.Unlock()
	refund.ID = id
	f.refunds[id] = refund
}

// setPayment overwrites what the API reports for a payment — how a test says "the money did (or did
// not) really move", independently of what a notification claims.
func (f *fakeYooKassa) setPayment(id string, mutate func(p *yookassa.Payment)) {
	f.mu.Lock()
	defer f.mu.Unlock()
	p := f.payments[id]
	p.ID = id
	mutate(&p)
	f.payments[id] = p
}

// yookassaServer builds a backend server whose direct rail is the fake YooKassa shop.
//
// The suite shares one database, and the kill-switch test leaves the direct rail disabled, so the
// rail status is reset first: these tests are about the provider, not about ops availability, and
// fail-open means an absent row is an enabled rail.
func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *payments.Service) {
	// Receipts off, as in production: the merchant is outside 54-ФЗ.
	return yookassaServerWithVat(t, shop, yookassa.VatCodeOff)
}

// yookassaServerWithVat is yookassaServer with an explicit VAT rate code, for exercising the fiscal
// receipt path that stays dormant until a rate is configured.
func yookassaServerWithVat(t *testing.T, shop yookassa.Config, vatCode int) (*server.Server, *payments.Service) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`DELETE FROM payments.rail_status WHERE rail LIKE 'direct%'`); err != nil {
		t.Fatalf("reset direct rail status: %v", err)
	}
	paySvc := newPaymentsService()
	srv := server.New(":0", server.Deps{
		Logger:          zap.NewNop(),
		Accounts:        account.NewStore(testDB),
		Games:           newGameService(),
		Registry:        testRegistry,
		DictDir:         dictDir(),
		Payments:        paySvc,
		YooKassa:        yookassa.Shops{yookassa.ChannelWeb: shop},
		YooKassaVatCode: vatCode,
		PublicBaseURL:   "https://scrabble.test",
	})
	return srv, paySvc
}

// postYooKassaNotify posts a notification body to the intake as the given sender, and reports the status code.
func postYooKassaNotify(t *testing.T, srv *server.Server, senderIP, event, paymentID, orderID string) int {
	t.Helper()
	body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded",
		"recipient":{"account_id":%q},"metadata":{"order_id":%q}}}`, event, paymentID, ykShopID, orderID)
	req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Forwarded-For", senderIP)
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, req)
	return rec.Code
}

// seedYooKassaOrder opens a paid-for-real order: a pending order with the provider payment id
// attached, exactly as the order path leaves it once YooKassa has minted the payment.
func seedYooKassaOrder(t *testing.T, f *fakeYooKassa, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
	t.Helper()
	ctx := context.Background()
	prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
	res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "yookassa")
	if err != nil {
		t.Fatalf("create order: %v", err)
	}
	paymentID := "pay-" + res.OrderID.String()
	f.setPayment(paymentID, func(p *yookassa.Payment) {
		p.Status = yookassa.StatusSucceeded
		p.Paid = true
		p.Test = true
		p.Amount = yookassa.Amount{Value: "149.00", Currency: "RUB"}
		p.Metadata = map[string]string{yookassa.MetadataOrderID: res.OrderID.String()}
		p.Recipient = yookassa.Recipient{AccountID: ykShopID}
	})
	if err := pay.AttachProviderPayment(ctx, res.OrderID, "yookassa", paymentID); err != nil {
		t.Fatalf("attach provider payment: %v", err)
	}
	return res.OrderID, paymentID
}

// TestYooKassaNotifyCreditsOnce drives the crediting path: a succeeded notification credits the
// order exactly once, and a redelivery credits nothing more.
func TestYooKassaNotifyCreditsOnce(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)

	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Errorf("balance = %d, want 100", got)
	}
	if orderStatus(t, orderID) != "paid" {
		t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
	}

	// YooKassa redelivers until it gets a 200; a redelivery must credit nothing and still answer 200.
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("redelivered notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Errorf("balance after redelivery = %d, want 100 (credited once)", got)
	}
	if ledgerRows(t, acc, "fund") != 1 {
		t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
	}
}

// TestYooKassaNotifyIsNotEvidence is the security property of the whole rail: notifications are
// unsigned, so a fabricated "succeeded" credits nothing when the confirming read says otherwise.
func TestYooKassaNotifyIsNotEvidence(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)

	// The provider says the payment never completed, whatever the notification claims.
	f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusPending; p.Paid = false })
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance = %d, want 0 — a forged notification credited chips", got)
	}

	// A payment we never made: the confirming read 404s, so there is nothing to credit.
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-invented", orderID.String()); code != http.StatusOK {
		t.Fatalf("notify for an unknown payment = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance = %d, want 0 — an invented payment credited chips", got)
	}
}

// TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall pins what actually keeps a forger from
// using this endpoint to drive traffic at the provider on our behalf. The sender address is not
// checked — it cannot be, in a deployment that sees only its own tunnel — so the guard is that the
// order is resolved from the notification first: an id matching no order costs one indexed read and
// stops there. Guessing a live order id means guessing a uuid.
func TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, _ := yookassaServer(t, shop)

	before := f.paymentGets
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-forged", uuid.NewString()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}
	if f.paymentGets != before {
		t.Errorf("confirming reads = %d, want %d — a forged notification reached the provider", f.paymentGets, before)
	}
}

// TestYooKassaNotifyIsAcceptedFromAnyAddress: the contour (and any deployment behind a tunnel) sees
// only its own internal address as the sender, so a genuine notification must still be honoured.
func TestYooKassaNotifyIsAcceptedFromAnyAddress(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)

	if code := postYooKassaNotify(t, srv, "10.77.0.1", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Errorf("balance = %d, want 100 — a genuine notification was refused on its source address", got)
	}
}

// TestYooKassaNotifyRejectsLiveOnTestShop is the guard that keeps play money out of a live ledger and
// real money out of a test one: a payment whose test flag disagrees with the shop's is not credited.
func TestYooKassaNotifyRejectsLiveOnTestShop(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop) // the shop is a test shop
	acc := provisionAccount(t)
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)

	f.setPayment(paymentID, func(p *yookassa.Payment) { p.Test = false }) // a live payment
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance = %d, want 0 — a live payment credited against test credentials", got)
	}
}

// TestYooKassaCanceledRecordsFailure checks an active decline records a failed payment event and
// credits nothing, so the customer learns the attempt did not go through.
func TestYooKassaCanceledRecordsFailure(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)

	f.setPayment(paymentID, func(p *yookassa.Payment) {
		p.Status = yookassa.StatusCanceled
		p.Paid = false
		p.CancellationDetails = &yookassa.Cancellation{Party: "payment_network", Reason: "insufficient_funds"}
	})
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentCanceled, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance = %d, want 0 — a canceled payment credited chips", got)
	}
	if orderStatus(t, orderID) != "pending" {
		t.Errorf("order status = %s, want pending (a decline does not settle the order)", orderStatus(t, orderID))
	}
	evs, err := pay.UndispatchedEvents(context.Background(), 50)
	if err != nil {
		t.Fatalf("read events: %v", err)
	}
	found := false
	for _, e := range evs {
		if e.AccountID == acc && e.Type == "failed" {
			found = true
		}
	}
	if !found {
		t.Error("no failed payment event recorded for a declined payment")
	}
}

// TestYooKassaReconcileCreditsLostNotification is the safety net: when no notification ever arrives,
// the expiry-time check asks the provider and credits an order that was in fact paid.
func TestYooKassaReconcileCreditsLostNotification(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, _ := seedYooKassaOrder(t, f, pay, acc)

	// Age the order just past the re-check threshold — minutes, not the order's full lifetime — with
	// no notification ever delivered.
	if _, err := testDB.ExecContext(context.Background(),
		`UPDATE payments.orders SET created_at = now() - interval '2 minutes' WHERE order_id=$1`, orderID); err != nil {
		t.Fatalf("age order: %v", err)
	}
	if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 {
		t.Fatalf("reconciled %d orders, want 1", n)
	}
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Errorf("balance = %d, want 100 — the safety net did not credit a paid order", got)
	}
	if orderStatus(t, orderID) != "paid" {
		t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
	}
	// A second sweep finds nothing left to do — the order is no longer pending.
	if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
		t.Errorf("second sweep credited %d orders, want 0", n)
	}
}

// TestYooKassaReconcileSkipsFreshOrders keeps the sweep off an order the customer is still paying
// for: it re-checks only orders older than ReconcileAfter, so opening a payment page does not start
// polling the provider straight away.
func TestYooKassaReconcileSkipsFreshOrders(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	seedYooKassaOrder(t, f, pay, acc) // just created

	before := f.paymentGets
	if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
		t.Errorf("reconciled %d orders, want 0 (the order is seconds old)", n)
	}
	if f.paymentGets != before {
		t.Errorf("confirming reads = %d, want %d — a fresh order was polled", f.paymentGets, before)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance = %d, want 0", got)
	}
}

// TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the
// customer abandoned.
func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
	f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusCanceled; p.Paid = false })

	if _, err := testDB.ExecContext(context.Background(),
		`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
		t.Fatalf("age order: %v", err)
	}
	if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
		t.Errorf("reconciled %d orders, want 0 (the payment was never completed)", n)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance = %d, want 0", got)
	}
}

// TestYooKassaConsoleRefundMovesMoneyThenRecords drives the operator refund end to end: the provider
// refund is requested first and the ledger records the provider's own refund id.
func TestYooKassaConsoleRefundMovesMoneyThenRecords(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}

	h := srv.Handler()
	base := "http://admin.test/_gm/users/" + acc.String()
	code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
	if code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
		t.Fatalf("refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
	}
	if f.refundCalls != 1 {
		t.Errorf("provider refunds requested = %d, want 1", f.refundCalls)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance after refund = %d, want 0", got)
	}
	// The ledger records the provider's own refund id, which is what keeps it reconcilable against
	// YooKassa's records — and is distinct from the payment id, so both rows coexist.
	var provider, refundID string
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
		acc).Scan(&provider, &refundID); err != nil {
		t.Fatalf("read refund ledger row: %v", err)
	}
	if provider != "yookassa" || refundID != "refund-"+paymentID {
		t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-%s)", provider, refundID, paymentID)
	}
}

// TestYooKassaRefundRecordsNothingWhenTheProviderFails is the invariant that keeps the ledger honest:
// if the money did not move, nothing is written.
func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}

	f.refundFails = true
	h := srv.Handler()
	base := "http://admin.test/_gm/users/" + acc.String()
	_, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
	if !strings.Contains(body, "nothing was recorded") {
		t.Errorf("refund failure message = %q, want it to say nothing was recorded", body)
	}
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Errorf("balance = %d, want 100 — chips were revoked without the money moving", got)
	}
	if ledgerRows(t, acc, "refund") != 0 {
		t.Error("a refund ledger row was written although the provider did not refund")
	}
}

// seedBuyerWithEmail provisions an account with a confirmed email — the D36 anchor a direct purchase
// requires — and returns the account and the address.
func seedBuyerWithEmail(t *testing.T) (uuid.UUID, string) {
	t.Helper()
	acc := provisionAccount(t)
	// The full id, not a prefix: account ids are uuid v7, whose leading hex digits are the top bits
	// of a millisecond timestamp and so are identical for accounts made moments apart.
	email := "buyer-" + acc.String() + "@example.test"
	if _, err := testDB.ExecContext(context.Background(),
		`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
		uuid.New(), acc, email); err != nil {
		t.Fatalf("seed email identity: %v", err)
	}
	return acc, email
}

// postWalletOrder opens a purchase over HTTP as the account, on the direct/web platform.
func postWalletOrder(t *testing.T, srv *server.Server, acc, productID uuid.UUID) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
		strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, productID)))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-User-ID", acc.String())
	req.Header.Set("X-Platform", "direct/web")
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, req)
	return rec
}

// TestYooKassaOrderSendsNoReceipt pins the fiscal decision: the merchant is outside 54-ФЗ, so a
// purchase must carry no receipt at all — the provider registers nothing and the merchant reports
// each operation itself.
func TestYooKassaOrderSendsNoReceipt(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, _ := yookassaServer(t, shop)
	acc, _ := seedBuyerWithEmail(t)
	prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})

	rec := postWalletOrder(t, srv, acc, prod)
	if rec.Code != http.StatusOK {
		t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
	}
	if f.lastReceipt != nil {
		t.Errorf("the purchase carried a receipt (%v); receipts are off outside 54-ФЗ", f.lastReceipt)
	}
}

// TestYooKassaReceiptTurnsOnWithAVatCode proves the dormant fiscal path still works, so a lost НПД
// regime is a deploy-variable change rather than a code change.
func TestYooKassaReceiptTurnsOnWithAVatCode(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, _ := yookassaServerWithVat(t, shop, yookassa.VatCodeNone)
	acc, email := seedBuyerWithEmail(t)
	prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})

	if rec := postWalletOrder(t, srv, acc, prod); rec.Code != http.StatusOK {
		t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
	}
	if f.lastReceipt == nil {
		t.Fatal("no receipt sent although a VAT rate code is configured")
	}
	customer, _ := f.lastReceipt["customer"].(map[string]any)
	if customer["email"] != email {
		t.Errorf("receipt customer = %v, want the confirmed email %q", customer, email)
	}
	items, _ := f.lastReceipt["items"].([]any)
	if len(items) != 1 {
		t.Fatalf("receipt items = %d, want 1", len(items))
	}
	item, _ := items[0].(map[string]any)
	if item["vat_code"] != float64(yookassa.VatCodeNone) || item["payment_subject"] != yookassa.PaymentSubjectService {
		t.Errorf("receipt fiscal attributes = %v", item)
	}
}

// TestYooKassaOrderMintsAPayment drives the purchase path over HTTP: the order endpoint calls the
// provider, threads the order id through, and hands the client the hosted payment page.
func TestYooKassaOrderMintsAPayment(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, _ := yookassaServer(t, shop)
	acc := provisionAccount(t)
	const email = "buyer@example.test"
	if _, err := testDB.ExecContext(context.Background(),
		`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
		uuid.New(), acc, email); err != nil {
		t.Fatalf("seed email identity: %v", err)
	}
	prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})

	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
		strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-User-ID", acc.String())
	req.Header.Set("X-Platform", "direct/web")
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
	}
	var out struct {
		OrderID     string `json:"order_id"`
		RedirectURL string `json:"redirect_url"`
		Rail        string `json:"rail"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
		t.Fatalf("decode order response: %v", err)
	}
	if out.Rail != "yookassa" || !strings.HasPrefix(out.RedirectURL, "https://yoomoney.test/pay/") {
		t.Errorf("order response = %+v, want the yookassa rail and its hosted payment page", out)
	}
	// The order id is both the idempotency key (so a retried create cannot mint a second payment)
	// and the metadata that later resolves a notification to this order.
	if f.createdIdempotenceKey != out.OrderID {
		t.Errorf("Idempotence-Key = %q, want the order id %q", f.createdIdempotenceKey, out.OrderID)
	}
	// The payment id is recorded on the order, which is what the safety net and a refund need.
	orderID, err := uuid.Parse(out.OrderID)
	if err != nil {
		t.Fatalf("parse order id: %v", err)
	}
	var paymentID string
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT provider_payment_id FROM payments.orders WHERE order_id=$1`, orderID).Scan(&paymentID); err != nil {
		t.Fatalf("read order payment id: %v", err)
	}
	if paymentID != "pay-"+out.OrderID {
		t.Errorf("order provider_payment_id = %q, want the minted payment id", paymentID)
	}
}

// TestYooKassaOrderRequiresAnEmailAnchor keeps D36 in force on the new rail: without a confirmed
// address there is no recovery anchor and nowhere to deliver the fiscal receipt, so no payment is
// minted at all.
func TestYooKassaOrderRequiresAnEmailAnchor(t *testing.T) {
	_, shop := newFakeYooKassa(t)
	srv, _ := yookassaServer(t, shop)
	acc := provisionAccount(t) // a telegram identity only — no confirmed email
	prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})

	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
		strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-User-ID", acc.String())
	req.Header.Set("X-Platform", "direct/web")
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "email_required") {
		t.Fatalf("order = %d (%s), want 403 email_required", rec.Code, rec.Body.String())
	}
}

// postYooKassaRefundNotify posts a refund notification to the intake and reports the status code.
func postYooKassaRefundNotify(t *testing.T, srv *server.Server, refundID, paymentID string) int {
	t.Helper()
	body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded","payment_id":%q}}`,
		yookassa.EventRefundSucceeded, refundID, paymentID)
	req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Forwarded-For", ykSenderIP)
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, req)
	return rec.Code
}

// fundedYooKassaOrder seeds an order and credits it, leaving an account with chips to reverse.
func fundedYooKassaOrder(t *testing.T, f *fakeYooKassa, srv *server.Server, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
	t.Helper()
	orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
	if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
		t.Fatalf("notify = %d, want 200", code)
	}
	return orderID, paymentID
}

// TestYooKassaCabinetRefundIsReversed is why the refund event is handled at all: an operator can
// refund in the merchant cabinet, which never passes through our API, so without this the money would
// go back while the chips stayed credited.
func TestYooKassaCabinetRefundIsReversed(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Fatalf("balance before the refund = %d, want 100", got)
	}

	// The refund exists at the provider, issued outside our API.
	f.setRefund("cabinet-refund-1", yookassa.Refund{
		Status: yookassa.StatusSucceeded, PaymentID: paymentID,
		Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
	})
	if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
		t.Fatalf("refund notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance = %d, want 0 — a cabinet refund did not revoke the chips", got)
	}
	if f.refundCalls != 0 {
		t.Errorf("provider refunds requested = %d, want 0 — the money had already moved", f.refundCalls)
	}
	var provider, refundID string
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
		acc).Scan(&provider, &refundID); err != nil {
		t.Fatalf("read refund ledger row: %v", err)
	}
	if provider != "yookassa" || refundID != "cabinet-refund-1" {
		t.Errorf("refund ledger row = (%s, %s), want (yookassa, cabinet-refund-1)", provider, refundID)
	}

	// A redelivery reverses nothing a second time.
	if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
		t.Fatalf("redelivered refund notify = %d, want 200", code)
	}
	if ledgerRows(t, acc, "refund") != 1 {
		t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
	}
}

// TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp checks the two refund entry points cannot double
// up: the event for a refund the console already recorded reverses nothing again.
func TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)

	base := "http://admin.test/_gm/users/" + acc.String()
	if code, _ := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK {
		t.Fatalf("console refund = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Fatalf("balance after the console refund = %d, want 0", got)
	}
	// YooKassa then notifies about that same refund.
	if code := postYooKassaRefundNotify(t, srv, "refund-"+paymentID, paymentID); code != http.StatusOK {
		t.Fatalf("refund notify = %d, want 200", code)
	}
	if ledgerRows(t, acc, "refund") != 1 {
		t.Errorf("refund ledger rows = %d, want 1 (reversed once)", ledgerRows(t, acc, "refund"))
	}
	if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
		t.Errorf("risk = (abuse %v, loss %d), want none — the second reversal ran anyway", abuse, loss)
	}
}

// TestYooKassaPartialRefundIsLeftToAnOperator: the reversal engine revokes exactly what the pack
// funded and rejects any other amount, so a partial refund cannot be recorded without guessing how
// many chips it costs. It must change nothing rather than guess.
func TestYooKassaPartialRefundIsLeftToAnOperator(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)

	f.setRefund("partial-1", yookassa.Refund{
		Status: yookassa.StatusSucceeded, PaymentID: paymentID,
		Amount: yookassa.Amount{Value: "50.00", Currency: "RUB"}, // part of the 149.00 order
	})
	if code := postYooKassaRefundNotify(t, srv, "partial-1", paymentID); code != http.StatusOK {
		t.Fatalf("refund notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Errorf("balance = %d, want 100 — a partial refund revoked chips", got)
	}
	if ledgerRows(t, acc, "refund") != 0 {
		t.Error("a partial refund wrote a refund ledger row")
	}
}

// TestYooKassaRefundNotifyIsNotEvidence: as with a payment, the body only names a refund. One the
// provider does not confirm reverses nothing.
func TestYooKassaRefundNotifyIsNotEvidence(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)

	// A refund the provider has never heard of.
	if code := postYooKassaRefundNotify(t, srv, "invented-refund", paymentID); code != http.StatusOK {
		t.Fatalf("refund notify = %d, want 200", code)
	}
	// One that exists but is not final.
	f.setRefund("pending-refund", yookassa.Refund{
		Status: yookassa.StatusPending, PaymentID: paymentID,
		Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
	})
	if code := postYooKassaRefundNotify(t, srv, "pending-refund", paymentID); code != http.StatusOK {
		t.Fatalf("refund notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Errorf("balance = %d, want 100 — an unconfirmed refund revoked chips", got)
	}
	if ledgerRows(t, acc, "refund") != 0 {
		t.Error("an unconfirmed refund wrote a refund ledger row")
	}
}

// TestYooKassaPendingRefundRecordsNothing: a refund can still be canceled while pending and the
// ledger is append-only, so the console records only a completed one. Retrying is the way out.
func TestYooKassaPendingRefundRecordsNothing(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, _ := fundedYooKassaOrder(t, f, srv, pay, acc)

	f.refundStatus = yookassa.StatusPending
	base := "http://admin.test/_gm/users/" + acc.String()
	_, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
	if !strings.Contains(body, "not completed the refund yet") || !strings.Contains(body, "nothing was recorded") {
		t.Errorf("message = %q, want it to say the refund is not final and nothing was recorded", body)
	}
	if got := readBalance(t, acc, "direct"); got != 100 {
		t.Errorf("balance = %d, want 100 — chips were revoked for an unsettled refund", got)
	}
	if ledgerRows(t, acc, "refund") != 0 {
		t.Error("a pending refund wrote a refund ledger row")
	}

	// Once the provider settles it, pressing again records the reversal — the idempotency key returns
	// the same refund rather than paying twice.
	f.refundStatus = yookassa.StatusSucceeded
	if code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
		t.Fatalf("retried refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Errorf("balance = %d, want 0", got)
	}
}

// TestYooKassaConsoleRefundAfterItsOwnNotification covers the race the two recording paths really
// run: the console asks the provider to refund, the provider fires refund.succeeded at once, and the
// notification records the reversal before the console gets to. Both name the same refund id, so the
// idempotency index catches the second write and nothing is revoked twice — but the operator must be
// told the refund succeeded, not that they clicked twice.
func TestYooKassaConsoleRefundAfterItsOwnNotification(t *testing.T) {
	f, shop := newFakeYooKassa(t)
	srv, pay := yookassaServer(t, shop)
	acc := provisionAccount(t)
	orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)

	// The provider's notification lands first, for the refund the console is about to create.
	refundID := "refund-" + paymentID
	f.setRefund(refundID, yookassa.Refund{
		Status: yookassa.StatusSucceeded, PaymentID: paymentID,
		Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
	})
	if code := postYooKassaRefundNotify(t, srv, refundID, paymentID); code != http.StatusOK {
		t.Fatalf("refund notify = %d, want 200", code)
	}
	if got := readBalance(t, acc, "direct"); got != 0 {
		t.Fatalf("balance after the notification = %d, want 0", got)
	}

	base := "http://admin.test/_gm/users/" + acc.String()
	code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
	if code != http.StatusOK {
		t.Fatalf("console refund = %d, want 200", code)
	}
	if strings.Contains(body, "Already refunded") {
		t.Errorf("the console reported a repeat click for a refund it had just made successfully: %q", body)
	}
	if !strings.Contains(body, "Refunded") {
		t.Errorf("console message = %q, want it to report the refund succeeded", body)
	}
	if ledgerRows(t, acc, "refund") != 1 {
		t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
	}
	if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
		t.Errorf("risk = (abuse %v, loss %d), want none", abuse, loss)
	}
}
