//go:build integration

package inttest

import (
	"context"
	"encoding/csv"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"
	"sync"
	"testing"
	"time"

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

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/mynalog"
	"scrabble/backend/internal/mynalogsync"
	"scrabble/backend/internal/payments"
	"scrabble/backend/internal/server"
)

// taxStub stands in for the professional-income tax cabinet. It records what it was asked and, by
// default, behaves like the real service: it mints a receipt id and remembers the income under the
// exact service name it was filed with, which is what the recovery probe searches by.
type taxStub struct {
	srv *httptest.Server

	mu       sync.Mutex
	calls    []string
	filed    map[string]string // service name -> receipt id
	annulled map[string]bool   // receipt id -> annulled
	seq      int

	// onIncome overrides the default success. Returning a non-zero status makes the stub answer
	// that instead; filing is the callback's own business, which is how a "the call failed but the
	// receipt exists" case is set up.
	onIncome func(s *taxStub, name string) (status int, body string)
}

// newTaxStub starts a stub cabinet and stops it when the test ends.
func newTaxStub(t *testing.T) *taxStub {
	t.Helper()
	st := &taxStub{filed: map[string]string{}, annulled: map[string]bool{}}
	st.srv = httptest.NewServer(http.HandlerFunc(st.serve))
	t.Cleanup(st.srv.Close)
	return st
}

// serve routes one stub call.
func (s *taxStub) serve(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	s.mu.Lock()
	s.calls = append(s.calls, r.URL.Path)
	s.mu.Unlock()

	w.Header().Set("Content-Type", "application/json")
	switch r.URL.Path {
	case "/api/v1/auth/lkfl", "/api/v1/auth/token":
		_, _ = io.WriteString(w, `{"token":"access","refreshToken":"refresh","profile":{"inn":"770000000000"}}`)
	case "/api/v1/income":
		s.serveIncome(w, body)
	case "/api/v1/cancel":
		s.serveCancel(w, body)
	case "/api/v1/incomes":
		s.serveList(w)
	default:
		w.WriteHeader(http.StatusNotFound)
	}
}

// serveIncome registers an income, or defers to the test's override.
func (s *taxStub) serveIncome(w http.ResponseWriter, body []byte) {
	var req struct {
		Services []struct {
			Name string `json:"name"`
		} `json:"services"`
	}
	_ = json.Unmarshal(body, &req)
	name := ""
	if len(req.Services) > 0 {
		name = req.Services[0].Name
	}
	if s.onIncome != nil {
		if status, out := s.onIncome(s, name); status != 0 {
			w.WriteHeader(status)
			_, _ = io.WriteString(w, out)
			return
		}
	}
	_, _ = io.WriteString(w, fmt.Sprintf(`{"approvedReceiptUuid":%q}`, s.file(name)))
}

// file records an income under name and returns the receipt id the stub assigned.
func (s *taxStub) file(name string) string {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.seq++
	id := fmt.Sprintf("receipt-%d", s.seq)
	s.filed[name] = id
	return id
}

// serveCancel annuls a receipt.
func (s *taxStub) serveCancel(w http.ResponseWriter, body []byte) {
	var req struct {
		ReceiptUUID string `json:"receiptUuid"`
	}
	_ = json.Unmarshal(body, &req)
	s.mu.Lock()
	s.annulled[req.ReceiptUUID] = true
	s.mu.Unlock()
	_, _ = io.WriteString(w, `{}`)
}

// serveList answers the income list the recovery probe searches, omitting annulled receipts the way
// the real service marks them.
func (s *taxStub) serveList(w http.ResponseWriter) {
	s.mu.Lock()
	defer s.mu.Unlock()
	var items []string
	for name, id := range s.filed {
		cancellation := "null"
		if s.annulled[id] {
			cancellation = `{"comment":"Возврат средств"}`
		}
		items = append(items, fmt.Sprintf(`{"name":%q,"approvedReceiptUuid":%q,"cancellationInfo":%s}`,
			name, id, cancellation))
	}
	_, _ = io.WriteString(w, `{"content":[`+strings.Join(items, ",")+`]}`)
}

// hits reports how many times a path was called.
func (s *taxStub) hits(path string) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	n := 0
	for _, c := range s.calls {
		if c == path {
			n++
		}
	}
	return n
}

// receiptFor returns the receipt id the stub assigned to a service name.
func (s *taxStub) receiptFor(name string) (string, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	id, ok := s.filed[name]
	return id, ok
}

// isAnnulled reports whether a receipt has been annulled.
func (s *taxStub) isAnnulled(id string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.annulled[id]
}

// recordingAlerter captures operator alerts instead of emailing them.
type recordingAlerter struct {
	mu    sync.Mutex
	kinds []string
}

func (a *recordingAlerter) Alert(_ context.Context, kind, _, _ string) error {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.kinds = append(a.kinds, kind)
	return nil
}

// saw reports whether an alert of the given kind was raised.
func (a *recordingAlerter) saw(kind string) bool {
	a.mu.Lock()
	defer a.mu.Unlock()
	for _, k := range a.kinds {
		if k == kind {
			return true
		}
	}
	return false
}

// taxKey is the encryption key the tests seal the stored refresh token with.
const taxKey = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY="

// taxServer wires a console server with the tax export pointed at the stub.
func taxServer(t *testing.T, st *taxStub) (*server.Server, *payments.Service, *mynalogsync.Exporter, *recordingAlerter) {
	t.Helper()
	pay := newPaymentsService()
	key, err := mynalog.ParseKey(taxKey)
	if err != nil {
		t.Fatalf("key: %v", err)
	}
	alerts := &recordingAlerter{}
	exp := mynalogsync.New(pay, mynalogsync.Config{
		Key:      key,
		BaseURL:  st.srv.URL,
		Location: time.FixedZone("MSK", 3*60*60),
		Pace:     time.Millisecond, // production paces at 2s; a test must not
	}, alerts, zap.NewNop())
	if exp == nil {
		t.Fatal("exporter not built")
	}
	srv := server.New(":0", server.Deps{
		Logger:   zap.NewNop(),
		Accounts: account.NewStore(testDB),
		Games:    newGameService(),
		Registry: testRegistry,
		DictDir:  dictDir(),
		Payments: pay,
		MyNalog:  exp,
	})
	drainTaxQueue(t, pay)
	return srv, pay, exp, alerts
}

// drainTaxQueue rules out every rouble income already in the shared test database, so a test starts
// from an empty queue and can assert on counts. The database is shared across the package, and
// other payment tests fund YooKassa orders that this export would otherwise pick up.
func drainTaxQueue(t *testing.T, pay *payments.Service) {
	t.Helper()
	ctx := context.Background()
	for {
		queue, err := pay.MyNalogQueue(ctx, 500)
		if err != nil {
			t.Fatalf("drain queue: %v", err)
		}
		pending := append(append([]payments.MyNalogIncome{}, queue.Incomes...), queue.NotRequired...)
		pending = append(pending, queue.Attention...)
		if len(pending) == 0 {
			return
		}
		for _, in := range pending {
			if err := pay.MarkMyNalogNotRequired(ctx, in, "seeded by another test"); err != nil {
				t.Fatalf("drain row: %v", err)
			}
		}
	}
}

// fundTaxableOrder funds one YooKassa rouble order and returns the account and the order id.
func fundTaxableOrder(t *testing.T, pay *payments.Service, chips int, minor int64) (uuid.UUID, uuid.UUID) {
	t.Helper()
	ctx := context.Background()
	acc := provisionAccount(t)
	prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: minor})
	res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"),
		[]payments.Source{payments.SourceDirect}, prod, "yookassa")
	if err != nil {
		t.Fatalf("order: %v", err)
	}
	paid, err := payments.MoneyFromMinor(minor, payments.CurrencyRUB)
	if err != nil {
		t.Fatalf("money: %v", err)
	}
	if _, err := pay.Fund(ctx, res.OrderID, "yookassa", "pay-"+res.OrderID.String(), paid); err != nil {
		t.Fatalf("fund: %v", err)
	}
	return acc, res.OrderID
}

// signInTax signs the export into the stub cabinet through the console.
func signInTax(t *testing.T, srv *server.Server) {
	t.Helper()
	code, body := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/login",
		"login=770000000000&password=secret", "http://admin.test")
	if code != http.StatusOK || !strings.Contains(body, "Signed in") {
		t.Fatalf("sign in = %d: %s", code, body)
	}
}

// onlyIncome returns the single owed income, failing when the queue does not hold exactly one.
func onlyIncome(t *testing.T, pay *payments.Service) payments.MyNalogIncome {
	t.Helper()
	queue, err := pay.MyNalogQueue(context.Background(), 500)
	if err != nil {
		t.Fatalf("queue: %v", err)
	}
	if len(queue.Incomes) != 1 {
		t.Fatalf("owed incomes = %d, want 1", len(queue.Incomes))
	}
	return queue.Incomes[0]
}

// statusOf reads the export status recorded for one income.
func statusOf(t *testing.T, pay *payments.Service, ledgerID uuid.UUID) payments.MyNalogIncome {
	t.Helper()
	in, ok, err := pay.MyNalogIncomeAt(context.Background(), ledgerID)
	if err != nil || !ok {
		t.Fatalf("income %s: ok=%v err=%v", ledgerID, ok, err)
	}
	return in
}

// TestMyNalogFilesIncomeAndAnnulsOnRefund walks the happy path end to end: a rouble purchase is
// filed with the tax service under a name carrying its order marker, and a later refund annuls that
// receipt without any further human involvement.
func TestMyNalogFilesIncomeAndAnnulsOnRefund(t *testing.T) {
	ctx := context.Background()
	st := newTaxStub(t)
	srv, pay, exp, _ := taxServer(t, st)
	signInTax(t, srv)

	_, orderID := fundTaxableOrder(t, pay, 100, 14900)
	owed := onlyIncome(t, pay)
	name := exp.ReceiptName(owed)
	// The marker is the TAIL of the order id: a UUIDv7's leading digits are a millisecond clock and
	// would be shared by every order placed within about a minute, which would make the recovery
	// probe unable to tell two purchases apart.
	marker := orderID.String()[len(orderID.String())-8:]
	if !strings.Contains(name, marker) {
		t.Fatalf("service name %q does not carry the order marker %q — the recovery probe depends on it", name, marker)
	}

	sum, err := exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if sum.Registered != 1 {
		t.Fatalf("registered = %d, want 1 (%+v)", sum.Registered, sum)
	}
	receipt, ok := st.receiptFor(name)
	if !ok {
		t.Fatalf("the stub cabinet has no receipt for %q", name)
	}
	if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent || got.ReceiptUUID != receipt {
		t.Fatalf("status = %q receipt = %q, want sent/%s", got.Status, got.ReceiptUUID, receipt)
	}

	// Refund the order: the annulment is owed automatically.
	if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
		t.Fatalf("refund: %v", err)
	}
	sum, err = exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run after refund: %v", err)
	}
	if sum.Cancelled != 1 {
		t.Fatalf("cancelled = %d, want 1 (%+v)", sum.Cancelled, sum)
	}
	if !st.isAnnulled(receipt) {
		t.Fatal("the receipt was not annulled at the tax service")
	}
}

// TestMyNalogProbeRecoversAnAmbiguousCall covers the failure this whole design exists for: the
// registration call has no idempotency key, so a receipt can exist even though the call reported an
// error. The probe must find it and record it, rather than the income being filed a second time.
func TestMyNalogProbeRecoversAnAmbiguousCall(t *testing.T) {
	ctx := context.Background()
	st := newTaxStub(t)
	// File the income, then answer as if the service fell over.
	st.onIncome = func(s *taxStub, name string) (int, string) {
		s.file(name)
		return http.StatusBadGateway, `{"message":"gateway"}`
	}
	srv, pay, exp, alerts := taxServer(t, st)
	signInTax(t, srv)
	fundTaxableOrder(t, pay, 100, 14900)
	owed := onlyIncome(t, pay)

	sum, err := exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if sum.Registered != 1 || sum.Unknown != 0 {
		t.Fatalf("registered = %d unknown = %d, want the probe to recover it (%+v)", sum.Registered, sum.Unknown, sum)
	}
	if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent {
		t.Fatalf("status = %q, want sent", got.Status)
	}
	if st.hits("/api/v1/incomes") == 0 {
		t.Fatal("the probe was never issued")
	}
	if alerts.saw("unknown") {
		t.Fatal("a recovered call must not alert the operator")
	}
}

// TestMyNalogUnresolvedOutcomeHaltsTheQueue asserts the rule that keeps income from being filed
// twice: when the call fails and the probe finds nothing, the income is left unresolved and NOTHING
// further is filed until a human has looked at it.
func TestMyNalogUnresolvedOutcomeHaltsTheQueue(t *testing.T) {
	ctx := context.Background()
	st := newTaxStub(t)
	st.onIncome = func(*taxStub, string) (int, string) { return http.StatusBadGateway, `{}` }
	srv, pay, exp, alerts := taxServer(t, st)
	signInTax(t, srv)

	fundTaxableOrder(t, pay, 100, 14900)
	first := onlyIncome(t, pay)

	sum, err := exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if sum.Unknown != 1 || sum.Registered != 0 {
		t.Fatalf("unknown = %d registered = %d, want one unresolved (%+v)", sum.Unknown, sum.Registered, sum)
	}
	if got := statusOf(t, pay, first.LedgerID); got.Status != payments.MyNalogUnknown {
		t.Fatalf("status = %q, want unknown", got.Status)
	}
	if !alerts.saw("unknown") {
		t.Fatal("an unresolved outcome must alert the operator")
	}

	// A second, perfectly fine purchase must NOT be filed while the first is unresolved.
	st.onIncome = nil
	fundTaxableOrder(t, pay, 50, 9900)
	sum, err = exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("second run: %v", err)
	}
	if sum.Registered != 0 {
		t.Fatalf("registered = %d while an outcome is unresolved, want 0", sum.Registered)
	}
	if st.hits("/api/v1/income") != 1 {
		t.Fatalf("income calls = %d, want no new filing attempt", st.hits("/api/v1/income"))
	}

	// The operator asserts nothing was created; the income goes back into the queue and files.
	if err := exp.Requeue(ctx, first.LedgerID); err != nil {
		t.Fatalf("requeue: %v", err)
	}
	sum, err = exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run after requeue: %v", err)
	}
	if sum.Registered != 2 {
		t.Fatalf("registered = %d after requeue, want both (%+v)", sum.Registered, sum)
	}
}

// TestMyNalogPausesAfterRepeatedRejections asserts the circuit breaker: an API that rejects
// everything — the shape a format change takes — must take the rail out of service rather than
// producing thousands of futile requests.
func TestMyNalogPausesAfterRepeatedRejections(t *testing.T) {
	ctx := context.Background()
	st := newTaxStub(t)
	st.onIncome = func(*taxStub, string) (int, string) {
		return http.StatusBadRequest, `{"message":"unknown field"}`
	}
	srv, pay, exp, alerts := taxServer(t, st)
	signInTax(t, srv)
	for i := 0; i < 4; i++ {
		fundTaxableOrder(t, pay, 10, 1000)
	}

	sum, err := exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if !sum.Paused {
		t.Fatalf("the rail did not pause after repeated rejections (%+v)", sum)
	}
	if st.hits("/api/v1/income") != 3 {
		t.Fatalf("income calls = %d, want the run to stop after 3", st.hits("/api/v1/income"))
	}
	if !alerts.saw("paused") {
		t.Fatal("a self-imposed pause must alert the operator")
	}

	// While paused, a run does nothing at all.
	before := st.hits("/api/v1/income")
	sum, err = exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run while paused: %v", err)
	}
	if !sum.Paused || st.hits("/api/v1/income") != before {
		t.Fatalf("a paused rail still called the service (%+v)", sum)
	}

	// Resuming through the console lets it run again.
	st.onIncome = nil
	if code, _ := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/resume",
		"", "http://admin.test"); code != http.StatusOK {
		t.Fatalf("resume = %d", code)
	}
	sum, err = exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run after resume: %v", err)
	}
	if sum.Registered != 4 {
		t.Fatalf("registered = %d after resume, want 4 (%+v)", sum.Registered, sum)
	}
}

// TestMyNalogSkipsIncomeRefundedBeforeFiling asserts that a purchase the buyer got back before any
// receipt was issued is ruled out locally: nothing is filed, nothing is annulled, and the tax
// service is not contacted at all.
func TestMyNalogSkipsIncomeRefundedBeforeFiling(t *testing.T) {
	ctx := context.Background()
	st := newTaxStub(t)
	srv, pay, exp, _ := taxServer(t, st)
	signInTax(t, srv)
	callsAfterSignIn := len(st.calls)

	_, orderID := fundTaxableOrder(t, pay, 100, 14900)
	if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
		t.Fatalf("refund: %v", err)
	}

	sum, err := exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if sum.NotRequired != 1 || sum.Registered != 0 {
		t.Fatalf("not required = %d registered = %d, want it ruled out (%+v)", sum.NotRequired, sum.Registered, sum)
	}
	if len(st.calls) != callsAfterSignIn {
		t.Fatalf("the tax service was contacted for an income that needed nothing: %v", st.calls[callsAfterSignIn:])
	}
}

// TestMyNalogIdleRunContactsNobody asserts the property that keeps an idle installation invisible to
// the tax service: with an empty queue a run does not even authenticate.
func TestMyNalogIdleRunContactsNobody(t *testing.T) {
	st := newTaxStub(t)
	srv, _, exp, _ := taxServer(t, st)
	signInTax(t, srv)
	before := len(st.calls)

	sum, err := exp.RunBatch(context.Background(), time.Minute)
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if sum.Registered != 0 || sum.Cancelled != 0 {
		t.Fatalf("an idle run did something: %+v", sum)
	}
	if len(st.calls) != before {
		t.Fatalf("an idle run issued %v", st.calls[before:])
	}
}

// TestMyNalogManualEntryStaysAnnullable asserts why the console asks for the receipt number rather
// than a simple "done" tick: an income filed by hand must still have its receipt annulled
// automatically when the money goes back.
func TestMyNalogManualEntryStaysAnnullable(t *testing.T) {
	ctx := context.Background()
	st := newTaxStub(t)
	srv, pay, exp, _ := taxServer(t, st)
	signInTax(t, srv)
	_, orderID := fundTaxableOrder(t, pay, 100, 14900)
	owed := onlyIncome(t, pay)

	// The operator files it in the cabinet by hand and types the number back in.
	form := "ledger_id=" + owed.LedgerID.String() + "&receipt_uuid=hand-1"
	if code, body := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/manual",
		form, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Recorded") {
		t.Fatalf("manual entry = %d: %s", code, body)
	}
	if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent || got.ReceiptUUID != "hand-1" {
		t.Fatalf("status = %q receipt = %q, want sent/hand-1", got.Status, got.ReceiptUUID)
	}

	if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
		t.Fatalf("refund: %v", err)
	}
	sum, err := exp.RunBatch(ctx, time.Minute)
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if sum.Cancelled != 1 || !st.isAnnulled("hand-1") {
		t.Fatalf("a hand-filed receipt was not annulled (%+v)", sum)
	}
}

// TestMyNalogConsoleExportListsWhatWouldBeSent asserts the manual fallback carries exactly the
// fields the tax cabinet's own form asks for, plus the ledger id needed to enter the receipt number
// back afterwards.
func TestMyNalogConsoleExportListsWhatWouldBeSent(t *testing.T) {
	st := newTaxStub(t)
	srv, pay, exp, _ := taxServer(t, st)
	fundTaxableOrder(t, pay, 100, 14900)
	owed := onlyIncome(t, pay)

	code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/mynalog.csv", "", "")
	if code != http.StatusOK {
		t.Fatalf("export = %d", code)
	}
	// Parsed rather than substring-matched: the service name contains quotes, so the CSV encoding of
	// it is not the string an operator has to type.
	rows, err := csv.NewReader(strings.NewReader(body)).ReadAll()
	if err != nil {
		t.Fatalf("parse CSV: %v\n%s", err, body)
	}
	if len(rows) != 2 {
		t.Fatalf("CSV rows = %d, want a header and one income\n%s", len(rows), body)
	}
	wantHeader := []string{"operation_time", "service_name", "amount", "currency", "status", "ledger_id"}
	if !slices.Equal(rows[0], wantHeader) {
		t.Fatalf("header = %v, want %v", rows[0], wantHeader)
	}
	got := rows[1]
	if got[1] != exp.ReceiptName(owed) {
		t.Errorf("service name = %q, want exactly what would be sent (%q)", got[1], exp.ReceiptName(owed))
	}
	if got[2] != "149.00" || got[3] != "RUB" {
		t.Errorf("amount = %q %q, want 149.00 RUB", got[2], got[3])
	}
	if got[5] != owed.LedgerID.String() {
		t.Errorf("ledger id = %q, want %s — without it the receipt number cannot be entered back", got[5], owed.LedgerID)
	}
}

// TestMyNalogConsoleRequiresSameOrigin asserts the export actions carry the console's only CSRF
// defence, since a forged sign-in would hand an attacker's credentials to the rail.
func TestMyNalogConsoleRequiresSameOrigin(t *testing.T) {
	st := newTaxStub(t)
	srv, _, _, _ := taxServer(t, st)
	for _, path := range []string{"/_gm/mynalog/login", "/_gm/mynalog/run", "/_gm/mynalog/auto"} {
		if code, _ := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test"+path, "", ""); code != http.StatusForbidden {
			t.Errorf("%s without an origin = %d, want 403", path, code)
		}
	}
}

// TestMyNalogBuyerMails asserts the three letters a buyer gets and that none of them is sent twice.
func TestMyNalogBuyerMails(t *testing.T) {
	ctx := context.Background()
	st := newTaxStub(t)
	srv, pay, exp, _ := taxServer(t, st)
	signInTax(t, srv)

	accounts := account.NewStore(testDB)
	acc, orderID := fundTaxableOrder(t, pay, 100, 14900)
	// Derived from the account so the address is unique in the shared test database: identities are
	// unique on (kind, external_id), and a fixed literal collides with another suite's fixture.
	addr := "mynalog-" + acc.String() + "@example.test"
	if err := accounts.AttachIdentity(ctx, acc, account.KindEmail, addr, true); err != nil {
		t.Fatalf("attach email: %v", err)
	}
	// The crediting path records the outbox event the purchase letter rides.
	payload, _ := json.Marshal(map[string]any{"chips": 100, "source": "direct"})
	if err := pay.RecordPaymentEvent(ctx, acc, &orderID, "succeeded", payload); err != nil {
		t.Fatalf("record event: %v", err)
	}

	box := &recordingMailer{}
	mailer := mynalogsync.NewMailer(pay, accounts, box, "no-reply@example.test",
		st.srv.URL, time.FixedZone("MSK", 3*60*60), zap.NewNop())
	if mailer == nil {
		t.Fatal("mailer not built")
	}

	// 1. The purchase confirmation, which must say plainly that it is not the fiscal receipt.
	mailer.Tick(ctx)
	first := box.to(addr)
	if len(first) != 1 {
		t.Fatalf("purchase letters = %d, want 1", len(first))
	}
	if !strings.Contains(first[0].Text, "не чек") {
		t.Errorf("the purchase letter does not say it is not the receipt:\n%s", first[0].Text)
	}
	// The catalog title already names the pack size, so appending it again read as
	// "50 фишек — 50 фишек". The line is the title verbatim, then the price, and nothing else.
	if !strings.Contains(first[0].Text, "Покупка совершена: test pack, 149.00 ₽.") {
		t.Errorf("the purchase line is not the catalog title plus the price:\n%s", first[0].Text)
	}

	// 2. The receipt letter, once the income is filed, carrying the printable link.
	if _, err := exp.RunBatch(ctx, time.Minute); err != nil {
		t.Fatalf("run: %v", err)
	}
	mailer.Tick(ctx)
	if got := len(box.to(addr)); got != 2 {
		t.Fatalf("letters after filing = %d, want 2", got)
	}
	receipt := box.to(addr)[1]
	if receipt.Subject != "Эрудит — Ваш чек покупки" {
		t.Errorf("receipt letter subject = %q", receipt.Subject)
	}
	if !strings.Contains(receipt.Text, "/api/v1/receipt/770000000000/") {
		t.Errorf("the receipt letter carries no receipt link:\n%s", receipt.Text)
	}
	// The amount carries its time-zone offset, because that offset is what decides the tax period
	// the receipt belongs to and the buyer should see the same moment the receipt states.
	if !strings.Contains(receipt.Text, "Покупка на сумму: 149.00 ₽ от ") ||
		!strings.Contains(receipt.Text, "+03:00") {
		t.Errorf("the receipt letter's amount line is wrong:\n%s", receipt.Text)
	}
	// Nothing beyond the amount, the moment and the link: the receipt is the document.
	if strings.Contains(receipt.Text, "test pack") {
		t.Errorf("the receipt letter repeats the catalog title; the receipt itself carries it:\n%s", receipt.Text)
	}

	// 3. The annulment letter after a refund.
	if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
		t.Fatalf("refund: %v", err)
	}
	if _, err := exp.RunBatch(ctx, time.Minute); err != nil {
		t.Fatalf("run after refund: %v", err)
	}
	mailer.Tick(ctx)
	if got := len(box.to(addr)); got != 3 {
		t.Fatalf("letters after the refund = %d, want 3", got)
	}
	if !strings.Contains(box.to(addr)[2].Text, "аннулирован") {
		t.Errorf("the third letter is not the annulment notice:\n%s", box.to(addr)[2].Text)
	}

	// Draining again must send nothing: every letter is decided exactly once.
	mailer.Tick(ctx)
	if got := len(box.to(addr)); got != 3 {
		t.Fatalf("letters after a second drain = %d, want 3 — a letter was repeated", got)
	}
}

// recordingMailer captures outgoing mail instead of relaying it.
type recordingMailer struct {
	mu   sync.Mutex
	sent []account.Message
}

func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.sent = append(m.sent, msg)
	return nil
}

// to returns the messages addressed to one recipient, in order.
func (m *recordingMailer) to(addr string) []account.Message {
	m.mu.Lock()
	defer m.mu.Unlock()
	var out []account.Message
	for _, msg := range m.sent {
		if msg.To == addr {
			out = append(out, msg)
		}
	}
	return out
}
