package simulation_test

import (
	"bytes"
	"math/rand"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/suite"

	sdkmath "cosmossdk.io/math"

	"github.com/cosmos/cosmos-sdk/codec"
	sdk "github.com/cosmos/cosmos-sdk/types"
	"github.com/cosmos/cosmos-sdk/types/module"
	simtypes "github.com/cosmos/cosmos-sdk/types/simulation"
	govtypes "github.com/cosmos/cosmos-sdk/x/gov/types/v1"

	"github.com/provenance-io/provenance/app"
	simappparams "github.com/provenance-io/provenance/app/params"
	"github.com/provenance-io/provenance/testutil"
	"github.com/provenance-io/provenance/testutil/assertions"
	"github.com/provenance-io/provenance/x/marker/keeper"
	"github.com/provenance-io/provenance/x/marker/simulation"
	"github.com/provenance-io/provenance/x/marker/types"
)

type SimTestSuite struct {
	suite.Suite

	ctx sdk.Context
	app *app.App
	cdc *codec.ProtoCodec
}

func TestSimTestSuite(t *testing.T) {
	suite.Run(t, new(SimTestSuite))
}

func (s *SimTestSuite) SetupTest() {
	govtypes.DefaultMinDepositRatio = sdkmath.LegacyZeroDec()
	s.app = app.Setup(s.T())
	s.ctx = s.app.BaseApp.NewContext(false)
	s.cdc = codec.NewProtoCodec(s.app.GetEncodingConfig().InterfaceRegistry)
}

// LogOperationMsg logs all fields of the provided operationMsg.
func (s *SimTestSuite) LogOperationMsg(operationMsg simtypes.OperationMsg) {
	msgFmt := "%s"
	if len(bytes.TrimSpace(operationMsg.Msg)) == 0 {
		msgFmt = "    %q"
	}
	fmtLines := []string{
		"operationMsg.Route:   %q",
		"operationMsg.Name:    %q",
		"operationMsg.Comment: %q",
		"operationMsg.OK:      %t",
		"operationMsg.Msg: " + msgFmt,
	}
	s.T().Logf(strings.Join(fmtLines, "\n"),
		operationMsg.Route, operationMsg.Name, operationMsg.Comment, operationMsg.OK, string(operationMsg.Msg),
	)
}

// MakeTestSimState creates a new module.SimulationState struct with the fields needed by the functions being tested.
func (s *SimTestSuite) MakeTestSimState() module.SimulationState {
	return module.SimulationState{
		AppParams: make(simtypes.AppParams),
		Cdc:       s.app.AppCodec(),
		TxConfig:  s.app.GetTxConfig(),
	}
}

func (s *SimTestSuite) TestWeightedOperations() {
	weightedOps := simulation.WeightedOperations(s.MakeTestSimState(), codec.NewProtoCodec(s.app.InterfaceRegistry()), *s.app.MarkerKeeper,
		s.app.AccountKeeper, s.app.BankKeeper, s.app.GovKeeper, s.app.AttributeKeeper,
	)

	// setup 3 accounts
	src := rand.NewSource(1)
	r := rand.New(src)
	accs := s.getTestingAccounts(r, 3)

	// Note: r is now passed around and used in several places, including in SDK functions.
	//	Since we're seeding it, these tests are deterministic. However, if there are changes
	//	made in the SDK or to the operations, these outcomes can change. To further confuse
	//	things, the operation name is sometimes taken from msg.Type(), and sometimes from
	//	fmt.Sprintf("%T", msg), and sometimes hard-coded. The .Type() function is no longer
	//	part of the Msg interface (though it is part of LegacyMsg). But depending on how the
	//	randomness plays out, it can be either of those. If one of these starts failing on
	//	the operation name, and the actual value is one of the other possibilities for that
	//	operation, it's probably just due to a change in the number of times r is used before
	//	getting to that operation.

	expected := []struct {
		weight     int
		opMsgRoute string
		opMsgName  string
	}{
		// Possible names: types.TypeAddMarkerRequest, fmt.Sprintf("%T", &types.MsgAddMarkerRequest{})
		{weight: simappparams.DefaultWeightMsgAddMarker, opMsgRoute: types.RouterKey, opMsgName: sdk.MsgTypeURL(&types.MsgAddMarkerRequest{})},
		// Possible names: "ChangeStatus",
		//	types.TypeActivateRequest, fmt.Sprintf("%T", &types.MsgActivateRequest{}),
		//	types.TypeFinalizeRequest, fmt.Sprintf("%T", &types.MsgFinalizeRequest{}),
		//	types.TypeCancelRequest, fmt.Sprintf("%T", &types.MsgCancelRequest{}),
		//	types.TypeDeleteRequest, fmt.Sprintf("%T", &types.MsgDeleteRequest{}),
		{weight: simappparams.DefaultWeightMsgFinalize, opMsgRoute: types.RouterKey, opMsgName: sdk.MsgTypeURL(&types.MsgFinalizeRequest{})},
		// Possible names: types.TypeAddAccessRequest, fmt.Sprintf("%T", &types.MsgAddAccessRequest{})
		{weight: simappparams.DefaultWeightMsgAddAccess, opMsgRoute: types.RouterKey, opMsgName: sdk.MsgTypeURL(&types.MsgAddAccessRequest{})},
		{weight: simappparams.DefaultWeightMsgAddFinalizeActivateMarker, opMsgRoute: types.RouterKey, opMsgName: sdk.MsgTypeURL(&types.MsgAddFinalizeActivateMarkerRequest{})},
		{weight: simappparams.DefaultWeightMsgAddMarkerProposal, opMsgRoute: "gov", opMsgName: sdk.MsgTypeURL(&govtypes.MsgSubmitProposal{})},
		{weight: simappparams.DefaultWeightMsgSetAccountData, opMsgRoute: types.RouterKey, opMsgName: sdk.MsgTypeURL(&types.MsgSetAccountDataRequest{})},
		{weight: simappparams.DefaultWeightMsgUpdateDenySendList, opMsgRoute: types.RouterKey, opMsgName: sdk.MsgTypeURL(&types.MsgUpdateSendDenyListRequest{})},
	}

	expNames := make([]string, len(expected))
	for i, exp := range expected {
		expNames[i] = exp.opMsgName
	}

	// Run all the ops and get the operation messages and their names.
	opMsgs := make([]simtypes.OperationMsg, len(weightedOps))
	actualNames := make([]string, len(weightedOps))
	for i, w := range weightedOps {
		opMsgs[i], _, _ = w.Op()(r, s.app.BaseApp, s.ctx, accs, "")
		actualNames[i] = opMsgs[i].Name
	}

	// First, make sure the op names are as expected since a failure there probably means the rest will fail.
	// And it's probably easier to address when you've got a nice list comparison of names and their orderings.
	s.Require().Equal(expNames, actualNames, "operation message names")

	for i := range expected {
		s.Require().Equal(expected[i].weight, weightedOps[i].Weight(), "weightedOps[%d].Weight", i)
		s.Require().Equal(expected[i].opMsgRoute, opMsgs[i].Route, "opMsgs[%d].Route", i)
		s.Require().Equal(expected[i].opMsgName, opMsgs[i].Name, "opMsgs[%d].Name", i)
	}
}

// TestSimulateMsgAddMarker tests the normal scenario of a valid message of type TypeAddMarkerRequest.
// Abnormal scenarios, where the message is created by an errors, are not tested here.
func (s *SimTestSuite) TestSimulateMsgAddMarker() {
	// setup 3 accounts
	src := rand.NewSource(1)
	r := rand.New(src)
	accounts := s.getTestingAccounts(r, 3)

	// execute operation
	op := simulation.SimulateMsgAddMarker(*s.app.MarkerKeeper, s.getWeightedOpsArgs())
	operationMsg, futureOperations, err := op(r, s.app.BaseApp, s.ctx, accounts, "")
	s.Require().NoError(err)

	var msg types.MsgAddMarkerRequest
	s.cdc.UnmarshalJSON(operationMsg.Msg, &msg)

	s.Require().True(operationMsg.OK, operationMsg.String())
	s.Require().Equal(sdk.MsgTypeURL(&msg), operationMsg.Name)
	s.Require().Equal(types.RouterKey, operationMsg.Route)
	s.Require().Len(futureOperations, 0)
}

// TestSimulateMsgAddActivateFinalizeMarker tests the normal scenario of a valid message of type TypeAddActivateFinalizeMarkerRequest.
// Abnormal scenarios, where the message is created by an errors, are not tested here.
func (s *SimTestSuite) TestSimulateMsgAddActivateFinalizeMarker() {
	// setup 3 accounts
	src := rand.NewSource(1)
	r := rand.New(src)
	accounts := s.getTestingAccounts(r, 3)

	// execute operation
	op := simulation.SimulateMsgAddFinalizeActivateMarker(*s.app.MarkerKeeper, s.getWeightedOpsArgs())
	operationMsg, futureOperations, err := op(r, s.app.BaseApp, s.ctx, accounts, "")
	s.Require().NoError(err)

	var msg types.MsgAddFinalizeActivateMarkerRequest
	s.cdc.Unmarshal(operationMsg.Msg, &msg)

	s.Require().True(operationMsg.OK, operationMsg.String())
	s.Require().Equal(sdk.MsgTypeURL(&msg), operationMsg.Name)
	s.Require().Equal(types.RouterKey, operationMsg.Route)
	s.Require().Len(futureOperations, 0)
}

// TestSimulateMsgAddMarkerProposal tests the normal scenario of a valid message of type MsgAddMarkerProposalRequest.
// Abnormal scenarios, where the message is created by an errors, are not tested here.
func (s *SimTestSuite) TestSimulateMsgAddMarkerProposal() {
	NewMsgAddMarker := func(denom string, totalSupply sdkmath.Int, manager sdk.AccAddress, status types.MarkerStatus,
		markerType types.MarkerType, access []types.AccessGrant, fixed bool, allowGov bool, authority string,
	) *types.MsgAddMarkerRequest {
		return &types.MsgAddMarkerRequest{
			Amount: sdk.Coin{
				Denom:  denom,
				Amount: totalSupply,
			},
			Manager:                manager.String(),
			FromAddress:            authority,
			Status:                 status,
			MarkerType:             markerType,
			AccessList:             access,
			SupplyFixed:            fixed,
			AllowGovernanceControl: allowGov,
		}
	}

	src := rand.NewSource(1)
	r := rand.New(src)
	accounts := s.getTestingAccounts(r, 10)
	accounts = append(accounts, s.createTestingAccountsWithPower(r, 1, 0)...)
	accounts = append(accounts, s.createTestingAccountsWithPower(r, 1, 1)...)
	acctZero := accounts[len(accounts)-2]
	acctOne := accounts[len(accounts)-1]
	acctOneBalance := s.app.BankKeeper.SpendableCoins(s.ctx, acctOne.Address)
	var acctOneBalancePlusOne sdk.Coins
	for _, c := range acctOneBalance {
		acctOneBalancePlusOne = acctOneBalancePlusOne.Add(sdk.NewCoin(c.Denom, c.Amount.AddRaw(1)))
	}

	// Set default deposit params
	govMinDep := sdk.NewCoins(sdk.NewInt64Coin(sdk.DefaultBondDenom, 3))
	depositPeriod := 1 * time.Second

	resetParams := func() {
		params := govtypes.DefaultParams()
		params.MinDeposit = govMinDep
		params.MaxDepositPeriod = &depositPeriod
		assertions.RequireNotPanicsNoError(s.T(), func() error {
			return s.app.GovKeeper.Params.Set(s.ctx, params)
		}, "reset gov params")
	}

	access := types.AccessGrant{
		Address:     acctOne.Address.String(),
		Permissions: types.AccessListByNames("DELETE,MINT,BURN"),
	}

	tests := []struct {
		name            string
		sender          simtypes.Account
		msg             sdk.Msg
		deposit         sdk.Coins
		comment         string
		expSkip         bool
		expOpMsgRoute   string
		expOpMsgName    string
		expOpMsgComment string
		expInErr        []string
	}{
		{
			name:            "no spendable coins",
			sender:          acctZero,
			msg:             NewMsgAddMarker("test2", sdkmath.NewInt(100), sdk.AccAddress{}, types.StatusUndefined, types.MarkerType_Coin, []types.AccessGrant{}, true, true, "validAuthority"),
			deposit:         sdk.Coins{sdk.NewInt64Coin(sdk.DefaultBondDenom, 5)},
			comment:         "should not matter",
			expSkip:         true,
			expOpMsgRoute:   "marker",
			expOpMsgName:    sdk.MsgTypeURL(&types.MsgAddMarkerRequest{}),
			expOpMsgComment: "sender has no spendable coins",
			expInErr:        nil,
		},
		{
			name:            "not enough coins for deposit",
			sender:          acctOne,
			msg:             NewMsgAddMarker("test2", sdkmath.NewInt(100), sdk.AccAddress{}, types.StatusUndefined, types.MarkerType_Coin, []types.AccessGrant{}, true, true, "validAuthority"),
			deposit:         acctOneBalancePlusOne,
			comment:         "should not be this",
			expSkip:         true,
			expOpMsgRoute:   "marker",
			expOpMsgName:    sdk.MsgTypeURL(&types.MsgAddMarkerRequest{}),
			expOpMsgComment: "sender has insufficient balance to cover deposit",
			expInErr:        nil,
		},
		{
			name:            "nil msg",
			sender:          accounts[0],
			msg:             nil,
			deposit:         sdk.Coins{sdk.NewInt64Coin(sdk.DefaultBondDenom, 5)},
			comment:         "will not get returned",
			expSkip:         true,
			expOpMsgRoute:   "marker",
			expOpMsgName:    "/",
			expOpMsgComment: "wrapping MsgAddMarkerProposalRequest as Any",
			expInErr:        []string{"expecting non nil value to create a new Any", "failed packing protobuf message to Any"},
		},
		{
			name: "gen and deliver returns error",
			sender: simtypes.Account{
				PrivKey: accounts[0].PrivKey,
				PubKey:  acctOne.PubKey,
				Address: acctOne.Address,
				ConsKey: accounts[0].ConsKey,
			},
			msg:             NewMsgAddMarker("test2", sdkmath.NewInt(100), sdk.AccAddress{}, types.StatusUndefined, types.MarkerType_Coin, []types.AccessGrant{}, true, true, "validAuthority"),
			deposit:         acctOneBalance,
			comment:         "this should be ignored",
			expSkip:         true,
			expOpMsgRoute:   "marker",
			expOpMsgName:    sdk.MsgTypeURL(&govtypes.MsgSubmitProposal{}),
			expOpMsgComment: "unable to deliver tx",
			expInErr:        []string{"pubKey does not match signer address", "invalid pubkey"},
		},
		{
			name:            "all good",
			sender:          accounts[1],
			msg:             NewMsgAddMarker("test2", sdkmath.NewInt(100), sdk.AccAddress{}, types.StatusFinalized, types.MarkerType_Coin, []types.AccessGrant{access}, true, true, "cosmos10d07y265gmmuvt4z0w9aw880jnsr700j6zn9kn"),
			deposit:         sdk.Coins{sdk.NewInt64Coin(sdk.DefaultBondDenom, 5)},
			comment:         "this is a test comment",
			expSkip:         false,
			expOpMsgRoute:   "gov",
			expOpMsgName:    sdk.MsgTypeURL(&govtypes.MsgSubmitProposal{}),
			expOpMsgComment: "this is a test comment",
			expInErr:        nil,
		},
	}

	for _, tc := range tests {
		resetParams()
		s.Run(tc.name, func() {
			args := &simulation.SendGovMsgArgs{
				WeightedOpsArgs: *s.getWeightedOpsArgs(),
				R:               rand.New(rand.NewSource(1)),
				App:             s.app.BaseApp,
				Ctx:             s.ctx,
				Accs:            accounts,
				ChainID:         "send-gov-test",
				Sender:          tc.sender,
				Msg:             tc.msg,
				Deposit:         tc.deposit,
				Comment:         tc.comment,
				Title:           "Add marker " + tc.name,
				Summary:         "Create a marker for " + tc.name,
			}

			var skip bool
			var opMsg simtypes.OperationMsg
			var err error
			testFunc := func() {
				skip, opMsg, err = simulation.SendGovMsg(args)
			}
			s.Require().NotPanics(testFunc, "SendGovMsg")
			s.LogOperationMsg(opMsg)
			assertions.AssertErrorContents(s.T(), err, tc.expInErr)

			s.Assert().Equal(tc.expSkip, skip, "SendGovMsg result skip bool")
			s.Assert().Equal(tc.expOpMsgRoute, opMsg.Route, "SendGovMsg result op msg route")
			s.Assert().Equal(tc.expOpMsgName, opMsg.Name, "SendGovMsg result op msg name")
			s.Assert().Equal(tc.expOpMsgComment, opMsg.Comment, "SendGovMsg result op msg comment")
			if !tc.expSkip && !skip {
				// If we don't expect a skip, and we didn't get one,
				// get the last gov prop and make sure it's the one we just sent.
				expMsgs := []sdk.Msg{tc.msg}
				prop := s.getLastGovProp()
				if s.Assert().NotNil(prop, "last gov prop") {
					msgs, err := prop.GetMsgs()
					if s.Assert().NoError(err, "error from prop.GetMsgs() on the last gov prop") {
						s.Assert().Equal(expMsgs[0], msgs[0], "messages in the last gov prop")
					}
				}
			}
		})
	}
}

func (s *SimTestSuite) TestSimulateMsgSetAccountData() {
	// setup 3 accounts
	src := rand.NewSource(1)
	r := rand.New(src)
	accounts := s.getTestingAccounts(r, 3)

	// Add a marker with deposit permissions so that it can be found by the sim.
	newMarker := &types.MsgAddFinalizeActivateMarkerRequest{
		Amount:      sdk.NewInt64Coin("simcoin", 1000),
		Manager:     accounts[1].Address.String(),
		FromAddress: accounts[1].Address.String(),
		MarkerType:  types.MarkerType_RestrictedCoin,
		AccessList: []types.AccessGrant{
			{
				Address: accounts[1].Address.String(),
				Permissions: types.AccessList{
					types.Access_Mint, types.Access_Burn, types.Access_Deposit, types.Access_Withdraw,
					types.Access_Delete, types.Access_Admin, types.Access_Transfer,
				},
			},
		},
		SupplyFixed:            true,
		AllowGovernanceControl: true,
		AllowForcedTransfer:    false,
		RequiredAttributes:     nil,
	}
	markerMsgServer := keeper.NewMsgServerImpl(*s.app.MarkerKeeper)
	_, err := markerMsgServer.AddFinalizeActivateMarker(s.ctx, newMarker)
	s.Require().NoError(err, "AddFinalizeActivateMarker")

	// execute operation
	op := simulation.SimulateMsgSetAccountData(*s.app.MarkerKeeper, s.getWeightedOpsArgs())
	operationMsg, futureOperations, err := op(r, s.app.BaseApp, s.ctx, accounts, "")
	s.Require().NoError(err, "SimulateMsgSetAccountData op(...) error")
	s.LogOperationMsg(operationMsg)

	var msg types.MsgSetAccountDataRequest
	s.Require().NoError(s.app.AppCodec().Unmarshal(operationMsg.Msg, &msg), "UnmarshalJSON(operationMsg.Msg)")

	s.Assert().True(operationMsg.OK, "operationMsg.OK")
	s.Assert().Equal(sdk.MsgTypeURL(&msg), operationMsg.Name, "operationMsg.Name")
	s.Assert().Equal("simcoin", msg.Denom, "msg.Denom")
	s.Assert().Equal("", msg.Value, "msg.Value")
	s.Assert().Equal(accounts[1].Address.String(), msg.Signer, "msg.Signer")
	s.Assert().Equal(types.RouterKey, operationMsg.Route, "operationMsg.Route")
	s.Assert().Len(futureOperations, 0, "futureOperations")
}

func (s *SimTestSuite) TestSimulateMsgUpdateSendDenyList() {
	// setup 3 accounts
	src := rand.NewSource(1)
	r := rand.New(src)
	accounts := s.getTestingAccounts(r, 3)

	// Add a marker with deposit permissions so that it can be found by the sim.
	newMarker := &types.MsgAddFinalizeActivateMarkerRequest{
		Amount:      sdk.NewInt64Coin("simcoin", 1000),
		Manager:     accounts[1].Address.String(),
		FromAddress: accounts[1].Address.String(),
		MarkerType:  types.MarkerType_RestrictedCoin,
		AccessList: []types.AccessGrant{
			{
				Address: accounts[1].Address.String(),
				Permissions: types.AccessList{
					types.Access_Mint, types.Access_Burn, types.Access_Deposit, types.Access_Withdraw,
					types.Access_Delete, types.Access_Admin, types.Access_Transfer,
				},
			},
		},
		SupplyFixed:            true,
		AllowGovernanceControl: true,
		AllowForcedTransfer:    false,
		RequiredAttributes:     nil,
	}
	markerMsgServer := keeper.NewMsgServerImpl(*s.app.MarkerKeeper)
	_, err := markerMsgServer.AddFinalizeActivateMarker(s.ctx, newMarker)
	s.Require().NoError(err, "AddFinalizeActivateMarker")

	// execute operation
	op := simulation.SimulateMsgUpdateSendDenyList(*s.app.MarkerKeeper, s.getWeightedOpsArgs())
	operationMsg, futureOperations, err := op(r, s.app.BaseApp, s.ctx, accounts, "")
	s.Require().NoError(err, "SimulateMsgUpdateSendDenyList op(...) error")
	s.LogOperationMsg(operationMsg)

	var msg types.MsgUpdateSendDenyListRequest
	s.Require().NoError(s.app.AppCodec().Unmarshal(operationMsg.Msg, &msg), "UnmarshalJSON(operationMsg.Msg)")

	s.Assert().True(operationMsg.OK, "operationMsg.OK")
	s.Assert().Equal(sdk.MsgTypeURL(&msg), operationMsg.Name, "operationMsg.Name")
	s.Assert().Equal("simcoin", msg.Denom, "msg.Denom")
	s.Assert().Len(msg.AddDeniedAddresses, 10, "msg.AddDeniedAddresses")
	s.Assert().Equal(accounts[1].Address.String(), msg.Authority, "msg.Authority")
	s.Assert().Equal(types.RouterKey, operationMsg.Route, "operationMsg.Route")
	s.Assert().Len(futureOperations, 0, "futureOperations")
}

func (s *SimTestSuite) getTestingAccounts(r *rand.Rand, n int) []simtypes.Account {
	return testutil.GenerateTestingAccounts(s.T(), s.ctx, s.app, r, n)
}

// getWeightedOpsArgs creates a standard WeightedOpsArgs.
func (s *SimTestSuite) getWeightedOpsArgs() *simulation.WeightedOpsArgs {
	return &simulation.WeightedOpsArgs{
		SimState:   s.MakeTestSimState(),
		ProtoCodec: codec.NewProtoCodec(s.app.InterfaceRegistry()),
		AK:         s.app.AccountKeeper,
		BK:         s.app.BankKeeper,
		GK:         s.app.GovKeeper,
		AttrK:      s.app.AttributeKeeper,
	}
}

// getLastGovProp gets the last gov prop to be submitted.
func (s *SimTestSuite) getLastGovProp() *govtypes.Proposal {
	propID, err := s.app.GovKeeper.ProposalID.Peek(s.ctx)
	if !s.Assert().NoError(err, "s.app.GovKeeper.ProposalID.Peek(s.ctx)") {
		return nil
	}
	propID--
	prop, err := s.app.GovKeeper.Proposals.Get(s.ctx, propID)
	if !s.Assert().NoError(err, "s.app.GovKeeper.Proposals.Get(s.ctx, %d)", propID) {
		return nil
	}
	return &prop
}

// freshCtx creates a new context and sets it to this SimTestSuite's ctx field.
func (s *SimTestSuite) freshCtx() {
	s.ctx = s.app.BaseApp.NewContext(false)
}

// createTestingAccountsWithPower creates new accounts with the specified power (coins amount).
func (s *SimTestSuite) createTestingAccountsWithPower(r *rand.Rand, count int, power int64) []simtypes.Account {
	return testutil.GenerateTestingAccountsWithPower(s.T(), s.ctx, s.app, r, count, power)
}
