package keeper_test

import (
	"fmt"
	"testing"

	sdkmath "cosmossdk.io/math"
	"github.com/0glabs/0g-chain/x/precisebank/keeper"
	"github.com/0glabs/0g-chain/x/precisebank/testutil"
	"github.com/0glabs/0g-chain/x/precisebank/types"
	sdk "github.com/cosmos/cosmos-sdk/types"
	authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
	banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
	ibctransfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types"
	"github.com/stretchr/testify/suite"
)

type burnIntegrationTestSuite struct {
	testutil.Suite
}

func (suite *burnIntegrationTestSuite) SetupTest() {
	suite.Suite.SetupTest()
}

func TestBurnIntegrationTest(t *testing.T) {
	suite.Run(t, new(burnIntegrationTestSuite))
}

func (suite *burnIntegrationTestSuite) TestBurnCoins_MatchingErrors() {
	// x/precisebank BurnCoins should be identical to x/bank BurnCoins to
	// consumers. This test ensures that the panics & errors returned by
	// x/precisebank are identical to x/bank.

	tests := []struct {
		name            string
		recipientModule string
		setupFn         func()
		burnAmount      sdk.Coins
		wantErr         string
		wantPanic       string
	}{
		{
			"invalid module",
			"notamodule",
			func() {},
			cs(c("ukava", 1000)),
			"",
			"module account notamodule does not exist: unknown address",
		},
		{
			"no burn permissions",
			// Check app.go to ensure this module has no burn permissions
			authtypes.FeeCollectorName,
			func() {},
			cs(c("ukava", 1000)),
			"",
			"module account fee_collector does not have permissions to burn tokens: unauthorized",
		},
		{
			"invalid amount",
			// Has burn permissions so it goes to the amt check
			ibctransfertypes.ModuleName,
			func() {},
			sdk.Coins{sdk.Coin{Denom: "ukava", Amount: sdkmath.NewInt(-100)}},
			"-100ukava: invalid coins",
			"",
		},
		{
			"insufficient balance - empty",
			ibctransfertypes.ModuleName,
			func() {},
			cs(c("ukava", 1000)),
			"spendable balance  is smaller than 1000ukava: insufficient funds",
			"",
		},
	}

	for _, tt := range tests {
		suite.Run(tt.name, func() {
			// Reset
			suite.SetupTest()

			if tt.wantErr == "" && tt.wantPanic == "" {
				suite.Fail("test must specify either wantErr or wantPanic")
			}

			if tt.wantErr != "" {
				// Check x/bank BurnCoins for identical error
				bankErr := suite.BankKeeper.BurnCoins(suite.Ctx, tt.recipientModule, tt.burnAmount)
				suite.Require().Error(bankErr)
				suite.Require().EqualError(bankErr, tt.wantErr, "expected error should match x/bank BurnCoins error")

				pbankErr := suite.Keeper.BurnCoins(suite.Ctx, tt.recipientModule, tt.burnAmount)
				suite.Require().Error(pbankErr)
				// Compare strings instead of errors, as error stack is still different
				suite.Require().Equal(
					bankErr.Error(),
					pbankErr.Error(),
					"x/precisebank error should match x/bank BurnCoins error",
				)
			}

			if tt.wantPanic != "" {
				// First check the wantPanic string is correct.
				// Actually specify the panic string in the test since it makes
				// it more clear we are testing specific and different cases.
				suite.Require().PanicsWithError(tt.wantPanic, func() {
					_ = suite.BankKeeper.BurnCoins(suite.Ctx, tt.recipientModule, tt.burnAmount)
				}, "expected panic error should match x/bank BurnCoins")

				suite.Require().PanicsWithError(tt.wantPanic, func() {
					_ = suite.Keeper.BurnCoins(suite.Ctx, tt.recipientModule, tt.burnAmount)
				}, "x/precisebank panic should match x/bank BurnCoins")
			}
		})
	}
}

func (suite *burnIntegrationTestSuite) TestBurnCoins() {
	tests := []struct {
		name         string
		startBalance sdk.Coins
		burnCoins    sdk.Coins
		wantBalance  sdk.Coins
		wantErr      string
	}{
		{
			"passthrough - unrelated",
			cs(c("meow", 1000)),
			cs(c("meow", 1000)),
			cs(),
			"",
		},
		{
			"passthrough - integer denom",
			cs(c(types.IntegerCoinDenom, 2000)),
			cs(c(types.IntegerCoinDenom, 1000)),
			cs(c(types.ExtendedCoinDenom, 1000000000000000)),
			"",
		},
		{
			"fractional only - no borrow",
			cs(c(types.ExtendedCoinDenom, 1000)),
			cs(c(types.ExtendedCoinDenom, 500)),
			cs(c(types.ExtendedCoinDenom, 500)),
			"",
		},
		{
			"fractional burn - borrows",
			cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().AddRaw(100))),
			cs(c(types.ExtendedCoinDenom, 500)),
			cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().SubRaw(400))),
			"",
		},
		{
			"error - insufficient integer balance",
			cs(ci(types.ExtendedCoinDenom, types.ConversionFactor())),
			cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(2))),
			cs(),
			// Returns correct error with akava balance (rewrites Bank BurnCoins err)
			"spendable balance 1000000000000akava is smaller than 2000000000000akava: insufficient funds",
		},
		{
			"error - insufficient fractional, borrow",
			cs(c(types.ExtendedCoinDenom, 1000)),
			cs(c(types.ExtendedCoinDenom, 2000)),
			cs(),
			// Error from SendCoins to reserve
			"spendable balance 1000akava is smaller than 2000akava: insufficient funds",
		},
	}

	for _, tt := range tests {
		suite.Run(tt.name, func() {
			// Reset
			suite.SetupTest()

			moduleName := ibctransfertypes.ModuleName
			recipientAddr := suite.AccountKeeper.GetModuleAddress(moduleName)

			// Start balance
			err := suite.Keeper.MintCoins(suite.Ctx, moduleName, tt.startBalance)
			suite.Require().NoError(err)

			// Burn
			err = suite.Keeper.BurnCoins(suite.Ctx, moduleName, tt.burnCoins)
			if tt.wantErr != "" {
				suite.Require().Error(err)
				suite.Require().EqualError(err, tt.wantErr)
				return
			}

			suite.Require().NoError(err)

			// -------------------------------------------------------------
			// Check FULL balances
			// x/bank balances + x/precisebank balance
			// Exclude "ukava" as x/precisebank balance will include it
			afterBalance := suite.GetAllBalances(recipientAddr)

			suite.Require().Equal(
				tt.wantBalance.String(),
				afterBalance.String(),
				"unexpected balance after minting %s to %s",
			)

			// Ensure reserve is backing all minted fractions
			allInvariantsFn := keeper.AllInvariants(suite.Keeper)
			res, stop := allInvariantsFn(suite.Ctx)
			suite.Require().False(stop, "invariant should not be broken")
			suite.Require().Empty(res, "unexpected invariant message: %s", res)

			intCoinAmt := tt.burnCoins.AmountOf(types.IntegerCoinDenom).
				Mul(types.ConversionFactor())

			fraCoinAmt := tt.burnCoins.AmountOf(types.ExtendedCoinDenom)

			totalExtCoinAmt := intCoinAmt.Add(fraCoinAmt)
			spentCoins := sdk.NewCoins(sdk.NewCoin(
				types.ExtendedCoinDenom,
				totalExtCoinAmt,
			))

			events := suite.Ctx.EventManager().Events()

			expBurnEvent := banktypes.NewCoinBurnEvent(recipientAddr, spentCoins)
			expSpendEvent := banktypes.NewCoinSpentEvent(recipientAddr, spentCoins)

			if totalExtCoinAmt.IsZero() {
				suite.Require().NotContains(events, expBurnEvent)
				suite.Require().NotContains(events, expSpendEvent)
			} else {
				suite.Require().Contains(events, expBurnEvent)
				suite.Require().Contains(events, expSpendEvent)
			}
		})
	}
}

func (suite *burnIntegrationTestSuite) TestBurnCoins_Remainder() {
	// This tests a series of small burns to ensure the remainder is both
	// updated correctly and reserve is correctly updated. This only burns from
	// 1 single account.

	reserveAddr := suite.AccountKeeper.GetModuleAddress(types.ModuleName)

	moduleName := ibctransfertypes.ModuleName
	moduleAddr := suite.AccountKeeper.GetModuleAddress(moduleName)

	startCoins := cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(5)))

	// Start balance
	err := suite.Keeper.MintCoins(
		suite.Ctx,
		moduleName,
		startCoins,
	)
	suite.Require().NoError(err)

	burnAmt := types.ConversionFactor().QuoRaw(10)
	burnCoins := cs(ci(types.ExtendedCoinDenom, burnAmt))

	// Burn 0.1 until balance is 0
	for {
		reserveBalBefore := suite.BankKeeper.GetBalance(
			suite.Ctx,
			reserveAddr,
			types.IntegerCoinDenom,
		)

		balBefore := suite.Keeper.GetBalance(
			suite.Ctx,
			moduleAddr,
			types.ExtendedCoinDenom,
		)
		remainderBefore := suite.Keeper.GetRemainderAmount(suite.Ctx)

		// ----------------------------------------
		// Burn
		err := suite.Keeper.BurnCoins(
			suite.Ctx,
			moduleName,
			burnCoins,
		)
		suite.Require().NoError(err)

		// ----------------------------------------
		// Checks
		remainderAfter := suite.Keeper.GetRemainderAmount(suite.Ctx)
		balAfter := suite.Keeper.GetBalance(
			suite.Ctx,
			moduleAddr,
			types.ExtendedCoinDenom,
		)
		reserveBalAfter := suite.BankKeeper.GetBalance(
			suite.Ctx,
			reserveAddr,
			types.IntegerCoinDenom,
		)

		suite.Require().Equal(
			balBefore.Amount.Sub(burnAmt).String(),
			balAfter.Amount.String(),
			"balance should decrease by burn amount",
		)

		// Remainder should be updated correctly
		suite.Require().Equal(
			remainderBefore.Add(burnAmt).Mod(types.ConversionFactor()),
			remainderAfter,
		)

		// If remainder has exceeded (then rolled over), reserve should be updated
		if remainderAfter.LT(remainderBefore) {
			suite.Require().Equal(
				reserveBalBefore.Amount.SubRaw(1).String(),
				reserveBalAfter.Amount.String(),
				"reserve should decrease by 1 if remainder exceeds ConversionFactor",
			)
		}

		// No more to burn
		if balAfter.Amount.IsZero() {
			break
		}
	}

	// Run Invariants to ensure remainder is backing all fractions correctly
	res, stop := keeper.AllInvariants(suite.Keeper)(suite.Ctx)
	suite.Require().False(stop, "invariant should not be broken")
	suite.Require().Empty(res, "unexpected invariant message: %s", res)
}

func (suite *burnIntegrationTestSuite) TestBurnCoins_Spread_Remainder() {
	// This tests a series of small burns to ensure the remainder is both
	// updated correctly and reserve is correctly updated. This burns from
	// a series of multiple accounts, to test when the remainder is modified
	// by multiple accounts.

	reserveAddr := suite.AccountKeeper.GetModuleAddress(types.ModuleName)
	burnerModuleName := ibctransfertypes.ModuleName
	burnerAddr := suite.AccountKeeper.GetModuleAddress(burnerModuleName)

	accCount := 20
	startCoins := cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(5)))

	addrs := []sdk.AccAddress{}

	for i := 0; i < accCount; i++ {
		addr := sdk.AccAddress(fmt.Sprintf("addr%d", i))
		suite.MintToAccount(addr, startCoins)

		addrs = append(addrs, addr)
	}

	burnAmt := types.ConversionFactor().QuoRaw(10)
	burnCoins := cs(ci(types.ExtendedCoinDenom, burnAmt))

	// Burn 0.1 from each account
	for _, addr := range addrs {
		reserveBalBefore := suite.BankKeeper.GetBalance(
			suite.Ctx,
			reserveAddr,
			types.IntegerCoinDenom,
		)

		balBefore := suite.Keeper.GetBalance(
			suite.Ctx,
			addr,
			types.ExtendedCoinDenom,
		)
		remainderBefore := suite.Keeper.GetRemainderAmount(suite.Ctx)

		// ----------------------------------------
		// Send & Burn
		err := suite.Keeper.SendCoins(
			suite.Ctx,
			addr,
			burnerAddr,
			burnCoins,
		)
		suite.Require().NoError(err)

		err = suite.Keeper.BurnCoins(
			suite.Ctx,
			burnerModuleName,
			burnCoins,
		)
		suite.Require().NoError(err)

		// ----------------------------------------
		// Checks
		remainderAfter := suite.Keeper.GetRemainderAmount(suite.Ctx)
		balAfter := suite.Keeper.GetBalance(
			suite.Ctx,
			addr,
			types.ExtendedCoinDenom,
		)
		reserveBalAfter := suite.BankKeeper.GetBalance(
			suite.Ctx,
			reserveAddr,
			types.IntegerCoinDenom,
		)

		suite.Require().Equal(
			balBefore.Amount.Sub(burnAmt).String(),
			balAfter.Amount.String(),
			"balance should decrease by burn amount",
		)

		// Remainder should be updated correctly
		suite.Require().Equal(
			remainderBefore.Add(burnAmt).Mod(types.ConversionFactor()),
			remainderAfter,
		)

		suite.T().Logf("acc: %s", string(addr.Bytes()))
		suite.T().Logf("acc bal: %s -> %s", balBefore, balAfter)
		suite.T().Logf("remainder: %s -> %s", remainderBefore, remainderAfter)
		suite.T().Logf("reserve: %v -> %v", reserveBalBefore, reserveBalAfter)

		// Reserve will change when:
		// 1. Account needs to borrow from integer (transfers to reserve)
		// 2. Remainder meets or exceeds conversion factor (burn 1 from reserve)
		reserveIncrease := sdkmath.ZeroInt()

		// Does account need to borrow from integer?
		if balBefore.Amount.Mod(types.ConversionFactor()).LT(burnAmt) {
			reserveIncrease = reserveIncrease.AddRaw(1)
		}

		// If remainder has exceeded (then rolled over), burn additional 1
		if remainderBefore.Add(burnAmt).GTE(types.ConversionFactor()) {
			reserveIncrease = reserveIncrease.SubRaw(1)
		}

		suite.Require().Equal(
			reserveBalBefore.Amount.Add(reserveIncrease).String(),
			reserveBalAfter.Amount.String(),
			"reserve should be updated by remainder and borrowing",
		)

		// Run Invariants to ensure remainder is backing all fractions correctly
		res, stop := keeper.AllInvariants(suite.Keeper)(suite.Ctx)
		suite.Require().False(stop, "invariant should not be broken")
		suite.Require().Empty(res, "unexpected invariant message: %s", res)
	}
}

func FuzzBurnCoins(f *testing.F) {
	f.Add(int64(0))
	f.Add(int64(100))
	f.Add(types.ConversionFactor().Int64())
	f.Add(types.ConversionFactor().MulRaw(5).Int64())
	f.Add(types.ConversionFactor().MulRaw(2).AddRaw(123948723).Int64())

	f.Fuzz(func(t *testing.T, amount int64) {
		// No negative amounts
		if amount < 0 {
			amount = -amount
		}

		// Manually setup test suite since no direct Fuzz support in test suites
		suite := new(burnIntegrationTestSuite)
		suite.SetT(t)
		suite.SetS(suite)
		suite.SetupTest()

		burnCount := int64(10)

		// Has both mint & burn permissions
		moduleName := ibctransfertypes.ModuleName
		recipientAddr := suite.AccountKeeper.GetModuleAddress(moduleName)

		// Start balance
		err := suite.Keeper.MintCoins(
			suite.Ctx,
			moduleName,
			cs(ci(types.ExtendedCoinDenom, sdkmath.NewInt(amount).MulRaw(burnCount))),
		)
		suite.Require().NoError(err)

		// Burn multiple times to ensure different balance scenarios
		for i := int64(0); i < burnCount; i++ {
			err := suite.Keeper.BurnCoins(
				suite.Ctx,
				moduleName,
				cs(c(types.ExtendedCoinDenom, amount)),
			)
			suite.Require().NoError(err)
		}

		// Check FULL balances
		balAfter := suite.Keeper.GetBalance(suite.Ctx, recipientAddr, types.ExtendedCoinDenom)

		suite.Require().Equalf(
			int64(0),
			balAfter.Amount.Int64(),
			"all coins should be burned, got %d",
			balAfter.Amount.Int64(),
		)

		// Run Invariants to ensure remainder is backing all fractions correctly
		allInvariantsFn := keeper.AllInvariants(suite.Keeper)
		res, stop := allInvariantsFn(suite.Ctx)
		suite.Require().False(stop, "invariant should not be broken")
		suite.Require().Empty(res, "unexpected invariant message: %s", res)
	})
}