package testsuite

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path"
	"slices"
	"strings"
	"sync"

	interchaintest "github.com/cosmos/interchaintest/v10"
	"github.com/cosmos/interchaintest/v10/chain/cosmos"
	"github.com/cosmos/interchaintest/v10/ibc"
	"github.com/cosmos/interchaintest/v10/testreporter"
	test "github.com/cosmos/interchaintest/v10/testutil"
	mobycli "github.com/moby/moby/client"
	testifysuite "github.com/stretchr/testify/suite"
	"go.uber.org/zap"

	sdkmath "cosmossdk.io/math"

	sdk "git.cw.tr/mukan-network/mukan-sdk/types"
	banktypes "git.cw.tr/mukan-network/mukan-sdk/x/bank/types"

	"github.com/cosmos/ibc-go/e2e/internal/directories"
	"github.com/cosmos/ibc-go/e2e/relayer"
	"github.com/cosmos/ibc-go/e2e/testsuite/diagnostics"
	"github.com/cosmos/ibc-go/e2e/testsuite/query"
	transfertypes "git.cw.tr/mukan-network/mukan-ibc/modules/apps/transfer/types"
	clienttypes "git.cw.tr/mukan-network/mukan-ibc/modules/core/02-client/types"
	channeltypes "git.cw.tr/mukan-network/mukan-ibc/modules/core/04-channel/types"
)

const (
	// ChainARelayerName is the name given to the relayer wallet on ChainA
	ChainARelayerName = "rlyA"
	// ChainBRelayerName is the name given to the relayer wallet on ChainB
	ChainBRelayerName = "rlyB"
	// DefaultGasValue is the default gas value used to configure tx.Factory
	DefaultGasValue = 500_000_0000
)

// E2ETestSuite has methods and functionality which can be shared among all test suites.
type E2ETestSuite struct {
	testifysuite.Suite

	// proposalIDs keeps track of the active proposal ID for each chain.
	proposalIDs map[string]uint64

	// chains is a list of chains that are created for the test suite.
	// each test suite has a single slice of chains that are used for all individual test
	// cases.
	chains         []ibc.Chain
	relayerWallets relayer.Map
	logger         *zap.Logger
	DockerClient   *mobycli.Client
	network        string

	// pathNameIndex is the latest index to be used for generating chains
	pathNameIndex int64

	// testSuiteName is the name of the test suite, used to store chains under the test suite name.
	testSuiteName string
	testPaths     map[string][]string
	channels      map[string]map[ibc.Chain][]ibc.ChannelOutput

	// channelLock ensures concurrent tests are not creating and accessing channels as the same time.
	channelLock sync.Mutex
	// relayerLock ensures concurrent tests are not accessing the pool of relayers as the same time.
	relayerLock sync.Mutex
	// relayerPool is a pool of relayers that can be used in tests.
	relayerPool []ibc.Relayer
	// testRelayerMap is a map of test suite names to relayers that are used in the test suite.
	// this is used as a cache after a relayer has been assigned to a test suite.
	testRelayerMap map[string]ibc.Relayer
}

// initState populates variables that are used across the test suite.
// note: this should be called only from SetupSuite.
func (s *E2ETestSuite) initState() {
	s.initDockerClient()
	s.proposalIDs = map[string]uint64{}
	s.testPaths = make(map[string][]string)
	s.channels = make(map[string]map[ibc.Chain][]ibc.ChannelOutput)
	s.relayerPool = []ibc.Relayer{}
	s.testRelayerMap = make(map[string]ibc.Relayer)
	s.relayerWallets = make(relayer.Map)

	// testSuiteName gets populated in the context of SetupSuite and stored as s.T().Name()
	// will return the name of the suite and test when called from SetupTest or within the body of tests.
	// the chains will be stored under the test suite name, so we need to store this for future use.
	s.testSuiteName = s.T().Name()
}

// initDockerClient creates a docker client and populates the network to be used for the test.
func (s *E2ETestSuite) initDockerClient() {
	client, network := interchaintest.DockerSetup(s.T())
	s.logger = zap.NewExample()
	s.DockerClient = client
	s.network = network
}

// configureGenesisDebugExport sets, if needed, env variables to enable exporting of Genesis debug files.
func (s *E2ETestSuite) configureGenesisDebugExport() {
	tc := LoadConfig()
	t := s.T()
	cfg := tc.DebugConfig.GenesisDebug
	if !cfg.DumpGenesisDebugInfo {
		return
	}

	// Set the export path.
	exportPath := cfg.ExportFilePath

	// If no path is provided, use the default (e2e/diagnostics/genesis.json).
	if exportPath == "" {
		e2eDir, err := directories.E2E()
		s.Require().NoError(err, "can't get e2edir")
		exportPath = path.Join(e2eDir, directories.DefaultGenesisExportPath)
	}

	if !path.IsAbs(exportPath) {
		wd, err := os.Getwd()
		s.Require().NoError(err, "can't get working directory")
		exportPath = path.Join(wd, exportPath)
	}

	// These environment variables are set by interchaintest at runtime.
	// Reference: https://github.com/cosmos/interchaintest/blob/main/chain/cosmos/cosmos_chain.go
	t.Setenv("EXPORT_GENESIS_FILE_PATH", exportPath)

	chainName := tc.GetGenesisChainName()
	chainIdx, err := tc.GetChainIndex(chainName)
	s.Require().NoError(err)

	// interchaintest adds a numeric suffix to the chain name, so we do the same.
	genesisChainName := fmt.Sprintf("%s-%d", chainName, chainIdx+1)
	t.Setenv("EXPORT_GENESIS_CHAIN", genesisChainName)
}

// initializeRelayerPool pre-loads the relayer pool with n relayers.
// this is a workaround due to the restriction on relayer creation during the test
// If/when interchaintest supports relayer creation during tests, this can be made lazy per-test.
func (s *E2ETestSuite) initializeRelayerPool(n int) []ibc.Relayer {
	var relayers []ibc.Relayer
	for range n {
		relayers = append(relayers, relayer.New(s.T(), *LoadConfig().GetActiveRelayerConfig(), s.logger, s.DockerClient, s.network))
	}
	return relayers
}

// SetupChains creates the chains for the test suite, and also a relayer that is wired up to establish
// connections and channels between the chains.
func (s *E2ETestSuite) SetupChains(ctx context.Context, chainCount int, channelOptionsModifier ChainOptionModifier, chainSpecOpts ...ChainOptionConfiguration) {
	s.T().Logf("Setting up %d chains: %s", chainCount, s.T().Name())

	if LoadConfig().DebugConfig.KeepContainers {
		s.Require().NoError(os.Setenv(KeepContainersEnv, "true"))
	}

	s.initState()
	s.configureGenesisDebugExport()

	chainOptions, err := DefaultChainOptions(chainCount)
	s.Require().NoError(err)

	for _, opt := range chainSpecOpts {
		opt(&chainOptions)
	}

	specCount := len(chainOptions.ChainSpecs)
	s.Require().GreaterOrEqualf(specCount, chainCount, "wants to create %d chains, but DefaultChainOptions has %d configurations", chainCount, specCount)

	s.chains = s.createChains(chainCount, chainOptions)

	s.relayerPool = s.initializeRelayerPool(chainOptions.RelayerCount)

	ic := s.newInterchain(s.relayerPool, s.chains, channelOptionsModifier)

	buildOpts := interchaintest.InterchainBuildOptions{
		TestName:  s.T().Name(),
		Client:    s.DockerClient,
		NetworkID: s.network,
		// we skip path creation because we are just creating the chains and not connections/channels
		SkipPathCreation: true,
	}

	s.Require().NoError(ic.Build(ctx, s.GetRelayerExecReporter(), buildOpts))

	// setup query paths for GRPC queries:
	for _, chain := range s.chains {
		s.Require().NoError(query.PopulateQueryReqToPath(ctx, chain))
	}
}

// CreateDefaultPaths creates a path between the chains using the default client and channel options.
// this should be called as the setup function in most tests if no additional options are required.
func (s *E2ETestSuite) CreateDefaultPaths(testName string) ibc.Relayer {
	return s.CreatePaths(ibc.DefaultClientOpts(), DefaultChannelOpts(s.GetAllChains()), testName)
}

// CreatePaths creates paths between the chains using the provided client and channel options.
// The paths are created such that ChainA is connected to ChainB, ChainB is connected to ChainC etc.
func (s *E2ETestSuite) CreatePaths(clientOpts ibc.CreateClientOptions, channelOpts ibc.CreateChannelOptions, testName string) ibc.Relayer {
	s.T().Logf("Setting up path for: %s", testName)

	if s.channels[testName] == nil {
		s.channels[testName] = make(map[ibc.Chain][]ibc.ChannelOutput)
	}

	r := s.GetRelayerForTest(testName)

	ctx := context.TODO()
	allChains := s.GetAllChains()
	for i := range len(allChains) - 1 {
		chainA, chainB := allChains[i], allChains[i+1]
		s.CreatePath(ctx, r, chainA, chainB, clientOpts, channelOpts, testName)
	}

	return r
}

// CreatePath creates a path between chainA and chainB using the provided client and channel options.
func (s *E2ETestSuite) CreatePath(
	ctx context.Context,
	r ibc.Relayer,
	chainA ibc.Chain,
	chainB ibc.Chain,
	clientOpts ibc.CreateClientOptions,
	channelOpts ibc.CreateChannelOptions,
	testName string,
) (chainAChannel ibc.ChannelOutput, chainBChannel ibc.ChannelOutput) {
	pathName := s.generatePathName()
	s.testPaths[testName] = append(s.testPaths[testName], pathName)

	s.T().Logf("establishing path between %s and %s on path %s", chainA.Config().ChainID, chainB.Config().ChainID, pathName)

	err := r.GeneratePath(ctx, s.GetRelayerExecReporter(), chainA.Config().ChainID, chainB.Config().ChainID, pathName)
	s.Require().NoError(err)

	// Create new clients
	err = r.CreateClients(ctx, s.GetRelayerExecReporter(), pathName, clientOpts)
	s.Require().NoError(err)
	err = test.WaitForBlocks(ctx, 1, chainA, chainB)
	s.Require().NoError(err)

	err = r.CreateConnections(ctx, s.GetRelayerExecReporter(), pathName)
	s.Require().NoError(err)
	err = test.WaitForBlocks(ctx, 1, chainA, chainB)
	s.Require().NoError(err)

	s.createChannelWithLock(ctx, r, pathName, testName, channelOpts, chainA, chainB)

	aChannels := s.channels[testName][chainA]
	bChannels := s.channels[testName][chainB]

	return aChannels[len(aChannels)-1], bChannels[len(bChannels)-1]
}

// createChannelWithLock creates a channel between the two provided chains for the given test name. This applies a lock
// to ensure that the channels that are created are correctly mapped to the test that created them.
func (s *E2ETestSuite) createChannelWithLock(ctx context.Context, r ibc.Relayer, pathName, testName string, channelOpts ibc.CreateChannelOptions, chainA, chainB ibc.Chain) {
	// NOTE: we need to lock the creation of channels and applying of packet filters, as if we don't, the result
	// of `r.GetChannels` may return channels created by other relayers in different tests.
	s.channelLock.Lock()
	defer s.channelLock.Unlock()

	err := r.CreateChannel(ctx, s.GetRelayerExecReporter(), pathName, channelOpts)
	s.Require().NoError(err)
	err = test.WaitForBlocks(ctx, 1, chainA, chainB)
	s.Require().NoError(err)

	for _, c := range []ibc.Chain{chainA, chainB} {
		channels, err := r.GetChannels(ctx, s.GetRelayerExecReporter(), c.Config().ChainID)
		s.Require().NoError(err)

		if _, ok := s.channels[testName][c]; !ok {
			s.channels[testName][c] = []ibc.ChannelOutput{}
		}

		// keep track of channels associated with a given chain for access within the tests.
		// only the most recent channel is relevant.
		s.channels[testName][c] = append(s.channels[testName][c], getLatestChannel(channels))

		err = relayer.ApplyPacketFilter(ctx, s.T(), r, c.Config().ChainID, s.channels[testName][c])
		s.Require().NoError(err, "failed to watch port and channel on chain: %s", c.Config().ChainID)
	}
}

// getLatestChannel returns the latest channel from the list of channels.
func getLatestChannel(channels []ibc.ChannelOutput) ibc.ChannelOutput {
	return slices.MaxFunc(channels, func(a, b ibc.ChannelOutput) int {
		seqA, _ := channeltypes.ParseChannelSequence(a.ChannelID)
		seqB, _ := channeltypes.ParseChannelSequence(b.ChannelID)
		return int(seqA - seqB)
	})
}

// GetChainAChannelForTest returns the ibc.ChannelOutput for the current test.
// this defaults to the first entry in the list, and will be what is needed in the case of
// a single channel test.
func (s *E2ETestSuite) GetChainAChannelForTest(testName string) ibc.ChannelOutput {
	return s.GetChannelsForTest(s.GetAllChains()[0], testName)[0]
}

// GetChannelsForTest returns all channels for the specified test.
func (s *E2ETestSuite) GetChannelsForTest(chain ibc.Chain, testName string) []ibc.ChannelOutput {
	channels, ok := s.channels[testName][chain]
	s.Require().True(ok, "channel not found for test %s", testName)
	return channels
}

// GetRelayerForTest returns the relayer for the current test from the available pool of relayers.
// once a relayer has been returned to a test, it is cached and will be reused for the duration of the test.
func (s *E2ETestSuite) GetRelayerForTest(testName string) ibc.Relayer {
	s.relayerLock.Lock()
	defer s.relayerLock.Unlock()

	if r, ok := s.testRelayerMap[testName]; ok {
		s.T().Logf("relayer already created for test: %s", testName)
		return r
	}

	if len(s.relayerPool) == 0 {
		panic(errors.New("relayer pool is empty"))
	}

	r := s.relayerPool[0]

	// remove the relayer from the pool
	s.relayerPool = s.relayerPool[1:]

	s.testRelayerMap[testName] = r

	return r
}

// GetRelayerUsers returns two ibc.Wallet instances which can be used for the relayer users
// on the two chains.
func (s *E2ETestSuite) GetRelayerUsers(ctx context.Context, testName string) (ibc.Wallet, ibc.Wallet) {
	chains := s.GetAllChains()
	chainA, chainB := chains[0], chains[1]

	rlyAName := fmt.Sprintf("%s-%s", ChainARelayerName, testName)
	rlyBName := fmt.Sprintf("%s-%s", ChainBRelayerName, testName)

	chainAAccountBytes, err := chainA.GetAddress(ctx, rlyAName)
	s.Require().NoError(err)

	chainBAccountBytes, err := chainB.GetAddress(ctx, rlyBName)
	s.Require().NoError(err)

	chainARelayerUser := cosmos.NewWallet(rlyAName, chainAAccountBytes, "", chainA.Config())
	chainBRelayerUser := cosmos.NewWallet(rlyBName, chainBAccountBytes, "", chainB.Config())

	s.relayerWallets.AddRelayer(testName, chainARelayerUser)
	s.relayerWallets.AddRelayer(testName, chainBRelayerUser)

	return chainARelayerUser, chainBRelayerUser
}

// ChainOptionModifier is a function which accepts 2 chains as inputs, and returns a channel creation modifier function
// in order to conditionally modify the channel options based on the chains being used.
type ChainOptionModifier func(chainA, chainB ibc.Chain) func(options *ibc.CreateChannelOptions)

// newInterchain constructs a new interchain instance that creates channels between the chains.
func (s *E2ETestSuite) newInterchain(relayers []ibc.Relayer, chains []ibc.Chain, modificationProvider ChainOptionModifier) *interchaintest.Interchain {
	ic := interchaintest.NewInterchain()
	for _, chain := range chains {
		ic.AddChain(chain)
	}

	for i, r := range relayers {
		ic.AddRelayer(r, fmt.Sprintf("r-%d", i))
	}

	// iterate through all chains, and create links such that there is a channel between
	// - chainA and chainB
	// - chainB and chainC
	// - chainC and chainD etc
	for i := range len(chains) - 1 {
		pathName := s.generatePathName()
		channelOpts := DefaultChannelOpts(chains)
		chain1, chain2 := chains[i], chains[i+1]

		if modificationProvider != nil {
			// make a modification to the channel options based on the chains which are being used.
			modificationFn := modificationProvider(chain1, chain2)
			modificationFn(&channelOpts)
		}

		for _, r := range relayers {
			ic.AddLink(interchaintest.InterchainLink{
				Chain1:            chains[i],
				Chain2:            chains[i+1],
				Relayer:           r,
				Path:              pathName,
				CreateChannelOpts: channelOpts,
			})
		}
	}

	return ic
}

// generatePathName generates the path name using the test suites name
func (s *E2ETestSuite) generatePathName() string {
	pathName := GetPathName(s.pathNameIndex)
	s.pathNameIndex++
	return pathName
}

func (s *E2ETestSuite) GetPaths(testName string) []string {
	paths, ok := s.testPaths[testName]
	s.Require().True(ok, "paths not found for test %s", testName)
	return paths
}

// GetPathName returns the name of a path at a specific index. This can be used in tests
// when the path name is required.
func GetPathName(idx int64) string {
	pathName := fmt.Sprintf("path-%d", idx)
	return strings.ReplaceAll(pathName, "/", "-")
}

// generatePath generates the path name using the test suites name. The indices provided specify which chains should be
// used. E.g. to generate a path between chain A and B, you would use 0 and 1, to specify between A and C, you would
// use 0 and 2 etc.
func (s *E2ETestSuite) generatePath(ctx context.Context, ibcrelayer ibc.Relayer, chainAIdx, chainBIdx int) string {
	chains := s.GetAllChains()
	chainA, chainB := chains[chainAIdx], chains[chainBIdx]
	chainAID := chainA.Config().ChainID
	chainBID := chainB.Config().ChainID

	pathName := s.generatePathName()

	err := ibcrelayer.GeneratePath(ctx, s.GetRelayerExecReporter(), chainAID, chainBID, pathName)
	s.Require().NoError(err)

	return pathName
}

// SetupClients creates clients on chainA and chainB using the provided create client options
func (s *E2ETestSuite) SetupClients(ctx context.Context, ibcrelayer ibc.Relayer, opts ibc.CreateClientOptions) {
	pathName := s.generatePath(ctx, ibcrelayer, 0, 1)
	err := ibcrelayer.CreateClients(ctx, s.GetRelayerExecReporter(), pathName, opts)
	s.Require().NoError(err)
}

// UpdateClients updates clients on chainA and chainB
func (s *E2ETestSuite) UpdateClients(ctx context.Context, ibcrelayer ibc.Relayer, pathName string) {
	err := ibcrelayer.UpdateClients(ctx, s.GetRelayerExecReporter(), pathName)
	s.Require().NoError(err)
}

// GetChains returns two chains that can be used in a test. The pair returned
// is unique to the current test being run. Note: this function does not create containers.
func (s *E2ETestSuite) GetChains() (ibc.Chain, ibc.Chain) {
	chains := s.GetAllChains()
	return chains[0], chains[1]
}

// GetAllChains returns all chains that can be used in a test. The chains returned
// are unique to the current test being run. Note: this function does not create containers.
func (s *E2ETestSuite) GetAllChains() []ibc.Chain {
	// chains are stored on a per test suite level
	chains := s.chains
	s.Require().NotEmpty(chains, "chains not found for test %s", s.testSuiteName)
	return chains
}

// GetRelayerWallets returns the ibcrelayer wallets associated with the chains.
func (s *E2ETestSuite) GetRelayerWallets(ibcrelayer ibc.Relayer) (ibc.Wallet, ibc.Wallet, error) {
	chains := s.GetAllChains()
	chainA, chainB := chains[0], chains[1]
	chainARelayerWallet, ok := ibcrelayer.GetWallet(chainA.Config().ChainID)
	if !ok {
		return nil, nil, errors.New("unable to find chain A relayer wallet")
	}

	chainBRelayerWallet, ok := ibcrelayer.GetWallet(chainB.Config().ChainID)
	if !ok {
		return nil, nil, errors.New("unable to find chain B relayer wallet")
	}
	return chainARelayerWallet, chainBRelayerWallet, nil
}

// RecoverRelayerWallets adds the corresponding ibcrelayer address to the keychain of the chain.
// This is useful if commands executed on the chains expect the relayer information to present in the keychain.
func (s *E2ETestSuite) RecoverRelayerWallets(ctx context.Context, ibcrelayer ibc.Relayer, testName string) (ibc.Wallet, ibc.Wallet, error) {
	chainARelayerWallet, chainBRelayerWallet, err := s.GetRelayerWallets(ibcrelayer)
	if err != nil {
		return nil, nil, err
	}

	chains := s.GetAllChains()
	chainA, chainB := chains[0], chains[1]

	rlyAName := fmt.Sprintf("%s-%s", ChainARelayerName, testName)
	rlyBName := fmt.Sprintf("%s-%s", ChainBRelayerName, testName)

	if err := chainA.RecoverKey(ctx, rlyAName, chainARelayerWallet.Mnemonic()); err != nil {
		return nil, nil, fmt.Errorf("could not recover relayer wallet on chain A: %s", err)
	}
	if err := chainB.RecoverKey(ctx, rlyBName, chainBRelayerWallet.Mnemonic()); err != nil {
		return nil, nil, fmt.Errorf("could not recover relayer wallet on chain B: %s", err)
	}
	return chainARelayerWallet, chainBRelayerWallet, nil
}

// StartRelayer starts the given ibcrelayer.
func (s *E2ETestSuite) StartRelayer(r ibc.Relayer, testName string) {
	s.Require().NoError(r.StartRelayer(context.TODO(), s.GetRelayerExecReporter(), s.GetPaths(testName)...), "failed to start relayer")

	chains := s.GetAllChains()
	var chainHeighters []test.ChainHeighter
	for _, c := range chains {
		chainHeighters = append(chainHeighters, c)
	}

	// wait for every chain to produce some blocks before using the relayer.
	s.Require().NoError(test.WaitForBlocks(context.TODO(), 10, chainHeighters...), "failed to wait for blocks")
}

// StopRelayer stops the given ibcrelayer.
func (s *E2ETestSuite) StopRelayer(ctx context.Context, ibcrelayer ibc.Relayer) {
	err := ibcrelayer.StopRelayer(ctx, s.GetRelayerExecReporter())
	s.Require().NoError(err)
}

// RestartRelayer restarts the given relayer.
func (s *E2ETestSuite) RestartRelayer(ctx context.Context, ibcrelayer ibc.Relayer, testName string) {
	s.StopRelayer(ctx, ibcrelayer)
	s.StartRelayer(ibcrelayer, testName)
}

// CreateUserOnChainA creates a user with the given amount of funds on chain A.
func (s *E2ETestSuite) CreateUserOnChainA(ctx context.Context, amount int64) ibc.Wallet {
	return s.createWalletOnChainIndex(ctx, amount, 0)
}

// CreateUserOnChainB creates a user with the given amount of funds on chain B.
func (s *E2ETestSuite) CreateUserOnChainB(ctx context.Context, amount int64) ibc.Wallet {
	return s.createWalletOnChainIndex(ctx, amount, 1)
}

// CreateUserOnChainC creates a user with the given amount of funds on chain C.
func (s *E2ETestSuite) CreateUserOnChainC(ctx context.Context, amount int64) ibc.Wallet {
	return s.createWalletOnChainIndex(ctx, amount, 2)
}

// createWalletOnChainIndex creates a wallet with the given amount of funds on the chain of the given index.
func (s *E2ETestSuite) createWalletOnChainIndex(ctx context.Context, amount, chainIndex int64) ibc.Wallet {
	chain := s.GetAllChains()[chainIndex]
	wallet := interchaintest.GetAndFundTestUsers(s.T(), ctx, strings.ReplaceAll(s.T().Name(), " ", "-"), sdkmath.NewInt(amount), chain)[0]
	// note the GetAndFundTestUsers requires the caller to wait for some blocks before the funds are accessible.
	s.Require().NoError(test.WaitForBlocks(ctx, 2, chain))
	return wallet
}

// GetChainANativeBalance gets the balance of a given user on chain A.
func (s *E2ETestSuite) GetChainANativeBalance(ctx context.Context, user ibc.Wallet) (int64, error) {
	chainA := s.GetAllChains()[0]
	return GetChainBalanceForDenom(ctx, chainA, chainA.Config().Denom, user)
}

// GetChainBNativeBalance gets the balance of a given user on chain B.
func (s *E2ETestSuite) GetChainBNativeBalance(ctx context.Context, user ibc.Wallet) (int64, error) {
	chainB := s.GetAllChains()[1]
	return GetChainBalanceForDenom(ctx, chainB, chainB.Config().Denom, user)
}

// GetChainBalanceForDenom returns the balance for a given denom given a chain.
func GetChainBalanceForDenom(ctx context.Context, chain ibc.Chain, denom string, user ibc.Wallet) (int64, error) {
	balanceResp, err := query.GRPCQuery[banktypes.QueryBalanceResponse](ctx, chain, &banktypes.QueryBalanceRequest{
		Address: user.FormattedAddress(),
		Denom:   denom,
	})
	if err != nil {
		return 0, err
	}

	return balanceResp.Balance.Amount.Int64(), nil
}

// AssertPacketRelayed asserts that the packet commitment does not exist on the sending chain.
// The packet commitment will be deleted upon a packet acknowledgement or timeout.
func (s *E2ETestSuite) AssertPacketRelayed(ctx context.Context, chain ibc.Chain, portID, channelID string, sequence uint64) {
	_, err := query.GRPCQuery[channeltypes.QueryPacketCommitmentResponse](ctx, chain, &channeltypes.QueryPacketCommitmentRequest{
		PortId:    portID,
		ChannelId: channelID,
		Sequence:  sequence,
	})
	s.Require().ErrorContains(err, "packet commitment hash not found")
}

// AssertPacketAcknowledged asserts that the packet has been acknowledged on the specified chain.
func (s *E2ETestSuite) AssertPacketAcknowledged(ctx context.Context, chain ibc.Chain, portID, channelID string, sequence uint64) {
	_, err := query.GRPCQuery[channeltypes.QueryPacketAcknowledgementResponse](ctx, chain, &channeltypes.QueryPacketAcknowledgementRequest{
		PortId:    portID,
		ChannelId: channelID,
		Sequence:  sequence,
	})
	s.Require().NoError(err)
}

// AssertHumanReadableDenom asserts that a human readable denom is present for a given chain.
func (s *E2ETestSuite) AssertHumanReadableDenom(ctx context.Context, chain ibc.Chain, counterpartyNativeDenom string, counterpartyChannel ibc.ChannelOutput) {
	chainIBCDenom := GetIBCToken(counterpartyNativeDenom, counterpartyChannel.Counterparty.PortID, counterpartyChannel.Counterparty.ChannelID)

	denomMetadataResp, err := query.GRPCQuery[banktypes.QueryDenomMetadataResponse](ctx, chain, &banktypes.QueryDenomMetadataRequest{
		Denom: chainIBCDenom.IBCDenom(),
	})
	s.Require().NoError(err)

	denomMetadata := denomMetadataResp.Metadata

	s.Require().Equal(chainIBCDenom.IBCDenom(), denomMetadata.Base, "denom metadata base does not match expected %s: got %s", chainIBCDenom.IBCDenom(), denomMetadata.Base)
	expectedName := fmt.Sprintf("%s/%s/%s IBC token", counterpartyChannel.Counterparty.PortID, counterpartyChannel.Counterparty.ChannelID, counterpartyNativeDenom)
	s.Require().Equal(expectedName, denomMetadata.Name, "denom metadata name does not match expected %s: got %s", expectedName, denomMetadata.Name)
	expectedDisplay := fmt.Sprintf("%s/%s/%s", counterpartyChannel.Counterparty.PortID, counterpartyChannel.Counterparty.ChannelID, counterpartyNativeDenom)
	s.Require().Equal(expectedDisplay, denomMetadata.Display, "denom metadata display does not match expected %s: got %s", expectedDisplay, denomMetadata.Display)
	s.Require().Equal(strings.ToUpper(counterpartyNativeDenom), denomMetadata.Symbol, "denom metadata symbol does not match expected %s: got %s", strings.ToUpper(counterpartyNativeDenom), denomMetadata.Symbol)
}

// createChains creates two separate chains in docker containers.
// test and can be retrieved with GetChains.
func (s *E2ETestSuite) createChains(chainCount int, chainOptions ChainOptions) []ibc.Chain {
	t := s.T()
	s.Require().GreaterOrEqualf(len(chainOptions.ChainSpecs), chainCount, "len(chainOptions.ChainSpecs): %d < chainCount: %d", len(chainOptions.ChainSpecs), chainCount)

	cf := interchaintest.NewBuiltinChainFactory(s.logger, chainOptions.ChainSpecs[:chainCount]) // Take the first N specs

	// this is intentionally called after the interchaintest.DockerSetup function. The above function registers a
	// cleanup task which deletes all containers. By registering a cleanup function afterwards, it is executed first
	// this allows us to process the logs before the containers are removed.
	t.Cleanup(func() {
		dumpLogs := LoadConfig().DebugConfig.DumpLogs
		var chainNames []string
		for _, chain := range chainOptions.ChainSpecs {
			chainNames = append(chainNames, chain.Name)
		}
		diagnostics.Collect(t, s.DockerClient, dumpLogs, s.testSuiteName, chainNames...)
	})

	chains, err := cf.Chains(t.Name())
	s.Require().NoError(err)

	// initialise proposal ids for all chains.
	for _, chain := range chains {
		s.proposalIDs[chain.Config().ChainID] = 1
	}

	return chains
}

// GetRelayerExecReporter returns a testreporter.RelayerExecReporter instances
// using the current test's testing.T.
func (s *E2ETestSuite) GetRelayerExecReporter() *testreporter.RelayerExecReporter {
	rep := testreporter.NewNopReporter()
	return rep.RelayerExecReporter(s.T())
}

// TransferChannelOptions configures both of the chains to have non-incentivized transfer channels.
func (*E2ETestSuite) TransferChannelOptions() ibc.CreateChannelOptions {
	opts := ibc.DefaultChannelOpts()
	opts.Version = transfertypes.V1
	return opts
}

// GetTimeoutHeight returns a timeout height of 1000 blocks above the current block height.
// This function should be used when the timeout is never expected to be reached
func (s *E2ETestSuite) GetTimeoutHeight(ctx context.Context, chain ibc.Chain) clienttypes.Height {
	height, err := chain.Height(ctx)
	s.Require().NoError(err)
	return clienttypes.NewHeight(clienttypes.ParseChainID(chain.Config().ChainID), uint64(height)+1000)
}

// GetIBCToken returns the denomination of the full token denom sent to the receiving channel
func GetIBCToken(fullTokenDenom string, portID, channelID string) transfertypes.Denom {
	return transfertypes.ExtractDenomFromPath(fmt.Sprintf("%s/%s/%s", portID, channelID, fullTokenDenom))
}

// getValidatorsAndFullNodes returns the number of validators and full nodes respectively that should be used for
// the test. If the test is running in CI, more nodes are used, when running locally a single node is used by default to
// use less resources and allow the tests to run faster.
// both the number of validators and full nodes can be overwritten in a config file.
func getValidatorsAndFullNodes(chainIdx int) (int, int) {
	tc := LoadConfig()
	return tc.GetChainNumValidators(chainIdx), tc.GetChainNumFullNodes(chainIdx)
}

// GetMsgTransfer returns a MsgTransfer that is constructed based on the channel version
func GetMsgTransfer(portID, channelID, version string, token sdk.Coin, sender, receiver string, timeoutHeight clienttypes.Height, timeoutTimestamp uint64, memo string) *transfertypes.MsgTransfer {
	var msg *transfertypes.MsgTransfer
	switch version {
	case transfertypes.V1:
		msg = &transfertypes.MsgTransfer{
			SourcePort:       portID,
			SourceChannel:    channelID,
			Token:            token,
			Sender:           sender,
			Receiver:         receiver,
			TimeoutHeight:    timeoutHeight,
			TimeoutTimestamp: timeoutTimestamp,
			Memo:             memo,
		}
	default:
		panic(fmt.Errorf("unsupported transfer version: %s", version))
	}

	return msg
}

// SuiteName returns the name of the test suite.
func (s *E2ETestSuite) SuiteName() string {
	return s.testSuiteName
}

// ThreeChainSetup provides the default behaviour to wire up 3 chains in the tests.
func ThreeChainSetup() ChainOptionConfiguration {
	// copy all values of existing chains and tweak to make unique to new chain.
	return func(options *ChainOptions) {
		chainCSpec := *options.ChainSpecs[0] // nolint

		chainCSpec.ChainID = "chainC-1"
		chainCSpec.Name = "simapp-c"

		options.ChainSpecs = append(options.ChainSpecs, &chainCSpec)
	}
}

// DefaultChannelOpts returns the default chain options for the test suite based on the provided chains.
func DefaultChannelOpts(chains []ibc.Chain) ibc.CreateChannelOptions {
	channelOptions := ibc.DefaultChannelOpts()
	channelOptions.Version = transfertypes.V1
	return channelOptions
}
