package routerrpc

import (
	"bytes"
	"encoding/hex"
	"testing"
	"time"

	"github.com/btcsuite/btcd/btcutil"
	"github.com/btcsuite/btcd/chaincfg"
	"github.com/lightningnetwork/lnd/lnmock"
	"github.com/lightningnetwork/lnd/lnrpc"
	"github.com/lightningnetwork/lnd/lnwire"
	"github.com/lightningnetwork/lnd/record"
	"github.com/lightningnetwork/lnd/routing"
	"github.com/lightningnetwork/lnd/routing/route"
	"github.com/stretchr/testify/require"
)

const (
	destKey       = "0286098b97bc843372b4426d4b276cea9aa2f48f0428d6f5b66ae101befc14f8b4"
	ignoreNodeKey = "02f274f48f3c0d590449a6776e3ce8825076ac376e470e992246eebc565ef8bb2a"
	hintNodeKey   = "0274e7fb33eafd74fe1acb6db7680bb4aa78e9c839a6e954e38abfad680f645ef7"

	testMissionControlProb = 0.5
)

var (
	sourceKey = route.Vertex{1, 2, 3}

	node1 = route.Vertex{10}

	node2 = route.Vertex{11}
)

// TestUnmarshallHopBlindedFieldsRequireEncryptedData verifies that callers
// cannot submit partial blinded-hop data through SendToRouteV2.
func TestUnmarshallHopBlindedFieldsRequireEncryptedData(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name    string
		hop     *lnrpc.Hop
		errText string
	}{
		{
			name: "total amount without encrypted data",
			hop: &lnrpc.Hop{
				TotalAmtMsat: 1000,
			},
			errText: "encrypted data should be present",
		},
		{
			name: "total amount with encrypted data",
			hop: &lnrpc.Hop{
				TotalAmtMsat:  1000,
				EncryptedData: []byte{1},
			},
		},
		{
			name: "ordinary hop",
			hop:  &lnrpc.Hop{},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()

			_, err := UnmarshallHopWithPubkey(test.hop, node1)
			if test.errText != "" {
				require.ErrorContains(t, err, test.errText)
				return
			}

			require.NoError(t, err)
		})
	}
}

// TestUnmarshallRouteBlindedTotalAmountFinalHop verifies that the blinded
// total amount is only accepted on the final hop of a caller-provided route.
func TestUnmarshallRouteBlindedTotalAmountFinalHop(t *testing.T) {
	t.Parallel()

	blindedHop := func() *lnrpc.Hop {
		return &lnrpc.Hop{
			PubKey:        destKey,
			EncryptedData: []byte{1},
			TotalAmtMsat:  1000,
		}
	}
	regularHop := func() *lnrpc.Hop {
		return &lnrpc.Hop{
			PubKey: destKey,
		}
	}

	tests := []struct {
		name    string
		hops    []*lnrpc.Hop
		errText string
	}{
		{
			name: "intermediate hop",
			hops: []*lnrpc.Hop{
				blindedHop(), regularHop(),
			},
			errText: "blinded total amount can only be provided " +
				"for the final hop",
		},
		{
			name: "final hop",
			hops: []*lnrpc.Hop{
				regularHop(), blindedHop(),
			},
		},
	}

	backend := &RouterBackend{
		SelfNode: sourceKey,
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()

			_, err := backend.UnmarshallRoute(&lnrpc.Route{
				Hops: test.hops,
			})
			if test.errText != "" {
				require.ErrorContains(t, err, test.errText)
				return
			}

			require.NoError(t, err)
		})
	}
}

// TestQueryRoutes asserts that query routes rpc parameters are properly parsed
// and passed onto path finding.
func TestQueryRoutes(t *testing.T) {
	t.Run("no mission control", func(t *testing.T) {
		testQueryRoutes(t, false, false, true)
	})
	t.Run("no mission control and msat", func(t *testing.T) {
		testQueryRoutes(t, false, true, true)
	})
	t.Run("with mission control", func(t *testing.T) {
		testQueryRoutes(t, true, false, true)
	})
	t.Run("no mission control bad cltv limit", func(t *testing.T) {
		testQueryRoutes(t, false, false, false)
	})
}

func testQueryRoutes(t *testing.T, useMissionControl bool, useMsat bool,
	setTimelock bool) {

	ignoreNodeBytes, err := hex.DecodeString(ignoreNodeKey)
	if err != nil {
		t.Fatal(err)
	}

	var ignoreNodeVertex route.Vertex
	copy(ignoreNodeVertex[:], ignoreNodeBytes)

	destNodeBytes, err := hex.DecodeString(destKey)
	if err != nil {
		t.Fatal(err)
	}

	var (
		lastHop         = route.Vertex{64}
		outgoingChanIds = []uint64{383322, 383323}
	)

	hintNode, err := route.NewVertexFromStr(hintNodeKey)
	if err != nil {
		t.Fatal(err)
	}

	rpcRouteHints := []*lnrpc.RouteHint{
		{
			HopHints: []*lnrpc.HopHint{
				{
					ChanId: 38484,
					NodeId: hintNodeKey,
				},
			},
		},
	}

	request := &lnrpc.QueryRoutesRequest{
		PubKey:         destKey,
		FinalCltvDelta: 100,
		IgnoredNodes:   [][]byte{ignoreNodeBytes},
		IgnoredEdges: []*lnrpc.EdgeLocator{{
			ChannelId:        555,
			DirectionReverse: true,
		}},
		IgnoredPairs: []*lnrpc.NodePair{{
			From: node1[:],
			To:   node2[:],
		}},
		UseMissionControl: useMissionControl,
		LastHopPubkey:     lastHop[:],
		DestFeatures:      []lnrpc.FeatureBit{lnrpc.FeatureBit_MPP_OPT},
		RouteHints:        rpcRouteHints,
	}

	amtSat := int64(100000)
	if useMsat {
		request.AmtMsat = amtSat * 1000
		request.FeeLimit = &lnrpc.FeeLimit{
			Limit: &lnrpc.FeeLimit_FixedMsat{
				FixedMsat: 250000,
			},
		}
	} else {
		request.Amt = amtSat
		request.FeeLimit = &lnrpc.FeeLimit{
			Limit: &lnrpc.FeeLimit_Fixed{
				Fixed: 250,
			},
		}
	}

	request.OutgoingChanIds = outgoingChanIds

	findRoute := func(req *routing.RouteRequest) (*route.Route, float64,
		error) {

		if int64(req.Amount) != amtSat*1000 {
			t.Fatal("unexpected amount")
		}

		if req.Source != sourceKey {
			t.Fatal("unexpected source key")
		}

		target := req.Target
		if !bytes.Equal(target[:], destNodeBytes) {
			t.Fatal("unexpected target key")
		}

		restrictions := req.Restrictions
		if restrictions.FeeLimit != 250*1000 {
			t.Fatal("unexpected fee limit")
		}

		if restrictions.ProbabilitySource(route.Vertex{2},
			route.Vertex{1}, 0, 0,
		) != 0 {
			t.Fatal("expecting 0% probability for ignored edge")
		}

		if restrictions.ProbabilitySource(ignoreNodeVertex,
			route.Vertex{6}, 0, 0,
		) != 0 {
			t.Fatal("expecting 0% probability for ignored node")
		}

		if restrictions.ProbabilitySource(node1, node2, 0, 0) != 0 {
			t.Fatal("expecting 0% probability for ignored pair")
		}

		if *restrictions.LastHop != lastHop {
			t.Fatal("unexpected last hop")
		}

		require.Equal(
			t, restrictions.OutgoingChannelIDs, outgoingChanIds,
		)

		if !restrictions.DestFeatures.HasFeature(lnwire.MPPOptional) {
			t.Fatal("unexpected dest features")
		}

		routeHints := req.RouteHints
		if _, ok := routeHints[hintNode]; !ok {
			t.Fatal("expected route hint")
		}

		expectedProb := 1.0
		if useMissionControl {
			expectedProb = testMissionControlProb
		}
		if restrictions.ProbabilitySource(route.Vertex{4},
			route.Vertex{5}, 0, 0,
		) != expectedProb {
			t.Fatal("expecting 100% probability")
		}

		hops := []*route.Hop{{}}
		route, err := route.NewRouteFromHops(
			req.Amount, 144, req.Source, hops,
		)

		return route, expectedProb, err
	}

	backend := &RouterBackend{
		FindRoute: findRoute,
		SelfNode:  route.Vertex{1, 2, 3},
		FetchChannelCapacity: func(chanID uint64) (
			btcutil.Amount, error) {

			return 1, nil
		},
		FetchAmountPairCapacity: func(nodeFrom, nodeTo route.Vertex,
			amount lnwire.MilliSatoshi) (btcutil.Amount, error) {

			return 1, nil
		},
		MissionControl: &mockMissionControl{},
		FetchChannelEndpoints: func(chanID uint64) (route.Vertex,
			route.Vertex, error) {

			if chanID != 555 {
				t.Fatalf("expected endpoints to be fetched for "+
					"channel 555, but got %v instead",
					chanID)
			}
			return route.Vertex{1}, route.Vertex{2}, nil
		},
	}

	// If this is set, we'll populate MaxTotalTimelock. If this is not set,
	// the test will fail as CltvLimit will be 0.
	if setTimelock {
		backend.MaxTotalTimelock = 1000
	}

	resp, err := backend.QueryRoutes(t.Context(), request)

	// If no MaxTotalTimelock was set for the QueryRoutes request, make
	// sure an error was returned.
	if !setTimelock {
		require.NotEmpty(t, err)
		return
	}

	if err != nil {
		t.Fatal(err)
	}
	if len(resp.Routes) != 1 {
		t.Fatal("expected a single route response")
	}
}

type mockMissionControl struct {
	MissionControl
}

func (m *mockMissionControl) GetProbability(fromNode, toNode route.Vertex,
	amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 {

	return testMissionControlProb
}

func (m *mockMissionControl) ResetHistory() error {
	return nil
}

func (m *mockMissionControl) GetHistorySnapshot() *routing.MissionControlSnapshot {
	return nil
}

func (m *mockMissionControl) GetPairHistorySnapshot(fromNode,
	toNode route.Vertex) routing.TimedPairResult {

	return routing.TimedPairResult{}
}

type recordParseOutcome byte

const (
	valid recordParseOutcome = iota
	invalid
	norecord
)

type unmarshalMPPTest struct {
	name    string
	mpp     *lnrpc.MPPRecord
	outcome recordParseOutcome
}

// TestUnmarshalMPP checks both positive and negative cases of UnmarshalMPP to
// assert that an MPP record is only returned when both fields are properly
// specified. It also asserts that zero-values for both inputs is also valid,
// but returns a nil record.
func TestUnmarshalMPP(t *testing.T) {
	tests := []unmarshalMPPTest{
		{
			name:    "nil record",
			mpp:     nil,
			outcome: norecord,
		},
		{
			name: "invalid total or addr",
			mpp: &lnrpc.MPPRecord{
				PaymentAddr:  nil,
				TotalAmtMsat: 0,
			},
			outcome: invalid,
		},
		{
			name: "valid total only",
			mpp: &lnrpc.MPPRecord{
				PaymentAddr:  nil,
				TotalAmtMsat: 8,
			},
			outcome: invalid,
		},
		{
			name: "valid addr only",
			mpp: &lnrpc.MPPRecord{
				PaymentAddr:  bytes.Repeat([]byte{0x02}, 32),
				TotalAmtMsat: 0,
			},
			outcome: invalid,
		},
		{
			name: "valid total and invalid addr",
			mpp: &lnrpc.MPPRecord{
				PaymentAddr:  []byte{0x02},
				TotalAmtMsat: 8,
			},
			outcome: invalid,
		},
		{
			name: "valid total and valid addr",
			mpp: &lnrpc.MPPRecord{
				PaymentAddr:  bytes.Repeat([]byte{0x02}, 32),
				TotalAmtMsat: 8,
			},
			outcome: valid,
		},
	}

	for _, test := range tests {
		test := test
		t.Run(test.name, func(t *testing.T) {
			testUnmarshalMPP(t, test)
		})
	}
}

func testUnmarshalMPP(t *testing.T, test unmarshalMPPTest) {
	mpp, err := UnmarshalMPP(test.mpp)
	switch test.outcome {
	// Valid arguments should result in no error, a non-nil MPP record, and
	// the fields should be set correctly.
	case valid:
		if err != nil {
			t.Fatalf("unable to parse mpp record: %v", err)
		}
		if mpp == nil {
			t.Fatalf("mpp payload should be non-nil")
		}
		if int64(mpp.TotalMsat()) != test.mpp.TotalAmtMsat {
			t.Fatalf("incorrect total msat")
		}
		addr := mpp.PaymentAddr()
		if !bytes.Equal(addr[:], test.mpp.PaymentAddr) {
			t.Fatalf("incorrect payment addr")
		}

	// Invalid arguments should produce a failure and nil MPP record.
	case invalid:
		if err == nil {
			t.Fatalf("expected failure for invalid mpp")
		}
		if mpp != nil {
			t.Fatalf("mpp payload should be nil for failure")
		}

	// Arguments that produce no MPP field should return no error and no MPP
	// record.
	case norecord:
		if err != nil {
			t.Fatalf("failure for args resulting for no-mpp")
		}
		if mpp != nil {
			t.Fatalf("mpp payload should be nil for no-mpp")
		}

	default:
		t.Fatalf("test case has non-standard outcome")
	}
}

type unmarshalAMPTest struct {
	name    string
	amp     *lnrpc.AMPRecord
	outcome recordParseOutcome
}

// TestUnmarshalAMP asserts the behavior of decoding an RPC AMPRecord.
func TestUnmarshalAMP(t *testing.T) {
	rootShare := bytes.Repeat([]byte{0x01}, 32)
	setID := bytes.Repeat([]byte{0x02}, 32)

	// All child indexes are valid.
	childIndex := uint32(3)

	tests := []unmarshalAMPTest{
		{
			name:    "nil record",
			amp:     nil,
			outcome: norecord,
		},
		{
			name: "invalid root share invalid set id",
			amp: &lnrpc.AMPRecord{
				RootShare:  []byte{0x01},
				SetId:      []byte{0x02},
				ChildIndex: childIndex,
			},
			outcome: invalid,
		},
		{
			name: "valid root share invalid set id",
			amp: &lnrpc.AMPRecord{
				RootShare:  rootShare,
				SetId:      []byte{0x02},
				ChildIndex: childIndex,
			},
			outcome: invalid,
		},
		{
			name: "invalid root share valid set id",
			amp: &lnrpc.AMPRecord{
				RootShare:  []byte{0x01},
				SetId:      setID,
				ChildIndex: childIndex,
			},
			outcome: invalid,
		},
		{
			name: "valid root share valid set id",
			amp: &lnrpc.AMPRecord{
				RootShare:  rootShare,
				SetId:      setID,
				ChildIndex: childIndex,
			},
			outcome: valid,
		},
	}

	for _, test := range tests {
		test := test
		t.Run(test.name, func(t *testing.T) {
			testUnmarshalAMP(t, test)
		})
	}
}

func testUnmarshalAMP(t *testing.T, test unmarshalAMPTest) {
	amp, err := UnmarshalAMP(test.amp)
	switch test.outcome {
	case valid:
		require.NoError(t, err)
		require.NotNil(t, amp)

		rootShare := amp.RootShare()
		setID := amp.SetID()
		require.Equal(t, test.amp.RootShare, rootShare[:])
		require.Equal(t, test.amp.SetId, setID[:])
		require.Equal(t, test.amp.ChildIndex, amp.ChildIndex())

	case invalid:
		require.Error(t, err)
		require.Nil(t, amp)

	case norecord:
		require.NoError(t, err)
		require.Nil(t, amp)

	default:
		t.Fatalf("test case has non-standard outcome")
	}
}

// extractIntentTestCase defines a test case for the
// TestExtractIntentFromSendRequest function. It includes the test name, the
// RouterBackend instance, the SendPaymentRequest to be tested, a boolean
// indicating if the test case is valid, and the expected error message if
// applicable.
type extractIntentTestCase struct {
	name             string
	backend          *RouterBackend
	sendReq          *SendPaymentRequest
	valid            bool
	expectedErrorMsg string
}

// TestExtractIntentFromSendRequest verifies that extractIntentFromSendRequest
// correctly translates a SendPaymentRequest from an RPC client into a
// LightningPayment intent.
func TestExtractIntentFromSendRequest(t *testing.T) {
	const paymentAmount = btcutil.Amount(300_000)

	const paymentReq = "lnbcrt500u1pnh0xflpp56w08q26t896vg2e9mtdkrem320tp" +
		"wws9z9sfr7dw86dx97d90u4sdqqcqzzsxqyz5vqsp5z9945kvfy5g9afmakz" +
		"yrur2t4hhn2tr87un8j0r0e6l5m5zm0fus9qxpqysgqk98c6j7qefdpdmzt4" +
		"g6aykds4ydvf2x9lpngqcfux3hv8qlraan9v3s9296r5w5eh959yzadgh5ck" +
		"gjydgyfxdpumxtuk3p3caugmlqpz5necs"

	const paymentReqMissingAddr = "lnbcrt100p1p70xwfzpp5qqqsyqcyq5rqwzqfq" +
		"qqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kge" +
		"tjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaqnp4q0n326hr8v9zprg8" +
		"gsvezcch06gfaqqhde2aj730yg0durunfhv669qypqqqz3uu8wnr7883qzxr" +
		"566nuhled49fx6e6q0jn06w6gpgyznwzxwf8xdmye87kpx0y8lqtcgwywsau" +
		"0jkm66evelkw7cggwlegp4anv3cq62wusm"

	destNodeBytes, err := hex.DecodeString(destKey)
	require.NoError(t, err)

	target, err := route.NewVertexFromBytes(destNodeBytes)
	require.NoError(t, err)

	mockClock := &lnmock.MockClock{}
	mockClock.On("Now").Return(time.Date(2025, 3, 1, 13, 0, 0, 0, time.UTC))

	testCases := []extractIntentTestCase{
		{
			name:    "Time preference out of range",
			backend: &RouterBackend{},
			sendReq: &SendPaymentRequest{
				TimePref: 2,
			},
			valid:            false,
			expectedErrorMsg: "time preference out of range",
		},
		{
			name:    "Invalid last hop pubkey length",
			backend: &RouterBackend{},
			sendReq: &SendPaymentRequest{
				LastHopPubkey: []byte{1},
			},
			valid:            false,
			expectedErrorMsg: "invalid vertex length",
		},
		{
			name: "total time lock exceeds max allowed",
			backend: &RouterBackend{
				MaxTotalTimelock: 1000,
			},
			sendReq: &SendPaymentRequest{
				CltvLimit: 1001,
			},
			valid: false,
			expectedErrorMsg: "total time lock of 1001 exceeds " +
				"max allowed 1000",
		},
		{
			name:    "Max parts exceed allowed limit",
			backend: &RouterBackend{},
			sendReq: &SendPaymentRequest{
				MaxParts:         1001,
				MaxShardSizeMsat: 300_000,
			},
			valid: false,
			expectedErrorMsg: "requested max_parts (1001) exceeds" +
				" the allowed upper limit",
		},
		{
			name: "Fee limit conflict, both sat and msat " +
				"specified",
			backend: &RouterBackend{},
			sendReq: &SendPaymentRequest{
				FeeLimitSat:  1000000,
				FeeLimitMsat: 1000000000,
			},
			valid: false,
			expectedErrorMsg: "sat and msat arguments are " +
				"mutually exclusive",
		},
		{
			name:    "Fee limit cannot be negative",
			backend: &RouterBackend{},
			sendReq: &SendPaymentRequest{
				FeeLimitSat: -1,
			},
			valid:            false,
			expectedErrorMsg: "amount cannot be negative",
		},
		{
			name: "Dest custom records with type below minimum" +
				" range",
			backend: &RouterBackend{},
			sendReq: &SendPaymentRequest{
				DestCustomRecords: map[uint64][]byte{
					65530: {1, 2},
				},
			},
			valid:            false,
			expectedErrorMsg: "no custom records with types below",
		},
		{
			name:    "MPP params with keysend payments",
			backend: &RouterBackend{},
			sendReq: &SendPaymentRequest{
				DestCustomRecords: map[uint64][]byte{
					record.KeySendType: {1, 2},
				},
				MaxShardSizeMsat: 300_000,
			},
			valid: false,
			expectedErrorMsg: "MPP not supported with keysend " +
				"payments",
		},
		{
			name: "Custom record entry with TLV type below " +
				"minimum range",
			backend: &RouterBackend{},
			sendReq: &SendPaymentRequest{
				FirstHopCustomRecords: map[uint64][]byte{
					65530: {1, 2},
				},
			},
			valid:            false,
			expectedErrorMsg: "custom records entry with TLV type",
		},
		{
			name: "Amount conflict, both sat and msat specified",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return true
				},
			},
			sendReq: &SendPaymentRequest{
				Amt:     int64(paymentAmount),
				AmtMsat: int64(paymentAmount) * 1000,
			},
			valid: false,
			expectedErrorMsg: "sat and msat arguments are " +
				"mutually exclusive",
		},
		{
			name: "Both dest and payment_request provided",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Amt:            int64(paymentAmount),
				PaymentRequest: "test",
				Dest:           destNodeBytes,
			},
			valid: false,
			expectedErrorMsg: "dest and payment_request " +
				"cannot appear together",
		},
		{
			name: "Both payment_hash and payment_request provided",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Amt:            int64(paymentAmount),
				PaymentRequest: "test",
				PaymentHash:    make([]byte, 32),
			},
			valid: false,
			expectedErrorMsg: "payment_hash and payment_request " +
				"cannot appear together",
		},
		{
			name: "Both final_cltv_delta and payment_request " +
				"provided",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Amt:            int64(paymentAmount),
				PaymentRequest: "test",
				FinalCltvDelta: 100,
			},
			valid: false,
			expectedErrorMsg: "final_cltv_delta and " +
				"payment_request cannot appear together",
		},
		{
			name: "Invalid payment request length",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
				ActiveNetParams: &chaincfg.RegressionNetParams,
			},
			sendReq: &SendPaymentRequest{
				Amt:            int64(paymentAmount),
				PaymentRequest: "test",
			},
			valid:            false,
			expectedErrorMsg: "invalid bech32 string length",
		},
		{
			name: "Expired invoice payment request",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
				ActiveNetParams: &chaincfg.RegressionNetParams,
				Clock:           mockClock,
			},
			sendReq: &SendPaymentRequest{
				Amt:            int64(paymentAmount),
				PaymentRequest: paymentReq,
			},
			valid:            false,
			expectedErrorMsg: "invoice expired.",
		},
		{
			name: "Invoice missing payment address",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
				ActiveNetParams:  &chaincfg.RegressionNetParams,
				MaxTotalTimelock: 1000,
				Clock:            mockClock,
			},
			sendReq: &SendPaymentRequest{
				PaymentRequest: paymentReqMissingAddr,
			},
			valid: false,
			expectedErrorMsg: "payment request must contain " +
				"either a payment address or blinded paths",
		},
		{
			name: "Invalid dest vertex length",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Amt:  int64(paymentAmount),
				Dest: []byte{1},
			},
			valid:            false,
			expectedErrorMsg: "invalid vertex length",
		},
		{
			name: "Payment request with missing amount",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Dest:           destNodeBytes,
				FinalCltvDelta: 100,
			},
			valid:            false,
			expectedErrorMsg: "amount must be specified",
		},
		{
			name: "Destination lacks AMP support",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Dest:         destNodeBytes,
				Amt:          int64(paymentAmount),
				Amp:          true,
				DestFeatures: []lnrpc.FeatureBit{},
			},
			valid: false,
			expectedErrorMsg: "destination doesn't " +
				"support AMP payments",
		},
		{
			name: "Invalid payment hash length",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Dest:        destNodeBytes,
				Amt:         int64(paymentAmount),
				PaymentHash: make([]byte, 1),
			},
			valid:            false,
			expectedErrorMsg: "invalid hash length",
		},
		{
			name: "Payment amount exceeds maximum possible amount",
			backend: &RouterBackend{
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Dest:             destNodeBytes,
				Amt:              int64(paymentAmount),
				PaymentHash:      make([]byte, 32),
				MaxParts:         10,
				MaxShardSizeMsat: 300_000,
			},
			valid: false,
			expectedErrorMsg: "payment amount 300000000 mSAT " +
				"exceeds maximum possible amount",
		},
		{
			name: "Reject self-payments if not permitted",
			backend: &RouterBackend{
				MaxTotalTimelock: 1000,
				ShouldSetExpAccountability: func() bool {
					return false
				},
				SelfNode: target,
			},
			sendReq: &SendPaymentRequest{
				Dest:        destNodeBytes,
				Amt:         int64(paymentAmount),
				PaymentHash: make([]byte, 32),
			},
			valid:            false,
			expectedErrorMsg: "self-payments not allowed",
		},
		{
			name: "Required and optional feature bits set",
			backend: &RouterBackend{
				MaxTotalTimelock: 1000,
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Dest:             destNodeBytes,
				Amt:              int64(paymentAmount),
				PaymentHash:      make([]byte, 32),
				MaxParts:         10,
				MaxShardSizeMsat: 30_000_000,
				DestFeatures: []lnrpc.FeatureBit{
					lnrpc.FeatureBit_GOSSIP_QUERIES_OPT,
					lnrpc.FeatureBit_GOSSIP_QUERIES_REQ,
				},
			},
			valid: true,
		},
		{
			name: "Valid send req parameters, payment settled",
			backend: &RouterBackend{
				MaxTotalTimelock: 1000,
				ShouldSetExpAccountability: func() bool {
					return false
				},
			},
			sendReq: &SendPaymentRequest{
				Dest:             destNodeBytes,
				Amt:              int64(paymentAmount),
				PaymentHash:      make([]byte, 32),
				MaxParts:         10,
				MaxShardSizeMsat: 30_000_000,
			},
			valid: true,
		},
	}

	for _, test := range testCases {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()

			_, err := test.backend.
				extractIntentFromSendRequest(test.sendReq)

			if test.valid {
				require.NoError(t, err)
			} else {
				require.ErrorContains(t, err,
					test.expectedErrorMsg)
			}
		})
	}
}

// TestMarshallRouteChanCapacity verifies that MarshallRoute correctly sets the
// ChanCapacity for each hop based on the incoming amount at that hop, not
// the total route amount. This is a regression test to ensure the
// incomingAmt is updated per hop.
func TestMarshallRouteChanCapacity(t *testing.T) {
	t.Parallel()

	// Build a two-hop route: source -> hop1 -> hop2 -> dest.
	//
	// TotalAmount (incoming to hop1) = 1000 msat
	// hop1.AmtToForward (incoming to hop2) = 900 msat (after fee)
	const (
		totalAmtMsat = lnwire.MilliSatoshi(1000)
		hop1Forward  = lnwire.MilliSatoshi(900)
		hop2Forward  = lnwire.MilliSatoshi(900)
	)

	hops := []*route.Hop{
		{
			ChannelID:    1,
			AmtToForward: hop1Forward,
			PubKeyBytes:  node1,
		},
		{
			ChannelID:    2,
			AmtToForward: hop2Forward,
			PubKeyBytes:  node2,
		},
	}

	r, err := route.NewRouteFromHops(totalAmtMsat, 100, sourceKey, hops)
	require.NoError(t, err)

	backend := &RouterBackend{}
	rpcRoute, err := backend.MarshallRoute(r)
	require.NoError(t, err)
	require.Len(t, rpcRoute.Hops, 2)

	// The first hop's capacity should reflect the total incoming amount
	// (route.TotalAmount), converted to satoshis.
	require.EqualValues(
		t, totalAmtMsat.ToSatoshis(), rpcRoute.Hops[0].ChanCapacity,
	)

	// The second hop's capacity should reflect hop1's forwarded amount, not
	// the total route amount. Before the fix, both hops incorrectly used
	// the total route amount.
	require.EqualValues(
		t, hop1Forward.ToSatoshis(), rpcRoute.Hops[1].ChanCapacity,
	)
}
