package quotation_service_v2

import (
	"context"
	"fmt"
	"github.com/tech/sendico/pkg/discovery"
	"strings"
	"testing"
	"time"

	"github.com/shopspring/decimal"
	"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service"
	"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
	"github.com/tech/sendico/payments/storage/model"
	quotestorage "github.com/tech/sendico/payments/storage/quote"
	"github.com/tech/sendico/pkg/merrors"
	pkgmodel "github.com/tech/sendico/pkg/model"
	paymenttypes "github.com/tech/sendico/pkg/payments/types"
	feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
	accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
	fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
	moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
	paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
	oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
	endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
	quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
	sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.uber.org/zap/zaptest"
	"google.golang.org/protobuf/encoding/protojson"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/types/known/timestamppb"
)

func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
	now := time.Unix(1_700_000_000, 0).UTC()
	orgID := bson.NewObjectID()

	store := newInMemoryQuotesStore()
	core := &fakeQuoteCore{now: now}
	svc := New(Dependencies{
		Logger:      zaptest.NewLogger(t),
		QuotesStore: store,
		Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
			return "q-intent-single"
		})),
		Computation: quote_computation_service.New(core),
		Now:         func() time.Time { return now },
		NewRef:      func() string { return "quote-single-usdt-rub" },
	})

	req := &quotationv2.QuotePaymentRequest{
		Meta: &sharedv1.RequestMeta{
			OrganizationRef: orgID.Hex(),
		},
		IdempotencyKey: "idem-single-usdt-rub",
		InitiatorRef:   "initiator-42",
		PreviewOnly:    false,
		Intent:         makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"),
	}

	result, err := svc.ProcessQuotePayment(context.Background(), req)
	if err != nil {
		t.Fatalf("ProcessQuotePayment returned error: %v", err)
	}
	if result == nil || result.Response == nil || result.Response.GetQuote() == nil {
		t.Fatalf("expected quote response")
	}
	quote := result.Response.GetQuote()

	if got, want := quote.GetQuoteRef(), "quote-single-usdt-rub"; got != want {
		t.Fatalf("unexpected quote_ref: got=%q want=%q", got, want)
	}
	if got, want := quote.GetIntentRef(), "q-intent-single"; got != want {
		t.Fatalf("unexpected intent_ref: got=%q want=%q", got, want)
	}
	if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
		t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
	}
	if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
		t.Fatalf("expected empty block reason, got=%s", got.String())
	}
	if got, want := quote.GetTransferPrincipalAmount().GetAmount(), "100"; got != want {
		t.Fatalf("unexpected principal amount: got=%q want=%q", got, want)
	}
	if got, want := quote.GetTransferPrincipalAmount().GetCurrency(), "USDT"; got != want {
		t.Fatalf("unexpected principal currency: got=%q want=%q", got, want)
	}
	if got, want := quote.GetDestinationAmount().GetAmount(), "9150"; got != want {
		t.Fatalf("unexpected destination amount: got=%q want=%q", got, want)
	}
	if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want {
		t.Fatalf("unexpected destination currency: got=%q want=%q", got, want)
	}
	if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "102.4"; got != want {
		t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want)
	}
	if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want {
		t.Fatalf("unexpected payer_total_debit_amount currency: got=%q want=%q", got, want)
	}
	if got, want := len(quote.GetFeeLines()), 2; got != want {
		t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want)
	}
	if got, want := quote.GetFeeLines()[0].GetLineType(), accountingv1.PostingLineType_POSTING_LINE_FEE; got != want {
		t.Fatalf("unexpected first fee line type: got=%s want=%s", got.String(), want.String())
	}
	if got, want := len(quote.GetFeeRules()), 1; got != want {
		t.Fatalf("unexpected fee rules count: got=%d want=%d", got, want)
	}
	if got := strings.TrimSpace(quote.GetFeeRules()[0].GetRuleId()); got == "" || !strings.Contains(got, "fee_") {
		t.Fatalf("expected route-bound fee rule id, got=%q", got)
	}
	if quote.GetFxQuote() == nil {
		t.Fatalf("expected fx quote")
	}
	if got, want := quote.GetFxQuote().GetPair().GetBase(), "USDT"; got != want {
		t.Fatalf("unexpected fx base: got=%q want=%q", got, want)
	}
	if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want {
		t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want)
	}
	if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want {
		t.Fatalf("unexpected fx side: got=%s want=%s", got.String(), want.String())
	}
	if quote.GetRoute() == nil {
		t.Fatalf("expected route specification")
	}
	if got := strings.TrimSpace(quote.GetRoute().GetRail()); got != "" {
		t.Fatalf("expected route rail header to be empty, got=%q", got)
	}
	if got := strings.TrimSpace(quote.GetRoute().GetProvider()); got != "" {
		t.Fatalf("expected route provider header to be empty, got=%q", got)
	}
	if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" {
		t.Fatalf("expected route_ref")
	}
	if got := strings.TrimSpace(quote.GetRoute().GetPricingProfileRef()); got == "" {
		t.Fatalf("expected pricing_profile_ref")
	}
	if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
		t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
	}
	if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
		t.Fatalf("expected route settlement asset")
	}
	if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
		t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
	}
	if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_source"; got != want {
		t.Fatalf("unexpected route settlement model: got=%q want=%q", got, want)
	}
	if quote.GetExecutionConditions() == nil {
		t.Fatalf("expected execution conditions")
	}
	if got, want := quote.GetExecutionConditions().GetReadiness(), quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY; got != want {
		t.Fatalf("unexpected readiness: got=%s want=%s", got.String(), want.String())
	}
	if quote.GetExecutionConditions().GetPrefundingRequired() {
		t.Fatalf("expected prefunding_required=false")
	}
	if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want {
		t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String())
	}
	if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want {
		t.Fatalf("unexpected resolved_fee_treatment: got=%s want=%s", got.String(), want.String())
	}

	// Verify that idempotent reuse keeps full quote payload (including fee lines/rules).
	reused, err := svc.ProcessQuotePayment(context.Background(), req)
	if err != nil {
		t.Fatalf("idempotent ProcessQuotePayment returned error: %v", err)
	}
	if reused == nil || reused.Response == nil || reused.Response.GetQuote() == nil {
		t.Fatalf("expected idempotent quote response")
	}
	if got, want := len(reused.Response.GetQuote().GetFeeLines()), 2; got != want {
		t.Fatalf("unexpected idempotent fee lines count: got=%d want=%d", got, want)
	}
	if got, want := len(reused.Response.GetQuote().GetFeeRules()), 1; got != want {
		t.Fatalf("unexpected idempotent fee rules count: got=%d want=%d", got, want)
	}
	if got := strings.TrimSpace(reused.Response.GetQuote().GetRoute().GetProvider()); got != "" {
		t.Fatalf("expected idempotent route provider header to be empty, got=%q", got)
	}
	if got, want := reused.Response.GetQuote().GetIntentRef(), "q-intent-single"; got != want {
		t.Fatalf("unexpected idempotent intent_ref: got=%q want=%q", got, want)
	}

	t.Logf("single request:\n%s", mustProtoJSON(t, req))
	t.Logf("single response:\n%s", mustProtoJSON(t, result.Response))
}

func TestQuotePayment_FixReceivedRUB_ProducesUSDTDebit_EndToEnd(t *testing.T) {
	now := time.Unix(1_700_000_000, 0).UTC()
	orgID := bson.NewObjectID()

	store := newInMemoryQuotesStore()
	core := &fakeQuoteCore{now: now}
	svc := New(Dependencies{
		Logger:      zaptest.NewLogger(t),
		QuotesStore: store,
		Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
			return "q-intent-fix-received"
		})),
		Computation: quote_computation_service.New(
			core,
			quote_computation_service.WithManagedWalletNetworkResolver(staticManagedWalletResolverForE2E{
				assetsByRef: map[string]*paymenttypes.Asset{
					"wallet-usdt-source": {
						Chain:       "TRON_NILE",
						TokenSymbol: "USDT",
					},
				},
			}),
		),
		Now:    func() time.Time { return now },
		NewRef: func() string { return "quote-fix-received-rub" },
	})

	intent := makeTransferIntent(t, "5000", "RUB", "wallet-usdt-source", "4111111111111111", "RU")
	intent.SettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
	intent.SettlementCurrency = "RUB"

	req := &quotationv2.QuotePaymentRequest{
		Meta: &sharedv1.RequestMeta{
			OrganizationRef: orgID.Hex(),
		},
		IdempotencyKey: "idem-fix-received-rub",
		InitiatorRef:   "initiator-42",
		PreviewOnly:    false,
		Intent:         intent,
	}

	result, err := svc.ProcessQuotePayment(context.Background(), req)
	if err != nil {
		t.Fatalf("ProcessQuotePayment returned error: %v", err)
	}
	if result == nil || result.Response == nil || result.Response.GetQuote() == nil {
		t.Fatalf("expected quote response")
	}
	quote := result.Response.GetQuote()

	rate := decimal.RequireFromString("91.5")
	received := decimal.RequireFromString("5000")
	expectedPrincipal := received.Div(rate)

	feeTotal := decimal.Zero
	for _, line := range quote.GetFeeLines() {
		if line == nil || line.GetMoney() == nil {
			continue
		}
		if !strings.EqualFold(line.GetMoney().GetCurrency(), "USDT") {
			continue
		}
		lineAmount := decimal.RequireFromString(line.GetMoney().GetAmount())
		switch line.GetSide() {
		case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
			feeTotal = feeTotal.Sub(lineAmount)
		default:
			feeTotal = feeTotal.Add(lineAmount)
		}
	}
	expectedTotalDebit := expectedPrincipal.Add(feeTotal)

	if got, want := quote.GetTransferPrincipalAmount().GetAmount(), expectedPrincipal.String(); got != want {
		t.Fatalf("unexpected principal amount: got=%q want=%q", got, want)
	}
	if got, want := quote.GetTransferPrincipalAmount().GetCurrency(), "USDT"; got != want {
		t.Fatalf("unexpected principal currency: got=%q want=%q", got, want)
	}
	if got, want := quote.GetDestinationAmount().GetAmount(), "5000"; got != want {
		t.Fatalf("unexpected destination amount: got=%q want=%q", got, want)
	}
	if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want {
		t.Fatalf("unexpected destination currency: got=%q want=%q", got, want)
	}
	if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), expectedTotalDebit.String(); got != want {
		t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want)
	}
	if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want {
		t.Fatalf("unexpected payer_total_debit_amount currency: got=%q want=%q", got, want)
	}
	if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
		t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String())
	}
	if quote.GetRoute() == nil || quote.GetRoute().GetSettlement() == nil {
		t.Fatalf("expected route settlement")
	}
	if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_received"; got != want {
		t.Fatalf("unexpected route settlement model: got=%q want=%q", got, want)
	}
	if quote.GetFxQuote() == nil {
		t.Fatalf("expected fx quote")
	}
	if got, want := quote.GetFxQuote().GetPair().GetBase(), "USDT"; got != want {
		t.Fatalf("unexpected fx base: got=%q want=%q", got, want)
	}
	if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want {
		t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want)
	}
	if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want {
		t.Fatalf("unexpected fx side: got=%s want=%s", got.String(), want.String())
	}
	if got, want := quote.GetFxQuote().GetQuoteAmount().GetAmount(), "5000"; got != want {
		t.Fatalf("unexpected fx quote amount: got=%q want=%q", got, want)
	}
	if got, want := quote.GetFxQuote().GetQuoteAmount().GetCurrency(), "RUB"; got != want {
		t.Fatalf("unexpected fx quote amount currency: got=%q want=%q", got, want)
	}
	if got, want := quote.GetFxQuote().GetBaseAmount().GetAmount(), expectedPrincipal.String(); got != want {
		t.Fatalf("unexpected fx base amount: got=%q want=%q", got, want)
	}
	if got, want := quote.GetFxQuote().GetBaseAmount().GetCurrency(), "USDT"; got != want {
		t.Fatalf("unexpected fx base amount currency: got=%q want=%q", got, want)
	}
}

func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) {
	now := time.Unix(1_700_000_000, 0).UTC()
	orgID := bson.NewObjectID()

	store := newInMemoryQuotesStore()
	core := &fakeQuoteCore{
		now:       now,
		resultTTL: 10 * time.Minute,
		fxTTL:     5 * time.Minute,
	}
	svc := New(Dependencies{
		Logger:      zaptest.NewLogger(t),
		QuotesStore: store,
		Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
			return "q-intent-single"
		})),
		Computation: quote_computation_service.New(core),
		Now:         func() time.Time { return now },
		NewRef:      func() string { return "quote-fx-expiry-clamp" },
	})

	req := &quotationv2.QuotePaymentRequest{
		Meta: &sharedv1.RequestMeta{
			OrganizationRef: orgID.Hex(),
		},
		IdempotencyKey: "idem-fx-expiry-clamp",
		InitiatorRef:   "initiator-42",
		Intent:         makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"),
	}

	result, err := svc.ProcessQuotePayment(context.Background(), req)
	if err != nil {
		t.Fatalf("ProcessQuotePayment returned error: %v", err)
	}
	if result == nil || result.Response == nil || result.Response.GetQuote() == nil {
		t.Fatalf("expected quote response")
	}
	quote := result.Response.GetQuote()
	if quote.GetFxQuote() == nil {
		t.Fatalf("expected fx quote")
	}
	if quote.GetExpiresAt() == nil {
		t.Fatalf("expected quote expires_at")
	}

	gotQuoteExpiry := quote.GetExpiresAt().AsTime().UTC()
	gotFXExpiry := time.UnixMilli(quote.GetFxQuote().GetExpiresAtUnixMs()).UTC()
	if gotQuoteExpiry.After(gotFXExpiry) {
		t.Fatalf("expected quote expires_at <= fx expires_at, got quote=%s fx=%s", gotQuoteExpiry, gotFXExpiry)
	}
	if got, want := gotQuoteExpiry.UnixMilli(), gotFXExpiry.UnixMilli(); got != want {
		t.Fatalf("expected quote expiry to be clamped to fx expiry, got=%d want=%d", got, want)
	}

	if result.Record == nil {
		t.Fatalf("expected persisted record")
	}
	if got, want := result.Record.ExpiresAt.UTC().UnixMilli(), gotFXExpiry.UnixMilli(); got != want {
		t.Fatalf("expected persisted expires_at to match clamped expiry, got=%d want=%d", got, want)
	}
}

func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
	now := time.Unix(1_700_000_000, 0).UTC()
	orgID := bson.NewObjectID()

	store := newInMemoryQuotesStore()
	core := &fakeQuoteCore{now: now}
	svc := New(Dependencies{
		Logger:      zaptest.NewLogger(t),
		QuotesStore: store,
		Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
			return fmt.Sprintf("q-intent-%d", time.Now().UnixNano())
		})),
		Computation: quote_computation_service.New(core),
		Now:         func() time.Time { return now },
		NewRef:      func() string { return "quote-batch-usdt-rub" },
	})

	req := &quotationv2.QuotePaymentsRequest{
		Meta: &sharedv1.RequestMeta{
			OrganizationRef: orgID.Hex(),
		},
		IdempotencyKey: "idem-batch-usdt-rub",
		InitiatorRef:   "initiator-42",
		PreviewOnly:    false,
		Intents: []*quotationv2.QuoteIntent{
			makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"),
			makeTransferIntent(t, "125", "USDT", "wallet-usdt-source", "4222222222222222", "RU"),
			makeTransferIntent(t, "80", "USDT", "wallet-usdt-source", "4333333333333333", "RU"),
		},
	}

	result, err := svc.ProcessQuotePayments(context.Background(), req)
	if err != nil {
		t.Fatalf("ProcessQuotePayments returned error: %v", err)
	}
	if result == nil || result.Response == nil {
		t.Fatalf("expected batch response")
	}

	if got, want := result.Response.GetQuoteRef(), "quote-batch-usdt-rub"; got != want {
		t.Fatalf("unexpected batch quote_ref: got=%q want=%q", got, want)
	}
	if got, want := len(result.Response.GetQuotes()), 3; got != want {
		t.Fatalf("unexpected quote count: got=%d want=%d", got, want)
	}

	for i, quote := range result.Response.GetQuotes() {
		if quote == nil {
			t.Fatalf("quote[%d] is nil", i)
		}
		if got := strings.TrimSpace(quote.GetIntentRef()); got == "" {
			t.Fatalf("expected intent_ref for item %d", i)
		}
		if quote.GetQuoteRef() != "quote-batch-usdt-rub" {
			t.Fatalf("unexpected quote_ref for item %d: %q", i, quote.GetQuoteRef())
		}
		if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
			t.Fatalf("unexpected quote state for item %d: got=%s want=%s", i, got.String(), want.String())
		}
		if quote.GetTransferPrincipalAmount().GetCurrency() != "USDT" {
			t.Fatalf("unexpected principal currency for item %d: %q", i, quote.GetTransferPrincipalAmount().GetCurrency())
		}
		if quote.GetDestinationAmount().GetCurrency() != "RUB" {
			t.Fatalf("unexpected destination currency for item %d: %q", i, quote.GetDestinationAmount().GetCurrency())
		}
		if quote.GetRoute() == nil {
			t.Fatalf("expected route for item %d", i)
		}
		if got := strings.TrimSpace(quote.GetRoute().GetRail()); got != "" {
			t.Fatalf("expected route rail header for item %d to be empty, got=%q", i, got)
		}
		if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" {
			t.Fatalf("expected route_ref for item %d", i)
		}
		if got := strings.TrimSpace(quote.GetRoute().GetPricingProfileRef()); got == "" {
			t.Fatalf("expected pricing_profile_ref for item %d", i)
		}
		if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
			t.Fatalf("unexpected route hops count for item %d: got=%d want=%d", i, got, want)
		}
		if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
			t.Fatalf("expected route settlement asset for item %d", i)
		}
		if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
			t.Fatalf("unexpected route settlement token for item %d: got=%q want=%q", i, got, want)
		}
		if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_source"; got != want {
			t.Fatalf("unexpected route settlement model for item %d: got=%q want=%q", i, got, want)
		}
		if quote.GetExecutionConditions() == nil {
			t.Fatalf("expected execution conditions for item %d", i)
		}
		if got, want := quote.GetExecutionConditions().GetReadiness(), quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY; got != want {
			t.Fatalf("unexpected readiness for item %d: got=%s want=%s", i, got.String(), want.String())
		}
		if got, want := len(quote.GetFeeLines()), 2; got != want {
			t.Fatalf("unexpected fee lines count for item %d: got=%d want=%d", i, got, want)
		}
		if got, want := len(quote.GetFeeRules()), 1; got != want {
			t.Fatalf("unexpected fee rules count for item %d: got=%d want=%d", i, got, want)
		}
		if quote.GetFxQuote() == nil {
			t.Fatalf("expected fx quote for item %d", i)
		}
		if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want {
			t.Fatalf("unexpected fx side for item %d: got=%s want=%s", i, got.String(), want.String())
		}
		if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want {
			t.Fatalf("unexpected resolved_settlement_mode for item %d: got=%s want=%s", i, got.String(), want.String())
		}
		if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want {
			t.Fatalf("unexpected resolved_fee_treatment for item %d: got=%s want=%s", i, got.String(), want.String())
		}
	}

	if got, want := core.quoteRequestIdempotencyKeys, []string{
		"idem-batch-usdt-rub:1",
		"idem-batch-usdt-rub:2",
		"idem-batch-usdt-rub:3",
	}; !equalStrings(got, want) {
		t.Fatalf("unexpected per-item idempotency keys: got=%v want=%v", got, want)
	}

	t.Logf("batch request:\n%s", mustProtoJSON(t, req))
	t.Logf("batch response:\n%s", mustProtoJSON(t, result.Response))
}

func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T) {
	now := time.Unix(1_700_000_000, 0).UTC()
	orgID := bson.NewObjectID()

	store := newInMemoryQuotesStore()
	core := &fakeQuoteCore{now: now}
	svc := New(Dependencies{
		Logger:      zaptest.NewLogger(t),
		QuotesStore: store,
		Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
			return "q-intent-topology"
		})),
		Computation: quote_computation_service.New(
			core,
			quote_computation_service.WithGatewayRegistry(staticGatewayRegistryForE2E{
				items: []*model.GatewayInstanceDescriptor{
					{
						ID:         "crypto-disabled",
						InstanceID: "crypto-disabled",
						Rail:       discovery.RailCrypto,
						Network:    "TRON",
						Currencies: []string{"USDT"},
						Capabilities: model.RailCapabilities{
							CanPayOut: true,
						},
						IsEnabled: false,
					},
					{
						ID:         "crypto-network-mismatch",
						InstanceID: "crypto-network-mismatch",
						Rail:       discovery.RailCrypto,
						Network:    "ETH",
						Currencies: []string{"USDT"},
						Capabilities: model.RailCapabilities{
							CanPayOut: true,
						},
						IsEnabled: true,
					},
					{
						ID:         "crypto-currency-mismatch",
						InstanceID: "crypto-currency-mismatch",
						Rail:       discovery.RailCrypto,
						Network:    "TRON",
						Currencies: []string{"EUR"},
						Capabilities: model.RailCapabilities{
							CanPayOut: true,
						},
						IsEnabled: true,
					},
					{
						ID:         "crypto-gw-1",
						InstanceID: "crypto-gw-1",
						Rail:       discovery.RailCrypto,
						Network:    "TRON",
						Currencies: []string{"USDT"},
						Capabilities: model.RailCapabilities{
							CanPayOut: true,
						},
						IsEnabled: true,
					},
					{
						ID:         "payout-disabled",
						InstanceID: "payout-disabled",
						Rail:       discovery.RailCardPayout,
						Currencies: []string{"USDT"},
						Capabilities: model.RailCapabilities{
							CanPayOut: true,
						},
						IsEnabled: false,
					},
					{
						ID:         "payout-currency-mismatch",
						InstanceID: "payout-currency-mismatch",
						Rail:       discovery.RailCardPayout,
						Currencies: []string{"EUR"},
						Capabilities: model.RailCapabilities{
							CanPayOut: true,
						},
						IsEnabled: true,
					},
					{
						ID:         "payout-gw-1",
						InstanceID: "payout-gw-1",
						Rail:       discovery.RailCardPayout,
						Currencies: []string{"USDT"},
						Capabilities: model.RailCapabilities{
							CanPayOut: true,
						},
						IsEnabled: true,
					},
				},
			}),
		),
		Now:    func() time.Time { return now },
		NewRef: func() string { return "quote-topology-usdt-rub" },
	})

	req := &quotationv2.QuotePaymentRequest{
		Meta: &sharedv1.RequestMeta{
			OrganizationRef: orgID.Hex(),
		},
		IdempotencyKey: "idem-topology-usdt-rub",
		InitiatorRef:   "initiator-42",
		PreviewOnly:    false,
		Intent:         makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"),
	}

	result, err := svc.ProcessQuotePayment(context.Background(), req)
	if err != nil {
		t.Fatalf("ProcessQuotePayment returned error: %v", err)
	}
	if result == nil || result.Response == nil || result.Response.GetQuote() == nil {
		t.Fatalf("expected quote response")
	}
	quote := result.Response.GetQuote()
	if quote.GetRoute() == nil {
		t.Fatalf("expected route")
	}
	if got := strings.TrimSpace(quote.GetRoute().GetProvider()); got != "" {
		t.Fatalf("expected route provider header to be empty, got=%q", got)
	}
	if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
		t.Fatalf("unexpected hops count: got=%d want=%d", got, want)
	}
	if got, want := quote.GetRoute().GetHops()[0].GetGateway(), "crypto-gw-1"; got != want {
		t.Fatalf("unexpected source hop gateway: got=%q want=%q", got, want)
	}
	if got, want := quote.GetRoute().GetHops()[1].GetGateway(), "internal"; got != want {
		t.Fatalf("unexpected bridge hop gateway: got=%q want=%q", got, want)
	}
	if got, want := quote.GetRoute().GetHops()[2].GetGateway(), "payout-gw-1"; got != want {
		t.Fatalf("unexpected destination hop gateway: got=%q want=%q", got, want)
	}
	if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
		t.Fatalf("expected route settlement asset")
	}
	if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
		t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
	}
	if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "102.4"; got != want {
		t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want)
	}
}

func makeTransferIntent(
	t *testing.T,
	amount string,
	currency string,
	sourceWalletID string,
	destinationPAN string,
	destinationCountry string,
) *quotationv2.QuoteIntent {
	t.Helper()

	walletData, err := bson.Marshal(pkgmodel.WalletPaymentData{WalletID: sourceWalletID})
	if err != nil {
		t.Fatalf("failed to marshal wallet method data: %v", err)
	}
	cardData, err := bson.Marshal(pkgmodel.CardPaymentData{
		Pan:       destinationPAN,
		FirstName: "Ivan",
		LastName:  "Petrov",
		ExpMonth:  "12",
		ExpYear:   "2032",
		Country:   destinationCountry,
	})
	if err != nil {
		t.Fatalf("failed to marshal card method data: %v", err)
	}

	return &quotationv2.QuoteIntent{
		Source: &endpointv1.PaymentEndpoint{
			Source: &endpointv1.PaymentEndpoint_PaymentMethod{
				PaymentMethod: &endpointv1.PaymentMethod{
					Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
					Data: walletData,
				},
			},
		},
		Destination: &endpointv1.PaymentEndpoint{
			Source: &endpointv1.PaymentEndpoint_PaymentMethod{
				PaymentMethod: &endpointv1.PaymentMethod{
					Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
					Data: cardData,
				},
			},
		},
		Amount: &moneyv1.Money{Amount: amount, Currency: currency},
	}
}

func mustProtoJSON(t *testing.T, msg proto.Message) string {
	t.Helper()
	payload, err := protojson.MarshalOptions{Multiline: true, Indent: "  "}.Marshal(msg)
	if err != nil {
		t.Fatalf("failed to marshal proto json: %v", err)
	}
	return string(payload)
}

type fakeQuoteCore struct {
	now time.Time

	quoteRequestIdempotencyKeys []string
	resultTTL                   time.Duration
	fxTTL                       time.Duration
}

func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_service.BuildQuoteInput) (*quote_computation_service.ComputedQuote, time.Time, error) {
	if strings.TrimSpace(in.IdempotencyKey) != "" {
		f.quoteRequestIdempotencyKeys = append(f.quoteRequestIdempotencyKeys, strings.TrimSpace(in.IdempotencyKey))
	}
	if in.Route == nil {
		return nil, time.Time{}, fmt.Errorf("selected route is required for route-bound quote pricing")
	}
	if in.ExecutionConditions == nil {
		return nil, time.Time{}, fmt.Errorf("execution conditions are required for route-bound quote pricing")
	}
	if strings.TrimSpace(in.Route.GetRouteRef()) == "" {
		return nil, time.Time{}, fmt.Errorf("route_ref is required for route-bound quote pricing")
	}
	if strings.TrimSpace(in.Route.GetPricingProfileRef()) == "" {
		return nil, time.Time{}, fmt.Errorf("pricing_profile_ref is required for route-bound quote pricing")
	}
	if len(in.Route.GetHops()) == 0 {
		return nil, time.Time{}, fmt.Errorf("route hops are required for route-bound quote pricing")
	}

	intentAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount())
	amountCurrency := strings.ToUpper(strings.TrimSpace(in.Intent.Amount.GetCurrency()))
	rate := decimal.RequireFromString("91.5")

	baseCurrency := "USDT"
	quoteCurrency := "RUB"
	fxSide := fxv1.Side_SELL_BASE_BUY_QUOTE
	if in.Intent.FX != nil && in.Intent.FX.Pair != nil {
		if base := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetBase())); base != "" {
			baseCurrency = base
		}
		if quote := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetQuote())); quote != "" {
			quoteCurrency = quote
		}
		switch in.Intent.FX.Side {
		case paymenttypes.FXSideBuyBaseSellQuote:
			fxSide = fxv1.Side_BUY_BASE_SELL_QUOTE
		default:
			fxSide = fxv1.Side_SELL_BASE_BUY_QUOTE
		}
	}

	baseAmount := intentAmount
	quoteAmount := intentAmount.Mul(rate)
	switch {
	case strings.EqualFold(amountCurrency, quoteCurrency):
		quoteAmount = intentAmount
		baseAmount = intentAmount.Div(rate)
	case strings.EqualFold(amountCurrency, baseCurrency):
		baseAmount = intentAmount
		quoteAmount = intentAmount.Mul(rate)
	}

	payAmount := baseAmount
	payCurrency := baseCurrency
	settlementAmount := quoteAmount
	settlementCurrency := quoteCurrency
	if fxSide == fxv1.Side_BUY_BASE_SELL_QUOTE {
		payAmount = quoteAmount
		payCurrency = quoteCurrency
		settlementAmount = baseAmount
		settlementCurrency = baseCurrency
	}

	feeAmount := decimal.RequireFromString("1.50")
	taxAmount := decimal.RequireFromString("0.30")
	if routeFeeClass(in.Route) != "card_payout:3_hops:"+paymenttypes.DefaultCardsGatewayID {
		feeAmount = decimal.RequireFromString("2.00")
		taxAmount = decimal.RequireFromString("0.40")
	}

	quote := &quote_computation_service.ComputedQuote{
		DebitAmount: &moneyv1.Money{
			Amount:   payAmount.String(),
			Currency: payCurrency,
		},
		CreditAmount: &moneyv1.Money{
			Amount:   settlementAmount.String(),
			Currency: settlementCurrency,
		},
		FeeLines: []*feesv1.DerivedPostingLine{
			{
				LedgerAccountRef: "ledger:fees:usdt",
				Money:            &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: payCurrency},
				LineType:         accountingv1.PostingLineType_POSTING_LINE_FEE,
				Side:             accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
				Meta: map[string]string{
					"component": "platform_fee",
					"provider":  routeDestinationGateway(in.Route),
				},
			},
			{
				LedgerAccountRef: "ledger:tax:usdt",
				Money:            &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: payCurrency},
				LineType:         accountingv1.PostingLineType_POSTING_LINE_TAX,
				Side:             accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
				Meta: map[string]string{
					"component": "vat",
					"provider":  routeDestinationGateway(in.Route),
				},
			},
		},
		FeeRules: []*feesv1.AppliedRule{
			{
				RuleId:      "rule.platform.usdt." + strings.TrimSpace(in.Route.GetPricingProfileRef()),
				RuleVersion: "2026-02-01",
				Formula:     "flat(1.50)+tax(0.30)",
				Rounding:    moneyv1.RoundingMode_ROUND_HALF_UP,
				TaxCode:     "VAT",
				TaxRate:     "0.20",
				Parameters: map[string]string{
					"country": "RU",
				},
			},
		},
		Route:               cloneRouteSpecForTest(in.Route),
		ExecutionConditions: cloneExecutionConditionsForTest(in.ExecutionConditions),
		FXQuote: &oraclev1.Quote{
			QuoteRef: "fx-usdt-rub",
			Pair: &fxv1.CurrencyPair{
				Base:  baseCurrency,
				Quote: quoteCurrency,
			},
			Side:            fxSide,
			Price:           &moneyv1.Decimal{Value: rate.String()},
			BaseAmount:      &moneyv1.Money{Amount: baseAmount.String(), Currency: baseCurrency},
			QuoteAmount:     &moneyv1.Money{Amount: quoteAmount.String(), Currency: quoteCurrency},
			ExpiresAtUnixMs: f.now.Add(f.fxTTLValue()).UnixMilli(),
			Provider:        "test-oracle",
			RateRef:         "rate-usdt-rub",
			Firm:            true,
			PricedAt:        timestamppb.New(f.now),
		},
	}
	return quote, f.now.Add(f.resultTTLValue()), nil
}

func (f *fakeQuoteCore) resultTTLValue() time.Duration {
	if f == nil || f.resultTTL <= 0 {
		return 5 * time.Minute
	}
	return f.resultTTL
}

func (f *fakeQuoteCore) fxTTLValue() time.Duration {
	if f == nil || f.fxTTL <= 0 {
		return f.resultTTLValue()
	}
	return f.fxTTL
}

func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {
	if src == nil {
		return nil
	}
	result := &quotationv2.RouteSpecification{
		RouteRef:          strings.TrimSpace(src.GetRouteRef()),
		PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
		Settlement:        cloneRouteSettlementForTest(src.GetSettlement()),
	}
	if hops := src.GetHops(); len(hops) > 0 {
		result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
		for _, hop := range hops {
			if hop == nil {
				continue
			}
			result.Hops = append(result.Hops, &quotationv2.RouteHop{
				Index:      hop.GetIndex(),
				Rail:       strings.TrimSpace(hop.GetRail()),
				Gateway:    strings.TrimSpace(hop.GetGateway()),
				InstanceId: strings.TrimSpace(hop.GetInstanceId()),
				Network:    strings.TrimSpace(hop.GetNetwork()),
				Role:       hop.GetRole(),
			})
		}
		if len(result.Hops) == 0 {
			result.Hops = nil
		}
	}
	return result
}

func cloneRouteSettlementForTest(src *quotationv2.RouteSettlement) *quotationv2.RouteSettlement {
	if src == nil {
		return nil
	}
	result := &quotationv2.RouteSettlement{
		Model: strings.TrimSpace(src.GetModel()),
	}
	if asset := src.GetAsset(); asset != nil {
		key := asset.GetKey()
		result.Asset = &paymentv1.ChainAsset{
			Key: &paymentv1.ChainAssetKey{
				Chain:       strings.ToUpper(strings.TrimSpace(key.GetChain())),
				TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
			},
		}
		if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
			result.Asset.ContractAddress = &contract
		}
	}
	if result.Asset == nil && result.Model == "" {
		return nil
	}
	return result
}

func cloneExecutionConditionsForTest(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
	if src == nil {
		return nil
	}
	result := &quotationv2.ExecutionConditions{
		Readiness:                         src.GetReadiness(),
		BatchingEligible:                  src.GetBatchingEligible(),
		PrefundingRequired:                src.GetPrefundingRequired(),
		PrefundingCostIncluded:            src.GetPrefundingCostIncluded(),
		LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(),
		LatencyHint:                       strings.TrimSpace(src.GetLatencyHint()),
	}
	if assumptions := src.GetAssumptions(); len(assumptions) > 0 {
		result.Assumptions = make([]string, 0, len(assumptions))
		for _, assumption := range assumptions {
			trimmed := strings.TrimSpace(assumption)
			if trimmed == "" {
				continue
			}
			result.Assumptions = append(result.Assumptions, trimmed)
		}
		if len(result.Assumptions) == 0 {
			result.Assumptions = nil
		}
	}
	return result
}

func routeFeeClass(route *quotationv2.RouteSpecification) string {
	if route == nil {
		return ""
	}
	hops := route.GetHops()
	destRail := ""
	destGateway := ""
	if n := len(hops); n > 0 && hops[n-1] != nil {
		destRail = strings.ToLower(strings.TrimSpace(hops[n-1].GetRail()))
		destGateway = strings.ToLower(strings.TrimSpace(hops[n-1].GetGateway()))
	}
	return destRail +
		":" + fmt.Sprintf("%d_hops", len(hops)) +
		":" + destGateway
}

func routeDestinationGateway(route *quotationv2.RouteSpecification) string {
	if route == nil {
		return ""
	}
	hops := route.GetHops()
	if n := len(hops); n > 0 && hops[n-1] != nil {
		return strings.TrimSpace(hops[n-1].GetGateway())
	}
	return ""
}

type inMemoryQuotesStore struct {
	byRef map[string]*model.PaymentQuoteRecord
	byKey map[string]*model.PaymentQuoteRecord
}

type staticGatewayRegistryForE2E struct {
	items []*model.GatewayInstanceDescriptor
}

type staticManagedWalletResolverForE2E struct {
	assetsByRef map[string]*paymenttypes.Asset
}

func (r staticManagedWalletResolverForE2E) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) {
	if len(r.assetsByRef) == 0 {
		return nil, nil //nolint:nilnil // test double: empty map means no managed wallet asset
	}
	asset, ok := r.assetsByRef[strings.TrimSpace(managedWalletRef)]
	if !ok || asset == nil {
		return nil, nil //nolint:nilnil // test double: unknown wallet means no managed wallet asset
	}
	cloned := *asset
	return &cloned, nil
}

func (r staticManagedWalletResolverForE2E) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) {
	asset, err := r.ResolveManagedWalletAsset(ctx, managedWalletRef)
	if err != nil || asset == nil {
		return "", err
	}
	return strings.TrimSpace(asset.GetChain()), nil
}

func (r staticGatewayRegistryForE2E) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
	if len(r.items) == 0 {
		return nil, nil //nolint:nilnil // test double: empty registry means no gateways configured
	}
	out := make([]*model.GatewayInstanceDescriptor, 0, len(r.items))
	for _, item := range r.items {
		if item == nil {
			continue
		}
		cloned := *item
		if item.Currencies != nil {
			cloned.Currencies = append([]string(nil), item.Currencies...)
		}
		out = append(out, &cloned)
	}
	return out, nil
}

func newInMemoryQuotesStore() *inMemoryQuotesStore {
	return &inMemoryQuotesStore{
		byRef: map[string]*model.PaymentQuoteRecord{},
		byKey: map[string]*model.PaymentQuoteRecord{},
	}
}

func (s *inMemoryQuotesStore) Create(_ context.Context, quote *model.PaymentQuoteRecord) error {
	if quote == nil {
		return merrors.InvalidArgument("quote is required")
	}
	if _, exists := s.byRef[quote.QuoteRef]; exists {
		return quotestorage.ErrDuplicateQuote
	}
	if _, exists := s.byKey[quote.IdempotencyKey]; exists {
		return quotestorage.ErrDuplicateQuote
	}
	s.byRef[quote.QuoteRef] = quote
	s.byKey[quote.IdempotencyKey] = quote
	return nil
}

func (s *inMemoryQuotesStore) GetByRef(_ context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
	record, ok := s.byRef[strings.TrimSpace(quoteRef)]
	if !ok || record == nil || record.GetOrganizationRef() != orgRef {
		return nil, quotestorage.ErrQuoteNotFound
	}
	return record, nil
}

func (s *inMemoryQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
	record, ok := s.byKey[strings.TrimSpace(idempotencyKey)]
	if !ok || record == nil || record.GetOrganizationRef() != orgRef {
		return nil, quotestorage.ErrQuoteNotFound
	}
	return record, nil
}

func equalStrings(got, want []string) bool {
	if len(got) != len(want) {
		return false
	}
	for i := range got {
		if got[i] != want[i] {
			return false
		}
	}
	return true
}
