package cosmosclient_test

import (
	"bufio"
	"context"
	"encoding/hex"
	"fmt"
	"io"
	"os"
	"testing"
	"time"

	"cosmossdk.io/math"
	"github.com/cometbft/cometbft/p2p"
	ctypes "github.com/cometbft/cometbft/rpc/core/types"
	tmtypes "github.com/cometbft/cometbft/types"
	"github.com/cosmos/cosmos-sdk/client/flags"
	sdktypes "github.com/cosmos/cosmos-sdk/types"
	"github.com/cosmos/cosmos-sdk/types/tx/signing"
	banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	"github.com/ignite/cli/v29/ignite/pkg/cosmosaccount"
	"github.com/ignite/cli/v29/ignite/pkg/cosmosclient"
	"github.com/ignite/cli/v29/ignite/pkg/cosmosclient/mocks"
	"github.com/ignite/cli/v29/ignite/pkg/cosmosclient/testutil"
	"github.com/ignite/cli/v29/ignite/pkg/cosmosfaucet"
	"github.com/ignite/cli/v29/ignite/pkg/errors"
)

const (
	defaultFaucetDenom     = "token"
	defaultFaucetMinAmount = 100
)

type suite struct {
	rpcClient        *mocks.RPCClient
	accountRetriever *mocks.AccountRetriever
	bankQueryClient  *mocks.BankQueryClient
	gasometer        *mocks.Gasometer
	faucetClient     *mocks.FaucetClient
	signer           *mocks.Signer
}

func newClient(t *testing.T, setup func(suite), opts ...cosmosclient.Option) cosmosclient.Client {
	t.Helper()

	s := suite{
		rpcClient:        mocks.NewRPCClient(t),
		accountRetriever: mocks.NewAccountRetriever(t),
		bankQueryClient:  mocks.NewBankQueryClient(t),
		gasometer:        mocks.NewGasometer(t),
		faucetClient:     mocks.NewFaucetClient(t),
		signer:           mocks.NewSigner(t),
	}
	// Because rpcClient is passed as argument inside clientContext of mocked
	// methods, we must EXPECT a call to String (because testify/mock is calling
	// String() on mocked methods' args)
	s.rpcClient.EXPECT().String().Return("plop").Maybe()
	// cosmosclient.New always makes a call to Status
	s.rpcClient.EXPECT().Status(mock.Anything).
		Return(&ctypes.ResultStatus{
			NodeInfo: p2p.DefaultNodeInfo{Network: "mychain"},
		}, nil).Once()
	if setup != nil {
		setup(s)
	}
	opts = append(opts, []cosmosclient.Option{
		cosmosclient.WithKeyringBackend(cosmosaccount.KeyringMemory),
		cosmosclient.WithRPCClient(s.rpcClient),
		cosmosclient.WithAccountRetriever(s.accountRetriever),
		cosmosclient.WithBankQueryClient(s.bankQueryClient),
		cosmosclient.WithGasometer(s.gasometer),
		cosmosclient.WithFaucetClient(s.faucetClient),
		cosmosclient.WithSigner(s.signer),
	}...)
	c, err := cosmosclient.New(context.Background(), opts...)
	require.NoError(t, err)
	return c
}

func TestNew(t *testing.T) {
	c := newClient(t, nil)

	ctx := c.Context()
	require.Equal(t, "mychain", ctx.ChainID)
	require.NotNil(t, ctx.InterfaceRegistry)
	require.NotNil(t, ctx.Codec)
	require.NotNil(t, ctx.TxConfig)
	require.NotNil(t, ctx.LegacyAmino)
	require.Equal(t, bufio.NewReader(os.Stdin), ctx.Input)
	require.Equal(t, io.Discard, ctx.Output)
	require.NotNil(t, ctx.AccountRetriever)
	require.Equal(t, flags.BroadcastSync, ctx.BroadcastMode)
	home, err := os.UserHomeDir()
	require.NoError(t, err)
	require.Equal(t, home+"/.mychain", ctx.HomeDir)
	require.NotNil(t, ctx.Client)
	require.True(t, ctx.SkipConfirm)
	require.Equal(t, c.AccountRegistry.Keyring, ctx.Keyring)
	require.False(t, ctx.GenerateOnly)
	txf := c.TxFactory
	require.Equal(t, "mychain", txf.ChainID())
	require.Equal(t, c.AccountRegistry.Keyring, txf.Keybase())
	require.EqualValues(t, 300000, txf.Gas())
	require.Equal(t, 1.0, txf.GasAdjustment())
	require.Equal(t, signing.SignMode_SIGN_MODE_UNSPECIFIED, txf.SignMode())
	require.NotNil(t, txf.AccountRetriever())
}

func TestClientWaitForBlockHeight(t *testing.T) {
	targetBlockHeight := int64(42)
	tests := []struct {
		name          string
		timeout       time.Duration
		expectedError string
		setup         func(suite)
	}{
		{
			name: "ok: no wait",
			setup: func(s suite) {
				s.rpcClient.EXPECT().Status(mock.Anything).Return(&ctypes.ResultStatus{
					SyncInfo: ctypes.SyncInfo{LatestBlockHeight: targetBlockHeight},
				}, nil)
			},
		},
		{
			name:    "ok: wait 1 time",
			timeout: time.Second * 2, // must exceed the wait loop duration
			setup: func(s suite) {
				s.rpcClient.EXPECT().Status(mock.Anything).Return(&ctypes.ResultStatus{
					SyncInfo: ctypes.SyncInfo{LatestBlockHeight: targetBlockHeight - 1},
				}, nil).Once()
				s.rpcClient.EXPECT().Status(mock.Anything).Return(&ctypes.ResultStatus{
					SyncInfo: ctypes.SyncInfo{LatestBlockHeight: targetBlockHeight},
				}, nil).Once()
			},
		},
		{
			name:          "fail: wait expired",
			timeout:       time.Millisecond,
			expectedError: "timeout exceeded waiting for block: context deadline exceeded",
			setup: func(s suite) {
				s.rpcClient.EXPECT().Status(mock.Anything).Return(&ctypes.ResultStatus{
					SyncInfo: ctypes.SyncInfo{LatestBlockHeight: targetBlockHeight - 1},
				}, nil)
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := newClient(t, tt.setup)
			ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
			defer cancel()

			err := c.WaitForBlockHeight(ctx, targetBlockHeight)

			if tt.expectedError != "" {
				require.EqualError(t, err, tt.expectedError)
				return
			}
			require.NoError(t, err)
		})
	}
}

func TestClientWaitForTx(t *testing.T) {
	var (
		ctx          = context.Background()
		hash         = "abcd"
		hashBytes, _ = hex.DecodeString(hash)
		result       = &ctypes.ResultTx{
			Hash: hashBytes,
		}
	)
	tests := []struct {
		name           string
		hash           string
		expectedError  string
		expectedResult *ctypes.ResultTx
		setup          func(suite)
	}{
		{
			name:          "fail: hash not in hex format",
			hash:          "zzz",
			expectedError: "unable to decode tx hash 'zzz': encoding/hex: invalid byte: U+007A 'z'",
		},
		{
			name:           "ok: tx found immediately",
			hash:           hash,
			expectedResult: result,
			setup: func(s suite) {
				s.rpcClient.EXPECT().Tx(ctx, hashBytes, false).Return(result, nil)
			},
		},
		{
			name:          "fail: tx returns an unexpected error",
			hash:          hash,
			expectedError: "fetching tx 'abcd': error while requesting node 'http://localhost:26657': oups",
			setup: func(s suite) {
				s.rpcClient.EXPECT().Tx(ctx, hashBytes, false).Return(nil, errors.New("oups"))
			},
		},
		{
			name:           "ok: tx found after 1 block",
			hash:           hash,
			expectedResult: result,
			setup: func(s suite) {
				// tx is not found
				s.rpcClient.EXPECT().Tx(ctx, hashBytes, false).Return(nil, errors.New("tx abcd not found")).Once()
				// wait for next block
				s.rpcClient.EXPECT().Status(ctx).Return(&ctypes.ResultStatus{
					SyncInfo: ctypes.SyncInfo{LatestBlockHeight: 1},
				}, nil).Once()
				s.rpcClient.EXPECT().Status(ctx).Return(&ctypes.ResultStatus{
					SyncInfo: ctypes.SyncInfo{LatestBlockHeight: 2},
				}, nil).Once()
				// next block reached, check tx again, this time it's found.
				s.rpcClient.EXPECT().Tx(ctx, hashBytes, false).Return(result, nil).Once()
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := newClient(t, tt.setup)

			res, err := c.WaitForTx(ctx, tt.hash)

			if tt.expectedError != "" {
				require.EqualError(t, err, tt.expectedError)
				return
			}
			require.NoError(t, err)
			require.Equal(t, tt.expectedResult, res)
		})
	}
}

func TestClientAccount(t *testing.T) {
	var (
		accountName = "bob"
		passphrase  = "passphrase"
	)
	r, err := cosmosaccount.NewInMemory()
	require.NoError(t, err)
	expectedAccount, _, err := r.Create(accountName)
	require.NoError(t, err)
	expectedAddr, err := expectedAccount.Address("cosmos")
	require.NoError(t, err)
	// Export created account to we can import it in the Client below.
	key, err := r.Export(accountName, passphrase)
	require.NoError(t, err)

	tests := []struct {
		name          string
		addressOrName string
		expectedError string
	}{
		{
			name:          "ok: find by name",
			addressOrName: expectedAccount.Name,
		},
		{
			name:          "ok: find by address",
			addressOrName: expectedAddr,
		},
		{
			name:          "fail: name not found",
			addressOrName: "unknown",
			expectedError: "decoding bech32 failed: invalid bech32 string length 7",
		},
		{
			name:          "fail: address not found",
			addressOrName: "cosmos1cs4hpwrpna6ucsgsa78jfp403l7gdynukrxkrv",
			expectedError: `account "cosmos1cs4hpwrpna6ucsgsa78jfp403l7gdynukrxkrv" does not exist`,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := newClient(t, nil)
			_, err := c.AccountRegistry.Import(accountName, key, passphrase)
			require.NoError(t, err)

			account, err := c.Account(tt.addressOrName)

			if tt.expectedError != "" {
				require.EqualError(t, err, tt.expectedError)
				return
			}
			require.NoError(t, err)
			require.Equal(t, expectedAccount, account)
		})
	}
}

func TestClientAddress(t *testing.T) {
	var (
		accountName = "bob"
		passphrase  = "passphrase"
	)
	r, err := cosmosaccount.NewInMemory()
	require.NoError(t, err)
	expectedAccount, _, err := r.Create(accountName)
	require.NoError(t, err)
	// Export created account to we can import it in the Client below.
	key, err := r.Export(accountName, passphrase)
	require.NoError(t, err)

	tests := []struct {
		name           string
		accountName    string
		opts           []cosmosclient.Option
		expectedError  string
		expectedPrefix string
	}{
		{
			name:           "ok: name exists",
			accountName:    expectedAccount.Name,
			expectedPrefix: "cosmos",
		},
		{
			name: "ok: name exists with different prefix",
			opts: []cosmosclient.Option{
				cosmosclient.WithAddressPrefix("test"),
			},
			accountName:    expectedAccount.Name,
			expectedPrefix: "test",
		},
		{
			name:          "fail: name not found",
			accountName:   "unknown",
			expectedError: `account "unknown" does not exist`,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := newClient(t, nil, tt.opts...)
			_, err := c.AccountRegistry.Import(accountName, key, passphrase)
			require.NoError(t, err)

			address, err := c.Address(tt.accountName)

			if tt.expectedError != "" {
				require.EqualError(t, err, tt.expectedError)
				return
			}
			require.NoError(t, err)
			expectedAddr, err := expectedAccount.Address(tt.expectedPrefix)
			require.NoError(t, err)
			require.Equal(t, expectedAddr, address)
		})
	}
}

func TestClientStatus(t *testing.T) {
	var (
		ctx            = context.Background()
		expectedStatus = &ctypes.ResultStatus{
			NodeInfo: p2p.DefaultNodeInfo{Network: "mychain"},
		}
	)
	tests := []struct {
		name          string
		expectedError string
		setup         func(suite)
	}{
		{
			name: "ok",
			setup: func(s suite) {
				s.rpcClient.EXPECT().Status(ctx).Return(expectedStatus, nil).Once()
			},
		},
		{
			name:          "fail",
			expectedError: "error while requesting node 'http://localhost:26657': oups",
			setup: func(s suite) {
				s.rpcClient.EXPECT().Status(ctx).Return(expectedStatus, errors.New("oups")).Once()
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := newClient(t, tt.setup)

			status, err := c.Status(ctx)

			if tt.expectedError != "" {
				require.EqualError(t, err, tt.expectedError)
				return
			}
			require.NoError(t, err)
			assert.Equal(t, expectedStatus, status)
		})
	}
}

func TestClientCreateTx(t *testing.T) {
	var (
		ctx         = context.Background()
		accountName = "bob"
		passphrase  = "passphrase"
	)
	r, err := cosmosaccount.NewInMemory()
	require.NoError(t, err)
	a, _, err := r.Create(accountName)
	require.NoError(t, err)
	// Export created account to we can import it in the Client below.
	key, err := r.Export(accountName, passphrase)
	require.NoError(t, err)
	sdkaddr, err := a.Record.GetAddress()
	require.NoError(t, err)

	tests := []struct {
		name           string
		opts           []cosmosclient.Option
		msg            sdktypes.Msg
		expectedJSONTx string
		expectedError  string
		setup          func(s suite)
	}{
		{
			name:          "fail: account doesn't exist",
			expectedError: "nope",
			setup: func(s suite) {
				s.accountRetriever.EXPECT().
					EnsureExists(mock.Anything, sdkaddr).Return(errors.New("nope"))
			},
		},
		{
			name: "ok: with default values",
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[],"gas_limit":"300000","payer":"","granter":""},"tip":null},"signatures":[]}`,
			setup: func(s suite) {
				s.expectPrepareFactory(sdkaddr)
			},
		},
		{
			name: "ok: with faucet enabled, account balance is high enough",
			opts: []cosmosclient.Option{
				cosmosclient.WithUseFaucet("localhost:1234", "", 0),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[],"gas_limit":"300000","payer":"","granter":""},"tip":null},"signatures":[]}`,
			setup: func(s suite) {
				s.expectMakeSureAccountHasToken(sdkaddr.String(), defaultFaucetMinAmount)

				s.expectPrepareFactory(sdkaddr)
			},
		},
		{
			name: "ok: with faucet enabled, account balance is too low",
			opts: []cosmosclient.Option{
				cosmosclient.WithUseFaucet("localhost:1234", "", 0),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[],"gas_limit":"300000","payer":"","granter":""},"tip":null},"signatures":[]}`,
			setup: func(s suite) {
				s.expectMakeSureAccountHasToken(sdkaddr.String(), defaultFaucetMinAmount-1)
				s.expectPrepareFactory(sdkaddr)
			},
		},
		{
			name: "ok: with fees",
			opts: []cosmosclient.Option{
				cosmosclient.WithFees("10token"),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[{"denom":"token","amount":"10"}],"gas_limit":"300000","payer":"","granter":""},"tip":null},"signatures":[]}`,
			setup: func(s suite) {
				s.expectPrepareFactory(sdkaddr)
			},
		},
		{
			name: "ok: with gas price",
			opts: []cosmosclient.Option{
				// Should set fees to 3*defaultGasLimit
				cosmosclient.WithGasPrices("3token"),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[{"denom":"token","amount":"900000"}],"gas_limit":"300000","payer":"","granter":""},"tip":null},"signatures":[]}`,
			setup: func(s suite) {
				s.expectPrepareFactory(sdkaddr)
			},
		},
		{
			name: "fail: with fees, gas prices and gas adjustment",
			opts: []cosmosclient.Option{
				cosmosclient.WithFees("10token"),
				cosmosclient.WithGasPrices("3token"),
				cosmosclient.WithGasAdjustment(2.1),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedError: "cannot provide both fees and gas prices",
			setup: func(s suite) {
				s.expectPrepareFactory(sdkaddr)
			},
		},
		{
			name: "ok: without empty gas limit",
			opts: []cosmosclient.Option{
				cosmosclient.WithGas(""),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[],"gas_limit":"20042","payer":"","granter":""},"tip":null},"signatures":[]}`,
			setup: func(s suite) {
				s.expectPrepareFactory(sdkaddr)
				s.gasometer.EXPECT().
					CalculateGas(mock.Anything, mock.Anything, mock.Anything).
					Return(nil, 42, nil)
			},
		},
		{
			name: "ok: without auto gas limit",
			opts: []cosmosclient.Option{
				cosmosclient.WithGas("auto"),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[],"gas_limit":"20042","payer":"","granter":""},"tip":null},"signatures":[]}`,
			setup: func(s suite) {
				s.expectPrepareFactory(sdkaddr)
				s.gasometer.EXPECT().
					CalculateGas(mock.Anything, mock.Anything, mock.Anything).
					Return(nil, 42, nil)
			},
		},
		{
			name: "ok: with gas adjustment",
			opts: []cosmosclient.Option{
				cosmosclient.WithGasAdjustment(2.4),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[],"gas_limit":"300000","payer":"","granter":""},"tip":null},"signatures":[]}`,
			setup: func(s suite) {
				s.expectPrepareFactory(sdkaddr)
			},
		},
		{
			name: "ok: without gas price and zero gas adjustment",
			opts: []cosmosclient.Option{
				cosmosclient.WithGas("auto"),
				cosmosclient.WithGasAdjustment(0),
			},
			msg: &banktypes.MsgSend{
				FromAddress: "from",
				ToAddress:   "to",
				Amount: sdktypes.NewCoins(
					sdktypes.NewCoin("token", math.NewIntFromUint64(1)),
				),
			},
			expectedJSONTx: `{"body":{"messages":[{"@type":"/cosmos.bank.v1beta1.MsgSend","from_address":"from","to_address":"to","amount":[{"denom":"token","amount":"1"}]}],"memo":"","timeout_height":"0","timeout_timestamp":null,"unordered":false,"extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[],"fee":{"amount":[],"gas_limit":"20042","payer":"","granter":""},"tip":null},"signatures":[]}
`,
			setup: func(s suite) {
				s.expectPrepareFactory(sdkaddr)
				s.gasometer.EXPECT().
					CalculateGas(mock.Anything, mock.Anything, mock.Anything).
					Return(nil, 42, nil)
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := newClient(t, tt.setup, tt.opts...)
			account, err := c.AccountRegistry.Import(accountName, key, passphrase)
			require.NoError(t, err)

			txs, err := c.CreateTx(ctx, account, tt.msg)

			if tt.expectedError != "" {
				require.EqualError(t, err, tt.expectedError)
				return
			}
			require.NoError(t, err)
			assert.NotNil(t, txs)
			bz, err := txs.EncodeJSON()
			require.NoError(t, err)
			require.JSONEq(t, tt.expectedJSONTx, string(bz))
		})
	}
}

func TestGetBlockTXs(t *testing.T) {
	m := testutil.NewTendermintClientMock(t)
	ctx := context.Background()

	// Mock the Block RPC endpoint
	block := createTestBlock(1)

	m.On("Block", ctx, &block.Height).Return(&ctypes.ResultBlock{Block: &block}, nil)

	// Mock the TxSearch RPC endpoint
	searchQry := fmt.Sprintf("tx.height=%d", block.Height)
	page := 1
	perPage := 30
	rtx := ctypes.ResultTx{}
	resSearch := ctypes.ResultTxSearch{
		Txs:        []*ctypes.ResultTx{&rtx},
		TotalCount: 1,
	}

	m.On("TxSearch", ctx, searchQry, false, &page, &perPage, "asc").Return(&resSearch, nil)

	// Create a cosmos client that uses the RPC mock
	client := cosmosclient.Client{RPC: m}

	txs, err := client.GetBlockTXs(ctx, block.Height)

	// Assert
	require.NoError(t, err)
	require.Equal(t, txs, []cosmosclient.TX{
		{
			BlockTime: block.Time,
			Raw:       &rtx,
		},
	})

	m.AssertNumberOfCalls(t, "Block", 1)
	m.AssertNumberOfCalls(t, "TxSearch", 1)
}

func TestGetBlockTXsWithBlockError(t *testing.T) {
	m := testutil.NewTendermintClientMock(t)

	wantErr := errors.New("expected error")

	// Mock the Block RPC endpoint to return an error
	m.OnBlock().Return(nil, wantErr)

	// Create a cosmos client that uses the RPC mock
	client := cosmosclient.Client{RPC: m}

	txs, err := client.GetBlockTXs(context.Background(), 1)

	// Assert
	require.ErrorIs(t, err, wantErr)
	require.Nil(t, txs)

	m.AssertNumberOfCalls(t, "Block", 1)
	m.AssertNumberOfCalls(t, "TxSearch", 0)
}

func TestGetBlockTXsPagination(t *testing.T) {
	m := testutil.NewTendermintClientMock(t)

	// Mock the Block RPC endpoint
	block := createTestBlock(1)

	m.OnBlock().Return(&ctypes.ResultBlock{Block: &block}, nil)

	// Mock the TxSearch RPC endpoint and fake the number of
	// transactions, so it is called twice to fetch two pages
	ctx := context.Background()
	searchQry := fmt.Sprintf("tx.height=%d", block.Height)
	perPage := 30
	fakeCount := perPage + 1
	first := 1
	second := 2
	firstPage := ctypes.ResultTxSearch{
		Txs:        []*ctypes.ResultTx{{}},
		TotalCount: fakeCount,
	}
	secondPage := ctypes.ResultTxSearch{
		Txs:        []*ctypes.ResultTx{{}},
		TotalCount: fakeCount,
	}

	m.On("TxSearch", ctx, searchQry, false, &first, &perPage, "asc").Return(&firstPage, nil)
	m.On("TxSearch", ctx, searchQry, false, &second, &perPage, "asc").Return(&secondPage, nil)

	// Create a cosmos client that uses the RPC mock
	client := cosmosclient.Client{RPC: m}

	txs, err := client.GetBlockTXs(ctx, block.Height)

	// Assert
	require.NoError(t, err)
	require.Equal(t, txs, []cosmosclient.TX{
		{
			BlockTime: block.Time,
			Raw:       firstPage.Txs[0],
		},
		{
			BlockTime: block.Time,
			Raw:       secondPage.Txs[0],
		},
	})

	m.AssertNumberOfCalls(t, "Block", 1)
	m.AssertNumberOfCalls(t, "TxSearch", 2)
}

func TestGetBlockTXsWithSearchError(t *testing.T) {
	m := testutil.NewTendermintClientMock(t)

	wantErr := errors.New("expected error")

	// Mock the Block RPC endpoint
	block := createTestBlock(1)

	m.OnBlock().Return(&ctypes.ResultBlock{Block: &block}, nil)

	// Mock the TxSearch RPC endpoint to return an error
	m.OnTxSearch().Return(nil, wantErr)

	// Create a cosmos client that uses the RPC mock
	client := cosmosclient.Client{RPC: m}

	txs, err := client.GetBlockTXs(context.Background(), block.Height)

	// Assert
	require.ErrorIs(t, err, wantErr)
	require.Nil(t, txs)

	m.AssertNumberOfCalls(t, "Block", 1)
	m.AssertNumberOfCalls(t, "TxSearch", 1)
}

func TestCollectTXs(t *testing.T) {
	m := testutil.NewTendermintClientMock(t)
	ctx := context.Background()

	// Mock the Status RPC endpoint to report that only two blocks exists
	status := ctypes.ResultStatus{
		SyncInfo: ctypes.SyncInfo{
			LatestBlockHeight: 2,
		},
	}

	m.On("Status", ctx).Return(&status, nil)

	// Mock the Block RPC endpoint to return two blocks
	b1 := createTestBlock(1)
	b2 := createTestBlock(2)

	m.On("Block", ctx, &b1.Height).Return(&ctypes.ResultBlock{Block: &b1}, nil)
	m.On("Block", ctx, &b2.Height).Return(&ctypes.ResultBlock{Block: &b2}, nil)

	// Mock the TxSearch RPC endpoint to return each of the two block.
	// Transactions are empty because only the pointer address is required to assert.
	page := 1
	perPage := 30
	q1 := "tx.height=1"
	r1 := ctypes.ResultTxSearch{
		Txs:        []*ctypes.ResultTx{{}},
		TotalCount: 1,
	}
	q2 := "tx.height=2"
	r2 := ctypes.ResultTxSearch{
		Txs:        []*ctypes.ResultTx{{}, {}},
		TotalCount: 2,
	}

	m.On("TxSearch", ctx, q1, false, &page, &perPage, "asc").Return(&r1, nil)
	m.On("TxSearch", ctx, q2, false, &page, &perPage, "asc").Return(&r2, nil)

	// Prepare expected values
	wantTXs := []cosmosclient.TX{
		{
			BlockTime: b1.Time,
			Raw:       r1.Txs[0],
		},
		{
			BlockTime: b2.Time,
			Raw:       r2.Txs[0],
		},
		{
			BlockTime: b2.Time,
			Raw:       r2.Txs[1],
		},
	}

	// Create a cosmos client that uses the RPC mock
	client := cosmosclient.Client{RPC: m}

	// Create a channel to receive the transactions from the two blocks.
	// The channel must be closed after the call to collect.
	tc := make(chan []cosmosclient.TX)

	// Collect all transactions
	var (
		txs  []cosmosclient.TX
		open bool
	)

	finished := make(chan struct{})
	go func() {
		defer close(finished)

		for t := range tc {
			txs = append(txs, t...)
		}
	}()

	err := client.CollectTXs(ctx, 1, tc)

	select {
	case <-time.After(time.Second):
		t.Fatal("expected CollectTXs to finish sooner")
	case <-finished:
	}

	select {
	case _, open = <-tc:
	default:
	}

	// Assert
	require.NoError(t, err)
	require.Equal(t, wantTXs, txs)
	require.False(t, open, "expected transaction channel to be closed")
}

func TestCollectTXsWithStatusError(t *testing.T) {
	m := testutil.NewTendermintClientMock(t)

	wantErr := errors.New("expected error")

	// Mock the Status RPC endpoint to return an error
	m.OnStatus().Return(nil, wantErr)

	// Create a cosmos client that uses the RPC mock
	client := cosmosclient.Client{RPC: m}

	// Create a channel to receive the transactions from the two blocks.
	// The channel must be closed after the call to collect.
	tc := make(chan []cosmosclient.TX)

	open := false
	ctx := context.Background()
	err := client.CollectTXs(ctx, 1, tc)

	select {
	case _, open = <-tc:
	default:
	}

	// Assert
	require.ErrorIs(t, err, wantErr)
	require.False(t, open, "expected transaction channel to be closed")
}

func TestCollectTXsWithBlockError(t *testing.T) {
	m := testutil.NewTendermintClientMock(t)

	wantErr := errors.New("expected error")

	// Mock the Status RPC endpoint
	status := ctypes.ResultStatus{
		SyncInfo: ctypes.SyncInfo{
			LatestBlockHeight: 1,
		},
	}

	m.OnStatus().Return(&status, nil)

	// Mock the Block RPC endpoint to return an error
	m.OnBlock().Return(nil, wantErr)

	// Create a cosmos client that uses the RPC mock
	client := cosmosclient.Client{RPC: m}

	// Create a channel to receive the transactions from the two blocks.
	// The channel must be closed after the call to collect.
	tc := make(chan []cosmosclient.TX)

	open := false
	ctx := context.Background()
	err := client.CollectTXs(ctx, 1, tc)

	select {
	case _, open = <-tc:
	default:
	}

	// Assert
	require.ErrorIs(t, err, wantErr)
	require.False(t, open, "expected transaction channel to be closed")
}

func TestCollectTXsWithContextDone(t *testing.T) {
	m := testutil.NewTendermintClientMock(t)

	// Mock the Status RPC endpoint
	status := ctypes.ResultStatus{
		SyncInfo: ctypes.SyncInfo{
			LatestBlockHeight: 1,
		},
	}

	m.OnStatus().Return(&status, nil)

	// Mock the Block RPC endpoint
	block := createTestBlock(1)

	m.OnBlock().Return(&ctypes.ResultBlock{Block: &block}, nil)

	// Mock the TxSearch RPC endpoint
	rs := ctypes.ResultTxSearch{
		Txs:        []*ctypes.ResultTx{{}},
		TotalCount: 1,
	}

	m.OnTxSearch().Return(&rs, nil)

	// Create a cosmos client that uses the RPC mock
	client := cosmosclient.Client{RPC: m}

	// Create a channel to receive the transactions from the two blocks.
	// The channel must be closed after the call to collect.
	tc := make(chan []cosmosclient.TX)

	// Create a context and cancel it so the collect call finishes because the context is done
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	open := false
	err := client.CollectTXs(ctx, 1, tc)

	select {
	case _, open = <-tc:
	default:
	}

	// Assert
	require.ErrorIs(t, err, ctx.Err())
	require.False(t, open, "expected transaction channel to be closed")
}

func (s suite) expectMakeSureAccountHasToken(address string, balance int64) {
	currentBalance := sdktypes.NewInt64Coin(defaultFaucetDenom, balance)
	s.bankQueryClient.EXPECT().Balance(
		context.Background(),
		&banktypes.QueryBalanceRequest{
			Address: address,
			Denom:   defaultFaucetDenom,
		},
	).Return(
		&banktypes.QueryBalanceResponse{
			Balance: &currentBalance,
		},
		nil,
	).Once()
	if balance >= defaultFaucetMinAmount {
		// balance is high enough, faucet won't be called
		return
	}

	s.faucetClient.EXPECT().Transfer(context.Background(),
		cosmosfaucet.TransferRequest{AccountAddress: address},
	).Return(
		cosmosfaucet.TransferResponse{}, nil,
	)

	newBalance := sdktypes.NewInt64Coin(defaultFaucetDenom, defaultFaucetMinAmount)
	s.bankQueryClient.EXPECT().Balance(
		mock.Anything,
		&banktypes.QueryBalanceRequest{
			Address: address,
			Denom:   defaultFaucetDenom,
		},
	).Return(
		&banktypes.QueryBalanceResponse{
			Balance: &newBalance,
		},
		nil,
	).Once()
}

func (s suite) expectPrepareFactory(sdkaddr sdktypes.Address) {
	s.accountRetriever.EXPECT().
		EnsureExists(mock.Anything, sdkaddr).
		Return(nil)
	s.accountRetriever.EXPECT().
		GetAccountNumberSequence(mock.Anything, sdkaddr).
		Return(1, 2, nil)
}

func createTestBlock(height int64) tmtypes.Block {
	return tmtypes.Block{
		Header: tmtypes.Header{
			Height: height,
			Time:   time.Now(),
		},
	}
}
