package loopd

import (
	"context"
	"fmt"
	"os"
	"testing"
	"time"

	"github.com/btcsuite/btcd/btcec/v2/schnorr"
	"github.com/btcsuite/btcd/btcutil"
	"github.com/btcsuite/btcd/chaincfg"
	"github.com/btcsuite/btcd/chaincfg/chainhash"
	"github.com/btcsuite/btcd/wire"
	"github.com/btcsuite/btclog/v2"
	"github.com/lightninglabs/lndclient"
	"github.com/lightninglabs/loop"
	"github.com/lightninglabs/loop/fsm"
	"github.com/lightninglabs/loop/labels"
	"github.com/lightninglabs/loop/liquidity"
	"github.com/lightninglabs/loop/loopdb"
	"github.com/lightninglabs/loop/looprpc"
	"github.com/lightninglabs/loop/staticaddr/address"
	"github.com/lightninglabs/loop/staticaddr/deposit"
	"github.com/lightninglabs/loop/staticaddr/loopin"
	"github.com/lightninglabs/loop/staticaddr/script"
	"github.com/lightninglabs/loop/swap"
	mock_lnd "github.com/lightninglabs/loop/test"
	"github.com/lightningnetwork/lnd/input"
	"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
	"github.com/lightningnetwork/lnd/lntypes"
	"github.com/lightningnetwork/lnd/lnwallet"
	"github.com/lightningnetwork/lnd/lnwire"
	"github.com/lightningnetwork/lnd/routing/route"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"
)

var (
	testnetAddr, _ = btcutil.NewAddressScriptHash(
		[]byte{123}, &chaincfg.TestNet3Params,
	)

	mainnetAddr, _ = btcutil.NewAddressScriptHash(
		[]byte{123}, &chaincfg.MainNetParams,
	)

	nodepubkeyAddr, _ = btcutil.DecodeAddress(
		mock_lnd.NewMockLnd().NodePubkey, &chaincfg.MainNetParams,
	)

	chanID1 = lnwire.NewShortChanIDFromInt(1)
	chanID2 = lnwire.NewShortChanIDFromInt(2)
	chanID3 = lnwire.NewShortChanIDFromInt(3)
	chanID4 = lnwire.NewShortChanIDFromInt(4)

	peer1 = route.Vertex{1}
	peer2 = route.Vertex{2}

	channel1 = lndclient.ChannelInfo{
		Active:        false,
		ChannelID:     chanID1.ToUint64(),
		PubKeyBytes:   peer1,
		LocalBalance:  10000,
		RemoteBalance: 0,
		Capacity:      10000,
	}

	channel2 = lndclient.ChannelInfo{
		Active:        true,
		ChannelID:     chanID2.ToUint64(),
		PubKeyBytes:   peer2,
		LocalBalance:  10000,
		RemoteBalance: 0,
		Capacity:      10000,
	}

	channel3 = lndclient.ChannelInfo{
		Active:        true,
		ChannelID:     chanID3.ToUint64(),
		PubKeyBytes:   peer2,
		LocalBalance:  10000,
		RemoteBalance: 0,
		Capacity:      10000,
	}

	channel4 = lndclient.ChannelInfo{
		Active:        true,
		ChannelID:     chanID4.ToUint64(),
		PubKeyBytes:   peer2,
		LocalBalance:  1000,
		RemoteBalance: 0,
		Capacity:      1000,
	}
)

// TestValidateConfTarget tests all failure and success cases for our conf
// target validation function, including the case where we replace a zero
// target with the default provided.
func TestValidateConfTarget(t *testing.T) {
	const (
		// Various input confirmation values for tests.
		zeroConf int32 = 0
		oneConf  int32 = 1
		twoConf  int32 = 2
		fiveConf int32 = 5

		// defaultConf is the default confirmation target we use for
		// all tests.
		defaultConf = 6
	)

	tests := []struct {
		name           string
		confTarget     int32
		expectedTarget int32
		expectErr      bool
	}{
		{
			name:           "zero conf, get default",
			confTarget:     zeroConf,
			expectedTarget: defaultConf,
			expectErr:      false,
		},
		{
			name:       "one conf, get error",
			confTarget: oneConf,
			expectErr:  true,
		},
		{
			name:           "two conf, ok",
			confTarget:     twoConf,
			expectedTarget: twoConf,
			expectErr:      false,
		},
		{
			name:           "five conf, ok",
			confTarget:     fiveConf,
			expectedTarget: fiveConf,
			expectErr:      false,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			target, err := validateConfTarget(
				test.confTarget, defaultConf,
			)

			if test.expectErr {
				require.Error(t, err)
			} else {
				require.NoError(t, err)
			}

			require.Equal(t, test.expectedTarget, target)
		})
	}
}

// TestValidateLoopInRequest tests validation of loop in requests.
func TestValidateLoopInRequest(t *testing.T) {
	tests := []struct {
		name               string
		amount             int64
		numDeposits        uint32
		external           bool
		confTarget         int32
		autoSelectDeposits bool
		expectErr          bool
		expectedTarget     int32
	}{
		{
			name:           "external and htlc conf set",
			amount:         100_000,
			external:       true,
			confTarget:     1,
			expectErr:      true,
			expectedTarget: 0,
		},
		{
			name:           "external and no conf",
			amount:         100_000,
			external:       true,
			confTarget:     0,
			expectErr:      false,
			expectedTarget: 0,
		},
		{
			name:           "not external, zero conf",
			amount:         100_000,
			external:       false,
			confTarget:     0,
			expectErr:      false,
			expectedTarget: loop.DefaultHtlcConfTarget,
		},
		{
			name:           "not external, bad conf",
			amount:         100_000,
			external:       false,
			confTarget:     1,
			expectErr:      true,
			expectedTarget: 0,
		},
		{
			name:           "not external, ok conf",
			amount:         100_000,
			external:       false,
			confTarget:     5,
			expectErr:      false,
			expectedTarget: 5,
		},
		{
			name:           "not external, amount no deposit",
			amount:         100_000,
			numDeposits:    0,
			external:       false,
			expectErr:      false,
			expectedTarget: loop.DefaultHtlcConfTarget,
		},
		{
			name:        "not external, deposit no amount",
			amount:      100_000,
			numDeposits: 1,
			external:    false,
			expectErr:   false,
		},

		{
			name:        "not external, deposit fractional amount",
			amount:      100_000,
			numDeposits: 1,
			external:    false,
			expectErr:   false,
		},
		{
			name:               "amount with deposit coin select",
			amount:             100_000,
			autoSelectDeposits: true,
			external:           false,
			expectErr:          false,
		},
		{
			name:               "amount with deposit coin select",
			numDeposits:        1,
			autoSelectDeposits: true,
			external:           false,
			expectErr:          true,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			external := test.external
			conf, err := validateLoopInRequest(
				test.confTarget, external, test.numDeposits,
				btcutil.Amount(test.amount),
				test.autoSelectDeposits,
			)

			if test.expectErr {
				require.Error(t, err)
			} else {
				require.NoError(t, err)
			}

			require.Equal(t, test.expectedTarget, conf)
		})
	}
}

// TestStaticAddressLoopInRejectsReservedLabel verifies that external static
// loop-in requests still reject reserved autoloop labels at the RPC boundary.
func TestStaticAddressLoopInRejectsReservedLabel(t *testing.T) {
	logger := btclog.NewSLogger(
		btclog.NewDefaultHandler(os.Stdout),
	)
	setLogger(logger.SubSystem(Subsystem))

	server := &swapClientServer{}

	_, err := server.StaticAddressLoopIn(
		t.Context(), &looprpc.StaticAddressLoopInRequest{
			Label: labels.AutoloopLabel(swap.TypeIn),
		},
	)
	require.ErrorContains(t, err, labels.ErrReservedPrefix.Error())
}

// TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental verifies that
// users must restart loopd with --experimental before enabling static-address
// autoloop.
func TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental(
	t *testing.T) {

	server := &swapClientServer{
		config: &Config{},
	}

	_, err := server.SetLiquidityParams(
		t.Context(), &looprpc.SetLiquidityParamsRequest{
			Parameters: &looprpc.LiquidityParameters{
				LoopInSource: looprpc.
					LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS,
			},
		},
	)
	require.Error(t, err)
	require.Equal(t, codes.FailedPrecondition, status.Code(err))
	require.ErrorContains(t, err, "--experimental")
}

// TestStaticAddressLoopInTimestamp verifies that zero timestamps are omitted
// from static loop-in responses instead of passing a zero time to UnixNano.
func TestStaticAddressLoopInTimestamp(t *testing.T) {
	require.Zero(t, staticAddressLoopInTimestamp(time.Time{}))

	timestamp := time.Unix(1_234, 567).UTC()
	require.Equal(
		t, timestamp.UnixNano(),
		staticAddressLoopInTimestamp(timestamp),
	)
}

// TestStaticAddressLoopInSwapServerCost verifies that static loop-in server
// costs are only reported once the invoice payment was received. Timeout path
// costs are not persisted today, so they are intentionally not estimated here.
func TestStaticAddressLoopInSwapServerCost(t *testing.T) {
	const quoteFee = btcutil.Amount(1_234)

	tests := []struct {
		name       string
		state      fsm.StateType
		wantServer int64
	}{
		{
			name:  "pending before payment",
			state: loopin.SignHtlcTx,
		},
		{
			name:       "payment received",
			state:      loopin.PaymentReceived,
			wantServer: int64(quoteFee),
		},
		{
			name:       "succeeded",
			state:      loopin.Succeeded,
			wantServer: int64(quoteFee),
		},
		{
			name:       "succeeded transition failed",
			state:      loopin.SucceededTransitioningFailed,
			wantServer: int64(quoteFee),
		},
		{
			name:  "timeout swept",
			state: loopin.HtlcTimeoutSwept,
		},
		{
			name:  "failed",
			state: loopin.Failed,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			swap := &loopin.StaticAddressLoopIn{
				QuotedSwapFee: quoteFee,
			}
			swap.SetState(test.state)

			costServer := staticAddressLoopInSwapServerCost(swap)

			require.Equal(t, test.wantServer, costServer)
		})
	}
}

// TestListStaticAddressSwapsPopulatesTimingAndCosts verifies that the RPC
// response maps stored static loop-in timing fields and cost fields.
func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) {
	ctx := t.Context()
	lnd := mock_lnd.NewMockLnd()

	const (
		paymentRequestAmount = btcutil.Amount(50_000)
		quotedSwapFee        = btcutil.Amount(1_234)
		depositValue         = btcutil.Amount(51_234)
		depositConfHeight    = int64(590)
		staticAddressExpiry  = uint32(25)
	)

	_, swapInvoice, err := lnd.Client.AddInvoice(
		ctx, &invoicesrpc.AddInvoiceData{
			Value: lnwire.NewMSatFromSatoshis(paymentRequestAmount),
		},
	)
	require.NoError(t, err)

	swapHash := lntypes.Hash{1, 2, 3}
	depositID := deposit.ID{4, 5, 6}
	depositOutpoint := wire.OutPoint{
		Hash:  chainhash.Hash{7, 8, 9},
		Index: 1,
	}
	testDeposit := &deposit.Deposit{
		ID:                 depositID,
		OutPoint:           depositOutpoint,
		Value:              depositValue,
		ConfirmationHeight: depositConfHeight,
		SwapHash:           &swapHash,
	}
	testDeposit.SetState(deposit.LoopedIn)

	initiationTime := time.Unix(1_234, 567).UTC()
	lastUpdateTime := time.Unix(2_345, 678).UTC()
	staticLoopIn := &loopin.StaticAddressLoopIn{
		SwapHash:         swapHash,
		SwapInvoice:      swapInvoice,
		InitiationTime:   initiationTime,
		LastUpdateTime:   lastUpdateTime,
		QuotedSwapFee:    quotedSwapFee,
		DepositOutpoints: []string{depositOutpoint.String()},
		Deposits:         []*deposit.Deposit{testDeposit},
	}
	staticLoopIn.SetState(loopin.Succeeded)

	depositStore := &mockDepositStore{
		byOutpoint: map[string]*deposit.Deposit{
			depositOutpoint.String(): testDeposit,
		},
	}
	depositMgr := deposit.NewManager(&deposit.ManagerConfig{
		Store: depositStore,
	})

	staticLoopInMgr, err := loopin.NewManager(&loopin.Config{
		Store: &mockStaticAddressLoopInStore{
			swaps: []*loopin.StaticAddressLoopIn{staticLoopIn},
		},
		DepositManager: depositMgr,
	}, 1)
	require.NoError(t, err)

	_, clientPubkey := mock_lnd.CreateKey(1)
	_, serverPubkey := mock_lnd.CreateKey(2)
	addrStore := &mockAddressStore{
		params: []*script.Parameters{{
			ClientPubkey: clientPubkey,
			ServerPubkey: serverPubkey,
			Expiry:       staticAddressExpiry,
			PkScript:     []byte("pkscript"),
		}},
	}
	addrMgr, err := address.NewManager(&address.ManagerConfig{
		Store:       addrStore,
		WalletKit:   lnd.WalletKit,
		ChainParams: lnd.ChainParams,
	}, 1)
	require.NoError(t, err)

	server := &swapClientServer{
		network:              lndclient.NetworkTestnet,
		lnd:                  &lnd.LndServices,
		staticAddressManager: addrMgr,
		depositManager:       depositMgr,
		staticLoopInManager:  staticLoopInMgr,
	}
	resp, err := server.ListStaticAddressSwaps(
		ctx, &looprpc.ListStaticAddressSwapsRequest{},
	)
	require.NoError(t, err)
	require.Len(t, resp.Swaps, 1)

	swap := resp.Swaps[0]
	require.Equal(t, swapHash[:], swap.SwapHash)
	require.Equal(t, []string{depositOutpoint.String()}, swap.DepositOutpoints)
	require.Equal(
		t, looprpc.StaticAddressLoopInSwapState_SUCCEEDED, swap.State,
	)
	require.Equal(t, int64(depositValue), swap.SwapAmountSatoshis)
	require.Equal(
		t, int64(paymentRequestAmount), swap.PaymentRequestAmountSatoshis,
	)
	require.Equal(t, initiationTime.UnixNano(), swap.InitiationTime)
	require.Equal(t, lastUpdateTime.UnixNano(), swap.LastUpdateTime)
	require.Equal(t, int64(quotedSwapFee), swap.CostServer)
	require.Zero(t, swap.CostOnchain)
	require.Zero(t, swap.CostOffchain)
	require.Len(t, swap.Deposits, 1)

	rpcDeposit := swap.Deposits[0]
	require.Equal(t, depositID[:], rpcDeposit.Id)
	require.Equal(t, depositOutpoint.String(), rpcDeposit.Outpoint)
	require.Equal(t, int64(depositValue), rpcDeposit.Value)
	require.Equal(t, depositConfHeight, rpcDeposit.ConfirmationHeight)
	require.Equal(t, swapHash[:], rpcDeposit.SwapHash)
	require.Equal(t, looprpc.DepositState_LOOPED_IN, rpcDeposit.State)
	require.Equal(
		t, depositConfHeight+int64(staticAddressExpiry)-600,
		rpcDeposit.BlocksUntilExpiry,
	)
}

// TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH protects the RPC
// mapping invariant that static loop-ins expose their static type, static
// state, and P2WSH HTLC address without leaking a taproot HTLC address.
func TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH(t *testing.T) {
	server := &swapClientServer{}
	loopSwap := &loop.SwapInfo{
		SwapStateData: loopdb.SwapStateData{
			State: loopdb.StateInitiated,
		},
		SwapContract: loopdb.SwapContract{
			InitiationTime: time.Now(),
		},
		LastUpdate:               time.Now(),
		SwapHash:                 lntypes.Hash{1},
		SwapType:                 swap.TypeStaticAddressLoopIn,
		StaticAddressLoopInState: loopin.SignHtlcTx,
		HtlcAddressP2WSH:         testnetAddr,
	}

	rpcSwap, err := server.marshallSwap(t.Context(), loopSwap)
	require.NoError(t, err)
	require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, rpcSwap.Type)
	require.Equal(
		t, looprpc.StaticAddressLoopInSwapState_SIGN_HTLC_TX,
		rpcSwap.GetStaticLoopInState(),
	)
	require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State)
	require.Equal(t, testnetAddr.EncodeAddress(), rpcSwap.HtlcAddressP2Wsh)
	require.Empty(t, rpcSwap.HtlcAddressP2Tr)
}

// TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault asserts that
// static loop-in failures keep default legacy fields while preserving the
// precise static state.
func TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault(
	t *testing.T) {

	tests := []struct {
		name            string
		state           fsm.StateType
		wantStaticState looprpc.StaticAddressLoopInSwapState
	}{
		{
			name:  "failed",
			state: loopin.Failed,
			wantStaticState: looprpc.
				StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP,
		},
		{
			name:  "succeeded transitioning failed",
			state: loopin.SucceededTransitioningFailed,
			wantStaticState: looprpc.
				StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			server, staticLoopIn := newGenericStaticLoopInServer(t)
			staticLoopIn.SetState(test.state)
			loopSwap, err := server.staticAddressLoopInSwapInfo(
				t.Context(), staticLoopIn,
			)
			require.NoError(t, err)

			rpcSwap, err := server.marshallSwap(t.Context(), loopSwap)

			require.NoError(t, err)
			require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State)
			require.Equal(
				t, looprpc.FailureReason_FAILURE_REASON_NONE,
				rpcSwap.FailureReason,
			)
			require.Equal(
				t, test.wantStaticState,
				rpcSwap.GetStaticLoopInState(),
			)
		})
	}
}

// TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress protects the
// fail-closed HTLC-address invariant for static loop-ins missing the P2WSH
// address required by the client-facing RPC representation.
func TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress(t *testing.T) {
	_, taprootAddress := newTestStaticAddressParams(t)
	server := &swapClientServer{}
	loopSwap := &loop.SwapInfo{
		SwapStateData: loopdb.SwapStateData{
			State: loopdb.StateInitiated,
		},
		SwapContract: loopdb.SwapContract{
			InitiationTime: time.Now(),
		},
		LastUpdate:               time.Now(),
		SwapHash:                 lntypes.Hash{1},
		SwapType:                 swap.TypeStaticAddressLoopIn,
		StaticAddressLoopInState: loopin.SignHtlcTx,
		HtlcAddressP2TR:          taprootAddress,
	}

	_, err := server.marshallSwap(t.Context(), loopSwap)
	require.ErrorContains(t, err, "missing static address loop-in P2WSH HTLC address")
}

// TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing protects the
// HTLC-address construction invariant that missing cooperative keys must not
// produce monitorable swap info.
func TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing(t *testing.T) {
	server, staticLoopIn := newGenericStaticLoopInServer(t)
	staticLoopIn.ClientPubkey = nil

	_, err := server.staticAddressLoopInSwapInfo(t.Context(), staticLoopIn)
	require.ErrorContains(
		t, err, "missing static address loop-in client HTLC key",
	)
}

// TestMonitorSnapshotIncludesStaticAddressLoopIns protects the monitor snapshot
// invariant that pending static loop-ins are included alongside cached generic
// swaps with their static state and swap-specific HTLC address.
func TestMonitorSnapshotIncludesStaticAddressLoopIns(t *testing.T) {
	ctx := t.Context()
	server, staticLoopIn := newGenericStaticLoopInServer(t)

	pendingSwaps, completedSwaps := server.monitorCachedSwaps()
	err := server.appendStaticAddressLoopInMonitorSnapshot(
		ctx, &pendingSwaps, &completedSwaps,
	)
	require.NoError(t, err)
	require.Empty(t, completedSwaps)
	require.Len(t, pendingSwaps, 1)
	require.Equal(t, staticLoopIn.SwapHash, pendingSwaps[0].SwapHash)
	require.Equal(t, swap.TypeStaticAddressLoopIn, pendingSwaps[0].SwapType)
	require.Equal(
		t, staticLoopIn.GetState(),
		pendingSwaps[0].StaticAddressLoopInState,
	)
	assertStaticLoopInUsesSwapHtlcAddress(t, staticLoopIn, pendingSwaps[0])
}

// TestMonitorSnapshotIncludesFinalStaticAddressLoopIns protects the monitor
// snapshot invariant that exact final static loop-in states are completed swaps.
func TestMonitorSnapshotIncludesFinalStaticAddressLoopIns(t *testing.T) {
	server, staticLoopIn := newGenericStaticLoopInServer(t)
	staticLoopIn.SetState(loopin.Succeeded)

	pendingSwaps, completedSwaps := server.monitorCachedSwaps()
	err := server.appendStaticAddressLoopInMonitorSnapshot(
		t.Context(), &pendingSwaps, &completedSwaps,
	)

	require.NoError(t, err)
	require.Empty(t, pendingSwaps)
	require.Len(t, completedSwaps, 1)
}

// TestStaticLoopInStatusUpdaterUsesSwapHtlcAddress protects the live-update
// invariant that static loop-in status events derive the HTLC address from the
// swap, not from reusable static address parameters.
func TestStaticLoopInStatusUpdaterUsesSwapHtlcAddress(t *testing.T) {
	ctx := t.Context()
	_, staticLoopIn := newGenericStaticLoopInServer(t)
	staticLoopIn.AddressParams = nil
	statusChan := make(chan loop.SwapInfo, 1)
	updater := &staticLoopInStatusUpdater{
		statusChan:  statusChan,
		mainCtx:     ctx,
		chainParams: &chaincfg.TestNet3Params,
	}

	err := updater.sendUpdate(ctx, staticLoopIn)
	require.NoError(t, err)
	swapInfo := <-statusChan
	assertStaticLoopInUsesSwapHtlcAddress(t, staticLoopIn, swapInfo)
}

// TestMonitorSuppressesStaticAddressLoopInSnapshotLiveDuplicate protects the
// monitor race invariant that live static loop-in updates arriving during the
// initial snapshot are deduplicated without dropping newer progress.
func TestMonitorSuppressesStaticAddressLoopInSnapshotLiveDuplicate(t *testing.T) {
	logger := btclog.NewSLogger(
		btclog.NewDefaultHandler(os.Stdout),
	)
	setLogger(logger.SubSystem(Subsystem))

	ctx, cancel := context.WithCancel(t.Context())
	defer cancel()
	mainCtx, mainCancel := context.WithCancel(t.Context())
	defer mainCancel()
	server, staticLoopIn, store := newGenericStaticLoopInServerWithStore(t)
	server.statusChan = make(chan loop.SwapInfo)
	server.subscribers = make(map[int]chan<- any)
	server.mainCtx = mainCtx

	snapshotStarted := make(chan struct{}, 1)
	releaseSnapshot := make(chan struct{})
	store.beforeGet = func() {
		select {
		case snapshotStarted <- struct{}{}:
		default:
		}
	}
	store.waitGet = releaseSnapshot

	go server.processStatusUpdates(mainCtx)

	monitorServer := &testMonitorServer{
		ctx:  ctx,
		sent: make(chan *looprpc.SwapStatus, 3),
	}
	errChan := make(chan error, 1)
	go func() {
		errChan <- server.Monitor(&looprpc.MonitorRequest{}, monitorServer)
	}()

	select {
	case <-snapshotStarted:
	case <-ctx.Done():
		t.Fatal(ctx.Err())
	}

	staticUpdate, err := server.staticAddressLoopInSwapInfo(ctx, staticLoopIn)
	require.NoError(t, err)
	staleUpdate := *staticUpdate
	staleUpdate.State = loopdb.StateInitiated
	staleUpdate.LastUpdate = staticUpdate.LastUpdate.Add(-time.Second)
	server.statusChan <- staleUpdate

	server.statusChan <- *staticUpdate
	close(releaseSnapshot)

	first := receiveMonitorUpdate(t, ctx, monitorServer.sent)
	require.Equal(t, staticLoopIn.SwapHash[:], first.IdBytes)
	require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, first.Type)
	require.Equal(
		t, looprpc.StaticAddressLoopInSwapState_PAYMENT_RECEIVED,
		first.GetStaticLoopInState(),
	)

	nextUpdate := *staticUpdate
	nextUpdate.State = loopdb.StateSuccess
	nextUpdate.StaticAddressLoopInState = loopin.Succeeded
	nextUpdate.LastUpdate = staticUpdate.LastUpdate.Add(time.Second)
	server.statusChan <- nextUpdate

	second := receiveMonitorUpdate(t, ctx, monitorServer.sent)
	require.Equal(t, staticLoopIn.SwapHash[:], second.IdBytes)
	require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, second.Type)
	require.Equal(
		t, looprpc.StaticAddressLoopInSwapState_SUCCEEDED,
		second.GetStaticLoopInState(),
	)

	cancel()
	require.NoError(t, <-errChan)
}

// TestStaticAddressLoopInHighWaterSuppressesExactDuplicate protects the
// high-water dedup invariant that an initial live update identical to the
// snapshot is treated as stale.
func TestStaticAddressLoopInHighWaterSuppressesExactDuplicate(t *testing.T) {
	swapHash := lntypes.Hash{1, 2, 3}
	lastUpdate := time.Unix(100, 0).UTC()
	snapshot := loop.SwapInfo{
		SwapHash:                 swapHash,
		SwapType:                 swap.TypeStaticAddressLoopIn,
		LastUpdate:               lastUpdate,
		StaticAddressLoopInState: loopin.PaymentReceived,
	}
	highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{
		snapshot,
	})

	isStale := isInitialStaticAddressLoopInStale(highWater, snapshot)

	require.True(t, isStale)
}

// TestStaticAddressLoopInHighWaterKeepsSameTimeDifferentState protects the
// high-water timing invariant that equal timestamps do not hide a distinct
// static loop-in state transition.
func TestStaticAddressLoopInHighWaterKeepsSameTimeDifferentState(t *testing.T) {
	swapHash := lntypes.Hash{1, 2, 3}
	lastUpdate := time.Unix(100, 0).UTC()
	snapshot := loop.SwapInfo{
		SwapHash:                 swapHash,
		SwapType:                 swap.TypeStaticAddressLoopIn,
		LastUpdate:               lastUpdate,
		StaticAddressLoopInState: loopin.PaymentReceived,
	}
	liveUpdate := snapshot
	liveUpdate.StaticAddressLoopInState = loopin.Succeeded
	highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{
		snapshot,
	})

	isStale := isInitialStaticAddressLoopInStale(highWater, liveUpdate)

	require.False(t, isStale)
}

// TestStaticAddressLoopInHighWaterSuppressesOlderStaticOnly protects the
// high-water cache invariant that stale suppression applies only to static
// loop-ins and cannot filter generic swap updates.
func TestStaticAddressLoopInHighWaterSuppressesOlderStaticOnly(t *testing.T) {
	swapHash := lntypes.Hash{1, 2, 3}
	lastUpdate := time.Unix(100, 0).UTC()
	snapshot := loop.SwapInfo{
		SwapHash:                 swapHash,
		SwapType:                 swap.TypeStaticAddressLoopIn,
		LastUpdate:               lastUpdate,
		StaticAddressLoopInState: loopin.PaymentReceived,
	}
	highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{
		snapshot,
	})
	olderStatic := snapshot
	olderStatic.LastUpdate = lastUpdate.Add(-time.Second)
	olderStatic.StaticAddressLoopInState = loopin.Succeeded
	nonStatic := olderStatic
	nonStatic.SwapType = swap.TypeOut

	staticStale := isInitialStaticAddressLoopInStale(highWater, olderStatic)
	nonStaticStale := isInitialStaticAddressLoopInStale(
		highWater, nonStatic,
	)

	require.True(t, staticStale)
	require.False(t, nonStaticStale)
}

// TestStaticAddressLoopInStatusUpdateDoesNotEnterGenericSwapCache protects the
// cache isolation invariant that static loop-in live updates reach subscribers
// without entering the generic swap cache.
func TestStaticAddressLoopInStatusUpdateDoesNotEnterGenericSwapCache(t *testing.T) {
	ctx := t.Context()
	server, staticLoopIn := newGenericStaticLoopInServer(t)
	server.statusChan = make(chan loop.SwapInfo)
	updates := make(chan any, 1)
	server.subscribers = map[int]chan<- any{0: updates}
	mainCtx, cancel := context.WithCancel(ctx)
	defer cancel()
	go server.processStatusUpdates(mainCtx)

	staticUpdate, err := server.staticAddressLoopInSwapInfo(ctx, staticLoopIn)
	require.NoError(t, err)
	server.statusChan <- *staticUpdate

	select {
	case update := <-updates:
		require.Equal(t, staticLoopIn.SwapHash, update.(loop.SwapInfo).SwapHash)

	case <-ctx.Done():
		t.Fatal(ctx.Err())
	}

	server.swapsLock.Lock()
	_, cached := server.swaps[staticLoopIn.SwapHash]
	server.swapsLock.Unlock()
	require.False(t, cached)
}

func newGenericStaticLoopInServer(t *testing.T) (*swapClientServer,
	*loopin.StaticAddressLoopIn) {

	server, staticLoopIn, _ := newGenericStaticLoopInServerWithStore(t)

	return server, staticLoopIn
}

func newTestStaticAddressParams(t *testing.T) (*script.Parameters,
	*btcutil.AddressTaproot) {

	t.Helper()

	const staticAddressExpiry = uint32(25)

	_, staticClientPubkey := mock_lnd.CreateKey(12)
	_, staticServerPubkey := mock_lnd.CreateKey(13)
	staticAddress, err := script.NewStaticAddress(
		input.MuSig2Version100RC2, int64(staticAddressExpiry),
		staticClientPubkey, staticServerPubkey,
	)
	require.NoError(t, err)

	staticPkScript, err := staticAddress.StaticAddressScript()
	require.NoError(t, err)

	taprootAddress, err := btcutil.NewAddressTaproot(
		schnorr.SerializePubKey(staticAddress.TaprootKey),
		&chaincfg.TestNet3Params,
	)
	require.NoError(t, err)

	return &script.Parameters{
		ClientPubkey: staticClientPubkey,
		ServerPubkey: staticServerPubkey,
		Expiry:       staticAddressExpiry,
		PkScript:     staticPkScript,
	}, taprootAddress
}

func newGenericStaticLoopInServerWithStore(t *testing.T) (*swapClientServer,
	*loopin.StaticAddressLoopIn, *mockStaticAddressLoopInStore) {

	t.Helper()

	_, clientPubkey := mock_lnd.CreateKey(10)
	_, serverPubkey := mock_lnd.CreateKey(11)
	addressParams, _ := newTestStaticAddressParams(t)
	depositOutpoint := wire.OutPoint{
		Hash:  chainhash.Hash{12, 13, 14},
		Index: 2,
	}
	staticDeposit := &deposit.Deposit{
		OutPoint: depositOutpoint,
		Value:    51_000,
	}
	lastHop := route.Vertex{7, 8, 9}

	staticLoopIn := &loopin.StaticAddressLoopIn{
		SwapHash:         lntypes.Hash{1, 2, 3},
		HtlcCltvExpiry:   700,
		InitiationTime:   time.Unix(100, 0).UTC(),
		LastUpdateTime:   time.Unix(200, 0).UTC(),
		Label:            "static-loop-in",
		ClientPubkey:     clientPubkey,
		ServerPubkey:     serverPubkey,
		LastHop:          lastHop[:],
		QuotedSwapFee:    1_111,
		SelectedAmount:   50_000,
		DepositOutpoints: []string{depositOutpoint.String()},
		Deposits:         []*deposit.Deposit{staticDeposit},
		AddressParams:    addressParams,
	}
	staticLoopIn.SetState(loopin.PaymentReceived)

	depositStore := &mockDepositStore{
		byOutpoint: map[string]*deposit.Deposit{
			depositOutpoint.String(): staticDeposit,
		},
	}
	loopInStore := &mockStaticAddressLoopInStore{
		swaps: []*loopin.StaticAddressLoopIn{staticLoopIn},
	}
	staticLoopInManager, err := loopin.NewManager(&loopin.Config{
		Store: loopInStore,
		DepositManager: deposit.NewManager(&deposit.ManagerConfig{
			Store: depositStore,
		}),
	}, 1)
	require.NoError(t, err)

	return &swapClientServer{
		network:             lndclient.NetworkTestnet,
		swaps:               make(map[lntypes.Hash]loop.SwapInfo),
		staticLoopInManager: staticLoopInManager,
	}, staticLoopIn, loopInStore
}

// assertStaticLoopInUsesSwapHtlcAddress verifies the static loop-in uses the
// swap HTLC P2WSH address expected by the fixture.
func assertStaticLoopInUsesSwapHtlcAddress(t *testing.T,
	staticLoopIn *loopin.StaticAddressLoopIn, swapInfo loop.SwapInfo) {

	t.Helper()

	expectedAddress, err := staticAddressLoopInHtlcAddress(
		staticLoopIn, &chaincfg.TestNet3Params,
	)
	require.NoError(t, err)
	require.Nil(t, swapInfo.HtlcAddressP2TR)
	require.NotNil(t, swapInfo.HtlcAddressP2WSH)
	require.Equal(
		t, expectedAddress.EncodeAddress(),
		swapInfo.HtlcAddressP2WSH.EncodeAddress(),
	)
}

// testMonitorServer implements the monitor stream interface for tests.
type testMonitorServer struct {
	ctx  context.Context
	sent chan *looprpc.SwapStatus
}

// Send forwards monitor updates to the test channel until the context is canceled.
func (s *testMonitorServer) Send(swapStatus *looprpc.SwapStatus) error {
	select {
	case s.sent <- swapStatus:
		return nil

	case <-s.ctx.Done():
		return s.ctx.Err()
	}
}

// SetHeader is a no-op stub that satisfies the monitor stream interface in tests.
func (s *testMonitorServer) SetHeader(metadata.MD) error {
	return nil
}

// SendHeader is a no-op stub that satisfies the monitor stream interface in tests.
func (s *testMonitorServer) SendHeader(metadata.MD) error {
	return nil
}

// SetTrailer is a no-op stub that satisfies the monitor stream interface in tests.
func (s *testMonitorServer) SetTrailer(metadata.MD) {}

// Context returns the stream context used by the test monitor server.
func (s *testMonitorServer) Context() context.Context {
	return s.ctx
}

// SendMsg is a no-op stub that satisfies the monitor stream interface in tests.
func (s *testMonitorServer) SendMsg(any) error {
	return nil
}

// RecvMsg is a no-op stub that satisfies the monitor stream interface in tests.
func (s *testMonitorServer) RecvMsg(any) error {
	return nil
}

// receiveMonitorUpdate waits for a monitor update or fails if the context is canceled.
func receiveMonitorUpdate(t *testing.T, ctx context.Context,
	updates <-chan *looprpc.SwapStatus) *looprpc.SwapStatus {

	t.Helper()

	select {
	case update := <-updates:
		return update

	case <-ctx.Done():
		t.Fatal(ctx.Err())
	}

	return nil
}

// mockStaticAddressLoopInStore is a minimal in-memory loop-in store for RPC
// response mapping tests.
type mockStaticAddressLoopInStore struct {
	swaps     []*loopin.StaticAddressLoopIn
	beforeGet func()
	waitGet   <-chan struct{}
}

// CreateLoopIn satisfies the static loop-in store interface.
func (s *mockStaticAddressLoopInStore) CreateLoopIn(_ context.Context,
	_ *loopin.StaticAddressLoopIn) error {

	return nil
}

// UpdateLoopIn satisfies the static loop-in store interface.
func (s *mockStaticAddressLoopInStore) UpdateLoopIn(_ context.Context,
	_ *loopin.StaticAddressLoopIn) error {

	return nil
}

// GetStaticAddressLoopInSwapsByStates returns the configured loop-ins.
func (s *mockStaticAddressLoopInStore) GetStaticAddressLoopInSwapsByStates(
	ctx context.Context, _ []fsm.StateType) ([]*loopin.StaticAddressLoopIn,
	error) {

	if s.beforeGet != nil {
		s.beforeGet()
	}
	if s.waitGet != nil {
		select {
		case <-s.waitGet:
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}

	return s.swaps, nil
}

// IsStored satisfies the static loop-in store interface.
func (s *mockStaticAddressLoopInStore) IsStored(_ context.Context,
	_ lntypes.Hash) (bool, error) {

	return false, nil
}

// RecordStaticAddressRiskDecision satisfies the static loop-in store interface.
func (s *mockStaticAddressLoopInStore) RecordStaticAddressRiskDecision(
	_ context.Context, _ lntypes.Hash,
	_ loopin.ConfirmationRiskDecision) error {

	return nil
}

// GetLoopInByHash returns the configured loop-in with the given hash.
func (s *mockStaticAddressLoopInStore) GetLoopInByHash(_ context.Context,
	swapHash lntypes.Hash) (*loopin.StaticAddressLoopIn, error) {

	for _, swp := range s.swaps {
		if swp.SwapHash == swapHash {
			return swp, nil
		}
	}

	return nil, nil
}

// SwapHashesForDepositIDs satisfies the static loop-in store interface.
func (s *mockStaticAddressLoopInStore) SwapHashesForDepositIDs(
	_ context.Context, _ []deposit.ID) (map[lntypes.Hash][]deposit.ID,
	error) {

	return nil, nil
}

// TestRPCAutoloopReasonStaticLoopInNoCandidate verifies that the new planner
// reason is exposed over rpc.
func TestRPCAutoloopReasonStaticLoopInNoCandidate(t *testing.T) {
	reason, err := rpcAutoloopReason(
		liquidity.ReasonStaticLoopInNoCandidate,
	)
	require.NoError(t, err)
	require.Equal(
		t,
		looprpc.AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE,
		reason,
	)
}

// TestRPCAutoloopReasonCustomChannelData verifies that custom-channel
// disqualifications are exposed over rpc instead of failing the whole dry run.
func TestRPCAutoloopReasonCustomChannelData(t *testing.T) {
	reason, err := rpcAutoloopReason(liquidity.ReasonCustomChannelData)
	require.NoError(t, err)
	require.Equal(
		t, looprpc.AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA, reason,
	)
}

// TestSwapClientServerStopDaemon ensures that calling StopDaemon triggers the
// daemon shutdown.
func TestSwapClientServerStopDaemon(t *testing.T) {
	t.Parallel()

	// Prepare a server instance that tracks whether shutdown is requested.
	var stopCalled bool
	server := &swapClientServer{
		stopDaemon: func() {
			stopCalled = true
		},
	}

	// Request the daemon to stop and assert the callback executed.
	_, err := server.StopDaemon(
		context.Background(), &looprpc.StopDaemonRequest{},
	)
	require.NoError(t, err)

	// Ensure our shutdown callback executed.
	require.True(t, stopCalled)
}

// TestValidateLoopOutRequest tests validation of loop out requests.
func TestValidateLoopOutRequest(t *testing.T) {
	tests := []struct {
		name            string
		chain           chaincfg.Params
		confTarget      int32
		destAddr        btcutil.Address
		label           string
		channels        []lndclient.ChannelInfo
		outgoingChanSet []uint64
		amount          int64
		maxRoutingFee   int64
		maxParts        uint32
		err             error
		expectedTarget  int32
	}{
		{
			name:       "mainnet address with mainnet backend",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel2,
			},
			amount:         10000,
			maxParts:       5,
			err:            nil,
			expectedTarget: 2,
		},
		{
			name:       "mainnet address with testnet backend",
			chain:      chaincfg.TestNet3Params,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel2,
			},
			amount:         10000,
			maxParts:       5,
			err:            errIncorrectChain,
			expectedTarget: 0,
		},
		{
			name:       "testnet address with testnet backend",
			chain:      chaincfg.TestNet3Params,
			destAddr:   testnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel2,
			},
			amount:         10000,
			maxParts:       5,
			err:            nil,
			expectedTarget: 2,
		},
		{
			name:       "testnet address with mainnet backend",
			chain:      chaincfg.MainNetParams,
			destAddr:   testnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel2,
			},
			amount:         10000,
			maxParts:       5,
			err:            errIncorrectChain,
			expectedTarget: 0,
		},
		{
			name:       "invalid label",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      labels.Reserved,
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel2,
			},
			amount:         10000,
			maxParts:       5,
			err:            labels.ErrReservedPrefix,
			expectedTarget: 0,
		},
		{
			name:       "invalid conf target",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 1,
			channels: []lndclient.ChannelInfo{
				channel2,
			},
			amount:         10000,
			maxParts:       5,
			err:            errConfTargetTooLow,
			expectedTarget: 0,
		},
		{
			name:       "default conf target",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 0,
			channels: []lndclient.ChannelInfo{
				channel2,
			},
			amount:         10000,
			maxParts:       5,
			err:            nil,
			expectedTarget: 9,
		},
		{
			name:       "valid amount for default channel set",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel1, channel2, channel3,
			},
			amount:         20000,
			maxParts:       5,
			err:            nil,
			expectedTarget: 2,
		},
		{
			name:       "invalid amount for default channel set",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel1, channel2, channel3,
			},
			amount:         25000,
			maxParts:       5,
			err:            errBalanceTooLow,
			expectedTarget: 0,
		},
		{
			name:       "inactive channel in outgoing channel set",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel1, channel2, channel3,
			},
			outgoingChanSet: []uint64{
				chanID1.ToUint64(),
			},
			amount:         1000,
			maxParts:       5,
			err:            errBalanceTooLow,
			expectedTarget: 0,
		},
		{
			name:       "outgoing channel set balance is enough",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel1, channel2, channel3,
			},
			outgoingChanSet: []uint64{
				chanID2.ToUint64(),
			},
			amount:         1000,
			maxParts:       5,
			err:            nil,
			expectedTarget: 2,
		},
		{
			name:       "outgoing channel set balance not sufficient",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel1, channel2, channel3,
			},
			outgoingChanSet: []uint64{
				chanID2.ToUint64(),
			},
			amount:         20000,
			maxParts:       5,
			err:            errBalanceTooLow,
			expectedTarget: 0,
		},
		{
			name:       "amount with routing fee too high",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel2,
			},
			amount:         10000,
			maxRoutingFee:  100,
			maxParts:       5,
			err:            errBalanceTooLow,
			expectedTarget: 0,
		},
		{
			name:       "can split between channels",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel2, channel4,
			},
			amount:         11000,
			maxParts:       16,
			err:            nil,
			expectedTarget: 2,
		},
		{
			name:       "can't split between channels",
			chain:      chaincfg.MainNetParams,
			destAddr:   mainnetAddr,
			label:      "label ok",
			confTarget: 2,
			channels: []lndclient.ChannelInfo{
				channel2, channel4,
			},
			amount:         11000,
			maxParts:       5,
			err:            errBalanceTooLow,
			expectedTarget: 0,
		},
		{
			name:           "node pubkey as dest addr",
			chain:          chaincfg.MainNetParams,
			destAddr:       nodepubkeyAddr,
			err:            errInvalidAddress,
			expectedTarget: 0,
		},
	}

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

			lnd := mock_lnd.NewMockLnd()
			lnd.Channels = test.channels

			req := &looprpc.LoopOutRequest{
				Amt:               test.amount,
				MaxSwapRoutingFee: test.maxRoutingFee,
				OutgoingChanSet:   test.outgoingChanSet,
				Label:             test.label,
				SweepConfTarget:   test.confTarget,
			}

			logger := btclog.NewSLogger(
				btclog.NewDefaultHandler(os.Stdout),
			)
			setLogger(logger.SubSystem(Subsystem))

			conf, err := validateLoopOutRequest(
				ctx, lnd.Client, &test.chain, req,
				test.destAddr, test.maxParts,
			)
			require.ErrorIs(t, err, test.err)
			require.Equal(t, test.expectedTarget, conf)
		})
	}
}

// TestHasBandwidth tests that the hasBandwidth function correctly simulates
// the MPP logic used by LND.
func TestHasBandwidth(t *testing.T) {
	tests := []struct {
		name           string
		channels       []lndclient.ChannelInfo
		maxParts       int
		amt            btcutil.Amount
		expectedRes    bool
		expectedShards int
	}{
		{
			name: "can route due to high number of parts",
			channels: []lndclient.ChannelInfo{
				{
					LocalBalance: 100,
				},
				{
					LocalBalance: 10,
				},
			},
			maxParts:       11,
			amt:            110,
			expectedRes:    true,
			expectedShards: 8,
		},
		{
			name: "can't route due to low number of parts",
			channels: []lndclient.ChannelInfo{
				{
					LocalBalance: 100,
				},
				{
					LocalBalance: 10,
				},
			},
			maxParts:    5,
			amt:         110,
			expectedRes: false,
		},
		{
			name: "can route",
			channels: []lndclient.ChannelInfo{
				{
					LocalBalance: 1000,
				},
				{
					LocalBalance: 1000,
				},
			},
			maxParts:       5,
			amt:            2000,
			expectedRes:    true,
			expectedShards: 2,
		},
		{
			name: "can route",
			channels: []lndclient.ChannelInfo{
				{
					LocalBalance: 100,
				},
				{
					LocalBalance: 100,
				},
				{
					LocalBalance: 100,
				},
			},
			maxParts:       10,
			amt:            300,
			expectedRes:    true,
			expectedShards: 10,
		},
		{
			name:           "can't route due to empty channel set",
			maxParts:       10,
			amt:            300,
			expectedRes:    false,
			expectedShards: 0,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			res, shards := hasBandwidth(test.channels, test.amt,
				test.maxParts)
			require.Equal(t, test.expectedRes, res)
			require.Equal(t, test.expectedShards, shards)
		})
	}
}

// TestListSwapsFilterAndPagination tests the filtering and
// paging of the ListSwaps command.
func TestListSwapsFilterAndPagination(t *testing.T) {
	unixTime := time.Unix(0, 0)
	firstSwapStartTime := unixTime.Add(10 * time.Minute)
	secondSwapStartTime := unixTime.Add(20 * time.Minute)
	thirdSwapStartTime := unixTime.Add(30 * time.Minute)

	// Create a set of test swaps of various types which contain the minimal
	// viable amount of info to successfully be run through marshallSwap.
	swapInOrder0 := loop.SwapInfo{
		SwapStateData: loopdb.SwapStateData{
			State: loopdb.StateInitiated,
			Cost:  loopdb.SwapCost{},
		},
		SwapContract: loopdb.SwapContract{
			InitiationTime: firstSwapStartTime,
		},
		LastUpdate:       time.Now(),
		SwapHash:         lntypes.Hash{1},
		SwapType:         swap.TypeIn,
		HtlcAddressP2WSH: testnetAddr,
		HtlcAddressP2TR:  testnetAddr,
	}

	swapOutOrder1 := loop.SwapInfo{
		SwapStateData: loopdb.SwapStateData{
			State: loopdb.StateInitiated,
			Cost:  loopdb.SwapCost{},
		},
		SwapContract: loopdb.SwapContract{
			InitiationTime: secondSwapStartTime,
		},
		LastUpdate:       time.Now(),
		SwapHash:         lntypes.Hash{2},
		SwapType:         swap.TypeOut,
		HtlcAddressP2WSH: testnetAddr,
		HtlcAddressP2TR:  testnetAddr,
	}

	swapOutOrder2 := loop.SwapInfo{
		SwapStateData: loopdb.SwapStateData{
			State: loopdb.StateInitiated,
			Cost:  loopdb.SwapCost{},
		},
		SwapContract: loopdb.SwapContract{
			InitiationTime: thirdSwapStartTime,
		},
		LastUpdate:       time.Now(),
		SwapHash:         lntypes.Hash{3},
		SwapType:         swap.TypeOut,
		HtlcAddressP2WSH: testnetAddr,
		HtlcAddressP2TR:  testnetAddr,
	}

	mockSwaps := []loop.SwapInfo{swapInOrder0, swapOutOrder1, swapOutOrder2}

	tests := []struct {
		name string
		// Define the mock swaps that will be stored in the mock client.
		mockSwaps []loop.SwapInfo
		req       *looprpc.ListSwapsRequest
		// These hashes must be in the correct return order as the response.
		expectedReturnedSwaps []lntypes.Hash
		expectedNextStartTime int64
	}{
		{
			name:      "fetch with defaults",
			mockSwaps: mockSwaps,
			req:       &looprpc.ListSwapsRequest{},
			expectedReturnedSwaps: []lntypes.Hash{
				swapInOrder0.SwapHash,
				swapOutOrder1.SwapHash,
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with swaptype=loopin filter",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					SwapType: looprpc.ListSwapsFilter_LOOP_IN},
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapInOrder0.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with swaptype=loopout filter",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					SwapType: looprpc.ListSwapsFilter_LOOP_OUT,
				},
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapOutOrder1.SwapHash,
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with limit",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				MaxSwaps: 2,
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapInOrder0.SwapHash,
				swapOutOrder1.SwapHash,
			},
			expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
		},
		{
			name:      "fetch with limit set to default",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				MaxSwaps: 0,
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapInOrder0.SwapHash,
				swapOutOrder1.SwapHash,
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with time filter #1",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					StartTimestampNs: unixTime.Add(25 * time.Minute).UnixNano(),
				},
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with time filter #2",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					StartTimestampNs: unixTime.Add(5 * time.Minute).UnixNano(),
				},
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapInOrder0.SwapHash,
				swapOutOrder1.SwapHash,
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with swaptype=loopout filter, time filter, limit set",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					SwapType:         looprpc.ListSwapsFilter_LOOP_OUT,
					StartTimestampNs: unixTime.Add(15 * time.Minute).UnixNano(),
				},
				MaxSwaps: 1,
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapOutOrder1.SwapHash,
			},
			expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
		},
		{
			name:      "fetch with time filter, limit set",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					StartTimestampNs: unixTime.UnixNano(),
				},
				MaxSwaps: 2,
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapInOrder0.SwapHash,
				swapOutOrder1.SwapHash,
			},
			expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
		},
		{
			name:      "fetch with time filter, limit set 2",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					StartTimestampNs: unixTime.UnixNano(),
				},
				MaxSwaps: 3,
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapInOrder0.SwapHash,
				swapOutOrder1.SwapHash,
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with time filter edge case 1",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					StartTimestampNs: secondSwapStartTime.UnixNano(),
				},
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapOutOrder1.SwapHash,
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with time filter edge case 2",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					StartTimestampNs: secondSwapStartTime.UnixNano() + 1,
				},
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
		{
			name:      "fetch with time filter edge case 3",
			mockSwaps: mockSwaps,
			req: &looprpc.ListSwapsRequest{
				ListSwapFilter: &looprpc.ListSwapsFilter{
					StartTimestampNs: secondSwapStartTime.UnixNano() - 1,
				},
			},
			expectedReturnedSwaps: []lntypes.Hash{
				swapOutOrder1.SwapHash,
				swapOutOrder2.SwapHash,
			},
			expectedNextStartTime: 0,
		},
	}

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

			// Create the swap client server with our mock client.
			server := &swapClientServer{
				swaps: make(map[lntypes.Hash]loop.SwapInfo),
			}

			// Populate the server's swap cache with our mock swaps.
			for _, swap := range test.mockSwaps {
				server.swaps[swap.SwapHash] = swap
			}

			// Call the ListSwaps method.
			resp, err := server.ListSwaps(context.Background(), test.req)
			require.NoError(t, err)

			require.Len(
				t,
				resp.Swaps,
				len(test.expectedReturnedSwaps),
				"incorrect returned count",
			)

			// Check order of returned swaps is exactly as expected.
			for idx, aswap := range resp.Swaps {
				newhash, err := lntypes.MakeHash(aswap.GetIdBytes())
				require.NoError(t, err)
				require.Equal(
					t,
					test.expectedReturnedSwaps[idx],
					newhash,
					"iteration order mismatch",
				)
			}

			require.Equal(
				t,
				test.expectedNextStartTime,
				resp.NextStartTime,
				"incorrect next start time",
			)
		})
	}
}

// mockAddressStore is a minimal in-memory store for address parameters.
type mockAddressStore struct {
	params []*script.Parameters
}

func (s *mockAddressStore) CreateStaticAddress(_ context.Context,
	p *script.Parameters) error {

	s.params = append(s.params, p)
	return nil
}

func (s *mockAddressStore) GetStaticAddress(_ context.Context, _ []byte) (
	*script.Parameters, error) {

	if len(s.params) == 0 {
		return nil, nil
	}

	return s.params[0], nil
}

func (s *mockAddressStore) GetAllStaticAddresses(_ context.Context) (
	[]*script.Parameters, error) {

	return s.params, nil
}

// mockDepositStore implements deposit.Store minimally for DepositsForOutpoints.
type mockDepositStore struct {
	byOutpoint map[string]*deposit.Deposit
}

func (s *mockDepositStore) CreateDeposit(_ context.Context,
	_ *deposit.Deposit) error {

	return nil
}

func (s *mockDepositStore) UpdateDeposit(_ context.Context,
	_ *deposit.Deposit) error {

	return nil
}

func (s *mockDepositStore) GetDeposit(_ context.Context,
	_ deposit.ID) (*deposit.Deposit, error) {

	return nil, nil
}

func (s *mockDepositStore) DepositForOutpoint(_ context.Context,
	outpoint string) (*deposit.Deposit, error) {

	if d, ok := s.byOutpoint[outpoint]; ok {
		return d, nil
	}
	return nil, deposit.ErrDepositNotFound
}

func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
	error) {

	deposits := make([]*deposit.Deposit, 0, len(s.byOutpoint))
	for _, d := range s.byOutpoint {
		deposits = append(deposits, d)
	}

	return deposits, nil
}

// listUnspentDepositManager backs ListUnspentDeposits tests without requiring
// the full deposit manager event loop.
type listUnspentDepositManager struct {
	byOutpoint map[string]*deposit.Deposit

	ensureDepositsFreshCalls int
	onEnsureDepositsFresh    func(*listUnspentDepositManager)
}

func (m *listUnspentDepositManager) EnsureDepositsFresh(
	context.Context) error {

	m.ensureDepositsFreshCalls++
	if m.onEnsureDepositsFresh != nil {
		m.onEnsureDepositsFresh(m)
	}

	return nil
}

func (m *listUnspentDepositManager) GetActiveDepositsInState(
	state fsm.StateType) ([]*deposit.Deposit, error) {

	deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint))
	for _, d := range m.byOutpoint {
		if !d.IsInState(state) {
			continue
		}

		deposits = append(deposits, d)
	}

	return deposits, nil
}

func (m *listUnspentDepositManager) DepositsForOutpoints(
	_ context.Context, outpoints []string, ignoreUnknown bool) (
	[]*deposit.Deposit, error) {

	deposits := make([]*deposit.Deposit, 0, len(outpoints))
	seen := make(map[string]struct{}, len(outpoints))
	for i, outpoint := range outpoints {
		if _, ok := seen[outpoint]; ok {
			return nil, fmt.Errorf("duplicate outpoint %s "+
				"at index %d", outpoint, i)
		}
		seen[outpoint] = struct{}{}

		d, ok := m.byOutpoint[outpoint]
		if !ok {
			if ignoreUnknown {
				continue
			}

			return nil, deposit.ErrDepositNotFound
		}

		deposits = append(deposits, d)
	}

	return deposits, nil
}

func (m *listUnspentDepositManager) GetVisibleDeposits(
	context.Context) ([]*deposit.Deposit, error) {

	return m.allDeposits(), nil
}

func (m *listUnspentDepositManager) GetAllDeposits(
	context.Context) ([]*deposit.Deposit, error) {

	return m.allDeposits(), nil
}

func (m *listUnspentDepositManager) allDeposits() []*deposit.Deposit {
	deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint))
	for _, d := range m.byOutpoint {
		deposits = append(deposits, d)
	}

	return deposits
}

// TestListUnspentDeposits tests filtering behavior of ListUnspentDeposits.
func TestListUnspentDeposits(t *testing.T) {
	ctx := context.Background()
	mock := mock_lnd.NewMockLnd()

	// Prepare a single static address parameter set.
	_, client := mock_lnd.CreateKey(1)
	_, server := mock_lnd.CreateKey(2)
	pkScript := []byte("pkscript")
	addrParams := &script.Parameters{
		ClientPubkey: client,
		ServerPubkey: server,
		Expiry:       10,
		PkScript:     pkScript,
	}

	addrStore := &mockAddressStore{params: []*script.Parameters{addrParams}}

	// Build an address manager using our mock lnd and fake address store.
	addrMgr, err := address.NewManager(&address.ManagerConfig{
		Store:       addrStore,
		WalletKit:   mock.WalletKit,
		ChainParams: mock.ChainParams,
		// ChainNotifier and AddressClient are not needed for this test.
	}, 1)
	require.NoError(t, err)

	// Construct several UTXOs with different confirmation counts.
	makeUtxo := func(idx uint32, confs int64) *lnwallet.Utxo {
		return &lnwallet.Utxo{
			AddressType:   lnwallet.TaprootPubkey,
			Value:         btcutil.Amount(250_000 + int64(idx)),
			Confirmations: confs,
			PkScript:      pkScript,
			OutPoint: wire.OutPoint{
				Hash:  chainhash.Hash{byte(idx + 1)},
				Index: idx,
			},
		}
	}

	utxoUnknown := makeUtxo(0, 0)
	utxoDeposited := makeUtxo(1, 1)
	utxoWithdrawn := makeUtxo(2, 2)
	utxoLoopingIn := makeUtxo(3, 5)
	utxoConfirmedUnknown := makeUtxo(4, 3)

	// Helper to build the deposit manager with specific states.
	buildDepositMgr := func(
		states map[wire.OutPoint]fsm.StateType) *listUnspentDepositManager {

		depMgr := &listUnspentDepositManager{
			byOutpoint: make(map[string]*deposit.Deposit),
		}
		for op, state := range states {
			d := &deposit.Deposit{OutPoint: op}
			d.SetState(state)
			depMgr.byOutpoint[op.String()] = d
		}

		return depMgr
	}

	// Only known Deposited records are available. Unknown deposits and
	// known non-Deposited states are excluded.
	t.Run("only known Deposited included",
		func(t *testing.T) {
			mock.SetListUnspent([]*lnwallet.Utxo{
				utxoUnknown, utxoDeposited, utxoWithdrawn,
				utxoLoopingIn,
			})

			depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
				utxoDeposited.OutPoint: deposit.Deposited,
				utxoWithdrawn.OutPoint: deposit.Withdrawn,
				utxoLoopingIn.OutPoint: deposit.LoopingIn,
			})

			server := &swapClientServer{
				staticAddressManager: addrMgr,
				depositManager:       depMgr,
			}

			resp, err := server.ListUnspentDeposits(
				ctx, &looprpc.ListUnspentDepositsRequest{},
			)
			require.NoError(t, err)
			require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)

			// Expect the Deposited utxo only.
			require.Len(t, resp.Utxos, 1)
			got := map[string]struct{}{}
			for _, u := range resp.Utxos {
				got[u.Outpoint] = struct{}{}
				// Confirm address string is non-empty and the
				// same across utxos.
				require.NotEmpty(t, u.StaticAddress)
			}
			_, ok := got[utxoDeposited.OutPoint.String()]
			require.True(t, ok)
		})

	// Confirmation depth no longer changes availability; state does.
	t.Run("availability ignores conf depth once deposit state is known",
		func(t *testing.T) {
			mock.SetListUnspent(
				[]*lnwallet.Utxo{
					utxoUnknown, utxoDeposited,
					utxoWithdrawn, utxoLoopingIn,
				})

			depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
				utxoDeposited.OutPoint: deposit.Deposited,
				utxoWithdrawn.OutPoint: deposit.Withdrawn,
				utxoLoopingIn.OutPoint: deposit.LoopingIn,
			})

			server := &swapClientServer{
				staticAddressManager: addrMgr,
				depositManager:       depMgr,
			}

			resp, err := server.ListUnspentDeposits(
				ctx, &looprpc.ListUnspentDepositsRequest{},
			)
			require.NoError(t, err)
			require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)

			require.Len(t, resp.Utxos, 1)
			got := map[string]struct{}{}
			for _, u := range resp.Utxos {
				got[u.Outpoint] = struct{}{}
			}
			_, ok := got[utxoDeposited.OutPoint.String()]
			require.True(t, ok)
		})

	// A wallet-visible UTXO reconciled by EnsureDepositsFresh should be
	// returned in the same ListUnspentDeposits call.
	t.Run("freshly reconciled wallet utxo is included", func(t *testing.T) {
		mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown})

		depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{})
		depMgr.onEnsureDepositsFresh = func(
			m *listUnspentDepositManager) {

			d := &deposit.Deposit{
				OutPoint: utxoConfirmedUnknown.OutPoint,
			}
			d.SetState(deposit.Deposited)
			m.byOutpoint[d.OutPoint.String()] = d
		}

		server := &swapClientServer{
			staticAddressManager: addrMgr,
			depositManager:       depMgr,
		}

		resp, err := server.ListUnspentDeposits(
			ctx, &looprpc.ListUnspentDepositsRequest{},
		)
		require.NoError(t, err)
		require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)

		require.Len(t, resp.Utxos, 1)
		require.Equal(
			t, utxoConfirmedUnknown.OutPoint.String(),
			resp.Utxos[0].Outpoint,
		)
	})
}
