package keeper_test

import (
	"testing"
	"time"

	"github.com/stretchr/testify/suite"

	sdkmath "cosmossdk.io/math"
	tmprototypes "github.com/cometbft/cometbft/proto/tendermint/types"
	"github.com/cosmos/cosmos-sdk/crypto/keys/ed25519"
	sdk "github.com/cosmos/cosmos-sdk/types"

	"github.com/cosmos/cosmos-sdk/x/staking"
	stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper"
	stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types"
	"github.com/kava-labs/kava/app"
	liquidtypes "github.com/kava-labs/kava/x/liquid/types"
	"github.com/kava-labs/kava/x/savings/keeper"
	"github.com/kava-labs/kava/x/savings/types"
)

var dep = types.NewDeposit

const (
	bkava1 = "bkava-kavavaloper15gqc744d05xacn4n6w2furuads9fu4pqn6zxlu"
	bkava2 = "bkava-kavavaloper15qdefkmwswysgg4qxgqpqr35k3m49pkx8yhpte"
)

type grpcQueryTestSuite struct {
	suite.Suite

	tApp        app.TestApp
	ctx         sdk.Context
	keeper      keeper.Keeper
	queryServer types.QueryServer
	addrs       []sdk.AccAddress
}

func (suite *grpcQueryTestSuite) SetupTest() {
	suite.tApp = app.NewTestApp()
	_, addrs := app.GeneratePrivKeyAddressPairs(2)

	suite.addrs = addrs

	suite.ctx = suite.tApp.NewContext(true, tmprototypes.Header{}).
		WithBlockTime(time.Now().UTC())
	suite.keeper = suite.tApp.GetSavingsKeeper()
	suite.queryServer = keeper.NewQueryServerImpl(suite.keeper)

	err := suite.tApp.FundModuleAccount(
		suite.ctx,
		types.ModuleAccountName,
		cs(
			c("usdx", 10000000000),
			c("busd", 10000000000),
		),
	)
	suite.Require().NoError(err)

	savingsGenesis := types.GenesisState{
		Params: types.NewParams([]string{"bnb", "busd", bkava1, bkava2}),
	}
	savingsGenState := app.GenesisState{types.ModuleName: suite.tApp.AppCodec().MustMarshalJSON(&savingsGenesis)}

	suite.tApp.InitializeFromGenesisStates(
		savingsGenState,
		app.NewFundedGenStateWithSameCoins(
			suite.tApp.AppCodec(),
			cs(
				c("bnb", 10000000000),
				c("busd", 20000000000),
			),
			addrs,
		),
	)
}

func (suite *grpcQueryTestSuite) TestGrpcQueryParams() {
	res, err := suite.queryServer.Params(sdk.WrapSDKContext(suite.ctx), &types.QueryParamsRequest{})
	suite.Require().NoError(err)

	var expected types.GenesisState
	savingsGenesis := types.GenesisState{
		Params: types.NewParams([]string{"bnb", "busd", bkava1, bkava2}),
	}
	savingsGenState := app.GenesisState{types.ModuleName: suite.tApp.AppCodec().MustMarshalJSON(&savingsGenesis)}
	suite.tApp.AppCodec().MustUnmarshalJSON(savingsGenState[types.ModuleName], &expected)

	suite.Equal(expected.Params, res.Params, "params should equal test genesis state")
}

func (suite *grpcQueryTestSuite) TestGrpcQueryDeposits() {
	suite.addDeposits([]types.Deposit{
		dep(suite.addrs[0], cs(c("bnb", 100000000))),
		dep(suite.addrs[1], cs(c("bnb", 20000000))),
		dep(suite.addrs[0], cs(c("busd", 20000000))),
		dep(suite.addrs[0], cs(c("busd", 8000000))),
	})

	tests := []struct {
		giveName          string
		giveRequest       *types.QueryDepositsRequest
		wantDepositCounts int
		shouldError       bool
		errorSubstr       string
	}{
		{
			"empty query",
			&types.QueryDepositsRequest{},
			2,
			false,
			"",
		},
		{
			"owner",
			&types.QueryDepositsRequest{
				Owner: suite.addrs[0].String(),
			},
			// Excludes the second address
			1,
			false,
			"",
		},
		{
			"invalid owner",
			&types.QueryDepositsRequest{
				Owner: "invalid address",
			},
			// No deposits
			0,
			true,
			"decoding bech32 failed",
		},
		{
			"owner and denom",
			&types.QueryDepositsRequest{
				Owner: suite.addrs[0].String(),
				Denom: "bnb",
			},
			// Only the first one
			1,
			false,
			"",
		},
		{
			"owner and invalid denom empty response",
			&types.QueryDepositsRequest{
				Owner: suite.addrs[0].String(),
				Denom: "invalid denom",
			},
			0,
			false,
			"",
		},
		{
			"denom",
			&types.QueryDepositsRequest{
				Denom: "bnb",
			},
			2,
			false,
			"",
		},
	}

	for _, tt := range tests {
		suite.Run(tt.giveName, func() {
			res, err := suite.queryServer.Deposits(sdk.WrapSDKContext(suite.ctx), tt.giveRequest)

			if tt.shouldError {
				suite.Error(err)
				suite.Contains(err.Error(), tt.errorSubstr)
			} else {
				suite.NoError(err)
				suite.Equal(tt.wantDepositCounts, len(res.Deposits))
			}
		})
	}
}

func (suite *grpcQueryTestSuite) TestGrpcQueryTotalSupply() {
	testCases := []struct {
		name           string
		deposits       types.Deposits
		expectedSupply sdk.Coins
	}{
		{
			name:           "returns zeros when there's no supply",
			deposits:       []types.Deposit{},
			expectedSupply: sdk.NewCoins(),
		},
		{
			name: "returns supply of one denom deposited from multiple accounts",
			deposits: []types.Deposit{
				dep(suite.addrs[0], sdk.NewCoins(c("busd", 1e6))),
				dep(suite.addrs[1], sdk.NewCoins(c("busd", 1e6))),
			},
			expectedSupply: sdk.NewCoins(c("busd", 2e6)),
		},
		{
			name: "returns supply of multiple denoms deposited from single account",
			deposits: []types.Deposit{
				dep(suite.addrs[0], sdk.NewCoins(c("busd", 1e6), c("bnb", 1e6))),
			},
			expectedSupply: sdk.NewCoins(c("busd", 1e6), c("bnb", 1e6)),
		},
		{
			name: "returns supply of multiple denoms deposited from multiple accounts",
			deposits: []types.Deposit{
				dep(suite.addrs[0], sdk.NewCoins(c("busd", 1e6), c("bnb", 1e6))),
				dep(suite.addrs[1], sdk.NewCoins(c("busd", 1e6), c("bnb", 1e6))),
			},
			expectedSupply: sdk.NewCoins(c("busd", 2e6), c("bnb", 2e6)),
		},
	}

	for _, tc := range testCases {
		suite.Run(tc.name, func() {
			suite.SetupTest()
			// setup deposits
			suite.addDeposits(tc.deposits)

			res, err := suite.queryServer.TotalSupply(
				sdk.WrapSDKContext(suite.ctx),
				&types.QueryTotalSupplyRequest{},
			)
			suite.Require().NoError(err)
			suite.Require().Equal(tc.expectedSupply, res.Result)
		})
	}

	suite.Run("aggregates bkava denoms, accounting for slashing", func() {
		suite.SetupTest()

		address1, derivatives1, _ := suite.createAccountWithDerivatives(bkava1, sdkmath.NewInt(1e9))
		address2, derivatives2, _ := suite.createAccountWithDerivatives(bkava2, sdkmath.NewInt(1e9))

		// bond validators
		staking.EndBlocker(suite.ctx, suite.tApp.GetStakingKeeper())
		// slash val2 - its shares are now 80% as valuable!
		err := suite.slashValidator(sdk.ValAddress(address2), sdk.MustNewDecFromStr("0.2"))
		suite.Require().NoError(err)

		suite.addDeposits(
			types.Deposits{
				dep(address1, cs(derivatives1)),
				dep(address2, cs(derivatives2)),
			},
		)

		expectedSupply := sdk.NewCoins(
			sdk.NewCoin(
				"bkava",
				sdkmath.NewIntFromUint64(1e9). // derivative 1
								Add(sdkmath.NewInt(1e9).MulRaw(80).QuoRaw(100))), // derivative 2: original value * 80%
		)

		res, err := suite.queryServer.TotalSupply(
			sdk.WrapSDKContext(suite.ctx),
			&types.QueryTotalSupplyRequest{},
		)
		suite.Require().NoError(err)
		suite.Require().Equal(expectedSupply, res.Result)
	})
}

func (suite *grpcQueryTestSuite) addDeposits(deposits types.Deposits) {
	for _, dep := range deposits {
		suite.NotPanics(func() {
			err := suite.keeper.Deposit(suite.ctx, dep.Depositor, dep.Amount)
			suite.Require().NoError(err)
		})
	}
}

// createUnbondedValidator creates an unbonded validator with the given amount of self-delegation.
func (suite *grpcQueryTestSuite) createUnbondedValidator(address sdk.ValAddress, selfDelegation sdk.Coin, minSelfDelegation sdkmath.Int) error {
	msg, err := stakingtypes.NewMsgCreateValidator(
		address,
		ed25519.GenPrivKey().PubKey(),
		selfDelegation,
		stakingtypes.Description{},
		stakingtypes.NewCommissionRates(sdk.ZeroDec(), sdk.ZeroDec(), sdk.ZeroDec()),
		minSelfDelegation,
	)
	if err != nil {
		return err
	}

	msgServer := stakingkeeper.NewMsgServerImpl(suite.tApp.GetStakingKeeper())
	_, err = msgServer.CreateValidator(sdk.WrapSDKContext(suite.ctx), msg)
	return err
}

// createAccountWithDerivatives creates an account with the given amount and denom of derivative token.
// Internally, it creates a validator account and mints derivatives from the validator's self delegation.
func (suite *grpcQueryTestSuite) createAccountWithDerivatives(denom string, amount sdkmath.Int) (sdk.AccAddress, sdk.Coin, sdk.Coins) {
	bondDenom := suite.tApp.GetStakingKeeper().BondDenom(suite.ctx)
	valAddress, err := liquidtypes.ParseLiquidStakingTokenDenom(denom)
	suite.Require().NoError(err)
	address := sdk.AccAddress(valAddress)

	remainingSelfDelegation := sdkmath.NewInt(1e6)
	selfDelegation := sdk.NewCoin(
		bondDenom,
		amount.Add(remainingSelfDelegation),
	)

	// create & fund account
	// ak := suite.tApp.GetAccountKeeper()
	// acc := ak.NewAccountWithAddress(suite.ctx, address)
	// ak.SetAccount(suite.ctx, acc)
	err = suite.tApp.FundAccount(suite.ctx, address, sdk.NewCoins(selfDelegation))
	suite.Require().NoError(err)

	err = suite.createUnbondedValidator(valAddress, selfDelegation, remainingSelfDelegation)
	suite.Require().NoError(err)

	toConvert := sdk.NewCoin(bondDenom, amount)
	derivatives, err := suite.tApp.GetLiquidKeeper().MintDerivative(suite.ctx,
		address,
		valAddress,
		toConvert,
	)
	suite.Require().NoError(err)

	fullBalance := suite.tApp.GetBankKeeper().GetAllBalances(suite.ctx, address)

	return address, derivatives, fullBalance
}

// slashValidator slashes the validator with the given address by the given percentage.
func (suite *grpcQueryTestSuite) slashValidator(address sdk.ValAddress, slashFraction sdk.Dec) error {
	stakingKeeper := suite.tApp.GetStakingKeeper()

	validator, found := stakingKeeper.GetValidator(suite.ctx, address)
	suite.Require().True(found)
	consAddr, err := validator.GetConsAddr()
	suite.Require().NoError(err)

	// Assume infraction was at current height. Note unbonding delegations and redelegations are only slashed if created after
	// the infraction height so none will be slashed.
	infractionHeight := suite.ctx.BlockHeight()

	power := stakingKeeper.TokensToConsensusPower(suite.ctx, validator.GetTokens())

	stakingKeeper.Slash(suite.ctx, consAddr, infractionHeight, power, slashFraction)
	return nil
}

func TestGrpcQueryTestSuite(t *testing.T) {
	suite.Run(t, new(grpcQueryTestSuite))
}